/* The visor's SYSTEM UI stylesheet: the strip, the identity cluster, the
   context cluster, and the drawer that unfolds above the strip — the
   CSS half of visor/ui/visor.ts. Extracted whole out of the demo spike
   (spikes/demo/web/index.html), the same way visor.ts was, so a second
   consumer (spikes/todomvc) can share the exact chrome rather than
   reimplement it.

   What is NOT here: any particular sheet's CONTENT styling (the demo's
   .cred-sheet/.name-sheet/.settings-sheet, todomvc's own app styles).
   Those are page-specific, built by each consumer's own drawer tenants,
   and stay in the consumer's own stylesheet.

   A consumer's page provides: --content-max/.bar-inner style centering
   (or its own layout) and links this file BEFORE its own page-specific
   <style> block, so cascade order matches what this stylesheet was
   authored against.

   --- THREE VOICES ---------------------------------------------------
   Every piece of content the visor renders belongs to exactly one
   provenance class, and the class is visible:

   class        | meaning                    | marking                                   | examples
   -------------|----------------------------|-------------------------------------------|--------------------------------
   (unmarked)   | FRAMEWORK VOICE — the      | none; it is what the visor looks like.     | sheet headings, labels, hints,
                | visor's own words          | `.said` commentary sits at .85 opacity,    | `.said` lines, announcements,
                |                            | headings/labels at full strength.          | SAS digits, pairing code, .fresh
   .petname     | USER VOICE — the user's    | weight 600, full opacity, NEVER quoted,    | petname on the strip and sheets,
   .who         | own vocabulary, spoken by  | NEVER monospace, no plate. `.who.device`   | the user's name, their word for
   (.mark-icon) | the visor                  | is the quieter half at 500. Pet icons are  | this device, the pet icon
                |                            | user voice BY CONSTRUCTION and so carry    |
                |                            | no extra marker at all.                    |
   .foreign     | APP VOICE — component-     | quoted (`<q>`) + monospace + textual       | "calls itself", the provenance
                | influenced strings         | attribution + a recessed PLATE, so it      | key, a panel's destination, the
                |                            | reads as an embedded token rather than as  | nominated glyph
                |                            | a word in the visor's sentence.            |

   NOT italics for user voice: CJK gets only synthetic oblique, Arabic
   has no italics, 12px italic legibility is poor, and italics read as
   quotation — the wrong connotation for the one voice that is not being
   quoted.

   THE ONE-DIRECTIONAL SECURITY RULE: app-influenced strings must only be
   renderable through the app-voice constructor; the reverse direction
   (visor text accidentally styled as a plate) is ugly but not dangerous.
   Hence the enforcement is a CONSTRUCTION funnel — visor/ui/visor.ts's
   `foreignToken`, the single site that assigns `.foreign`, pinned by
   invariant (h) of spikes/demo/scripts/check-invariants.sh — and not a
   style audit. This stylesheet only has to dress what that door emits. */

/* THE PLATE: the app-voice token's own surface. ALPHA-based on purpose —
   the strip's background is the user's anchor hue, one of ten, and the
   plate has to read on all of them at 38% lightness; a fixed colour
   would be legible on some and invisible on others.
   NO BORDER: a bordered light rectangle is this visor's BUTTON dress
   (#visor-name-it, #visor-settings, the picker buttons), and a
   non-interactive token must not wear a control's clothes. The inset
   shadow says RECESSED, not raised — embedded in the visor's surface
   rather than sitting on top of it offering to be pressed.
   VERTICAL PADDING IS PAINT-ONLY here: on an inline non-replaced
   element, vertical padding does not contribute to line-box height, so
   it cannot change the strip's measured geometry (the `strip-geometry`
   e2e scenario) — the constraint is visual, not layout: the chip's
   ~1px overhang must stay inside the .15em gap between the strip's two
   lines, which it does. (First shipped at .08 alpha with no vertical
   inset; raised after the plate proved too subtle to register as a
   chip at a glance.) */
#visor-context q.foreign, #visor-context span.foreign,
.cred-sheet q.foreign, .cred-sheet span.foreign,
.name-sheet .name-nomination q.foreign,
.name-sheet .name-nomination span.foreign {
  background: rgba(255,255,255,.13);
  box-shadow: inset 0 1px 2px rgba(0,0,0,.35);
  border-radius: 3px; padding: .08em .45em;
}

/* THE VISOR STRIP (#22). The one region an app can never paint, so
   it is where identity lives. Its background is the user's own
   colour: randomised on first run, changeable, and never disclosed
   to app code — a SECONDARY anchor behind position, defending the
   case where an app mimics the visor inside its own rectangle. It stays
   CONSTANT while secondary surfaces come and go: an anchor that
   changes per component would stop being an anchor. */
/* PINNED. An anchor that scrolls away is not an anchor: the strip
   must be on screen at the moment a secret is typed, which is only
   guaranteed if it cannot leave the viewport. Pinning now belongs to
   #visor-zone, the assembly that holds the sheet ABOVE the strip:
   both must ride the same sticky box, or the sheet could be scrolled
   away from the bar it hangs off. The zone's z-index puts the whole
   assembly above the dim layer and all page content — nothing an app
   draws can ever be painted over it. */
#visor-zone { position: sticky; top: 0; z-index: 30; }
#visor-strip { padding: .45em 0; font-size: 12px;
                background: var(--visor-bg, oklch(38% .07 260));
                color: var(--visor-fg, #f2f4fb);
                border-bottom: 1px solid rgba(0,0,0,.35); }
/* TWO CLUSTERS, AND A GUARANTEED GAP BETWEEN THEM. The strip carries
   two stacked lines per side now, so it is roughly twice as tall as
   the single-line bar it replaces — deliberately: the anchor is the
   one thing that must be legible at a glance, and a 12px single line
   was the thinnest possible version of it.

   The centre is EMPTY BY CONSTRUCTION: space-between plus a 45% cap
   on each cluster leaves at least a 10% exclusion band down the
   middle at every width, with no element in it to measure or to
   collide with. The old standing-rule line lived there and is GONE — see
   the note on .ctx-bottom below. */
#visor-strip .bar-inner { display: flex; align-items: stretch;
                            justify-content: space-between; gap: .8em;
                            padding: 0 .9em; box-sizing: border-box; }

/* THE BACK CHEVRON (visor/ui/visor.ts's `setBack`). Present only while
   the consumer says the user is in a nested PLACE, and absent — not
   disabled, absent — otherwise.

   IT IS THE UNFORGEABLE EXIT. A page's own Cancel sits in scrollable
   content, which an app can reproduce pixel for pixel inside its own
   rectangle; this sits in the one region no component can draw. Same
   control dress as #visor-settings and #visor-name-it, because it is the
   same kind of thing — a visor-owned button, in framework voice — and
   the strip's controls must be one visual family, so that "this is the
   visor's" is a learnable look rather than a per-button judgement.

   SQUARE ACROSS BOTH LINES, mirroring #visor-settings at the other edge:
   the same 44px touch floor for the same reason (at the strip's 12px
   type, 2.4em is only ~29px), so the two ends of the bar are balanced
   and both are thumb-sized. */
#visor-back { font: inherit; color: inherit; cursor: pointer;
               background: rgba(255,255,255,.12);
               border: 1px solid rgba(255,255,255,.3);
               border-radius: 4px; padding: 0;
               align-self: center; flex: none;
               display: inline-flex; align-items: center;
               justify-content: center;
               width: 2.4em; height: 2.4em;
               min-width: 44px; min-height: 44px;
               font-size: 1.5em; line-height: 1; }
/* NOT A CIRCLE. Round means "me" in this visor (#visor-settings wears the
   user's own glyph, and the settings sheet's picker is round to match).
   Back is a navigation verb, about a place rather than a person, so it
   keeps the rectangular control dress. */
#visor-back:focus-visible { outline: 2px solid rgba(255,255,255,.7);
                             outline-offset: 2px; }
/* THE CENTRE OF THE BAR STAYS EMPTY. With two children, space-between
   put the exclusion band between them; with three it would put a band on
   EITHER side of the context cluster, pulling the context away from the
   chevron it belongs beside and shrinking the gap that separates the
   visor's cluster from the component's. The auto margin restores the
   original arrangement: back and context grouped at the leading edge,
   all remaining space in one band before the identity cluster.
   (strip-geometry measures that band.) */
#visor-context { margin-right: auto; }
/* LEFT CLUSTER — two lines, ordered by WHOSE QUESTION THEY ANSWER:
     .ctx-top    THE USER'S RECOGNITION PAIR: the mark they picked and
                 the word they chose, side by side — or, before those
                 exist, the visor's offer to create them (the NEW marker
                 and "name it", which sit exactly where the answer will
                 land).
     .ctx-bottom CLAIMS AND STATUS: what the component calls itself
                 (quoted, monospaced, plated — app voice), the name of
                 whichever visor sheet is open, and timed announcements,
                 which replace the whole line for their window.
   "What is this, to me?" is answered above "what does it call itself?",
   which is the demotion of the component's self-description made
   structural. An app that declares no nickname leaves the bottom line
   empty outside sheets and announcements — deliberately: an empty row
   beats a filler sentence, and it leaves the user's own line clean.

   THE LINES ARE FREE TO BE REORDERED BECAUSE THE VOICES ARE MARKED. See
   the three-voices table at the top of this file: provenance rides the
   TOKEN (the plate and the monospace say "a component said this", the
   600 weight says "you said this"), not the row it sits in. When the row
   was the only marking, moving a token between lines would have changed
   what it claimed. */
#visor-context { display: flex; flex-direction: column;
                  justify-content: center; gap: .15em;
                  min-width: 0; max-width: 45%; }
#visor-context .ctx-top, #visor-context .ctx-bottom {
  display: flex; align-items: center; gap: .5em; min-width: 0;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* The cluster is one tap target: it opens the visor's App settings
   sheet for whatever surface the top line names. Strip pixels, so
   the gesture starts somewhere no component can draw or reach. */
#visor-context[role="button"] { cursor: pointer; }
#visor-context[role="button"]:focus-visible { outline: 2px solid rgba(255,255,255,.7);
                                               outline-offset: 2px; border-radius: 3px; }
/* THE PET ICON: the user's own recognition mark for a component, in the
   visor's own foreground colour — plain text, not a painted chip. The
   colour chip that used to sit here is GONE with the mark hue (#22
   discussion): the ANCHOR colour still does its job, but per-app colour
   was never a recognition device a user could name or rehearse. The
   slot is fixed-width so a mark never shifts the nickname beside it,
   and an UNMARKED surface renders no element at all — not an empty
   slot, because the visor says nothing before the user has.

   USER VOICE BY CONSTRUCTION, which is why it wears no marker: a glyph
   only reaches this slot after the user adopted it in the naming
   ceremony, and a merely NOMINATED glyph never renders outside that
   ceremony's picker — where it is dashed, plated and app-voice
   attributed. The provenance is enforced by the code path, so the
   pixels do not have to carry it. */
#visor-context .mark-icon { flex: none; font-size: 15px; line-height: 1;
                            width: 1.1em; text-align: center;
                            font-family: ui-monospace, "Segoe UI Symbol",
                                         "Apple Symbols", monospace; }
/* APP VOICE on the strip: quoted, monospaced, clamped and plated (the
   plate rule is in this file's header) — never part of the visor's own
   sentence (#22's untrusted-string rule). Built only by
   visor/ui/visor.ts's `foreignToken`. */
#visor-context q.foreign, #visor-context span.foreign {
                            font-family: ui-monospace, monospace;
                            max-width: 22em; overflow: hidden;
                            text-overflow: ellipsis; white-space: nowrap; }
#visor-context .said { opacity: .85; overflow: hidden;
                        text-overflow: ellipsis; }

/* THE PULSE — the visor pointing at its own context lines. Fired by
   visor/ui/visor.ts's `pulseContext` when what the lines MEAN just
   changed (a component surface arriving on the page, say): the cluster's
   background washes twice and clears, and the lines themselves are left
   completely alone. That is what it is for. The alternative it replaced
   was a timed announcement, which takes the bottom line away for its
   window and so HIDES the arriving surface's plated nickname during
   exactly the seconds a user should be reading it.

   ALPHA-WHITE ONLY, for the same reason the app-voice plate is (see this
   file's header): the surface underneath is the user's anchor hue, one
   of ten at 38% lightness, and a fixed colour would read on some and
   vanish on others.

   EXPLICITLY NOT THE PLATE'S DRESS: no border, no inset shadow. Those
   two marks are what APP VOICE looks like in this visor — "a component
   said this, embedded in the visor's surface" — and the pulse is the
   framework speaking about its own chrome. A framework-voice attention
   cue that wore app-voice clothing would say, for a second and a half,
   that the whole cluster was a quoted token. (It is not the BUTTON dress
   either, which is the other bordered rectangle here.)

   ZERO LAYOUT SHIFT is a hard requirement — the strip's line and cluster
   geometry is measured by the `strip-geometry` e2e scenario, and an
   attention cue that nudges the text it is pointing at is a bug. The
   wash therefore lives on an ABSOLUTELY POSITIONED PSEUDO-ELEMENT,
   which cannot participate in layout by construction — the breathing
   room is the pseudo's inset overhang, painted into the gaps around
   the cluster without owning them.

   THE LESSON THIS PARAGRAPH REPLACED, kept because the bug shipped: the
   first version put `padding: 0 .4em; margin: 0 -.4em` on the cluster
   itself. `margin` is a SHORTHAND — it silently clobbered the
   cluster's `margin-right: auto` (the rule, above, that makes the
   cluster absorb the strip's free space), so every pulse shoved the
   whole cluster ~340px toward the identity box and snapped it back on
   expiry. A cue that yanks the text it is pointing at is worse than no
   cue. The pseudo has no such failure mode: it carries no box the flex
   row can see. (z-index -1 under an isolated stacking context paints
   it behind the cluster's text, above the strip's background.) */
@keyframes visor-ctx-pulse {
  0%   { background-color: rgba(255,255,255,0); }
  50%  { background-color: rgba(255,255,255,.14); }
  100% { background-color: rgba(255,255,255,0); }
}
#visor-context.pulse { position: relative; isolation: isolate; }
#visor-context.pulse::before {
  content: ""; position: absolute; inset: 0 -.4em; z-index: -1;
  border-radius: 4px; pointer-events: none;
  animation: visor-ctx-pulse .9s ease-in-out 2;
}
/* REDUCED MOTION: no oscillation. The chosen substitute is a SINGLE
   FADE-OUT over the same total duration — the wash is present at full
   strength the instant the pulse starts and decays once to nothing —
   because the information the cue carries is "something here just
   changed", which one appearance conveys as well as two, and repetition
   is precisely the part a motion-sensitive user asked not to receive.
   (Not a hard static block removed at the end: an abrupt disappearance
   is itself a movement-like event, and the fade costs nothing.) */
@media (prefers-reduced-motion: reduce) {
  @keyframes visor-ctx-pulse-once {
    0%   { background-color: rgba(255,255,255,.14); }
    100% { background-color: rgba(255,255,255,0); }
  }
  #visor-context.pulse::before {
    animation: visor-ctx-pulse-once 1.8s ease-out 1;
  }
}

/* THE STRIP'S LIVE REGION — visually hidden, never `display:none`. A
   display:none (or [hidden]) live region is not announced by assistive
   tech at all, which would make the element decorative and useless; the
   clip-rect recipe is the one that keeps it in the accessibility tree
   while giving it no pixels. The node is created by visor/ui/visor.ts's
   `initVisor`, not by a consumer's markup — the strip's internals are
   the visor's own. */
#visor-live {
  position: absolute; width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%);
  white-space: nowrap;
}

/* THE PETNAME — USER VOICE (see the three-voices table in this file's
   header). The user's own word for a component, so the visor says it in
   the visor's own sentence: no quotes, no monospace, no plate, full
   opacity, weight 600. The contrast with q.foreign above is the whole
   message: quoted + monospace + plate means "a component said this";
   unquoted, unplated, weight 600 means "you said this"; and everything
   unmarked is the visor speaking for itself. */
#visor-context .petname { font-weight: 600; letter-spacing: .01em;
                           max-width: 14em; overflow: hidden;
                           text-overflow: ellipsis; white-space: nowrap;
                           /* The user's word is never the thing flex
                              squeezes: the max-width above caps a
                              pathological record, but within it the
                              petname wins the space fight — the
                              descriptive visor sentences shrink
                              first. */
                           flex: none; }
/* "calls itself" is one phrase; breaking it mid-air reads as two
   stray words. The LONG visor sentence stays wrappable. */
#visor-context .calls-itself, .cred-sheet .calls-itself { white-space: nowrap; }
/* Clickable petname / "name it": visor pixels in the strip, which is
   the one place a component can never draw or reach. */
#visor-context .petname.clickable { cursor: pointer;
                                     text-decoration: underline dotted;
                                     text-underline-offset: .2em; }
#visor-name-it { font: inherit; color: inherit; cursor: pointer;
                  background: rgba(255,255,255,.12);
                  border: 1px solid rgba(255,255,255,.3);
                  border-radius: 3px; padding: .05em .5em; white-space: nowrap; }
#visor-context .fresh { background: #b03030; color: #fff; font-weight: 600;
                         padding: .05em .5em; border-radius: 2px;
                         letter-spacing: .03em; white-space: nowrap; }
/* A timed announcement in the visor's own voice, on the visor's own line.
   It replaces the bottom line for a few seconds and then the line is
   RE-RENDERED from the live context — never restored from a saved
   string, because what belongs there may have changed underneath. */
/* FRAMEWORK VOICE, at full strength. The weight-600 that used to be
   here was the USER-voice marker borrowed by a framework-voice element:
   an announcement is the visor speaking, so it inherits the visor's
   weight. Its salience comes from being full-strength AND from
   replacing the whole bottom line for its window — the span carries
   `said announce`, so the explicit opacity:1 undoes `.said`'s .85
   muting, which is for standing commentary and not for a line that
   arrived just now. An announcement is a flat string and therefore
   cannot carry class marking at all: see `announce`'s doc comment in
   visor/ui/visor.ts — framework voice, user-voice words admissible
   inline, an app-influenced string never. */
#visor-context .announce { opacity: 1; overflow: hidden;
                            text-overflow: ellipsis; }

/* THE STANDING RULE LINE IS GONE. It used to sit in the
   centre of this bar reciting "storage secrets are only entered in
   the sheet this bar reveals above itself" at all times. Two reasons
   it went:
     - a sentence recited permanently is read once and then becomes
       furniture; the claim matters AT THE MOMENT OF ENTRY, and that
       is where it now lives — the credential sheet states the
       invariant in its own note, on the surface it is about;
     - the centre of the strip has to be EMPTY for the two clusters
       to have a structural exclusion gap between them (see
       .bar-inner). An element there is one more thing that can be
       crowded, wrapped or mistaken for a component's words.
   What survives of its job on the strip is session naming: while a
   visor sheet is open, .ctx-bottom says which one, so the anchor
   and the surface hanging off it agree. */

/* THE IDENTITY CLUSTER (strip right). The user's own name and their
   word for this device, spoken in THE VISOR'S voice — plain text, full
   opacity, slightly heavier — because the user typed them. Same
   contrast as the petname above — this is the same USER VOICE family
   as .petname (weight 600, unquoted, unplated), against q.foreign's
   plated app voice. Nothing here is ever handed to
   a component (see visor/ui/visor.ts's identity record), so the
   cluster is a second thing an impersonating rectangle cannot
   reproduce: it would have to guess words it can never read.

   The old standalone "⛨ polymorph" brand mark is deliberately GONE.
   The anchor's authority is its POSITION and the user's colour, not
   a word — and a word is precisely the part an app CAN paint inside
   its own rectangle. What sits here instead is a control: the
   user's chosen glyph, opening the visor's settings sheet.

   TWO LINES HERE TOO, mirroring the left cluster: the user's name
   above, their word for this device below, each ellipsizing inside
   the same 45% cap. The button spans BOTH lines as a square. */
#visor-identity { display: flex; align-items: stretch; gap: .5em;
                   min-width: 0; max-width: 45%; flex: none; }
#visor-identity .id-lines { display: flex; flex-direction: column;
                             justify-content: center; gap: .15em;
                             min-width: 0; text-align: right; }
#visor-identity .who { font-weight: 600; letter-spacing: .01em;
                        max-width: 16em; overflow: hidden;
                        text-overflow: ellipsis; white-space: nowrap; }
/* The device word is the quieter half of the pair. */
#visor-identity .who.device { font-weight: 500; opacity: .85; }
/* THE DOUBLED BUTTON. It spans both text lines, which does two
   things: it makes the visor's own control the most prominent object on
   the bar (it is the only control here that opens the visor's own
   settings), and it gives the gesture a target that clears the 44px
   touch minimum at the strip's 12px type — hence the px floor beside
   the em sizing: at 12px, 2.4em is only ~29px.

   IT IS A CIRCLE, and that shape means "me": the avatar convention, the
   same one every account menu on the platform uses. The visor spends it
   on the one control that is about the USER rather than about a
   component — see the rectangular pet-icon picker in .name-icons, which
   is about an "it". */
#visor-settings { font: inherit; color: inherit; cursor: pointer;
                   background: rgba(255,255,255,.12);
                   border: 1px solid rgba(255,255,255,.3);
                   border-radius: 50%; padding: 0;
                   align-self: center; flex: none;
                   display: inline-flex; align-items: center;
                   justify-content: center;
                   width: 2.4em; height: 2.4em;
                   min-width: 44px; min-height: 44px;
                   font-size: 1.5em; line-height: 1; }

/* --- the drawer host's own chrome (no sheet CONTENT here) -----------------
   Credential/naming/settings entry — every consumer's version of it —
   lives on a surface only the visor can produce: a sheet that unfolds
   ABOVE the pinned strip, full width, in the user's own anchor colour,
   with the strip still fully visible BELOW it.

   WHY ABOVE AND NOT BELOW. The first cut hung the sheet beneath the
   strip, which is forgeable by ADJACENCY: the strip sits over
   scrollable content, so an app frame can be scrolled flush against
   the strip's bottom edge and paint a counterfeit sheet that looks
   attached to the real bar. The space ABOVE the strip has no such
   reachable position — at every scroll offset the strip is at the
   viewport's top edge, so nothing but the visor's own pixels can ever
   occupy the band above it. And the REVEAL ITSELF is unforgeable:
   the sheet appears by pushing the real bar down, and an app cannot
   move the real bar. A fake can draw a sheet; it cannot displace the
   anchor. The open sheet is therefore bracketed — viewport edge
   above, the user's coloured bar below.

   The assembly is IN FLOW inside the sticky zone, not position:fixed:
   one animated height drives the strip's descent and the page
   content's descent together, rigidly glued (the todomvc visor
   spike settled on this after fr-interpolation proved nonlinear and
   engine-varied — spikes/todomvc/host/visor.ts:82-90, pre-C3). */
#visor-drawer { background: var(--visor-bg, oklch(38% .07 260));
                 color: var(--visor-fg, #f2f4fb); }
#visor-drawer[hidden] { display: none; }
/* Only while the sheet is up does the assembly cast onto the page and
   does the strip grow a top edge: closed, the strip must look exactly
   as it always has at the top of the viewport. */
#visor-zone:has(#visor-drawer:not([hidden])) {
  box-shadow: 0 10px 26px rgba(0,0,0,.5);
}
#visor-zone:has(#visor-drawer:not([hidden])) #visor-strip {
  border-top: 1px solid rgba(255,255,255,.22);
}
/* Pixel-height animation set from JS at open (0 → measured target):
   the same single-property curve the todomvc visor spike settled on.
   flex-end keeps the growing content glued to the sheet's BOTTOM edge
   — i.e. to the strip's top edge — so the sheet unfurls upward out of
   the bar rather than sliding down over the page. The ARMING DELAY is
   visor/ui/visor.ts's ARM_MS, never this transition — reduced motion
   drops the animation and keeps the delay. */
#visor-drawer-inner { height: 0; overflow: hidden;
                       display: flex; flex-direction: column;
                       justify-content: flex-end;
                       transition: height 700ms cubic-bezier(.2,.8,.2,1); }
@media (prefers-reduced-motion: reduce) {
  #visor-drawer-inner { transition: none; }
}
/* --- THE DRAWER'S SECOND MOTION: one occupant replacing another ------
   The page track's grammar, replayed at drawer scale (#22 "the storage
   picker moves above the bar"): the occupant leaves to the LEFT while
   the arriving sheet comes in from the RIGHT, and the reverse when the
   arriving one closes and the suspended occupant returns. A user who
   has learned "sideways means one step further in, and back out again"
   from the page track reads the drawer the same way — and all of it
   happens inside the drawer's own rectangle, so it is not a second
   region and not a stack. "One expanded occupant at a time" stays
   literally true.

   The OUTGOING sheet is taken out of flow for the travel, which is what
   lets the drawer's existing height curve animate to the INCOMING
   sheet's height while both are on screen. `left/right: 0` rather than
   a width, so the departing sheet keeps the width it had. It is removed
   by the host once the travel is done (visor.ts's SWAP_MS). */
#visor-drawer-inner { position: relative; }
.visor-swap-out { position: absolute; left: 0; right: 0; bottom: 0;
                  pointer-events: none; z-index: 0;
                  transition: transform 420ms cubic-bezier(.2,.8,.2,1),
                              opacity 420ms linear; opacity: 0; }
.visor-swap-out.to-left { transform: translateX(-12%); }
.visor-swap-out.to-right { transform: translateX(12%); }
/* The arriving sheet paints OVER the departing one — they overlap for
   the length of the travel, and "the new thing is coming in on top of
   the old one leaving" is the reading that matches the page track. */
.visor-swap-in { position: relative; z-index: 1;
                 transition: transform 420ms cubic-bezier(.2,.8,.2,1),
                             opacity 420ms linear; transform: translateX(0); opacity: 1; }
.visor-swap-in.from-right { transform: translateX(12%); opacity: 0; }
.visor-swap-in.from-left { transform: translateX(-12%); opacity: 0; }
/* REDUCED MOTION: the swap still HAPPENS — it is a state change, not a
   decoration — it simply does not travel. The outgoing sheet is still
   removed on the host's timer; it just never moves or fades on the way.
   Same discipline as the reveal above: motion drops, timing does not. */
@media (prefers-reduced-motion: reduce) {
  /* The DIRECTIONAL rules have to be overridden by name: they are one
     class more specific than the base, so a bare `.visor-swap-out
     { transform: none }` loses to `.to-left` and the departing sheet
     JUMPS its full travel in one frame with no transition to soften it
     — a bigger movement than the animation it was meant to replace. */
  .visor-swap-out, .visor-swap-in,
  .visor-swap-out.to-left, .visor-swap-out.to-right,
  .visor-swap-in.from-right, .visor-swap-in.from-left {
    transition: none; transform: none;
  }
  .visor-swap-out { opacity: 0; }
  .visor-swap-in.from-right, .visor-swap-in.from-left { opacity: 1; }
}

/* The dim layer sits BETWEEN the page and the visor assembly: the
   zone (30) above, every app surface below. */
#visor-dim { position: fixed; inset: 0; z-index: 20;
              background: rgba(6,8,16,.62); }
#visor-dim[hidden] { display: none; }

/* --- Mobile geometry fixes (<=480px), visor cluster half only. Each
   consumer's own controls/dialog/sheet-content narrow-viewport rules
   stay in that consumer's stylesheet. */
@media (max-width: 480px) {
  /* No wrapping and no hiding: the two clusters are each capped at
     45% and every line inside them ellipsizes, so narrowness is
     handled by TRUNCATION rather than by dropping content. The
     identity words used to be display:none here — which quietly
     removed half of what makes the anchor unspoofable at exactly the
     width where the strip is most crowded. They stay visible now. */
  #visor-context q.foreign, #visor-context span.foreign { max-width: 100%; }
  #visor-context .petname { max-width: 100%; flex: 0 1 auto; min-width: 0; }
  #visor-identity .who { max-width: 100%; }
}

/* --- THE SHEETS' OWN STYLING -------------------------------------------
   The three visor sheets' content rules, moved here VERBATIM out of
   spikes/demo/web/index.html alongside visor/ui/sheets.ts. They were
   left page-side by the first extraction under a "who creates the
   elements" tie-break; that tie-break points the other way now that the
   framework creates the naming and settings sheets itself, and a second
   consumer must not have to copy a stylesheet to get them.

   `.cred-sheet` IS SHARED VOCABULARY, not one sheet's name. It is the
   sheet SHAPE every drawer tenant wears — the width budget, the
   scroll-past-the-budget rule, the field/row/note/arming styling — and
   the demo's own credential sheets (which stay demo-side, being the
   demo's content) keep wearing it, exactly as the framework's naming and
   settings sheets do.

   Cascade order is preserved: these rules came after the rest of
   visor.css (which the page links before its own <style>) and nothing in
   any consumer's stylesheet targets these selectors, so appending them
   here lands them in the same relative position they had. */

/* --- the credential sheet (#22) --------------------------------------
   Credential entry lives on a surface only the visor can produce: a
   sheet that unfolds ABOVE the pinned strip, full width, in the
   user's own anchor colour, with the strip still fully visible
   BELOW it.

   WHY ABOVE AND NOT BELOW. The first cut hung the sheet beneath the
   strip, which is forgeable by ADJACENCY: the strip sits over
   scrollable content, so an app frame can be scrolled flush against
   the strip's bottom edge and paint a counterfeit sheet that looks
   attached to the real bar. The space ABOVE the strip has no such
   reachable position — at every scroll offset the strip is at the
   viewport's top edge, so nothing but the visor's own pixels can ever
   occupy the band above it. And the REVEAL ITSELF is unforgeable:
   the sheet appears by pushing the real bar down, and an app cannot
   move the real bar. A fake can draw a sheet; it cannot displace the
   anchor. The open sheet is therefore bracketed — viewport edge
   above, the user's coloured bar below.

   The assembly is IN FLOW inside the sticky zone, not position:fixed:
   one animated height drives the strip's descent and the page
   content's descent together, rigidly glued (the todomvc visor
   spike settled on this after fr-interpolation proved nonlinear and
   engine-varied — spikes/todomvc/host/visor.ts:82-90).

   Deliberately NOT a <dialog>: the top layer would fight the zone's
   stacking (a modal dialog paints above everything, including the
   anchor the sheet is supposed to be bracketed by), and this
   embedding's dialog close events are unreliable — see README
   findings, and the state-change retirement the storage dialog needs
   because of it.

   (The drawer's own chrome — #visor-drawer, #visor-drawer-inner,
   #visor-dim, the reveal animation and the reduced-motion override —
   moved to visor/ui/visor.css with the rest of the shared strip; only
   this sheet's OWN content styling stays here.) */

/* A sheet taller than the space above the strip would push the strip
   off the bottom of the viewport — losing the anchor exactly when it
   matters. It scrolls internally instead; --visor-sheet-max is the
   measured viewport-minus-strip budget, set from JS at open/resize.
   `width:100%` is load-bearing now that the container is a flex
   column: `margin:0 auto` is an AUTO cross-axis margin, which
   suppresses the default stretch and would size the sheet to its
   content instead of to the page's centered column. */
.cred-sheet { box-sizing: border-box; width: 100%; max-width: 34em; margin: 0 auto;
              max-height: var(--visor-sheet-max, 80dvh); overflow-y: auto;
              flex-shrink: 0;
              padding: .8em 1.1em 1.1em; font-size: 13px; line-height: 1.45; }
.cred-sheet h2 { margin: 0 0 .35em; font-size: 15px; font-weight: 600; }
.cred-sheet .cred-line { display: flex; align-items: baseline; gap: .4em;
                         flex-wrap: wrap; font-size: 12px;
                         opacity: .92; margin-bottom: .35em; }
/* Same pet icon, same rule, on the sheets: text in the visor's colour,
   fixed slot, absent entirely when the surface is unmarked. */
.cred-sheet .mark-icon { flex: none; font-size: 14px; line-height: 1;
                         width: 1.1em; text-align: center;
                         font-family: ui-monospace, "Segoe UI Symbol",
                                      "Apple Symbols", monospace; }
/* APP VOICE on a sheet: panel-influenced strings (component name,
   provenance key, destination origin) are quoted, monospaced, clamped
   and plated — never part of the visor's sentence. */
.cred-sheet q.foreign, .cred-sheet span.foreign {
                        font-family: ui-monospace, monospace;
                        max-width: 20em; overflow: hidden;
                        text-overflow: ellipsis; white-space: nowrap; }
.cred-sheet .cred-warning { font-size: 12px; color: #ffd79a; margin: .3em 0; }
.cred-sheet .cred-reason { font-size: 12px; color: #ffd79a; margin-top: .5em;
                           min-height: 1.2em; }
.cred-sheet .cred-note { font-size: 11px; opacity: .8; margin-top: .6em;
                         line-height: 1.4; }
.cred-sheet .cred-field { display: flex; flex-direction: column;
                          gap: .2em; margin: .55em 0; }
.cred-sheet .cred-field label { font-size: 11px; opacity: .85; }
.cred-sheet .cred-field .hint { font-size: 10px; opacity: .7; }
.cred-sheet .cred-field input { width: 100%; box-sizing: border-box;
                                font: inherit; font-size: 13px;
                                background: rgba(0,0,0,.32);
                                color: var(--visor-fg, #f2f4fb);
                                border: 1px solid rgba(255,255,255,.35);
                                border-radius: 3px; padding: .4em .5em; }
.cred-sheet .cred-row { display: flex; gap: .8em; margin-top: .9em; }
.cred-sheet .cred-row button { flex: 1; font: inherit; font-size: 13px;
                               padding: .5em .8em; border-radius: 5px; cursor: pointer;
                               border: 1px solid rgba(255,255,255,.4);
                               background: transparent; color: inherit;
                               opacity: .35; transition: opacity 200ms; }
/* Visible arming: dim until the timer elapses. The disabled attribute
   is the enforcement — this is only its visible form. */
.cred-sheet.armed .cred-row button { opacity: 1; }
.cred-sheet .cred-row button:first-child { background: #fff; color: #17182b;
                                           border-color: #fff; font-weight: 600; }

/* --- the storage picker (#22 "the picker moves above the bar") -------
   The sheet where a provider is CHOSEN and the app is connected to it.
   It wears the shared `.cred-sheet` shape and adds only its lists.

   TWO GROUPS, ON TWO ORTHOGONAL AXES: which group an entry is in
   follows CONFIG state (configured -> offered for selection; installed
   but unconfigured -> offered for configuration); which VOICE its
   identity wears follows NAMING state. Nothing here styles voice: the
   user-voice petname and the app-voice plate arrive already marked from
   the visor's constructors (`petnameSpan`, `nicknameQuote`) and are
   dressed by the rules at the top of this file. An entry is a row of
   ALREADY-MARKED tokens plus the visor's own description of the
   provider, which is unmarked because it is the visor speaking. */
.picker-sheet .picker-lists { display: flex; flex-direction: column; gap: .7em;
                              margin: .5em 0 .2em; }
.picker-sheet .picker-head { font-size: 11px; opacity: .8; margin-bottom: .3em; }
/* An entry IS a button, so it wears a border: a border is honest on a
   control (the same rule the naming sheet's icon picker states). It is
   NOT the app-voice plate — the plate is for tokens, and a plated
   rectangle offering to be pressed would say "a component wrote this
   control". */
.picker-sheet .picker-entry { display: flex; flex-direction: column; gap: .15em;
                              width: 100%; box-sizing: border-box; text-align: left;
                              font: inherit; font-size: 13px; cursor: pointer;
                              padding: .45em .6em; margin-bottom: .35em;
                              border: 1px solid rgba(255,255,255,.4); border-radius: 5px;
                              background: transparent; color: inherit;
                              opacity: .35; transition: opacity 200ms; }
/* Visible arming, exactly as on the credential sheet: dim until the
   timer elapses, with the `disabled` attribute as the enforcement and
   this only its visible form. It applies to the SELECTION entries alone
   — the ones that connect the app to a provider. The "set it up" entries
   in the second group and the sheet's Close button are never disabled,
   because the arming delay defends an act that SPENDS something, and
   walking to a configuration page spends nothing (the same weight-class
   judgement that leaves the naming sheet unarmed). */
.picker-sheet.armed .picker-entry, .picker-sheet #picker-unconfigured .picker-entry,
.picker-sheet .picker-row button { opacity: 1; }
.picker-sheet .picker-entry:focus-visible { outline: 2px solid rgba(255,255,255,.7);
                                            outline-offset: 2px; }
.picker-sheet .picker-entry-id { display: flex; align-items: baseline; gap: .4em;
                                 flex-wrap: wrap; min-width: 0; }
.picker-sheet .picker-entry-what { font-size: 11px; opacity: .8; }
/* The pet icon and the NEW marker, in the same dress the strip gives
   them — the entry is a recognition act, so it must look like the one
   the user already learned on the anchor. */
.picker-sheet .mark-icon { font-size: 14px; }
.picker-sheet .fresh { background: #b03030; color: #fff; font-weight: 600;
                       font-size: 10px; letter-spacing: .03em;
                       padding: 0 .35em; border-radius: 2px; }
.picker-sheet .picker-row { justify-content: flex-end; }
.picker-sheet .picker-row button { flex: 0 0 auto; min-width: 8em; }

/* --- THE BAND: the picker collapsed to a ceremony breadcrumb ---------
   While the user is off on a provider's configuration page, the picker
   does not sit there at full height over the place it sent them to. It
   shrink-wraps to the entry they chose plus a line saying what is
   happening — two or three strip-heights of TRUSTED CHROME in total,
   answering "what step of MY ceremony is this" where the strip below it
   answers "who is drawing down there". The ceremony stays legible and
   stays out of the way.

   INERT BY CONSTRUCTION, not by styling: the host builds the entry as a
   non-interactive element in this mode (no selection, no arming, no
   navigation), and the one control it keeps is the way out — a user's
   own sheet is always theirs to dismiss. Nothing here disables
   anything, because there is nothing here to disable. */
/* Vertical padding is what puts the band in the ruling's budget: with
   the strip below it, the total trusted chrome lands at roughly two
   strip-heights — enough to read as a piece of the visor rather than a
   toast, and nowhere near enough to be the thing on screen. */
.picker-sheet.picker-band { padding: .85em .9em .9em; }
.picker-band .band-row { display: flex; align-items: center; gap: .8em;
                         flex-wrap: wrap; }
.picker-band .band-entry { display: flex; align-items: baseline; gap: .4em;
                           min-width: 0; flex-wrap: wrap; }
/* No border and no button dress: the band's entry is a STATEMENT about
   what the user picked, not an offer to pick it again. */
.picker-band .band-status { font-size: 11px; opacity: .8; }
.picker-band .band-row .band-close { margin-left: auto; flex: 0 0 auto;
                                     font: inherit; font-size: 12px;
                                     padding: .3em .7em; border-radius: 5px;
                                     cursor: pointer; color: inherit;
                                     border: 1px solid rgba(255,255,255,.4);
                                     background: transparent; }

/* --- the naming sheet -----------------------------------------------
   The SAME reveal above the strip (that geometry is the unforgeable
   part), a LIGHTER session: no arming delay, no frozen surfaces, no
   dim — nothing secret is typed here and the ceremony starts from
   strip pixels no app can draw. It ships with `.armed` already set,
   so the button row is never dimmed for a delay that does not exist.
   The user's own word is shown in USER voice; what the component calls
   itself stays in q.foreign — app voice, quoted, monospaced, plated. */
.name-sheet .petname { font-weight: 600; }
/* THE ICON PICKER (was the recognition-colour swatch row). Six offers,
   each a glyph no other trust record wears. The NOMINATED one — a glyph
   the component itself asked to wear — is drawn differently on purpose:
   a dashed border says "this offer is not the visor's", the same way a
   dotted underline or a q.foreign says it in text. It keeps its border
   because it IS a button — a border is honest on a control — while its
   background and inset shadow are the app-voice PLATE's, so the offer
   and the attribution line above it speak one vocabulary. */
.name-sheet .name-icons { display: flex; gap: .4em; flex-wrap: wrap;
                          margin: .1em 0 .6em; }
/* RECTANGULAR ON PURPOSE: a pet icon is a mark for an "it" — the round
   shape is reserved for the user's own glyph (see #visor-settings and
   the settings sheet's picker), because a circle reads as "me". */
.name-sheet .name-icons button { font: inherit; font-size: 16px;
                                 width: 2em; height: 2em; padding: 0;
                                 cursor: pointer; border-radius: 3px;
                                 color: inherit; line-height: 1;
                                 background: rgba(255,255,255,.1);
                                 border: 1px solid rgba(255,255,255,.35);
                                 font-family: ui-monospace, "Segoe UI Symbol",
                                              "Apple Symbols", monospace; }
.name-sheet .name-icons button.nominated {
  border-style: dashed;
  /* Follows the app-voice plate's background (see the plate rule near
     the top of this file): the nominated glyph is the picker's one
     app-voice token, and the two must brighten together. */
  background: rgba(255,255,255,.13);
  box-shadow: inset 0 1px 2px rgba(0,0,0,.35);
}
.name-sheet .name-icons button.picked { outline: 2px solid #fff;
                                        outline-offset: 1px; }
/* The app-voice attribution line above the row: the visor's sentence,
   the component's glyph, quoted and plated like every other thing a
   component said. */
.name-sheet .name-nomination q.foreign,
.name-sheet .name-nomination span.foreign {
                                         font-family: ui-monospace,
                                         "Segoe UI Symbol", "Apple Symbols",
                                         monospace; font-size: 14px; }
.name-sheet .cred-line.said { opacity: .8; font-size: 11px; margin-bottom: .15em; }
.name-sheet .name-forget { display: flex; align-items: center; gap: .6em;
                           flex-wrap: wrap; margin-top: .8em;
                           padding-top: .6em;
                           border-top: 1px solid rgba(255,255,255,.18); }
.name-sheet .name-forget button.forget { font: inherit; font-size: 12px;
                                         cursor: pointer; padding: .3em .7em;
                                         border-radius: 4px; color: #ffd0d0;
                                         background: transparent;
                                         border: 1px solid rgba(255,160,160,.5); }
.name-sheet .name-forget .hint { font-size: 10px; opacity: .75; }

/* --- the settings sheet ----------------------------------------------
   THE THIRD TENANT of the same drawer, and lightweight for the same
   reason the naming sheet is: the reveal above the strip is the
   unforgeable part and is kept, but there is no arming delay, no
   runner suspension and no dim. Nothing secret is typed here, the
   sheet is opened from strip pixels an app can neither draw nor
   reach, and the worst a mis-tap can do is open a form the user
   closes. Paying the arming tax where it buys nothing would train
   users to click through a delay that means something elsewhere —
   which is the real cost. It ships `.armed` so the button row is
   never dimmed for a delay that does not exist. */
.settings-sheet .settings-icons,
.settings-sheet .settings-hues { display: flex; gap: .35em; flex-wrap: wrap;
                                 margin: .1em 0 .6em; }
/* THE USER'S OWN GLYPH, so the offers are ROUND: choosing "me" wears the
   me-shape, and the button that will carry the choice (#visor-settings)
   is a circle too. The component pet-icon picker (.name-icons) stays
   rectangular — that one is an "it". */
.settings-sheet .settings-icons button { font: inherit; font-size: 14px;
                                         width: 1.9em; height: 1.9em; padding: 0;
                                         cursor: pointer; border-radius: 50%;
                                         color: inherit; line-height: 1;
                                         background: rgba(255,255,255,.1);
                                         border: 1px solid rgba(255,255,255,.35); }
.settings-sheet .settings-hues button { width: 1.4em; height: 1.4em; padding: 0;
                                        border-radius: 3px; cursor: pointer;
                                        border: 1px solid rgba(255,255,255,.45); }
.settings-sheet .picked { outline: 2px solid #fff; outline-offset: 1px; }
.settings-sheet .cred-line.said { opacity: .8; font-size: 11px; margin-bottom: .15em; }
/* THE DANGER ENTRY, below the Save/Cancel row (danger last, the same
   placement as the naming sheet's forget row) and separated from it by
   the same hairline rule .name-forget uses, so "past the way out" is
   visible and not merely a matter of order. */
.settings-sheet .settings-reset { display: flex; flex-direction: column;
                                  gap: .25em; margin-top: .9em;
                                  padding-top: .6em;
                                  border-top: 1px solid rgba(255,255,255,.18); }
/* The `.forget` idiom escalated one step — same font, padding and radius,
   a warmer red on the text and a stronger border. ALPHA-BASED (like every
   plate and every control here) so it reads on all ten anchor hues at 38%
   lightness; the border is honest because this IS a button, which is the
   one thing a bordered light rectangle is allowed to be in this visor. */
.settings-sheet .settings-reset button.reset { font: inherit; font-size: 12px;
                                               cursor: pointer; padding: .3em .7em;
                                               border-radius: 4px; color: #ffd0d0;
                                               align-self: flex-start;
                                               background: transparent;
                                               border: 1px solid rgba(255,140,140,.6); }
.settings-sheet .settings-reset .hint { font-size: 10px; opacity: .75; }

/* --- the reset sheet -------------------------------------------------
   THE HEAVY TENANT of the same drawer, and the only one of the three
   framework sheets that is: armed and dimmed, because what it does is
   total and has no undo (see the reset tenant in visor/ui/sheets.ts).
   It does NOT ship `.armed` — the drawer host adds that class when the
   arming timer elapses, and here the delay is real, so the dimmed button
   row is telling the truth rather than decorating a wait that does not
   exist.

   THE STATEMENT OF CONSEQUENCE is recessed and red-tinted, so it reads
   as a block the sheet is standing behind rather than as another hint.
   All alpha, again for the ten anchor hues at 38% lightness; the text
   stays --visor-fg, because colouring the sentence itself would trade
   legibility for alarm. */
.reset-sheet .cred-danger { background: rgba(255,120,120,.12);
                            border: 1px solid rgba(255,140,140,.45);
                            border-radius: 4px; padding: .55em .7em;
                            margin: .4em 0 .2em; font-size: 12px;
                            line-height: 1.5; color: var(--visor-fg, #f2f4fb); }
.reset-sheet .cred-danger div + div { margin-top: .35em; }
/* The user's own name inside the confirmation label: USER VOICE, the
   same weight it wears in the identity cluster. */
.reset-sheet .cred-field label .who { font-weight: 600; }
/* THE ERASE BUTTON. It is `.cred-row button:first-child`, which is the
   Save dress (solid white) everywhere else — overridden here, because a
   destructive control must not wear the affirmative one's clothes. While
   the sheet is unarmed it keeps the row's dimmed opacity (the shared
   arming convention) and this softer fill; once the host adds `.armed`
   it comes up to full strength and says what it does. */
.reset-sheet .cred-row button.erase-confirm,
.reset-sheet .cred-row button.erase-confirm:first-child {
                                background: rgba(255,100,100,.14);
                                color: #ffd0d0; font-weight: 600;
                                border-color: rgba(255,140,140,.55);
                                transition: opacity 200ms, background 200ms,
                                            border-color 200ms, color 200ms; }
.reset-sheet.armed .cred-row button.erase-confirm,
.reset-sheet.armed .cred-row button.erase-confirm:first-child {
                                background: rgba(255,100,100,.25);
                                border-color: rgba(255,140,140,.8);
                                color: #ffe4e4; }
/* Inert while disabled — including the case that is NOT the arming
   delay: both buttons are disabled again for the duration of the wipe,
   when the sheet is already `.armed` and the row's opacity rule has
   stopped dimming anything. */
.reset-sheet .cred-row button:disabled { opacity: .45; cursor: default; }

/* --- Mobile geometry (<=480px) for the sheets, moved with them. */
@media (max-width: 480px) {
  /* The sheet is already full width; at phone width it just loses its
     side padding and the button row stops sharing a line badly.
     Fields are stacked and box-sized, so nothing overflows at 390px.
     The strip must never be pushed off-screen by a tall sheet, so the
     measured budget (--visor-sheet-max) matters most here — a phone
     viewport is short and the vocabulary fields are stacked. */
  .cred-sheet { padding: .7em .8em 1em; font-size: 13px; }
  .cred-sheet q.foreign, .cred-sheet span.foreign { max-width: 100%; }
  .cred-sheet .cred-row { gap: .6em; }
  .cred-sheet .cred-line { row-gap: .2em; }
}
