Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions docs/architecture/PANELS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
64 changes: 50 additions & 14 deletions gently/ui/web/static/css/operate.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 ────────────────────────────────────────────────
Expand All @@ -201,6 +223,7 @@
header abstraction. */
.op-lock {
grid-column: 1 / -1;
grid-row: 1;
display: flex;
align-items: center;
justify-content: space-between;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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. */
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
29 changes: 20 additions & 9 deletions gently/ui/web/static/js/atrium.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) ─────── */
Expand Down Expand Up @@ -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.');
}

Expand All @@ -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) {
Expand Down Expand Up @@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion gently/ui/web/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -582,7 +582,7 @@ <h2 class="devices-title"><span class="devices-title-script">Device</span> <em c
<!-- Marking sits under the frame it acts on, not off in the
instrument rail. Shared panel; the module owns its
markup (docs/architecture/PANELS.md). -->
<div id="op-marking-host"></div>
<div class="op-block op-block-marking" id="op-marking-host"></div>
</div>

<aside class="op-inst">
Expand Down
Loading