/* ============================================================================
   THE SKIN CONTRACT  ·  Todomodo / Slosar design system
   ----------------------------------------------------------------------------
   One truth, two halves.

     contract.css   draws the components, and carries the BUILT-IN skin.
     skins/*.css    supply values only. Never draw anything.

   The built-in skin (the :root block below) is "Plex" -- the calm one the Lab
   dashboard and the work queue already use. It lives here rather than in
   skins/plex.css so there is exactly one copy of those values: a file loaded
   with no skin still renders correctly, and there is nothing to drift.

   This is the WinAmp arrangement: the player owns the behaviour, the skin owns
   the look. A skin that has to add a rule to work is a skin that has found a
   hole in this file -- fix the hole, do not patch the skin.

   ── WHAT A SKIN MAY CHANGE ────────────────────────────────────────────────
   Every --ds-* custom property declared in :root below. Colour, type, corner
   geometry, density, depth, motion duration. That is the whole surface area.

   ── WHAT A SKIN MAY NEVER CHANGE ──────────────────────────────────────────
   The invariants block near the bottom of this file. They are accessibility
   floors, not taste:

     · 44px minimum on anything you tap        (motor accessibility)
     · a visible keyboard focus ring            (never `outline:none`)
     · honouring prefers-reduced-motion         (motion is optional, always)
     · 16px minimum body text, no `user-scalable=no`
     · a scrim floor under translucent material (added with .ds-mat)
     · a lit .ds-node differs by stroke weight, not only by colour  (v0.3)
     · a .ds-portlet cannot resize or paint outside the host        (v0.3)
     · a .ds-gauge cap mark cannot be deleted by a skin             (v0.6)
     · an alert's severity is a WORD, not only a colour             (v0.6)

   ── THE THIRD AXIS ────────────────────────────────────────────────────────
   v0.3 adds `data-mood`: six identity hues, 60 degrees apart, that say what
   register a surface is in -- alert level, health, priority, semantic group.
   It composes with data-skin and data-theme and it is NOT a skin. See the
   MOOD block below, and GRAMMAR.md for what each mood means.

   A skin cannot opt out of these: they sit in the first-declared @layer and are
   !important, which is the one cascade arrangement no ordinary stylesheet can
   beat. See the block at the bottom -- the first attempt got this wrong and a
   hostile-skin test caught it. Appearance is configurable, access is not.

   ── THEMES ────────────────────────────────────────────────────────────────
   Every skin ships light AND dark, in three blocks, because the viewer has
   three states and only two of them are stamped on the root element:
     :root                                   the complete light palette
     :root:not([data-theme=light]) @media    dark, when the OS says dark
     :root[data-theme=dark]                  dark, when the user says dark
   A token defined in only one of those renders one theme's ink on the other
   theme's ground.
   ========================================================================== */

/* Layer order, declared before any rule so it cannot depend on link order.
   ds-invariants is FIRST because for !important declarations the earlier layer
   wins -- the accessibility floors outrank the mood, which outranks a skin. */
@layer ds-invariants, ds-mood;

:root{
  /* ---- ground & surfaces -------------------------------------------------- */
  --ds-ground:#EDF0F4;
  --ds-surface:#FFFFFF;
  --ds-surface-2:#F6F8FA;

  /* ---- ink ---------------------------------------------------------------- */
  --ds-ink:#15181E;
  --ds-ink-2:#4E5765;
  --ds-ink-3:#646C77;   /* 5.31:1 on surface, 4.65:1 on GROUND. #6E7783 was 3.97 on ground */

  /* ---- lines -------------------------------------------------------------- */
  --ds-line:#D6DCE4;
  --ds-line-2:#E7ECF1;

  /* ---- accent: identity. NOT state. --------------------------------------- */
  --ds-accent:#1B5FA6;
  --ds-accent-ink:#FFFFFF;
  --ds-accent-soft:#E3EDF8;

  /* ---- accent 2: the counterpoint. Both todomodo pages are two-accent
         designs -- an orange frame against a blue panel field -- and one
         accent token cannot express that. Still identity, still NOT state. -- */
  --ds-accent-2:#6B4E93;
  --ds-accent-2-soft:#EDE7F6;

  /* ink that reads on ANY saturated fill in this skin: the accents and the
     four state colours. One token, not five, because within a single skin the
     state palette is consistent in luminance -- Plex's states are all dark so
     white reads on all of them; todomodo's are all bright so black does. A
     skin whose fills disagree in luminance has a palette problem, not a token
     problem. */
  --ds-on-fill:var(--ds-accent-ink);

  /* ---- state: semantic, and deliberately separate from the accent, so a
         skin can change its identity colour without changing what "critical"
         looks like ------------------------------------------------------- */
  --ds-ok:#166B47;    --ds-ok-soft:#E2F0E9;
  --ds-warn:#9A3F12;  --ds-warn-soft:#F8E9DF;
  --ds-crit:#A32020;  --ds-crit-soft:#F9E3E3;
  --ds-idle:#0F4C81;  --ds-idle-soft:#E1EBF5;

  /* ---- type --------------------------------------------------------------- */
  --ds-font-body:"IBM Plex Sans",ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
  --ds-font-display:var(--ds-font-body);
  --ds-font-mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
  --ds-text:16px;
  --ds-text-sm:14px;
  --ds-lh:1.55;
  --ds-display-weight:700;
  --ds-display-spacing:-.015em;
  --ds-display-transform:none;

  /* labels: the eyebrow / column-head / chip voice */
  --ds-label-transform:uppercase;
  --ds-label-spacing:.12em;
  --ds-label-weight:600;
  --ds-label-size:11.5px;

  /* ---- geometry ----------------------------------------------------------- */
  --ds-radius:10px;
  --ds-radius-sm:6px;
  --ds-radius-pill:999px;
  --ds-border:1px;
  /* --ds-elbow is the asymmetric corner: 0 on three corners, large on one.
     A rounded-rectangle skin sets it to var(--ds-radius) and nothing moves. */
  --ds-elbow:var(--ds-radius);
  --ds-rail:0px;          /* width of a left colour rail on a card; 0 = none */

  /* ---- density ------------------------------------------------------------ */
  --ds-pad:16px;
  --ds-pad-tight:11px;
  --ds-gap:12px;

  /* THE PAGE MEASURE -- v0.7, and it is Lab Manager's ask 5, which was not
     "change 940 to 1200" but "is this a contract value at all".

     It is. They measured four surfaces on one design language at a 1400px
     viewport and got four measures -- dashboard 1200, feature index 1040,
     queue 720, this console 940 -- so the reading column jumped 130px every
     time a reader crossed between them. Each number was locally reasonable
     when it was written and nobody could see the disagreement until the four
     were opened side by side, which is exactly the class of thing a token
     fixes and a convention does not.

     A DEFAULT, not a law: a surface with a real reason to be narrower or wider
     says so in one declaration on its wrapper and is legible about it. What
     the token removes is the surface that differs by accident.

     16px minimum inline padding pairs with it, because --ds-chrome-bleed
     defaults to --ds-pad and the chrome's full bleed only cancels exactly when
     the wrapper pads at the same number. See .ds-page. */
  --ds-measure:1200px;

  /* ---- depth -------------------------------------------------------------- */
  --ds-shadow:0 1px 2px rgba(15,25,40,.06),0 4px 14px rgba(15,25,40,.05);
  /* the emphasis halo. `none` by default: a glow is a todomodo idea, not a
     universal one, and a skin that does not want one must not have to undo it. */
  --ds-glow:none;

  /* ---- material ----------------------------------------------------------
     .ds-mat draws translucency; these values dial it. The defaults below are
     FLAT -- alpha 1, blur 0, scrim 1 -- so on Plex, console and instrument a
     .ds-mat renders what .ds-card renders. Nothing moves for a skin that does
     not opt in. ------------------------------------------------------------ */
  --ds-mat-tint:var(--ds-surface);   /* the colour of the glass itself */
  --ds-mat-scrim:1;        /* 0-1 opacity of that tint. FLOORED at .55. */
  --ds-mat-blur:0px;       /* backdrop blur radius */
  --ds-mat-sat:1;          /* backdrop saturation multiplier */
  --ds-mat-edge:var(--ds-line);

  /* ---- data palette: categorical, NOT semantic ---------------------------
     The four educational simulators each carry five or six distinct hues used
     to tell things APART, not to say good or bad -- gol's mint/aqua/violet/
     gold/danger, core's sky/green/amber/pink/lime. The four state colours
     cannot do that job: a series is not "warn". These are for series, legends,
     traces and categories, and a skin owns all eight. ---------------------- */
  --ds-d1:oklch(0.50 0.110 250);   /* #2A669F */
  --ds-d2:oklch(0.50 0.110 295);   /* #68559B */
  --ds-d3:oklch(0.50 0.110 340);   /* #8C4877 */
  --ds-d4:oklch(0.50 0.110 25);   /* #984742 */
  --ds-d5:oklch(0.50 0.110 70);   /* #8A5600 */
  --ds-d6:oklch(0.50 0.110 115);   /* #626A09 */
  --ds-d7:oklch(0.50 0.110 160);   /* #0B764D */
  --ds-d8:oklch(0.50 0.110 205);   /* #007481 */

  /* ---- controls ----------------------------------------------------------
     Sizing for the interactive vocabulary lifted from the simulators. Density
     is a skin's business; the 44px tap floor is not, and is not a token. ---- */
  --ds-track:6px;          /* slider rail thickness */
  --ds-thumb:22px;         /* slider handle */
  --ds-ctl-gap:10px;

  /* ---- readouts ----------------------------------------------------------
     The gauge is sized to be read from across the room, which is Lab Manager's
     stated requirement for the one screen the lab is run from. The NUMBER is
     the primary element and the bar confirms it. ---------------------------- */
  --ds-gauge-track:10px;   /* bar thickness */
  --ds-gauge-size:30px;    /* the numeral. Deliberately larger than body text. */
  --ds-dot-size:10px;

  /* ---- frame -------------------------------------------------------------
     Sizing for the bar / side / cap chrome. Values only; the frame is drawn
     below and a skin that never uses it pays nothing. ----------------------- */
  --ds-frame-gap:10px;
  --ds-frame-bar-h:60px;
  --ds-frame-foot-h:44px;
  --ds-frame-side-w:200px;
  --ds-frame-cap-w:150px;

  /* ---- motion ------------------------------------------------------------- */
  --ds-motion:160ms;
  --ds-ease:cubic-bezier(.2,.6,.2,1);
}

:root:not([data-theme="light"]){
  @media (prefers-color-scheme: dark){
    --ds-ground:#11141A; --ds-surface:#191D25; --ds-surface-2:#1F242E;
    --ds-ink:#E8ECF2; --ds-ink-2:#AEB8C6; --ds-ink-3:#828D9C;
    --ds-line:#2C333F; --ds-line-2:#242A34;
    --ds-accent:#79ADE8; --ds-accent-ink:#0E1319; --ds-accent-soft:#1C2A3A;
    --ds-accent-2:#B49BE0; --ds-accent-2-soft:#2A2338;
  --ds-d1:oklch(0.80 0.120 250);   /* #80C3FF */
  --ds-d2:oklch(0.80 0.120 295);   /* #C3AEFF */
  --ds-d3:oklch(0.80 0.120 340);   /* #F19FD6 */
  --ds-d4:oklch(0.80 0.120 25);   /* #FF9E96 */
  --ds-d5:oklch(0.80 0.120 70);   /* #EFB062 */
  --ds-d6:oklch(0.80 0.120 115);   /* #BBC769 */
  --ds-d7:oklch(0.80 0.120 160);   /* #71D6A3 */
  --ds-d8:oklch(0.80 0.120 205);   /* #44D4E2 */
    --ds-d1:oklch(0.80 0.120 250);   /* #80C3FF */
    --ds-d2:oklch(0.80 0.120 295);   /* #C3AEFF */
    --ds-d3:oklch(0.80 0.120 340);   /* #F19FD6 */
    --ds-d4:oklch(0.80 0.120 25);   /* #FF9E96 */
    --ds-d5:oklch(0.80 0.120 70);   /* #EFB062 */
    --ds-d6:oklch(0.80 0.120 115);   /* #BBC769 */
    --ds-d7:oklch(0.80 0.120 160);   /* #71D6A3 */
    --ds-d8:oklch(0.80 0.120 205);   /* #44D4E2 */
    --ds-ok:#63C79B;   --ds-ok-soft:#16281F;
    --ds-warn:#E9A377; --ds-warn-soft:#33241A;
    --ds-crit:#F08A8A; --ds-crit-soft:#331A1A;
    --ds-idle:#8FC0F0; --ds-idle-soft:#182534;
    --ds-shadow:0 1px 2px rgba(0,0,0,.4),0 4px 14px rgba(0,0,0,.3);
  }
}
:root[data-theme="dark"]{
  --ds-ground:#11141A; --ds-surface:#191D25; --ds-surface-2:#1F242E;
  --ds-ink:#E8ECF2; --ds-ink-2:#AEB8C6; --ds-ink-3:#828D9C;
  --ds-line:#2C333F; --ds-line-2:#242A34;
  --ds-accent:#79ADE8; --ds-accent-ink:#0E1319; --ds-accent-soft:#1C2A3A;
  --ds-accent-2:#B49BE0; --ds-accent-2-soft:#2A2338;
  --ds-d1:oklch(0.80 0.120 250);   /* #80C3FF */
  --ds-d2:oklch(0.80 0.120 295);   /* #C3AEFF */
  --ds-d3:oklch(0.80 0.120 340);   /* #F19FD6 */
  --ds-d4:oklch(0.80 0.120 25);   /* #FF9E96 */
  --ds-d5:oklch(0.80 0.120 70);   /* #EFB062 */
  --ds-d6:oklch(0.80 0.120 115);   /* #BBC769 */
  --ds-d7:oklch(0.80 0.120 160);   /* #71D6A3 */
  --ds-d8:oklch(0.80 0.120 205);   /* #44D4E2 */
  --ds-ok:#63C79B;   --ds-ok-soft:#16281F;
  --ds-warn:#E9A377; --ds-warn-soft:#33241A;
  --ds-crit:#F08A8A; --ds-crit-soft:#331A1A;
  --ds-idle:#8FC0F0; --ds-idle-soft:#182534;
  --ds-shadow:0 1px 2px rgba(0,0,0,.4),0 4px 14px rgba(0,0,0,.3);
}

/* ==========================================================================
   MOOD -- the third axis, and the one Tony asked for by drawing a wheel
   --------------------------------------------------------------------------
   WARM and COOL were shipped as two skins. They were not two skins. Diffed
   against each other they are the same file with the hue rotated 180 degrees,
   which is the definition of two positions on one wheel rather than two
   designs. This block is that wheel, with six positions instead of two.

   ── WHAT A MOOD IS ────────────────────────────────────────────────────────
   A mood is the IDENTITY hue of a surface, and it carries meaning: alert
   level, health, priority, or plain semantic grouping. It is set at the
   highest level of page organisation -- the frame, the plane, the tab set --
   so a reader knows what register they are in before reading a word.

   ── WHAT A MOOD IS NOT ────────────────────────────────────────────────────
   A mood is NOT the state palette. --ds-ok / warn / crit / idle do not move
   when the mood does. That separation is the whole reason a page can BE in
   `alert` and still contain a card that is individually `ok`: the page-level
   hue says how the situation is, the component-level state says how that one
   component is. Collapse them and the page can only ever say one thing.

   A mood is also NOT the ground. Every mood shares the skin's ground, surface
   and ink -- the room does not change, only the light in it. That is what
   makes a mood compose with EVERY skin instead of only with its own.

   ── THE GEOMETRY ──────────────────────────────────────────────────────────
   Six hues, 60 degrees apart, anchored so that 70 is the orange the old
   `warm` led with and 250 is the blue `cool` led with. Both survive; they
   are now `active` and `survey`.

   Equal radius is the constraint, not a decoration. --ds-mood-c is ONE
   chroma for all six, so no mood is louder than another merely because sRGB
   happens to hold more of its hue. 0.128 at L 0.76 is the largest circle
   sRGB can actually contain at any single lightness -- measured, not
   guessed. The cost is honest and worth naming: a mood accent is slightly
   less saturated than the #FF9900 the todomodo pages lead with, because
   #FF9900 sits outside the circle that also contains a blue of equal
   loudness. Raise --ds-mood-c in a skin to trade the parity back for punch.

   ── PRECEDENCE ────────────────────────────────────────────────────────────
   The attribute is doubled -- :root[data-mood][data-mood] -- which is
   specificity (0,3,0) and beats any skin's (0,2,0) no matter what order the
   stylesheets are linked in. Consumers generate their <link> tags from a
   directory listing; an axis that depends on link order is an axis that
   breaks the first time a directory sorts differently.
   ========================================================================== */

:root{
  /* The list, so a consumer can build a mood selector without holding a list
     of its own. Read it with getComputedStyle(root).getPropertyValue.

     Lab Manager's rule -- "no hand-written skin list survives anywhere in
     ~/ops" -- is the right rule, and it is the reason the NAME and the
     one-line MEANING of each mood are tokens too (--ds-mood-name and
     --ds-mood-says). A consuming tool can render the whole selector, labels
     and explanations included, while holding nothing that can go stale. */
  --ds-version:"0.10";
  --ds-moods:alert active steady inspect survey focus;

  /* The radii: one value each, shared by all six positions -- that is the
     "equidistant" in the sketch. They are per THEME because light and dark
     are different gamuts: 0.128 is the largest circle sRGB holds at L 0.76,
     and 0.082 the largest it holds at the L a light accent has to sit at.
     Measured against every hue the six moods can produce, not guessed. */
  --ds-mood-c:.082;    /* accent chroma        */
  --ds-mood-c2:.075;   /* counterpoint chroma  */
  --ds-mood-cd:.080;   /* categorical ramp     */
}

/* The six positions. Two numbers and a name each -- adding a seventh mood is
   this line, and nothing else in the system. */
:root[data-mood="alert"]  {--ds-mood-h:10;  --ds-mood-h2:130; --ds-mood-name:"Alert"; --ds-mood-says:"Something is wrong now and needs a person.";}
:root[data-mood="active"] {--ds-mood-h:70;  --ds-mood-h2:190; --ds-mood-name:"Active"; --ds-mood-says:"Work is in flight on this surface.";}
:root[data-mood="steady"] {--ds-mood-h:130; --ds-mood-h2:250; --ds-mood-name:"Steady"; --ds-mood-says:"Healthy, and running unattended.";}
:root[data-mood="inspect"]{--ds-mood-h:190; --ds-mood-h2:310; --ds-mood-name:"Inspect"; --ds-mood-says:"One thing, opened up and examined.";}
:root[data-mood="survey"] {--ds-mood-h:250; --ds-mood-h2:310; --ds-mood-name:"Survey"; --ds-mood-says:"Reading, reference and browsing.";}
:root[data-mood="focus"]  {--ds-mood-h:310; --ds-mood-h2:70;  --ds-mood-name:"Focus"; --ds-mood-says:"A single task, everything else out of the way.";}

/* The counterpoint sits 120 degrees round, in whichever direction misses the
   alert wedge (340-40), so no mood accidentally grows a red second accent and
   reads as a warning it is not. `survey` is the one hand-set pair: blue with
   gol.html's violet, kept because that pairing is read off a real page. */

/* ---- The two lightness registers ------------------------------------------
   Declared on plain :root, NOT gated on [data-mood], so that a mood selector
   can draw a swatch for a mood the page is not currently in -- each swatch
   substitutes its own hue and needs these to resolve regardless.

   Verified: accent on white and white on accent both clear 4.5:1 at every one
   of the six hues, lowest 5.27. The ramp clears 3:1 at all 24 hues it can
   produce, lowest 5.26. In dark, accent on surface clears 7.84 and the ink on
   a filled accent clears 8.41. */
:root{
  /* .48, not .52. A mood accent has to clear 4.5:1 on the DARKEST ground any
     skin ships -- instrument's #DFE1E4 -- not merely on white, because the
     plane chrome and the tab underline sit on the ground. At .52 the accent
     measured 4.01:1 there. The full sweep in the console is what found it. */
  --ds-mood-l:.48;
  --ds-mood-soft-l:.93;
  --ds-mood-soft-c:.028;
  --ds-mood-on:#FFFFFF;
}
:root:not([data-theme="light"]){
  @media (prefers-color-scheme: dark){
    --ds-mood-l:.76;
    --ds-mood-c:.128; --ds-mood-c2:.108; --ds-mood-cd:.120;
    --ds-mood-soft-l:.27;
    --ds-mood-soft-c:.040;
    --ds-mood-on:oklch(.17 .022 var(--ds-mood-h,255));
  }
}
:root[data-theme="dark"]{
  --ds-mood-l:.76;
  --ds-mood-c:.128; --ds-mood-c2:.108; --ds-mood-cd:.120;
  --ds-mood-soft-l:.27;
  --ds-mood-soft-c:.040;
  --ds-mood-on:oklch(.17 .022 var(--ds-mood-h,255));
}

/* ---- The formulas. Written once; every mood is the same eleven lines with
        one number substituted, which is the point.

   ── WHY THIS IS IN A LAYER, WITH !important ───────────────────────────────
   The first attempt used a doubled attribute selector -- :root[data-mood]
   [data-mood], specificity (0,3,0) -- on the theory that it would outrank any
   skin. It did not. A skin's dark block is
   `:root[data-skin=liquid][data-theme=dark]`, ALSO (0,3,0), and it is linked
   after the contract, so the skin won on source order and every mood rendered
   as graphite. The media-query variant is (0,4,0) and would have beaten a
   tripled attribute too. Counting attributes is a race, not a rule.

   The cascade arrangement that is actually decisive is the one the invariants
   already use, because for !important declarations the EARLIER layer wins and
   an unlayered !important is the weakest important there is:

     skin, normal              loses  (important beats normal)
     skin, !important          loses  (unlayered important loses to any layer)
     skin in its own layer     loses  (ds-mood is declared before it)
     ds-invariants             WINS   (declared first, and stays supreme)

   So a skin cannot take the identity tokens back, which is the intended
   semantics rather than a side effect: identity belongs to the mood, and a
   skin that wants to set an accent is a skin doing the mood's job.

   The two composite tokens keep their geometry skinnable -- a skin still sets
   --ds-glow-blur and --ds-glow-y and only the COLOUR comes from the mood. -- */
@layer ds-mood{
  :root[data-mood]{
    --ds-accent:oklch(var(--ds-mood-l) var(--ds-mood-c) var(--ds-mood-h)) !important;
    --ds-accent-ink:var(--ds-mood-on) !important;
    --ds-accent-soft:oklch(var(--ds-mood-soft-l) var(--ds-mood-soft-c) var(--ds-mood-h)) !important;
    --ds-accent-2:oklch(var(--ds-mood-l) var(--ds-mood-c2) var(--ds-mood-h2)) !important;
    --ds-accent-2-soft:oklch(var(--ds-mood-soft-l) var(--ds-mood-soft-c) var(--ds-mood-h2)) !important;
    --ds-on-fill:var(--ds-mood-on) !important;

    --ds-d1:oklch(var(--ds-mood-l) var(--ds-mood-cd) calc(var(--ds-mood-h) +   0)) !important;
    --ds-d2:oklch(var(--ds-mood-l) var(--ds-mood-cd) calc(var(--ds-mood-h) +  45)) !important;
    --ds-d3:oklch(var(--ds-mood-l) var(--ds-mood-cd) calc(var(--ds-mood-h) +  90)) !important;
    --ds-d4:oklch(var(--ds-mood-l) var(--ds-mood-cd) calc(var(--ds-mood-h) + 135)) !important;
    --ds-d5:oklch(var(--ds-mood-l) var(--ds-mood-cd) calc(var(--ds-mood-h) + 180)) !important;
    --ds-d6:oklch(var(--ds-mood-l) var(--ds-mood-cd) calc(var(--ds-mood-h) + 225)) !important;
    --ds-d7:oklch(var(--ds-mood-l) var(--ds-mood-cd) calc(var(--ds-mood-h) + 270)) !important;
    --ds-d8:oklch(var(--ds-mood-l) var(--ds-mood-cd) calc(var(--ds-mood-h) + 315)) !important;

    --ds-glow:0 var(--ds-glow-y,3px) var(--ds-glow-blur,14px)
              oklch(var(--ds-mood-l) var(--ds-mood-c) var(--ds-mood-h) / var(--ds-glow-a,.42)) !important;
    --ds-mat-edge:oklch(var(--ds-mood-l) var(--ds-mood-c) var(--ds-mood-h) / .26) !important;
  }
}

/* ==========================================================================
   COMPONENTS -- these read tokens and are never overridden by a skin
   ========================================================================== */

*{box-sizing:border-box;}

body{
  margin:0;
  background:var(--ds-ground);
  color:var(--ds-ink);
  font-family:var(--ds-font-body);
  font-size:var(--ds-text);
  line-height:var(--ds-lh);
  -webkit-text-size-adjust:100%;
}

.ds-display{
  font-family:var(--ds-font-display);
  font-weight:var(--ds-display-weight);
  letter-spacing:var(--ds-display-spacing);
  text-transform:var(--ds-display-transform);
  text-wrap:balance;
  margin:0;
}

.ds-label{
  font-family:var(--ds-font-mono);
  font-size:var(--ds-label-size);
  font-weight:var(--ds-label-weight);
  letter-spacing:var(--ds-label-spacing);
  text-transform:var(--ds-label-transform);
  color:var(--ds-ink-3);
}

/* -- card ---------------------------------------------------------------- */
.ds-card{
  background:var(--ds-surface);
  border:var(--ds-border) solid var(--ds-line);
  border-radius:var(--ds-elbow) var(--ds-radius) var(--ds-radius) var(--ds-radius);
  box-shadow:var(--ds-shadow);
  border-left-width:calc(var(--ds-border) + var(--ds-rail));
  overflow:hidden;
}
.ds-card[data-state="ok"]{border-left-color:var(--ds-ok);}
.ds-card[data-state="warn"]{border-left-color:var(--ds-warn);}
.ds-card[data-state="crit"]{border-left-color:var(--ds-crit);}

/* A FOLDED CARD DOES NOT STRETCH, v0.7, and it is the first of the two host
   requirements Lab Manager paid for and asked us to write down. It is written
   down HERE, in the component, because the host should never have had to know.

   A grid stretches its items to the row height. A closed <details> has nothing
   to fill that height with, so the moment two cards share a row and one of
   them is folded -- which is most of the time -- the folded one draws its state
   rail down the side of nothing. They measured a closed Network card at 622px.

   Only the FOLDED case is exempted. Open cards still stretch, so a row of open
   cards keeps its flush bottom edge, which is the reason `stretch` is the grid
   default in the first place. And only details.ds-card matches: a card that is
   a plain <div> has content at its own height and is not this bug. */
details.ds-card:not([open]){align-self:start;}

/* WRAPS since v0.6, and the reason is U1 below: a card head now routinely
   carries a name, a freshness line and a state chip, which is more than fits
   across a 430px phone card. A head with two children never wraps and is
   unchanged; one that would have overflowed now folds instead of clipping.
   Same fix, same reason, as .ds-row in v0.4. */
.ds-card-head{
  display:flex; align-items:center; gap:10px; flex-wrap:wrap;
  padding:var(--ds-pad-tight) var(--ds-pad);
  border-bottom:var(--ds-border) solid var(--ds-line-2);
  background:var(--ds-surface-2);
}
.ds-card-name{
  font-family:var(--ds-font-mono);
  font-size:var(--ds-label-size);
  font-weight:var(--ds-label-weight);
  letter-spacing:var(--ds-label-spacing);
  text-transform:var(--ds-label-transform);
  color:var(--ds-ink-2);
  margin-right:auto;
}
.ds-card-body{padding:var(--ds-pad-tight) var(--ds-pad);}

/* -- row: label on the left, value on the right, digits aligned ----------- */
.ds-row{
  display:flex; align-items:baseline; gap:var(--ds-gap);
  padding:7px 0;
  border-bottom:var(--ds-border) solid var(--ds-line-2);
}
.ds-row:last-child{border-bottom:none;}
.ds-row dt{color:var(--ds-ink-2); font-size:var(--ds-text-sm); margin:0;}
.ds-row dd{
  margin:0 0 0 auto; font-family:var(--ds-font-mono); font-size:var(--ds-text-sm);
  font-variant-numeric:tabular-nums; color:var(--ds-ink); font-weight:500;
}

/* -- chip: state encoded in form as well as in the word ------------------- */
.ds-chip{
  display:inline-block;
  font-family:var(--ds-font-mono);
  font-size:var(--ds-label-size);
  font-weight:var(--ds-label-weight);
  letter-spacing:var(--ds-label-spacing);
  text-transform:var(--ds-label-transform);
  padding:3px 9px;
  border-radius:var(--ds-radius-pill);
  white-space:nowrap;
  color:var(--ds-idle); background:var(--ds-idle-soft);
}
.ds-chip[data-state="ok"]{color:var(--ds-ok); background:var(--ds-ok-soft);}
.ds-chip[data-state="warn"]{color:var(--ds-warn); background:var(--ds-warn-soft);}
.ds-chip[data-state="crit"]{color:var(--ds-crit); background:var(--ds-crit-soft);}

/* -- button --------------------------------------------------------------- */
.ds-btn{
  font-family:var(--ds-font-body); font-size:var(--ds-text-sm); font-weight:500;
  line-height:1; cursor:pointer;
  padding:12px 15px;
  border-radius:var(--ds-radius-sm);
  border:var(--ds-border) solid var(--ds-line);
  background:var(--ds-surface); color:var(--ds-ink-2);
  transition:border-color var(--ds-motion) var(--ds-ease),
             background var(--ds-motion) var(--ds-ease);
}
.ds-btn:hover:not(:disabled){border-color:var(--ds-ink-3);}
.ds-btn[aria-pressed="true"],.ds-btn.is-on{
  background:var(--ds-accent); color:var(--ds-accent-ink);
  border-color:transparent; font-weight:600;
}
.ds-btn:disabled{opacity:.4; cursor:default;}
/* AN IRREVERSIBLE ACTION IS DECLARED ON THE CONTROL -- v0.10, ITM W31, Tony's
   ruling. Not a state: the button is not in a crit condition, the thing it
   does cannot be undone. A VARIANT of .ds-btn, the way .ds-exit[data-rel]
   declares a relation rather than being a second component -- the reader's
   question at the control is still "do it now", and the answer to "can I take
   it back" is what the colour carries. Found on the deployment plane, where
   "Deploy to production" and "Deploy preview" rendered identically and the
   plane had to style one from --ds-crit locally.

   data-armed is the host's press-twice confirmation: one press arms, the
   second fires, and no modal -- a modal blocks every event on the page. The
   ARMED LOOK is the contract's, so "press again" reads the same on every
   plane; the timer and the label swap are the host's. */
.ds-btn[data-irreversible]{border-color:var(--ds-crit); color:var(--ds-crit);}
.ds-btn[data-irreversible]:hover:not(:disabled){border-color:var(--ds-crit);}
.ds-btn[data-irreversible][data-armed]{
  background:var(--ds-crit); color:var(--ds-on-fill); border-color:var(--ds-crit); font-weight:600;
}

/* -- row sub-line: a second, quieter line under a row ----------------------
   Lab Manager: several dashboard cards "drop context because there is nowhere
   to put it", and a long task title "is currently just long". Both are the same
   gap. .ds-row now wraps, so a third child at full width falls to its own line.

   A two-child row -- every row that exists today -- never wraps and is
   unchanged. What DOES change: a row whose label and value together overflow
   now wraps instead of squeezing. That is the improvement they asked for. */
.ds-row{flex-wrap:wrap;}
.ds-row-sub{
  flex:0 0 100%;
  margin:2px 0 0;
  font-size:var(--ds-text-sm);
  line-height:1.4;
  color:var(--ds-ink-3);
}

/* -- dot: state as a mark, for a card header ------------------------------
   The third spelling of the state vocabulary Lab Manager is currently writing
   three ways (.status-dot / .tag / .gauge-fill). All three collapse onto
   ok | warn | crit | idle, which is what the contract has always used.

   A dot is colour and nothing else, so it is NEVER the only carrier of a
   state: give it an accessible name, and keep the word somewhere on the card.
   The ring is what keeps it visible on a ground that happens to match it. */
.ds-dot{
  display:inline-block;
  flex:none;
  width:var(--ds-dot-size); height:var(--ds-dot-size);
  border-radius:var(--ds-radius-pill);
  background:var(--ds-idle);
  box-shadow:0 0 0 2px var(--ds-surface);
}
.ds-dot[data-state="ok"]{background:var(--ds-ok);}
.ds-dot[data-state="warn"]{background:var(--ds-warn);}
.ds-dot[data-state="crit"]{background:var(--ds-crit);}

/* -- gauge: a number, confirmed by a bar -----------------------------------
   Used for CPU, memory and disk. The value is set large and in tabular
   figures; the bar restates it. Remove the bar entirely and the reading is
   unchanged, which is the test for whether a readout is colour-only. */
.ds-gauge{display:block;}
.ds-gauge-head{
  display:flex; align-items:baseline; gap:var(--ds-gap);
  margin-bottom:6px;
}
.ds-gauge-label{
  font-family:var(--ds-font-mono);
  font-size:var(--ds-label-size);
  font-weight:var(--ds-label-weight);
  letter-spacing:var(--ds-label-spacing);
  text-transform:var(--ds-label-transform);
  color:var(--ds-ink-2);
}
.ds-gauge-value{
  margin-left:auto;
  font-family:var(--ds-font-mono);
  font-size:var(--ds-gauge-size);
  font-variant-numeric:tabular-nums;
  font-weight:600;
  line-height:1;
  color:var(--ds-ink);
}
.ds-gauge-unit{font-size:.5em; color:var(--ds-ink-3); margin-left:1px;}
.ds-gauge-track{
  height:var(--ds-gauge-track);
  border-radius:var(--ds-radius-pill);
  background:var(--ds-line-2);
  overflow:hidden;
}
.ds-gauge-fill{
  height:100%;
  border-radius:inherit;
  background:var(--ds-idle);
  transition:width var(--ds-motion) var(--ds-ease);
}
.ds-gauge[data-state="ok"]   .ds-gauge-fill{background:var(--ds-ok);}
.ds-gauge[data-state="warn"] .ds-gauge-fill{background:var(--ds-warn);}
.ds-gauge[data-state="crit"] .ds-gauge-fill{background:var(--ds-crit);}

/* -- gauge threshold: draw the cap, and the level that means it is not
      holding -----------------------------------------------------------------
   v0.6, from Lab Manager, and the case that names it is the good one:
   journald is capped at 1 GB and their alert fires at 1.25x, because journald
   FILLS to its cap and vacuums back. Sitting at 100% is journald working
   correctly -- and an unmarked bar at 100% says the opposite. So anything with
   a SELF-ENFORCED limit draws the limit, and draws the level that means the
   limit is not holding. That is most quotas.

   Position comes from --ds-at, a percentage along the track, because CSS
   cannot read a number out of an attribute. The mark is ink with a surface
   ring -- the same trick .ds-dot uses -- so it reads over the filled part of
   the track and over the empty part.

   NEVER the only carrier: .ds-gauge-scale states the same numbers in words
   underneath, which is the rule the gauge already passes for its bar. */
.ds-gauge-track{position:relative;}
/* Overhang needs the clip lifted, and only for a track that has a mark, so
   nothing moves on the gauges that do not. */
.ds-gauge-track:has(.ds-gauge-mark){overflow:visible;}
.ds-gauge-mark{
  position:absolute; top:-3px; bottom:-3px;
  left:clamp(1px, var(--ds-at,100%), calc(100% - 1px));
  width:2px; margin-left:-1px;
  border-radius:1px;
  background:var(--ds-ink);
  box-shadow:0 0 0 1px var(--ds-surface);
}
/* The cap is a fact about the system. The alert level is a judgement about
   when the fact has stopped being true, so it is heavier and it is crit. */
.ds-gauge-mark[data-kind="alert"]{
  width:3px; margin-left:-1.5px;
  background:var(--ds-crit);
}
.ds-gauge-scale{
  display:flex; flex-wrap:wrap; gap:4px var(--ds-gap);
  margin:6px 0 0;
  font-family:var(--ds-font-mono); font-size:var(--ds-label-size);
  color:var(--ds-ink-3);
}
.ds-gauge-scale > *{display:inline-flex; align-items:center; gap:6px;}
.ds-gauge-scale > *::before{
  content:""; flex:none; width:10px; height:2px; background:var(--ds-ink);
}
.ds-gauge-scale > [data-kind="alert"]::before{height:3px; background:var(--ds-crit);}

/* -- nodata: absent on purpose, not broken --------------------------------
   Three of the lab's cards read a remote box over ssh and can be absent
   entirely. Lab Manager: "no data is a real, frequent state and needs to look
   deliberate rather than broken."

   So it is drawn deliberately -- a dashed edge reads as a placeholder rather
   than a failure -- and it is idle, NEVER crit. An optional box being
   unreachable is not an emergency, and colouring it like one trains the eye to
   ignore real ones. */
.ds-nodata{
  display:flex; align-items:center; gap:10px;
  padding:var(--ds-pad-tight) var(--ds-pad);
  border:var(--ds-border) dashed var(--ds-line);
  border-radius:var(--ds-radius-sm);
  background:var(--ds-surface-2);
  color:var(--ds-ink-3);
  font-size:var(--ds-text-sm);
}
.ds-nodata::before{
  content:"\2014";                 /* the em dash they already use for null */
  font-family:var(--ds-font-mono);
  color:var(--ds-idle);
  font-weight:600;
}

/* ==========================================================================
   SLOT -- a named position in a set that is deliberately still empty
   --------------------------------------------------------------------------
   v0.8. `.ds-nodata` says THERE IS NO DATA HERE AND THAT IS NORMAL. A slot says
   something stronger and more useful: THIS SET HAS A PLACE FOR ONE MORE, HERE
   IS WHERE IT GOES, AND HERE IS WHAT IT WILL BE CALLED. Absence with an
   identity, which is a different fact from absence.

   From slosars.me, where Tony asked for "a clear border where additional public
   pages go, ghost in holding spot for John Fox Slosar". The same shape exists
   on three other surfaces already: the deployment plane that is not built, a
   rack position with nothing in it, an ID project that is staged and not
   started. Every one of them had been drawn as prose or not drawn at all.

   Why it is not `.ds-realm[data-state="planned"]`: that is a REALM, a frame
   that owns cards and is headed by a device. A slot owns nothing. It is one
   position, and its whole content is the name of what is coming.

   DASHED, like .ds-nodata, because a dashed edge reads as a placeholder rather
   than a failure. NEVER a state colour: an empty position is not a condition,
   and colouring it like one trains the eye to discount the real ones. The word
   comes from the attribute, so the label cannot disagree with the markup --
   same rule as .ds-alert's severity. */
.ds-slot{
  display:flex; align-items:center; gap:10px; flex-wrap:wrap;
  min-height:52px;
  padding:var(--ds-pad-tight) var(--ds-pad);
  border:var(--ds-border) dashed var(--ds-line);
  border-radius:var(--ds-radius-sm);
  background:transparent;
  color:var(--ds-ink-3);
}
.ds-slot::before{
  content:attr(data-slot);
  font-family:var(--ds-font-mono); font-size:var(--ds-label-size);
  letter-spacing:var(--ds-label-spacing); text-transform:var(--ds-label-transform);
  color:var(--ds-idle);
  border:var(--ds-border) solid var(--ds-line);
  border-radius:999px;
  padding:2px 9px;
}
.ds-slot-name{
  font-family:var(--ds-font-display);
  font-size:16px;
  color:var(--ds-ink-3);
}
.ds-slot-note{
  margin:0; margin-inline-start:auto;
  font-size:var(--ds-text-sm); color:var(--ds-ink-3);
}

/* ==========================================================================
   TREE -- a hierarchy you can see the shape of
   --------------------------------------------------------------------------
   v0.8, and rule 1 in GIZMOS.md is satisfied twice over: slosars.me needed one
   for a member's holdings, and Lab Manager's feature index has been a tree of
   features since before this contract existed, drawn by hand.

   IT IS NOT A TAB SET AND NOT A PLANE SWITCH. Those answer WHICH ONE AM I
   LOOKING AT. A tree answers WHAT IS IN HERE, AND HOW IS IT ARRANGED -- every
   branch is visible at once, and the shape itself is the information. That is
   why the connectors are drawn: a list of five links is not a tree, and the
   difference is the only reason to reach for this.

   Plain <ul>/<li> so it degrades to a nested list with no CSS and no script.
   The connectors are ::before/::after on the item, not characters in the
   markup, so a screen reader reads the names and not a row of box glyphs.

   Nesting is by nesting: a .ds-tree inside a .ds-tree-node indents one step and
   draws its own spine. */
.ds-tree{
  /* The connector geometry needs ONE number: how tall a branch row is. It is
     the 44px tap floor plus the node's own padding, and it is a token so the
     elbow meets the name instead of being eyeballed. A branch name is one line;
     a name that wraps makes a taller row and the elbow stays where the first
     line is, which is the right answer for a diagram of a hierarchy. */
  --ds-tree-row:50px;
  list-style:none;
  margin:0;
  padding-inline-start:0;
  font-size:var(--ds-text-sm);
}
.ds-tree .ds-tree{
  padding-inline-start:18px;
}
.ds-tree-node{
  position:relative;
  padding-inline-start:22px;
  padding-block:3px;
}
/* The spine runs the full height of every node and is cut short on the last
   one, which is what makes the elbow read as an end rather than a break. */
.ds-tree-node::before{
  content:"";
  position:absolute; inset-block-start:0; inset-block-end:0;
  inset-inline-start:5px;
  border-inline-start:var(--ds-border) solid var(--ds-line);
}
.ds-tree-node:last-child::before{
  inset-block-end:calc(100% - var(--ds-tree-row) / 2);
}
.ds-tree-node::after{
  content:"";
  position:absolute;
  inset-inline-start:5px;
  inset-block-start:calc(var(--ds-tree-row) / 2);
  inline-size:15px;
  border-block-start:var(--ds-border) solid var(--ds-line);
}
.ds-tree-link{
  display:inline-flex; align-items:center; gap:8px;
  min-height:44px;              /* it is a link. The floor ships with it. */
  color:var(--ds-ink-2);
  text-decoration:none;
  border-radius:var(--ds-radius-sm);
  padding-inline:6px;
  margin-inline-start:-6px;
}
.ds-tree-link:hover{ color:var(--ds-ink); background:var(--ds-surface-2); }
.ds-tree-count{
  font-family:var(--ds-font-mono); font-size:var(--ds-label-size);
  color:var(--ds-ink-3);
}
/* A branch that is a categorical position takes the ramp, exactly as .ds-node
   does -- WHICH branch, never how it is. */
.ds-tree-node[data-d="1"] > .ds-tree-link::before{background:var(--ds-d1);}
.ds-tree-node[data-d="2"] > .ds-tree-link::before{background:var(--ds-d2);}
.ds-tree-node[data-d="3"] > .ds-tree-link::before{background:var(--ds-d3);}
.ds-tree-node[data-d="4"] > .ds-tree-link::before{background:var(--ds-d4);}
.ds-tree-node[data-d="5"] > .ds-tree-link::before{background:var(--ds-d5);}
.ds-tree-node[data-d="6"] > .ds-tree-link::before{background:var(--ds-d6);}
.ds-tree-node[data-d] > .ds-tree-link::before{
  content:""; inline-size:7px; block-size:7px; border-radius:2px; flex:none;
}

/* ==========================================================================
   CONTROLS -- the interactive vocabulary
   --------------------------------------------------------------------------
   Read off the four educational pages, which are the working library for this:
   4004, Game of Life, 6AU6A and the core memory lab. Every one of them is a
   live simulation with a control cluster, and between them they use the same
   small set of parts over and over -- a labelled control block, a segmented
   picker, a slider with its value shown, transport buttons, a grid of
   readouts, a status line and a legend.

   None of it existed in this contract, which is why a page that needed any of
   it hand-rolled it four separate times.
   ========================================================================== */

/* -- ctl: a labelled control block. The unit those pages repeat. ----------- */
.ds-ctl{display:block; margin:var(--ds-ctl-gap) 0;}
.ds-ctl-head{
  display:flex; align-items:baseline; gap:var(--ds-gap); margin-bottom:6px;
}
.ds-ctl-head .ds-label{margin-right:auto;}
.ds-ctl-val{
  font-family:var(--ds-font-mono); font-variant-numeric:tabular-nums;
  font-size:var(--ds-text-sm); font-weight:600; color:var(--ds-ink);
}

/* -- seg: a segmented picker -----------------------------------------------
   One choice from few. The pressed state is the accent, exactly as .ds-btn
   already does, so the two never disagree about what "on" looks like. */
.ds-seg{display:flex; gap:6px; flex-wrap:wrap;}
.ds-seg .ds-btn{flex:1 1 auto;}

/* -- slider ----------------------------------------------------------------
   The simulators use these for speed, voltage and bias -- always with the
   value shown next to the label, because a handle position is not a reading.
   The 44px floor covers the input; the visible rail is deliberately thinner,
   with the padding making up the target. */
.ds-slider{
  -webkit-appearance:none; appearance:none;
  width:100%; background:transparent; cursor:pointer;
}
.ds-slider::-webkit-slider-runnable-track{
  height:var(--ds-track); border-radius:var(--ds-radius-pill);
  background:var(--ds-line);
}
.ds-slider::-moz-range-track{
  height:var(--ds-track); border-radius:var(--ds-radius-pill);
  background:var(--ds-line);
}
.ds-slider::-webkit-slider-thumb{
  -webkit-appearance:none; appearance:none;
  width:var(--ds-thumb); height:var(--ds-thumb);
  margin-top:calc((var(--ds-track) - var(--ds-thumb)) / 2);
  border-radius:var(--ds-radius-pill);
  background:var(--ds-accent); border:2px solid var(--ds-surface);
  box-shadow:var(--ds-glow);
}
.ds-slider::-moz-range-thumb{
  width:var(--ds-thumb); height:var(--ds-thumb); border-radius:var(--ds-radius-pill);
  background:var(--ds-accent); border:2px solid var(--ds-surface);
  box-shadow:var(--ds-glow);
}

/* -- switch: a two-state toggle that reads as on/off without colour alone -- */
.ds-switch{
  position:relative; display:inline-flex; align-items:center;
  width:56px; height:30px; flex:none;
  border-radius:var(--ds-radius-pill);
  background:var(--ds-line); border:var(--ds-border) solid var(--ds-line);
  cursor:pointer; padding:0;
  transition:background var(--ds-motion) var(--ds-ease);
}
.ds-switch::after{
  content:""; position:absolute; left:3px;
  width:20px; height:20px; border-radius:var(--ds-radius-pill);
  background:var(--ds-ink-3);
  transition:transform var(--ds-motion) var(--ds-ease),
             background var(--ds-motion) var(--ds-ease);
}
.ds-switch[aria-checked="true"]{background:var(--ds-accent-soft); border-color:var(--ds-accent);}
.ds-switch[aria-checked="true"]::after{transform:translateX(26px); background:var(--ds-accent);}

/* -- readouts: the grid of small value tiles the simulators all use -------- */
.ds-readouts{
  display:grid; grid-template-columns:repeat(auto-fit,minmax(84px,1fr));
  gap:8px;
}
.ds-readout{
  background:var(--ds-surface-2);
  border:var(--ds-border) solid var(--ds-line-2);
  border-radius:var(--ds-radius-sm);
  padding:8px 9px; text-align:center; min-width:0;
}
.ds-readout-k{
  display:block;
  font-family:var(--ds-font-mono); font-size:var(--ds-label-size);
  font-weight:var(--ds-label-weight); letter-spacing:var(--ds-label-spacing);
  text-transform:var(--ds-label-transform); color:var(--ds-ink-3);
}
.ds-readout-v{
  display:block; margin-top:3px;
  font-family:var(--ds-font-mono); font-variant-numeric:tabular-nums;
  font-size:17px; font-weight:600; color:var(--ds-ink);
  overflow:hidden; text-overflow:ellipsis;
}

/* -- pill: a status line, bordered in its own state ------------------------ */
.ds-pill{
  display:block; margin:8px 0;
  padding:9px 12px;
  border-radius:var(--ds-radius-sm);
  border:var(--ds-border) solid var(--ds-idle);
  background:var(--ds-idle-soft); color:var(--ds-ink);
  font-size:var(--ds-text-sm); line-height:1.45;
}
.ds-pill[data-state="ok"]{border-color:var(--ds-ok); background:var(--ds-ok-soft);}
.ds-pill[data-state="warn"]{border-color:var(--ds-warn); background:var(--ds-warn-soft);}
.ds-pill[data-state="crit"]{border-color:var(--ds-crit); background:var(--ds-crit-soft);}

/* -- viewport: the framed well a canvas or simulation lives in ------------- */
.ds-viewport{
  display:block; width:100%;
  background:var(--ds-ground);
  border:var(--ds-border) solid var(--ds-line);
  border-radius:var(--ds-radius-sm);
  overflow:hidden;
}
.ds-viewport canvas,.ds-viewport svg{display:block; width:100%; height:auto;}

/* -- legend: what each colour in a viewport means -------------------------
   A legend is the thing that makes a categorical palette legible, so it ships
   with the palette rather than after it. */
.ds-legend{display:flex; flex-wrap:wrap; gap:6px 14px; align-items:center;}
.ds-legend-item{
  display:inline-flex; align-items:center; gap:6px;
  font-size:var(--ds-text-sm); color:var(--ds-ink-2);
}
.ds-swatch{
  width:11px; height:11px; border-radius:3px; flex:none;
  background:var(--ds-d1);
  box-shadow:0 0 0 1px var(--ds-line);
}
.ds-swatch[data-d="2"]{background:var(--ds-d2);} .ds-swatch[data-d="3"]{background:var(--ds-d3);}
.ds-swatch[data-d="4"]{background:var(--ds-d4);} .ds-swatch[data-d="5"]{background:var(--ds-d5);}
.ds-swatch[data-d="6"]{background:var(--ds-d6);} .ds-swatch[data-d="7"]{background:var(--ds-d7);}
.ds-swatch[data-d="8"]{background:var(--ds-d8);}

/* -- field: a text input. Every tool needs one; four pages hand-rolled it. -- */
.ds-field{
  width:100%;
  padding:10px 12px;
  border-radius:var(--ds-radius-sm);
  border:var(--ds-border) solid var(--ds-line);
  background:var(--ds-surface); color:var(--ds-ink);
  font-family:var(--ds-font-body); font-size:var(--ds-text);
}
.ds-field::placeholder{color:var(--ds-ink-3);}

/* -- lamp: an indicator that is lit or not --------------------------------
   From core's .lamp array. Different from .ds-dot: a dot reports a state that
   is always one of four, a lamp reports a thing that is ON or OFF and whose
   colour says WHICH thing. So a lamp takes a categorical hue, not a state. */
.ds-lamp{
  display:inline-block; flex:none;
  width:12px; height:12px; border-radius:var(--ds-radius-pill);
  background:var(--ds-line);
  box-shadow:inset 0 0 0 1px var(--ds-line);
  transition:background var(--ds-motion) var(--ds-ease),
             box-shadow var(--ds-motion) var(--ds-ease);
}
.ds-lamp[aria-pressed="true"],.ds-lamp.is-on{
  background:var(--ds-d1);
  box-shadow:0 0 8px var(--ds-d1), inset 0 0 0 1px var(--ds-d1);
}
.ds-lamp[data-d="2"].is-on{background:var(--ds-d2);box-shadow:0 0 8px var(--ds-d2);}
.ds-lamp[data-d="3"].is-on{background:var(--ds-d3);box-shadow:0 0 8px var(--ds-d3);}
.ds-lamp[data-d="4"].is-on{background:var(--ds-d4);box-shadow:0 0 8px var(--ds-d4);}
.ds-lamp[data-d="5"].is-on{background:var(--ds-d5);box-shadow:0 0 8px var(--ds-d5);}
.ds-lamp[data-d="6"].is-on{background:var(--ds-d6);box-shadow:0 0 8px var(--ds-d6);}
.ds-lamps{display:flex; gap:6px; align-items:center;}

/* -- bits: a binary field, read or set ------------------------------------
   core's .bits register display and 4004's DIP switches are the same gizmo at
   two sizes. Each cell says its own value, so the row is readable without
   counting positions. */
.ds-bits{display:flex; gap:3px; flex-wrap:wrap;}
.ds-bit{
  width:24px; height:28px;
  display:flex; align-items:center; justify-content:center;
  font-family:var(--ds-font-mono); font-size:12px; font-weight:600;
  border-radius:4px;
  background:var(--ds-surface-2); color:var(--ds-ink-3);
  border:var(--ds-border) solid var(--ds-line-2);
}
.ds-bit[data-v="1"]{background:var(--ds-accent); color:var(--ds-on-fill); border-color:transparent;}
.ds-bit[data-hl]{outline:2px solid var(--ds-accent-2); outline-offset:1px;}
button.ds-bit{cursor:pointer;}

/* -- steps: where you are in a fixed sequence ----------------------------- */
.ds-steps{display:flex; gap:6px; align-items:center;}
.ds-step{
  width:9px; height:9px; border-radius:var(--ds-radius-pill);
  background:var(--ds-line); flex:none;
}
.ds-step[data-at="done"]{background:var(--ds-ok);}
.ds-step[data-at="on"]{background:var(--ds-accent); box-shadow:0 0 0 3px var(--ds-accent-soft);}

/* -- listing: a table where one row is current and some rows changed ------
   core's program listing. The current row is marked by a rail AND a
   background, because a background alone disappears at low contrast. */
.ds-listing{width:100%; border-collapse:collapse; font-family:var(--ds-font-mono);
            font-size:var(--ds-text-sm); font-variant-numeric:tabular-nums;}
.ds-listing th{
  text-align:left; padding:6px 8px;
  font-size:var(--ds-label-size); letter-spacing:var(--ds-label-spacing);
  text-transform:var(--ds-label-transform); color:var(--ds-ink-3); font-weight:var(--ds-label-weight);
  border-bottom:var(--ds-border) solid var(--ds-line);
}
.ds-listing td{padding:6px 8px; border-bottom:var(--ds-border) solid var(--ds-line-2); color:var(--ds-ink-2);}
.ds-listing tr[data-at="pc"] td{background:var(--ds-accent-soft); color:var(--ds-ink);}
.ds-listing tr[data-at="pc"] td:first-child{box-shadow:inset 3px 0 0 var(--ds-accent);}
.ds-listing tr[data-changed] td{color:var(--ds-accent-2);}

/* -- note: an aside, at three volumes ------------------------------------- */
.ds-note{
  margin:8px 0; padding-left:11px;
  border-left:3px solid var(--ds-line);
  color:var(--ds-ink-2); font-size:var(--ds-text-sm); line-height:1.5;
}
.ds-note[data-vol="loud"]{border-left-color:var(--ds-accent); color:var(--ds-ink);}
.ds-note[data-vol="quiet"]{border-left-color:var(--ds-line-2); color:var(--ds-ink-3);}
/* State on a note colours its rail, the same three as .ds-card, and it is
   declared after volume so it outranks it: a loud note reporting a fault must
   not draw the rail a loud note reporting success draws. v0.10. Until it,
   deploy.html had written data-state="crit" on notes since 2026-09-05 and
   nothing drew it, and _chrome.js's designed-read-only sentence and its fault
   sentence (W23 ask 10) rendered alike. */
.ds-note[data-state="ok"]  {border-left-color:var(--ds-ok);}
.ds-note[data-state="warn"]{border-left-color:var(--ds-warn);}
.ds-note[data-state="crit"]{border-left-color:var(--ds-crit);}

/* -- tags: a flat set of labels, none of them state ------------------------ */
.ds-tags{display:flex; flex-wrap:wrap; gap:6px;}
.ds-tag{
  font-family:var(--ds-font-mono); font-size:var(--ds-label-size);
  letter-spacing:var(--ds-label-spacing); text-transform:var(--ds-label-transform);
  padding:3px 8px; border-radius:var(--ds-radius-sm);
  background:var(--ds-surface-2); color:var(--ds-ink-2);
  border:var(--ds-border) solid var(--ds-line-2);
}

/* -- material: the translucent panel ---------------------------------------
   From todomodo.io/index.html's .glass-panel. Translucency, blur and edge
   light have to be DRAWN, so they are here; a skin only dials them. With the
   contract's flat defaults this renders what .ds-card renders, which is why
   adopting it costs a flat skin nothing.

   The ::before is the scrim -- the tinted layer between the blurred ground and
   the text. Its geometry is here; how transparent it is allowed to get is in
   the invariant layer, because glass over a busy ground is the single reason
   translucent interfaces are usually unreadable. */
.ds-mat{
  position:relative;
  isolation:isolate;
  -webkit-backdrop-filter:blur(var(--ds-mat-blur)) saturate(var(--ds-mat-sat));
  backdrop-filter:blur(var(--ds-mat-blur)) saturate(var(--ds-mat-sat));
  border:var(--ds-border) solid var(--ds-mat-edge);
  border-radius:var(--ds-elbow) var(--ds-radius) var(--ds-radius) var(--ds-radius);
  box-shadow:var(--ds-shadow);
  padding:var(--ds-pad);
  color:var(--ds-ink);
}
/* The scrim IS the glass: the tint, at an opacity the invariant layer floors.
   It sits at z-index -1 so it paints above the backdrop blur and below the
   content. There is deliberately no second, more-opaque layer -- one number
   controls how much ground shows through, so there is exactly one number to
   floor. */
.ds-mat::before{
  content:"";
  position:absolute; inset:0; z-index:-1;
  border-radius:inherit;
  pointer-events:none;
  background:var(--ds-mat-tint);
  opacity:var(--ds-mat-scrim);
}

/* -- panel: a solid block of one tone, with ink chosen to read on it -------
   The nda-review page's sidebar blocks and bar segments are all this one
   thing. data-tone selects WHICH fill; --ds-on-fill is the ink for every one
   of them. */
.ds-panel{
  display:flex; align-items:center; justify-content:center;
  text-align:center;
  padding:var(--ds-pad-tight) var(--ds-pad);
  border-radius:var(--ds-radius);
  background:var(--ds-accent);
  color:var(--ds-on-fill);
  font-family:var(--ds-font-display);
  font-weight:var(--ds-display-weight);
  letter-spacing:var(--ds-display-spacing);
  text-transform:var(--ds-display-transform);
}
.ds-panel[data-tone="accent-2"]{background:var(--ds-accent-2);}
.ds-panel[data-tone="ok"]{background:var(--ds-ok);}
.ds-panel[data-tone="warn"]{background:var(--ds-warn);}
.ds-panel[data-tone="crit"]{background:var(--ds-crit);}
.ds-panel[data-tone="idle"]{background:var(--ds-idle);}
.ds-panel[data-tone="quiet"]{
  background:var(--ds-surface-2); color:var(--ds-ink-2);
  border:var(--ds-border) solid var(--ds-line);
}
.ds-panel-fill{flex:1;}

/* -- frame: the bar / side / content chrome --------------------------------
   From todomodo.io/nda-review.html. A page-level scaffold: a bar across the
   top, optional columns of panels down each side, content in the middle, a
   bar across the bottom. The sides are DECORATION -- they carry status, never
   the only copy of anything -- which is what makes it safe for them to drop
   out below 1024px. */
.ds-frame{
  min-height:100vh;
  display:flex; flex-direction:column;
  gap:var(--ds-frame-gap);
  padding:var(--ds-frame-gap);
}
.ds-frame-bar{
  display:grid;
  grid-template-columns:var(--ds-frame-cap-w) 1fr var(--ds-frame-cap-w);
  gap:var(--ds-frame-gap);
  min-height:var(--ds-frame-bar-h);
}
.ds-frame-bar[data-where="foot"]{min-height:var(--ds-frame-foot-h);}
/* the end caps: a panel with one rounded end, which is the whole gesture */
.ds-frame-cap{border-radius:var(--ds-radius-pill);}
.ds-frame-cap[data-end="left"]{
  border-radius:var(--ds-radius-pill) var(--ds-radius) var(--ds-radius) var(--ds-radius-pill);
}
.ds-frame-cap[data-end="right"]{
  border-radius:var(--ds-radius) var(--ds-radius-pill) var(--ds-radius-pill) var(--ds-radius);
}
.ds-frame-body{
  flex:1;
  display:grid;
  grid-template-columns:var(--ds-frame-side-w) 1fr var(--ds-frame-side-w);
  gap:var(--ds-frame-gap);
  align-items:stretch;
}
.ds-frame-body[data-sides="left"]{
  grid-template-columns:var(--ds-frame-side-w) 1fr;
}
.ds-frame-body[data-sides="none"]{grid-template-columns:1fr;}
.ds-frame-side{
  display:flex; flex-direction:column; gap:var(--ds-frame-gap);
}
.ds-frame-main{
  display:flex; flex-direction:column; gap:var(--ds-frame-gap);
  min-width:0;                        /* so wide children scroll, not the page */
}
/* the elbow: the side column's head, square where it meets the column */
.ds-frame-elbow{
  border-radius:var(--ds-elbow) var(--ds-elbow) 0 0;
  min-height:var(--ds-frame-bar-h);
}

@media (max-width:1024px){
  .ds-frame-body,
  .ds-frame-body[data-sides="left"]{grid-template-columns:1fr;}
  .ds-frame-side{display:none;}
}
@media (max-width:768px){
  .ds-frame-bar{grid-template-columns:1fr;}
  .ds-frame-cap{display:none;}
}

/* ==========================================================================
   THE PAGE -- one measure, and the padding the chrome's bleed cancels
   --------------------------------------------------------------------------
   v0.7. The wrapper every plane already hand-rolled, named once so the four
   surfaces stop disagreeing by accident. See --ds-measure for why it is a
   token rather than a convention.

   A host is not obliged to use this class -- a wrapper that reads the token
   is equally correct, and that is how a plane with its own wrapper adopts the
   measure without renaming anything:

       .wrap{ max-width:var(--ds-measure); margin-inline:auto;
              padding-inline:var(--ds-pad); }

   The inline padding is part of the component, not a decoration: .ds-chrome
   bleeds by --ds-chrome-bleed, which defaults to --ds-pad, so a page padded
   at --ds-pad gets an exact cancellation and a page padded at anything else
   has to say so once. Lab Manager paid for that in v0.6.1 and paid for it
   again in the queue, where two hand-typed `margin:0 -16px` bleeds were
   correct only while nobody changed the gutter.
   ========================================================================== */
.ds-page{
  max-width:var(--ds-measure);
  margin-inline:auto;
  padding-inline:var(--ds-pad);
}

/* ==========================================================================
   PLANE CHROME -- the page's own top-level organisation
   --------------------------------------------------------------------------
   Lab Manager and Grok Build both wrote this component, independently, inside
   24 hours (~ops/dashboard/templates/plane-chrome.css and
   ID/ssec88/docs/ds/terbeaux-chrome.css -- the TREE was renamed 2026-09-02,
   the file keeps its name because Grok owns its identifiers). They are not
   identical and they are
   the same thing. That is the drift a shared grammar exists to prevent, so it
   lives here now and both of those files can lose their chrome half.

   Lab Manager also asked the right question about it: the plane switch and a
   page's view tabs currently look the same, "which may be wrong -- they are
   different actions." They are, so here they do not look the same:

     .ds-planes   LEAVES the page. Filled blocks, because a plane is a PLACE.
     .ds-tabs     changes the view WITHIN the page. Underlines, because a tab
                  is a way of looking at the place you are already in.

   A reader should be able to tell, without clicking, whether a control is
   about to unload the document.
   ========================================================================== */

/* THE ONE REQUIREMENT THIS COMPONENT PLACES ON ITS HOST, and v0.6.1 states it
   because Lab Manager paid for it not being stated.

   .ds-chrome is FULL-BLEED: it cancels its wrapper's horizontal padding with a
   negative margin and pays it back as its own padding, so the sticky bar's
   background reaches the edge of the viewport while its contents stay in the
   page's column. That cancellation is exact only when the negative margin and
   the wrapper's padding are the SAME NUMBER. Lab Manager's wrapper padded at
   0.5rem while the margin cancelled --ds-pad; they measured the sticky bar
   hanging 10px past each edge and scrolling the whole document sideways on a
   phone.

   The default is --ds-pad, which is right for a host that pads at --ds-pad --
   every plane in this tree does. A host that pads at anything else says so in
   one declaration on the WRAPPER, and nothing else has to change:

       .wrap{ padding:0 0.5rem; --ds-chrome-bleed:0.5rem; }

   The token is deliberately not a global: it is a fact about one wrapper, so
   it is declared on that wrapper. test-invariants.html measures both cases. */
.ds-chrome{
  position:sticky; top:0; z-index:30;
  background:var(--ds-ground);
  border-bottom:var(--ds-border) solid var(--ds-line);
  display:flex; flex-direction:column; gap:2px;
  padding:8px var(--ds-chrome-bleed,var(--ds-pad)) 0;
  margin:0 calc(var(--ds-chrome-bleed,var(--ds-pad)) * -1);
}
/* The title row. Identity and page name on the left, mood on the right --
   which is where Tony asked for it, and it is the correct place: the mood is a
   property OF this page, so it belongs in the same row as the page's name. */
.ds-chrome-head{
  display:flex; align-items:center; gap:var(--ds-gap); flex-wrap:wrap;
  padding-bottom:6px;
}
.ds-chrome-title{display:flex; flex-direction:column; min-width:0; margin-right:auto;}
.ds-chrome-title h1,.ds-chrome-title .ds-display{
  font-size:19px; line-height:1.2; margin:0;
}

/* -- WHAT STAYS STUCK, AND WHAT SCROLLS AWAY -------------------------------
   v0.7, and it is Lab Manager's ask 6. They measured the built dashboard at
   430x900 -- Tony's actual phone -- and got a 331px chrome: 37% of the
   viewport, following him down every page. Nothing overflowed and nothing
   scrolled sideways. The bar was simply that tall, because four legitimate
   rows were in it and two of them wrap to two lines at that width.

   Their proposed shape was a height ceiling with the overflow scrolling
   inside the bar. That is the wrong first answer: a scroll gesture inside a
   sticky bar is the least reachable control on the page, and hiding the plane
   switch behind one would be worse than the height. The ceiling belongs in
   the invariants as a backstop, not as the mechanism.

   The mechanism is a rule about WHICH ROWS STAY:

     .ds-chrome        identity, the page's name, the mood, the plane switch.
                       Where am I, and where else can I go -- the two questions
                       a reader has at any scroll position. STICKY.
     .ds-chrome-tail   the exits and the plane's posture. Both are read on
                       arrival and neither is a navigation. SCROLLS AWAY.

   The tail is a SIBLING immediately after .ds-chrome, not a child, because a
   child of a sticky element is stuck with it -- that is the whole point:

       <header>
         <div class="ds-chrome"> ... head, planes ... </div>
         <div class="ds-chrome-tail"> ... exits, posture ... </div>
       </header>

   It carries the same bleed and the same ground, so the two read as one bar
   until the page moves. The border-bottom moves to whichever is last. */
.ds-chrome-tail{
  background:var(--ds-ground);
  border-bottom:var(--ds-border) solid var(--ds-line);
  display:flex; flex-direction:column; gap:2px;
  padding:0 var(--ds-chrome-bleed,var(--ds-pad)) 4px;
  margin:0 calc(var(--ds-chrome-bleed,var(--ds-pad)) * -1) var(--ds-gap);
}
.ds-chrome:has(+ .ds-chrome-tail){border-bottom:0;}
.ds-chrome-tail .ds-exits{padding-bottom:8px;}
/* A TAIL WITH NOTHING TO SHOW COLLAPSES TO THE CHROME'S OWN BOTTOM EDGE, and it
   is not a hypothetical: the posture note is the one row every plane has and it
   is `hidden` whenever the copy CAN write, which is the common case. Our ITM
   measured a 5px empty bar drawing a stray line the moment it adopted the tail
   with nothing else in it.

   It collapses its padding rather than setting display:none, and that is the
   whole trick: the tail keeps its border, which is exactly the border the
   chrome gave up, so the bar has one bottom edge whether the tail is drawn or
   not. Hiding it outright would need the chrome to know the tail had collapsed,
   and a nested :has() is invalid CSS -- the browser drops the whole rule, which
   is how this was found: both chromes measured a border and the console grew a
   second line.

   A tail holds ROWS, so the test is an element child that is not hidden. */
.ds-chrome-tail:not(:has(> :not([hidden]))){padding-bottom:0;}

/* -- the plane's posture, as a first-class slot ----------------------------
   v0.7, Lab Manager's ask 8. `_chrome.js` finds [data-ds-can-why] by attribute
   anywhere in the document, so a host CAN put the sentence anywhere -- and the
   question was whether the chrome is where it belongs. It is, for every
   context rather than for theirs: every plane can be read in a posture that
   withdraws its controls (served by its service, served as static files,
   saved to disk), and the sentence explaining why is a fact about the PLANE,
   which is what this component is for. A reader who cannot find a Save button
   should not have to scroll to learn that this copy has none.

   It sits in the tail, not the sticky part: it is read once on arrival.

   TWO SENTENCES, NOT ONE, and that is the second half of their ask. sayWhy()
   sets textContent, so a host could not add a line of its own beside ours --
   theirs would have said WHY a control is gone and could not say WHERE TO GO
   INSTEAD, which for the dashboard is a real address on loopback. So the slot
   has two children: .ds-why-what is written by _chrome.js and is ours, and
   anything the host marks [data-ds-can-where] is left alone. */
.ds-chrome-posture{margin:0 0 6px;}
.ds-chrome-posture .ds-why-what{display:block;}

/* -- the plane's SYSTEM MESSAGES, as a slot in the chrome -- v0.10 ----------
   Lab Manager's ask 10b, and Tony's: "a simple system message capability for
   each pane ... consistent across all O&I planes." The posture note above is
   one system message, and it is HIDDEN whenever the copy can write -- so a
   plane with a writable copy had nowhere at all to say anything about itself,
   and Lab Manager built a second host out of a plain div and .ds-note rather
   than invent a ds- class. This names it.

   A container in the tail for .ds-note rows, written by DS.notices.say(id,
   text, {vol, state}) -- each row carries the note's own volume and, since
   v0.10, its state. Ordered by first raise, so a message that re-states
   itself does not jump under the reader's eye. Empty and hidden until
   something is said; the tail collapses around it.

   In the tail, not the sticky part, on the same argument as the posture: a
   system message is read on arrival. A message urgent enough to follow the
   reader down the page is a .ds-alert on the card it is about -- a chrome
   banner cannot say WHICH thing is wrong. */
.ds-chrome-notices{display:flex; flex-direction:column; gap:2px; margin:0 0 6px;}
.ds-chrome-notices > .ds-note{margin:0;}
.ds-chrome-posture [data-ds-can-where]{
  display:block; margin-top:3px;
  color:var(--ds-ink-3); font-size:var(--ds-text-sm);
}

/* -- plane switch: a destination. Leaves the page. ------------------------- */
.ds-planes{display:flex; gap:6px; flex-wrap:wrap; padding-bottom:8px;}
.ds-plane{
  display:inline-flex; align-items:center; gap:7px;
  padding:10px 14px;
  border-radius:var(--ds-radius-sm);
  border:var(--ds-border) solid var(--ds-line);
  background:var(--ds-surface-2); color:var(--ds-ink-2);
  font-family:var(--ds-font-mono); font-size:var(--ds-label-size);
  font-weight:var(--ds-label-weight); letter-spacing:var(--ds-label-spacing);
  text-transform:var(--ds-label-transform); text-decoration:none;
  transition:background var(--ds-motion) var(--ds-ease);
}
.ds-plane:hover{background:var(--ds-surface); color:var(--ds-ink);}
.ds-plane[aria-current="page"]{
  background:var(--ds-accent); color:var(--ds-on-fill);
  border-color:transparent; box-shadow:var(--ds-glow);
}
/* A plane that is not reachable right now -- its service is down -- is said
   so, not silently styled dead. */
.ds-plane[aria-disabled="true"]{
  opacity:.55; cursor:not-allowed;
  border-style:dashed; background:transparent;
}

/* -- tab set: a view. Never navigates. ------------------------------------- */
.ds-tabs{
  display:flex; gap:0; flex-wrap:wrap;
  border-bottom:var(--ds-border) solid var(--ds-line);
}
.ds-tab{
  padding:11px 15px;
  border:0; border-bottom:3px solid transparent;
  border-radius:0;
  background:transparent; color:var(--ds-ink-2);
  font-family:var(--ds-font-body); font-size:var(--ds-text-sm); font-weight:500;
  cursor:pointer; margin-bottom:calc(var(--ds-border) * -1);
  transition:color var(--ds-motion) var(--ds-ease),
             border-color var(--ds-motion) var(--ds-ease);
}
.ds-tab:hover{color:var(--ds-ink);}
.ds-tab[aria-selected="true"]{
  color:var(--ds-ink); border-bottom-color:var(--ds-accent); font-weight:600;
}
/* A panel hidden by `hidden` is out of the a11y tree already; `inert` is here
   for the case where a tab panel is animated out rather than removed, so its
   controls cannot be tabbed into while invisible. */
.ds-tabpanel[hidden]{display:none;}

/* -- a tab set down the side ----------------------------------------------
   v0.7, Lab Manager's ask 4, from Tony: "create side vertical mood tabs with
   vertical text -- that may be better for mobile" on the three cards that
   carry several tab sets.

   IT IS NOT BETTER ON MOBILE, AND THAT IS MEASURED, NOT ASSUMED. Lab Manager
   measured a vertical tab column on a 430px phone at about 44px of a 398px
   content box, against a panel that is the part needing the room. It reads
   well on a wide screen and badly on the screen Tony actually uses. So the
   orientation ships, and it FALLS BACK TO THE HORIZONTAL ROW below 560px of
   its own container -- one build, correct at both ends, and the host writes no
   media query. That was their §4 preference for the group name and it is right
   here too: a component that only works at one width should carry the other
   width itself.

   The container is the tab set, not the window, for the reason the card
   container-query block already gives: the same card is a third of a 27-inch
   screen and the whole of a phone.

   ── THE LABELS ARE SET HORIZONTALLY, AND THIS IS A RULING ──────────────────
   Tony asked for vertical text here as well as on the group rail. Vertical
   text ships on the group name (see .ds-group) and not on a tab, because the
   two are read differently: a group name is read ONCE, to locate yourself, and
   a tab is read repeatedly, to choose. Rotated text costs reading speed on
   every glance, which is affordable for the first kind and not for the second
   -- and a tab column is already the arrangement that gives a label room to be
   horizontal. The saving he was after is vertical space, and a side column
   spends none of it.

   ── THE ONE REQUIREMENT THIS PLACES ON ITS HOST ───────────────────────────
   The tablist and its panels must be SIBLINGS inside .ds-tabset, because a
   tab column and its panel are two columns of one grid and CSS cannot lay out
   two elements that share no parent:

       <div class="ds-tabset">
         <div class="ds-tabs" role="tablist" data-orient="vertical"> ... </div>
         <section class="ds-tabpanel" role="tabpanel"> ... </section>
       </div>

   .ds-tabset costs a horizontal tab set nothing -- it stacks, which is what a
   tablist and its panels do anyway -- so a host may wrap both orientations and
   change one attribute to switch. */
.ds-tabset{
  display:grid;
  grid-template-columns:auto minmax(0,1fr);
  column-gap:var(--ds-gap);
  container-type:inline-size; container-name:ds-tabset;
}
.ds-tabset > *{grid-column:1/-1;}
.ds-tabset > .ds-tabs[data-orient="vertical"]{grid-column:1; grid-row:1;}
.ds-tabset:has(> .ds-tabs[data-orient="vertical"]) > .ds-tabpanel{
  grid-column:2; grid-row:1;
}

.ds-tabs[data-orient="vertical"]{
  flex-direction:column; flex-wrap:nowrap; align-items:stretch;
  min-width:9rem;
  border-bottom:0;
  border-inline-end:var(--ds-border) solid var(--ds-line);
}
.ds-tabs[data-orient="vertical"] .ds-tab{
  text-align:start;
  border-bottom:0; margin-bottom:0;
  border-inline-end:3px solid transparent;
  margin-inline-end:calc(var(--ds-border) * -1);
}
.ds-tabs[data-orient="vertical"] .ds-tab[aria-selected="true"]{
  border-inline-end-color:var(--ds-accent);
}

/* Below 560px of its own container the column costs more than it returns, so
   it is the row again -- same markup, same mounted tablist, same arrow keys. */
@container ds-tabset (max-width: 560px){
  .ds-tabs[data-orient="vertical"],
  .ds-tabpanel{grid-column:1/-1 !important; grid-row:auto !important;}
  .ds-tabs[data-orient="vertical"]{
    flex-direction:row; flex-wrap:wrap; min-width:0;
    border-inline-end:0;
    border-bottom:var(--ds-border) solid var(--ds-line);
  }
  .ds-tabs[data-orient="vertical"] .ds-tab{
    border-inline-end:0; margin-inline-end:0;
    border-bottom:3px solid transparent;
    margin-bottom:calc(var(--ds-border) * -1);
  }
  .ds-tabs[data-orient="vertical"] .ds-tab[aria-selected="true"]{
    border-bottom-color:var(--ds-accent);
  }
}

/* -- the view bar, and the sticky stack under the chrome -------------------
   v0.9, and it is Tony's ruling of 2026-09-09 for every O&I plane in every
   context: the row that switches VIEW stays at the top of the window, so a
   reader who is deep inside one view can move to another and keep the place
   they were scrolled to.

   THE TERM IS "VIEW", AND IT ALREADY EXISTED. Tony asked for a name for these
   rows -- "sub-context tab rows, for lack of a better term" -- and GRAMMAR.md
   §2 has named the level since v0.3: a PLANE is which surface you are on, a
   VIEW is which way of looking at that surface. So the bar that carries the
   view switch is .ds-viewbar and nothing new is invented. The word to avoid is
   "tab", which by itself does not say which of the two levels is meant, and
   that ambiguity is the reason the two were once drawn identically.

   WHAT STICKS AND WHAT DOES NOT, because the answer is not "everything":

     .ds-chrome        identity, the page's name, the mood, the plane switch
     [data-ds-stick]   any row the HOST declares must stay, in document order
     .ds-chrome-tail   the exits and the posture sentence -- these SCROLL AWAY

   The tail keeps the v0.7 ruling it was built for: a link to somebody else's
   product and a sentence explaining this copy's posture are both read once on
   arrival, and four stuck rows measured 331px of a phone. So an exit reads
   ABOVE the view bar, which is where Tony asked for it, without occupying the
   window for the rest of the session.

   THE STACK IS MEASURED, NOT TYPED. Each stuck row's `top` is the height of
   everything stuck above it, and those heights change with the viewport -- a
   page title wraps to two lines on a phone and every offset below it moves.
   _chrome.js measures the chrome and each [data-ds-stick] row and writes
   --ds-stick-top onto that row, re-measuring on resize. A typed number is
   correct until the first wrap.

   IT DEGRADES BY NOT STICKING. The sticky rules are gated on
   :root[data-ds-stack], which _chrome.js sets only once it has measured, so a
   page served without the script -- a saved copy, a host that took the CSS and
   not the JS -- renders every row in place and in order. Nothing overlaps and
   nothing is hidden under the chrome, which is what a hard-coded fallback
   offset would have produced. */
:root[data-ds-stack] [data-ds-stick]{
  position:sticky;
  top:var(--ds-stick-top,0px);
  z-index:29;                          /* under .ds-chrome's 30, over content */
  background:var(--ds-ground);
  border-bottom:var(--ds-border) solid var(--ds-line);
  /* Full bleed, for the same reason .ds-chrome is: a stuck row whose ground
     stops at the page column lets content scroll past in the gutters beside
     it. Same token, so a host that declared its gutter once has declared it
     for the whole stack. */
  padding-inline:var(--ds-chrome-bleed,var(--ds-pad));
  margin-inline:calc(var(--ds-chrome-bleed,var(--ds-pad)) * -1);
}
/* A live notice is the system's message about this page, and Tony's ruling
   puts it with the header. It carries margins meant for a paragraph in the
   flow; stuck to the top of the window they read as a gap in the chrome. */
:root[data-ds-stack] .ds-live[data-ds-stick]{
  margin-block:0;
  padding-block:7px;
}

/* THE BAR ITSELF is .ds-frame's bar, which is what Tony asked for: end caps
   with one rounded end, the tab row spanning the middle. The frame's own
   grid is repeated here rather than reused, because .ds-frame-bar is a child
   of a full-page .ds-frame scaffold and this bar is not -- it sits in an
   ordinary document flow under the chrome. The caps drop below 768px, which
   is the breakpoint .ds-frame already uses for the same decision: an end cap
   is decoration and the tab row is the control. */
.ds-viewbar{
  display:grid;
  grid-template-columns:var(--ds-frame-cap-w) 1fr var(--ds-frame-cap-w);
  gap:var(--ds-frame-gap);
  align-items:stretch;
  padding-block:6px;
  margin-block:0 var(--ds-gap);
  background:var(--ds-ground);
}
.ds-viewbar > .ds-frame-cap{
  min-height:var(--ds-frame-bar-h);
  font-size:var(--ds-label-size);
  letter-spacing:var(--ds-label-spacing);
}
/* The bar draws the edge now, so the row inside it must not draw a second one
   two pixels above. */
.ds-viewbar > .ds-tabs{border-bottom:0; align-items:stretch;}
.ds-viewbar > .ds-tabs .ds-tab{margin-bottom:0;}
@media (max-width:768px){
  .ds-viewbar{grid-template-columns:1fr;}
  .ds-viewbar > .ds-frame-cap{display:none;}
}

/* -- exits: leaving THE SYSTEM, not the page ------------------------------
   v0.6, and it is Lab Manager's argument one level further out than the one
   this section already makes. Three levels were drawn -- a plane leaves the
   page, a tab does not, a facet filters -- and there is a fourth on nearly
   every plane: a link to somebody else's product on somebody else's origin.
   Theirs are the OPNsense GUI and Netdata Cloud; Web Studio's is the
   Cloudflare dashboard; Industrial Design's will be vendor documentation.

   On their page today those are drawn as filled blocks, which is the plane
   switch, so the header promises that Netdata is one of their planes. It is
   not, and the reader should be able to see that without clicking.

   So an exit is DASHED and it NAMES ITS HOST. Dashed is already this
   contract's mark for "we did not draw what is on the other side of this" --
   .ds-portlet and .ds-nodata both use it -- and the host is what makes the
   departure legible rather than a surprise tab.

   It is never filled and never accented: fill means place, and this is not a
   place in this system. */
.ds-exits{display:flex; gap:6px; flex-wrap:wrap; align-items:center;}
.ds-exit{
  display:inline-flex; align-items:center; gap:8px;
  padding:8px 12px;
  border:var(--ds-border) dashed var(--ds-line);
  border-radius:var(--ds-radius-sm);
  background:transparent; color:var(--ds-ink-2);
  font-family:var(--ds-font-body); font-size:var(--ds-text-sm);
  text-decoration:none;
}
.ds-exit:hover{color:var(--ds-ink); border-color:var(--ds-ink-3);}
.ds-exit::after{
  content:"\2197";              /* north-east arrow: it leaves */
  flex:none; font-size:13px; line-height:1; color:var(--ds-ink-3);
}
.ds-exit-host{
  font-family:var(--ds-font-mono); font-size:var(--ds-label-size);
  color:var(--ds-ink-3); white-space:nowrap;
}
/* -- an exit that is also where the readings CAME FROM ---------------------
   v0.7, Lab Manager's ask 7. Tony called their OPNsense GUI and Netdata Cloud
   links "source panes" and asked for a different motif to tell them apart from
   an ordinary exit.

   THE RULING IS ONE COMPONENT, NOT TWO. The reader's question at that control
   is unchanged -- am I about to leave this system -- and dashed-plus-host is
   the answer to it. A separate component would have to re-answer that question
   in a second visual language, and two vocabularies for one departure is how a
   reader stops trusting either.

   But the relation IS real and it is not derivable from the link: OPNsense and
   netdata are where this page's numbers come from, which is the same fact the
   provenance foot states as `Source: status.json`. So it is DECLARED on the
   exit and SAID IN A WORD, taken from the attribute so it cannot disagree with
   the markup -- the same arrangement .ds-alert's severity badge uses:

       <a class="ds-exit" data-rel="source" href="...">OPNsense GUI
          <span class="ds-exit-host">10.0.0.1</span></a>

   Everything else about it is unchanged: dashed, hosted, never filled, never
   accented, and in the 44px floor. Any other data-rel value labels itself the
   same way, which is what makes this a slot rather than one special case. */
.ds-exit[data-rel]::before{
  content:attr(data-rel);
  flex:none;
  font-family:var(--ds-font-mono); font-size:var(--ds-label-size);
  font-weight:var(--ds-label-weight); letter-spacing:var(--ds-label-spacing);
  text-transform:var(--ds-label-transform);
  color:var(--ds-ink-3);
  padding-inline-end:8px;
  border-inline-end:var(--ds-border) solid var(--ds-line);
}

/* In the chrome head an exit row is secondary to the plane switch, and says
   so by sitting after it in the same column at a quieter weight. */
.ds-chrome .ds-exits{padding-bottom:8px;}

/* ==========================================================================
   REALM -- a frame that OWNS cards
   --------------------------------------------------------------------------
   v0.6, and it is the component Lab Manager pushed hardest for. .ds-frame is
   page chrome -- bar, caps, side columns. .ds-card groups controls. Neither
   of them is "a bordered container with its own head bar, its own status, and
   ordinary cards inside it", so they invented .realm / .realm-head /
   .realm-name / .realm-sub locally, which is the drift a shared grammar
   exists to stop -- the same way the plane switch got written twice in 24
   hours.

   It is a TEMPLATE rather than one page's layout, which is what makes it a
   component: the apartment AP heads one, a pocket router will head a
   lab-restricted realm, an unmanaged switch will head an AV chain.

   ── THE HEAD BAR HAS NO CHEVRON, ON PURPOSE ───────────────────────────────
   The frame never collapses. Only the cards inside it do. So the head is a
   <header>, not a <summary>, and it is not a control -- which is also why it
   is the one head bar in this contract with no tap-target floor to meet.

   ── THE DECISION LAB MANAGER ASKED FOR: THE FRAME CARRIES ITS OWN STATE ────
   Yes, and it is not a roll-up of the cards inside it.

   A roll-up is derivable -- anything that can read the cards can compute the
   worst one -- and double-encoding a derived value is how two numbers on one
   page start disagreeing. What is NOT derivable from anything inside is the
   state of the thing the realm is NAMED FOR: the AP, the router, the switch.
   That head device can be down while every card in the realm still holds the
   last good reading, and that is exactly the situation a reader must not
   misread. So:

     .ds-realm[data-state]        the HEAD DEVICE -- reachable, degraded, down
     .ds-card[data-state] inside  each card's own subject, unchanged

   `planned` is the fifth value and the one that answers "a realm can exist
   while containing nothing at all": declared in the register, not connected
   yet. It is drawn like .ds-nodata -- dashed, quiet, idle -- and NEVER as
   crit, for the same reason: a thing that is not there on purpose is not an
   emergency, and colouring it like one trains the eye to ignore real ones.

   The state is never colour alone here either: the head carries a .ds-dot and
   the word beside it, which is the rule .ds-card already follows.
   ========================================================================== */

.ds-realm{
  border:var(--ds-border) solid var(--ds-line);
  border-left-width:calc(var(--ds-border) + 3px);
  border-left-color:var(--ds-line);
  border-radius:var(--ds-elbow) var(--ds-radius) var(--ds-radius) var(--ds-radius);
  background:var(--ds-surface-2);
  overflow:hidden;
}
.ds-realm[data-state="ok"]  {border-left-color:var(--ds-ok);}
.ds-realm[data-state="warn"]{border-left-color:var(--ds-warn);}
.ds-realm[data-state="crit"]{border-left-color:var(--ds-crit);}
.ds-realm[data-state="idle"]{border-left-color:var(--ds-idle);}
/* Declared, not connected. Dashed and idle -- see the block comment. */
.ds-realm[data-state="planned"]{
  border-style:dashed; border-left-color:var(--ds-line);
  background:transparent;
}
.ds-realm[data-state="planned"] .ds-realm-head{background:transparent;}
.ds-realm[data-state="planned"] .ds-realm-name{color:var(--ds-ink-3);}

.ds-realm-head{
  display:flex; align-items:center; gap:10px; flex-wrap:wrap;
  padding:var(--ds-pad-tight) var(--ds-pad);
  border-bottom:var(--ds-border) solid var(--ds-line);
  background:var(--ds-surface);
}
.ds-realm-title{display:flex; flex-direction:column; min-width:0; margin-right:auto;}
.ds-realm-name{
  margin:0;
  font-family:var(--ds-font-display); font-size:16px; line-height:1.25;
  font-weight:var(--ds-display-weight);
  letter-spacing:var(--ds-display-spacing);
  text-transform:var(--ds-display-transform);
  color:var(--ds-ink);
}
.ds-realm-sub{
  margin:0; font-size:var(--ds-text-sm); color:var(--ds-ink-3);
  font-variant-numeric:tabular-nums;
}
/* The cards it owns. A realm is the only place in this contract that lays out
   other components, and it does it with the same auto-fit grid a dashboard
   section uses, so a realm and a plain section do not disagree at any width. */
.ds-realm-body{
  display:grid; gap:var(--ds-gap);
  grid-template-columns:repeat(auto-fit,minmax(240px,1fr));
  padding:var(--ds-pad);
}
/* A REALM BODY IS THE GRID, and v0.7 gives the frame-level rows their span
   rather than making every host type it.

   Lab Manager paid for the first half: they put their own .card-grid inside
   this one, got one cell, and drew a seven-card device list in the right half
   of the frame with an empty column down the left. The comment above already
   said it and they read it and nested anyway, which is what makes it worth
   drawing rather than restating.

   The second half is the ask underneath that: a realm carries rows that are
   about the WHOLE frame -- a facet row, a note saying the poll has stopped, an
   alert -- and those are not cards and must not be laid out as one. Anything
   in this body that is not a card IS such a row, and spans. A host that wants
   two things side by side puts them in one child. */
.ds-realm-body > :not(.ds-card):not(.ds-mat){grid-column:1/-1;}

/* ==========================================================================
   GROUP -- a frame that names a SUBJECT
   --------------------------------------------------------------------------
   v0.7, and it is Lab Manager's asks 1, 2 and 3 together, from Tony's mark on
   two of their three card groups: "modify vertical outer frame by 1. narrowing,
   2. applying mood, and 3. changing frame name to vertical text."

   What they had for a group was a bare label over an unrelated grid -- no
   frame at all. What he asked for is .ds-realm's rail, narrower, in the mood
   hue, with the group's name running up it instead of sitting in a head bar.
   The horizontal head costs a full row of vertical space to say one word,
   which on a phone is the row a reading could have used.

   ── ASK 2: A GROUP RAIL CARRIES MOOD. A REALM RAIL CARRIES STATE. ─────────
   That is the ruling, and Lab Manager's reading of it was right:

     .ds-realm   names a DEVICE. The AP, the router, the switch. That device
                 has a state nothing inside can derive, and it can be down
                 while every card inside holds its last good reading. STATE.
     .ds-group   names a SUBJECT. Infrastructure, Systems & operations. A
                 subject has no health of its own; the only state available is
                 a roll-up of the cards, which is derivable, and
                 double-encoding a derived value is how two numbers on one
                 page start disagreeing. MOOD.

   TWO COMPONENTS, NOT ONE WITH TWO RAIL SOURCES. Lab Manager preferred two and
   the reason is the one that decides it: a single component whose rail means
   health here and identity there has to be explained to a reader at every
   frame, and the explanation is not on the page.

   So .ds-group has NO data-state and never will. It cannot go red. If a group
   of cards needs to raise something, the card raises it, or the plane raises a
   .ds-alert -- both of which already exist and both of which say WHICH thing
   is wrong, which a frame-level colour cannot.

   ── WHICH HUE, WHEN THERE ARE THREE GROUPS ON ONE PAGE ────────────────────
   A mood is a property of the PLANE and lives on :root, so all three groups on
   one page share one identity hue -- which is correct, and is what the default
   does. Three frames that need telling apart take a position on the
   CATEGORICAL ramp instead, with data-d, exactly as .ds-node does. That ramp
   is anchored on the mood hue and moves with it, so a page in `alert` and the
   same page in `survey` both stay coherent, and nothing here has reached for
   --ds-ok/warn/crit to do it.

   ── ASK 3: THE THREE FLOORS A VERTICAL NAME TOUCHES ───────────────────────
   1. THE RAIL IS NOT A CONTROL, so "narrow" and "44px" do not collide. Same
      ruling as .ds-realm's head bar and for the same reason: the frame never
      collapses, only the cards inside it do. That is what buys the rail the
      right to be 28px. And it is enforced rather than asserted -- the
      invariants give any control placed in a rail a 44px MINIMUM WIDTH, and
      the rail column is auto-sized, so a host that makes the rail tappable
      gets a 44px rail whether it wanted one or not. You may narrow it exactly
      as far as it is not a control.
   2. A GROUP NAME IS NEVER CLIPPED. The rail grows to fit it: the name sets
      the frame's minimum height rather than being truncated, so the failure
      mode of a long name is a taller frame and never a name you cannot read.
      Truncating a value is a defect; truncating the name of the thing you are
      looking at is a different and worse one. In the invariants.
   3. THE FALLBACK IS IN THE COMPONENT, not in every host's media query, which
      was Lab Manager's stated preference and is right. It is a CONTAINER
      query, not a media query, because the thing that decides is the frame's
      own width -- the same argument the card container-query block makes. On
      Tony's 430px phone a group measures about 398px and KEEPS the vertical
      rail, which is the whole point of his request: 28px of width bought in
      exchange for a row of height. Below 320px, where the rail stops being a
      good trade, it becomes a horizontal head.
   ========================================================================== */

.ds-group{
  display:grid;
  grid-template-columns:auto minmax(0,1fr);
  border:var(--ds-border) solid var(--ds-line);
  border-radius:var(--ds-elbow) var(--ds-radius) var(--ds-radius) var(--ds-radius);
  background:var(--ds-surface-2);
  overflow:hidden;
  container-type:inline-size; container-name:ds-group;
  --ds-group-hue:var(--ds-accent);
}
.ds-group[data-d="1"]{--ds-group-hue:var(--ds-d1);}
.ds-group[data-d="2"]{--ds-group-hue:var(--ds-d2);}
.ds-group[data-d="3"]{--ds-group-hue:var(--ds-d3);}
.ds-group[data-d="4"]{--ds-group-hue:var(--ds-d4);}
.ds-group[data-d="5"]{--ds-group-hue:var(--ds-d5);}
.ds-group[data-d="6"]{--ds-group-hue:var(--ds-d6);}
.ds-group[data-d="7"]{--ds-group-hue:var(--ds-d7);}
.ds-group[data-d="8"]{--ds-group-hue:var(--ds-d8);}

/* The rail. `writing-mode` sits HERE rather than on the name, so the name and
   the count share one axis.

   v0.7.2 REVERSES THE ROTATION, and it is Tony's, reading the adopted dashboard:
   "the text 'rotation' needs to be rotated 180 degrees -- bottom of text content
   should be towards center of element."

   v0.7 shipped bare `vertical-rl`, which rotates latin 90 degrees CLOCKWISE: on
   a rail down the left side of a frame, the feet of the glyphs face OUT and
   their tops face in, and the name reads top-to-bottom. The v0.7 comment here
   argued for that on the grounds that the first word should be where the eye
   already is. Tony has now read both on a real page and ruled the other way,
   which is the input that decides it -- the rail is read by tilting the head,
   and every other vertical label a reader meets (a book spine, a chart's y
   axis) tilts it the same way.

   So: bottom-to-top, glyph feet toward the frame's centre, and the name at the
   BOTTOM of the rail because that is where a bottom-to-top reading starts.

   rotate(180deg) RATHER THAN `writing-mode:sideways-lr`, which is the same
   rendering said semantically and in one property. Measured, and the reason is
   support rather than taste: `sideways-lr` is not universal, and a value a
   browser does not know is a DROPPED DECLARATION -- so that spelling renders
   the old direction on some browsers and the new one on others. One contract,
   N surfaces, three consumers and two public domains: a rotation that depends
   on the reader's browser is worse than a transform. A 180 degree rotation is
   size-preserving, so the rail's intrinsic height still comes from the name and
   every measurement in the invariants still measures what it did.

   THE BORDER WENT WITH IT, and it was already on the wrong edge. `border-inline-end`
   resolves against THIS element's writing mode, not the group's: under
   vertical-rl the rail's inline-end is its BOTTOM, so the seam line meant for
   the rail/body edge had been painting a 1px line along the bottom of the frame
   since v0.7, invisible under the frame's own border. Nobody saw it, including
   us, and the rotation would have moved it to the TOP of the rail where it
   would have been very visible indeed. Deleted rather than relocated: the rail
   is a filled block, the fill IS the seam, and a release that has run for a week
   with no divider is evidence the divider was not doing anything. The head-bar
   fallback keeps its own explicit `border-bottom`, which does separate two
   things that are both on the ground. */
.ds-group-rail{
  writing-mode:vertical-rl;
  transform:rotate(180deg);
  display:flex; flex-direction:row; align-items:center; gap:10px;
  min-width:28px;
  padding:var(--ds-pad-tight) 5px;
  background:var(--ds-group-hue);
  color:var(--ds-on-fill);
}
.ds-group-name{
  margin:0;
  font-family:var(--ds-font-display);
  font-size:14px; line-height:1.1;
  font-weight:var(--ds-display-weight);
  letter-spacing:var(--ds-label-spacing);
  text-transform:var(--ds-label-transform);
  white-space:nowrap;
}
.ds-group-count{
  font-family:var(--ds-font-mono);
  font-size:var(--ds-label-size);
  letter-spacing:var(--ds-label-spacing);
  text-transform:var(--ds-label-transform);
  white-space:nowrap;
  opacity:.78;
}
/* Same grid, same minmax and same frame-level span as a realm body, so a realm
   and a group do not disagree at any width. */
.ds-group-body{
  display:grid; gap:var(--ds-gap);
  grid-template-columns:repeat(auto-fit,minmax(240px,1fr));
  padding:var(--ds-pad);
}
.ds-group-body > :not(.ds-card):not(.ds-mat){grid-column:1/-1;}

/* Below 320px of its own width the rail stops paying for itself and becomes a
   head bar. Both children span, which collapses the auto column to zero.

   `transform:none` is not tidiness, it is the whole fallback: a head bar that
   keeps the rotation is a head bar printed upside down. It was rendered that way
   once, on purpose, to see it. */
@container ds-group (max-width: 320px){
  .ds-group-rail{
    grid-column:1/-1;
    writing-mode:horizontal-tb;
    transform:none;
    padding:var(--ds-pad-tight) var(--ds-pad);
    border-bottom:var(--ds-border) solid var(--ds-line);
  }
  .ds-group-body{grid-column:1/-1;}
}

/* ==========================================================================
   MOOD SELECTOR -- the appearance control, replaced
   --------------------------------------------------------------------------
   The old control was a row of skin buttons. It asked the reader to choose a
   VISUAL LANGUAGE, which is a designer's question, not an operator's. A mood
   is an operator's question, because a mood MEANS something -- alert level,
   health, priority, semantic grouping -- and the system can raise it on its
   own when an event says so.

   Three levels of precedence, and the control has to show which one is in
   force or an overridden page looks like a page the reader broke:

     1. system override   an event raised it. Wins. Shown, and releasable.
     2. user default      what this reader chose for this page. Persisted.
     3. page default      what the page declares in its markup.

   Built on the popover attribute -- no JS to open, close, dismiss on Escape,
   or trap focus, and it lands in the top layer so no z-index can bury it.
   ========================================================================== */

.ds-moodbar{display:flex; align-items:center; gap:6px; margin-left:auto;}
.ds-mood-btn{
  display:inline-flex; align-items:center; gap:8px;
  padding:9px 12px;
  border-radius:var(--ds-radius-pill);
  border:var(--ds-border) solid var(--ds-line);
  background:var(--ds-surface); color:var(--ds-ink-2);
  font-family:var(--ds-font-mono); font-size:var(--ds-label-size);
  font-weight:var(--ds-label-weight); letter-spacing:var(--ds-label-spacing);
  text-transform:var(--ds-label-transform); cursor:pointer;
}
.ds-mood-btn:hover{border-color:var(--ds-ink-3); color:var(--ds-ink);}
/* The disc is the current mood, drawn from the same formula the page is using,
   so it cannot disagree with the page. */
.ds-mood-disc{
  width:14px; height:14px; flex:none;
  border-radius:var(--ds-radius-pill);
  background:var(--ds-accent);
  box-shadow:0 0 0 1px var(--ds-line), var(--ds-glow);
}
/* The name comes from the mood's own token, so nothing holds a list. */
.ds-mood-name::after{content:var(--ds-mood-name,"Default");}
/* System override: the control says so, in the control, not in a toast that
   has already gone by the time anyone looks. */
/* v0.6: this was `data-src`, which is now the third inert declaration and
   means "what does this element READ". A mood button reads no source, and a
   grep for data-src that returns "system" is a grep that has been lied to. */
.ds-mood-btn[data-mood-src="system"]{
  border-color:var(--ds-accent); border-style:dashed;
  color:var(--ds-ink);
}
.ds-mood-btn[data-mood-src="system"]::after{
  content:"HELD"; font-size:9.5px; letter-spacing:.14em;
  padding:2px 6px; border-radius:var(--ds-radius-pill);
  background:var(--ds-accent-soft); color:var(--ds-accent);
}

.ds-mood-menu{
  border:var(--ds-border) solid var(--ds-line);
  border-radius:var(--ds-radius);
  background:var(--ds-surface); color:var(--ds-ink);
  box-shadow:var(--ds-shadow);
  padding:8px; margin:0;
  width:min(360px, calc(100vw - 24px));
  max-height:min(80vh, 560px); overflow:auto;
  /* Popovers centre themselves in the top layer by default. The control is at
     the top right, so the menu goes to the top right. Anchor positioning
     refines this where it exists; this is correct without it. */
  position:fixed; inset:auto; top:64px; right:12px; left:auto;
}
@supports (anchor-name:--a){
  .ds-mood-btn{anchor-name:--ds-mood-anchor;}
  .ds-mood-menu{
    position-anchor:--ds-mood-anchor;
    top:anchor(bottom); right:anchor(right); left:auto;
    margin-top:6px;
  }
}
.ds-mood-menu::backdrop{background:rgba(0,0,0,.28);}
.ds-mood-menu-head{
  display:flex; align-items:baseline; gap:var(--ds-gap);
  padding:4px 8px 8px;
}
.ds-mood-item{
  display:grid; grid-template-columns:auto 1fr; gap:2px 11px;
  align-items:center;
  width:100%; text-align:left;
  padding:9px 10px;
  border:var(--ds-border) solid transparent; border-radius:var(--ds-radius-sm);
  background:transparent; color:var(--ds-ink);
  font-family:var(--ds-font-body); font-size:var(--ds-text-sm);
  cursor:pointer;
}
.ds-mood-item:hover{background:var(--ds-surface-2); border-color:var(--ds-line-2);}
.ds-mood-item[aria-checked="true"]{border-color:var(--ds-accent); background:var(--ds-accent-soft);}
/* Each row draws ITS OWN hue, not the page's, by substituting one number into
   the same formula the mood block uses. Six rows, one rule. */
.ds-mood-item .ds-mood-disc{
  grid-row:span 2;
  width:26px; height:26px;
  background:oklch(var(--ds-mood-l) var(--ds-mood-c) var(--h));
  box-shadow:0 0 0 1px var(--ds-line);
}
.ds-mood-item[data-mood="alert"]  {--h:10;}
.ds-mood-item[data-mood="active"] {--h:70;}
.ds-mood-item[data-mood="steady"] {--h:130;}
.ds-mood-item[data-mood="inspect"]{--h:190;}
.ds-mood-item[data-mood="survey"] {--h:250;}
.ds-mood-item[data-mood="focus"]  {--h:310;}
.ds-mood-item b{font-weight:600; font-size:var(--ds-text);}
.ds-mood-item span{color:var(--ds-ink-3); font-size:var(--ds-text-sm); line-height:1.35;}
.ds-mood-foot{
  display:flex; gap:6px; flex-wrap:wrap; align-items:center;
  padding:8px 8px 2px; margin-top:6px;
  border-top:var(--ds-border) solid var(--ds-line-2);
}

/* ==========================================================================
   THE LIVE-SURFACE TRIO -- save-state, live-notice, ask-block
   --------------------------------------------------------------------------
   All three are Lab Manager's, asked for against a real editor, and all three
   belong to every Interactive Task Manager the planes sketch describes --
   theirs, Grok Build's, and the Web Design one that does not exist yet. Which
   is exactly the argument for designing them once.
   ========================================================================== */

/* -- save-state: five states, none of them decorative ----------------------
   clean    nothing to save. The action is off, and says why.
   dirty    edits exist only in this browser. This is the state that loses
            work, so it is the loudest of the four non-error states.
   saving   in flight.
   saved    committed to the source of truth. Transient.
   conflict the file moved underneath the page. NOT dismissible by saving --
            it needs a decision, so it offers the two decisions.               */
.ds-save{
  display:flex; align-items:center; gap:10px; flex-wrap:wrap;
  padding:var(--ds-pad-tight) var(--ds-pad);
  border:var(--ds-border) solid var(--ds-line);
  border-left-width:calc(var(--ds-border) + 3px);
  border-left-color:var(--ds-line);
  border-radius:var(--ds-radius-sm);
  background:var(--ds-surface-2);
  font-size:var(--ds-text-sm);
}
.ds-save-mark{
  width:9px; height:9px; flex:none; border-radius:var(--ds-radius-pill);
  background:var(--ds-ink-3);
}
.ds-save-text{color:var(--ds-ink-2);}
.ds-save-acts{display:flex; gap:6px; margin-left:auto; flex-wrap:wrap;}
.ds-save[data-state="dirty"]{border-left-color:var(--ds-accent); background:var(--ds-accent-soft);}
.ds-save[data-state="dirty"] .ds-save-mark{background:var(--ds-accent);}
.ds-save[data-state="dirty"] .ds-save-text{color:var(--ds-ink); font-weight:600;}
.ds-save[data-state="saving"]{border-left-color:var(--ds-idle);}
.ds-save[data-state="saving"] .ds-save-mark{background:var(--ds-idle); animation:ds-pulse 1.1s var(--ds-ease) infinite;}
.ds-save[data-state="saved"]{border-left-color:var(--ds-ok); background:var(--ds-ok-soft);}
.ds-save[data-state="saved"] .ds-save-mark{background:var(--ds-ok);}
.ds-save[data-state="conflict"]{border-left-color:var(--ds-crit); background:var(--ds-crit-soft);}
.ds-save[data-state="conflict"] .ds-save-mark{background:var(--ds-crit);}
.ds-save[data-state="conflict"] .ds-save-text{color:var(--ds-ink); font-weight:600;}

@keyframes ds-pulse{50%{opacity:.25;}}

/* -- live-notice: what makes "live" legible rather than spooky -------------
   A plane that silently re-reads its source is a page that changes under the
   reader's hands for no visible reason. One quiet line fixes that. Three
   states, and the third is the one that matters:

   synced  re-read, nothing of yours is at risk
   stale   the page has not managed to re-read recently
   moved   the SOURCE changed while you have unsaved edits. Loud.            */
.ds-live{
  display:flex; align-items:center; gap:8px;
  margin:8px 0;
  font-family:var(--ds-font-mono); font-size:var(--ds-text-sm);
  color:var(--ds-ink-3);
}
.ds-live-mark{
  width:7px; height:7px; flex:none; border-radius:var(--ds-radius-pill);
  background:var(--ds-ok);
  animation:ds-breathe 2.6s var(--ds-ease) infinite;
}
.ds-live[data-state="stale"]{color:var(--ds-warn);}
.ds-live[data-state="stale"] .ds-live-mark{background:var(--ds-warn); animation:none;}
.ds-live[data-state="moved"]{
  color:var(--ds-ink); font-weight:600;
  padding:8px 11px; border-radius:var(--ds-radius-sm);
  background:var(--ds-crit-soft);
  border:var(--ds-border) solid var(--ds-crit);
}
.ds-live[data-state="moved"] .ds-live-mark{background:var(--ds-crit); animation:ds-pulse 1.1s var(--ds-ease) infinite;}

@keyframes ds-breathe{0%,100%{opacity:1;} 50%{opacity:.35;}}

/* -- live-notice at CARD scale, not only page scale (v0.6, U1) ------------
   Lab Manager measured the reason and it is the best argument in their note.
   Their dashboard prints ONE timestamp for the whole document, and against
   the served status.json those rows were 2 minutes, 2 minutes, 3 hours, 5
   days, 6 days and 28 days old -- all six drawn under one "2 minutes ago".

   A composite document does not have one age. It has one age per subtree, and
   a page-level live-notice cannot say so. So the same component sits in a card
   head against that card's own budget.

   It needed the margin dropped and the mark shrunk; the head itself now wraps
   (see .ds-card-head) so name + freshness + state fold rather than clip on a
   phone. Nothing else changed, which is what U1 asked us to check. */
.ds-card-head .ds-live,
.ds-realm-head .ds-live,
summary.ds-card-head .ds-live{
  margin:0; font-size:12px; gap:6px; min-width:0;
}
.ds-card-head .ds-live-mark,
.ds-realm-head .ds-live-mark{width:6px; height:6px;}
/* A stale card is stale in the head, where the fold control is, so it is
   visible whether the card is open or shut. */
.ds-card-head .ds-live[data-state="moved"]{padding:4px 8px;}

/* -- ask-block: a decision waiting on a person -----------------------------
   The question, and why it matters, and the actual choices. All three, or it
   is a notification rather than a question, and notifications get ignored. */
.ds-ask{
  border:var(--ds-border) solid var(--ds-accent-2);
  border-left-width:calc(var(--ds-border) + 4px);
  border-radius:var(--ds-radius-sm);
  background:var(--ds-accent-2-soft);
  padding:var(--ds-pad-tight) var(--ds-pad);
  margin:var(--ds-gap) 0;
}
.ds-ask-q{
  margin:0; font-weight:600; font-size:var(--ds-text); color:var(--ds-ink);
  text-wrap:balance;
}
.ds-ask-why{margin:5px 0 0; font-size:var(--ds-text-sm); color:var(--ds-ink-2);}
.ds-ask-opts{display:flex; gap:6px; flex-wrap:wrap; margin-top:11px;}

/* ==========================================================================
   ALERT -- a condition the system raised, and the act of having seen it
   --------------------------------------------------------------------------
   v0.6. Lab Manager: "the central object of the only alerting surface in any
   of the four contexts, and the contract has no component for it." Correct,
   and the two neighbours it is NOT are the argument for the row:

     .ds-pill    announce   a sentence about how it is now
     .ds-ask     ask-block  a decision waiting on a person, with the choices
     .ds-alert   alert      A CONDITION THE SYSTEM RAISED

   The person's job with an alert is to understand it and decide what to do --
   not to answer a question the page asked. The fields prove the distinction
   rather than assert it: an alert carries `since` (how long this has been
   true), a `severity`, and an owning card to jump to. .ds-ask carries none of
   those and should not.

   The object, final at Lab Manager's end and drawn here field for field:

     { id, card, severity, message, detail, action, since, acknowledged }

   `severity` is warn | crit. `detail` explains what the condition MEANS,
   `action` INSTRUCTS -- and "nothing to do, this clears itself" is a
   legitimate action that has to draw as one, so the action line is drawn the
   same way whatever it says. They are told apart by weight and a rail, not by
   colour, and never by position alone.

   ── THE SEVERITY IS A WORD ────────────────────────────────────────────────
   Drawn from the attribute with content:attr(), the same arrangement
   .ds-proof uses, so the badge cannot disagree with the data. It is also an
   invariant: a skin may restyle the badge and may not empty it. Nothing in
   this system is encoded in colour alone, and an alerting surface is the last
   place to start.

   ── ACKNOWLEDGE ───────────────────────────────────────────────────────────
   GRAMMAR.md §3 has always stated one automatic mood rule -- "a plane holds
   `alert` while it carries an unacknowledged `crit`, and releases on
   acknowledgement" -- and until now NOTHING IN THE CONTRACT COULD ACKNOWLEDGE
   ANYTHING, so no plane in any context could obey it. Lab Manager found the
   hole by going looking for the gizmo to implement our own rule with.

   Acknowledgement is its own act. It does not change the condition, does not
   resolve anything, and does not answer a question: it records that a person
   has SEEN this, and that is what releases the hold. So an acknowledged alert
   STAYS ON THE PAGE -- quieter, keeping its severity rail, because the
   condition is still true -- and it grows a receipt line saying who, when,
   and with what evidence. _chrome.js does the acting and the mood hold.
   ========================================================================== */

.ds-alerts{display:flex; flex-direction:column; gap:8px;}

/* Two columns: the severity badge, then everything else. ::before is the
   first grid item, so it takes row 1 column 1 and every child is placed in
   column 2 down the rows. */
.ds-alert{
  display:grid; grid-template-columns:auto 1fr; gap:5px 10px;
  align-items:baseline;
  padding:var(--ds-pad-tight) var(--ds-pad);
  border:var(--ds-border) solid var(--ds-warn);
  border-left-width:calc(var(--ds-border) + 4px);
  border-radius:var(--ds-radius-sm);
  background:var(--ds-warn-soft);
}
.ds-alert > *{grid-column:2; margin:0;}
.ds-alert::before{
  content:attr(data-severity);
  grid-column:1; grid-row:1;
  font-family:var(--ds-font-mono); font-size:9.5px;
  letter-spacing:.12em; text-transform:uppercase; white-space:nowrap;
  padding:3px 6px; border-radius:3px;
  color:var(--ds-on-fill); background:var(--ds-warn);
}
.ds-alert[data-severity="crit"]{border-color:var(--ds-crit); background:var(--ds-crit-soft);}
.ds-alert[data-severity="crit"]::before{background:var(--ds-crit);}

.ds-alert-msg{font-weight:600; color:var(--ds-ink); text-wrap:balance;}
/* What the condition MEANS. */
.ds-alert-detail{font-size:var(--ds-text-sm); color:var(--ds-ink-2);}
/* What to DO about it. Same drawing whatever it says, including when what to
   do is nothing. */
.ds-alert-do{
  font-size:var(--ds-text-sm); font-weight:500; color:var(--ds-ink);
  border-left:2px solid var(--ds-ink-3);
  padding-left:9px;
}
.ds-alert-foot{
  display:flex; flex-wrap:wrap; align-items:center; gap:8px;
  margin-top:2px;
}
/* WHEN THIS BECAME TRUE, not when it was last checked. */
.ds-alert-since{
  font-family:var(--ds-font-mono); font-size:var(--ds-label-size);
  color:var(--ds-ink-3); font-variant-numeric:tabular-nums;
}
/* The owning card. It is a jump, not a plane and not a tab. */
.ds-alert-at{
  font-family:var(--ds-font-mono); font-size:var(--ds-label-size);
  color:var(--ds-accent); text-decoration:none;
}
.ds-alert-at::after{content:" \2192";}
.ds-alert-foot .ds-btn{margin-left:auto;}

/* Seen. The condition has not changed, so the rail has not changed either --
   only the ground quietens and the receipt line appears. */
.ds-alert[data-acknowledged]{
  background:var(--ds-surface-2);
  border-color:var(--ds-line);
  border-left-color:var(--ds-warn);
}
.ds-alert[data-acknowledged][data-severity="crit"]{border-left-color:var(--ds-crit);}
.ds-alert[data-acknowledged]::before{opacity:.75;}
.ds-alert[data-acknowledged] .ds-alert-msg{font-weight:500; color:var(--ds-ink-2);}
/* The receipt for the act of acknowledging -- section 5 of Lab Manager's
   note, applied to the first write this contract can make: an action returns
   evidence, or it does not ship. .ds-proof[data-kind="none"] is the honest
   answer when the acknowledgement went no further than this browser. */
.ds-alert-ack{
  display:flex; flex-wrap:wrap; align-items:baseline; gap:4px 8px;
  font-size:var(--ds-text-sm); color:var(--ds-ink-3);
}

/* ==========================================================================
   MECHANISM -- showing what is happening INSIDE the thing
   --------------------------------------------------------------------------
   This is the family the four educational pages are actually built out of,
   and until now the contract had no name for any of it.

     6au6a.html   an animated cross-section of a valve: cathode, grids, plate,
                  and electrons in flight between them
     4004.html    an animated datapath: PC, ROM, IR, decoder, registers, RAM,
                  with a labelled value moving from one to the next each cycle
     core.html    a plane and stack diagram where the drive currents, the
                  sense wire and the inhibit wire animate through a four-phase
                  read-restore cycle
     gol.html     the grid itself

   Four pages, four subjects, one gizmo: a DIAGRAM OF A MECHANISM, whose parts
   light when active and between which labelled quantities visibly move.

   Works identically on SVG children and HTML children, because three of those
   four pages draw in SVG and the fourth in canvas.
   ========================================================================== */

.ds-schematic{
  position:relative; display:block; width:100%;
  background:var(--ds-ground);
  border:var(--ds-border) solid var(--ds-line);
  border-radius:var(--ds-radius-sm);
  overflow:hidden;
}
.ds-schematic svg,.ds-schematic canvas{display:block; width:100%; height:auto;}

/* -- node: one part of the mechanism -------------------------------------
   Off is a real state and gets drawn, not omitted -- a part you cannot see
   when it is idle is a part whose absence you cannot reason about.
   [data-d] gives a part its categorical hue: which part, never how it is. */
.ds-node{
  fill:var(--ds-surface-2);
  stroke:var(--ds-line);
  stroke-width:1.5;
  background:var(--ds-surface-2);
  border:var(--ds-border) solid var(--ds-line);
  border-radius:var(--ds-radius-sm);
  transition:fill var(--ds-motion) var(--ds-ease),
             stroke var(--ds-motion) var(--ds-ease),
             filter var(--ds-motion) var(--ds-ease);
}
.ds-node-label{
  fill:var(--ds-ink-2); color:var(--ds-ink-2);
  font-family:var(--ds-font-mono); font-size:var(--ds-label-size);
  letter-spacing:var(--ds-label-spacing); text-transform:var(--ds-label-transform);
}
/* Lit is stroke AND fill AND a halo -- three carriers, so it survives being
   printed, being colour-blind, and being looked at from across the room. */
.ds-node[data-lit]{
  fill:var(--ds-accent-soft); background:var(--ds-accent-soft);
  stroke:var(--ds-accent); border-color:var(--ds-accent);
  stroke-width:2.5;
  filter:drop-shadow(0 0 5px var(--ds-accent));
}
.ds-node[data-lit] + .ds-node-label,.ds-node[data-lit] .ds-node-label{
  fill:var(--ds-ink); color:var(--ds-ink);
}
.ds-node[data-d="1"][data-lit]{stroke:var(--ds-d1); border-color:var(--ds-d1); filter:drop-shadow(0 0 5px var(--ds-d1));}
.ds-node[data-d="2"][data-lit]{stroke:var(--ds-d2); border-color:var(--ds-d2); filter:drop-shadow(0 0 5px var(--ds-d2));}
.ds-node[data-d="3"][data-lit]{stroke:var(--ds-d3); border-color:var(--ds-d3); filter:drop-shadow(0 0 5px var(--ds-d3));}
.ds-node[data-d="4"][data-lit]{stroke:var(--ds-d4); border-color:var(--ds-d4); filter:drop-shadow(0 0 5px var(--ds-d4));}
.ds-node[data-d="5"][data-lit]{stroke:var(--ds-d5); border-color:var(--ds-d5); filter:drop-shadow(0 0 5px var(--ds-d5));}
.ds-node[data-d="6"][data-lit]{stroke:var(--ds-d6); border-color:var(--ds-d6); filter:drop-shadow(0 0 5px var(--ds-d6));}

/* -- bus: the path a quantity travels along ------------------------------- */
.ds-bus{
  fill:none; stroke:var(--ds-line); stroke-width:2;
  transition:stroke var(--ds-motion) var(--ds-ease);
}
.ds-bus[data-active]{stroke:var(--ds-accent); stroke-width:3;}
/* A dashed march is how three of the four pages show direction. The dash is
   the animation, so reduced-motion leaves a static dashed line that still
   reads as a path -- it does not leave a blank. */
.ds-bus[data-flowing]{
  stroke-dasharray:6 7;
  animation:ds-march 900ms linear infinite;
}
.ds-bus[data-flowing][data-dir="rev"]{animation-direction:reverse;}
@keyframes ds-march{to{stroke-dashoffset:-13;}}

/* -- flow: a labelled quantity in transit --------------------------------
   4004 emits `F(pc, rom, "0x1A2", "addr")` on every cycle: this thing, of
   this kind, moved from here to there. The kind is categorical -- an address
   is not worse than a datum -- so it takes a data hue, never a state colour. */
.ds-flow{
  display:inline-flex; align-items:center; gap:5px;
  padding:2px 7px; border-radius:var(--ds-radius-pill);
  font-family:var(--ds-font-mono); font-size:12px; font-weight:600;
  font-variant-numeric:tabular-nums;
  background:var(--ds-surface); color:var(--ds-ink);
  border:var(--ds-border) solid var(--ds-d1);
  white-space:nowrap;
}
.ds-flow::before{
  content:attr(data-kind);
  font-size:9.5px; letter-spacing:.12em; text-transform:uppercase;
  color:var(--ds-ink-3);
}
.ds-flow[data-kind="addr"]  {border-color:var(--ds-d1);}
.ds-flow[data-kind="code"]  {border-color:var(--ds-d3);}
.ds-flow[data-kind="data"]  {border-color:var(--ds-d5);}
.ds-flow[data-kind="charge"]{border-color:var(--ds-d7);}
/* The moving mark, for a diagram that animates the transit rather than
   labelling it. Sized like a lamp so the two read as the same family. */
.ds-flow-dot{
  width:7px; height:7px; border-radius:var(--ds-radius-pill);
  fill:var(--ds-d1); background:var(--ds-d1);
}

/* -- callout: an annotation tied to a part -------------------------------
   6au6a draws these into an SVG <g> that a switch empties. That switch is
   `toggle-layer` below; this is what it toggles. */
.ds-callout{
  fill:var(--ds-ink-2); color:var(--ds-ink-2);
  font-family:var(--ds-font-mono); font-size:12px;
}
.ds-callout-line{stroke:var(--ds-line); stroke-width:1; fill:none;}
.ds-callout-box{
  position:absolute; z-index:2;
  padding:5px 9px; border-radius:var(--ds-radius-sm);
  background:var(--ds-surface); color:var(--ds-ink);
  border:var(--ds-border) solid var(--ds-line);
  font-size:var(--ds-text-sm); box-shadow:var(--ds-shadow);
  max-width:min(260px, 60%);
}

/* -- layers: what of the diagram is drawn --------------------------------
   core.html has three (sense wire / always show inhibit / labels), 6au6a has
   one (labels). Deliberately NOT the same gizmo as `toggle`: a toggle changes
   the SYSTEM, a layer changes only what the observer is shown. Confusing the
   two is how a demo ends up with a "labels" switch that looks like it might
   rewire the circuit. */
.ds-layers{
  display:flex; gap:6px 14px; flex-wrap:wrap; align-items:center;
  border:0; padding:0; margin:var(--ds-ctl-gap) 0; min-width:0;
}
.ds-layers > legend{
  padding:0; float:left; width:100%;
  font-family:var(--ds-font-mono); font-size:var(--ds-label-size);
  font-weight:var(--ds-label-weight); letter-spacing:var(--ds-label-spacing);
  text-transform:var(--ds-label-transform); color:var(--ds-ink-3);
  margin-bottom:6px;
}
.ds-layer{
  display:inline-flex; align-items:center; gap:8px;
  padding:8px 11px;
  border-radius:var(--ds-radius-sm);
  border:var(--ds-border) solid var(--ds-line);
  background:var(--ds-surface); color:var(--ds-ink-2);
  font-size:var(--ds-text-sm); cursor:pointer;
}
.ds-layer:has(:checked){
  border-color:var(--ds-accent); background:var(--ds-accent-soft); color:var(--ds-ink);
}
.ds-layer input{margin:0; accent-color:var(--ds-accent);}

/* -- narrate: the running commentary ---------------------------------------
   The 4004 writes a sentence per cycle ("address latch <- $2E"); core writes
   one per phase of the read-restore cycle. It is the single highest-value
   thing either page does, because it converts an animation from something you
   watch into something you can follow. An aria-live region, so it is also the
   only way a screen reader gets the simulation at all. */
.ds-narrate{
  display:block; margin:8px 0 0;
  padding:9px 12px;
  border-radius:var(--ds-radius-sm);
  background:var(--ds-surface-2);
  border-left:3px solid var(--ds-accent-2);
  font-family:var(--ds-font-mono); font-size:var(--ds-text-sm);
  color:var(--ds-ink); min-height:2.6em;
}
.ds-narrate-step{
  display:block; color:var(--ds-ink-3); font-size:var(--ds-label-size);
  letter-spacing:var(--ds-label-spacing); text-transform:var(--ds-label-transform);
  margin-bottom:2px;
}
/* Prior lines, dimmed, so the last few stay readable without scrolling. */
.ds-narrate-log{
  margin:0; padding:0; list-style:none;
  font-family:var(--ds-font-mono); font-size:12.5px; color:var(--ds-ink-3);
  max-height:8.5em; overflow:auto;
}
.ds-narrate-log li{padding:2px 0; border-bottom:var(--ds-border) solid var(--ds-line-2);}

/* -- guide: a stepped walkthrough over a live surface ---------------------
   6au6a's guided tour. Sits inside a .ds-schematic or .ds-viewport, which is
   why the positioning is here and not in the page. */
.ds-guide{
  position:absolute; left:8px; right:8px; bottom:8px; z-index:10;
  padding:12px 14px;
  border-radius:var(--ds-radius-sm);
  background:var(--ds-surface);
  border:var(--ds-border) solid var(--ds-accent);
  box-shadow:var(--ds-shadow);
}
.ds-guide[hidden]{display:none;}
.ds-guide h4{margin:0 0 4px; font-size:var(--ds-text); color:var(--ds-ink);}
.ds-guide p{margin:0 0 10px; font-size:var(--ds-text-sm); color:var(--ds-ink-2); line-height:1.45;}
.ds-guide-nav{display:flex; gap:7px; align-items:center;}
.ds-guide-nav .ds-btn{flex:1;}
.ds-guide-n{
  font-family:var(--ds-font-mono); font-size:12px; color:var(--ds-ink-3);
  min-width:44px; text-align:center; font-variant-numeric:tabular-nums;
}

/* -- example: prose that drives the live thing ---------------------------
   gol.html's taxonomy cards each end in a button that loads that pattern into
   the running simulation. It is the strongest single move in any of the four
   pages: the explanation and the instrument are not two sections of a
   document, they are one instrument. Every reference surface should be able
   to do it, so it is a gizmo. */
.ds-example{
  display:flex; flex-direction:column; gap:6px;
  padding:var(--ds-pad-tight) var(--ds-pad);
  background:var(--ds-surface);
  border:var(--ds-border) solid var(--ds-line);
  border-radius:var(--ds-elbow) var(--ds-radius) var(--ds-radius) var(--ds-radius);
  border-left-width:calc(var(--ds-border) + var(--ds-rail));
  border-left-color:var(--ds-accent-2);
}
.ds-example h4{margin:0; font-size:var(--ds-text); color:var(--ds-ink);}
.ds-example p{margin:0; font-size:var(--ds-text-sm); color:var(--ds-ink-2); flex:1;}
.ds-example .ds-btn{margin-top:6px; align-self:flex-start;}
.ds-examples{
  display:grid; gap:var(--ds-gap);
  grid-template-columns:repeat(auto-fit,minmax(190px,1fr));
}

/* -- source: text that is a program, not prose --------------------------
   4004's assembly editor. Mono, tab-stops that match the language, no
   spellcheck, and a resize handle -- a source box that wraps its lines and
   red-underlines its opcodes is a source box nobody will type into twice. */
.ds-source{
  display:block; width:100%;
  min-height:180px; resize:vertical;
  padding:11px 13px;
  border-radius:var(--ds-radius-sm);
  border:var(--ds-border) solid var(--ds-line);
  background:var(--ds-surface); color:var(--ds-ink);
  font-family:var(--ds-font-mono); font-size:var(--ds-text-sm);
  line-height:1.55; tab-size:8; white-space:pre; overflow:auto;
}
.ds-source:focus{border-color:var(--ds-accent);}

/* ==========================================================================
   PORTLET -- a surface this system did not draw
   --------------------------------------------------------------------------
   The lab dashboard wants cards whose data comes from netdata and OPNsense.
   Most of that should arrive as JSON and be drawn with the gizmos above, and
   the advice on which is which is in GRAMMAR.md. This is for the residue: a
   foreign UI genuinely worth embedding whole, where re-drawing it would be
   re-implementing someone's product.

   The rule for those is that they must LOOK foreign. A panel that borrows the
   page's chrome while obeying none of its rules -- not the tap floor, not the
   focus ring, not reduced motion -- is the one place a design system can lie
   about what it guarantees. So a portlet is drawn with a visible seam and
   says where it came from.
   ========================================================================== */

.ds-portlet{
  position:relative;
  border:var(--ds-border) dashed var(--ds-line);
  border-radius:var(--ds-radius-sm);
  background:var(--ds-surface-2);
  overflow:hidden;
}
.ds-portlet-head{
  display:flex; align-items:center; gap:8px;
  padding:7px var(--ds-pad-tight);
  border-bottom:var(--ds-border) dashed var(--ds-line);
  font-family:var(--ds-font-mono); font-size:var(--ds-label-size);
  font-weight:var(--ds-label-weight); letter-spacing:var(--ds-label-spacing);
  text-transform:var(--ds-label-transform); color:var(--ds-ink-3);
}
.ds-portlet-src{margin-left:auto; color:var(--ds-ink-3); text-transform:none; letter-spacing:0;}
.ds-portlet-body{position:relative; min-height:80px;}
.ds-portlet-body > iframe{display:block; width:100%; border:0; background:var(--ds-surface);}
/* A portlet that has not loaded is absent, not broken -- same rule as
   .ds-nodata, same reason. */
.ds-portlet[data-state="absent"] .ds-portlet-body{display:none;}

/* ==========================================================================
   FACETS -- narrow a long list to one of many
   --------------------------------------------------------------------------
   Lab Manager's feature index filters 11 branches; the same control filters
   tags, owners, hosts. Distinct from .ds-seg: a segmented picker shows a few
   choices that all fit, a facet row shows many and scrolls. Sharing one gizmo
   between them is how a 3-item picker and a 30-item filter end up with the
   same wrapping behaviour and neither works.
   ========================================================================== */

.ds-facets{
  display:flex; gap:6px; align-items:center;
  overflow-x:auto; -webkit-overflow-scrolling:touch;
  padding-bottom:4px; scrollbar-width:thin;
}
.ds-facet{
  flex:none;
  padding:8px 12px;
  border-radius:var(--ds-radius-pill);
  border:var(--ds-border) solid var(--ds-line);
  background:var(--ds-surface); color:var(--ds-ink-2);
  font-family:var(--ds-font-mono); font-size:var(--ds-text-sm);
  cursor:pointer; white-space:nowrap;
}
.ds-facet[aria-pressed="true"]{
  background:var(--ds-accent); color:var(--ds-on-fill); border-color:transparent;
}
.ds-facet-n{
  margin-left:6px; font-size:11px; color:var(--ds-ink-3);
  font-variant-numeric:tabular-nums;
}
.ds-facet[aria-pressed="true"] .ds-facet-n{color:var(--ds-on-fill); opacity:.75;}

/* ==========================================================================
   CARDS RESPOND TO THEIR COLUMN, NOT TO THE WINDOW
   --------------------------------------------------------------------------
   The same dashboard card is a third of a 27-inch screen and the whole of a
   phone, and a viewport media query cannot tell those apart -- it is measuring
   the wrong box. A container query measures the card's own width, which is the
   number that decides whether a gauge and its label fit side by side.

   This matters here specifically because these cards are meant to be read on
   a phone over WireGuard and, later, through igent.me.
   ========================================================================== */

.ds-card,.ds-mat{container-type:inline-size; container-name:ds-card;}
@container ds-card (max-width: 300px){
  .ds-readouts{grid-template-columns:repeat(auto-fit,minmax(70px,1fr));}
  .ds-gauge-value{font-size:calc(var(--ds-gauge-size) * .8);}
  .ds-save-acts,.ds-ask-opts{width:100%; margin-left:0;}
  .ds-row{row-gap:0;}
  /* The severity badge stops being a column and becomes the first line. */
  .ds-alert{grid-template-columns:1fr;}
  .ds-alert::before{grid-column:1; justify-self:start;}
  .ds-alert > *{grid-column:1;}
  .ds-alert-foot .ds-btn{margin-left:0; width:100%;}
}


/* -- link: the base <a> --------------------------------------------------
   v0.4. The contract had no <a> rule at all, so an unstyled link fell back to
   the browser's #0000EE -- on a near-black ground, unreadable. It was found by
   looking at a screenshot of slosars.me, not by the audit, because the audit
   compares tokens to each other and a browser default is neither of them.
   Every page that uses this contract had the bug and none of them knew. */
a{color:var(--ds-accent); text-underline-offset:2px;}
a:hover{color:var(--ds-accent-2);}
a:visited{color:var(--ds-accent);}
/* A link that has been given a component's own colour keeps it. */
a.ds-btn,a.ds-plane,a.ds-tab,a.ds-facet,a.ds-panel,a.ds-exit{color:inherit;}
.ds-exit{color:var(--ds-ink-2);}
.ds-plane{color:var(--ds-ink-2);}
.ds-plane[aria-current="page"]{color:var(--ds-on-fill);}

/* -- jump: a list of places on THIS page ---------------------------------
   v0.4, and it exists because building slosars.me needed one and neither
   existing gizmo was it. A jump list does not leave the page, so it is not a
   plane switch; it does not swap a view, so it is not a tab set. It scrolls.
   Naming it as its own function is what stops the next page reaching for
   .ds-planes and quietly teaching readers that a plane switch might not
   navigate. */
.ds-jump{
  display:flex; flex-wrap:wrap; gap:4px;
  padding:10px 0 2px;
  border-bottom:var(--ds-border) solid var(--ds-line-2);
}
.ds-jump a{
  display:inline-flex; align-items:center;
  padding:0 12px;
  border-radius:var(--ds-radius-sm);
  text-decoration:none;
  font-family:var(--ds-font-mono); font-size:var(--ds-label-size);
  font-weight:var(--ds-label-weight); letter-spacing:var(--ds-label-spacing);
  text-transform:var(--ds-label-transform);
  color:var(--ds-ink-2);
}
.ds-jump a:hover{background:var(--ds-surface-2); color:var(--ds-ink);}
.ds-jump a[aria-current="true"]{color:var(--ds-accent); box-shadow:inset 0 -2px 0 var(--ds-accent);}
/* Anchors have to clear the sticky chrome or a jump lands under it. */
[id]{scroll-margin-top:calc(var(--ds-frame-bar-h) + 40px);}

/* -- appearance: theme and mood, in the frame head ------------------------
   v0.4. Two controls, deliberately different weights. The MOOD is the loud one
   because it means something -- alert level, health, priority. The THEME is
   quiet because it is only a preference, and a preference does not deserve the
   same visual weight as a state. That is the whole reason they are not a
   matched pair of buttons. */
.ds-appearance{display:flex; align-items:center; gap:10px; margin-left:auto;}
.ds-themeset{
  display:inline-flex; gap:0;
  border:var(--ds-border) solid var(--ds-line);
  border-radius:var(--ds-radius-sm);
  overflow:hidden;
  background:var(--ds-surface);
}
.ds-theme-opt{
  padding:0 9px;
  border:0; border-radius:0;
  background:transparent; color:var(--ds-ink-3);
  font-family:var(--ds-font-mono); font-size:10px;
  font-weight:var(--ds-label-weight); letter-spacing:.1em;
  text-transform:uppercase; cursor:pointer;
  min-width:44px;
}
.ds-theme-opt + .ds-theme-opt{border-left:var(--ds-border) solid var(--ds-line);}
.ds-theme-opt:hover{color:var(--ds-ink); background:var(--ds-surface-2);}
.ds-theme-opt[aria-pressed="true"]{
  background:var(--ds-surface-2); color:var(--ds-ink); font-weight:700;
  box-shadow:inset 0 -2px 0 var(--ds-accent);
}
@media (max-width:520px){
  .ds-appearance{width:100%; margin-left:0;}
  .ds-themeset{margin-left:auto;}
}

/* -- provenance: what this surface is reading, and when it last read it ---
   v0.4, and it is what the footer of a plane is FOR. The planes sketch says
   every plane is live against a source of truth; that claim is unverifiable
   from the page unless the page says which source, when it last read it, and
   what version of the contract drew it. A footer that says "© 2026" on an
   instrument is a wasted row.

   Not a decoration: this is the same job .ds-live does for a single read,
   done once for the whole surface. */
.ds-provenance{
  margin-top:32px; padding:12px 0 0;
  border-top:var(--ds-border) solid var(--ds-line);
  display:flex; flex-wrap:wrap; gap:6px 18px; align-items:baseline;
  font-family:var(--ds-font-mono); font-size:12px; color:var(--ds-ink-3);
}
.ds-provenance dt{
  font-size:var(--ds-label-size); letter-spacing:var(--ds-label-spacing);
  text-transform:var(--ds-label-transform); color:var(--ds-ink-3);
  margin:0 6px 0 0;
}
.ds-provenance dd{margin:0; color:var(--ds-ink-2); font-variant-numeric:tabular-nums;}
.ds-provenance > div{display:flex; align-items:baseline; min-width:0;}


/* -- receipt: a thing that finished, kept with what proves it -------------
   From Lab Manager's live queue, which renders `.receipts` / `.receipt` /
   `.proof` under a fold headed "Every closed task, with its proof". Their
   markup carried a functional concept this grammar had no name for, and by
   rule 1 in GIZMOS.md that makes it a real gizmo rather than an invention.

   It is genuinely a fourth thing, not a variant of the three that existed:

     progress-steps   where we are           present tense
     announce         how it is now          present tense
     read-listing     rows, one is current   present tense
     RECEIPT          it finished, and here is what shows it   PAST, AUDITABLE

   The distinction earns its place because "done" without evidence is an
   assertion, and a queue full of assertions is a queue nobody can audit. Web
   Design had the same gap in its own ITM: closed tasks with nothing attached. */
.ds-receipts{display:flex; flex-direction:column; gap:6px;}
/* WRAPS, and the basis is why. A receipt row is id + what + when, and `when`
   is flex:none/nowrap, so on a narrow card it holds ~110px while the title
   squeezes: measured at 430px the title was down to 199px and the row 125px
   tall, and at 320px the row was 256px tall with the proof hash spilling its
   box. A basis on the body means the timestamp drops to its own line before
   the title is shredded, rather than after. */
.ds-receipt{
  display:flex; flex-wrap:wrap; align-items:flex-start; gap:4px 10px;
  padding:9px 12px;
  border-radius:var(--ds-radius-sm);
  background:var(--ds-surface-2);
  border-left:3px solid var(--ds-ok);
  font-size:var(--ds-text-sm);
}
.ds-receipt-id{
  font-family:var(--ds-font-mono); font-size:var(--ds-text-sm); font-weight:600;
  color:var(--ds-ink-3); flex:none; min-width:2.5rem;
}
.ds-receipt-body{display:flex; flex-direction:column; gap:2px; min-width:0; flex:1 1 15rem;}
.ds-receipt-what{color:var(--ds-ink-2);}
.ds-receipt-when{
  margin-left:auto; flex:none;
  font-family:var(--ds-font-mono); font-size:var(--ds-label-size);
  color:var(--ds-ink-3); font-variant-numeric:tabular-nums; white-space:nowrap;
}
/* Deleted but recoverable -- their `.receipt.gone`. Not crit: a task someone
   removed on purpose is not an emergency, same rule as .ds-nodata. */
.ds-receipt[data-state="void"]{
  border-left-color:var(--ds-line); opacity:.72;
}
.ds-receipt[data-state="void"] .ds-receipt-what{text-decoration:line-through;}

/* -- proof: the evidence itself ------------------------------------------
   What actually shows the thing was done. data-kind is CATEGORICAL -- a hash
   is not better than a check -- so it takes the data ramp, never a state
   colour.

   `none` is the one that matters and it is deliberately the weakest thing on
   the row: dashed, quiet, and it says so in words. A receipt with no proof is
   an assertion wearing a receipt's clothes, and the design should not help it
   pass. That is the whole reason this component is worth having. */
.ds-proof{
  display:inline-flex; flex-wrap:wrap; align-items:baseline; gap:2px 6px;
  font-family:var(--ds-font-mono); font-size:12px;
  color:var(--ds-ink-3); min-width:0;
  /* A hash has no spaces in it, so it spills a narrow card unless it is told
     it may break anywhere. The evidence staying inside its box is the whole
     point of the component. */
  overflow-wrap:anywhere;
}
.ds-proof::before{
  content:attr(data-kind);
  font-size:9.5px; letter-spacing:.12em; text-transform:uppercase;
  padding:1px 5px; border-radius:3px; flex:none;
  color:var(--ds-on-fill); background:var(--ds-d1);
}
.ds-proof[data-kind="check"]::before  {background:var(--ds-d7);}
.ds-proof[data-kind="hash"]::before   {background:var(--ds-d1);}
.ds-proof[data-kind="link"]::before   {background:var(--ds-d3);}
.ds-proof[data-kind="measure"]::before{background:var(--ds-d5);}
.ds-proof[data-kind="none"]{
  color:var(--ds-ink-3); font-style:italic;
}
.ds-proof[data-kind="none"]::before{
  content:"no proof";
  background:transparent; color:var(--ds-ink-3);
  border:1px dashed var(--ds-line); font-style:normal;
}
.ds-proof a{color:var(--ds-accent);}
.ds-receipt:has(.ds-proof[data-kind="none"]){border-left-color:var(--ds-line);}

/* -- emphasis: the glow, opt-in per skin ----------------------------------- */
.ds-glow{box-shadow:var(--ds-glow);}

/* ==========================================================================
   INVARIANTS -- genuinely unreachable from a skin, via @layer + !important
   --------------------------------------------------------------------------
   The first version of this block simply sat at the bottom of the file and the
   comment claimed a skin "cannot reach" it. That was FALSE, and a hostile-skin
   test proved it in one line: `:root[data-skin=x] .ds-btn` has specificity
   (0,2,1) and beats a bare `.ds-btn` (0,1,0) no matter which is declared last.
   The measured result was a 12px tap target and no focus ring.

   The cascade rule that actually works is the layered-important one:

     · normal declarations   -> LATER layers win; unlayered beats every layer
     · !important            -> EARLIER layers win; unlayered LOSES to any layer

   So the invariants live in the first-declared layer AND are !important. A skin
   is an ordinary unlayered stylesheet, which means:

     skin, normal            loses    (important beats normal)
     skin, !important        loses    (layered important beats unlayered)
     skin in a later layer   loses    (earlier layer wins for important)

   Tokens and components stay unlayered on purpose, so a skin overriding a
   --ds-* value still works exactly as before -- that is the whole feature.
   Only inline style or JS can defeat these now, and neither is CSS skinning.
   ========================================================================== */

/* Order was already declared at the top of this file. */
@layer ds-invariants{

  /* 44px is the floor for anything a finger has to hit. Tony's motor
     accessibility is why this is not a token: a dense skin may shrink type and
     padding, and may not shrink the target. */
  .ds-btn,
  button, [role="button"], a.ds-btn,
  /* v0.3: the plane switch is made of LINKS -- it leaves the page -- and a
     bare <a> was not in this list. Neither were the tab, facet, layer and
     mood controls, all of which are things a finger has to hit. Every one of
     them was added the same day it was drawn, which is rule 4 in GIZMOS.md:
     a new way to get accessibility wrong ships with its floor, not after. */
  a.ds-plane, .ds-plane, .ds-tab, [role="tab"],
  .ds-facet, .ds-layer, .ds-mood-btn, .ds-mood-item,
  .ds-jump a, .ds-theme-opt,
  /* v0.6: an exit is a link too, and it is the one control on the page that
     hands the reader to somebody else's product. Missing it would mean the
     only unreachable-by-finger control is the one that leaves. */
  a.ds-exit, .ds-exit,
  /* v0.8: a tree branch is a link, and a tree is a list of them. It arrived
     with its floor rather than after -- rule 4 in GIZMOS.md, and the fourth
     time this list has grown the day a component was drawn. */
  .ds-tree-link,
  input[type="checkbox"], input[type="radio"], select,
  input[type="text"], input[type="email"], input[type="number"],
  input[type="password"], input[type="search"], input[type="tel"],
  input[type="url"], input[type="date"], input[type="time"],
  input[type="datetime-local"], input[type="range"], input[type="file"],
  textarea, summary{
    min-height:44px !important;
  }
  .ds-chip{min-height:auto !important;}   /* a chip is a label, not a control */

  /* The scrim floor. Once a surface can be translucent, the ground can bleed
     through the text, and that is the single reason glass interfaces are
     usually unreadable. A skin picks the tint and how much shows through; it
     cannot pick "all of it". max() clamps from below, so a skin asking for a
     more opaque panel still gets exactly what it asked for.

     .55 is the floor because at that value a ground of arbitrary luminance can
     move the composited backing by at most 45%, which keeps a token pair that
     passes 4.5:1 on the skin's own surface above 3:1 in the worst case. It is
     a floor, not a guarantee of any particular pairing -- the skin still owns
     its palette. */
  .ds-mat::before{
    opacity:max(.55, var(--ds-mat-scrim)) !important;
  }

  /* A lit part of a mechanism differs from an unlit one by MORE than colour.
     .ds-node is drawn with a heavier stroke when active as well as a
     different one, and a skin may not equalise them. This is the same rule
     .ds-gauge, .ds-dot and .ds-listing already carry -- nothing is encoded in
     colour alone -- applied to the family that arrived with v0.3. */
  .ds-node[data-lit]{stroke-width:2.5 !important;}

  /* A portlet holds a surface this system did not draw and cannot vouch for.
     It may not resize the host, and it may not paint outside its own box.
     Without this, one embedded panel with `position:fixed` or a wide table
     takes the page's layout with it, and the containing card's own
     guarantees stop being true. */
  .ds-portlet{contain:layout paint !important; max-width:100% !important;}
  .ds-portlet-body > iframe{max-width:100% !important;}

  /* v0.6. A gauge with a self-enforced cap reads BACKWARDS without its mark:
     journald at 100% of its 1 GB cap is journald working correctly, and an
     unmarked full bar says the opposite. A skin may move it, colour it and
     resize it; it may not delete the thing that makes the number mean what it
     means. Same family as the lit node: nothing in colour alone, and nothing
     load-shed from a reading. */
  .ds-gauge-mark{display:block !important; min-width:2px !important;}

  /* v0.6. An alert's severity is a WORD, taken from the attribute so it
     cannot disagree with the data. A skin may restyle the badge and may not
     empty it -- an alerting surface is the last place to encode a level in
     colour alone. */
  .ds-alert::before{content:attr(data-severity) !important;}

  /* v0.8. A SLOT SAYS WHAT IT IS IN A WORD, and the word comes from the
     attribute so it cannot disagree with the markup. Same rule as the alert
     severity one line up, and the same reason: a dashed border alone does not
     say "reserved", it says "something is odd here". An empty position that a
     reader cannot name is indistinguishable from a rendering fault. */
  .ds-slot::before{content:attr(data-slot) !important;}

  /* v0.7. THE STICKY BAR CANNOT TAKE THE PAGE.

     Lab Manager measured a 331px .ds-chrome at 430x900 -- 37% of Tony's phone,
     following him down every page. Nothing overflowed; the bar was legitimately
     that tall, four rows of things he or we asked to be there.

     The MECHANISM for that is .ds-chrome-tail, which takes the two rows that
     are read on arrival out of the sticky element. This is the BACKSTOP under
     it, and it is deliberately a number that would have caught the render they
     measured: 33svh of a 900px viewport is 297px, so that 331px bar hits this
     ceiling. A ceiling is the worse fix -- a scroll gesture inside a sticky bar
     is the least reachable control on a page -- which is exactly why it is the
     floor of last resort rather than the design.

     svh, not vh: the small viewport unit is the one that stays true while a
     mobile browser's own chrome is showing, which is when the page has least
     room to give away. overscroll-behavior keeps a scroll that starts in the
     bar from continuing into the document. */
  .ds-chrome{
    max-block-size:33svh !important;
    overflow-y:auto !important;
    overscroll-behavior:contain !important;
  }

  /* v0.7.1. A PLANE STARTS AT ITS TOP EDGE.

     Lab Manager's ask 9, and it is Tony's, reading the three planes side by
     side: "very 'top margin' is consistent across planes except on dashboard
     there is more space at the very top ... these items are base consistency
     requirements across all design system."

     Same class as --ds-measure -- a value written once per plane, drifting
     because nothing compared them -- but it does not get the same answer. A
     token invites a number, and here there is only one. .ds-chrome is STICKY
     at top:0, so a wrapper that pads above it starts the bar BELOW the edge it
     will eventually stick to: the reader scrolls a strip of ground over the top
     of the bar, and the bar changes position relative to the viewport while
     they read. No page wants that, so nothing has to be able to ask for it.

     It is --ds-chrome-bleed one axis over, and the difference is the whole
     ruling: the correct horizontal cancellation depends on the host's gutter,
     which this file cannot know, so it is a token the host declares. The
     correct top padding is 0 on every host, so it is enforced here.

     TWO DEPTHS, because those are the two shapes a plane actually has: the
     chrome directly in the page wrapper, and the chrome inside the <header>
     that also holds .ds-chrome-tail. :has() may not be nested inside :has(),
     so the second depth is a second relative selector rather than a recursion,
     and a wrapper three levels above a sticky bar is not a shape this system
     draws.

     :first-child throughout, on purpose. A wrapper that puts content ABOVE the
     chrome is a different arrangement and its top padding belongs to that
     content. This zeroes the padding only where the padding is the thing
     pushing the bar off the top edge. */
  :has(> .ds-chrome:first-child),
  :has(> :first-child > .ds-chrome:first-child){
    padding-block-start:0 !important;
  }

  /* v0.7. A GROUP'S NAME IS NEVER CLIPPED.

     .ds-group is the first component in this contract to set text vertically,
     and a vertical name in a frame of some other height is the one place a
     name can silently lose its end. The rail grows to fit instead: a long name
     makes a taller frame, which a reader can see, rather than a shorter name,
     which they cannot. Truncating a value is a defect; truncating the name of
     the thing being looked at is a worse one, because the reader does not know
     what they are reading. */
  .ds-group-name{
    overflow:visible !important;
    text-overflow:clip !important;
    -webkit-line-clamp:none !important;
    /* And it cannot be capped, which is the other half. overflow:visible alone
       leaves the text readable but stops the RAIL growing to fit it, so the
       name paints out over the cards instead of making the frame taller. Both
       halves were needed: the first version of this invariant had only the
       three above and a hostile max-height walked straight through it. */
    max-block-size:none !important;
    max-inline-size:none !important;
  }

  /* v0.7, and it is the answer to ask 3's first collision: Tony asked for the
     rail to be NARROW, and 44px is the floor for anything a finger hits. Both
     hold, because the rail is not a control -- the frame never collapses, only
     its cards do, which is the same ruling .ds-realm's head bar already
     carries.

     Enforced rather than asserted. The rail's grid column is auto-sized, so a
     host that puts a control in the rail gets a 44px-wide rail whether it
     meant to or not: you may narrow it exactly as far as it is not tappable.
     min-height is already covered by the block at the top of this layer. */
  .ds-group-rail button, .ds-group-rail [role="button"],
  .ds-group-rail a, .ds-group-rail summary{
    min-width:44px !important;
  }

  /* v0.8.1. HIDDEN MEANS HIDDEN, AND IT IS ONE RULE RATHER THAN 83.

     Measured 2026-09-04: tony.html set `hidden` on a .ds-nodata, the DOM showed
     hidden="", and the note rendered anyway. The browser's own
     [hidden]{display:none} lives in the user-agent stylesheet and is an
     attribute selector, so ANY author rule that sets `display` outranks it.
     .ds-nodata is display:flex. 83 components in this file set a display value
     in their own rule and none of them was guarded; the two guards that exist,
     .ds-tabpanel[hidden] and .ds-guide[hidden], were each written the day
     somebody hit the bug on one component, which is the case-by-case answer to
     a cascade problem and is why the rest stayed broken.

     NOT A LAYOUT BUG. `hidden` is honoured by a screen reader, which skips the
     element, and ignored here for a sighted reader, who sees it. The page then
     says two different things to two different readers.

     AN INVARIANT, BY THE TEST v0.7.1 SET: a token is right when the HOST holds
     information this file does not, an invariant when it does not. "Hidden
     means hidden" is true on every host, so there is nothing to ask for. It
     also passes the skin test -- a skin must not be able to make a hidden
     element visible.

     THE COST, and it is real: !important on a bare attribute selector beats a
     page's own deliberate [hidden]{display:block} override. Nothing in the five
     consuming trees does that, measured before shipping, and a page that wants
     an element shown should not be marking it hidden. It is in the release note
     because five trees consume this file.

     The two per-component guards above are left alone. Removing them is a
     separate change with its own risk and it buys nothing. */
  [hidden]{display:none !important;}

  /* Keyboard focus is always visible. */
  :focus-visible{
    outline:2px solid var(--ds-accent) !important;
    outline-offset:2px !important;
  }

  /* Body text never drops below 16px, whatever a skin sets --ds-text to. */
  body{font-size:max(16px, var(--ds-text)) !important;}

  /* Motion is optional, always. */
  @media (prefers-reduced-motion: reduce){
    *,*::before,*::after{
      animation-duration:.01ms !important; animation-iteration-count:1 !important;
      transition-duration:.01ms !important; scroll-behavior:auto !important;
    }
  }
}

/* Not an invariant -- just a utility. Wide content scrolls inside its own box
   so the page body never scrolls sideways. */
.ds-scroll{overflow-x:auto; -webkit-overflow-scrolling:touch;}
