:root {
  /* ── WHICH BUILD OF THIS FILE IS THE BROWSER ACTUALLY USING ────────────
     Read by viewer.js at boot and logged beside what that build expects. A
     colour cannot answer this: the canary cycles red -> green -> blue, so red
     has been current three separate times in one day, and "stale" and "three
     rotations stale" look identical. The author said as much — "perhaps the
     Canary is one from three cycles back" — and was right to doubt it.

     A date-and-letter, so a mismatch names BOTH versions and the gap between
     them is readable. Bump it with every canary rotation.

     A DECLARATION inside this block, not a rule of its own. The first cut
     wrote `:root { --bd-css-build: … }` immediately after the opening brace of
     THIS block — a nested rule inside a declaration block, which is invalid,
     and whose closing brace ended the block early. --bd-gold, declared below
     it, was being dropped. It reported itself: the browser logged
     "css build (none — pre-marker)" while painting the NEW canary colour,
     which is a combination that cannot happen and so could only mean the
     property was not being read. The check caught its own installation.  */
  --bd-css-build: "2026-10-24t";
  /* 2026-08-27 — BD's system gold, desaturated from #FFD700 (100 sat) to
     50. Designer feedback singled out the top-row buttons and the helper
     card header as the loudest thing on screen.

     Hue and lightness are unchanged, so it is the same gold at half the
     intensity — 7.6:1 against the dark text it carries, where the bright
     version gave 12.5:1, so nothing becomes hard to read.

     Now a variable because this colour appeared in 12 places and the
     next adjustment should be one line, not 12.

     2026-09-01 — and it was one line: lightness 50% -> 46%, hue and
     saturation untouched, so it is the same gold a touch quieter. 6.4:1
     against the dark text it carries, down from 7.6:1 and still well clear
     of 4.5. Asked for on the radio bar and the Pair button; the corner
     controls and the mic button share the variable and follow, which is
     right — the three top-row controls are meant to read as one family.

     The ROOT NODE's gold is NOT this: it is #FFD700 in viewer.js, left
     bright deliberately. Bright is fine on the home page. */
  --bd-gold: #b09e3b;
}
*, *::before, *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
  touch-action: manipulation;
}

html, body {
  width: 100%;
  height: 100%;
  background: #000;
  overflow: hidden;
  display: flex;
  flex-direction: column;
}

/* 2026-08-14 — #title-bar rule removed alongside the element itself.
   Vacated top row now hosts #back-btn + #pair-control at top:6px. */

#bc-spacer {
  height: 40px;                          /* 2026-08-14 — reduced from 50px (title-bar gone; top-row buttons now at top:6px extending to ~34px, so 40px clears them with a 6 px gap) */
  flex-shrink: 0;
}

/* 2026-08-21 — the enlarged-copy panel (#buddy-latest) is RETIRED. The
   partner's position is now a NODE on the user's own graph, haloed in blue
   (blue_node_spec.md §6). Its styles, its mobile override and its tap-shield
   went with it; the shield existed because cytoscape swallows taps on its own
   container, which the main #cy already handles. */

#cy-buddy {
  position: fixed;                       /* 2026-08-20 — UPPER slot, swapped with
                                            #cy-you so the remote strip is
                                            contiguous with the navy enlarged
                                            copy above it (A51c had put it in the
                                            bottom slot). */
  /* Full width by design — the strip fills the viewport at every width so
     narrowing the browser leaves NO black side wings (the chips reflow via
     buddyCy.resize() on the window-resize handler). */
  left: 0;
  right: 0;
  bottom: 63px;                          /* clears #cy-you (bottom: 37px, 23px tall) + 3px gap */
  height: 23px;                          /* -35% from 36px (2026-06-29) */
  background: #001f4d;
  cursor: url('cursor-wings.svg') 16 16, auto;
  z-index: 5;                            /* above the cy canvas, below fixed top controls */
}

#pair-control {
  position: fixed;
  /* 2026-08-27 — now a flex child of #bd-toppanel above the canvas. The
     position/top/left below are overridden by the #back-btn,#bn-btn,#gn-btn
     rule further down; they survive only as the pre-panel fallback. */
  top: 6px;                              /* 2026-08-14 — raised from 34px into vacated title-bar row */
  /* 2026-08-14 v2 — panel-aligned on desktop: 30% from right edge lines
     up with the 40%-wide centred panel's right edge. Mobile override
     below restores the edge-flush 8 px so we don't waste horizontal
     space when the panel goes full-width. */
  right: 30%;
  display: flex;
  align-items: center;
  gap: 8px;
  z-index: 10;
  font-size: 10px;
  font-family: sans-serif;
}
@media (max-width: 767px) {
  #pair-control { right: 8px; }
}

/* 2026-08-14 — Pair button promoted to BD's system yellow (#FFD700),
   matching #back-btn and #view-mode-toggle. All three top-row primary
   controls now share the same visual language. Canary role moved OFF
   this button and onto #copy-link-btn (below). */
#chat-btn {
  background: var(--bd-gold);
  border: 1px solid #a07820;
  border-radius: 4px;
  color: #1a1a20;
  font-size: 10px;
  font-weight: 600;
  cursor: pointer;
  padding: 3px 10px;                    /* slight bump so Pair reads at parity with Back's 4px 12px */
  line-height: 1.4;
}

#chat-btn:hover:not(:disabled) {
  background: #ffe555;
  border-color: #d4a52e;
}

/* Fallback for plain-HTTP LAN access where navigator.clipboard.writeText is
   blocked (secure-context requirement). Floats near #pair-control; user
   selects the text and Cmd/Ctrl+C manually. Removed on outside click. */
#copy-link-fallback {
  position: fixed;
  top: 68px;
  right: 8px;
  z-index: 20;
  background: #1a1200;
  border: 1px solid #a07820;
  border-radius: 4px;
  padding: 6px;
  display: flex;
  flex-direction: column;
  gap: 4px;
  max-width: 90vw;
  box-shadow: 0 4px 12px rgba(0,0,0,0.6);
  font-family: sans-serif;
  font-size: 12px;
  color: #ffd88a;
}
#copy-link-fallback textarea {
  width: min(70ch, 80vw);
  height: 4.5em;
  background: #0f0800;
  color: #ffd88a;
  border: 1px solid #5a4400;
  font-family: 'Courier New', Courier, monospace;
  font-size: 12px;
  padding: 4px;
  resize: none;
  outline: none;
}
#copy-link-fallback .fb-hint { opacity: 0.75; }

/* 2026-08-14 — .active no longer swaps the palette; label already
   communicates the state (Pair ↔ Unpair). Kept as a no-op selector
   in case other code toggles .active for state tracking. */
#chat-btn.active {
  background: var(--bd-gold);
  border-color: #a07820;
  color: #1a1a20;
}

/* 2026-09-29 — the top row's labels dropped to 10px, from 11-14px, to make room
   as this row takes on more: the View button, and eventually the Local:Browse /
   Local:Create labelling.

   10px is the smallest SENSIBLE, not the smallest possible. At the usual ~0.5
   x-height ratio that is ~5px of glyph, which is the readable-but-effortful
   threshold measured for the headset work (ThreeJS_and_VR_2026-09-28.md §8b);
   below it you are recognising a word by its shape rather than reading it. The
   file's existing floor is 8-9px on #copy-link-to-ext-btn, so there is room —
   but not much, and not free: this author reads by luminance rather than hue,
   so size carries more of the load here than it would elsewhere. */
#back-btn {
  display: none;
  position: fixed;
  top: 6px;                              /* 2026-08-14 — raised from 34px into vacated title-bar row */
  /* 2026-08-14 v2 — panel-aligned on desktop; edge-flush on mobile
     (media query at bottom of #back-btn block). */
  left: 30%;
  z-index: 10;
  /* 2026-07-28 — filled signal yellow (same as #copy-link-btn) since Back
     is a primary navigation control and users were missing it in its dim
     amber-on-amber form. Same colour language reads as "important". */
  /* 2026-08-27 — background, colour and border-radius are now set INLINE by
     updateBackBtn from the node it will return you to, so the button says where
     it goes rather than only that going back is possible. These are the
     fallback for the parentless case, where the arrow stands alone. */
  background: var(--bd-gold);
  border: 2px solid rgba(255,255,255,0.5);   /* LOCAL — quiet; see the block below */
  border-radius: 4px;
  color: #1a1a20;
  /* Was 18px/700 for a lone arrow. A label needs to be readable without the
     button growing into #pair-control, so: smaller, still bold, clipped at a
     width that suits the 13-char cap truncateChipLabel applies. */
  font-size: 10px;
  font-weight: 600;
  max-width: 40vw;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  cursor: pointer;
  padding: 5px 10px;
  line-height: 1.15;
  font-family: sans-serif;
}

@media (max-width: 767px) {
  #back-btn { left: 8px; }
}

/* 2026-08-27 — the BN corner control. Same shape language as #back-btn, which
   it deliberately mirrors: both stand for a node, both wear that node's colour,
   and only the border tells them apart — dark for your own trail, MARK_BLUE for
   the partner. Placement (bottom-right of the canvas) is written by
   positionPnBtn from #cy's live rect, as for #back-btn. */
#bn-btn {
  display: none;
  position: fixed;
  bottom: 8px;
  right: 8px;
  z-index: 10;
  background: var(--bd-gold);
  border: 2px solid rgba(255,255,255,0.8);   /* REMOTE — the one with something to say */
  border-radius: 6px;
  color: #1a1a20;
  font-size: 10px;
  font-weight: 600;
  max-width: 40vw;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  cursor: pointer;
  padding: 5px 10px;
  line-height: 1.15;
  font-family: sans-serif;
}

/* The GN control — laid out by #bd-toppanel's flex row. Same shape
   language as #back-btn and #bn-btn; only the border separates the three, which
   is the whole mark vocabulary: dark for your own trail, blue for the partner,
   green for a convergence you chose to record. */
#gn-btn {
  display: none;
  position: fixed;
  top: 8px;
  left: 8px;
  z-index: 10;
  background: var(--bd-gold);
  border: 3px solid rgba(255,255,255,1);     /* COMMON — thicker AND opaque, as a snap is */
  border-radius: 6px;
  color: #1a1a20;
  font-size: 10px;
  font-weight: 600;
  max-width: 40vw;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  cursor: pointer;
  padding: 5px 10px;
  line-height: 1.15;
  font-family: sans-serif;
}
#gn-btn:hover   { filter: brightness(1.18); }

/* Clear the merged remote view. Icon-only and narrow — the other three controls
   stand for NODES and wear their colours; this one is an action, so it stays
   neutral and takes only the width it needs. Still 44px tall, because a thumb
   does not care what a button means. */
#clear-merge-btn {
  display: none;
  position: static;
  flex: 0 0 auto;
  width: 38px;
  min-width: 38px;
  height: 44px;
  box-sizing: border-box;
  background: #1a1a20;
  border: 2px solid rgba(255,255,255,0.5);   /* an action, so it takes the quiet border */
  border-radius: 6px;
  color: #cfd6e0;
  font-size: 15px;
  line-height: 1;
  cursor: pointer;
  padding: 0;
  font-family: sans-serif;
}
#clear-merge-btn.visible { display: block; }

/* 2026-09-29 — the Jump Bar's right-hand group: View and Device (and the sync
   note that belongs beside View). margin-left:auto on the WRAPPER pushes the
   pair right while the three named controls stay left — and on the wrapper
   rather than on the first button, because #av-sync-note is `hidden` most of
   the time and a margin on a hidden element aligns nothing.

   Shown only when a module is in play. body.module-node is set by
   updateSendBtn from the same test that decides whether a module exists, so
   the button cannot appear for a node it would do nothing on. It shows in
   BOTH modes: Browse needs it because Browse no longer raises the module, and
   Create keeps it because the viewer is still the way to another device. */
#jb-right {
  display: none;
  /* As far right as the row allows — `margin-left: auto` takes every spare
     pixel in the flex row, and the 4px gap (was 8) keeps the group tight so
     the pairing buttons have the left of the bar to themselves. Author's
     request, 2026-10-11, for iOS width. */
  margin-left: auto;
  align-items: center;
  gap: 4px;
  flex: 0 0 auto;
}
body.module-node #jb-right { display: flex; }
/* 2026-10-09 — a TEXT node offers View too, but not Device: there is nothing
   for another screen to watch, and a button that is present and inert is worse
   than one that is absent. Set in Create only for now. */
body.text-node #jb-right { display: flex; }
body.text-node #send-to-device-btn,
body.text-node #av-sync-note { display: none; }

/* Matching the JB's other controls: 44px for a thumb, a 2px border so the
   heights agree, and the row's 10px type. Deliberately NOT the amber of Local
   — these are destinations elsewhere, not a position here. */
#jb-right button {
  position: static;
  flex: 0 0 auto;
  /* #jump-to-ext-btn carries width:50px and white-space:normal from its old
     home in the narrow panel, where a wrapped two-line label was right. In a
     row it should size to its text and stay on one line. */
  width: auto;
  white-space: nowrap;
  font-weight: bold;
  height: 44px;
  box-sizing: border-box;
  /* 7px, was 10 — about 10% off the width of each button at this label length,
     by the author's measure, and taken from the padding rather than the font
     so the 44px thumb target and the 10px type are both untouched. */
  padding: 0 7px;
  background: #1a1a20;
  border: 2px solid rgba(255,255,255,0.5);
  border-radius: 4px;
  color: #cfd6e0;
  font-family: sans-serif;
  font-size: 10px;
  line-height: 1;
  cursor: pointer;
}
#jb-right button:hover     { filter: brightness(1.3); }
#jb-right button[hidden]   { display: none; }
#clear-merge-btn:hover   { filter: brightness(1.3); }

/* 2026-08-28 — ORPHAN BN alert. Your partner's node is not in your view, so
   there is no halo to tap and this button is the only route to them.

   FOUR pulses at 0.57s. The total is about 2.4s — roughly half the original
   five-at-1s, because the overlap now carries most of the signal by itself — but
   the RATE is ~50% quicker, because what remains is routing the user cannot get
   any other way and a slow pulse is easy to look past. Shorter overall, more
   insistent while it lasts.

   Then it stops and stays put: an alert that keeps moving after it has been read
   is costing attention and battery for nothing.

   A SPREADING RING, not a colour change — motion and luminance rather than hue,
   which is the channel to rely on for this user. */
@keyframes bn-alert-flash {
  0%, 100% { box-shadow: 0 0 0 0 rgba(74, 155, 255, 0); }
  50%      { box-shadow: 0 0 0 4px rgba(74, 155, 255, 0.9); }
}
#bn-btn.bn-alert { animation: bn-alert-flash 0.57s ease-in-out 4; }

/* Motion is the cue, so when motion is unwanted the ring simply stays on for
   the same duration rather than the alert disappearing altogether. */
@media (prefers-reduced-motion: reduce) {
  #bn-btn.bn-alert { animation: none; box-shadow: 0 0 0 4px rgba(74, 155, 255, 0.9); }
}
#bn-btn:hover   { filter: brightness(1.18); }

/* Hover must not name a colour any more — the button wears its destination's,
   which changes on every navigation, and a fixed hover yellow would contradict
   it. Brightening whatever is there works for all of them. */
#back-btn:hover {
  filter: brightness(1.18);
}

#chat-btn:disabled {
  opacity: 0.45;
  cursor: default;
}

#pair-status {
  /* 2026-08-15 — lifted OUT of the #pair-control flex flow. When pair-
     status had text like "Waiting…" or "Another BD tab on this device
     is already waiting to chat…", the shrink-wrapping flex container
     grew leftward and dragged #chat-btn across the top row into the
     radio group. Absolute-positioned under the button keeps the button
     rock-steady at right:30% regardless of what text lands here. */
  position: absolute;
  top: 100%;
  right: 0;
  margin-top: 4px;
  max-width: 30vw;
  color: #aaa;
  font-size: 10px;
  line-height: 1.2;
  text-align: right;
}


#cy-you {
  position: fixed;                       /* 2026-08-20 — BOTTOM slot, swapped with
                                            #cy-buddy (see there). */
  left: 0;                               /* full width — see #cy-buddy (no side wings; chips reflow via youCy.resize()) */
  right: 0;
  bottom: 37px;                          /* clears #media-bar (bottom: 2px, ~26px tall) */
  height: 23px;                          /* -35% from 36px (2026-06-29) */
  /* 2026-07-28 — brightened from #5a5000 to #7f6d00 so the strip is
     visibly present AND so `.hi-bread` highlight text can match it
     exactly whilst staying readable on the recessed panel bg. */
  /* 2026-08-25 — the same #8d7900 as the selection ring and the local card
     head, so the local family is ONE colour rather than a gradient of roles.
     User's call, made with the numbers in view: at this value 7 of 10 chip
     colours sit under 1.5:1 against the strip, worst 1.00:1 (Reason). Judged
     acceptable — chip separation was not the problem it was measured to be.
     If some chips do disappear, a hairline border on .breadcrumb-chip frees
     the strip colour entirely rather than forcing it dark again. */
  background: #8d7900;
  /* 2026-08-25 — fade the left edge. The trail is RIGHT-aligned, so older chips
     run off to the left and the leftmost is usually cut mid-word: "Loss" reads
     as "oss", which looks like a broken label rather than a scrolled one. An
     18px fade says "this continues". Changes how the edge READS, not what is
     drawn. */
  -webkit-mask-image: linear-gradient(to right, transparent 0, #000 18px);
          mask-image: linear-gradient(to right, transparent 0, #000 18px);
  cursor: url('cursor-wings.svg') 16 16, auto;
  z-index: 5;
}


/* 2026-08-27 — THE CONTROL PANEL. Sits directly above the canvas, is written by
   positionCyEl from the same anchor #cy uses, and is PERMANENT: it survives the
   Nodes/Edit/Player toggle, unlike every other panel in the stack.

   Depth 50px: a 44px button — the iOS/Android minimum touch target, and the
   number everything here is sized from — plus 3px above and below. The two
   retired strips came to 46; the extra 4 buys a usable thumb.

   The role label moved INSIDE the button as a second line ("Local:" over the
   node name), so no separate description row is needed above it. */
#bd-toppanel {
  position: fixed;
  left: 0;
  right: 0;
  top: 158px;                            /* set precisely by JS; fallback only */
  height: 50px;                          /* 44px button + 3px above + 3px below */
  z-index: 6;                            /* above the canvas, below the top button row */
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 0 8px;
  box-sizing: border-box;
  background: #14141a;
  border-bottom: 1px solid #2a2a32;
  overflow: hidden;
}

/* Inside the panel the three controls are laid out by flex, so every trace of
   the corner positioning goes — including #back-btn's `left: 30%` and its mobile
   override, which would otherwise still apply. */
/* 2026-08-27 — sized for a THUMB, not a cursor. 44px is the iOS/Android minimum
   touch target and is set explicitly rather than left to the content, so all
   three stay identical whatever their labels say. Borders are 2px on all three
   (the Back button was 1px) or they would not match in height.

   Two lines inside: a 10px role prefix over the 12px node name. The name is the
   content and keeps the larger size; the role is a label and recedes. Total
   type height 11 + 14.4 = 25.4, centred in the 40px interior. */
#back-btn, #bn-btn, #gn-btn {
  position: static;
  top: auto; left: auto; right: auto; bottom: auto;
  flex: 0 1 auto;
  min-width: 84px;                       /* two thumb-widths, so a short name is still hittable */
  max-width: 32%;                        /* three across a phone, with the 8px gaps */
  height: 44px;
  box-sizing: border-box;
  border-width: 2px;
  display: none;                         /* .visible re-shows as flex, below */
  flex-direction: column;
  justify-content: center;
  align-items: flex-start;
  padding: 0 9px;
  line-height: 1;
  text-align: left;
  overflow: hidden;
}
#back-btn.visible, #bn-btn.visible, #gn-btn.visible { display: flex; }

/* 2026-08-31 — THE CONTROLS SPEAK THE RINGS' LANGUAGE. Their borders carry STATE
   and are achromatic, at the three strengths the graph uses:

     Local    2px @ 0.5   quiet — you already know where you are
     Remote   2px @ 0.8   the one with something to tell you
     Common   3px @ 1.0   thicker and opaque, as a snap is on the graph

   The blue and green borders are gone with the graph colours they matched.

   BACKGROUNDS are deliberately untouched and still take the node's own colour,
   because that is CONTENT — the whole distinction the achromatic move was for.
   A control that stands for a node should still look like that node. */

.btn-role {
  display: block;
  font-size: 10px;
  line-height: 1.1;
  font-weight: 500;
  opacity: 0.72;                         /* recedes without changing hue — luminance, not colour */
  letter-spacing: 0.02em;
  white-space: nowrap;
}
.btn-name {
  display: block;
  font-size: 12px;
  line-height: 1.2;
  font-weight: 700;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 100%;
}

/* 2026-08-27 — the two breadcrumb strips are RETIRED (see BREADCRUMB_BARS in
   viewer.js). Hidden rather than removed: the markup and all the chip code stay
   whole so flipping that one constant brings the parallel view back. */
#cy-you, #cy-buddy { display: none !important; }

#cy {
  position: fixed;
  left: 0;
  right: 0;
  top: 204px;   /* set precisely by JS = panel bottom; fallback only */
  /* 2026-08-27 — was 90px, clearing the two breadcrumb strips (the upper ended
     at 63+23=86, +4 gap). Both are retired, so the canvas now runs down to the
     bottom of where the LOCAL strip was: #media-bar ends at ~28, and 37 was
     #cy-you's own bottom edge, so this reclaims 53px without touching the
     media bar. */
  bottom: 37px;
  z-index: 1;
  cursor: url('cursor-wings.svg') 16 16, auto;
  touch-action: none;
  -webkit-tap-highlight-color: transparent;
}

#cy canvas {
  cursor: url('cursor-wings.svg') 16 16, auto !important;
}

/* 2026-08-14 — panel split. #current-panel above action-bar holds
   only the newest card (or arrived remote/system/helper card); the
   older stack lives below the bar in #chat-panel (which retains its
   old id but plays the role of "History"). Together with the action-
   bar between them they form the SR-destination-over-source layout
   we'll host the Edit-mode SR controls on. */

#current-panel {
  display: flex;
  width: 40%;                           /* matches #chat-panel */
  height: 180px;                        /* 2026-08-14 v2 — bumped from 118 (+50 %) so a fresh Local card renders at ~4 rows of body height rather than 2 — matches user's "roughly the depth of a new card when created" */
  flex-shrink: 0;
  align-self: center;
  background: rgba(61, 46, 0, 0.55);
  align-items: flex-start;
  gap: 8px;
  padding: 8px;
}
@media (max-width: 767px) {
  #current-panel { width: 100%; }
}
#current-stack {
  flex: 1;
  min-width: 0;
  width: 100%;
  height: 100%;
  min-height: 0;                        /* iOS: lets internal overflow-y actually activate inside a flex column */
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  align-content: flex-start;
  gap: 6px;
  padding: 4px;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  touch-action: pan-y;
  background: #0a0a1a;
  border: 1px solid #444;
  border-radius: 6px;
}

#chat-panel {
  display: none;
  width: 40%;                           /* A51e — matches #default-panel so the panel width
                                           is constant whether chat-mode is on or off */
  height: 20dvh;                        /* 2026-08-17 — history pane cut 25dvh → 20dvh (−20%, matching the card-text shrink) to hand vertical space to #cy; #current-panel depth left unchanged per user. Was 33dvh (2026-08-14). */
  flex-shrink: 0;
  align-self: center;
  /* 2026-07-28 — dropped from solid #3d2e00 to rgba(...,0.55) so the frame
     recedes visually. Panel is still there, but reads less as a bright
     rectangle imposed on the page. */
  background: rgba(61, 46, 0, 0.55);
  align-items: flex-start;
  gap: 8px;
  padding: 8px;
}

@media (max-width: 767px) {
  #chat-panel { width: 100%; }
}

#chat-panel.active {
  display: flex;
}

/* Action bar (A50j) — full-width strip under whichever panel is showing
   (chat or default). Always visible. Save left-aligned, Send to its right,
   then the status span. Dark amber to pair visually with the panels above
   it (A50k). */
/* Phone-in-landscape overlay (2026-08-05 v10) — BD's layout isn't feasible
   on a phone in landscape; show a rotate-to-portrait message instead of
   trying to squeeze into ~350×160 px of usable viewport. Fires only on
   small viewport (< 768 px) AND landscape orientation. Hidden on desktop
   landscape and phone-portrait. */
#rotate-to-portrait {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 10000;
  background: rgba(10, 8, 4, 0.96);
  color: var(--bd-gold);
  align-items: center;
  justify-content: center;
  font-family: Georgia, 'Times New Roman', serif;
  font-size: 18px;
  text-align: center;
  padding: 20px;
  line-height: 1.4;
}
/* v14: was `max-width: 767px and orientation: landscape` but a rotated
   phone has width > 767 in landscape (e.g. iPhone 14 Pro Max = 932 wide).
   Detect short-landscape via max-height instead. Any device where the
   viewport height is ≤ 500 px AND orientation is landscape counts as a
   phone or small tablet that can't fit BD's layout.
   v15 (2026-08-10): add a second rule for iPad-in-landscape — heights
   are 768–1024 (well above the 500 threshold) but aspect-ratio is 4:3
   or thinner, which never fits BD's layout well. Desktop 16:9/16:10
   monitors have aspect-ratio ≥ 1.6 so aren't caught. Phone landscape
   has aspect-ratio ~2:1 so is caught only by the max-height rule. */
/* v16 (2026-08-18): gate on (pointer: coarse) — a TOUCH primary input.
   A desktop mouse reports (pointer: fine), so narrowing a desktop browser
   window (which passes through landscape + aspect ≤ 4/3 en route to a
   two-window layout) no longer wrongly triggers the rotate overlay.
   Phones/tablets report (pointer: coarse) and still get it. */
@media (orientation: landscape) and (max-height: 500px) and (pointer: coarse),
       (orientation: landscape) and (max-aspect-ratio: 4/3) and (pointer: coarse) {
  #rotate-to-portrait { display: flex; }
}

#action-bar {
  display: flex;
  align-items: center;
  gap: 8px;
  /* 2026-08-14 v2 — shrunk to match panel width (40%) and centred so it
     visually sits under #current-panel and above #chat-panel as their
     shared divider / control strip. Grey background (was dark amber)
     helps it read as a chrome divider rather than a third amber panel.
     Mobile override restores full-width. */
  width: 40%;
  align-self: center;
  background: rgba(70, 70, 70, 0.55);
  padding: 4px 8px;
  /* 2026-08-15 — fixed min-height so the bar's depth stays constant
     across Nodes / Edit / Player modes. Without this, entering Edit
     mode surfaces Send + New and the bar grows ~22 px, pushing
     #chat-panel down and shrinking #cy below it. 34 px = ~22 px
     tallest child (Send/New with border) + 8 px total padding + a
     hair of breathing room. */
  min-height: 34px;
  box-sizing: border-box;
  flex-shrink: 0;
}
@media (max-width: 767px) {
  #action-bar { width: 100%; }
}

/* 2026-08-17 — Nodes-mode single reading pane. In Nodes mode the user only
   reads + navigates, so the Current/History split, the dividing action-bar
   strip and its compose tools are idle. Collapse to ONE scrollable pane: all
   cards live in the (taller) Current pane, History is hidden, and the bar box
   is zero-height — handing the reclaimed depth to #cy (positionCyEl anchors #cy
   to #current-panel in this mode). The mode radios (top) and Copy Link (bottom)
   are position:fixed, so the collapsed bar box doesn't hide them. Card DOM is
   merged into the single pane on mode toggle (reflowCardsForMode). Edit/Player
   restore the split via body.edit-active / body.player-active. */
body:not(.edit-active):not(.player-active) #chat-panel.active { display: none; }
body:not(.edit-active):not(.player-active) #current-panel { height: 34dvh; }
body:not(.edit-active):not(.player-active) #action-bar {
  min-height: 0;
  height: 0;
  padding: 0;
  overflow: hidden;
  background: transparent;
}

/* Default (system-message) panel — visible whenever the chat panel is not active */
#default-panel {
  display: flex;
  width: 40%;
  height: 34dvh;                       /* doubled from 17dvh on 2026-06-23 to give system messages more room */
  flex-shrink: 0;
  align-self: center;
  /* 2026-07-28 — matched to #chat-panel: rgba dark-amber at 0.55 alpha
     so the frame recedes. */
  background: rgba(61, 46, 0, 0.55);
  align-items: flex-start;
  gap: 8px;
  padding: 8px;
}

@media (max-width: 767px) {
  #default-panel { width: 100%; }
}

#chat-panel.active ~ #default-panel { display: none; }

#default-stack {
  flex: 1;
  min-width: 0;
  width: 100%;
  height: 100%;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  align-content: flex-start;
  gap: 6px;
  padding: 4px;
  overflow-y: auto;
  background: #0a0a1a;
  border: 1px solid #444;
  border-radius: 6px;
}

.card.system {
  background: #1c1a30;
}

/* default-panel system card is the panel's only scroller — it must stretch */
#default-stack .card.system {
  flex: 1;                             /* fill the default-stack vertically */
}

#default-stack .card.system .card-body {
  position: relative;                  /* offsetParent for inserted spans */
  flex: 1;                             /* fill the card minus the head */
  min-height: 0;                       /* allow shrink-to-overflow inside flex parent */
  white-space: pre-wrap;
  word-wrap: break-word;
  overflow-y: auto;
}

/* Panel-hosted system card — pure black body; head stripe carries the kind
   signal. 2026-08-14 — extended to #current-stack (system cards may live in
   either half of the split now). */
#chat-stack .card.system,
#current-stack .card.system {
  background: #000;
}

#chat-stack {
  flex: 1;
  min-width: 0;
  width: 100%;
  height: 100%;
  min-height: 0;                       /* 2026-08-14 — iOS: lets internal overflow-y actually activate inside a flex column (same fix used in sr_editor.html + #current-stack) */
  box-sizing: border-box;
  display: flex;
  flex-direction: column;             /* top-down; newest card prepended so it sits at top */
  align-content: flex-start;
  gap: 6px;
  padding: 4px;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  touch-action: pan-y;
  background: #0a0a1a;
  border: 1px solid #444;
  border-radius: 6px;
}

.card { flex: 0 0 auto; }              /* don't stretch to fill the panel */

.card {
  display: flex;
  flex-direction: column;
  border: 1px solid #333;
  border-radius: 4px;
  overflow: hidden;
}

.card-head {
  padding: 3px 8px;
  background: rgba(255,255,255,0.04);
  color: #ffffff;
  font-family: sans-serif;
  font-size: 12px;
  border-bottom: 1px solid #2a2a3a;
  user-select: none;
}

/* Card heads colour-coded to mirror the bar / title palette. White text
   throughout — body backgrounds are very dark so the head reads as a
   distinct stripe. Local takes the brighter yellow (user's compose
   area should stand out); system fades back to the dimmer green. */
/* 2026-09-01 — the heads now speak the RINGS' language, achromatically. Colour
   had been saying "whose voice" here, which is the job it gave up on the graph;
   the local gold was also matched to a selection ring that no longer exists.

     local     mid grey, WHITE text    quiet — the ring at 0.5, as a solid
     remote    white, black text       strong — the ring at 0.8, likewise
     helper    very dark, white text   neither of you, and recedes

   Local keeps WHITE text, so it and the helper both speak in white and only
   remote inverts — which is the one that should stand out.

   #707070, not the half-white #808080 the ring literally composites to: white on
   #808080 is 3.9:1, below AA. #707070 is 5.0:1 and reads as the same mid grey.
   The grey had to move because the TEXT colour was chosen first — legibility
   decides, then the shade gets as close to the ring as it can. */
.card.local    .card-head { background: #707070; color: #ffffff; }
.card.received .card-head { background: #ffffff; color: #1a1a20; }
/* 2026-07-16 — system card head now uses the Root-node fill colour
   (#FFD700, bright gold). Dark text (#1a1a20) required — white text
   on bright yellow fails luminance contrast (see [[user-colour-vision]]).
   2026-08-14 — extended to #current-stack. */
/* 2026-08-30 — LIGHT GREY, not gold. The card heads say WHO: gold for you, navy
   for your partner, and the system was wearing a third gold — so two of the
   three voices shared a colour, and the graph's local ring has just moved to a
   strong yellow, which made the clash louder.
   Grey is the honest answer: the system is neither of you, and a neutral says
   that without needing a colour of its own. 10.8:1 against the dark text. */
#chat-stack .card.system .card-head,
#current-stack .card.system .card-head { background: #232329; color: #ffffff; }

.card-body {
  width: 100%;
  box-sizing: border-box;
  background: transparent;
  color: #ffffff;
  border: 0;
  padding: 8px 10px;
  font-family: sans-serif;
  font-size: 16px;
  line-height: 1.6;
  resize: none;
  outline: none;
  min-height: 8.5em;                  /* ~5 lines visible; with ~1.5em head ≈ 10em per card → 3 cards fill the 33dvh panel */
}

/* Non-editable cards size to content — no writing room needed. The 8.5em
   floor is for the local textarea. System / received bodies are divs that
   shrink-wrap their text, freeing vertical space in the chat-stack.
   white-space: pre-wrap so \n\n in server-sent helper messages renders
   as a paragraph break rather than collapsing to a single space
   (2026-07-16 — needed for the multi-paragraph Helper message on
   ready_to_pair with no partner). */
#chat-stack .card.system .card-body,
#current-stack .card.system .card-body,
.card.received .card-body {
  min-height: 0;
  white-space: pre-wrap;
  /* 2026-08-17 — reading/partner text shrunk 20% (16px → 12.8px) to reclaim
     vertical room for #cy. These bodies are divs, so unlike the editable
     local textarea (kept at 16px) they don't trip iOS's <16px auto-zoom.
     ── THAT LAST CLAUSE WAS ALREADY FALSE WHEN IT WAS WRITTEN (2026-10-21).
     The divs were made `contentEditable = 'true'` on 2026-07-25 — "all card
     bodies editable by default" — and this shrink came three weeks later,
     reasoning from the state before that. A CONTENTEDITABLE DIV TRIPS iOS's
     AUTO-ZOOM EXACTLY AS AN INPUT DOES: it is focus that triggers it, not the
     tag name. Reported by the author as the page zooming on going to edit in
     the panel, "and if one is careless everything is properly messed up".
     The 12.8px is kept for bodies that are genuinely not editable — Helper and
     notice cards, which are created with contentEditable 'false' and cannot
     be focused — and overridden below for the ones that can. */
  font-size: 12.8px;
}

/* ── AN EDITABLE BODY IS 16px, WHATEVER TAG IT IS (2026-10-21) ───────────
   The project's rule is "every focusable thing is at least 16px or iOS Safari
   zooms the page on focus", and it had been applied by TAG — textareas got
   16px, divs were assumed safe. The discriminator is FOCUSABILITY, not the
   tag, so it is written here as the attribute that actually decides:
   contenteditable. A Helper card keeps 12.8px because it is contentEditable
   'false' and cannot be focused at all.

   The selectors mirror the ones above with the attribute appended, so they win
   on specificity rather than on !important — (1,4,0) against (1,3,0).

   THE COST, which is real and was the point of the shrink: the node's own text
   in Create is one of these bodies, so it returns to 16px and reclaims the 20%
   of vertical room that was taken from #cy. Worth it — a page that zooms on
   every edit and has to be pinched back is a worse tax than a shorter graph,
   and a mis-pinch leaves the whole layout wrong. */
#chat-stack .card.system .card-body[contenteditable="true"],
#current-stack .card.system .card-body[contenteditable="true"],
.card.received .card-body[contenteditable="true"] {
  font-size: 16px;
}

/* 2026-07-24 — chunked node-message cards (system-kind, one per chunk).
   The body holds a .chunk-text paragraph followed by a .chunk-hint line.
   No blank line between them; the hint is centred + accent-coloured +
   italic so it stands out as a directive from the tool. */
.card.system .card-body .chunk-text {
  white-space: pre-wrap;
}
/* 2026-07-28 — author-triggered centre alignment via `%%bd_center` line
   directive in node text. Everything after that line becomes centred so
   short call-to-action tails read as one visual unit. Sits close to the
   left-aligned intro above (just a newline's worth of space) so the two
   read as one continuous chunk; the real breathing room goes below, via
   .chunk-hint's margin-top — that stops the injected tap hint from
   looking like it refers to the coloured phrase just above it. */
.card.system .card-body .chunk-text--center {
  text-align: center;
  margin-top: 4px;
}
/* 2026-08-30 — the helper's grey, not amber. (2026-09-01: the head behind it is
   now very dark, so this grey sits on dark rather than on light — still the
   system's voice, still distinct from the amber it replaced.) These hints ARE the system
   speaking ("Tap the ButterflyDreaming node below..."), so they belong to the
   same voice as the header above them. Amber made them read as a local mark
   living inside a system card — and now that the graph's local ring is a strong
   yellow, that misreading would only have got worse.
   10.6:1 on the card body, against the amber's 7.0. */
.card.system .card-body .chunk-hint {
  text-align: center;
  color: #C9CCD1;
  font-style: italic;
  /* 2026-07-28 — line-space above the tap hint so it reads as a
     separate advisory, not as a caption/continuation of the sentence
     immediately above (which especially matters when that sentence
     names a UI element like the Back button). */
  margin-top: 22px;
}

/* 2026-07-24 — TextNode chunk cards dim head + hint to 0.5 opacity so the
   actual text content is the star. Header and hint are still visible for
   orientation but don't compete with the reading experience. */
.card.system.text-reading .card-head,
.card.system.text-reading .card-body .chunk-hint {
  opacity: 0.5;
}

/* 2026-07-28 — Inline colour highlights inside chunk text (and hints).
   Curator uses `<<yellow>>…<</>>` or `<<bread>>…<</>>` in node text to
   paint short phrases the same colour as the UI element they name —
   Back button (yellow), local breadcrumb strip (bread). Font weight
   nudged up so the highlight reads clearly against the panel bg. */
.hi-yellow {
  color: var(--bd-gold);
  font-weight: 600;
}
.hi-bread {
  /* Foreground gold — the bright end of the family, matching the selected-node
     mark rather than the dark backdrops it highlights within.
     (was an exact match with #cy-you and .card.local .card-head)
     so the reader sees the same colour on the sentence and on the
     local breadcrumb strip / local card headers. */
  color: var(--bd-gold);
  font-weight: 700;
}

/* Previous chunk cards dim to 0.4; newest chunk goes to 1.0. Managed
   via inline style: createCard writes `el.style.opacity = 0.85` on every
   card, and inline beats class selectors here — so insertNodeChunkAsCard
   sets opacity on card.el.style directly. No CSS rule needed. */

/* Body backgrounds — pure black for all kinds. The coloured head stripe
   already signals kind, and dim hue tints in the body were proving more
   confusing than helpful. N=k labels distinguish adjacent local cards. */
.card.local    { background: #000; }
.card.received { background: #000; }

/* Hidden ghost N=0 — conceptual anchor only, never shown */
.card.card-hidden {
  display: none;
}
.card-delivered {
  margin-left: 0.6em;
  font-size: 0.85em;
  opacity: 0.8;
  font-style: italic;
}

/* 2026-07-25 — #default-save-btn and #default-save-status styles removed:
   the Save button was orphaned by always-on-chat (default panel never got
   the bdName/bdLabel metadata Save required) and has been retired. Node
   text editing lives on the bd_tool.js write path via nav_nodes_text.md. */

/* 2026-08-15 — Send + New + SR mic controls hidden by default; only
   surfaced when Edit mode is engaged (body.edit-active toggled by
   setViewMode('edit')). User's spec: these are compose-side controls
   that belong in Edit mode; the top row's Nodes/Player/Edit radio is
   what governs when they're relevant. */
#chat-send-btn,
#chat-new-card-btn,
#sr-mic-btn,
#sr-level-meter,
#sr-accept-btn,
#sr-wrap-btn {
  display: none;
}
#chat-send-btn,
#chat-new-card-btn {
  background: #1a1a2e;
  border: 1px solid #555;
  border-radius: 3px;
  color: #cccccc;
  font-family: sans-serif;
  font-size: 10px;
  line-height: 1.1;
  padding: 3px 6px;
  cursor: pointer;
  text-align: center;
}
body.edit-active #chat-send-btn,
body.edit-active #chat-new-card-btn {
  display: inline-block;
}

/* ── Collage is a Create-mode action (author, 2026-10-20) ────────────────
   The SAME test that hides #chat-panel above, because it is the same
   question: is there a visible card? Collage writes into the top card, and in
   Browse the card stack is not on screen, so the button offered to modify
   something invisible. Not `body.edit-active` alone — Player mode is where it
   is actually used, looking at a module and pressing Collage.
   Guarded in viewer.js too, because this one WRITES on a single press and a
   stylesheet is not a safety mechanism. */
body:not(.edit-active):not(.player-active) #collage-btn { display: none; }

/* ── Explore sessions — RETIRED 2026-08-28 ───────────────────────────────
   The button, its four data-state colours and the heartbeat animation are
   gone with the offer/accept protocol they served. A GN is minted by a
   deliberate arrival at the partner's position, so there was nothing left for
   the control to do.

   Two things from it are worth re-reading before building the SAVE consent
   step, and both are in git rather than here: the states were carried by the
   LABEL as well as by colour, never by colour alone; and the heartbeat pulsed
   for 3.4s in each 20s cycle for six cycles, then stopped — because an alert
   nobody has answered in two minutes has become wallpaper, and the movement is
   costing attention and battery for nothing. The Remote button's orphan flash
   follows the same reasoning at a shorter scale. */

body.edit-active #sr-mic-btn    { display: inline-block; }
body.edit-active #sr-level-meter { display: block; }
body.edit-active #sr-accept-btn  { display: inline-block; }
body.edit-active #sr-wrap-btn    { display: inline-block; }

/* Accept-{?…} and Wrap-{?} buttons — subdued vs. the primary yellow
   Mic, since they're relevant after transcription / while reviewing
   and don't need to compete for attention. Same size language as
   Send / New. */
#sr-accept-btn,
#sr-wrap-btn {
  background: #1a1a2e;
  border: 1px solid #555;
  border-radius: 3px;
  color: #cccccc;
  font-family: sans-serif;
  font-size: 11px;
  font-weight: 500;
  line-height: 1.2;
  padding: 3px 8px;
  cursor: pointer;
  touch-action: manipulation;
}
#sr-accept-btn:hover,
#sr-wrap-btn:hover:not(:disabled) { border-color: #aaa; color: #fff; }
#sr-wrap-btn:disabled { opacity: 0.4; cursor: not-allowed; }

/* SR mic button — three-state hybrid.
   - default (idle):  "🎤 Use Mic" — amber, ready for click-to-grant
   - .listening:      "🎤 Recording…" — red, holding to capture
   - .processing:     "🎤 Transcribing…" — dim, waiting on Whisper */
#sr-mic-btn {
  background: var(--bd-gold);
  border: 1px solid #a07820;
  border-radius: 4px;
  color: #1a1a20;
  font-family: sans-serif;
  /* 2026-08-16 — icon-only across all states; width halved (80→40 min).
     Font bumped 11→14 so the emoji reads at a comfortable size on iPhone
     without the "Use Mic" text carrying it. */
  font-size: 14px;
  font-weight: 600;
  line-height: 1;
  padding: 3px 6px;
  cursor: pointer;
  min-width: 40px;
  touch-action: manipulation;
}
#sr-mic-btn.listening {
  background: #d03030;
  border-color: #ff5555;
  color: #ffffff;
}
#sr-mic-btn.processing {
  background: #444;
  border-color: #666;
  color: #ccc;
  opacity: 0.7;
  cursor: default;
}
#sr-mic-btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

/* Level meter — thin bar next to the mic button.
   Bar fills 0-100% width; green normal, amber loud, red clipping. */
#sr-level-meter {
  width: 60px;
  height: 8px;
  background: #1a1a2e;
  border: 1px solid #444;
  border-radius: 2px;
  position: relative;
  overflow: hidden;
}
#sr-level-meter .bar {
  height: 100%;
  width: 0%;
  background: #4caf50;
  transition: width 60ms linear;
}
#sr-level-meter .bar.mid { background: var(--bd-gold); }
#sr-level-meter .bar.hot { background: #ff5555; }

/* SR mic primer — appears in Edit mode over the panel area while the
   browser permission prompt is up, and again on any transcription
   error state. Positioned to be visible without depending on the user
   looking at the source pane. */
#sr-mic-primer {
  position: fixed;
  left: 50%;
  top: 90px;                            /* below the yellow top row */
  transform: translateX(-50%);
  z-index: 25;
  max-width: 460px;
  padding: 10px 14px;
  background: rgba(0, 40, 90, 0.95);
  border: 1px solid #4080ff;
  border-radius: 6px;
  color: #ffffff;
  font-family: sans-serif;
  font-size: 13px;
  line-height: 1.4;
  pointer-events: none;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
}
#sr-mic-primer[hidden] { display: none; }

#chat-send-btn {
  font-size: 11px;
  padding: 4px 10px;
}

/* 2026-07-28 — enabled Send button pops to white so the "ready to send"
   state is unmistakable. Disabled state keeps the #cccccc + 0.4 opacity
   dim; hover state further brightens border. */
#chat-send-btn:not(:disabled) {
  color: #fff;
}

#chat-send-btn:hover:not(:disabled),
#chat-new-card-btn:hover {
  border-color: #aaa;
  color: #fff;
}

#chat-send-btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

/* 2026-07-25 — Renamed "New Card" → "New / Edit". 2026-08-15 — trimmed
   to just "New". 2026-08-16 — width shrunk (min-width 60→36, padding
   tighter) since single 3-char label needs no more room. */
#chat-new-card-btn {
  background: #1a1a2e;
  border: 1px solid #555;
  border-radius: 3px;
  color: #cccccc;
  font-family: sans-serif;
  font-size: 10px;
  line-height: 1.2;
  padding: 4px 6px;
  cursor: pointer;
  text-align: center;
  min-width: 36px;
  flex-shrink: 0;
}
#chat-new-card-btn:hover { border-color: #aaa; color: #fff; }

/* Copy Link — bottom-row utility strip, same visual family as #media-bar
   and #user-count-panel. CANARY host: border colour rotates
   red → green → blue per commit to ANY file BD serves to a browser —
   including the media modules under /bd_V_Kolam, /bd_M_ABC, /bd_M_Fractal,
   which have no canary host of their own. A module change that leaves this
   border alone is a module change the user cannot verify arrived.
   (Do not argue from Cache-Control. This file is served public,max-age=0
   while the Kolam renderer is served no-store — the canary host is the MORE
   cacheable of the two. The canary reports what the user can SEE.)

   2026-10-09k: RED (the build marker was a NESTED :root rule inside the
   :root block — invalid, and it was dropping --bd-gold; and "preparing…"
   tested speakBusy, which means "working" not "audible"). Next = green.

   2026-10-09j: BLUE ("preparing…" is poll-driven now — the one-shot version
   never appeared, because on a first use the download message claims the
   bar first and then leaves it empty through the synthesis). Next = red.

   2026-10-09i: GREEN (rotated on request, to settle whether an iOS device is
   seeing current CSS at all — and viewer.js now LOGS the computed border
   against what it expects, so the question is answerable from the server
   log instead of from memory of a colour). Next = blue.

   2026-10-09h: RED (View's Speak says "preparing…" in the bar before the
   first words — synthesis costs about a second even with the voice cached,
   and speechProgress only speaks up during a download). Next = green.

   2026-10-09g: BLUE (View's Speak now puts the CONSENT question where the
   user is rather than sending them out to find a checkbox, and shows the
   download progress in its own bar. The first-use figure was 60 MB and is
   measured at 31). Next = red.

   2026-10-09f: GREEN (View's text panel gains SPEAK — stops and restarts on
   press, stops on Back; the Speak toggle is not bypassed because that is
   where the voice-model download is consented to). Next = blue.

   2026-10-09e: RED (View's reference line brightened to #9e9e90 — measured,
   #5a5a62 was 2.94:1 and failed both WCAG thresholds, so "dim grey" had
   become unreadable; 13px rather than 11). Next = green.

   2026-10-24b: BLUE (THE STEPPER COLUMN SAYS THERE IS MORE BELOW. 24 control
   rows at 94px is ~2,200px of column in a panel a few hundred tall, and the
   only clue was a scrollbar iOS does not draw until you are already scrolling.
   Pure CSS, no JS: two shadow layers attached to the SCROLLER and two cover
   layers attached to the CONTENT, so each half appears only while there is
   somewhere to scroll and retires at its own end — a state machine with no
   state. Luminance, not hue. It needed a bug fixed first: the phone rule
   re-declared `background` as a SHORTHAND with !important, which would have
   wiped the layers on exactly the screens that need them; it is now
   background-color, which is all it ever wanted, and the amber is one
   --panel-amber variable instead of five literals. Also, on the author's
   reminder that BD ONLY ALLOWS PORTRAIT ON MOBILES, two comments in BOTH
   visual modules were corrected: "Default (landscape)" could only ever have
   meant desktop, and "overrides to a bottom band" is simply false — 108px of
   flex-basis in a row layout is a WIDTH, so the panel stays a side column. The
   identical sentence with the identical 108px sits in both files, so it was
   true of a layout neither has any more). Next = red.

   2026-10-24a: GREEN (the copy notice's third sentence is BACK, as a stated
   PLAN rather than an instruction — the author's call: "useful for current
   visitors to see what the system is moving towards". Save-as-a-new-node still
   does not exist (clone is Cluster-only, Sv overwrites the node it is on,
   pairing is not a write credential), so the wording is "Planned: readers
   working in a pair will be able to save new work as a node of its own". The
   author's draft read "consider saving new work as a node", which is an
   instruction — a paired reader would hunt for a control that is not there,
   and a notice whose whole purpose is to correct a false expectation must not
   create a second one. Ungated and shown to everyone, deliberately: advertising
   a direction is for the public visitor above all, and it would otherwise have
   been the third different gate tried on one sentence). Next = blue.

   2026-10-23k: RED (THE NOTICE DROPS ITS THIRD SENTENCE, because the thing it
   would point at IS NOT BUILT. The author wanted it to name the POSSIBILITY of
   creating a node of your own; checked against the code, there is none.
   edit_clone_cluster matches `(c:Cluster {name: $newName})` and the client
   sends sourceCluster — Cluster-only, so no content node can be cloned into
   one of yours. Sv writes over THIS node, which is the opposite of what an
   explorer wants. And pairing is not a write credential in the first place. So
   the notice is two sentences and the reasoning sits in a comment beside it, to
   stop a plausible-sounding promise being re-added. SAVE AS A NEW NODE is now
   an open item in PLANNING_REGISTER rather than a sentence pretending it
   exists). Next = green.

   2026-10-23j: BLUE (PAIRING IS NOT A WRITE CREDENTIAL. The author asked
   whether the save option would be flagged for a paired user; it would not
   have been, and the question exposed two errors at once. WRONG MECHANISM:
   every write handler in server.js checks curationCodeOk — the CURATION code —
   while pairing uses a different one via pairCodePresent(), so a paired user
   without curation access cannot save and someone with it can save unpaired.
   WRONG ACT: there is no "save as a new node" at all; the write path for a
   script is Sv, which saves TEXT where Wr saves LAYOUT. The sentence now reads
   "with curation access, Sv saves the script to the node itself" and is gated
   on curationCodePresent(). A public visitor has neither code and still sees
   nothing extra, which was the point of gating it — promising a route that does
   not exist is worse than silence). Next = red.

   2026-10-23i: GREEN (A NOTICE MUST NOT OUTLIVE THE STATE IT DESCRIBES. The
   copy-scope notice sat on over the Create preview after Back from View, where
   its own words — "press Back" — had stopped being true. Dismissed now on
   either View toggle and on opening another node: the three ways out of the
   situation it describes. And "switch to Create" is GONE from the wording, on
   the author's point, which follows from the notice's own trigger: the steppers
   and the card are only reachable in Create, so anyone whose script has
   diverged was ALREADY there, and telling someone to go where they are standing
   reads as not knowing where they are. The sentence is context-sensitive for the
   same reason — from View there is a Back to press, from the graph side there is
   not). Next = blue.

   2026-10-23h: RED (!important DOES NOT BEAT SPECIFICITY WHEN BOTH SIDES HAVE
   IT — and I was wrong about this twice in one day, in a comment asserting the
   opposite. View's whitelist is `display: none !important`, so an exemption
   marked !important does not win; the two compete on specificity and the
   whitelist's SEVEN :not()-carried ids beat the exemption's one. Both
   exemptions added today were silently losing. The author found it from the
   one symptom that identifies it: the copy notice appeared only AFTER leaving
   View — an element that existed all along and was merely hidden. Fixed where
   it belonged all along, by naming #copy-link-fallback and #copy-scope-notice
   in the whitelist itself, which IS the single statement of what survives
   View. A LIST WRITTEN TWICE WILL BE CHANGED ONCE: a second rule arguing with
   the list is not an addition to it, it is a competitor). Next = green.

   2026-10-23g: BLUE (A NODE URL NAMES THE NODE AS STORED, and a reader who has
   worked the steppers is looking at their own exploration — so the button was
   lying by omission, technically accurate and useless to whoever shares it. The
   author's catch. A notice now says so, but ONLY when the card's script has
   actually diverged from the node's: COMPARED, not assumed, and the payload's
   own tier-3 fallback is the node's text so a node with no card text raises
   nothing. Normalised per line, since trailing space and blank lines are not
   changes a reader made — eight cases unit-tested. Not a blocking alert: View is
   where someone sits and watches the figure move, and withUpdatePrompt had just
   been removed from this very button for being friction. Wired for BOTH Copy
   Link buttons, because the question belongs to the LINK and not to whichever
   button made it. And the sentence about saving while paired is GATED on
   pairing being possible — pairAllowed needs pairCodePresent(), so a public
   visitor's Pair button is hidden outright and promising them a way to save
   would be worse than silence; asked of the button rather than re-deriving the
   rule, so it cannot drift). Next = red.

   2026-10-23f: GREEN (the view bar's button says COPY LINK, on two lines at
   10px — the author's instruction was the full name without widening the bar,
   and height was the dimension going spare: the bar is 44px and two 10px lines
   take 23. It ends up NARROWER than the one-word version it replaces, ~38px
   against ~56, so the full label buys width back. innerHTML is cached for the
   Copied! flash, because a textContent round-trip would collapse the <br> and
   leave "CopyLink" on one line ever after — the trap #copy-link-btn documents
   for its own two-line label, met again on a button of the same shape).
   Next = blue.

   2026-10-23e: RED (COPY IN THE VIEW BAR — I had built a path nobody could
   walk. Copy Link grew a ?view=1 branch that no user could reach, because View
   hides the whole action bar: everything but a seven-item whitelist. The author
   found it by trying to run the test. So the view bar gains a Copy button,
   wired in the scope that already owns copyLinkText and showFallback rather
   than at init()'s level, which would have meant a second clipboard
   implementation. Space was MEASURED, not assumed: at 390px Back + Speak + the
   hint + Copy overflow by ~35px, so the hint now truncates (flex 0 1 auto — it
   describes a gesture, the one thing a reader can lose half of) and the title
   goes below 430px on the author's offer, needing min-width:0 or a flex child
   refuses to shrink below its content and pushes Copy off instead. And the
   clipboard FALLBACK is exempted from View's whitelist, or the one case where
   copying fails would be the one case where its text cannot be seen —
   !important because that whitelist carries seven ids inside its :not()s).
   Next = green.

   2026-10-23d: BLUE (THE ADDRESS BAR IS A LINK, NOT A STATUS DISPLAY — the
   author's correction, and it overturns the reasoning one rotation old. I had
   stripped ?view=1 on arrival because a flag in the bar would contradict the
   screen once View was closed. But "this link opens the graphic" stays TRUE
   after Back is pressed, and a flag left in the bar is what lets someone
   copy-paste the graphic to another person, which is the entire point of a
   permanent URL. So canonicalNodeUrl now carries the flag whenever view-active
   is set, and two extra listeners on #jump-to-ext-btn and #bd-view-exit re-sync
   the bar on the way in and out — deferred one frame, because the class being
   read is the one the press is about to toggle. Wired as listeners rather than
   inside bdViewEnter, which is nested far below init()'s block level). Next =
   red.

   2026-10-23c: GREEN (?view=1 LANDED ON THE TEXT, and the flag vanished from
   the address bar — ONE cause wearing two symptoms. syncAddressBar replaces the
   URL with the bare /n/<uuid> DURING the open, so the handler that read
   location.search afterwards found nothing and never pressed View. The flag is
   now captured at load beside NODE_URL_UUID, which is what this file already
   says about ARRIVED_VIA_LINK four hundred lines above: read it at load,
   because the thing that consumes it runs later than the thing that destroys
   it. The bar stays bare on purpose — a flag there would have to track View
   being toggled, in a scope the arrival cannot reach, and would soon state the
   opposite of the truth; Copy Link supplies it from the live class instead.
   Also: the bar no longer rewrites itself at BOOT, because Root has a url of
   its own and typing the plain domain must leave the plain domain showing).
   Next = blue.

   2026-10-23b: RED (EVERY NODE NOW HAS A WORKING PERMANENT URL. `/n/<uuid>` is
   served by server.js and resolved by viewer.js against cy, and the address bar
   FOLLOWS THE READING via replaceState in advanceOrNavigate's fresh branch — so
   the browser's own URL is the canonical link and copying it needs no button.
   The id is not new: every node has carried url='butterflydreaming.org/n/<uuid>'
   since MM1, and nothing ever resolved it, because BD's deep links are state
   blobs naming a SCRIPT. Matched on the path and never the host, since .org is
   stored, .net is now tunnelled and graph.virtualfictions.uk serves — three
   names, one node, and the stored url untouched because it is identity. The
   sub-path also forced four relative URLs absolute (style.css,
   module_wire_tables.js, viewer.js, speech_lexicon.json): at /n/<uuid> each
   would have 404'd as /n/..., which is a blank page rather than a degraded
   one). Next = green.

   2026-10-14m: RED (UNHANDLED REJECTION FROM THE PREFETCH. "Unhandled rejection:
   run@viewer.js:1993" — `run` is the stale check inside prepareUtterance, and
   the prefetch promise is parked in speakAhead where an interrupt nulls the
   reference without ever attaching a handler. A no-op catch marks it handled;
   the stored promise still resolves for its consumer, a promise carrying more
   than one handler. The lesson is about the FIX: before the stale check a
   rejection meant a rare genuine failure, so a dangling promise never
   surfaced — making stale work reject ROUTINELY turned that into one on every
   interrupt). Next = green.

   2026-10-14l: BLUE (maxLen 400 -> 150, a LATENCY BUDGET rather than a tidiness
   rule. With the backlog gone every remaining long wait was at normal queue
   depth and on a module-description node, and the rate is strikingly constant:
   about 50 ms per CHARACTER, so the 12-21 second waits were simply
   maxLen-sized utterances. Speech plays at ~70 ms/char, so synthesis is 1.4x
   faster than playback — which is why the prefetch keeps up once reading starts
   and why the only unhidden cost is the FIRST utterance, priced by its length.
   150 caps the wait before anything is heard at about 7.5 s. Verse is
   unaffected: its units are lines). Next = red.

   2026-10-14k: GREEN (THE FIRST-FRAGMENT MERGE IS REVERTED. It existed for an
   hour. The diagnosis behind it was right — the gap after "Chapter 11." is the
   next line's synthesis not finishing — but the remedy lengthened the FIRST
   clip and so made the start of every reading two to three times slower.
   Author: "reading start is much slower than it used to be." Which settles the
   trade that was genuinely open: a gap in the middle is cheaper than a delay at
   the beginning, because silence before anything starts is the only silence the
   listener is certain to notice. Also added: the synthesis queue DEPTH on every
   slow-synth line, so a backlog is one line rather than a pattern across
   twenty). Next = blue.

   2026-10-14j: RED (A SYNTHESIS BACKLOG, named by the log: slow synth 14622ms,
   10963, 15332, 23092, 24348, 29446, 29315 — monotonically climbing on SHORT
   lines, which is a backlog and not slow text. stopSpeech empties the queue but
   cannot cancel a synthesis already dispatched into the WASM worker, so every
   interrupt left its predecessor's work running and a few View entries piled up
   ten pieces of obsolete work on a runtime that does one at a time. Now every
   request goes through ONE chain and checks its generation at the moment it
   would START, so abandoned work is never begun and at most one orphan can be
   in flight. Also: a stale callback no longer clears speakBusy, a flag the
   CURRENT generation owns — clearing it allowed two utterances to write src to
   one element, one lost silently). Next = green.

   2026-10-14i: BLUE (THE VOICE WAS LOADED TWICE, CONCURRENTLY — one fault behind
   both the twenty-second "preparing" and the first sentences stumbling.
   piper_direct cached the voice only at the END of loadVoice, after two fetches
   and an InferenceSession.create, so two overlapping calls both missed and both
   did the whole thing: two sessions, neither released. The overlap is the
   ORDINARY case — ticking Speak warms the voice, then the first utterance calls
   loadVoice again — and a desktop can tick and press View inside the window
   where iOS cannot, which is the platform split reported. The in-flight promise
   is now shared. piper_direct.js?v= bumped to 794: that file is the one thing
   the canary cannot certify). Next = red.

   2026-10-14h: GREEN (THE SPACE BAR WAS RE-PRESSING THE BUTTON. Nothing binds
   space: the Speak toggle and View's Speak button simply KEEP KEYBOARD FOCUS
   after a click, so a later space re-activates them — and View's Speak button
   ticks the Speak box when speech is off, so a stray space turned speech back
   on and started reading. Both now blur after a POINTER press only (detail > 0),
   so a keyboard user keeps their focus. Also measured, not fixed: the long
   "preparing" is the synthesis itself — the log says `slow synth 15018ms` for
   one 46-character line, against 1.5s for others, so it is load-dependent
   inference and not the voice reloading). Next = blue.

   2026-10-14g: RED (NO TITLE OVER A COLLAGE. The View bar showed the name of
   whichever node you happened to be standing on, which is not what the collage
   IS, and a collage can name more modules than one line can hold. The element
   stays, because it doubles as the speech status line — the only thing that can
   report "preparing…" or the first-use download in View — and with the title
   text empty a message still shows and the line returns to blank). Next = green.

   2026-10-14f: BLUE (THE PAUSE AFTER "Chapter 11", measured not guessed. The log
   already answered it: the second utterance IS prefetched and had simply not
   finished — the prefetch starts when the current clip begins PLAYING, so a
   one-second title cannot cover two and a half seconds of synthesis, and the
   hole is the difference, varying with the line and the load. Overlapping the
   calls would not help: one WASM instance, so they serialise. A short FIRST
   fragment is now merged into the second — the first only, because merging
   verse lines would destroy the line pacing each line is an utterance for. The
   threshold is derived: synthesis runs ~5x faster than speech, so a clip covers
   the next when it is at least a fifth of it; a third is used for margin).
   Next = red.

   2026-10-14e: GREEN (TWO THINGS. A plain text NODE in View now reads itself when
   Speak is ticked, for consistency with a collage's text. And an EXPLICITLY SET
   duration is a FLOOR the reading cannot cut short: a step set to 30s holds for
   30s even when the reading finishes in ten, and the reading governs only when
   it is the longer of the two. An absent duration sets no floor, because the
   computed 0.5s-a-word is a worse estimate of the reading than the reading
   itself — so collageSeqFloor reads only a value someone actually wrote, kept
   distinct from collageSeqDuration which falls back to the estimate. The speech
   cap grows with the floor so a long deliberate duration cannot trip the stall
   guard). Next = blue.

   2026-10-14d: RED (THE VOICE DRIVES THE TEXT SEQUENCE. A text step is spoken as
   it arrives, interrupting rather than queueing — a voice reading words that
   have left the screen is worse than silence. And THE READING BECOMES THE
   STEP'S LENGTH: 0.5s a word was an estimate needed because the real length was
   unknowable, and with the voice running it is knowable, so the clock stops
   competing with the voice. A cap of four times the estimate plus a minute
   releases a stalled reading, with a log. collageSeqStart is now the single
   authority for what is shown at the start whether or not anything advances —
   it used to return early, so a collage with ONE passage was never spoken).
   Next = green.

   2026-10-14c: BLUE (THE LAST STEP OF EVERY KIND HOLDS, not just the last
   graphic. The author, on seeing the words wrap while the figure settled: might
   it be better to loop = true on the last text? Yes, and for a better reason
   than symmetry — a piece that arrives somewhere is a better default than one
   that never ends. collageLastGraphicHolds becomes collageLastStepHolds and
   covers every kind. Music is included, where loop true is ALSO what the module
   reads as "repeat my own material": two meanings agreeing rather than one, and
   benign here where it would not be in general. Cycling is still expressible —
   set a last block to loop false and the preamble's loop takes over).
   Next = red.

   2026-10-14b: GREEN (TEXT ALWAYS ADDS. The seal-then-grow rule leans on the ECHO
   to grow the next block, and text has no module and so no announcements — so
   sealing it left no way at all to add a second passage: the press would find
   the existing text block, seal it again and do nothing. The rule that covers
   both: a kind with nothing to announce can only grow by pressing. Found by the
   author asking whether text times and loops like a graphic, which it does not:
   its duration is absent-means-computed at 0.5s a word with a 4s floor, and
   every text block is loop false, so the words wrap while the figure rests).
   Next = blue.

   2026-10-14a: RED (ONLY THE LAST GRAPHIC HOLDS. The visual default of loop true
   meant a graphic sequence sat on step 1 until every block was hand-edited; the
   author's rule is that only the last should hold, so a sequence hands on
   through every step and rests on the final figure. collageLastGraphicHolds
   re-establishes the invariant at all three places a block can arrive — the
   press, a paste into an existing collage, and the echo's grow. It is the one
   deliberate exception to "a sealed block is never written to again", because
   loop on a graphic says which step HOLDS: structural, not a composed value).
   Next = green.

   2026-10-13k: BLUE (THE SEQUENCER, stage 3. A collage with more than one block
   of a kind plays through them in script order, each for its own duration, with
   the preamble's loop deciding whether the kind begins again. In View only; one
   ticker at 250ms rather than a timer per kind; the kinds advance
   independently; a sequence starts at step 1 rather than at the step pointer,
   which has been following the block being WORKED on. It never writes to the
   card and that needed a guard: posting step 1 to the module makes the module
   announce it, and the echo would merge those values into the LAST block, a
   performance overwriting its own score. NOTE the visual default is loop true,
   which HOLDS, so a graphic sequence needs a loop false on its blocks; the log
   says so once per kind. Visual duration is 30s for debugging, was 300).
   Next = red.

   2026-10-13j: GREEN (A PRESS SEALS A STEP; THE NEXT CHANGE STARTS THE NEXT ONE.
   The author's choice between keeping the snapshot with a tooltip and this. The
   snapshot version was right in semantics and wrong in what it SHOWED — the
   committed value was invisible while the block on screen was the draft of the
   next step, so the script did not say what the collage held. Now: a press seals
   (adding first if the module has none), a sealed block is never written to
   again, and the next DELIBERATE change appends a new block. No hidden state, a
   simple collage stays one block, and drift can neither add a step nor edit a
   sealed one — the second of those the first test missed, because its fixture
   had no angle to drift). Next = blue.

   2026-10-13i: RED (THE LAST BLOCK OF A KIND IS LIVE, THE EARLIER ONES FROZEN.
   Author: symmetry 8, Collage, adjust to 7, Collage — and both blocks said 7.
   Nothing was broken: the echo keeps a block in step with its module, so the 7
   correctly overwrote the 8, and the press then added the live script which was
   also 7. The 8 was never kept, because a sequence is not knowable from one
   press. So a press RECORDS what the module said, and the next press COMMITS
   that recorded value as a frozen step ahead of the live block — inserted, never
   rewritten, so a hand edit cannot be undone. The echo now targets the last
   block naming the announcing module rather than a step index). Next = green.

   2026-10-13h: BLUE (A PRESS ADDS NOW, it does not replace — the author's rule
   change, taken instead of the long-press modifier I offered. Safe because
   REPLACE WAS ALREADY REDUNDANT: the echo merges each module's announcements
   into its own block, so a slot stays current with no press at all. Added LAST
   among its kind, which the canonical sort gives for nothing since it is stable
   within a rank. The new block becomes the current step, and must — the echo
   merges into the current step, so leaving it at 0 would send the next stepper
   move into the wrong block. An add identical to the last of its kind is
   refused: a double press is not a sequence of two identical steps. A new step
   inherits the last same-kind block's loop and duration). Next = red.

   2026-10-13g: GREEN (SLOT ADDRESSING, stage 2. collageSlot returned the FIRST
   block of a kind and twelve callers were built on that — every one of which
   means "the slot that is SHOWING", which with a sequence is the CURRENT step.
   So one function changed meaning and no caller was touched; with one block per
   kind the step is 0 and nothing moves. Order in the script is the identity, so
   no %%bd_slot directive was needed. Two faults that exist only once there are
   two of a kind, both found by writing the test first: addOrReplace tested the
   kind alone and so replaced BOTH blocks, and mergeBySlot keyed by module id
   alone, which cannot tell two kolam steps apart and would have destroyed the
   other step's values). Next = blue.

   2026-10-13f: RED (THE TIMELINE DIRECTIVES, stage 1 of the author's sequencing
   design. `%%bd_loop` in the collage PREAMBLE is the arrangement's — above the
   first module header it belongs to no module, so the existing name needed no
   replacing; inside a block `loop` stays the module's, as it already is in all
   three music modules. Blocks are stamped with loop/duration defaults (visual
   true/300, music false, text false with duration ABSENT meaning 0.5s x words)
   and the stamp CARRIES FORWARD what the collage already said, so a hand-edited
   duration survives a press — a module's live script never mentions these, so
   without that every press would undo the edit. Forced BARE: the prefix rule
   took `_p_` from the block's neighbours and `_p_` is a claim that a stepper
   exists). Next = green.

   2026-10-13e: BLUE (THE ARROWS WERE HIDDEN ALMOST ALWAYS. One rule —
   body:not(.player-active) display:none — correct while they meant card to
   module (no module, nothing to copy to) and wrong the moment they carried
   cards, so they appeared only when a wander landed on a module node. Now
   gated on edit-active: Create, where cards are composed. The mobile rule stays
   on player-active because it docks them beside the STEPPER COLUMN; and
   positionExtendPanel's reset of their inline geometry moved ABOVE its
   display:none early return, which is the no-module case — otherwise a fixed
   rect stamped for the mobile player layout would survive and float them over
   the cards). Next = red.

   2026-10-13d: GREEN (THE ARROWS CARRY CARDS NOW. Down keeps a copy of the top
   card in History and leaves the top card's text SELECTED for a one-key delete;
   Up copies the PICKED History card into Current — into the top card if empty,
   otherwise a new one, so nothing on screen is overwritten. The pick is the tap
   you already make to read a card, marked by luminance with an outline so the
   pane does not shift. Given up: the manual card-to-module route, which `auto`
   covers in both directions. And a module pasted INTO an existing collage now
   adds or replaces that SLOT, merged into the collage as it stood when the
   paste began — before, the adopt branch claimed the card and the pasted module
   lay in the text as an unordered extra block). Next = blue.

   2026-10-13c: RED (THE ECHO WAS BLURRING THE CARD YOU WERE TYPING IN. autoWrite
   held an exception reading "fromDrift:false means a person moved a control, so
   they are demonstrably not typing in the card — take the caret out and write".
   True when written; falsified by `auto` itself, since a hand edit now CAUSES a
   non-drift announcement (the card is pushed down, the module announces its
   normalised form back). A second after you stopped typing — right after a
   space, where a typist pauses — the card was blurred and its value replaced,
   and further typing landed below %%bd_]. Withdrawing the exception costs
   nothing provably: working a stepper necessarily moves focus to the stepper's
   button). Next = green.

   2026-10-13b: BLUE (THE CARD'S OWN WORDS WIN. A new card with "This is my own
   text" typed into it, then a pasted Kolam script, put the NODE's Tao passage
   in the text slot and ignored what had just been typed. Words typed into this
   card a moment ago are a deliberate act; the node's prose is only the default
   for a card with none of its own. nodeProse reads both — from the card it
   returns exactly the prose extractFirstModuleScript ignored, so one text is
   divided between two readers with no third rule to keep in step). Next = red.

   2026-10-13a: GREEN (THE PASTE HANDLER NEVER SAW THE PASTE. Its guard tested
   the event target with classList.contains('card-body') — right for a textarea,
   wrong for a contenteditable card, where the target is the inner .chunk-text
   div. So pasting a Kolam script into a text node declined in SILENCE, with the
   boot line proving the handler was attached and current. Now
   el.closest('.card-body'). And a paste into a BUILT card cannot be read back
   at all — the log showed `bd_V_Kolam3D%%bd_p_symmetry`, a module id with the
   next line welded on, because browser paste markup is not what the chunk
   reader was built from. So a built card is read from the CLIPBOARD, its paste
   is refused, and the collage goes to a new local card). Next = blue.

   2026-10-11aa: RED (A PASTED SCRIPT IS AN INSTRUCTION TO MAKE A COLLAGE. The
   author's resolution of "what happens when a pasted module is incompatible
   with the current node": a collage is the one document allowed to name a
   module the node does not have, so the question dissolves. On a PASTE — not
   `input`, or a hand-typed script would be rewritten under the keyboard
   mid-word — the first properly parsed module becomes a slot, everything
   outside it is ignored, the node's prose is folded in as the text slot, and
   the graphic is dimmed to 0.45. A pasted collage is adopted unchanged. And a
   collage in the card no longer raises the module layout, which also stops the
   Collage button misfiring that path on a text node). Next = green.

   2026-10-11z: BLUE (THE AUTO-OPACITY ACTUALLY WORKS NOW, and it is 0.45. The
   name in a script is NOT the bare name: every stepper-backed directive carries
   the p_ prefix, so matching ^%%bd_opacity against the real node's
   %%bd_p_opacity 0.76 found nothing, INSERTED a second line, and the module's
   parser took the LAST of the two and kept 0.76 — a log line saying it was set
   beside a figure that had not changed. Also: the collage scrolls so the
   module's header sits 2.5 lines below the top rather than centred, which gives
   context above and removes the tall-block special case; and the T G M readout
   leaves the bar for iOS width, moving into the Collage button's title, with
   the row's gap 8->4px and the buttons' padding 10->7px). Next = red.

   2026-10-11y: GREEN (THE COLLAGE SCROLLS TO THE BLOCK BEING WORKED ON — the
   textarea, not the panes, on a Collage press and on the way back from View.
   MEASURED with a hidden mirror, because multiplying lines by a line height is
   wrong as soon as a line wraps, and a trailing newline's empty line box is not
   rendered the same way by every engine — so it measures "everything above plus
   one real line" and takes off a line whose height is also measured. Not
   setSelectionRange/focus, which would steal the caret and raise the iOS
   keyboard. Declines under a live caret, and shows a too-tall block from its
   start so the header stays visible). Next = blue.

   2026-10-11x: RED (THE COLLAGE DIMS THE PICTURE FOR YOU. A Collage press on a
   graphic writes %%bd_opacity 0.4 into the block AND pushes the script to the
   module — card-only would have been undone within a second, because the echo
   merges the module's announced values into the slot and the module would go on
   saying opacity 1. collageSetDirective edits by line-walking so a score block
   is never reached into; verified on replace-in-place, insert-after-header,
   score-block-untouched, idempotent, and overriding a hand-set value). Next =
   green.

   2026-10-11w: BLUE (VIEW SHOWS WHAT IS ON SCREEN. Wander to a new graphic node,
   press View before pressing Collage, and the OLD COLLAGE appeared instead of
   the node you were standing on — bdViewEnter asked topLocalCard(), the newest
   local card in the MODEL, so a collage relegated to History went on answering
   for ever. The third function in three rounds to ask the model and mean the
   screen. View consults the card only while the collage is the card on screen,
   and says in the log when a collage exists but is relegated, because "showing
   the node" and "could not find the collage" look identical on screen. The
   press that displays a wandered-away collage is Collage, which puts it back on
   top). Next = red.

   2026-10-11v: GREEN (VIEW GIVES BACK WHAT IT BORROWED. A collage View points
   the ONE module frame at the collage's visual slot, and leaving put the
   classes back and nothing else — so standing on a DroneFrac node, Viewing the
   collage and pressing Back left the preview showing the KOLAM with Kolam
   steppers while the card beside it was the plain DroneFrac script. Two
   restorations were missing and both are the same principle: the frame goes
   back to the NODE's own module (loadModuleForNode, so its exploration comes
   back too), and the collage goes back on top via a shared collageReinstate
   that the button's press and this exit cannot disagree about). Next = blue.

   2026-10-11u: RED (THE WRITE TARGET IS THE CARD ON SCREEN, and the collage's
   WORDS GO ON TOP. Two faults, one root each. getFocusedCardBody() fell
   through to topLocalCard() — the newest local card in the MODEL — so after a
   Collage press the collage was the write target for ever: standing at a Kolam
   node and working the steppers wrote into the relegated collage, which
   accepted the announcement, had no slot of that module to put it in, and
   reported the no-op as "card already equals the script" — the same reason as
   the previous skip, so the change-only log suppressed it and the steppers
   appeared to sync with nothing. The card in Current now wins. Second: block
   order is the stacking order, and v2 builds in PRESS order, so text-then-
   graphic put the figure over the words and its background hid them.
   collageAddOrReplace emits a canonical picture, sound, words — words last,
   therefore on top. Also the exploration-mismatch log drops to every 250th:
   it fired 1,300 times in two minutes and buried everything else). Next = green.

   2026-10-11t: BLUE (THE COLLAGE COMES BACK TO THE TOP. topLocalCard() answers
   "the newest local card in the MODEL"; the user's "top card" is a PLACE, the
   first card in Current, and the two part company the moment a wander relegates
   a card to History. So the collage went on being updated perfectly correctly
   several screens down, where the syncing it exists to show could not be
   watched. collageTopCard() now reports BOTH — the card wherever it lies, and
   whether it is the one on screen — and a press writes in place only while it
   is on screen, otherwise to a fresh top card. Also: the button says "Collage"
   and nothing else, because the type is DERIVED and the label was asserting
   something the code had worked out, about a thing the user already knows.
   And DroneFrac's Play button, found still RED against this file's GREEN, is
   back in step). Next = red.

   2026-10-11s: GREEN (THE COLLAGE BUTTON IS NEVER DISABLED. The log showed
   "btn: Collage | disabled true | type null" and no press line at all — it was
   greyed, so the click produced no event and the handler never ran. Disabling
   a control from event-refreshed state turns a stale label into a dead button;
   the press now decides for itself at the moment it happens and says what it
   decided. The label is also recomputed on pointerdown, and the refresh's
   catch logs instead of swallowing). Next = blue.

   2026-10-11r: RED (diagnostics, and a CANARY THAT HAD BEEN LYING. viewer.js
   expected build 2026-10-11b while this file said q — fourteen rotations stale,
   because every canary edit was a str.replace with no assert and silently did
   nothing once its target stopped matching. The boot line PRINTED the
   disagreement every time and still said MATCH, because `ok` was computed from
   the colour alone; it now takes both halves. Also every exit from the Collage
   handler now logs, since a press that did nothing left nothing to read). Next
   = green.

   2026-10-11q: BLUE (COLLAGE v2, STEPS 4 AND 5 — the button WORKS. Press it on
   a text node and a collage is started in a new top card; press it on a module
   node and that module'''s LIVE script is added as a slot, or replaces the one
   of its kind IN PLACE so the stacking cannot change. BD remembers one
   authoritative collage string, so wandering away and pressing Collage again
   reinstates it). Next = red.

   2026-10-11p: GREEN (COLLAGE v2, STEPS 1-3. Sv refuses to save a collage —
   checked on the text it is about to write, not on the top card, because
   getCardText lives in init() and that handler lives in setupInteractions:
   sibling scopes, and the first cut's try/catch would have swallowed the
   ReferenceError and left the guard silently doing nothing. View now reads the
   TOP CARD when it holds a collage, since a v2 collage lives in a card. And
   the Collage button is in place beside View, INERT, showing the type it would
   add). Next = blue.

   2026-10-11o: RED (on iOS the collage's text overlaid the SCRIPT PANEL on
   first arrival, and Browse-and-back or View-and-back cleared it. The layer was
   SHOWN BEFORE IT WAS PLACED: a `position: fixed` element with no insets falls
   back to its STATIC position, in the flow beside the script panel, and on a
   phone the module frame is not up for several frames so nothing had measured
   the square yet. It is now hidden until a placement succeeds, with a bounded
   retry — two animation frames was the whole of the previous patience). Next =
   green.

   2026-10-11n: BLUE (View no longer shows the last module behind a text node's
   prose. `body.view-active #visual-iframe { display: block }` showed the frame
   whenever View was open, and an iframe keeps its src — so after a collage,
   Back, and View on a text node, the kolam was still there. Gated on a new
   `view-has-module` class set only by the two branches that actually show a
   module). Next = red.

   2026-10-11m: GREEN (Back shows the DESTINATION's text. It restored the view
   correctly and then left whatever was in the panel — the whole collage script
   after leaving a collage — while already asserting through activeNodeId and
   markReadNode that you were reading the destination. advanceOrNavigate gains
   a `textOnly` option that skips its navigateInto calls and the breadcrumb
   chip, so not one node moves; and because navigateInto is what calls
   saveState, that also stops Back from pushing a state as it pops one, which
   would have left the stack unable to shrink). Next = blue.

   2026-10-11l: RED (the collage's text layer stayed painted over the graph
   after pressing Back — read as a caching fault, and it was a teardown in the
   wrong place. The only teardown lived inside loadModuleForNode, which runs
   ONLY in Player mode and returns early for a node with no module, so
   navigating back to a text node never reached it in either mode. The
   furniture is BD's own fixed DOM and outlives both the mode and the iframe,
   so one idempotent teardown now runs on every node read BEFORE the
   Player-mode gate, on leaving the Player layout, and on leaving a collage for
   another module node). Next = green.

   2026-10-11k: BLUE (bd_collage_layout was never in KOLAM3D'''s WRAPPER
   relay, so the white frame never appeared and its column was never capped
   either. The log proved it: BD reported `lane 130x543` while the module
   printed no receipt — two sites of three, the relay trap for the third time.
   A multi-step edit script had thrown at its second step and never run its
   third, and I read the traceback as one failure rather than two). Next = red.

   2026-10-11j: GREEN (Kolam3D's stepper column gets its WHITE FRAME while
   sharing the lane, matching the music panel's so the two stacked groups read
   as two panels of one list. Moved below the rule it overrides and written as
   the full `border` shorthand: specificity already favoured it, but a
   shorthand below a longhand is true until someone adds a declaration. It also
   logs when the class goes on, so "no white frame" can be told apart from
   "the class was never set"). Next = blue.

   2026-10-11i: RED (publishCard splits a collage per slot too — it had been
   posting the WHOLE card to #visual-iframe, so the visual module read the music
   slot's directives as its own and the music module got nothing: the same fault
   Copy Down had, in a second place. And every push to the music frame now LOGS
   what it carries, with DroneFrac logging each receipt, because a stale push is
   the one thing that can silently undo a stepper and nothing said one had
   happened). Next = green.

   2026-10-11h: BLUE (THE MERGE IS PER-SLOT. mergeExploredValues keys by BARE
   NAME, and this collage holds Kolam3D's %%bd_p_angle 124 beside DroneFrac's
   %%bd_angle 90 — a grammar directive with no stepper — so each module's
   announcement overwrote the other's angle and values moved then reverted. The
   author found it from "the _p_angle stepper is changing when I alter
   iterations, and HERE IS THE CLUE, the _p_angle stepper is not showing". Also
   Copy Down now SPLITS a collage per slot: it had been posting the whole text
   to the visual module, which read the music slot's directives as its own
   while the music module received nothing). Next = red.

   2026-10-11f: RED (the music steppers SYNC with the script at last. bdModuleOf
   reports a collage's VISUAL slot — right for #visual-iframe and silently
   wrong for every other slot — so a music slot's announcement looked like a
   different module's and was dropped by the exploration recorder and skipped
   by the card writer. bdSameModule now knows a collage names SEVERAL modules,
   and a collage's exploration ACCUMULATES across its slots instead of each
   announcement discarding the last). Next = green.

   2026-10-11e: BLUE (the music header was missing because its BASE
   `display: none` rule was written INSIDE the @media (min-aspect-ratio: 2/1)
   block — a default that exists at some window shapes and not others, which is
   a latent bug in both directions. Lifted to the top level, and the header
   given flex: 0 0 auto so a column of 25 overflowing rows cannot shrink it
   away. Kolam3D's column takes a white frame while sharing the lane, matching
   the music panel's). Next = red.

   2026-10-11d: GREEN (an overlay's PANELS are opaque and only the gaps are
   not — `.panel` set a border and no background, so it had always shown the
   body's, and the body is now transparent: Kolam3D's lower steppers were
   legible through this module's own column. The music header drops to 8px to
   match Kolam3D's. And the lane split now comes from the FRAME's height, not
   from the column it is about to shorten — which was a runaway that crept the
   midpoint upward every pass). Next = blue.

   2026-10-11c: RED (the black band across the lower half of the graphic was
   the THIRD stacked background — M_DroneFrac's WRAPPER, whose html+body kept
   `#0a0a0f`. The two Kolam wrappers were made transparent when the 3D module
   gained the feature; this one was not. Also `html:has(body.overlay)` replaced
   by a class on <html>: `:has()` needs Safari 15.4+, and a selector that
   silently does nothing is the worst shape of failure in a chain where ONE
   opaque layer hides everything). Next = green.

   2026-10-11b: BLUE (VIEW GETS THE PLAYER: the same frame the preview uses,
   moved to a 110px strip at the foot, transport only — no steppers, no
   overlay, no clip. The square shrinks by exactly the strip's depth because
   --bd-collage-side already subtracted it. The dashed reserve stands down but
   stays in the file, for a music module BD cannot load). Next = red.

   2026-10-11: GREEN (STAGES 2 AND 3: the music module's 25 steppers appear in
   the LOWER HALF of the lane and Kolam3D's column stops at the midpoint to
   make room. The overlay's clip grows to one band from the lane's midpoint to
   the frame's bottom — still a single `inset()`, because the bounding box of
   the transport and the stepper column IS that band, and what it costs is
   clicks on the canvas, which takes none, and on the half of Kolam3D's column
   that Kolam3D no longer draws). Next = blue.

   2026-10-10o: RED (returning from View no longer leaves the text spilling
   past both edges: it was placed once, from a square that was still VIEW's —
   `min(100vw, 100dvh - 44px)` — because View clears the frame's inline rect on
   the way in and the module re-measures from a ResizeObserver that fires after
   BD's pass. The text layer now re-measures twice like the music overlay
   already did, and the cached square is DROPPED on the way out of View rather
   than offered as a fallback from a different layout). Next = green.

   2026-10-10n: BLUE (the preview's text layer becomes VISIBLE: #visual-iframe
   keeps `background: #0a0a0f` outside View, and an iframe ELEMENT paints its
   own background before the document inside it renders — so a layer behind it
   was hidden however transparent the module made itself. The fourth stacked
   background, caught a fourth time). Next = red.

   2026-10-10m: GREEN (the PREVIEW gets its text layer — BD's own DOM, not a
   module, so no iframe, no clip and no relay, and `pointer-events: none` is
   enough because nothing in it is ever clicked. Placed over the same measured
   square the playbar uses, with the SCRIPT's block order deciding whether the
   words sit over or under the figure, exactly as in View). Next = blue.

   2026-10-10l: RED (STAGE 1 of the overlay: the music frame now covers the
   WHOLE module rect and is CLIPPED to just its transport's region. Clipped
   rather than pointer-transparent, because pointer events inside an iframe
   cannot pass through to the parent — the iframe ELEMENT is the hit target —
   while a clip-path DOES take part in hit-testing. That is what lets one frame
   hold two separated parts: the clip need not be one rectangle. Nothing should
   look different; that is the test). Next = green.

   2026-10-10k: BLUE (the playbar goes BACK under the graphic, 52px and the
   transport alone — the stepper lane is not a home for a transport, and the
   lane is wanted for the music STEPPERS, which is the next and separate piece
   of work. compactControls and DroneFrac's two layouts for it are left in
   place, unsent, ready for it. Kolam3D keeps its column header). Next = red.

   2026-10-10j: GREEN (the music module moves OUT of the 52px bar and into the
   lower half of the STEPPER LANE, under the graphic module's own column, each
   group headed by its module's own name. The 25 steppers were too crowded in
   the bar on a phone. The lane is MEASURED, and DroneFrac chooses a column or
   a row from the aspect ratio of the frame it is given rather than from a
   sixth flag). Next = blue.

   2026-10-10i: RED (the music steppers go IN the 52px playbar, as a shallow
   horizontally-scrolling row beside the transport — a fifth bd_ui_config flag,
   `compactControls`, because outputOnly could not answer two questions at
   once: View wants the transport alone, a collage PREVIEW is where the piece
   is built. The module's name heads its own group, with a rule between it and
   the first stepper). Next = green.

   2026-10-10h: BLUE (the playbar is 52px, not 70 — exactly the reserve
   `.canvas-wrapper` keeps below the square, so flush-with-the-frame and
   under-the-square become the SAME position and the hang over BD's bottom row
   goes with the overlap. DroneFrac's output-only buttons shallowed to a 38px
   minimum with 3px panel padding, the 10px gap kept). Next = red.

   2026-10-10g: GREEN (the playbar's TOP comes from the measured graphic's
   bottom rather than the frame's, so it cannot overlap the square; and BD hands
   the module the second measure it was waiting for — the square settles from a
   ResizeObserver that fires after BD's layout, which is the whole of "on the
   first draw it overlaps and on resizing it lines up"). Next = blue.

   2026-10-10f: RED (the playbar OVERLAYS the module frame instead of slicing
   74px off it — the frame holds the stepper column too, so the slice made the
   steppers stop short and the graphic smaller for nothing. The "massive gap"
   was `.canvas-wrapper`'s own `padding-bottom: 52px`, a reserve for BD's
   Extension strip. And the bar now has a 240px width floor: the graphic is
   SQUARE, so a vertical shrink narrowed it until the two buttons wrapped).
   Next = green.

   2026-10-10e: BLUE (the playbar matches the GRAPHIC's width and left edge
   instead of the frame's — the frame also holds the module's stepper column,
   so a full-width bar sat across it. The canvas rect is MEASURED through the
   same-origin frames, because the canvas is square at min(wrapper w, h),
   right-aligned behind a 44px arrows band, and differently aligned again in
   portrait: a formula about someone else's layout). Next = red.

   2026-10-10d: GREEN (the collage PREVIEW gets a simple playbar under the
   graphic — Play and Stop only, no steppers. Positioned by taking a slice off
   the bottom of the rect the working stamp just wrote, never by a branch of
   its own: yesterday's version took its own branch and returned before the
   fallback that keeps the last good rect, and in Player mode #cy is
   display:none so its rect is zeros. View is untouched). Next = blue.

   2026-10-10c: RED (the two music-player commits are REVERTED — the preview's
   stacked frames and View's player strip both went wrong on desktop. What is
   left is the SPACE: a 110px band at the foot of View, outlined, labelled and
   deliberately EMPTY, with the square shrinking by exactly its depth. Nothing
   is loaded into it). Next = green.

   2026-10-09s: BLUE (bdModuleOf reports a COLLAGE's visual slot instead of
   its first %%bd_module line. Putting the figure on top made that line
   `%%bd_module text`, so the exploration recorder compared 'text' against the
   module's own id, refused the match and silently dropped every announcement —
   the same collage in the other order worked. Block order is the STACKING
   order and must not also be an identity. The guard now logs its refusals).
   Next = red.

   2026-10-09r: GREEN (the text can go UNDERNEATH the figure. `%%bd_background
   transparent` makes a kolam canvas see-through — FOUR stacked backgrounds had
   to yield, the same shape as the three-site relay trap — and the ORDER of the
   blocks in a collage script is the stacking order, so swapping them puts the
   picture on top. No new directive, no placement syntax). Next = blue.

   2026-10-09q: RED (ONE BOX: the picture and the text now take the same
   centred square, named once as --bd-collage-side, because the module's canvas
   is square and the words were being centred on the VIEWPORT instead. The font
   scales with that square — vmin, then cqmin — where 2.2vw had been pinned at
   its 20px floor on any narrow window). Next = green.

   2026-10-09p: BLUE (the collage picture is sized by the SAME rule
   single-module View uses, with no override at all in the ordinary case. Two
   previous cuts both reached a 150px box: a calc carrying a custom property
   that iOS dropped whole, then width/height:auto on an iframe — a REPLACED
   element, whose auto is its intrinsic 300x150, not the insets). Next = red.

   2026-10-09o: GREEN (a module's announcement now UPDATES a collage card
   instead of replacing it with one slot — the card had been losing
   %%bd_collage and the whole text block on the module's first announcement,
   and the card is what Sv saves, so it was one keystroke from writing the
   slot over the collage in the database). Next = blue.

   2026-10-09n: RED (the collage picture is sized by top/bottom insets instead
   of a dvh calc — on iOS the calc did not resolve, the declaration was dropped
   whole, and an iframe with no height falls back to 150px, which is the "small
   frame" that was reported; plus bd_ui_config is now sent on EVERY View entry
   rather than only on the module-swap path, so the steppers stay hidden, and a
   collage node's exploration is recorded and restored like a module node's).
   Next = green.

   2026-10-09m: BLUE (collage text is TOP-ALIGNED — one of a pair of auto
   margins survived the reference line being emptied, so the prose was pushed
   to the foot and overspilled rather than being centred — and sits at 0.7
   opacity so a little of the figure shows through. Also: a gateway with no
   sections now shows its own CHILD content, which every one of the five
   module gateways needed). Next = red.

   2026-10-09l: GREEN (COLLAGE v1 — a node whose script opens `%%bd_collage 1`
   renders in View as a picture with the words over it: one visual slot at the
   module's own low opacity, one text slot above it, and the music slot
   RESERVED at 110px but not yet rendered. Placement comes from the module's
   declared `kind`, not from a directive). Next = blue.

   2026-10-09d: BLUE (View reaches TEXT nodes — the node's prose, centred and
   larger, with its reference in dim grey; Device is hidden there because
   there is nothing for another screen to watch). Next = red.

   2026-10-09c: GREEN (three module wrappers were dropping bd_ui_config in
   RELAY_DOWN, so outputOnly never reached Kolam, ABC or Fractal and View
   showed their whole interface). Next = blue.

   2026-10-09b: RED (#bd-view-bar's base rule said `display: flex` with no
   view-active qualifier, so it beat the `hidden` attribute and sat on the
   mode radios permanently — reported as "a Back button and no radio bar").
   Next = green.

   2026-10-09: BLUE (VIEW — the in-page presentation surface. The View button
   stops opening a window and takes over this page instead; the window route
   is retired in place, Device is untouched). Next = red.

   2026-10-08b: GREEN (robustness after a question the logs could not answer:
   client log lines are TIMESTAMPED, the prefetch hit RATE rides on the miss
   line, and the claim that slowing the speech buys synthesis headroom is
   corrected — measured at 3%, not headroom). Next = blue.

   2026-10-08: RED (scaling fixes 1 and 2 — each node now crosses the wire
   ONCE instead of 11.3 times, and the raw_text duplicate is gone from 198
   nodes; a [boot] line now reports what the boot cost). Next = green.

   2026-10-07: BLUE (the default speech voice is now a QUANTISED build of
   en_GB-alba-medium — 19.4 MB against 60.3 MB, same Scottish speaker, chosen
   by ear; built by make_quantised_voice.py, with piper_direct falling back to
   the published voice when the build is absent). Next = red.

   2026-10-02: GREEN (speech 10% slower and a 20% longer sentence pause; the
   drone module gained an output stage).

   2026-10-01b: RED (the new module is bd_M_DroneFrac, Cluster Drone, after
   bd_M_Grain was renamed before anything depended on it). Next = green.

   2026-10-01: BLUE (a granular module joins the system — four registries, a
   static route and a new module under Music).

   2026-09-30k: GREEN (both MUSIC modules gained the same no-host-chrome mode,
   releasing their two dock-slot grid areas; and MODULES gained a `page` field
   naming the four new standalone repos). Next rotation = blue.

   The music-module half of that shipped in 8ae5581 WITHOUT rotating this
   border — on the mistaken belief that media modules have no canary host. This
   comment says the opposite, three lines up, and is right: that is exactly what
   this host is for. The rotation above covers both commits.

   NB this prose has now been left behind TWICE. It said "2026-09-11: GREEN,
   next = blue" while the border painted RED; then it said "2026-09-19: RED"
   through four rotations that took the border to blue. Trust the hex on the
   border-line, not this prose, and update both together — the note is only
   worth keeping if it is true. */
#copy-link-btn {
  position: fixed;
  bottom: 6px;
  /* 2026-08-14 v2 — panel-aligned on desktop; edge-flush on mobile
     (media query below). */
  right: 30%;
  z-index: 10;
  background: rgba(0, 0, 0, 0.75);
  color: #fff;
  border: 2px solid #c0392b;             /* CANARY — red (was blue) */
  border-radius: 0;
  font-family: sans-serif;
  font-size: 11px;
  font-weight: 400;
  line-height: 1.4;
  padding: 2px 8px;                     /* -1px vertical to compensate for the 2px canary border, keeps trio height parity */
  cursor: pointer;
  white-space: nowrap;
}
@media (max-width: 767px) {
  #copy-link-btn { right: 8px; }
}
#copy-link-btn:hover:not(:disabled) {
  border-color: #7fb0ff;
  color: #fff;
}
#copy-link-btn:disabled { opacity: 0.4; cursor: not-allowed; }

/* A42 §42.6 / §42.7 — Copy Down (↓) and Copy Up (↑) single-glyph buttons.
   Same visual family as the other action-bar buttons. Start disabled and
   get enabled per chat-active + first-Copy-Down rules.
   2026-07-25 — hidden unless body.player-active. Their whole purpose is
   swapping module scripts between the top Local card and the iframe, which
   only makes sense while the iframe is visible. In Player mode the leftmost
   arrow takes `margin-left: auto` from #view-mode-toggle so both arrows sit
   tight against the Nodes/Player radio. */
/* ── THE PICKED HISTORY CARD (2026-10-13) ────────────────────────────────
   Which card ↑ will copy up. LUMINANCE, not hue: a brighter rim and full
   brightness against the History pane's dimmed cards, so it reads for a
   viewer who cannot rely on colour. `outline`, not `border`, because a border
   would change the card's size and shift the pane as the pick moves. */
#chat-stack .card.picked {
  outline: 2px solid #e8e8e0;
  outline-offset: -2px;
  filter: brightness(1.35);
}

#copy-down-btn,
#copy-up-btn {
  background: #1a1a2e;
  border: 1px solid #555;
  border-radius: 3px;
  color: #cccccc;
  font-family: sans-serif;
  font-size: 14px;
  line-height: 1;
  padding: 2px 8px;
  cursor: pointer;
  min-width: 26px;
}
#copy-down-btn:hover:not(:disabled),
#copy-up-btn:hover:not(:disabled) {
  border-color: #aaa;
  color: #fff;
}
#copy-down-btn:disabled,
#copy-up-btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}
/* ── SHOWN IN CREATE, NOT ONLY WITH A MODULE (2026-10-13) ────────────────
   This was `body:not(.player-active)`, which was exactly right while the pair
   meant card → module and module → card: with no module on screen there was
   nothing to copy to or from. They carry CARDS now, which has nothing to do
   with whether a module is up — so the gate was hiding them almost all the
   time, and they appeared only when a wander happened to land on a module
   node. Reported as *"no up down buttons most of the time — occasionally
   appear after a back to browse then create wander"*, which is that gate
   described exactly.

   Create, not Browse: Create is where cards are composed, and ↓↑ move cards.
   Browse is reading, and gains nothing from a pair of buttons that rearrange
   the stack.

   The mobile rule below stays keyed on `player-active`, and must: it takes
   these out of the bar and stamps them beside the STEPPER COLUMN, which only
   exists when a module does. Without a module they stay in the bar's flow,
   which is where they belong. */
body:not(.edit-active) #copy-down-btn,
body:not(.edit-active) #copy-up-btn {
  display: none;
}

/* 2026-08-17 — Kolam player (mobile): the ↓↑ (Copy Down/Up) arrows move OUT of
   the control bar to sit just left of the stepper column, stacked, aligned with
   the top stepper. `right` places them left of the ~108px column; `top` is set
   per-button by positionExtendPanel to the canvas top. z-index above the iframe
   (1) and History pane (2). The control bar keeps its space for pairing/edit. */
/* ── `arrows-docked` IS THE JS'S PROMISE THAT IT HAS PLACED THEM ─────────
   2026-10-21, reported as: on iOS "only the up arrow showing and that has
   landed on top of the microphone yellow button at the wrong end of the strip
   entirely. It is fine on the desktop until you resize down to a narrow
   width."

   Both symptoms are one fault. This rule used to fire on nothing more than
   the media query and `player-active`, so it made the arrows `position: fixed`
   while only positionExtendPanel can supply a `left` and a `top`. When that
   measurement fails — and it reaches through TWO nested iframes to find the
   module's canvas and stepper column, so it has several ways to — the arrows
   are fixed and UNPLACED: both land at the same viewport spot, which is why
   one hides the other, and that spot is wherever the flow had them, which on a
   narrow screen is the top row with the radios and the mic.

   Desktop never showed it because above 767px this rule does not apply at all
   and the arrows sit in the bar's normal flow. The narrow case is the ONLY one
   where the JS placement has to work, so a failure there had nowhere to
   surface.

   The class is added by positionExtendPanel only after it has stamped both
   buttons, and removed whenever it cannot — so the worst case is now "they
   stay in the bar", which is visible, correct and complete. A stylesheet
   should not assert a layout it cannot finish. */
@media (max-width: 767px) {
  body.player-active.arrows-docked #auto-echo,
  body.player-active.arrows-docked #copy-up-btn,
  body.player-active.arrows-docked #copy-down-btn {
    position: fixed;
    /* 2026-08-19 — `right: 116px` removed. It assumed the module's SMALL
       layout (108px stepper column), but the module switches layout on its own
       IFRAME width, not the window, so at ~700px the column is 220px and the
       arrows sat on the steppers. positionExtendPanel now stamps `left` from
       the column's live rect; these two properties are all that is left to do
       here. */
    z-index: 7;
  }
}
/* auto-echo tick box. Checkbox above, the word "auto" below it.
   2026-09-23 — position: static by DEFAULT, not fixed.
   It was fixed with `left`/`top` stamped by positionExtendPanel — which is
   right where that runs, and wrong everywhere else. On desktop with Kolam or
   Fractal that branch does not run at all, so a fixed box with no offsets
   fell back to its static position while the arrows stayed in the flow of the
   action bar, and it landed on top of them.
   Static, and placed BEFORE the arrows in the markup, it sits to their left
   in the bar with nothing having to position it. Where the stamping branch
   does run it sets position:fixed inline, which wins over this. */
#auto-echo {
  display: none;
  position: static;
  z-index: 7;
  flex-direction: column;
  align-items: center;
  gap: 1px;
  font: 9px/1 sans-serif;
  color: #c9a227;
  cursor: pointer;
  user-select: none;
  -webkit-user-select: none;
  -webkit-tap-highlight-color: transparent;
}
body.player-active #auto-echo { display: flex; }

/* ── `auto` RETIRED, NOT DELETED (author, 2026-10-21) ────────────────────
   "The use of the tick box to do that sounds not transparent to the user and
   a bit arcane. The syncing seems to work pretty well at the moment … so I am
   thinking just remove the tick box."

   Right on both counts. Unticking cut BOTH couplings and left no manual way
   across — the arrows that used to be that way carry cards now — so the box
   had become a switch between "works" and "works less", with a re-tick that
   silently pushed the card over the module. The asymmetry was a bug waiting
   to be reported rather than a feature.

   AFTER the rule above, deliberately: that one is the only thing that shows
   the control, so this must follow it to win on source order at equal
   specificity. The INPUT stays in the DOM and stays `checked`, so the four
   `box.checked` guards in viewer.js read true and nothing else changes — the
   coupling is now simply always on. Deleting the element would have meant
   editing four live code paths to remove a condition that is now constant,
   which is a bigger change than the one being asked for.

   It also returns the label's width to the control bar, which on iOS is the
   tightest row in the app. */
#auto-echo { display: none !important; }
#auto-echo input {
  /* 16px minimum or iOS zooms the page on focus; also an easier target than
     the browser default on a phone. */
  width: 16px; height: 16px; margin: 0;
  accent-color: #c9a227;
  cursor: pointer;
}

/* 2026-08-09 — arrows moved left by one button-width per user request.
   Previously: copy-down-btn took margin-left: auto and view-mode-toggle
   margin-left: 0, so both stuck together on the right. Now arrows sit
   right after Copy Link (default gap only), and the view-mode-toggle
   keeps its base margin-left: auto so it pushes to the right edge and
   grows the empty space between arrows and radios. */

/* 2026-08-14 — radio group promoted OUT of #action-bar layout into the
   top row alongside #back-btn (left) and #pair-control (right). Sits on
   BD's system yellow (#FFD700) so it reads as a primary mode selector,
   visually paired with the yellow Back + Pair buttons that flank it.
   All three radios (Nodes / Player / Edit) always visible; disabled state
   is contextual and will get a click-time explanatory message soon. */
/* 2026-09-07 — LEFT-ALIGNED to the panel, not centred.
   Centring put the strip's right end under #pair-control on a narrow screen
   (reported on an iPhone 8, where Pair was partially overlaid). The top row has
   nothing on its left — #back-btn moved into #bd-toppanel — so the space was
   there to take.
   30% is the panel's left edge, the same convention #back-btn used before it
   moved and #pair-control still uses from the right; 8px is the mobile
   equivalent once the panel goes full width. The extra 2px is deliberate
   slack so sub-pixel rounding can never put the border outside the panel. */
/* 2026-09-30 — #view-mode-toggle is now a bare POSITIONED ROW holding two
   pills: the Browse/Create pair, and Speak. It used to be the pill itself, so
   Speak sat inside the gold block and a margin only moved it along — there was
   nothing for a gap to appear in.

   Everything that placed it stays here; everything that PAINTED it moved to
   #view-mode-pill, and #speak-control takes the same paint. The gap between
   them is this row's own gap, which is what makes it a real gap.

   The descendant selectors below (#view-mode-toggle input[type="radio"], and
   the label rules) are unaffected: the radios are still descendants. */
#view-mode-toggle {
  position: fixed;
  top: 6px;
  left: calc(30% + 2px);
  transform: none;
  z-index: 10;
  display: inline-flex;
  /* STRETCH, not center. The two pills are not naturally the same height: the
     radio pill's inner <label>s add 2px of vertical padding each for their tap
     zone, while #speak-control IS the label and takes the pill's padding
     instead — so it came out 4px shorter. Stretching makes both pills take the
     row's height, which is whatever the taller one needs, so they match by
     construction rather than by a number that would drift the next time either
     one's padding changes. Each pill centres its own contents. */
  align-items: stretch;
  gap: 20px;                             /* the air the author asked for */
  color: #1a1a20;                        /* dark text on yellow */
  font-family: sans-serif;
  font-size: 10px;
  font-weight: 600;
}

/* The two pills. Identical paint, so they read as two controls of one kind
   rather than one control and an afterthought. */
/* Scoped through the row for SPECIFICITY, not for tidiness: `#view-mode-toggle
   label` below is 0,1,1 and #speak-control alone is 0,1,0, so the label rule's
   padding would win and the Speak pill would sit tighter than its neighbour.
   Two pills that are meant to match must actually match. */
#view-mode-toggle #view-mode-pill,
#view-mode-toggle #speak-control {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  background: var(--bd-gold);
  border: 1px solid #a07820;
  border-radius: 4px;
  padding: 2px 8px;
  box-sizing: border-box;
}
/* MUST stay after the rule above: a media query carries no extra specificity,
   so an override placed earlier in the file loses to it at every width. Putting
   this next to #back-btn's mobile rule is what moved the strip RIGHT on a phone
   (30% of 375px = 114px) instead of left. */
@media (max-width: 767px) {
  /* Panel is full width here, so its left edge is the 8px gutter. */
  #view-mode-toggle { left: 10px; }
}
#view-mode-toggle label {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  cursor: pointer;
  user-select: none;
  padding: 2px 4px;                      /* tap-zone */
  touch-action: manipulation;
  -webkit-tap-highlight-color: rgba(0, 0, 0, 0.15);
}
#view-mode-toggle input[type="radio"] {
  cursor: pointer;
  margin: 0;
  /* Green accent stays — reads well on yellow and keeps the "go / active"
     semantic from the previous action-bar incarnation. */
  accent-color: #00b050;
  /* Slightly larger so the tap target isn't a pinhead on iPhone.
     accent-color survives width/height changes on all evergreen browsers. */
  width: 15px;
  height: 15px;
}
#view-mode-toggle input[type="radio"]:disabled,
#view-mode-toggle input[type="radio"]:disabled + span {
  opacity: 0.45;
  cursor: not-allowed;
}
#view-mode-toggle label:has(input:disabled) {
  opacity: 0.55;
  cursor: not-allowed;
}

/* A42 §42.3 — #visual-iframe occupies the same footprint as #cy. Hidden by
   default; the .active class swaps it in and #cy.hidden hides the graph
   canvas at the same time. JS keeps the top in sync with #cy via
   positionCyEl(). */
#visual-iframe {
  display: none;
  position: fixed;
  top: 0;                               /* JS stamps top / left / width / height from #cy's rect */
  left: 0;
  z-index: 1;
  border: 0;
  background: #0a0a0f;
}
#visual-iframe.active { display: block; }

/* COLLAGE PREVIEW — the playbar under the graphic. Only `display` is decided
   here: the geometry is stamped inline by positionCyEl, which is the ONE writer
   of that band. A second writer of the same rect has cost this file four
   rounds now, the most recent of them yesterday.
   It is NOT in View's blanket-hide whitelist, so View hides it automatically —
   View is untouched in this step and the bar has no business there yet. */
#collage-playbar {
  display: none;
  position: fixed;
  border: 0;
  background: transparent;
  z-index: 1;
}
/* ── THE PREVIEW'S MODULE FRAME MUST BE SEE-THROUGH ──────────────────────
   #visual-iframe carries `background: #0a0a0f` for the ordinary case, and THAT
   is what hid the text layer: the words sit at z-index 0, behind the frame at
   z-index 1, and an iframe ELEMENT paints its own background before the
   document inside it ever renders. So `%%bd_background transparent` in the
   module's script was doing its job perfectly and could never have been
   enough — four stacked backgrounds again, and this is the fourth.
   View already does this; the preview was simply never told to. */
body.collage-preview #visual-iframe { background: transparent; }

/* ── COLLAGE v2: the slots readout is GONE from the bar (2026-10-11) ──────
   It was a monospace `T G M` beside the Collage button. Removed at the
   author's request, for width: iOS will be tight once the pairing buttons
   (Remote and Coincident) are showing, and three characters of status are the
   cheapest thing in that row to give up. The state moved to the Collage
   button's own `title`, which costs no space at all, and it is still on every
   `[collage] btn:` line in the log.

   The hiding rule is KEPT. The element is gone from index.html, but a cached
   copy of that file with a live copy of this one would otherwise show an
   unstyled readout — and a stale index.html is exactly what the canary exists
   to make visible rather than mysterious. */
#collage-slots { display: none !important; }

/* ── THE PREVIEW'S TEXT LAYER ─────────────────────────────────────────────
   Its rect, its font size and its z-index are all stamped inline by
   positionCyEl — the font size because the preview square is a third of View's
   and the type has to track it, the z-index because the SCRIPT's block order
   decides whether the words sit over or under the figure. Only the things that
   never vary live here. */
#collage-preview-text[hidden] { display: none !important; }
#collage-preview-text {
  display: none;
  position: fixed;
  box-sizing: border-box;
  overflow: hidden;
  /* NOTHING HERE IS EVER CLICKED, which is what lets this one layer be
     pointer-transparent outright. The music overlay could not: its transport
     needs clicks, and pointer events inside an iframe cannot pass through to
     the parent, so it had to be clipped instead. */
  pointer-events: none;
}
body.collage-preview-text #collage-preview-text:not([hidden]) { display: block; }
#collage-preview-text-body {
  font-family: Georgia, "Times New Roman", serif;
  line-height: 1.5;
  color: #e8e8e0;
  opacity: 0.7;
  white-space: pre-wrap;
  /* Luminance, not colour: a dark halo under bright glyphs holds whatever hue
     the figure takes. The same pair View uses, at preview scale. */
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.95), 0 0 8px rgba(0, 0, 0, 0.75);
}
/* The slot marker, in the modules' own 8px idiom — a label that lives WITH the
   thing it names, which is the rule that keeps the script's order honest
   without anything having to arrange it. */
#collage-preview-text .collage-title-mark {
  position: absolute;
  top: 0; left: 0;
  font: 8px/11px ui-monospace, "SF Mono", Menlo, monospace;
  letter-spacing: 0.06em;
  color: #9e9e90;
  text-transform: lowercase;
}

body.collage-bar #collage-playbar {
  display: block;
  /* ABOVE the module frame now that it overlays rather than sits beneath it.
     #visual-iframe is z-index 1. */
  z-index: 3;
}
#cy.hidden { display: none; }

/* MM3 (2026-07-12) — #bd-invite-panel-viewer.
   Fixed panel in the 220 px band positionCyEl reserves on the right of
   #visual-iframe in landscape. Muted-amber styling to match the EV's
   invitation panel, so the two-way traffic between BD and EV feels of
   a piece. Visible only in Player mode via body.player-active. Hidden
   on portrait (media query below) — layout there stacks vertically. */
#bd-invite-panel-viewer {
  position: fixed;
  /* Landscape: back on the right, vertically centred (pre-v3 behaviour).
     v3 moved it to bottom-left globally to fix a mobile-overlap issue, but
     that broke V_Kolam's landscape layout — canvas + steppers are already
     right-aligned; the 100 px right-reserve in positionCyEl exists to give
     the panel that band. Portrait override below keeps the panel on the
     LEFT where the mobile overlap actually happens. */
  right: 8px;
  top: 50%;
  transform: translateY(-50%);
  max-width: 76px;                        /* 50 px button + 2×12 padding + 2 border */
  /* 2026-08-08 — vertical padding was 8 px each; per user "too generous"
     took 8 off top and 8 off bottom (net −16 px overall panel height). */
  padding: 0 12px;
  background: rgba(10, 10, 26, 0.95);
  border: 1px solid #333;
  border-radius: 6px;
  color: #ccc;
  font-family: sans-serif;
  font-size: 12px;
  line-height: 1.3;
  z-index: 6;                             /* above #cy-you/#cy-buddy (5), below tooltips (10) */
  display: none;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.6);
}

/* 2026-09-29 — also in BROWSE, whenever a module is in play (body.module-node,
   set by updateSendBtn). Browse no longer raises the module itself: it stays
   text and offers View as the way to the work, so the button has to be there
   without player-active.

   PLACEMENT IS PROVISIONAL. This panel is positioned for the 220px band
   positionCyEl reserves beside the iframe in the player layout; in Browse the
   graph occupies that space, so it will sit over #cy's right edge. The agreed
   home is the Local/Remote row — done once it can be seen rather than guessed. */
/* 2026-09-29 — EMPTIED and hidden. Its three live controls are in the Jump
   Bar's #jb-right now. The element stays so positionExtendPanel and the music
   modules' dock slots still find what they were written against.
   NOTE: those dock slots therefore receive nothing — visible in Fractal and
   ABC, and worth a look before deciding whether the slots still earn their
   place in those layouts. */
#bd-invite-panel-viewer { display: none !important; }

#bd-invite-text-viewer {
  margin: 0 0 8px 0;
  color: #ccc;
  text-align: center;
}

#bd-invite-actions-viewer {
  display: flex;
  flex-direction: column;
  gap: 5px;
}

/* 2026-08-09 — Jump to and Copy Link to unified: grey background,
   blue text (matches Extend / External Website labels above/below).
   Was amber solid / amber-outline; user asked for a quieter, more
   uniform panel look. */
#jump-to-ext-btn {
  background: #000;
  color: #fff;
  border: 1px solid #fff;
  border-radius: 3px;
  padding: 5px 4px;
  font-size: 11px;
  cursor: pointer;
  font-weight: bold;
  font-family: sans-serif;
  width: 50px;
  line-height: 1.15;
  white-space: normal;
}
#jump-to-ext-btn:hover { background: #5c5c5c; }

#copy-link-to-ext-btn {
  background: #000;
  color: #fff;
  border: 1px solid #fff;
  border-radius: 3px;
  padding: 5px 4px;
  font-size: 11px;
  cursor: pointer;
  font-weight: bold;
  font-family: sans-serif;
  width: 50px;
  line-height: 1.15;
  white-space: normal;
}
#copy-link-to-ext-btn:hover { background: #5c5c5c; }

/* Caption under the panel's button(s). 2026-08-09 — font unified with
   #bd-invite-text-viewer above (sans-serif, blue) per user request; was
   Georgia italic amber.

   2026-09-15 — reads "Ancillary Viewer", was "External Website". The panel
   used to hold two verb-buttons that shared this as their object ("Jump to
   External Website" / "Copy Link to External Website"), which is why the
   wording was a bare noun phrase and why it was forced onto two lines with
   a <br>. Now there is one button, View, and it opens BD's own viewer on
   this origin — not an external site. The <br> is gone; the 76px panel
   wraps it anyway. */
#bd-invite-target-viewer {
  margin: 8px 0 0 0;
  text-align: center;
  color: #4080ff;
  font-family: sans-serif;
  font-size: 12px;
  font-weight: bold;
  line-height: 1.1;
  letter-spacing: 0.02em;
}

/* Small viewport (mobile OR tablet) — 2026-08-09 v6: breakpoint raised
   from 767 → 1024 so iPad (portrait 768–1024, iPad Mini landscape 1024)
   gets the same left-anchored panel as iPhone. Was leaving iPad on the
   desktop right-anchor path, which collided with the module's own right
   stepper column. */
@media (max-width: 1024px) {
  #bd-invite-panel-viewer {
    top: auto;
    transform: none;
    /* 2026-08-09 v5: v4 (22vh) overshot; back up 1.5vh to 23.5vh. */
    bottom: calc(23.5vh + 22px);
    right: auto;
    left: 8px;
  }
}
/* 2026-08-09 v7 — iPad-specific override. Same vh from bottom on a
   tall iPad viewport (768–1024) puts the panel below the module's
   abc-pane, over the stepper column. Raise it to ~50vh to match the
   panel/abc-pane alignment iPhone gets at 23.5vh from bottom. */
@media (min-width: 768px) and (max-width: 1024px) {
  #bd-invite-panel-viewer {
    /* v7 was 50vh+22px; +30 px to raise the panel further. */
    bottom: calc(50vh + 52px);
  }
}

/* 2026-08-17 — Kolam mobile: Extension panel as a HORIZONTAL bar centred under
   the canvas. positionExtendPanel adds .under-canvas and sets top/left/
   transform (translateX(-50%)) from the live canvas rect; here we lay the two
   buttons side by side and drop the verbose stacked labels for a compact strip.
   2026-08-19 — .under-canvas is now shared: Kolam gets it for this strip, and
   the ABC/Fractal dock-slot placement gets it PLUS .in-slot for the stacked
   variant (see below). Both want the same bare-buttons look, which is what
   this rule provides. Desktop clears both classes. */
#bd-invite-panel-viewer.under-canvas {
  max-width: none;
  padding: 2px;
  background: transparent;                /* 2026-08-18 — just the two white-framed buttons; no dark box/shadow */
  border: none;
  box-shadow: none;
}
/* Header and caption are dropped in BOTH placements — the button labels
   ("Jump to external player" / "Copy external url") say it themselves. */
#bd-invite-panel-viewer.under-canvas #bd-invite-text-viewer,
#bd-invite-panel-viewer.under-canvas #bd-invite-target-viewer {
  display: none;
}

/* Kolam, under the canvas: a HORIZONTAL strip, as fa062b4 built it on
   2026-08-17. 322ed49 then made this rule `column` for the ABC dock slot —
   both placements were riding on `.under-canvas` alone, so Kolam's bar
   silently became a tall stack that hung down over the breadcrumbs, and
   d2492e7's ~50%-deeper buttons made it taller still. The stacked variant now
   lives under `.in-slot` below; this is the strip again. */
#bd-invite-panel-viewer.under-canvas #bd-invite-actions-viewer {
  flex-direction: row;
  gap: 6px;
  margin: 0;
}
/* The button LABELS grew long in 322ed49 ("Jump to external player" /
   "Copy external url") while the base width stayed 50px, so side-by-side they
   would be two narrow 5-line columns. Widen them for the strip: two ~2-line
   buttons, ~34px tall, which is what has to fit between the canvas bottom and
   the breadcrumbs. */
/* 2026-09-29 — DEAD: these buttons are no longer descendants of the panel,
   they are in the Jump Bar. Left rather than deleted because the panel and its
   dock-slot machinery are retired-in-place, not removed, and this is the shape
   they would need again. */
#bd-invite-panel-viewer.under-canvas #jump-to-ext-btn,
#bd-invite-panel-viewer.under-canvas #copy-link-to-ext-btn {
  width: auto;
  min-width: 96px;
  max-width: 132px;
  white-space: normal;
  font-size: 9px;
  font-weight: normal;
  line-height: 1.15;
  padding: 5px 6px;
}

/* ABC / Fractal, docked into the module's #bd-ext-slot: two long buttons
   stacked one under the other, filling the cell. `.in-slot` is added only by
   positionExtendPanel's dock-slot branch. */
#bd-invite-panel-viewer.under-canvas.in-slot #bd-invite-actions-viewer {
  flex-direction: column;
  gap: 4px;
}
#bd-invite-panel-viewer.under-canvas.in-slot #jump-to-ext-btn,
#bd-invite-panel-viewer.under-canvas.in-slot #copy-link-to-ext-btn {
  width: 100%;
  min-width: 0;                           /* reset the strip's min/max — the slot
                                             sets the width, not the button */
  max-width: none;
  white-space: normal;
  font-size: 8px;
  font-weight: normal;
  line-height: 1.15;
  padding: 8px 4px;                       /* 2026-08-18 — ~50% deeper; easier to press */
}

/* 2026-08-17 — Kolam player (mobile): the module iframe is position:fixed
   z-index 1 and frozen in Player mode, so lift the History pane above it and
   let it overflow-scroll. Its height is grown to just above the canvas top by
   positionExtendPanel. Only in Player mode on phones. */
@media (max-width: 767px) {
  body.player-active #chat-panel {
    position: relative;
    z-index: 2;
    overflow-y: auto;
  }
}

/* 2026-08-09 — Extend label now permanent blue to match the
   External Website label below. Canary role for CSS/module changes
   moved to the play button in music_module.html. */
#bd-invite-text-viewer {
  color: #4080ff !important;
}

#media-bar {
  display: none;
  position: fixed;
  bottom: 6px;                          /* 2026-08-14 — raised 4 px so it aligns with #user-count-panel and #copy-link-btn on the same bottom row */
  left: 50%;                            /* 2026-08-14 — recentred (5% shift undone; Copy Link fits fine at right:8px without moving the player) */
  transform: translateX(-50%);
  background: rgba(0, 0, 0, 0.75);
  border: 1px solid #444;
  padding: 0 6px;                       /* 2026-08-14 — removed vertical padding; inner mp-btn/mp-loop/mp-select shrunk to match user-count + copy-link depth */
  color: #fff;
  font-size: 11px;
  font-family: sans-serif;
  white-space: nowrap;
  z-index: 10;
}

#media-bar.active {
  display: flex;
  align-items: center;
  gap: 4px;                             /* 2026-08-14 — tightened (was 6px) so shrunk controls sit close-packed */
}

#cluster-editor-bar {
  position: fixed;
  align-items: center;
  gap: 10px;
  padding: 8px 13px;
  background: rgba(0,0,0,0.75);
  border: 1px solid #444;
  font-size: 13px;
  font-family: sans-serif;
  color: #cccccc;
  white-space: nowrap;
  z-index: 10;
}

.spinner-group {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 3px;
}

.spinner-group input {
  width: 62px;
  font-size: 14px;
  text-align: center;
  background: #1a1a2e;
  color: #ffffff;
  border: 1px solid #444;
  border-radius: 3px;
  padding: 3px;
}

#editor-save-btn, #editor-delete-btn, #editor-clone-btn {
  font-size: 14px;
  padding: 5px 13px;
  border-radius: 3px;
  cursor: pointer;
  align-self: flex-end;
}

#editor-save-btn {
  background: #4A8C4F;
  color: #ffffff;
  border: none;
}

#editor-delete-btn {
  background: #C0504D;
  color: #ffffff;
  border: none;
}

#editor-clone-btn {
  background: #4A7BC0;
  color: #ffffff;
  border: none;
}

#clone-panel {
  position: fixed;
  align-items: center;
  gap: 10px;
  padding: 5px 13px;
  background: rgba(0, 0, 0, 0.75);
  border: 1px solid #444;
  font-size: 14px;
  font-family: sans-serif;
  z-index: 10;
  white-space: nowrap;
}

#clone-name-input {
  width: 260px;
  font-size: 14px;
  background: #1a1a2e;
  color: #ffffff;
  border: 1px solid #444;
  border-radius: 3px;
  padding: 4px 8px;
}

#clone-confirm-btn {
  background: #4A8C4F;
  color: #ffffff;
  border: none;
  border-radius: 3px;
  padding: 4px 10px;
  font-size: 14px;
  cursor: pointer;
}

#clone-cancel-btn {
  background: #444;
  color: #cccccc;
  border: none;
  border-radius: 3px;
  padding: 4px 10px;
  font-size: 14px;
  cursor: pointer;
}

.mp-select {
  background: #1a1a1a;
  border: 1px solid #555;
  color: #ccc;
  font-size: 11px;
  font-family: sans-serif;
  padding: 0 3px;                       /* 2026-08-14 — trimmed for bottom-row height parity */
  min-width: 110px;                     /* 2026-08-14 — was 130px; tighter media-bar footprint */
  height: 18px;                         /* 2026-08-14 — explicit so Safari's native dropdown chrome doesn't push media-bar taller than sibling utility panels */
  cursor: pointer;
}

.media-close {
  background: none;
  border: none;
  color: #888;
  font-size: 14px;
  cursor: pointer;
  padding: 0 0 0 6px;
  line-height: 1;
}

.media-close:hover {
  color: #fff;
}


.mp-btn {
  background: none;
  border: 1px solid #888;
  border-radius: 4px;
  color: #fff;
  font-size: 12px;                      /* 2026-08-14 — was 13px; shrunk for bottom-row parity */
  cursor: pointer;
  padding: 0 6px;                       /* 2026-08-14 — was 2px 8px; media-bar depth now matches user-count + copy-link */
  line-height: 18px;                    /* 2026-08-14 — explicit for consistent height alongside .mp-select */
  flex-shrink: 0;
}

.mp-btn:hover {
  border-color: #fff;
}

/* Loop toggle (2026-08-05) — outlined resting, filled amber when engaged.
   2026-08-14 — matches new .mp-btn dimensions (bottom-row parity pass). */
.mp-loop {
  background: none;
  border: 1px solid #888;
  border-radius: 4px;
  color: #fff;
  font-size: 12px;
  cursor: pointer;
  padding: 0 5px;
  line-height: 18px;
  flex-shrink: 0;
}
.mp-loop:hover     { border-color: #fff; }
.mp-loop.on        {
  background: rgba(255, 215, 0, 0.2);
  border-color: var(--bd-gold);
  color: var(--bd-gold);
}


#loading-overlay {
  display: none;
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.88);
  z-index: 500;
  align-items: center;
  justify-content: center;
}

#loading-overlay.active {
  display: flex;
}

#loading-box {
  background: #111;
  border: 1px solid #444;
  padding: 28px 32px;
  text-align: center;
  font-family: sans-serif;
  max-width: 300px;
}

#loading-box p {
  color: #ccc;
  font-size: 14px;
  line-height: 1.7;
  margin: 0;
}

/* 2026-07-15 — Copy-Link "should I update the script?" dialog. Mirrors
   preview.html's dialog CSS so the two look identical across BD and EV. */
.modal-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.6);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 400;                  /* above cy/action-bar, below loading-overlay */
  font-family: sans-serif;
}
.modal-overlay[hidden] { display: none; }
.modal-box {
  background: #1a1a20;
  border: 1px solid #a07820;
  border-radius: 6px;
  padding: 18px 20px;
  max-width: 380px;
  width: calc(100vw - 40px);
  color: #e8e8e0;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
}
.modal-heading {
  margin: 0 0 8px 0;
  font-size: 15px;
  font-weight: bold;
  color: #ffd88a;
}
.modal-body {
  margin: 0 0 14px 0;
  font-size: 13px;
  line-height: 1.4;
  color: #cfcfc4;
}
.modal-buttons {
  display: flex;
  gap: 8px;
  justify-content: flex-end;
  flex-wrap: wrap;
}
.modal-buttons button {
  background: #3d2e00;
  color: #ffd88a;
  border: 1px solid #a07820;
  border-radius: 3px;
  padding: 6px 12px;
  font-size: 13px;
  cursor: pointer;
  font-family: sans-serif;
}
.modal-buttons button:hover {
  background: rgba(160, 120, 32, 0.2);
  color: #fff;
  border-color: #c9a227;
}
.modal-check {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-top: 12px;
  font-size: 12px;
  color: #a09a8a;
  cursor: pointer;
}
.modal-check input {
  cursor: pointer;
  accent-color: #a07820;
}


#session-expired {
  display: none;
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.88);
  z-index: 500;
  align-items: center;
  justify-content: center;
}

#session-expired.active {
  display: flex;
}

#session-expired-box {
  background: #111;
  border: 1px solid #444;
  padding: 28px 32px;
  text-align: center;
  font-family: sans-serif;
  max-width: 300px;
}

#session-expired-box p {
  color: #ccc;
  font-size: 14px;
  line-height: 1.7;
  margin-bottom: 20px;
}

#session-expired-box button {
  background: #1a3a6b;
  border: 1px solid #3a6abf;
  color: #aac4ff;
  padding: 6px 18px;
  font-size: 12px;
  font-family: sans-serif;
  letter-spacing: 0.06em;
  cursor: pointer;
}

#session-expired-box button:hover {
  color: #fff;
  border-color: #6a9adf;
}

#dev-panel {
  position: fixed;
  top: 34px;                            /* same row as #back-btn (left:8px) and #pair-control (right:8px) */
  left: 54px;                           /* ~8px gap right of #back-btn (left:8px, ~46px right edge) */
  display: flex;
  align-items: center;
  gap: 6px;
  z-index: 10;
  font-family: sans-serif;
}

#dev-code {
  /* 2026-07-28 — very low resting visibility: only the few curators who
     know it's there should notice it. Transparent background + hairline
     border in a colour that reads as noise against the app ground.
     Focus + hover restore full contrast so it's usable when engaged. */
  background: transparent;
  border: 1px solid rgba(255, 255, 255, 0.08);
  color: rgba(255, 255, 255, 0.35);
  /* iOS Safari auto-zooms any input under 16px on focus. Keeping >= 16px
     prevents the zoom-in / can't-zoom-back problem on iPhone. */
  font-size: 16px;
  padding: 2px 6px;
  /* 4-char numeric code (2026-07-25). Password-mode bullets are wider
     than digits — widened to ~62px so all four bullet markers are
     visible without overflow. */
  width: 62px;
  text-align: center;
  font-family: ui-monospace, monospace;
  border-radius: 3px;
  transition: background 150ms ease, border-color 150ms ease, color 150ms ease;
}

#dev-code:hover,
#dev-code:focus {
  background: #111;
  border-color: #444;
  color: #ccc;
  outline: none;
}

#dev-code::placeholder { color: rgba(255, 255, 255, 0.15); }
#dev-code:hover::placeholder,
#dev-code:focus::placeholder { color: #555; }

/* Wr / Re dev buttons. Compact labels + generous padding so the hit
   area stays at least 44 px per side even though the visible label is
   only 2 chars — meets Apple iOS touch-target minimums.
   2026-07-25 — first .dev-btn (Wr) gets margin-left: 50px so the pair
   sits well right of the code input, matching the layout brief. */
.dev-btn {
  background: none;
  border: 1px solid #555;
  border-radius: 4px;
  color: #aaa;
  font-size: 12px;
  font-weight: 600;
  cursor: pointer;
  padding: 6px 10px;
  line-height: 1;
  min-width: 32px;
  text-align: center;
}

.dev-btn:hover { border-color: #aaa; color: #fff; }

#dev-status {
  color: #888;
  font-size: 11px;
  white-space: nowrap;
}

#user-count-panel {
  position: fixed;
  /* 2026-08-14 — moved from top-right (inside old #title-bar band) to
     bottom-left alongside #media-bar and #copy-link-btn. Connection
     count is developer / popularity info, not primary UX; belongs at
     the bottom edge with other technical readouts. Style harmonised
     with #media-bar and #copy-link-btn — one visual family across
     the bottom utility row.
     v2 — panel-aligned on desktop: left:30% lines up with the panel's
     left edge. Mobile override restores the edge-flush 8 px. */
  bottom: 6px;
  left: 30%;
  background: rgba(0, 0, 0, 0.75);
  border: 1px solid #444;
  color: #fff;
  font-size: 11px;
  font-family: sans-serif;
  padding: 3px 8px;
  white-space: nowrap;
  /* 2026-08-18 — doubles as the general BD status pane. Connection count
     shows first, then relayed module messages; the strip can overflow, so
     it scrolls horizontally (swipe on touch) rather than pushing width. */
  max-width: 42%;
  overflow-x: auto;
  overflow-y: hidden;
  -webkit-overflow-scrolling: touch;
  z-index: 20;                          /* above media-bar just in case */
  display: none;
}
@media (max-width: 767px) {
  #user-count-panel { left: 8px; max-width: 58vw; }
}

#user-count-panel.active {
  display: block;
}

#label-tooltip {
  position: fixed;
  background: rgba(0, 0, 0, 0.7);
  color: #fff;
  border: 1px solid #444;
  padding: 8px 12px;
  font-size: 13px;
  font-family: sans-serif;
  line-height: 1.6;
  white-space: pre-wrap;
  width: min(500px, calc(100vw - 28px));
  pointer-events: none;
  display: none;
  z-index: 100;
}

/* 2026-09-05 — the speech intro. The first thing anyone meets, so it is kept to
   four short lines and declining is made as easy as accepting: the two buttons
   are the same size and neither is styled as the "right" one. */
/* 2026-09-08 — #dev-notice shares this chrome rather than copying it. Two
   dialogs meet the reader back to back, so any drift between them would be
   visible as a jolt at the moment of the change. */
#speech-intro, #dev-notice {
  position: fixed; inset: 0; z-index: 200;
  display: flex; align-items: center; justify-content: center;
  background: rgba(10,10,14,0.72);
  padding: 20px;
}
#speech-intro .si-box, #dev-notice .si-box {
  max-width: 420px; width: 100%;
  background: #1a1a20; color: #e8e8ea;
  border: 1px solid #3a3a44; border-radius: 8px;
  padding: 20px 20px 16px;
  font: 15px/1.5 system-ui, -apple-system, sans-serif;
}
#speech-intro h2, #dev-notice h2 { font-size: 17px; margin: 0 0 10px; font-weight: 600; }
#speech-intro p,  #dev-notice p  { margin: 0 0 10px; color: #c9c9d0; }
#speech-intro .si-fine, #dev-notice .si-fine { font-size: 13px; color: #9a9aa4; }
#speech-intro .si-row,  #dev-notice .si-row { display: flex; gap: 10px; margin-top: 14px; }
#speech-intro button, #dev-notice button {
  flex: 1 1 0; font: 600 16px/1 inherit; padding: 12px 10px;
  border-radius: 6px; cursor: pointer;
  background: #2a2a32; color: #e8e8ea; border: 1px solid #4a4a56;
}
#speech-intro button.si-yes, #dev-notice button.si-yes { background: var(--bd-gold); color: #1a1a20; border-color: #a07820; }
/* The address is the point of the notice, so it must be selectable and must not
   be truncated on a narrow screen if the mail client never opens. */
#dev-notice .dn-code {
  display: flex; align-items: center; gap: 10px;
  margin: 14px 0 12px; color: #c9c9d0;
}
#dev-notice .dn-code span { flex: 1 1 auto; }
#dev-notice .dn-code input {
  flex: 0 0 5.5em; box-sizing: border-box;
  font: 600 16px/1 inherit;            /* 16px or iOS zooms the page on focus */
  text-align: center; letter-spacing: 0.25em;
  padding: 9px 6px; border-radius: 6px;
  background: #101016; color: #e8e8ea; border: 1px solid #4a4a56;
}
#dev-notice .dn-code input:focus {
  outline: none; border-color: var(--bd-gold);
}
#dev-notice .dn-mail {
  display: block; margin: 2px 0 0;
  color: var(--bd-gold); font-weight: 600;
  word-break: break-all; user-select: text; -webkit-user-select: text;
}
/* The download note lives where the Pair button will later sit: the message
   itself must stay readable, so progress cannot go in the reading panel. */
#pair-status { font-size: 10px; color: #9a9aa4; }



/* ── "Device": the viewer link, as a QR and as text (2026-09-22) ──────────
   Reuses .modal-overlay/.modal-box so this dialog looks like the one BD
   already had. Only the two things that are new need styling. */
#device-qr {
  display: none;
  /* A QR needs a LIGHT quiet zone to be read. On a dark dialog it has to
     bring its own, so the canvas is deliberately a white tile rather than
     inheriting the box. */
  background: #fff;
  border-radius: 4px;
  margin: 0 auto 12px;
}
#device-qr.on { display: block; }
#device-url-row {
  display: flex;
  gap: 8px;
  align-items: center;
  margin-bottom: 10px;
}
#device-url {
  flex: 1 1 auto;
  min-width: 0;
  background: #101014;
  color: #cfcfc4;
  border: 1px solid #4a4a3a;
  border-radius: 3px;
  padding: 6px 8px;
  /* 16px or iOS Safari zooms the whole page on focus — the rule that applies
     to every focusable input in this project. */
  font: 16px ui-monospace, 'Courier New', monospace;
}
#device-copy {
  flex: 0 0 auto;
  background: #3d2e00;
  color: #ffd88a;
  border: 1px solid #a07820;
  border-radius: 3px;
  padding: 6px 10px;
  font-size: 13px;
  cursor: pointer;
  font-family: sans-serif;
}
#device-copy:hover { background: rgba(160, 120, 32, 0.2); color: #fff; }
#device-ttl { font-size: 12px; color: #9a9a8a; margin-bottom: 12px; }
#device-ttl.expired { color: #d98a8a; }


/* A viewer's Sync landing here (2026-09-22). Sits left of View, says its
   piece for two seconds and goes. Deliberately quiet: it explains a change
   the user did not make, which is a different job from asking for attention. */
#av-sync-note {
  font-size: 12px;
  color: #ffd88a;
  background: rgba(160, 120, 32, 0.18);
  border: 1px solid #a07820;
  border-radius: 3px;
  padding: 3px 8px;
  margin-right: 6px;
  white-space: nowrap;
}
#av-sync-note[hidden] { display: none; }

/* ══ VIEW — the in-page presentation surface (2026-10-08) ══════════════════
   "BD Viewer": the preview's own module, at full size, with BD's furniture
   out of the way. Achieved ENTIRELY by restyling what is already on the page.

   WHY NOTHING IS MOVED IN THE DOM. Reparenting an <iframe> reloads it — the
   document is destroyed and rebuilt — which would restart the module, stop
   the audio and lose whatever the user had set up. #visual-iframe is already
   a direct child of <body> positioned by CSS, so View is a class and not a
   relayout. "Nothing leaves the page" is the whole design, and this is where
   it is cashed in.

   HIDE-ALL-THEN-EXEMPT, rather than a list of things to hide. A list would be
   correct today and wrong the first time a panel is added — the new one would
   leak into View and nobody would know why. Inverting it means a new panel is
   hidden by default and has to ASK to appear. #session-expired and
   #loading-overlay are exempt because an emergency must still be able to
   speak, and `display:none !important` would gag them.                     */
body.view-active {
  background: #07070c;
}
body.view-active > *:not(#bd-view-bar):not(#bd-view-text):not(#visual-iframe):not(#collage-playbar):not(#collage-sound-reserve):not(#session-expired):not(#loading-overlay):not(#copy-link-fallback):not(#copy-scope-notice) {
  display: none !important;
}

/* The return bar. PERMANENT — no fade, no tap-to-reveal. On an iPhone there is
   no Esc, so a control that hides itself is a control that can trap someone. */
/* ── THE ATTRIBUTE MUST WIN (fixed 2026-10-09) ────────────────────────────
   This rule said `display: flex` unqualified, and an author display rule BEATS
   the `hidden` attribute — which is only a low-specificity UA `display:none`.
   So the bar showed permanently: 44px, opaque, sitting exactly on top of the
   mode radios. Reported as "a Back button top left and no radio bar", which is
   one rule causing both halves.

   Both of these elements are toggled by `hidden` in JS, so the attribute is
   made to win explicitly rather than left to specificity. Narrow on purpose —
   a global `[hidden] { display: none !important }` would be the general fix and
   would also change every other element in BD that relies on an author display
   rule, which is not a change to make while chasing a bug.                  */
#bd-view-bar[hidden], #bd-view-text[hidden] { display: none !important; }

#bd-view-bar {
  position: fixed;
  top: 0; left: 0; right: 0;
  height: 44px;
  z-index: 60;
  display: none;
  align-items: center;
  gap: 12px;
  padding: 0 12px;
  background: #101018;
  border-bottom: 1px solid #2a2a32;
  box-sizing: border-box;
}
body.view-active #bd-view-bar:not([hidden]) { display: flex; }

/* THE COPY-SCOPE NOTICE (2026-10-23) — "this link is the saved node, not your
   changes". Shown only when the card's script has actually diverged from the
   node's, so it is never noise.

   GOLD border, from the mark palette, where gold means "mine": the message is
   about the reader's own work. Never hue alone — the bold first line and the
   panel's own luminance carry it too.

   Bottom-centred so it clears the view bar at the top, and exempted from
   View's whitelist below for the same reason the clipboard fallback is: it is
   appended to document.body, and a notice about copying that cannot be seen
   after a copy is worse than none. */
#copy-scope-notice {
  position: fixed;
  left: 50%;
  bottom: 18px;
  transform: translateX(-50%);
  z-index: 80;
  max-width: min(520px, calc(100vw - 32px));
  box-sizing: border-box;
  background: #15151d;
  border: 1px solid #8d7900;
  border-radius: 5px;
  padding: 12px 14px;
  color: #e8e8e0;
  font: 13px/1.45 system-ui, sans-serif;
  box-shadow: 0 6px 24px rgba(0, 0, 0, 0.55);
}
#copy-scope-notice .csn-head {
  margin: 0 0 6px;
  font-weight: 600;
  color: #f2f2ec;
}
#copy-scope-notice p { margin: 0 0 10px; color: #c8c8c0; }
#copy-scope-notice button {
  background: #1c1c26;
  border: 1px solid #3a3a46;
  color: #e8e8e0;
  font: 13px/1 system-ui, sans-serif;
  min-height: 32px;
  padding: 0 14px;
  border-radius: 3px;
  cursor: pointer;
}
#copy-scope-notice button:hover { background: #26262f; color: #fff; }

/* Survives View via the whitelist above, not via a competing display rule —
   see the correction on #copy-link-fallback below. */

/* Stacking only. Surviving View is handled where it belongs — in the whitelist
   above, which now names #copy-link-fallback and #copy-scope-notice.

   CORRECTED 2026-10-23, having been wrong twice in one day. The first cut added
   a separate `body.view-active > #copy-link-fallback { display: block
   !important }` and a comment claiming !important was enough. It is not: the
   whitelist's declaration is ALSO !important, so the two compete on specificity
   and the whitelist's seven :not()-carried ids beat this selector's one. Both
   exemptions were silently losing — the author found it by watching the copy
   notice appear only AFTER leaving View, which is the signature of an element
   that existed all along and was merely hidden.

   The general lesson, met again: A LIST WRITTEN TWICE WILL BE CHANGED ONCE.
   The whitelist is the single statement of what survives View; a second rule
   arguing with it is not an addition to the list, it is a competitor to it. */
body.view-active > #copy-link-fallback { z-index: 70; }
#bd-view-exit {
  background: #1c1c26;
  border: 1px solid #8d7900;          /* local gold — see the mark palette */
  color: #e8e8e0;
  font: 13px/1 system-ui, sans-serif;
  /* 16px would be the rule for an INPUT (iOS auto-zooms below it); a button is
     not focusable text, so 13 is safe here. Min-height for the touch target. */
  min-height: 32px;
  padding: 0 14px;
  border-radius: 3px;
  cursor: pointer;
}
#bd-view-exit:hover { background: #26262f; color: #fff; }
/* Same family as Back, one step quieter: Back is the way out and must read
   first. Shares the hidden-attribute guard above so JS can toggle it. */
#bd-view-speak[hidden] { display: none !important; }
#bd-view-speak {
  background: #16161e;
  border: 1px solid #3a3a46;
  color: #d8d8d0;
  font: 13px/1 system-ui, sans-serif;
  min-height: 32px;
  padding: 0 14px;
  border-radius: 3px;
  cursor: pointer;
}
#bd-view-speak:hover { background: #20202a; color: #fff; }
#bd-view-speak.speaking { border-color: #8d7900; color: #fff; }
/* Copy, in the view bar (2026-10-23). Same language as #bd-view-speak but
   10px of side padding rather than 14, because the bar is measured: at 390px
   with Back + Speak + the hint there are about 35 pixels to find. */
#bd-view-copy {
  background: #16161e;
  border: 1px solid #3a3a46;
  color: #d8d8d0;
  /* TWO LINES AND SMALLER TYPE, so the full name costs LESS width than the
     one-word version did: "Copy" at 10px is ~24px of glyphs against ~32px at
     13px, and the 7px side padding takes the button to about 38px where single-
     line "Copy" was 56. The bar is 44px tall and two 10px lines occupy 23, so
     the height is free — this is width bought with a dimension that was going
     spare. The author asked for the full label and for it not to grow the bar;
     both are satisfied by spending the height instead.

     Not a focusable input, so sub-16px type carries no iOS zoom risk — the same
     reasoning the 13px on #bd-view-exit and #bd-view-speak rests on. The 32px
     min-height keeps the touch target. */
  font: 10px/1.15 system-ui, sans-serif;
  text-align: center;
  min-height: 32px;
  padding: 0 7px;
  border-radius: 3px;
  cursor: pointer;
  flex: 0 0 auto;
}
#bd-view-copy:hover { background: #22222c; color: #fff; }

#bd-view-title {
  font: 12px/1 system-ui, sans-serif;
  color: #9e9e90;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  /* The shrinkable item. Without min-width:0 a flex child refuses to go below
     its content's intrinsic width, so the ellipsis never engages and the bar
     overflows instead — the title would push Copy off the end. */
  min-width: 0;
  flex: 1 1 auto;
}

/* NARROW SCREENS: the title goes, on the author's offer — "the Node title is
   not vital". The view bar names one node and the card behind it says which,
   so the title is the one item here that repeats information. Keeps Back,
   Speak, Copy and the hint whole on a portrait phone. */
@media (max-width: 430px) {
  #bd-view-bar { gap: 8px; }
  #bd-view-title { display: none; }
}

/* How to reach the controls — Browse only, set by bdViewEnter (2026-10-20).
   `margin-left: auto` pushes it to the right-hand end of the flex bar, and
   `flex: 0 0 auto` keeps it whole: the title beside it has the ellipsis and
   is the one that should give way, because a truncated node name still reads
   while a truncated instruction does not. Dimmer than the title, because it
   is standing guidance rather than information about this node. */
#bd-view-hint {
  margin-left: auto;
  /* 2026-10-23 — was `flex: 0 0 auto`, which kept it whole and pushed the new
     Copy button off the bar on a narrow screen. It now truncates: of everything
     in this bar the hint is the one thing a reader can afford to lose half of,
     since it describes a gesture rather than naming content. */
  flex: 0 1 auto;
  overflow: hidden;
  text-overflow: ellipsis;
  font: 11px/1 system-ui, sans-serif;
  color: #6f6f66;
  white-space: nowrap;
}

/* The module fills what is left. Two bands, chosen by the module's `kind`:
   a visual output takes the space, a musical one is a shallow strip at the
   foot — which is where it will sit in a collage, so View rehearses it. */
/* ── ONLY WHEN THE VIEWED NODE HAS A MODULE (2026-10-11) ──────────────────
   This was `body.view-active #visual-iframe`, so the frame was shown whenever
   View was open — and once ANY module had been loaded it kept its src, so
   viewing a TEXT node afterwards put the last module's picture behind the
   prose. A hole from the start, invisible until something had been loaded
   before: X -> Y -> Z -> a collage -> Back -> View on a text node, and the
   kolam was still there.
   `view-has-module` is set by the two branches of bdViewEnter that actually
   show a module, and by neither the text branch nor anything else. */
body.view-active.view-has-module #visual-iframe {
  display: block;
  position: fixed;
  top: 44px; left: 0;
  width: 100vw;
  height: calc(100dvh - 44px);
  z-index: 1;
}
/* 110px: enough for a transport row plus the two or three steppers the author
   wants room for later, and shallow enough to leave a collage its picture. */
body.view-active.view-music #visual-iframe {
  top: auto;
  bottom: 0;
  height: 110px;
}

/* Text has no module, so it presents directly. */
body.view-active #bd-view-text:not([hidden]) {
  display: flex;
  flex-direction: column;
  align-items: center;                 /* centred across the screen */
  position: fixed;
  top: 44px; left: 0; right: 0; bottom: 0;
  padding: 24px clamp(16px, 6vw, 80px);
  box-sizing: border-box;
  overflow-y: auto;
  z-index: 2;
}
/* CENTRED VERTICALLY BY AUTO MARGINS, not by justify-content. In a scrolling
   flex container `justify-content: center` CLIPS the overflow at the top — the
   first lines become unreachable once the text is taller than the screen.
   A top auto-margin here and a bottom one on the reference line below centre
   the pair when it fits and scroll properly when it does not. */
#bd-view-text-body {
  margin-top: auto;
  /* Bigger, as asked, and relative so it reads at any size — the same reason
     the collage's text will want relative units rather than px: one value then
     serves a small preview and a full screen. */
  font: clamp(20px, 2.2vw, 28px)/1.6 Georgia, "Times New Roman", serif;
  color: #e8e8e0;
  white-space: pre-wrap;
  /* Narrower than before because the type is larger: a comfortable line is
     measured in CHARACTERS, not pixels, so this holds as the font scales.
     Left-aligned inside a centred block — centred prose is hard to read, and
     the block being centred is what was asked for. */
  max-width: 54ch;
}
/* Dim grey on purpose: it is for keeping track of WHICH node this was, so the
   collage's Merge can name it later. Not for reading. */
#bd-view-text-ref {
  margin-top: 24px;
  margin-bottom: auto;                 /* the other half of the centring */
  max-width: 54ch;
  padding-top: 10px;
  border-top: 1px solid #2a2a34;
  /* 13px, not 11: the body text beside it is now clamp(20px..28px), and a
     reference line a fifth of that size reads as decoration rather than
     information. */
  font: 13px/1.6 "Courier New", Courier, monospace;
  /* BRIGHTENED 2026-10-09, and measured rather than judged. #5a5a62 on this
     background is 2.94:1 — it fails BOTH WCAG thresholds (4.5 body, 3.0
     large), so "dim grey" had become "not readable", which is what was
     reported. #9e9e90 is 7.42:1 and is already BD's colour for subordinate
     text, so this is the established value rather than a new one. Still
     plainly subordinate: the prose beside it is 16.3:1.

     Legibility here is not a preference — see the note on the author's
     reduced colour vision. Luminance contrast is the thing to get right, and
     it is the thing that can be calculated instead of guessed. */
  color: #9e9e90;
  word-break: break-all;
}

/* ══ COLLAGE v1 — the picture behind, the words over it (2026-10-08) ═══════
   Almost no new layout, and that is the point: View already stacks
   #bd-view-text (z-index 2) over #visual-iframe (z-index 1) with a panel that
   declares no background of its own. The two were simply never shown at the
   same time. So this file adds a reserve for the music strip, a luminance
   safety margin under the glyphs, and nothing else.

   THE FIGURE IS DIMMED BY THE MODULE, NOT HERE. There is deliberately no
   `opacity` on #visual-iframe: a module's own `%%bd_opacity` fades its ink
   toward its own background, so on this near-black ground the figure recedes,
   whereas CSS opacity here would also fade the slot's own black toward the page
   and buy nothing. The two controls share a name and are not the same lever —
   PLANNING_REGISTER.md, "Added 2026-10-08 — text over a 3D graphic".          */
body.view-active.collage-active {
  --bd-collage-music: 0px;
  /* ── ONE BOX FOR BOTH, AND WHY IT HAS TO BE ────────────────────────────
     The module's canvas is SQUARE — `min(wrapper width, height)` — so a
     full-screen iframe on a phone draws a centred 390x390 square inside a
     390x756 frame, while the text was laid out against the whole screen.
     Two different boxes: the picture centred as a square, the words centred
     on the viewport. Nothing could line up, and the author's report is
     exactly that — "the graphic occupies a centred roughly half the screen
     whilst the text occupies the top 65%, so not in any way centred in the
     graphics frame".

     So the square is named ONCE here and both read it. That is also the
     answer to "scaling both graphic and text might be a problem": it is not,
     as long as there is a single number. Two expressions that happen to agree
     today are the problem.

     If this value fails to resolve, the width/height declarations that use it
     are dropped and both boxes fall back to filling the screen — which is
     today's behaviour, not a collapse. That degradation is deliberate: the
     last two rounds were both lost to a dropped declaration leaving nothing
     behind. */
  --bd-collage-side: min(100vw, calc(100dvh - 44px - var(--bd-collage-music)));
}
/* 110px — the same figure the single-module music band uses, so a collage and
   View agree about how deep a player is. Read by the TEXT COLUMN's bottom
   inset, as a plain length rather than a term inside a calc — see the note on
   the picture below for why that distinction cost two rounds. */
body.view-active.collage-active.collage-music { --bd-collage-music: 110px; }

/* ── THE PLAYER IN VIEW ───────────────────────────────────────────────────
   The same frame the preview uses, moved to the foot. 110px — the depth
   single-module View gives a musical Output, so a collage and View agree about
   how deep a player is — and --bd-collage-side already subtracts
   --bd-collage-music, so the square shrinks by exactly this and the two cannot
   overlap by construction.

   Above the picture and the text both: a transport has to be reachable
   whichever of those two the script puts on top.

   The PREVIEW's inline rect and clip-path are cleared by bdViewEnter, because
   an inline style beats this rule and positionCyEl — the only thing that would
   otherwise remove them — stands down in View. */
body.view-active.collage-active.collage-music #collage-playbar {
  display: block;
  position: fixed;
  left: 0; right: 0; bottom: 0;
  top: auto;
  width: auto;
  height: 110px;
  z-index: 20;
  background: transparent;
}
/* The dashed placeholder stands down once there is a real player to show. It
   stays in the file: it is what proves the space is reserved when a collage
   names a music module BD cannot load. */
body.view-active.collage-active.collage-music #collage-sound-reserve { display: none; }

/* ── THE RESERVED SOUND BAND — EMPTY ON PURPOSE ──────────────────────────
   110px at the foot, the same depth single-module View gives a musical Output,
   so a collage and View agree about how deep a player is.

   It does NOT overlay the picture: --bd-collage-side already subtracts
   --bd-collage-music, so the square shrinks by exactly this depth and the two
   cannot overlap by construction rather than by two calculations agreeing.

   SHOWN rather than left as black, so the depth can be judged on a real screen
   before anything is committed to it. Dashed and dim — it is scaffolding and it
   should read as scaffolding. Nothing is loaded into it: no iframe, no module.
   The two commits that did load one are reverted. */
#collage-sound-reserve[hidden] { display: none !important; }
#collage-sound-reserve { display: none; }
body.view-active.collage-active.collage-music #collage-sound-reserve:not([hidden]) {
  display: flex;
  align-items: center;
  justify-content: center;
  position: fixed;
  left: 0; right: 0; bottom: 0;
  height: 110px;
  box-sizing: border-box;
  border-top: 1px dashed #3a3a46;
  /* Above the picture and the text both: whatever eventually goes here is a
     CONTROL, and a control has to be reachable whichever of those two is on
     top. */
  z-index: 20;
  background: transparent;
  /* Nothing in it to press yet, and it must not intercept the taps that scroll
     the text. */
  pointer-events: none;
}
body.view-active.collage-active.collage-music #collage-sound-reserve > span {
  font: 8px/11px ui-monospace, "SF Mono", Menlo, monospace;
  letter-spacing: 0.08em;
  color: #7d7d72;
  text-transform: uppercase;
}

/* ── THE PICTURE IS THE SQUARE ───────────────────────────────────────────
   Centred by `margin: auto` against all four insets with a DEFINITE width and
   height. That is the one safe way to centre an absolutely positioned replaced
   element, and the reason the size is stated rather than left to `auto`:

   TWO WRONG CUTS, both reaching the same 150px box, both worth recording:

   1. `height: calc(100dvh - 44px - var(--bd-collage-music))`. On iOS the
      picture came up as a small frame. A calc that does not resolve drops the
      WHOLE declaration, and nothing else supplied a height — JS normally
      stamps one and View clears it — so the iframe fell back to its intrinsic
      size. dvh was NOT the culprit: the base rule's own `calc(100dvh - 44px)`
      works on iOS in single-module View.

   2. `top/left/right/bottom` with `width: auto; height: auto`, expecting the
      box to stretch to the insets. AN IFRAME IS A REPLACED ELEMENT — `auto`
      resolves to the intrinsic 300x150, not to the insets — and the
      over-constrained inset then parked it in a corner. "Very small and
      positioned to the top right", and that one broke desktop too.

   The rule to carry: a replaced element must be given a STATED size.

   Shrinking the iframe to the square costs nothing visible, because the area
   it gives up was never the picture — it was the module's own background,
   which is #0a0a0f against the page's #07070c. If anything the faint edge
   between those two blacks gets smaller.                                   */
body.view-active.collage-active #visual-iframe {
  /* THE FOURTH STACKED BACKGROUND. #visual-iframe carries
     `background: #0a0a0f` for the ordinary case; a module set to
     %%bd_background transparent would still sit on that, so whatever the
     collage put behind it would be hidden and nothing would say why. Safe
     unconditionally here, because an opaque module paints its own background
     over this one anyway. The other three are the module body, the wrapper's
     html+body, and three.js's clear alpha. */
  background: transparent;
  top: 44px; left: 0; right: 0;
  bottom: var(--bd-collage-music);
  margin: auto;
  width:  var(--bd-collage-side);
  height: var(--bd-collage-side);
}
body.view-active.collage-active #bd-view-text:not([hidden]) {
  /* THE SAME BOX AS THE PICTURE, by the same centring, from the same value —
     so the words sit inside the figure's frame instead of on the screen. */
  top: 44px; left: 0; right: 0;
  bottom: var(--bd-collage-music);
  margin: auto;
  width:  var(--bd-collage-side);
  height: var(--bd-collage-side);
  /* Padding in PER CENT, so the margin round the words is a property of the
     square rather than a pixel count that stops meaning anything when the
     square is a third the size. */
  padding: 5%;
  /* Makes this element the query container the font below measures itself
     against. Without it `cqmin` falls back to the viewport-based line. */
  container-type: size;
  /* Stays interactive on purpose. The obvious move is pointer-events:none so
     taps reach the picture — but this panel IS the scroll container, and the
     Kolam canvas takes no pointer input in outputOnly (its only handlers are on
     the stepper buttons, which are hidden). So there is nothing beneath to
     reach and everything to lose. */
  pointer-events: auto;
}
body.view-active.collage-active #bd-view-text-body {
  /* ── THE FONT SCALES WITH THE SQUARE, NOT WITH THE VIEWPORT ────────────
     The single-node rule is `clamp(20px, 2.2vw, 28px)`, and 2.2vw on a
     390px-wide phone is 8.6px — so the clamp FLOOR held it at 20px and the
     type stopped scaling altogether. That is why shrinking the desktop window
     made the text overlap the frame: the box got smaller and the glyphs did
     not.

     Two declarations, the second preferred. `vmin` already fixes the real
     complaint, because it tracks whichever viewport dimension is smaller —
     which on a phone is the one that defines the square. `cqmin` is better
     still: it measures the SQUARE ITSELF, so it stays correct if the box ever
     stops being viewport-derived (a quarter-size slot in a real collage, say).
     An engine without container units simply keeps the first line. */
  font-size: clamp(11px, 3.6vmin, 24px);
  font-size: clamp(11px, 4.2cqmin, 24px);
  /* The square is the measure now. 54ch at this size would be wider than the
     box, and a max-width that never binds is a comment pretending to be a
     rule. */
  max-width: 100%;
  /* ── TOP-ALIGNED, AND WHY IT WAS AT THE BOTTOM ──────────────────────────
     The single-node rules centre the prose with a PAIR of auto margins —
     `margin-top: auto` on this body and `margin-bottom: auto` on the reference
     line below it. A collage empties the reference line and the rule above
     takes it out of the flow, which leaves the top margin UNOPPOSED: the text
     was not centred, it was pushed to the foot, and a long passage then
     overspilled the frame. One of a pair of balanced margins is not half a
     centring, it is an alignment to the other end.

     Top-aligned is also the better default here regardless: a passage grows
     downwards into space that is already reserved, instead of growing upwards
     out of the top of the screen.                                          */
  margin-top: 0;
  /* Author's call, 2026-10-08 — a little of the figure shows through the
     glyphs. Applied to the element, so the shadow below fades with the text
     rather than standing proud of it as a hard outline. Worth knowing it cuts
     contrast: at 0.7 over this ground the prose goes from about 16:1 to about
     9:1, which is still comfortably above the 4.5:1 body threshold, but the
     margin for a bright passage of the figure is now thinner. */
  opacity: 0.7;
  /* LUMINANCE, NOT COLOUR. A dark halo under bright glyphs holds whatever hue
     the figure takes, which matters because the ink's luminance spans 6.6x
     across hue at one `lightness` setting and `colour_speed` cycles hue along
     the curve — so both extremes are present in a single frame and no single
     text colour is safe against all of it. Two shadows: a tight one for the
     stroke edge, a wide soft one to lift the glyph off a busy lattice.
     This is the cheap half of the guarantee; a scrim is the expensive half and
     is deliberately NOT here until a passage proves it needs one. */
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.95), 0 0 10px rgba(0, 0, 0, 0.75);
}
/* No reference line over a picture — JS empties it, and this makes sure an
   empty one takes no space (its border-top would otherwise draw a bare rule
   across the artwork). */
body.view-active.collage-active #bd-view-text-ref:empty {
  display: none;
}
