Skip to content

Console design layer

management.logrite.ai and downtime.logrite.ai are two surfaces of one product, one click apart. They are also two separate deploy units — the console lives in LogriteMasterControl/ops/management/, the tracker is a git submodule at services/downtime/dev/logrite-downtime-tracker/ — so they cannot share a stylesheet without coupling the submodule’s build to a file outside it.

Each therefore carries its own copy of the design layer, and ops/management/test_design_parity.js is what stops those copies drifting.

Logrite-UI is the authority, not these consoles. Values are lifted from it rather than re-chosen:

ThingSource of truth
Colour, radius, type, spacing tokensLogrite-UI/src/index.css, tailwind.config.js
BreakpointsLogrite-UI/src/responsive.css (Tailwind’s 640/768/1024/1280)
The wait marksrc/components/common/LogriteLoader.jsx
The date pickersrc/components/common/LogriteDatePicker.jsx
Shell geometryuseSidebar.js — 280px rail, 64px mobile header

CSS cannot read a custom property inside a media query, so the scale cannot be enforced with var(). It is enforced by the parity test, which greps every @media in both stylesheets and fails on a width that is off-scale.

479px compact phone — console-only; these are built to a 360px floor
639px sm ─┐
767px md │ Logrite-UI's own scale, as max-width complements
1023px lg │ of Tailwind's 640 / 768 / 1024 / 1280
1279px xl ─┘

Before this, the two files mixed 460, 720, 980, 1100 and 1279 — five tiers nothing else in the product shares, so a layout that reflowed correctly in the app reflowed at a different width in the console.

The spacing scale says what the steps are; these say which step a given kind of seam gets, so “how much air does a card get” is one decision rather than a px argument at every call site.

--gap-page: var(--sp-6); /* between top-level cards on a page */
--gap-card: var(--sp-5); /* inside a card, between its blocks */
--gap-control: var(--sp-3); /* between controls on one toolbar row */
--gap-inline: var(--sp-2); /* icon-to-label, chip-to-chip */
--col-svc: 20.5rem; /* minimum service-card track */
--col-stat: 15rem; /* minimum stat-tile track */

A card grid states a minimum column, never a count — a count has to be restated at every breakpoint and drifts from the container it actually sits in. The sidebar collapses and changes a grid’s width without the viewport changing at all, which is the state an operator uses most.

grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--col-svc)), 1fr));

The min(100%, …) is what stops a track being wider than its own container. A bare minmax(22rem, 1fr) on a 320px phone lays out a 352px column and the page scrolls sideways — the most common way a responsive grid breaks.

<input type="date"> cannot be themed: its calendar is user-agent chrome and Safari, Chrome and Firefox each draw a different one. The field looked right until somebody opened it. Both consoles now render a dependency-free port of LogriteDatePicker — selected day #E64100 on white, today bold orange, .375rem hover radius, calendar-icon trigger.

The console applies it as progressive enhancement: the native input stays in the DOM and stays the value, so everything that reads $("dep-since").value is untouched, and if the script never runs the field still filters.

The value is a bare YYYY-MM-DD with no zone, and every bug available here is a day-off bug: the table returns rows, they are just the wrong day’s rows. test_datepicker.js pins the arithmetic across six zones because both hosts run Etc/UTC, where every trap below is invisible.

  • new Date("2026-09-22") is parsed as UTC; new Date(2026, 8, 22) is local. Mixing them puts the highlighted day one off from the text in the field for every viewer west of Greenwich.
  • toISOString() converts to UTC before formatting, so a local date near midnight serialises as the adjacent day.
  • new Date(2026, 1, 31) normalises to 3 March rather than refusing, so a regex proving the digits are digits does not prove the date is a date.

Swapping in the naive new Date(str) fails the suite with 2026-01-01 resolving to 2025-12-31 in America/Los_Angeles.

Two cascade traps, both live during this work

Section titled “Two cascade traps, both live during this work”

These are not hypothetical; both shipped and both were caught by a test written afterwards.

Two rules for the same bare class have the same specificity, so they do not merge — the later one wins for whatever property they share. Nothing looks wrong in either place: both rules are valid, both target real elements, and only one has any effect.

.lr-field was the vertical form group (flex-direction: column, used by the login form and the deploy branch picker). A second .lr-field added for toolbar pairings set display: inline-flex, won on source order, and laid every stacked form field in the console out horizontally — including both login inputs. The fix is a different name (.lr-inline-field), not a stronger selector.

A genuine override belongs on a modifier — .lr-modal--sm — which is a different selector. But a modifier is also the same specificity as its base class, so it must be declared after it:

.lr-modal { max-width: 32rem; }
.lr-modal--sm { max-width: 28rem; } /* ✅ after — wins on source order */

Declared above the base rule, .lr-modal--sm parses fine, matches the right elements, and does nothing.

[hidden] is display: none only because the UA stylesheet says so, and any author rule setting display beats a UA rule outright. Both consoles hide a great deal this way — eight .lr-page sections, six of the tracker’s seven views — and both also set display: flex on those classes.

One line makes that safe:

[hidden] { display: none !important; }

It is load-bearing for every hidden element on both sites and is exactly the kind of line someone deletes while “cleaning up !important”. Losing it renders all eight sections stacked, which reads as a data bug.

Run it with node ops/management/test_design_parity.js.

  1. A token defined in both files has the same value — compared as resolved --name: value pairs, not as text, since the two files comment their token blocks differently and always have.

  2. Every @media width is on the scale above.

  3. Both consoles become a drawer at the same width, and the JS agrees with the CSS.

  4. Both carry the shared .lr-dp__* date-picker layer, and the selected day is --accent-orange on both.

  5. The global [hidden] rule exists with !important.

  6. No class is redefined with a conflicting display.

  7. A selector defined at the top level of both files lays out the same in both — every gap, padding, margin, grid-template-*, flex and min-width, compared as resolved declarations.

node ops/management/test_dropdown.js pins the .lr-dd__* layer the same way, including the one rule that is deliberately not shared: only the management sheet carries .lr-dd__native, because only it has a <select> left to hide.

Equal tokens are necessary and nowhere near sufficient. Both files already had a character-identical token block while:

Selectormanagementdowntime
.lr-toolbar14px 16px12px 16px
.lr-pagergap: 12pxgap: 10px
.lr-grid--kpivar(--col-stat)a literal 178px
.lr-kv190px label column200px
.lr-th-sortgap: 5pxgap: 4px
.lr-mobile-header0 16px0 14px

Sixteen shared selectors disagreed. None of it failed anything — it just made the two pages feel like near-misses of one product, which is the whole thing this design layer exists to prevent. Eleven were aligned; five are real differences and sit in LAYOUT_EXCEPTIONS with the reason, because a bare allowlist rots into “the ones we gave up on” and a reason is what lets the next reader tell a decision from an oversight.

It also caught a one-sided fix already in the tree: .lr-note .lr-icon had flex-shrink: 0 on the tracker and not on the console, so on a narrow card the console’s note icon squashed to an ellipse while its text kept full width.

The list/grid switch is two icons with no words; the labels said nothing the icons did not. The label moves to aria-label rather than being deleted — an icon-only control still has to announce itself, and title alone is not reliably announced by screen readers.

The segmented control’s base rule is flex: 1 1 0 so text segments share the width evenly. With the label gone that stretches a 16px glyph across half the control and paints the pressed state as a wide orange slab, so .lr-segmented--icon sizes its segments square instead.

<select> was the last control either console let the browser draw. appearance: none restyles the closed box and nothing else — the open list is an operating-system menu, so it ignores the theme, the radius, the type scale and the focus ring. On a dark console macOS painted a light popup in a different font.

.lr-dd replaces it, and the two sites build it differently on purpose:

SiteHowWhy
managementEnhances a <select> left in the DOMEvery reader goes through $("id").value and change. If the script fails, a working native control is left behind.
downtimeBuilds the listbox directlyThere is no markup to enhance and nothing to degrade to — if the script does not run, the page does not render at all.

An icon is a <use> into the page’s own sprite

Section titled “An icon is a <use> into the page’s own sprite”

A <use href="#i-check"> pointing at an id the sprite does not have renders nothing — no error, no console warning, no broken-glyph box. Porting this widget to the tracker brought check and chevron-down with it and that sprite had neither, so the caret and the tick would simply not have been drawn. A dropdown with no caret does not look broken; it looks like a slightly odd button. test_dropdown.js now checks every icon reference against each site’s own sprite.

One rule gave cursor: pointer to every <tr> in the console:

.lr-table tbody tr:hover { background: var(--bg-hover); cursor: pointer; }

So the dev→prod table, the service registry and the image list inside the disk drill-down all told the operator there was more to see and then did nothing. That is worse than a plain row: an operator who learns rows here are not clickable stops clicking the ones that are, and the disk table’s drill-down — which has worked the whole time — goes undiscovered.

The pointer now belongs to .lr-row-link, and rowOpens() is the only thing that applies it — the same call attaches the handler, so the affordance and the behaviour cannot drift apart. test_row_actions.js asserts the base rule has no pointer, that rowOpens sets role/tabindex/aria-label, and that a click landing on a button inside a row belongs to that button rather than also opening the row.

Both tables genuinely had more to say than fitted in their columns, so the affordance was made true rather than removed: #info-dialog is one section-driven dialog serving drift rows, registry rows and the disk reclaim breakdown.

It carried style="margin-left:6px;vertical-align:-2px". vertical-align does nothing without an inline box to align against, and the icon sat beside a <span> in a table cell with no shared baseline — so the glyph floated high and drifted further at every font size. .lr-cell-link is a flex row, which is what actually centres it.

A granted Notification permission cannot be withdrawn from script. The toggle was therefore a one-way door: once the browser said yes it read “Notifications on” for ever, and the only way back was the address-bar permission menu.

The permission and the preference are two different things. The browser owns the first; lr-notify-muted in localStorage owns the second, and notifyFire() honours it. Muting does not revoke the permission, and the button’s title says so — otherwise the operator goes hunting for a revocation that did not happen.

aria-pressed tracks whether notifications will actually arrive, not whether the permission exists: a muted console announcing itself as on is a console that lies to a screen reader.

logrite.deployd.io has no DNS record and was the frontend’s public_url in the tracker’s services.json. It is now app.logrite.ai.

public_url is rendered as a link, not probed — the frontend’s health check is http://localhost:3000/ — so the dead name was a broken link rather than a false outage. It reaches the UI through write_index(), which reads services.json rather than the per-service ledger.

Terminal in the console sidebar runs one shell command at a time on the dev host and shows its output. It appears only when the server reports it enabled. Source: ops/management/terminal.py, with routes in server.py.

PropertyHow it is enforced
Dev onlyNeeds --terminal and --allow-actions, and a hostname equal to LOGRITE_TERMINAL_HOST (default deployd-dev). The same unit file on prod opens nothing, and commands never use the prod SSH path.
A dedicated accountCommands run with sudo -n -u logrite-term. install.sh gives that account the docker group and nothing else: no sudo, no SSH key, and no read access to deployd’s ~/.ssh.
Password every sessionPOST /api/terminal/unlock re-checks the console password, using the same throttle as login. It returns a 15-minute token bound to that user, kept server-side only as a digest. The browser keeps the token in page memory only and sends it in X-Logrite-Terminal, never in a cookie. Operator tokens, Cloudflare-only identities and loopback callers cannot unlock it.
Everything auditedOne audit line when a command starts and one when it ends (exit code, duration, output tail). Every refusal is audited too: cross-origin, identity, bad password, locked.
Limits120 s per command by default, 600 s at most. 256 KB of output is shown, and a command is killed past 64 MB. Commands are non-interactive, so vim and top do not work. cd persists between commands.

Turn it on with TERMINAL=1 sudo ./install.sh and off with TERMINAL=0. When the variable is unset, the installed setting is kept, the same as ALLOW_ACTIONS. A locked terminal answers 423, not 401, because the console treats every 401 as “signed out” and redirects to the login page.