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 @@