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.
Where the design comes from
Section titled “Where the design comes from”Logrite-UI is the authority, not these consoles. Values are lifted from it rather than re-chosen:
| Thing | Source of truth |
|---|---|
| Colour, radius, type, spacing tokens | Logrite-UI/src/index.css, tailwind.config.js |
| Breakpoints | Logrite-UI/src/responsive.css (Tailwind’s 640/768/1024/1280) |
| The wait mark | src/components/common/LogriteLoader.jsx |
| The date picker | src/components/common/LogriteDatePicker.jsx |
| Shell geometry | useSidebar.js — 280px rail, 64px mobile header |
The breakpoint scale
Section titled “The breakpoint scale”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 floor639px sm ─┐767px md │ Logrite-UI's own scale, as max-width complements1023px lg │ of Tailwind's 640 / 768 / 1024 / 12801279px 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.
Density tokens
Section titled “Density tokens”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.
The date picker
Section titled “The date picker”<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.
Day-off bugs do not look like bugs
Section titled “Day-off bugs do not look like bugs”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.
A class redefined later silently wins
Section titled “A class redefined later silently wins”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] needs !important
Section titled “[hidden] needs !important”[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.
What the parity test pins
Section titled “What the parity test pins”Run it with node ops/management/test_design_parity.js.
-
A token defined in both files has the same value — compared as resolved
--name: valuepairs, not as text, since the two files comment their token blocks differently and always have. -
Every
@mediawidth is on the scale above. -
Both consoles become a drawer at the same width, and the JS agrees with the CSS.
-
Both carry the shared
.lr-dp__*date-picker layer, and the selected day is--accent-orangeon both. -
The global
[hidden]rule exists with!important. -
No class is redefined with a conflicting
display. -
A selector defined at the top level of both files lays out the same in both — every
gap,padding,margin,grid-template-*,flexandmin-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.
Why (7) had to be added
Section titled “Why (7) had to be added”Equal tokens are necessary and nowhere near sufficient. Both files already had a character-identical token block while:
| Selector | management | downtime |
|---|---|---|
.lr-toolbar | 14px 16px | 12px 16px |
.lr-pager | gap: 12px | gap: 10px |
.lr-grid--kpi | var(--col-stat) | a literal 178px |
.lr-kv | 190px label column | 200px |
.lr-th-sort | gap: 5px | gap: 4px |
.lr-mobile-header | 0 16px | 0 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.
Icon-only controls
Section titled “Icon-only controls”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.
The dropdown
Section titled “The dropdown”<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:
| Site | How | Why |
|---|---|---|
| management | Enhances a <select> left in the DOM | Every reader goes through $("id").value and change. If the script fails, a working native control is left behind. |
| downtime | Builds the listbox directly | There 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.
Hover is not an affordance
Section titled “Hover is not an affordance”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.
The chevron
Section titled “The chevron”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.
Notifications have an off switch
Section titled “Notifications have an off switch”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.
Hostnames
Section titled “Hostnames”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.
The Terminal (dev only)
Section titled “The Terminal (dev only)”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.
| Property | How it is enforced |
|---|---|
| Dev only | Needs --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 account | Commands 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 session | POST /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 audited | One 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. |
| Limits | 120 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.