Skip to content

Visual language

The visual standard for Danbyte is defined in /CLAUDE.md at the project root. The running React SPA in frontend/ is its source of truth - read the shared primitives before adding UI.

In one breath

Restrained, real, neutral. Borders define edges, not shadows. Color exists only to convey state. The interface is built for technical operators who scan a lot of data fast - typography and spacing serve that, not decoration.

Tokens

  • Neutrals: Tailwind zinc. Not gray, not slate.
  • Status colors (only when conveying state):
    • Success - emerald
    • Warning - amber
    • Danger - red
    • Neutral - zinc
  • Primary / accent: --primary is Danbyte blue (styles.css), and the chart ramp is built from it. Earlier drafts of this doc claimed a neutral primary with no brand accent; the shipped token has been blue for a long time and the whole product is built and screenshotted against it, so the doc was the stale side and has been corrected here rather than the token. Blue is for primary action and selection only - it is not decoration, and it never carries meaning that belongs to a status colour.
  • One selection colour. Anything meaning "this is the selected thing" uses --primary. Canvas surfaces (3D, floor plan, site map, topology) can't read CSS variables today and hard-code #0ea5e9 in ~19 places, which is a different blue - so selection currently reads as two colours depending on which surface you're on. Until a readCssVar() bridge exists, keep new canvas code on the shared constant rather than adding another literal.
  • Links: never blue. Use the .link class (styles.css) - it inherits the surrounding text colour and reveals an underline only on hover and keyboard focus (with a focus ring). This keeps link-dense pages from turning into a wall of blue while still marking clickability. --primary is reserved for primary action / selection, never for a link. A trailing chain-glyph affordance is available as the opt-in .link-icon variant for the rare prominent standalone link; it is off by default to avoid per-row clutter and hover reflow in tables. The shared cell factories (components/cells/*) and TanStack Links alike all use .link.
  • Mono font: for every IP, CIDR, MAC, serial, ID, UUID, custom-field key.
  • Tabular nums: on every counter, percentage, timestamp.
  • Radii: rounded-md and rounded-lg only. rounded-full for presence/health dots and avatars.
  • Statuses are pills, never dots. A status renders as its ColorBadge pill everywhere it appears - table cells, dropdown options, the selected value of a select. A colored dot beside a plain name is not an acceptable compact form of a status.
  • Badges are squarish, never pills. Every count/label chip uses the Badge primitive (rounded-[5px]) - including chips floating over a canvas (map, floor plan, topology). Do not hand-roll rounded-full pill buttons for labels or counts; rounded-full stays reserved for status dots and avatars. Severity chips use the semantic variants (destructive, warning, success) as separate badges per severity, not one combined pill. Chips overlaying a tinted/tiled canvas sit on a solid bg-background/95 rounded-md border backdrop so the tint reads.
  • Tooltips never go white in dark mode. The plain tooltip is a chip: inverted on light (dark chip, light text), but on dark it steps up to a raised surface rather than inverting all the way to near-white, which glared against the zinc palette. Rich content - headings, muted secondary text, semantic colour - uses variant="panel" instead, because those colours are chosen against the page background. Use the shared Tooltip or InfoTip, never a title= attribute.
  • Canvas bands (topology layers): rows neutral by default - --muted toward --border, title centred on top unless a cable crosses there - side bands on a pastel zone swatch; never a free colour picker, never the role colour the cards already carry.
  • Canvas notes are text in text-foreground/75 and Lucide icons in --muted-foreground, no box unless outlined (border-border on bg-card); never coloured.
  • Shadows: don't. Borders define edges. Exception: dropdowns/popovers get shadow-sm.

See /CLAUDE.md for the canonical class snippets per component (button, badge, tag, table, dropdown, etc).

Where to look

  • frontend/src/styles.css - the active design tokens and global styling
  • frontend/src/components/ui/ - the shadcn primitives (button, badge, input, checkbox, select, command, popover, dialog, …)
  • frontend/src/components/forms/ - the form-field layer built on those primitives, re-exported from one barrel (@/components/forms)
  • frontend/src/components/ - shared and domain components (DataTable, ListPageShell, DetailShell, KvCard, StatusBadge, ObjectPicker, …)
  • docs/architecture/shadcn-tokens.md - the token/variable reference

Never hand-roll a control

Every interactive control comes from the primitives above. A native <input type="checkbox">, <select>, or a <div> styled to look like one is wrong even when the classes approximate the design: it renders with the browser's own widget, ignores the theme, and drifts the moment tokens change.

Need Use Not
Checkbox with a label FormCheckbox from @/components/forms <label><input type="checkbox">
Bare checkbox (table cell, list row) Checkbox from @/components/ui/checkbox <input type="checkbox">
Dropdown of fixed options FormSelect, or Select for an unlabelled one <select>
Long / searchable option list FormCombobox / Combobox <select> with many <option>s
Searchable picker in a popover Popover + Command (see ui/combobox.tsx) a hand-built input + <button> list
Free text with common values FormText with suggestions (or SuggestInput directly) <datalist>, or a <select> that locks out other values
Object reference ObjectPicker or an existing domain picker preset (onPickMany when a list collects several at once) a bespoke fetch + list

Radix controls report changes differently from DOM ones - Checkbox uses onCheckedChange(bool) and Select uses onValueChange(string), not onChange(event). A Radix SelectItem also cannot carry value=""; give an "any" row a sentinel value and map it at both ends.

Dead classes have bitten this codebase three times. The archived reference/design/tokens.css was deleted in 9ba017d while 450+ call sites still referenced its classes:

class sites found
.ck (checkboxes → browser defaults) ~40 2026
.num (tabular figures) 367 2026-07
text-destructive-foreground (delete buttons) 89 2026-07

All three are fixed, .num now lives in src/styles.css, and destructive confirms use variant="destructive". The lesson stands: if you find a class with no definition behind it, delete the markup and use a primitive. Grep styles.css before trusting a bare class name.

Loading, and the shared Maps parts

One loader. A page, section, panel or canvas that is fetching shows <Loading /> (components/loading.tsx): the first-load splash spinner with a small muted Loading… under it, centred in the box it loads. The splash itself is the same component with its label kept for screen readers only. A table keeps its built-in loading row. A pending labelled button keeps its size and swaps the verb (Saving…); a spinner on its own is for icon-only buttons. The ellipsis is always the one … character.

The Maps pages (Topology, Site map, Floor plans) build their chrome from shared parts, so a control reads the same on each:

Need Use
Toolbar controls (h-7, text-xs, size-3 icons) components/map-toolbar.tsx: BarButton, BarIconButton (its required label is the aria-label and the tooltip; destructive is ghost with destructive text; tipSide moves the tip off the bottom for a control that is not in a top bar), BarToggle (aria-pressed, muted when off), BarMenuTrigger (label plus chevron, no tooltip) and BarTip (the plain default tooltip, below the control)
Undo and Redo on a bar HistoryButtons (components/topology/history-buttons.tsx), each with its key in the tip; HistoryMenuItems for the same two in a More menu
A map's Arrange ▾ ArrangeMenu (components/topology/arrange-menu.tsx): Reset layout, the bands and the Layout group (direction, Levels…)
Zoom buttons in a map's corner ZoomControls (components/topology/zoom-controls.tsx): Zoom in, Zoom out and Fit view, the bar's icon buttons stacked, tips to the right
A map embedded in another page (a device's Map tab, a trace, a tunnel) EmbeddedMap (components/topology/embedded-map.tsx): the Diagram's Detailed cards and Elbow lines, no overview, a folded legend
A rail map (VLANs, virtual networks) RailCanvas (fills a page, scrolls) or RailFrame (a card on a detail page), from components/topology/rail-diagram.tsx, with RailLegend (components/topology/rail-legend.tsx) in the corner
Leaving the map for an object's page OpenLink (components/open-link.tsx): a router link drawn as a bar button with a leading ArrowUpRight, e.g. Open device. Drilling in on the same map is a plain button, no arrow
Unsaved edits on the way out LeaveGuardDialog (components/leave-guard-dialog.tsx), driven by useBlocker({ withResolver: true }): Discard unsaved changes? with Keep editing / Discard and leave
A shortcut in a tooltip or menu Kbd (components/ui/kbd.tsx), with modKey() (lib/mod-key.ts) for the modifier: ${modKey()}S reads ⌘S on a Mac, Ctrl+S elsewhere
The Objects sidebar ObjectsPanel (components/objects-panel.tsx): Objects and its count, the search box, CheckFilterTabs, the hidden row, then the page's ObjectsSections. Each group inside is a FoldableGroup (components/foldable-group.tsx) with its count and, where it can be hidden, the VisibilityToggle eye
How much is hidden, with the sidebar shut HiddenChip (components/hidden-chip.tsx): N hidden · Show all, in the corner the page names with position (the one its MiniMap, legend and attribution leave free)
A right-click menu on a canvas PointerMenu (components/pointer-menu.tsx): the shared dropdown opened at the pointer. A key an item shows (H, Del) is passed in keys, so it acts on what was right-clicked, not on the selection
The detail panel over a canvas PanelShell (components/map-panel.tsx): title and Close, PanelRow key/value rows, PanelSections under a SectionLabel, and the actions at the foot (Open … first). SectionLabel also heads a legend
A chip on a canvas (Partial map) Bordered bg-background/95, no shadow and no blur: shadows are for overlays
A legend on a canvas LegendFrame (components/map-legend.tsx): bordered and opaque bg-background, no shadow and no blur, headed Legend with a Hide legend button, open or folded (remembered per browser under its storageKey, or held by the page), so a colored rail or card behind it never shows through; folded, an outline xs button with the List icon. w-60 unless its rows need their own width (w-fit). A legend that is part of an exported picture - the floor plan's Color by key, inside its PNG area - is hideable={false}: always open, no Hide button to land in the export. The topology's CanvasLegend, the rail maps' RailLegend, the site map's legend and the 3D room's key are built on it. TopologyCanvas takes its box as keepClear, so a fit keeps the map beside or above it; RailCanvas keeps room for it under the drawing
A legend's rows From components/map-legend.tsx: LegendRow (swatch, then label), LegendLine (a line keys a line), LegendTones (a colour mode's keys as short lines, wrapping), and LegendPills / LegendStatuses for roles and statuses as their ColorBadge / StatusBadge pills - never a coloured dot beside a name. LegendItems draws a list of LegendItems in that order
Speeds lib/speed.ts: parseSpeedMbps (a bare number is kbps, as on the server), parseSpeedKbps (a speed typed into a *_kbps form field: whole kbps, null when blank, undefined when it is not a speed), fmtKbps / fmtMbps (short 10G, 100/20M; long 10 Gbps) and the one tier scale, SPEED_TIERS, that the faceplates, the 3D room and the topology's and the site map's Speed colouring share
How full a rack is lib/rack-capacity.ts: above 80 % amber, above 95 % red, in the status colours - never the accent - and formatWatts. CapacityBar (components/cells/capacity-bar.tsx) is its thin bar; PowerFigure (components/cells/power-figure.tsx) reads a rack's power as demand / supply and PortsFigure (components/cells/ports-figure.tsx) its ports as in use / counted. On a map the level goes on a rack's fill and monitoring keeps its outline (components/floorplan/tile-paint.ts). IPAM prefixes keep their own scale (UtilCell)

Anything that copies to the clipboard goes through copyWithToast() (lib/clipboard.ts), so a copy that fails always says Couldn't copy.

Widths and truncation are the primitive's job

A control must declare one width contract, and the shared primitives do:

primitive contract
Input, Textarea, SelectTrigger, Combobox w-full min-w-0 - fill the slot, and stay shrinkable
compact toolbar control an explicit w-* plus shrink-0 at the call site

Never let a control size itself to its content. SelectTrigger shipped with upstream shadcn's w-fit, so it was narrow when empty and grew when a value was picked - a labelled field in a grid row changed width and shoved its neighbours.

Long values ellipsise, they don't clip. Watch for one trap: truncate and line-clamp-* both set display, so they lose to a sibling flex on the same element. If a value span needs flex (icon + text), put the truncation on the inner text node instead.

Dialog width: use size, never a class

DialogContent and AlertDialogContent take a size prop (sm | md | lg | xl | 2xl | 3xl, default md = 28rem) which is keyed off data-size.

Do not pass a width class. cn() is tailwind-merge, which only dedupes classes carrying the same modifier - so an unprefixed max-w-lg does not cancel the primitive's sm:max-w-*. Both land, specificity ties, and Tailwind emits the variant later, so the default wins on every desktop. Six dialogs shipped believing they were wide and weren't. data-size can't be clobbered that way.

Pick by content, not by taste: md for 2–4 fields, lg for 5–7, xl for 8+ or any 2-column grid, 2xl/3xl for tables, trees and traces.

List-page chrome

Every list page is ListPageShell + DataTable - no exceptions, so header height, search placement, action order, and the loading/error/empty treatment can't drift page to page (source of truth: frontend/src/components/list-page-shell.tsx, reference implementation routes/manufacturers.index.tsx):

  • The shell owns the header (title · count chip · search · actions) and the scrolling body. Header order is fixed: search first, then the action cluster - TableActions (Import / Export) and then Add X.
  • The header is one h-14 row when everything fits. When it doesn't, the search box narrows (18rem down to 10rem), then the controls move under the title and wrap onto more rows. Nothing in a page header is ever scrolled out of sight behind a hidden scrollbar - the same goes for DetailShell's actions and tab strip, which wrap too.
  • Add X is the copy for a create button, with no icon. Not "New X".
  • Filters live in the rail (FilterRail + FacetGroup, usually via useTableFilters), not in a second toolbar row under the header. A filter that is genuinely either/or (no "any" state) is a SegmentedTabs switch in the action cluster instead; a single-select filter over a long fixed list can be a Select under a rail heading.
  • A facet with no options renders nothing, and a derived enum facet (a yes/no or state split computed from the row rather than read off a catalog object) sets hideWhenSingle so it also disappears when every row lands in the same bucket - ticking its one option would select the whole table. Rail length is the budget: a rail you have to scroll to reach Manufacturer is worse than a short one.
  • Empty is either the DataTable "No results." row (a filter matched nothing) or an EmptyState carrying first-run guidance (nothing exists yet) - never a bare paragraph. Loading and errors are the shell's, via its query prop.
  • A tabbed list (Prefixes, Drift, Alerts, Compliance, Jobs) puts one h-10 SegmentedTabs strip above the shell and renders a shell per tab, so each tab supplies its own count, search, rail, and actions.
  • Tables are paged by DataTable alone. When the API pages server-side, hand it serverPagination={{ page, pageCount, totalRows, onPageChange }} so that one pager drives the server - don't add a second Prev/Next row.
  • An embedded table on a detail page fetches one capped page (page_size=500) rather than paging. Hand DataTable the server's total={q.data?.count} with it: when more rows exist than arrived it says so, instead of a site with 600 prefixes showing exactly 500 and looking complete.
  • A list that is a view of another list (e.g. /racks/elevations) gets the shell's backTo / backLabel breadcrumb rather than its own nav.
  • A table that points at its rows' objects somewhere else on the page (a rack on a floor plan) uses DataTable's opt-in onRowHover (the row under the pointer, null once it leaves) and onRowClick. A click on a link, checkbox, button, input or other control in a cell stays that control's, as does a text selection or a click in a menu a cell opened; a cell can mark more with data-row-click="ignore". Without the props a table is unchanged.

Row actions always go through RowActions / actionsColumn(), and every list-page table names a tableId so it gets the persistent column picker. Every tableId is registered in frontend/src/lib/tables.ts with the api list path its rows come from (null when they are not one list's rows) - routes/-table-registry.test.ts fails on an unregistered id, and an id built at runtime passes autoColumns (an api path, or false) itself.

A table wider than its pane scrolls sideways inside its own frame, with the row actions pinned to the right edge, and the Download / Columns bar wraps instead of running off. The pinned column holds only the compact row actions (edit, delete - an icon or two). Wider per-row controls go in an ordinary column that scrolls with the data: the interface tables keep cable status, trace, connect and the IP buttons in port_actions and pin only Edit. A pinned column as wide as the pane covers every data column on a narrow window. Its header cell, and a stickyHeader table's header row, are opaque, so the columns scrolling under them never show through. The page itself never scrolls or clips sideways. For that, every flex item between the table and the page carries min-w-0: ListPageShell, DetailShell and DataTable already do, and a bare DetailTab gives it to each direct child. A pane that nests its own rail-and-table row inside another flex row puts min-w-0 on that row too. components/table-overflow.test.tsx checks this for the shared shells and the prefix IPs pane. The app's main column is overflow-clip, not overflow-hidden, so not even focusing a control past its edge can scroll the page sideways.

stickyHeader makes the table's own frame the scroller in both directions, so it needs a parent that bounds its height - the pane is a flex min-h-0 flex-1 flex-col column (see PrefixIpsTable). Then the header row stays in view and the sideways scrollbar sits at the pane's bottom edge rather than after the last row. In a pane that grows with its content the header does not stick.

Column factories

One entity, one column factory (frontend/src/components/columns/), reused by its list page and every embedded table. A second ColumnDef[] for the same entity is how a row starts reading differently depending on which page you opened it from - a device that shows its compliance marker on /devices and hides it on the site page, a site link that is blue in one table and neutral in the next.

Entity Factory
Prefix buildPrefixColumns()
IP address buildIpColumns()
Device buildDeviceColumns()
Interface buildInterfaceColumns() (+ DEVICE_INTERFACE_COLUMNS preset)
VLAN buildVlanColumns()
Rack buildRackColumns()
Cluster buildClusterColumns()
Virtual machine buildVmColumns()
Aggregate buildAggregateColumns()
Cable buildCableColumns()
VRF buildVrfColumns()
Site buildSiteColumns()
Device type buildDeviceTypeColumns()
Device role buildDeviceRoleColumns()
Service buildServiceColumns()

Each takes options - never per-caller branches inside the factory:

  • include / omit pick columns; the factory's canonical order always applies, so two pages showing the same columns cannot show them in a different order. buildPrefixColumns also takes order for the one surface that genuinely leads with a different column.
  • selection, actions, humanIds add the checkbox, actionsColumn(), and the # numid column.
  • violations adds the compliance marker, monitoring the roll-up status column, tagFilter wires tag chips to a page filter (omit it and the chips are static rather than falsely clickable), cfDefs adds custom-field columns.
  • A handful carry a named presentation knob where an embedded pane genuinely renders a column differently from the list - siteVariant (linked vs muted plain text), buildClusterColumns' typeVariant, buildCableColumns' labelVariant / terminationsLinked / statusEditable, buildServiceColumns' linked (the device / VM pane renders the name and IP as plain text), and the header labels vidHeader / nameHeader / heightHeader. Prefer one of these over a second ColumnDef[]; the point is that the variant is declared at the call site instead of hidden in a copied column.
  • plainHeaders, zeroCounts and countFacets keep an older surface reading exactly as it did: a read-only or embedded table that never offered sorting on a column, one that prints 0 rather than - for an empty count, or a tab that filters counts by range instead of the list page's in-use / unused split.
  • A page's own columns are spliced around the factory's output (rack position, monitoring bindings, a virtual-chassis Member column, the cables list's trace-plus-row-actions pair) - see routes/locations.$id.tsx, routes/racks.$id.tsx, and routes/cables.index.tsx.

Catalog columns

DataTable adds the rest by itself (#243): for a registered table it asks /api/list-fields/ what the list's rows carry and offers every field no factory column covers - and every custom field (cf_<key>) - as a column that starts hidden, sorts, exports, and renders by its kind (components/columns/auto-columns.tsx). So a factory hand-writes only the columns that need a look of their own (a badge, a link, a facet), and:

  • a column whose id is not the row key it shows sets meta.field (the device factory's type shows device_type, the cable factory's a shows a_terminations), else the menu offers the field twice;
  • a page that leaves a field out on purpose passes autoColumns={{ exclude }};
  • rows that wrap the list row ({ kind, ip }) pass autoColumns={{ get }};
  • a new field worth a column goes into the list serializer, with the joins that keep api/tests_list_queries.py flat - it then appears in the menu with no frontend change. Detail-only getters use @detail_only.

An object value reads as its name; a record with no name of its own reads as its parts (a link peer sw1 · Gi1/0/1, an unlabelled cable #50), the same in the cell, the sort and the export. A field whose values render no text on the rows seen so far is not offered.

Object references inside a cell come from components/cells/: siteColumn / SiteCell, deviceColumn / DeviceCell, plus locationColumn, rackColumn, platformColumn, manufacturerColumn, vrfColumn, tagsColumn, timeAgoColumn, numidColumn. They render a reference as a link (never plain text), underlined on hover and never text-primary blue, and they carry the facet meta the filter rail reads. Each column helper takes className for tables that run text-xs.

Detail-page tabs

Every object detail page follows one tab convention (source of truth: frontend/src/components/segmented-tabs.tsx, reference implementations routes/devices.$id.tsx and routes/interfaces.$id.tsx):

  • The breadcrumb header and the summary section carry only the headline: the object's name/title, status/state badges, one subtitle line, tags and the description. No facts, counts or figures - not even one or two - and no bands of text under it: every fact goes in the Overview cards.
  • All the remaining attributes live in an Overview tab - the first tab, and the default - rendered as KvCard tables (<KvCard title rows>) in a grid gap-6 lg:grid-cols-2, grouped into a few sensibly-titled cards. This is the "read it as tables in the page body" layout.
  • After Overview come the related-object tabs (with a count where the API provides one), then always Journal and Change log as the last two, in that order.
  • On a narrow window the tab strip wraps onto more rows instead of scrolling, so Journal and Change log never sit out of sight past its edge. The header's actions do the same beside the breadcrumb, moving under it when less than 18rem is left.

Never render Change log (ChangeLogPanel) or Journal (JournalPanel) - or a wall of attribute fields - inline in the header. Attributes go in the Overview tab's KvCards; history and journal are always their own tabs.

The detail hero

The summary section itself is DetailHero, passed to DetailShell's hero prop (source of truth: frontend/src/components/detail-shell.tsx, reference implementation routes/aggregates.$id.tsx). It owns the section wrapper and the title element and its size - a page only supplies content:

slot renders
title (+ mono) the page's single <h1>, always text-2xl font-semibold tracking-tight
badges status/state chips, inline with the title and wrapping with it
subtitle one secondary line - a parent link, a facility ID, ports, a second row of chips
tags <TagList tags={…} />
description the object's description
children anything else in the left column, below the description
stats + statCols the SLA page's figure rail - no other page uses it

Facts do not go in the header: a count, size or utilisation is a row in an Overview KvCard - custom fields included, as CustomFieldValues layout="cards" in the Overview grid. The one exception is the SLA page, whose stats rail keeps the agreement's live figures (this period, budget left, members); no other page passes stats.

Never pass a title size, and never hand-roll the section. The hero was copied 42× before this primitive existed and had drifted to four title sizes (text-lg, text-xl, text-2xl, text-3xl), three title elements (div/span/h1, only four of them a real heading), two incompatible layouts, a local DetailStat fork that rendered prefix utilisation 50% larger than every other stat in the product, and one page (cables) with no title at all. If a title is an identifier - IP, CIDR, ASN, interface, circuit ID - pass mono; if it is a coloured catalog object, pass the ColorBadge as title so the badge still sizes itself and the <h1> still lands in the document outline.

A hero needing an extra full-width band under it (SNMP credentials, CustomFieldValues, a usage note) renders DetailHero and the band as siblings in a fragment. Prefer folding the content into a slot: extra stacked strips push the tab bar down the page, which is why the prefix page's ancestor chain is now its hero subtitle and its subnet-details disclosure sits at the top of the Overview tab.