From d8e827c88bea28c45e221346d12efb04401fe9c1 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 11 Nov 2026 01:08:42 -0500 Subject: [PATCH 1/6] Rack: gear resting on other gear, and live desk switches on shelves A USW Flex Mini lies on top of the NVR. A shelf item with Front drawing "No shelf: gear resting on what is below" draws no tray or ears, so its contents sit directly on the item under it. UniFi desk switches on a shelf (Flex Mini, Flex) are drawn with their live faceplate, scaled to their real width from Ubiquiti's tech specs (107 mm and 122.5 mm), with working ports. faceplateSvg() returns a faceplate's SVG on its own so it can be placed inside the shelf drawing. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++- app.py | 3 +- cutsheet.py | 1 + static/faceplates.js | 18 +++++++---- static/fronts.js | 72 +++++++++++++++++++++++++++++------------- static/rack.js | 13 ++++++-- tests/test_app.py | 1 + tests/test_cutsheet.py | 2 +- 8 files changed, 83 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index c5e714a..993e0f3 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,11 @@ The rack front, drawn from the rack units set on cutsheet items, top first. enp2s0 (left) and enp3s0 (right), and the status glow follows the host. The "ZimaBoard 832 + ConnectX-3" drawing adds the single-port SFP+ card lying flat in the side PCIe slot, as in the rack, with a DAC attached; its round dot is Gandalf's link mark for enp1s0 (the slot is PCI bus 1), not an - LED on the card. Anything else is a labelled box. + LED on the card. UniFi desk switches (Flex Mini, Flex) on a shelf are drawn with their live faceplate + at their real width. Anything else is a labelled box. +- **Gear resting on other gear**: a shelf item with Front drawing "No shelf: gear resting on what is + below" draws no tray, so a switch lying on top of the NVR is a "shelf" in the unit above the NVR whose + contents sit directly on it. - **Racks**: admins name a rack and set its height with **Rack settings** on the page, and add more with **+ Rack** (a tab per rack). Renaming a rack moves its items; an empty rack can be deleted. Each item has a rack (empty means the first). Until a rack is saved, racks come from config: diff --git a/app.py b/app.py index b18fba1..4b49211 100644 --- a/app.py +++ b/app.py @@ -1210,7 +1210,8 @@ def api_rack(): 'status': _device_status(dev(c), snapshot) if dev(c) in known else '', 'model': next((u.get('model') for u in snapshot.get('unifi') or [] if u.get('name') == dev(c)), '') or '', 'notes': c.get('notes') or '', 'chassis': c.get('chassis') or '', - 'slot': c.get('slot'), 'interfaces': _physical_interfaces(dev(c), snapshot)} + 'slot': c.get('slot'), 'interfaces': _physical_interfaces(dev(c), snapshot), + 'device': dev(c), 'ports': (switches.get(dev(c)) or {}).get('ports') or {}} for c in sorted(on_shelf.get(name, []), key=lambda c: (c.get('slot') or 999, c['name'].lower())) ] out.append(entry) diff --git a/cutsheet.py b/cutsheet.py index 3f13b1d..9111bfd 100644 --- a/cutsheet.py +++ b/cutsheet.py @@ -41,6 +41,7 @@ CHASSIS = { 'rosewill-rsv-l4500u': 'Rosewill RSV-L4500U 4U (15-bay)', 'zimaboard-832': 'IceWhale ZimaBoard 832 (shelf gear)', 'zimaboard-832-cx3': 'IceWhale ZimaBoard 832 + ConnectX-3 SFP+ in PCIe (shelf gear)', + 'resting': 'No shelf: gear resting on what is below (for a shelf item)', 'server-1u': 'Generic 1U server', 'server-2u': 'Generic 2U server', 'server-4u': 'Generic 4U server', diff --git a/static/faceplates.js b/static/faceplates.js index fc33088..3f04150 100644 --- a/static/faceplates.js +++ b/static/faceplates.js @@ -595,6 +595,15 @@ function faceplateLegendHtml(spec, marks) { // ctx: { swName, portMap, outlets, online, titleFor(port, idx, role), cutsheetFor?(idx) -> {mark, text}, // rack? (draw only the front, spec.rackView, as mounted in a rack) } function renderFaceplate(model, ctx) { + const { svg, marks } = faceplateSvg(model, ctx); + return `
+
${svg}
+
${faceplateLegendHtml(FACEPLATES[model], marks)}`; +} + +// The faceplate's SVG on its own, for drawing inside another picture (a shelf). `attrs` adds +// attributes to the (x, y, width, height to place it). Returns { svg, marks }. +function faceplateSvg(model, ctx, attrs = '') { const spec = FACEPLATES[model]; const anyPort = Object.values(ctx.portMap)[0]; const marks = new Set(); @@ -612,9 +621,7 @@ function renderFaceplate(model, ctx) { const outlets = (spec.outlets || []).map(o => faceplateOutletSvg(o, outletData[o.idx])).join(''); const body = spec.noBody ? '' : ``; - return `
-
- + const svg = ` @@ -628,9 +635,8 @@ function renderFaceplate(model, ctx) { ${spec.decor({ online: ctx.online })} ${outlets} ${ports} - -
-
${faceplateLegendHtml(spec, marks)}`; + `; + return { svg, marks }; } // Where an fp-scroll panel should start: its port area diff --git a/static/fronts.js b/static/fronts.js index c068120..f7a38b6 100644 --- a/static/fronts.js +++ b/static/fronts.js @@ -1,5 +1,5 @@ 'use strict'; -/* exported RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg, shelfSvg, SHELF_GEAR */ +/* exported RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg, shelfSvg, SHELF_GEAR, DESK_FACEPLATES */ // Drawn fronts for rack gear Gandalf can't read a faceplate from: custom-built servers. Chosen per cutsheet item ("Front drawing"). Each one // shows the device's live status on its power light when the item is linked to a device. // @@ -256,42 +256,70 @@ const SHELF_GEAR = { }, }; +// UniFi desk gear drawn from its live faceplate when it sits on a shelf: the real width of the +// face (Ubiquiti tech specs) and the span of that face in the faceplate drawing, to scale it. +const DESK_FACEPLATES = { + USMINI: { mm: 107, body: [2, 661], label: 'UniFi Switch Flex Mini' }, // 107 x 70 x 21 mm + USF5P: { mm: 122.5, body: [66, 488], label: 'UniFi Switch Flex' }, // 122.5 x 107.1 x 28 mm +}; + // A shelf at true scale (1100 units = 482.6 mm, a 19" panel) with what sits on it, left to right. -// Gear with a SHELF_GEAR drawing is drawn to its real width; anything else is a labelled box. -function shelfSvg(item, esc) { +// Gear with a SHELF_GEAR drawing, or UniFi desk gear with a live faceplate, is drawn to its real +// width; anything else is a labelled box. A shelf with Front drawing "resting" has no tray: its gear +// lies directly on whatever is below it (a switch on top of the NVR). +function shelfSvg(item, esc, faceplate) { const units = item.rack_height || 1; const W = 1100, H = 105 * units, perMm = W / 482.6; - const floor = H - 20; + const resting = item.chassis === 'resting'; + const floor = resting ? H - 3 : H - 20; let x = 64; const things = (item.contents || []).map(c => { const gear = SHELF_GEAR[c.chassis]; - const w = gear ? gear.mm * perMm : 150; - const k = gear ? w / gear.width : 1; - const h = gear ? gear.height * k : 58; + const desk = !gear && faceplate && DESK_FACEPLATES[c.model]; + let w, h, body; + if (gear) { + w = gear.mm * perMm; + const k = w / gear.width; + h = gear.height * k; + body = `${gear.draw(c)}`; + } else if (desk) { + // Scale the faceplate so its body spans the switch's real width, then place the whole drawing + const spec = faceplate.spec(c.model); + const k = (desk.mm * perMm) / (desk.body[1] - desk.body[0]); + w = desk.mm * perMm; + h = spec.height * k; + body = faceplate.svg(c, `x="${x - desk.body[0] * k}" y="${floor - h}" width="${spec.width * k}" height="${h}"`); + } else { + w = 150; + h = 58; + body = ` + ${esc(c.name)}`; + } const top = floor - h; const nics = (c.interfaces || []).map(n => `${n.name} ${n.up ? 'up' : 'down'}`).join(', '); - const tip = [c.name, gear ? gear.label : c.kind, c.status, nics, gear && gear.specs].filter(Boolean).join(' · '); - const body = gear - ? `${gear.draw(c)}` - : ` - ${esc(c.name)}`; - const out = `${esc(tip)} + const label = gear ? gear.label : (desk ? desk.label : c.kind); + const tip = [c.name, label, c.status, nics, gear && gear.specs].filter(Boolean).join(' · '); + // A live faceplate's ports are links of their own, so it isn't wrapped in one + const open = desk ? '' : ``; + const tagY = resting ? top - 16 : floor + 5; + const out = `${open}${esc(tip)} ${body} - - - ${esc(c.name)} - `; + ${desk ? '' : ``} + + ${esc(c.name)} + ${desk ? '' : ''}`; x += w + 26; return out; }).join(''); + const tray = resting ? '' : `${ears(W, H, 'rf-ear-dark')} + + + `; return ` ${esc(item.name)} - ${ears(W, H, 'rf-ear-dark')} - - - - ${things || `empty shelf`} + ${tray} + ${things || `${resting ? 'nothing here' : 'empty shelf'}`} ${esc(item.name)} `; } diff --git a/static/rack.js b/static/rack.js index 55aa62e..0453185 100644 --- a/static/rack.js +++ b/static/rack.js @@ -1,5 +1,5 @@ 'use strict'; -/* global FACEPLATES, renderFaceplate, RACK_CAN_EDIT, RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg, shelfSvg */ +/* global FACEPLATES, renderFaceplate, faceplateSvg, RACK_CAN_EDIT, RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg, shelfSvg */ // Rack page: draws the rack front from /api/rack. Switches use their real faceplates, // patch panels a generic panel; hovering a port lights up every port on the same cable run. @@ -145,6 +145,15 @@ function plateHtml(item) { `; } +// Live faceplates for UniFi desk gear sitting on a shelf (a Flex Mini on top of the NVR) +const shelfFaceplates = { + spec: model => FACEPLATES[model], + svg: (c, attrs) => faceplateSvg(c.model, { + swName: c.device || c.name, portMap: portMapOf(c.ports), outlets: [], online: c.status === 'up', + titleFor: (port, idx) => portTitle(port, idx), + }, attrs).svg, +}; + function backHtml(item) { // A full-depth item seen from the rear: its back, not its faceplate return `
@@ -161,7 +170,7 @@ function itemHtml(item) { else if (item.chassis && RACK_FRONTS[item.chassis]) body = rackFrontSvg(item.chassis, item); else if (item.kind === 'patch_panel' && item.chassis === 'uacc-patch-24') body = uaccPanelSvg(item); else if (item.kind === 'patch_panel') body = panelSvg(item); - else if (item.kind === 'shelf') body = shelfSvg(item, esc); + else if (item.kind === 'shelf') body = shelfSvg(item, esc, shelfFaceplates); else body = plateHtml(item); return `
${body}
`; } diff --git a/tests/test_app.py b/tests/test_app.py index 9fb9186..8d83b6a 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -552,6 +552,7 @@ class TestRackFacesAndSettings: assert [(c['name'], c['slot'], c['chassis']) for c in things] == [('b-box', 1, 'zimaboard-832'), ('large1', 2, '')] assert things[1]['interfaces'] == [{'name': 'eth0', 'up': True}] + assert things[0]['device'] == 'b-box' and things[0]['ports'] == {} def test_saved_racks_replace_config(self, client): self._items() diff --git a/tests/test_cutsheet.py b/tests/test_cutsheet.py index 48e02f5..48bbe1e 100644 --- a/tests/test_cutsheet.py +++ b/tests/test_cutsheet.py @@ -369,7 +369,7 @@ class TestChassis: def test_every_drawing_has_a_label(self): assert all(cs.CHASSIS.values()) assert {'sliger-cx4712', 'rosewill-rsv-l4500u', 'unvr-pro', 'uacc-patch-24', - 'usw-pro-max-24-poe', 'usw-pro-max-48-poe', 'zimaboard-832', 'zimaboard-832-cx3'} <= set(cs.CHASSIS) + 'usw-pro-max-24-poe', 'usw-pro-max-48-poe', 'zimaboard-832', 'zimaboard-832-cx3', 'resting'} <= set(cs.CHASSIS) class TestFacingAndSlots: -- 2.47.3 From 3bc8df6dc741ffb63b03c2d6f556bdd7e6caa72a Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 11 Nov 2026 01:08:52 -0500 Subject: [PATCH 2/6] Tests: wrap a long line Co-Authored-By: Claude Opus 5.5 --- tests/test_cutsheet.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_cutsheet.py b/tests/test_cutsheet.py index 48bbe1e..e59349f 100644 --- a/tests/test_cutsheet.py +++ b/tests/test_cutsheet.py @@ -369,7 +369,8 @@ class TestChassis: def test_every_drawing_has_a_label(self): assert all(cs.CHASSIS.values()) assert {'sliger-cx4712', 'rosewill-rsv-l4500u', 'unvr-pro', 'uacc-patch-24', - 'usw-pro-max-24-poe', 'usw-pro-max-48-poe', 'zimaboard-832', 'zimaboard-832-cx3', 'resting'} <= set(cs.CHASSIS) + 'usw-pro-max-24-poe', 'usw-pro-max-48-poe', 'zimaboard-832', 'zimaboard-832-cx3', + 'resting'} <= set(cs.CHASSIS) class TestFacingAndSlots: -- 2.47.3 From 7408b1feca2f452635a740976f156605aa614da9 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 11 Nov 2026 01:51:29 -0500 Subject: [PATCH 3/6] Rack: Sliger fronts in red, white and blue, and the UniFi brush panel From the rack photo on the wiki, the three Sligers have their coloured perforated fronts fitted, not the faceplate-off view: Front drawings "Sliger CX4712 4U, red/white/blue front" draw the hex-perforated panel (about 20 rows of holes, the drive cages faint behind it), black rack ears, the power button with Gandalf's status ring, and the USB recess. UniFi UACC-Rack-Panel-Brush-1U is drawn from Ubiquiti's product images: silver face, ear plates with rounded slots, a recessed chamfered frame and two rows of bristles meeting at a seam. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++ cutsheet.py | 6 +++- static/fronts.js | 74 ++++++++++++++++++++++++++++++++++++++++++ static/style.css | 14 ++++++++ tests/test_cutsheet.py | 3 +- 5 files changed, 98 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 993e0f3..b3dcceb 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,9 @@ The rack front, drawn from the rack units set on cutsheet items, top first. with a DAC attached; its round dot is Gandalf's link mark for enp1s0 (the slot is PCI bus 1), not an LED on the card. UniFi desk switches (Flex Mini, Flex) on a shelf are drawn with their live faceplate at their real width. Anything else is a labelled box. +- **More drawn fronts**: the Sliger CX4712 with its red, white or blue perforated front fitted (as in + the rack photo, drive cages faint behind the mesh), and the UniFi brush panel + (UACC-Rack-Panel-Brush-1U, from Ubiquiti's product images). - **Gear resting on other gear**: a shelf item with Front drawing "No shelf: gear resting on what is below" draws no tray, so a switch lying on top of the NVR is a "shelf" in the unit above the NVR whose contents sit directly on it. diff --git a/cutsheet.py b/cutsheet.py index 9111bfd..9921c94 100644 --- a/cutsheet.py +++ b/cutsheet.py @@ -37,7 +37,11 @@ CHASSIS = { 'uacc-patch-24': 'UniFi Patch Panel Blank 24 (UACC-Rack-Panel-Patch-Blank-24)', 'usw-pro-max-24-poe': 'UniFi Switch Pro Max 24 PoE (until UniFi reports it)', 'usw-pro-max-48-poe': 'UniFi Switch Pro Max 48 PoE (until UniFi reports it)', - 'sliger-cx4712': 'Sliger CX4712 4U (10-bay)', + 'sliger-cx4712': 'Sliger CX4712 4U (10-bay, faceplate off)', + 'sliger-cx4712-red': 'Sliger CX4712 4U, red front', + 'sliger-cx4712-white': 'Sliger CX4712 4U, white front', + 'sliger-cx4712-blue': 'Sliger CX4712 4U, blue front', + 'uacc-brush-1u': 'UniFi Rack Panel Brush 1U (UACC-Rack-Panel-Brush-1U)', 'rosewill-rsv-l4500u': 'Rosewill RSV-L4500U 4U (15-bay)', 'zimaboard-832': 'IceWhale ZimaBoard 832 (shelf gear)', 'zimaboard-832-cx3': 'IceWhale ZimaBoard 832 + ConnectX-3 SFP+ in PCIe (shelf gear)', diff --git a/static/fronts.js b/static/fronts.js index f7a38b6..7fdfead 100644 --- a/static/fronts.js +++ b/static/fronts.js @@ -106,12 +106,86 @@ const RACK_FRONTS = { }, }, + // Sliger CX4712 with its coloured front fitted, as in the rack photo (wiki: rack-2026.jpg): + // a hex-perforated panel over most of the face, the drive cages faintly visible through it, + // black rack ears, and a power button over two USB ports at the top right. + 'sliger-cx4712-red': sligerFront('#c8161d', '#e9e5e0'), + 'sliger-cx4712-white': sligerFront('#eceae6', '#9aa0a6'), + 'sliger-cx4712-blue': sligerFront('#1f5fae', '#e9e5e0'), + + // Ubiquiti UACC-Rack-Panel-Brush-1U, from Ubiquiti's product images (store.ui.com, UACC-Rack-Panel + // variant Brush 1U): a light silver face with separate ear plates (two rounded slots each), a + // recessed chamfered frame around the opening (shadowed along the top, bright bevel along the + // bottom), and two rows of black bristles meeting at a seam along the middle. + 'uacc-brush-1u': { + label: 'UniFi Rack Panel Brush 1U (UACC-Rack-Panel-Brush-1U)', units: 1, width: 1100, height: 105, + draw() { + // Bristle tufts: thin strands of varying shade, deterministic so the drawing never flickers + const strands = (y0, y1, seed) => Array.from({ length: 300 }, (_, i) => { + const n = (i * 7919 + seed * 104729) % 97; + return ``; + }).join(''); + const slot = x => ` + `; + return ` + + + + + + + + + ${slot(16)}${slot(1084)} + + + + + ${strands(31, 53, 1)}${strands(54, 76, 2)} + `; + }, + }, + // Generic servers, for gear without a specific drawing 'server-1u': genericServer(1, 4), 'server-2u': genericServer(2, 12), 'server-4u': genericServer(4, 24), }; +function sligerFront(colour, hole) { + const W = 1100, H = 420; + // Hex perforations: a pattern of hexagons cut out of the panel through a mask + const hex = (cx, cy, r) => Array.from({ length: 6 }, (_, i) => { + const a = Math.PI / 6 + i * Math.PI / 3; + return `${(cx + r * Math.cos(a)).toFixed(1)},${(cy + r * Math.sin(a)).toFixed(1)}`; + }).join(' '); + // Hole size and pitch from the rack photo: about 20 rows of holes over the panel's height + const r = 6.9, dx = 18, dy = 15.6; + const holes = []; + for (let row = 0, y = 46; y < H - 34; row++, y += dy) { + for (let x = 82 + (row % 2 ? dx / 2 : 0); x < 925; x += dx) holes.push(``); + } + const bays = Array.from({ length: 10 }, (_, i) => ``).join(''); + // The cages show only faintly through the mesh + return { + label: 'Sliger CX4712 4U (10-bay)', units: 4, width: W, height: H, + draw({ status }) { + const id = `sl-${colour.slice(1)}`; + return `${holes.join('')} + ${bays} + + + + + + + + ${usbA(977, 146)}${usbA(977, 168)}${usbC(980, 192)}`; + }, + }; +} + function genericServer(units, bays) { const H = 105 * units; return { diff --git a/static/style.css b/static/style.css index 0313b95..4cbc1b2 100644 --- a/static/style.css +++ b/static/style.css @@ -1630,3 +1630,17 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; } .cx-tab { fill: none; stroke: #c9ccd0; stroke-width: 9; stroke-linejoin: round; } .cx-link { stroke: #101418; stroke-width: 3; } .fp-speedbar { fill: #b3b5ba; } +.rf-sl-inside { fill: #141619; } +.rf-sl-ear { fill: #17181a; } +.rf-sl-edge { fill: none; stroke: rgba(0,0,0,.35); stroke-width: 2; } +.rf-sl-button { fill: #c9ccd0; stroke: #8a8e93; stroke-width: 2; } +.rf-sl-bay { opacity: .45; } +.rf-sl-io { fill: #0e0f11; } +.ua-ear-plate { fill: #d3d5d8; stroke: #b2b4b8; stroke-width: 1; } +.ua-ear-seam { fill: #9fa1a6; } +.ua-ear-slot { fill: #2a2c30; } +.ub-frame { fill: #bfc1c5; stroke: #a5a7ac; stroke-width: 1; } +.ub-frame-shadow { fill: #8c8e93; } +.ub-frame-bevel { fill: #f1f2f3; } +.ub-opening { fill: #0d0e10; } +.ub-seam { fill: #050506; } diff --git a/tests/test_cutsheet.py b/tests/test_cutsheet.py index e59349f..6d49bb2 100644 --- a/tests/test_cutsheet.py +++ b/tests/test_cutsheet.py @@ -370,7 +370,8 @@ class TestChassis: assert all(cs.CHASSIS.values()) assert {'sliger-cx4712', 'rosewill-rsv-l4500u', 'unvr-pro', 'uacc-patch-24', 'usw-pro-max-24-poe', 'usw-pro-max-48-poe', 'zimaboard-832', 'zimaboard-832-cx3', - 'resting'} <= set(cs.CHASSIS) + 'resting', 'uacc-brush-1u', 'sliger-cx4712-red', 'sliger-cx4712-white', + 'sliger-cx4712-blue'} <= set(cs.CHASSIS) class TestFacingAndSlots: -- 2.47.3 From 6e6150f9b96804ff0edc4c64b1315c645189b477 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 11 Nov 2026 01:55:12 -0500 Subject: [PATCH 4/6] Rack: rack handles on the Sliger ears Co-Authored-By: Claude Opus 5.5 --- static/fronts.js | 10 ++++++++++ static/style.css | 3 +++ 2 files changed, 13 insertions(+) diff --git a/static/fronts.js b/static/fronts.js index 7fdfead..63dc928 100644 --- a/static/fronts.js +++ b/static/fronts.js @@ -153,6 +153,15 @@ const RACK_FRONTS = { 'server-4u': genericServer(4, 24), }; +// A rack handle on a Sliger ear: a vertical bar standing off the ear on two mounts +function sligerHandle(x) { + const top = 70, bottom = 350; + return ` + + + `; +} + function sligerFront(colour, hole) { const W = 1100, H = 420; // Hex perforations: a pattern of hexagons cut out of the panel through a mask @@ -176,6 +185,7 @@ function sligerFront(colour, hole) { ${bays} + ${sligerHandle(14)}${sligerHandle(W - 40)} diff --git a/static/style.css b/static/style.css index 4cbc1b2..09dcfd0 100644 --- a/static/style.css +++ b/static/style.css @@ -1636,6 +1636,9 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; } .rf-sl-button { fill: #c9ccd0; stroke: #8a8e93; stroke-width: 2; } .rf-sl-bay { opacity: .45; } .rf-sl-io { fill: #0e0f11; } +.rf-sl-mount { fill: #0c0d0f; } +.rf-sl-handle { fill: #3a3e44; stroke: #0a0b0c; stroke-width: 2; } +.rf-sl-handle-shine { fill: #6b717a; } .ua-ear-plate { fill: #d3d5d8; stroke: #b2b4b8; stroke-width: 1; } .ua-ear-seam { fill: #9fa1a6; } .ua-ear-slot { fill: #2a2c30; } -- 2.47.3 From 36cbdd061dd80f6dcba306bbb46eb79dfe94f764 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 11 Nov 2026 01:56:24 -0500 Subject: [PATCH 5/6] Rack: brush panel holds up at rack size The bristles were hundreds of hard strands, which aliased into flat stripes at normal rack size. Each row is now shaded root to tip with a faint strand texture over it, so the panel looks the same at any size. Co-Authored-By: Claude Opus 5.5 --- static/fronts.js | 20 +++++++++++++++----- static/style.css | 3 ++- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/static/fronts.js b/static/fronts.js index 63dc928..77db066 100644 --- a/static/fronts.js +++ b/static/fronts.js @@ -120,17 +120,25 @@ const RACK_FRONTS = { 'uacc-brush-1u': { label: 'UniFi Rack Panel Brush 1U (UACC-Rack-Panel-Brush-1U)', units: 1, width: 1100, height: 105, draw() { - // Bristle tufts: thin strands of varying shade, deterministic so the drawing never flickers - const strands = (y0, y1, seed) => Array.from({ length: 300 }, (_, i) => { + // Bristles: each row is shaded root to tip (dark where it leaves the frame, lighter where the + // two rows meet), with a faint strand texture on top. Strong individual strands alias into hard + // stripes at rack size, so the texture stays low-contrast and the shading carries the look. + const strands = (y0, y1, seed) => Array.from({ length: 160 }, (_, i) => { const n = (i * 7919 + seed * 104729) % 97; - return ``; + return ``; }).join(''); const slot = x => ` `; return ` + + + + + + @@ -142,8 +150,10 @@ const RACK_FRONTS = { + + ${strands(31, 53, 1)}${strands(54, 76, 2)} - `; + `; }, }, diff --git a/static/style.css b/static/style.css index 09dcfd0..23dcd59 100644 --- a/static/style.css +++ b/static/style.css @@ -1646,4 +1646,5 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; } .ub-frame-shadow { fill: #8c8e93; } .ub-frame-bevel { fill: #f1f2f3; } .ub-opening { fill: #0d0e10; } -.ub-seam { fill: #050506; } +.ub-seam { fill: #050506; opacity: .8; } +.ub-strand { fill: #000; } -- 2.47.3 From cbdae54d70f6b93f2ade6d38322fa4f76c9257c7 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 11 Nov 2026 01:57:19 -0500 Subject: [PATCH 6/6] Rack: brush panel opening runs nearly the full width between the ears As in Ubiquiti's product image and the rack photo. Co-Authored-By: Claude Opus 5.5 --- static/fronts.js | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/static/fronts.js b/static/fronts.js index 77db066..feab522 100644 --- a/static/fronts.js +++ b/static/fronts.js @@ -123,9 +123,10 @@ const RACK_FRONTS = { // Bristles: each row is shaded root to tip (dark where it leaves the frame, lighter where the // two rows meet), with a faint strand texture on top. Strong individual strands alias into hard // stripes at rack size, so the texture stays low-contrast and the shading carries the look. - const strands = (y0, y1, seed) => Array.from({ length: 160 }, (_, i) => { + // The opening runs almost the full width between the ear plates, as in Ubiquiti's image + const strands = (y0, y1, seed) => Array.from({ length: 172 }, (_, i) => { const n = (i * 7919 + seed * 104729) % 97; - return ``; }).join(''); const slot = x => ` @@ -146,14 +147,14 @@ const RACK_FRONTS = { ${slot(16)}${slot(1084)} - - - - - - + + + + + + ${strands(31, 53, 1)}${strands(54, 76, 2)} - `; + `; }, }, -- 2.47.3