From 8fbd46ee59b6a9b4df72004bc01b3d21053af6c1 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Fri, 4 Sep 2026 07:32:09 +0530 Subject: [PATCH] feat(ui): a design language for panels, and the layout bug it exposed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## The Atrium is off by default, and stays off `enable()` wrote '1' to localStorage, so one visit to `?atrium=1` turned the Atrium on permanently, on that browser, for every later visit including plain `/`. An experimental surface that latches after a single look is the trap in #133 with the sign flipped. The flag is the URL and only the URL now; older latched values are cleared on load, and `exit` drops the parameter rather than recording a preference. ## A design language `docs/architecture/PANELS.md` gains it. A panel has to be recognisable as one: an operator scanning for a control should not have to work out where one grouping ends and the next begins, and a panel that dissolves into the background will not be found under pressure. - **Anatomy** — the `.op-block` card the instrument rail already uses; a small uppercase heading; `label — control — value` rows with fixed-width labels so values align; monospace tabular values; an em dash for unknown; red only for derived hazards. - **Composition** — standalone panels draw their own card and heading; composed ones draw neither. Two borders around one subject is the same duplication as two controls for one LED, just quieter. - **Placement** — panels go where the thing they act on is. Marking sits under the frame it marks; Light and Camera sit beside the surface they drive. - **Overflow** — the rail scrolls and must show it. Nothing may claim vertical space to say nothing. Two heading styles were quietly diverging — `.lp-title` at 0.72rem/700/0.06em against `.op-block-head` at 0.62rem/600/0.10em. One rule now. Marking gets the card it was missing, laid out across rather than down since it is a wide card under the frame. ## The layout bug Chasing "the image panel stretches awkwardly", measured rather than guessed: the camera box was 487 px wide holding a 163 px image — 324 px of dead space — inside a pane that was 464 px tall. `.op-pane` declares `grid-template-rows: auto minmax(0, 1fr)`, but `.op-lock` is `hidden` most of the time and a hidden element generates no box. So auto-placement dropped `.op-main` and `.op-inst` into row 1, the `auto` row, and the `1fr` row stayed empty. The pane sized to its content and the frame never grew. Worse, it was conditional: when the banner IS shown it fills row 1 and pushes both into row 2. The frame was one size with the sample at the objective and another without. Explicit `grid-row` on all three. The image goes from 163x163 to 356x356 — 2.2x — and dead space from 324 px to 131 px, which is what a square in a 487x356 area costs and no more. Co-Authored-By: Claude Opus 5 (1M context) --- docs/architecture/PANELS.md | 56 ++++++++++++++++++++++++ gently/ui/web/static/css/operate.css | 64 ++++++++++++++++++++++------ gently/ui/web/static/js/atrium.js | 29 +++++++++---- gently/ui/web/templates/index.html | 2 +- 4 files changed, 127 insertions(+), 24 deletions(-) diff --git a/docs/architecture/PANELS.md b/docs/architecture/PANELS.md index 87ba23cb..d431393c 100644 --- a/docs/architecture/PANELS.md +++ b/docs/architecture/PANELS.md @@ -60,6 +60,62 @@ instance. Markup, styles and behaviour ship together and mount into a host element, so adding it to a new surface is one call rather than a copy-paste of markup. +## Design language + +A panel has to be recognisable as one. An operator scanning the screen for a +control should not have to work out where one grouping ends and the next +begins, and a panel that dissolves into the background is a panel they will not +find under pressure. + +### Anatomy + +**A card.** Standalone panels sit in `.op-block`: 1px border, 6px radius, panel +background, consistent padding. Same card as the instrument rail already uses — +one panel look in the app, not a second one that nearly matches. + +**A heading.** Small, uppercase, letter-spaced, naming the subject. `.lp-title` +shares the `.op-block-head` rule rather than defining its own. + +**Rows.** `label — control — value`. Labels are fixed-width and muted so the +values line up down the panel. Values are monospace and tabular, because they +are read by comparison between glances. + +**An em dash for unknown.** Never a plausible default (rule 3). `—` is a +statement. + +**Red only for hazards**, and only when derived from read-back state (rule 5). + +### Composition + +A panel mounted **standalone** draws its own card and heading. A panel composed +**into an existing card** draws neither — `CameraPanel.mount(host, {titled: +false})` puts exposure inside the block that already names the camera. Two +borders around one subject, or two headings for one device, is the same +duplication as two controls for one LED, just quieter. + +### Placement + +**Panels go where the thing they act on is.** Marking sits under the frame it +marks, not out in the instrument rail — the marks are on that image. Light and +Camera sit in the rail beside the surface they drive. The main column is for +the specimen and the work; the rail is for the state of the instrument. + +Group by subject, not by widget type. Everything about illumination is in one +panel; exposure is with its camera and not with the light. + +### Overflow + +The rail scrolls, and must show that it does. `scrollbar-gutter: stable` keeps +a scrollbar appearing from reflowing the rail, and the scrollbar is the signal +that there is more below. + +**Nothing may claim vertical space to say nothing.** The temperature strip +rendered "No temperature data yet" permanently and cost ~60px, which pushed +real panels below the fold; it is hidden until a sample arrives. A widget that +occupies the screen while reporting that nothing has happened is taking space +from one that has something to say — this is rule 6 read as a layout +constraint. + ## Status | Panel | Subject | Mounted in | Notes | diff --git a/gently/ui/web/static/css/operate.css b/gently/ui/web/static/css/operate.css index 8014f136..8881d948 100644 --- a/gently/ui/web/static/css/operate.css +++ b/gently/ui/web/static/css/operate.css @@ -179,6 +179,15 @@ .op-main { grid-column: 1; + /* Row 2 EXPLICITLY. The lock banner is `hidden` most of the time, and a + hidden element generates no box, so auto-placement used to drop this and + the rail into row 1 — the `auto` row — leaving the `1fr` row empty. The + whole pane then sized to its content: the camera rendered 163 px tall in + a 464 px pane, with 324 px of dead space beside a 163 px image. + Worse, it changed with interlock state, because when the banner IS shown + it fills row 1 and pushes these into row 2. The frame was one size with + the sample at the objective and another without. */ + grid-row: 2; min-width: 0; min-height: 0; display: flex; @@ -187,12 +196,25 @@ } .op-inst { grid-column: 2; + grid-row: 2; min-height: 0; display: flex; flex-direction: column; gap: var(--op-2); overflow-y: auto; padding-right: var(--op-1); + /* A panel silently clipped is a panel that does not exist. The gutter is + reserved so a scrollbar appearing cannot reflow the rail, and the + scrollbar itself is the honest signal that there is more below. A fade + was tried and dropped: CSS cannot tell whether the rail actually + overflows, so a permanent fade makes the last panel look cut off when + everything already fits. */ + scrollbar-gutter: stable; + scrollbar-width: thin; +} + +@supports not (scrollbar-gutter: stable) { + .op-inst { padding-right: calc(var(--op-1) + 6px); } } /* ── the XY interlock banner ──────────────────────────────────────────────── @@ -201,6 +223,7 @@ header abstraction. */ .op-lock { grid-column: 1 / -1; + grid-row: 1; display: flex; align-items: center; justify-content: space-between; @@ -397,14 +420,15 @@ flex-direction: column; gap: var(--op-2); } -.op-block-head, .op-gauge-name, .op-label { +.op-block-head, .op-gauge-name, .op-label, +.lp-title, .lp-label { font-size: 0.62rem; font-weight: 600; letter-spacing: 0.10em; text-transform: uppercase; color: var(--op-ink-dim); } -.op-block-head { color: var(--op-ink); } +.op-block-head, .lp-title { color: var(--op-ink); } .op-row { display: flex; @@ -741,13 +765,8 @@ margin-bottom: 2px; } -.lp-title { - font-size: 0.72rem; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--text-muted); -} +/* .lp-title / .lp-label share the block heading rule above — one panel + heading style in the app, not two that nearly match. */ /* Staleness is load-bearing: these are read-back values, and a number with no age is indistinguishable from a remembered one. */ @@ -763,11 +782,7 @@ min-height: 26px; } -.lp-label { - flex: 0 0 62px; - font-size: 0.74rem; - color: var(--text-muted); -} +.lp-label { flex: 0 0 62px; } .lp-val { font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; @@ -1017,3 +1032,24 @@ line-height: 1.35; color: var(--text-muted); } + +/* Marking sits under the frame as a full-width card, so it reads across rather + than down: counts on the left, verbs on the right, note beneath. It uses the + same .op-block card as the instrument rail — one panel look, not two. */ +.op-block-marking .lp { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 4px 18px; +} + +.op-block-marking .lp-head { grid-column: 1 / -1; } + +.op-block-marking .mk-counts { margin: 0; } + +.op-block-marking .mk-acts { justify-content: flex-end; } + +.op-block-marking .mk-note { + grid-column: 1 / -1; + margin: 0; +} diff --git a/gently/ui/web/static/js/atrium.js b/gently/ui/web/static/js/atrium.js index 04eb1fb8..9d16d660 100644 --- a/gently/ui/web/static/js/atrium.js +++ b/gently/ui/web/static/js/atrium.js @@ -13,14 +13,16 @@ * "Everything exists" is not a change we have to make, it is already true. We * are deleting the hiding. * - * OFF BY DEFAULT. Enable with ?atrium=1, disable with ?atrium=0; the choice - * sticks in localStorage. With it off this file registers nothing and the + * OFF BY DEFAULT, and it does not stick. Enable with ?atrium=1 for that page + * load; anything else is off. With it off this file registers nothing and the * tabbed UI is untouched. */ const Atrium = (() => { 'use strict'; + // Retained only to clear what older builds latched — see wanted(). const FLAG_KEY = 'gently.atrium.enabled'; + try { localStorage.removeItem(FLAG_KEY); } catch (_) {} const SAVE_KEY = 'gently.atrium.layout.v1'; /* ── CONFIG — the whole surface is described here (SPEC R9) ─────── */ @@ -792,7 +794,6 @@ const Atrium = (() => { setDensity(CONFIG.density); restore(); goHome(false); - try { localStorage.setItem(FLAG_KEY, '1'); } catch (_) {} console.info('[atrium] on —', wins.size, 'windows adopted. ?atrium=0 to leave.'); } @@ -809,8 +810,8 @@ const Atrium = (() => { function disable() { legacyChrome().forEach(el => el.removeAttribute('inert')); - try { localStorage.setItem(FLAG_KEY, '0'); } catch (_) {} - location.href = location.pathname + '?atrium=0'; + // Leaving means leaving the flag behind, not recording a preference. + location.href = location.pathname; } function onKey(e) { @@ -845,11 +846,21 @@ const Atrium = (() => { if (best) attend(best.dataset.tab); } + /** + * The flag is the URL, and only the URL. + * + * It used to persist: `enable()` wrote '1' to localStorage, so a single + * visit to `?atrium=1` turned the Atrium on permanently, on that browser, + * for every later visit — including plain `/`. An experimental surface + * that latches after one look is the same trap as #133, where the agent + * panel's "open by default" was defeated permanently by one close. + * + * Off unless this page load asked for it. Nothing to discover and undo + * later, and no way to hand someone a machine that opens into a viewer + * they did not choose. + */ function wanted() { - const q = new URLSearchParams(location.search).get('atrium'); - if (q === '1') return true; - if (q === '0') return false; - try { return localStorage.getItem(FLAG_KEY) === '1'; } catch (_) { return false; } + return new URLSearchParams(location.search).get('atrium') === '1'; } function init() { diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 8c0272c3..455d9b2c 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -582,7 +582,7 @@

Device -
+