/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/ai/ai.css */
/* grain/ai/ai.css — the AI-interaction MODULE's styling: the "AI is acting" spotlight.
   Optional: include this ONLY when you use GRAIN's AI interface. The design system
   (grain/styles/grain.css + the b-* atoms) works without it. (AI-INTERFACE §5c) */

/* When the AI (not the user) drives a surface, the page dims and a LAMP — one traveling
   frame — glides onto the touched surface. Calm, monochrome, no shadows on content. */
/* THE LAMP MODEL (the root design): the spotlight is ONE fixed-position element whose rect
   glides between surfaces; the dim is the lamp's own cutout shadow, so hole and veil travel
   together. The lit element is NEVER restyled — a rect is a rect, so every surface kind
   (card, list, row, input) gets the same treatment by construction (this designed out the
   old per-kind lit CSS, grain lesson 8). The veil color is ONE theme token, --ai-veil, set
   once in the theme (variables.css) so every page fades the same; the default below (a
   gentle ink dim) applies if a theme doesn't set it. */
.ai-lamp {
  position: fixed; top: 0; left: 0; z-index: var(--z-ai-veil, 9000);
  border-radius: var(--radius-md);   /* no border — the lamp is just the lit hole in the veil */
  /* the dim IS the lamp's shadow: everything outside the frame recedes, and it MOVES with it */
  box-shadow: 0 0 0 200vmax var(--ai-veil, color-mix(in srgb, var(--ink) 22%, transparent));
  opacity: 0; pointer-events: none;
  /* the focus-switch glide — the lamp EASES onto the new surface, soft start and landing,
     like a page transition (keep the duration ≤ the reasoner's SETTLE so it lands before
     the AI acts). Travel rides `transform` (composited — GPU, never janked by layout);
     only the box's size interpolates in layout, and it's one element. */
  transform: translate3d(50vw, 50vh, 0);
  will-change: transform;   /* transform only — promoting width/height forces extra invalidations */
  transition: opacity 0.35s ease,
              transform var(--ai-focus-move, 0.4s) var(--ai-focus-ease, ease),
              width     var(--ai-focus-move, 0.4s) var(--ai-focus-ease, ease),
              height    var(--ai-focus-move, 0.4s) var(--ai-focus-ease, ease);
}
.ai-lamp.is-on { opacity: 1; }
/* The click-catcher: invisible (the lamp's shadow carries the dim) but it still owns the
   interrupt gesture — clicking anywhere while the AI acts asks it to stop. AI surfaces that
   stay live during a run (chat, console, the acting label) sit above it at --z-ai-surface. */
.ai-backdrop {
  position: fixed; inset: 0; z-index: var(--z-ai-veil, 9000);
  background: transparent; pointer-events: none;
}
.ai-backdrop.is-on { pointer-events: auto; }
/* .ai-spotlit stays as the SEMANTIC marker for the surface under the lamp (tests, per-kind
   data-attr logic in the dispatcher) — it carries no visual of its own in the lamp model. */
/* scroll-margin gives the lit surface breathing room from the scroll region's top/bottom edges
   when the dispatcher scrolls it into view (block:"start") — so it never butts the top bar. */
.ai-spotlit { position: relative; scroll-margin-top: var(--space-4); scroll-margin-bottom: var(--space-4); }
/* the "AI clicked here" pulse. Self-sufficient: .is-click alone renders it (position:relative
   anchors its own ::before), so pulsing never silently no-ops for want of a sibling .ai-spotlit.
   (.ai-spotlit already establishes a positioned context, so spotlit elements are unaffected.) */
.is-click { position: relative; }
.is-click::before {     /* ::before so it never collides with a button's ::after caret */
  content: ""; position: absolute; inset: -4px;
  border: 1px solid var(--ink); border-radius: inherit;
  animation: ai-click 0.5s ease-out;
  pointer-events: none;
}
@keyframes ai-click { from { transform: scale(1); opacity: 0.7; } to { transform: scale(1.12); opacity: 0; } }
.ai-acting-label {
  position: fixed; top: var(--space-4); left: 50%; transform: translateX(-50%); z-index: var(--z-ai-surface, 9002);
  font-family: var(--font-grain); font-size: var(--text-sm); color: var(--ink);
  background: var(--paper); border: var(--border); border-radius: var(--radius-sm);
  padding: var(--space-1) var(--space-3);
}

/* interrupt confirm — "ask the AI to stop?" (mediated, never a force-kill).
   A native modal <dialog>: top-layer, focus-trap, Escape-to-close come free. The bare
   element is transparent (the spotlight backdrop already dims); only the card shows. */
.ai-confirm { border: 0; padding: 0; background: transparent; opacity: 0; transition: opacity 0.18s ease; }
.ai-confirm[open] { opacity: 1; }
@starting-style { .ai-confirm[open] { opacity: 0; } }   /* fade in from display:none */
.ai-confirm::backdrop { background: transparent; }      /* the .ai-backdrop beneath already provides the dim */
.ai-confirm__card {
  background: var(--paper); border: 1px solid var(--ink); border-radius: var(--radius-md);
  padding: var(--space-5); max-width: 24rem; text-align: center;
}
.ai-confirm__msg { margin: 0 0 var(--space-4); }
.ai-confirm__actions { display: flex; gap: var(--space-3); justify-content: center; }

@media (prefers-reduced-motion: reduce) {
  .ai-lamp { transition: none; }
  .ai-confirm { transition: none; }
  .is-click::before { animation: none; display: none; }
}

/* ---- X-RAY: see the page as the AI sees it (grain/scripts/xray.js) --------------------
   A dev-mode overlay: outline every operable surface and label it with kind + accepted
   verbs. Purely presentational — xray.js sets `data-xray` on <html> and stamps each
   [data-surface] with a `data-xray-label`; this draws them. Tokens only, so it re-skins
   with the theme. It's the visual twin of the terminal's `context` command. */
html[data-xray] [data-surface] {
  outline: 1px dashed var(--color-accent, var(--ink));
  outline-offset: 2px;
  position: relative;   /* anchor the label; dev-mode only (may shift abs-positioned children) */
}
html[data-xray] [data-surface]::after {
  content: attr(data-xray-label);
  position: absolute; top: 0; right: 0; z-index: var(--z-ai-surface, 9002);
  font-family: var(--font-grain); font-size: var(--text-xs); line-height: 1;
  padding: var(--space-1) var(--space-2);
  color: var(--color-accent, var(--ink));
  background: var(--color-bg, var(--paper));
  border: 1px solid var(--color-line, var(--ink));
  border-radius: var(--radius-sm);
  pointer-events: none; white-space: nowrap;
}

/* ---- honest presence (the AI-online indicator) ---------------------------------------
   The dispatcher stamps <body data-ai-online="true|false"> by OUTCOME (server `ready`
   handshake received / client door actually loaded — ai-dispatch.js). A consumer's presence
   indicator shows three states: waking (default, nothing reported yet), online, offline.
   The wording inside the spans is the consumer's. */
.presence__on, .presence__off { display: none; }
body[data-ai-online] .presence__wait { display: none; }
body[data-ai-online="true"]  .presence__on  { display: inline; }
body[data-ai-online="false"] .presence__off { display: inline; }
/* the presence STAR is its own element (so its color tracks transport health, apart from the
   wording): muted while waking, the accent hue when online, faint when offline. */
.presence__star { color: var(--color-muted); }
body[data-ai-online="true"]  .presence__star { color: var(--color-accent); }
body[data-ai-online="false"] .presence__star { color: var(--ink-faint); }

/* ---- presence GATING: when the door's reply channel is down, AI controls are visibly
   disabled (not silently dead). Consumers mark AI-only controls [data-ai-run] (a trigger that
   drives the AI) or [data-ai-gate] (a composer/region gated on the AI). The dispatcher also
   no-ops submit() when offline (belt-and-braces) — this is the honest visual half. */
body[data-ai-online="false"] [data-ai-run],
body[data-ai-online="false"] [data-ai-gate] {
  pointer-events: none; opacity: 0.5; cursor: not-allowed;
}

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/atoms/action-badge/action-badge.css */
/* atoms/action-badge — the AI's action verb as a badge. It's AI by nature, so it always
   wears the non-text grain (dashed "terminal" edge + grain family), the same vocabulary as
   b-button / b-badge in transit. `data-status` carries the step's state:
     (default) done   → dashed outline, muted ink
     active            → filled ink (the AI is doing THIS now)  */
.action-badge {
  display: inline-block;
  font-family: var(--font-grain);          /* always grain — the AI is always the actor */
  font-size: var(--text-xs);
  text-transform: uppercase; letter-spacing: 0.08em;
  padding: 0 var(--space-2);
  color: var(--ink-muted);
  border: 1px dashed var(--ink); border-radius: var(--radius-pill);
}
.action-badge[data-status="active"] {
  background: var(--ink); color: var(--paper); border-style: solid;
}

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/atoms/b-badge/b-badge.css */
/* atoms/b-badge — one class; tone via attribute. Monochrome: a hairline tag, no
   fill, no pill. State is carried by ink vs muted, not by hue (DESIGN-SYSTEM §2). */
.badge {
  font-size: var(--text-xs); padding: 0 var(--space-2);
  border: 1px solid var(--line-soft); border-radius: var(--radius-pill);
  background: transparent;
}
.badge[data-status="active"]   { color: var(--ink); border-color: var(--ink); }
.badge[data-status="archived"] { color: var(--ink-muted); }

/* ---- AI / in-transit: the non-text grain. A badge the AI is setting (live pending, or
   any grain ancestor) wears the dashed "terminal" edge; the label already swaps to the
   grain family via --type-font. Same vocabulary as the button + field. */
.badge[data-commit="pending"],
[data-grade="grain"] .badge { border-style: dashed; border-color: var(--ink); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/atoms/b-button/b-button.css */
/* atoms/b-button — the button, ONE class: .btn. Variants are ATTRIBUTES, not
   extra classes (vanilla CSS, no utility soup). The component owns its styling.
   Each pseudo-state has a parallel data-force value so the catalog can show it
   statically (e.g. .btn:hover AND .btn[data-force~="hover"]). */

.btn {
  display: inline-flex; align-items: center; justify-content: center;
  gap: var(--space-2);
  font-family: var(--type-font);
  font-weight: var(--font-weight-medium);
  font-size: var(--text-sm);
  letter-spacing: 0.08em; text-transform: uppercase;   /* DESIGN-SYSTEM §3 button label */
  line-height: var(--leading-normal);
  min-height: 2.75rem;                    /* ≥44px touch target (DESIGN-SYSTEM §7) */
  padding: var(--space-2) var(--space-4);
  border: var(--border);                  /* ink hairline — no fill, no shadow */
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: background-color 0.15s, color 0.15s, border-color 0.15s;

  /* default = transparent fill, ink border + label (DESIGN-SYSTEM §5 Buttons) */
  background: transparent;
  color: var(--ink);
}

/* ---- sizes (attribute) ---- */
.btn[data-size="sm"] { min-height: 0; padding: var(--space-1) var(--space-3); font-size: var(--text-xs); }
.btn[data-size="lg"] { padding: var(--space-3) var(--space-5); font-size: var(--text-base); }
/* On a phone the small button's dense min-height:0 lands ~30px - below the touch
   target floor (DESIGN-SYSTEM §7). Restore a comfortable tap size at the narrow
   breakpoint without fattening the dense desktop rows. */
@media (max-width: 768px) { .btn[data-size="sm"] { min-height: 2.5rem; } }

/* ---- variants (attribute). Monochrome: the difference is fill, not hue ---- */
.btn[data-variant="soft"]    { background: var(--paper-2); }
.btn[data-variant="outline"] { background: transparent; }   /* already the default look */

/* ---- status (attribute): a doubled rule on the irreversible one, and nothing else ----
   Settled 2026-08-11 by a walked review, not by a reading: plans/grain-token-debt.md in the
   portfolio, tour review-grain-status, candidate B with the fill kept for the hover.
   Four --btn-accent* tokens used to be declared above and repointed here. They are DELETED
   rather than wired up, because wiring them would have rendered nothing: --color-success and
   --color-danger both resolve to var(--ink), with the same --paper and --paper-2 partners as
   every other button. There is no hue to spend and that is the design (DESIGN-SYSTEM §2), so
   the honest fix is to stop declaring a colour the palette does not have.
   success gets NO visual rule, on purpose. It keeps its meaning for assistive tech and for the
   AI layer while visual ranking stays data-variant's job, which is the axis that already works
   in a monochrome palette. Only the action you cannot undo is marked. */
.btn[data-status="danger"] { border-width: 2px; }

/* ---- pseudo-states. Hover = a quiet ink WASH (DESIGN-SYSTEM §5), never an invert and
   never a new hue — the full ink-fill invert read as a loud accent block and revealed the
   corner radius on otherwise-flat buttons (regression, 2026-07-04). ---- */
.btn:hover, .btn[data-force~="hover"] { background: color-mix(in srgb, var(--ink) 8%, transparent); }

/* The one exception to "hover is a wash", and the one place a solid fill is spent: the
   destructive control. At rest it is only a doubled rule, so the row stays quiet; the moment the
   pointer is on the button that cannot be undone it goes solid ink with a paper label, which is
   the loudest thing GRAIN can say without a hue. Reads at (0,3,0) so it beats the wash above on
   specificity, not on source order. border-color is set explicitly for a flavor that repoints
   --hairline away from --ink; in the stock themes the two are the same value. */
.btn[data-status="danger"]:hover,
.btn[data-status="danger"][data-force~="hover"] {
  background: var(--ink); color: var(--paper); border-color: var(--ink);
}

.btn:focus-visible, .btn[data-force~="focus"] { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.btn:active,        .btn[data-force~="active"] { transform: translateY(1px); }

.btn:disabled, .btn[disabled] { opacity: 0.5; cursor: not-allowed; pointer-events: none; }

/* ---- AI / in-transit: the non-text grain (grade = commit state, AI-INTERFACE §5).
   Same idea as the text grain, expressed for a button: the label already swaps to the
   Redaction grain family via the inherited --type-font; here we add the "retro terminal"
   edge — a dashed border + a blinking block caret — that says "machine-made, not settled".
   Keyed off the button's own pending state OR any grain ancestor (e.g. the catalog toggle). */
.btn[data-commit="pending"],
.btn[data-grade="grain"],
[data-grade="grain"] .btn {
  border-style: dashed;          /* a control the AI is using — terminal edge + grain label */
  pointer-events: none;          /* one intent at a time while the writer works */
}
/* processing indicator: a blinking block caret after the label = "this control is
   working". A flex item (the button is inline-flex) so it sits to the RIGHT of the
   label, vertically centred — not absolutely positioned (that was the old top-left bug,
   a collision with the spotlight pulse, which now lives on ::before). */
.btn[data-commit="pending"]::after {
  content: ""; align-self: center;
  width: 0.5ch; height: 1.05em; margin-left: 0.45ch;
  background: currentColor;
  animation: caret-blink 1s steps(1) infinite;   /* keyframes in grain/styles/grain.css */
}
@media (prefers-reduced-motion: reduce) { .btn[data-commit="pending"]::after { animation: none; } }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/atoms/b-checkbox/b-checkbox.css */
/* atoms/b-checkbox — the field family's tick box, and the stylesheet b-radio and b-check both reuse.
   It adds .field__box to the frame b-input.css owns, exactly the way b-select adds .field__select and
   b-textarea adds .field__textarea. One class per element (BEM, component-scoped).

   No appearance reset. The platform's own checkbox and radio are accessible, they already draw the
   difference between a square tick and a round dot, and a hand-drawn one would need a hardcoded color
   to fill — which the tokens-only rule does not allow. `accent-color` is the whole styling budget
   here, and it is a token.

   Not to be confused with b-switch, which is a drawn track-and-thumb for a setting the user flips.
   This is a checkbox in a form: something submitted, not something switched. */
.field__box {
  flex: none; margin: 0;
  inline-size: var(--text-lg); block-size: var(--text-lg);
  accent-color: var(--color-accent);
  cursor: pointer;
}

/* The row. A .field is a column — label above, control below — and that is right for every control
   whose value shows inside it. A tick box has no inside, so its label sits beside it and reads as the
   thing being agreed to. Keyed off the CONTROL being present rather than off a modifier class, so a
   caller cannot compose the markup correctly and still get the column: the layout follows from what
   is in the frame, which is the one thing that cannot be forgotten. */
.field:has(> .field__box) {
  flex-direction: row; flex-wrap: wrap; align-items: center; gap: var(--space-2);
  /* The tap target is the whole row, because the label wraps the input — which is also why the box
     itself is allowed to stay small. The row is what has to clear the ≥44px floor
     (DESIGN-SYSTEM §7); a 17px box and a 21px line of text would not, and a checkbox nobody can
     reliably hit on a phone is the one control where that failure is invisible on a desktop. */
  min-block-size: 2.75rem;
}
/* In a column the label captions a control that shows its own value, so it is muted. In a check row
   the label IS the value — the sentence the tick agrees to — so it reads at full ink. Size is
   unchanged, so a row of checks still lines up with the fields above it. */
.field:has(> .field__box) .field__label {
  color: var(--ink);
  /* Take the rest of the row, so a long sentence wraps INSIDE the label beside the box rather than
     wrapping the label itself onto the line under it. A tick box whose text has fallen below it
     reads as two controls, and the sentence a checkbox agrees to is usually long. */
  flex: 1;
}
/* The message slots would otherwise land beside the box on the same row. Send them under it, across
   the whole frame, where a hint about a checkbox actually reads. */
.field:has(> .field__box) .field__hint,
.field:has(> .field__box) .field__error { flex-basis: 100%; }

/* ---- sizes (attribute, on .field, same as b-input) ---- */
.field[data-size="sm"] .field__box { inline-size: var(--text-base); block-size: var(--text-base); }
.field[data-size="lg"] .field__box { inline-size: var(--text-xl); block-size: var(--text-xl); }

/* ---- AI / in-transit: the non-text grain, same language as the rest of the family
   (AI-INTERFACE §5). A box has no border of its own to dash, so the dashed "terminal" edge arrives
   as an outline instead of a border-style. The focus ring is declared AFTER this on purpose: both
   rules write `outline` on the same element, and a keyboard user's ring must win over the ambient
   AI treatment rather than depend on which selector happens to be more specific. */
.field[data-commit="pending"] .field__box,
[data-grade="grain"] .field__box { outline: 1px dashed var(--ink); outline-offset: 2px; }

/* focus ring; data-force parallel lets the catalog show the state statically (same pattern as
   b-input) — never `autofocus`, which would steal focus + scroll the catalog on load. */
.field__box:focus-visible, .field__box[data-force~="focus"] { outline: 2px solid var(--color-accent); outline-offset: 2px; }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/atoms/b-icon-button/b-icon-button.css */
/* atoms/b-icon-button — the icon square. One class (.icon-btn); variant/size are
   attributes. Mirrors b-button: ink hairline, transparent fill, quiet wash on hover, no
   shadow. Default is a ≥44px touch target (DESIGN-SYSTEM §7). */
.icon-btn {
  display: inline-flex; align-items: center; justify-content: center;
  width: 2.75rem; height: 2.75rem;        /* ≥44px touch target */
  flex-shrink: 0;                         /* the ≥44px promise is dead if a flex parent can shrink
                                              it — e.g. the mobile topbar squeezing the rail-toggle
                                              hamburger down to an untappable sliver (2026-07-14
                                              responsive-polish audit). A square icon affordance has
                                              no "shrink gracefully" reading; it's this size or gone. */
  padding: 0;
  border: var(--border); border-radius: var(--radius-sm);
  background: transparent; color: var(--ink);
  cursor: pointer;
  transition: background-color 0.15s, color 0.15s;
}

/* ---- sizes / variants (attributes) ---- */
.icon-btn[data-size="sm"] { width: 2rem; height: 2rem; }
.icon-btn[data-size="lg"] { width: 3.25rem; height: 3.25rem; }
.icon-btn[data-variant="ghost"] { border-color: transparent; }   /* rail / toolbar use */
/* the native `hidden` attribute and this rule's own `display` tie in specificity (both 0-1-0);
   author order wins, so `.icon-btn`'s display silently beats the UA's `[hidden]{display:none}`
   and a "hidden" button stays visible. Same trap, same fix as sidebar-panel's `.assistant__pane`. */
.icon-btn[hidden] { display: none; }

/* ---- pseudo-states. Hover = a quiet ink WASH (DESIGN-SYSTEM §5), never an invert —
   the ink-fill invert read as a loud block and made ghost buttons (invisible at rest)
   pop in as a rounded box out of nowhere (regression, 2026-07-04). ---- */
.icon-btn:hover, .icon-btn[data-force~="hover"] { background: color-mix(in srgb, var(--ink) 8%, transparent); }
.icon-btn:focus-visible, .icon-btn[data-force~="focus"] { outline: 2px solid var(--ink); outline-offset: 2px; }
.icon-btn:active, .icon-btn[data-force~="active"] { transform: translateY(1px); }
.icon-btn:disabled, .icon-btn[disabled] { opacity: 0.5; cursor: not-allowed; pointer-events: none; }

/* ---- AI / in-transit: the same non-text grain as b-button — a dashed "terminal"
   edge while the writer uses the control (AI-INTERFACE §5). ---- */
.icon-btn[data-commit="pending"],
.icon-btn[data-grade="grain"],
[data-grade="grain"] .icon-btn {
  border-style: dashed; border-color: var(--ink);
  pointer-events: none;
}

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/atoms/b-icon/b-icon.css */
/* atoms/b-icon — a monochrome line glyph. Inherits color (currentColor) so it flips
   ink↔grain with whatever it sits in; sized in rem so it scales with the layout, not
   a fixed pixel. One class (.icon); size is an attribute (DESIGN-SYSTEM §4). */
.icon {
  display: inline-block; flex: 0 0 auto; vertical-align: middle;
  width: 1.25rem; height: 1.25rem;   /* default */
  color: currentColor;               /* the sprite's stroke reads this */
}
.icon[data-size="sm"] { width: 1rem;   height: 1rem; }
.icon[data-size="lg"] { width: 1.5rem; height: 1.5rem; }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/atoms/b-input/b-input.css */
/* atoms/b-input — one class per element (BEM, component-scoped).
   Variants are attributes, same pattern as the button. */
.field { display: flex; flex-direction: column; gap: var(--space-1); flex: 1; }
.field__label { font-size: var(--text-sm); color: var(--color-muted); }
.field__input {
  padding: var(--space-2) var(--space-3);
  font-family: var(--type-font); font-size: var(--text-base);
  color: var(--ink); background: transparent;
  border: var(--border); border-radius: var(--radius-sm);   /* hard edge — you tap it */
}
.field__input::placeholder { color: var(--ink-muted); }
/* focus ring; data-force parallel lets the catalog show the state statically (same pattern as
   the button) — never `autofocus`, which would steal focus + scroll the catalog on load. */
.field__input:focus-visible, .field__input[data-force~="focus"] { outline: 2px solid var(--color-accent); outline-offset: 1px; }

/* invalid state — native, no JS. Monochrome: a heavier rule, not a red one. */
.field__input:user-invalid { border-width: 2px; }

/* ---- the two message slots, declared HERE because they belong to the frame rather than to any one
   control: an input, a select, a textarea and a checkbox all want somewhere to say a thing, and a
   per-control copy of these rules is how one frame becomes four. Both hide when empty, which is what
   makes them safe to put in every template: a slot with nothing in it must not open a gap in the
   column, and an optional slot nobody fills is the common case, not the exception. */
.field__hint { font-size: var(--text-xs); color: var(--ink-muted); line-height: var(--leading-normal); }
/* An error is NOT a red one. Status is weight, not hue (DESIGN-SYSTEM §2): the message reads at full
   ink against a hint's faint, and --color-danger is hueless by default so a flavor that DOES own a
   hue can reach it without this file ever naming a color. */
.field__error { font-size: var(--text-xs); color: var(--color-danger); line-height: var(--leading-normal); }
.field__hint:empty, .field__error:empty { display: none; }

/* ---- the required marker. No markup carries it and no author can forget it: the frame reads the
   `required` attribute the control already has to have anyway, so the marker cannot drift from the
   constraint the browser actually enforces. Decorative by construction — the semantics are native,
   and a screen reader announces the control as required with or without this glyph. */
.field:has(:required) .field__label::after {
  content: "*"; margin-inline-start: var(--space-1); color: var(--color-accent);
}

/* ---- variants (attribute) ---- */
.field[data-variant="inline"] { flex-direction: row; align-items: center; gap: var(--space-3); }
.field[data-variant="inline"] .field__label { flex: 0 0 auto; }

/* ---- sizes (attribute) ---- */
.field[data-size="sm"] .field__input { padding: var(--space-1) var(--space-2); font-size: var(--text-sm); }
.field[data-size="lg"] .field__input { padding: var(--space-3) var(--space-4); font-size: var(--text-lg); }

/* ---- AI / in-transit: the non-text grain, same language as the button (AI-INTERFACE §5).
   A field the AI is touching (live pending, or any grain ancestor) wears the dashed
   "terminal" edge; the label + value already swap to the grain family via --type-font.
   EXCEPTION: while the AI is actively COMPOSING, the dispatcher sets data-grade="smooth"
   on the input itself (clean ink, real caret, like a human typing) — so that stays solid.
   The `:not([data-grade="smooth"])` guard makes the exception explicit: a self-smooth input
   never matches the dashed rule, so the outcome doesn't depend on stylesheet source order. */
.field[data-commit="pending"] .field__input:not([data-grade="smooth"]),
[data-grade="grain"] .field__input:not([data-grade="smooth"]) { border-style: dashed; }
.field__input[data-grade="smooth"] { border-style: solid; }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/atoms/b-kbd/b-kbd.css */
/* atoms/b-kbd — a keyboard-shortcut hint. A quiet hairline chip in muted ink; reads the
   inherited --type-font like every type primitive (no bespoke font). */
.kbd {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 1.5rem; min-height: 1.5rem;
  padding: 0 var(--space-2);
  font-family: var(--type-font); font-size: var(--text-xs);
  color: var(--ink-muted);
  border: 1px solid var(--line-soft); border-radius: var(--radius-sm);
  white-space: nowrap;
}

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/atoms/b-list/b-list.css */
/* atoms/b-list — a quiet list. Markers are an editorial em-dash (bullet) or a counter
   (ordered) in muted ink — no heavy disc, fits the e-ink paper look (DESIGN-SYSTEM §4). */
.list { margin: 0 0 var(--space-4); padding: 0; list-style: none; }
.list__item { position: relative; padding-left: var(--space-5); margin: var(--space-2) 0; }
.list__item::before { content: "—"; position: absolute; left: 0; color: var(--ink-muted); }

/* ---- ordered variant (attribute): a counter, not a disc ---- */
.list[data-variant="ordered"] { counter-reset: li; }
.list[data-variant="ordered"] .list__item { counter-increment: li; }
.list[data-variant="ordered"] .list__item::before { content: counter(li) "."; }

/* ---- AI / in-transit: an item the AI is writing reads grain (the label swaps via the
   inherited --type-font); its marker recedes to faint while pending, settling on commit.
   Same vocabulary as the other atoms (AI-INTERFACE §5). */
.list__item[data-commit="pending"]::before,
[data-grade="grain"] .list__item::before { color: var(--ink-faint); }

/* While the AI writes an item it's momentarily EMPTY (appended, not yet typed). Reserve
   a line so the list doesn't collapse then grow back — that jumpy shrink before writing. */
.list__item:empty { min-height: 1.55em; }   /* ≈ one --leading-relaxed line */


/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/atoms/b-meter/b-meter.css */
/* atoms/b-meter — a proportional horizontal bar split into segments: a health bar (passed/
   failed/pending share), a storage bar, a progress bar, a poll result. Grain had no meter/
   progress primitive at all. One class per element (BEM); tone is an attribute. Each segment's
   width is one custom property (--seg) so it's data-bindable server-side with zero JS.
   Monochrome: segments differ by TEXTURE + ink-level, never by hue (DESIGN-SYSTEM §2) — solid
   ink (ok), hatched (bad), faint (the rest) — the e-ink language. */
.meter {
  display: flex; align-items: stretch;
  height: var(--space-2); overflow: hidden;
  border-radius: var(--radius-pill); background: var(--line-soft);
}
/* a segment fills its share of the track; default/"pending" reads faint solid */
.meter__seg { flex: 0 0 auto; width: var(--seg, 0); background: var(--ink-faint); }
/* ok — solid ink (the load-bearing "good" share) */
.meter__seg[data-tone="ok"] { background: var(--ink); }
/* bad — a dense diagonal hatch of ink (stands out by texture, not colour) */
.meter__seg[data-tone="bad"] {
  background-color: transparent;
  background-image: repeating-linear-gradient(45deg, var(--ink) 0 2px, transparent 2px 5px);
}
/* pending — a sparse dotted hatch, echoing the in-transit e-ink grain (lighter than "bad") */
.meter__seg[data-tone="pending"] {
  background-color: transparent;
  background-image: repeating-linear-gradient(45deg, var(--ink-faint) 0 1px, transparent 1px 4px);
}

/* ---- indeterminate: running, totals unknown yet. A single faint stripe sweeps the track.
   Honours reduced-motion (falls back to a static faint fill). ---- */
.meter[data-state="busy"] .meter__seg { width: 35%; background: var(--ink-faint); animation: meter-sweep 1.1s ease-in-out infinite; }
@keyframes meter-sweep { 0% { margin-left: -35%; } 100% { margin-left: 100%; } }
@media (prefers-reduced-motion: reduce) {
  .meter[data-state="busy"] .meter__seg { animation: none; width: 100%; }
}

/* ---- AI / in-transit: the non-text grain, same vocabulary as the rest of the family
   (AI-INTERFACE §5). A meter the AI is updating (live pending, or any grain ancestor) wears the
   dashed "terminal" edge on the track. ---- */
.meter[data-commit="pending"],
[data-grade="grain"] .meter { border: 1px dashed var(--ink); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/atoms/b-select/b-select.css */
/* atoms/b-select — the field family's dropdown; reuses .field/.field__label from b-input,
   adds .field__select. One class per element (BEM, component-scoped); variants are attributes,
   same pattern as the input/button. Native dropdown arrow kept (no appearance: none) — accessible,
   and a hardcoded caret color would break the tokens-only rule. */
.field__select {
  width: 100%; box-sizing: border-box;
  padding: var(--space-2) var(--space-3);
  font-family: var(--type-font); font-size: var(--text-base);
  color: var(--ink); background: transparent;
  border: var(--border); border-radius: var(--radius-sm);
}
/* focus ring; data-force parallel lets the catalog show the state statically (same pattern as
   b-input) — never `autofocus`, which would steal focus + scroll the catalog on load. */
.field__select:focus-visible, .field__select[data-force~="focus"] { outline: 2px solid var(--color-accent); outline-offset: 1px; }

/* invalid state — native, no JS. Monochrome: a heavier rule, not a red one. */
.field__select:user-invalid { border-width: 2px; }

/* ---- sizes (attribute, on .field, same as b-input) ---- */
.field[data-size="sm"] .field__select { padding: var(--space-1) var(--space-2); font-size: var(--text-sm); }
.field[data-size="lg"] .field__select { padding: var(--space-3) var(--space-4); font-size: var(--text-lg); }

/* Inline variant (.field[data-variant="inline"]) already works — it's declared on .field itself
   in b-input.css, so b-select gets it for free by reusing the same frame. Not re-declared here. */

/* ---- AI / in-transit: the non-text grain, same language as the button/field (AI-INTERFACE §5).
   A select the AI is touching (live pending, or any grain ancestor) wears the dashed "terminal"
   edge; the label + value already swap to the grain family via --type-font.
   EXCEPTION: while the AI is actively COMPOSING, the dispatcher sets data-grade="smooth" on the
   select itself (clean ink, like a human choosing) — so that stays solid. The
   `:not([data-grade="smooth"])` guard makes the exception explicit: a self-smooth select never
   matches the dashed rule, so the outcome doesn't depend on stylesheet source order. */
.field[data-commit="pending"] .field__select:not([data-grade="smooth"]),
[data-grade="grain"] .field__select:not([data-grade="smooth"]) { border-style: dashed; }
.field__select[data-grade="smooth"] { border-style: solid; }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/atoms/b-switch/b-switch.css */
/* atoms/b-switch — a labelled on/off toggle, built on a real <input type="checkbox">
   so it keeps native focus, keyboard (Space), and form semantics. The checkbox is
   visually hidden (not display:none — it stays focusable and hit-testable); the
   track + thumb are the drawn control, driven by :checked / :focus-visible on the
   sibling input. Monochrome by default; ON reaches the accent hue (the one place a
   toggle earns color, matching nav-item[aria-current] / tabs). Use where an action
   is a STATE the user sets (delivered / held, locked / open), not a one-shot verb -
   a button that reads "PUBLISHING" and un-publishes on click is the anti-pattern
   this replaces. */

.switch {
  display: inline-flex; align-items: center; gap: var(--space-2);
  cursor: pointer; font-family: var(--type-font); font-size: var(--text-xs);
  line-height: var(--leading-normal); color: var(--ink);
}

/* the real control: visually hidden, still focusable + operable */
.switch__input {
  position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
  border: 0; clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap;
}

.switch__track {
  --sw-h: 1.25rem; --sw-w: 2.25rem;
  position: relative; flex: none;
  inline-size: var(--sw-w); block-size: var(--sw-h);
  border: var(--border); border-radius: 999px;
  background: var(--paper-2);
  transition: background-color 0.15s, border-color 0.15s;
}
.switch__thumb {
  position: absolute; top: 50%; inset-inline-start: 0.125rem;
  inline-size: calc(var(--sw-h) - 0.5rem); block-size: calc(var(--sw-h) - 0.5rem);
  transform: translateY(-50%); border-radius: 50%;
  background: var(--ink);
  transition: inset-inline-start 0.15s, background-color 0.15s;
}

/* ON: accent wash track + accent thumb slid to the end */
.switch__input:checked ~ .switch__track { background: var(--color-accent-soft); border-color: var(--color-accent); }
.switch__input:checked ~ .switch__track .switch__thumb { inset-inline-start: calc(100% - var(--sw-h) + 0.375rem); background: var(--color-accent); }

/* keyboard focus lands on the hidden input; show the ring on the drawn track */
.switch__input:focus-visible ~ .switch__track { outline: 2px solid var(--color-accent); outline-offset: 2px; }

.switch__input:disabled ~ .switch__track { opacity: 0.5; }
.switch:has(.switch__input:disabled) { cursor: not-allowed; }

.switch__label { color: inherit; }
.switch__label--off { color: var(--ink-muted); }

@media (prefers-reduced-motion: reduce) {
  .switch__track, .switch__thumb { transition: none; }
}

/* AI / in-transit: same grade-as-signal edge the button uses - a dashed track +
   locked out while the writer works (AI-INTERFACE §5). */
.switch__input[data-commit="pending"] ~ .switch__track,
[data-grade="grain"] .switch__track {
  border-style: dashed; pointer-events: none;
}

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/atoms/b-text/b-text.css */
/* atoms/b-text — typography tokens this atom can wear */
.heading-2 { font-size: var(--text-lg); color: var(--color-muted); margin: var(--space-6) 0 var(--space-3); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/atoms/b-textarea/b-textarea.css */
/* atoms/b-textarea — the field family's multi-line control; reuses .field/.field__label from
   b-input and adds .field__textarea, exactly the way b-select adds .field__select. One class per
   element (BEM, component-scoped); variants are attributes, same pattern as the input/select.
   No custom scrollbar and no appearance reset: the platform's own is accessible, and a hand-drawn
   one would need a hardcoded color, which the tokens-only rule does not allow. */
.field__textarea {
  width: 100%; box-sizing: border-box;
  padding: var(--space-2) var(--space-3);
  font-family: var(--type-font); font-size: var(--text-base); line-height: var(--leading-relaxed);
  color: var(--ink); background: transparent;
  border: var(--border); border-radius: var(--radius-sm);   /* hard edge — you tap it, same as the input */
  /* Vertical only: a person can grow the box, and the page can never be shoved sideways by it. */
  resize: vertical;
  /* The default height, in the only unit that means anything here: LINES of the body text it holds,
     plus the padding declared above, since box-sizing is border-box. `rows` overrides it upward when
     an author sets one; without this a textarea comes up two lines tall, which reads as a broken
     input rather than a place to write a paragraph. Composed from tokens, so re-skinning the type
     scale moves it too. */
  min-height: calc(var(--text-base) * var(--leading-relaxed) * 4 + var(--space-2) * 2);
}
.field__textarea::placeholder { color: var(--ink-muted); }
/* focus ring; data-force parallel lets the catalog show the state statically (same pattern as
   b-input) — never `autofocus`, which would steal focus + scroll the catalog on load. */
.field__textarea:focus-visible, .field__textarea[data-force~="focus"] { outline: 2px solid var(--color-accent); outline-offset: 1px; }

/* invalid state — native, no JS. Monochrome: a heavier rule, not a red one. */
.field__textarea:user-invalid { border-width: 2px; }

/* ---- sizes (attribute, on .field, same as b-input) ---- */
.field[data-size="sm"] .field__textarea { padding: var(--space-1) var(--space-2); font-size: var(--text-sm); }
.field[data-size="lg"] .field__textarea { padding: var(--space-3) var(--space-4); font-size: var(--text-lg); }

/* The inline variant (.field[data-variant="inline"]) is declared on .field itself in b-input.css and
   works here for free. It is a poor fit for a multi-line box and the .md says so, rather than this
   file quietly overriding a variant the frame offers. */

/* ---- AI / in-transit: the non-text grain, same language as the input/select (AI-INTERFACE §5).
   A textarea the AI is touching (live pending, or any grain ancestor) wears the dashed "terminal"
   edge; the label + value already swap to the grain family via --type-font. EXCEPTION: while the AI
   is actively COMPOSING, the dispatcher sets data-grade="smooth" on the textarea itself (clean ink,
   real caret, like a human typing) — so that stays solid. The `:not([data-grade="smooth"])` guard
   makes the exception explicit: a self-smooth textarea never matches the dashed rule, so the outcome
   doesn't depend on stylesheet source order. This is the one place the textarea is NOT a copy of the
   input: ai-dispatch.js types into INPUT and TEXTAREA through the same branch, so a missing rule
   here would have meant the AI writing into a control that never showed it was being written to. */
.field[data-commit="pending"] .field__textarea:not([data-grade="smooth"]),
[data-grade="grain"] .field__textarea:not([data-grade="smooth"]) { border-style: dashed; }
.field__textarea[data-grade="smooth"] { border-style: solid; }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/atoms/code-block/code-block.css */
/* atoms/code-block — a monospace code block (and a .code-inline run) for content prose.
   CSS-only (CONVENTIONS §4): MILL hand-composes <pre class="code-block">, nothing data-binds
   it, so there's no .html template. Reads --font-mono, so code stays mono in either grade. */
.code-block {
  margin: 0 0 var(--space-4);
  padding: var(--space-4);
  overflow-x: auto;                         /* long lines scroll, never break the column */
  background: var(--color-surface);
  border: 1px solid var(--color-line);
  border-radius: var(--radius-md);
  font-family: var(--font-mono);
  font-size: var(--text-sm);
  line-height: var(--leading-normal);
  color: var(--color-fg);
}
.code-block code { font-family: inherit; }

/* optional language tag: `data-lang` renders a quiet eyebrow above the block */
.code-block[data-lang]::before {
  content: attr(data-lang);
  display: block;
  margin-bottom: var(--space-2);
  font-size: var(--text-xs);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--color-muted);
}

/* inline code — a quiet mono run inside prose */
.code-inline {
  font-family: var(--font-mono);
  font-size: 0.9em;
  padding: 0 var(--space-1);
  background: var(--color-surface);
  border-radius: var(--radius-sm);
}

/* ---- AI / in-transit: same vocabulary as the atoms (CONVENTIONS §4). A code block the AI
   is writing wears the dashed "terminal" edge (non-text grain). MILL output is human-authored,
   so it never triggers this; the catalog grain-flip demonstrates it. */
.code-block[data-commit="pending"],
[data-grade="grain"] .code-block { border-style: dashed; border-color: var(--ink); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/molecules/attachment/attachment.css */
/* molecules/attachment — a document hanging off a page: a deck, a paper, a slide set. CSS-only
   (CONVENTIONS §4), composed by hand, nothing data-binds it.

   The shape is deliberately quiet and horizontal. An attachment is not the page's content, it is
   the thing the page is about, offered on the way in — so it announces itself in one row near the
   top and then gets out of the way. That is the whole reason it is not a media-card: a poster-sized
   tile at the top of a note reads as the subject rather than the appendix.

   The whole row is the link. Composed as an <a> with <span> children, so every child that must sit
   on its own line declares display, because a <span> inside an <a> does not get one for free. Owns
   no outer margin: where an attachment sits is the consumer's layout decision. */
.attachment {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-4);
  padding: var(--space-3) var(--space-4);
  border: 1px solid var(--color-line);
  border-radius: var(--radius-md);
  text-decoration: none;
  color: var(--color-fg);
  transition: background-color 0.15s, border-color 0.15s;
}
.attachment:hover { background: var(--paper-2); border-color: var(--ink); }

/* the kind chip: PDF, DECK, SLIDES. Monospace and small, so it reads as a file type rather than
   a heading, and fixed-width enough that a row of attachments lines up down the left. */
.attachment__kind {
  flex: none;
  min-width: 3.5rem;
  padding: var(--space-1) var(--space-2);
  border: 1px solid var(--color-line);
  border-radius: var(--radius-sm);
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  text-transform: uppercase;
  letter-spacing: .04em;
  text-align: center;
  color: var(--color-muted);
}

.attachment__body { flex: 1 1 12rem; min-width: 10rem; }
.attachment__title { display: block; font-size: var(--text-base); overflow-wrap: anywhere; }
.attachment__meta {
  display: block;
  margin-top: var(--space-1);
  font-size: var(--text-xs);
  color: var(--color-muted);
}

/* the affordance on the right: says what the click does. Hidden on narrow screens, where the row
   is obviously tappable and the words cost more than they carry.

   The row WRAPS rather than squeezing (flex-wrap above, and a 12rem basis on the body). Without
   that, a narrow container — the component catalog's preview column is the one that caught it —
   crushes the title to one word per line while the chip and this label sit comfortably beside it.
   A viewport media query cannot fix that: the viewport is wide, the box is not. */
.attachment__action {
  flex: none;
  font-size: var(--text-sm);
  color: var(--color-muted);
}
.attachment:hover .attachment__action { color: var(--color-fg); }
@media (max-width: 32rem) {
  .attachment__action { display: none; }
}

/* ---- AI / in-transit: an attachment the AI is still producing wears the dashed terminal edge,
   the same vocabulary card, badge and field use. */
.attachment[data-commit="pending"],
[data-grade="grain"] .attachment { border-style: dashed; border-color: var(--ink); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/molecules/callout/callout.css */
/* molecules/callout — a blockquote / margin-note aside for content prose. A quiet ruled
   block, not a coloured box (monochrome, DESIGN-SYSTEM §2). CSS-only (CONVENTIONS §4): MILL
   maps Markdown blockquotes to <blockquote class="callout">; nothing data-binds it. */
.callout {
  margin: 0 0 var(--space-4);
  padding: var(--space-2) 0 var(--space-2) var(--space-4);
  border-left: 2px solid var(--ink);
  color: var(--color-muted);
  font-style: italic;
}
.callout > :last-child { margin-bottom: 0; }

/* tone via attribute (not a second class): a stronger note keeps full ink, upright */
.callout[data-status="strong"] { color: var(--color-fg); font-style: normal; }

/* ---- AI / in-transit: dashed terminal edge (non-text grain), same idiom as the atoms.
   MILL output is human-authored → never triggers; the catalog grain-flip demonstrates it. */
.callout[data-commit="pending"],
[data-grade="grain"] .callout { border-left-style: dashed; }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/molecules/card/card.css */
/* molecules/card — a bordered content tile + its companion grid. CSS-only (CONVENTIONS §4):
   composed by hand — a <div> for a plain fact, an <a> for a navigable tile. Monochrome:
   a hairline box, no fill, no shadow (DESIGN-SYSTEM §2). Variants are attributes. */
.card {
  border: 1px solid var(--color-line);
  border-radius: var(--radius-md);
  padding: var(--space-5);
}
.card[data-pad="sm"] { padding: var(--space-4); }

.card__title { font-size: var(--text-base); margin: 0 0 var(--space-2); }
.card__body  { color: var(--color-muted); font-size: var(--text-sm);
  line-height: var(--leading-normal); margin: 0; }

/* a navigable card: the whole tile is the link — quiet at rest, paper lifts on hover */
a.card { display: block; text-decoration: none; color: var(--color-fg);
  transition: background-color 0.15s; }
a.card:hover { background: var(--paper-2); }
a.card[aria-disabled="true"] { pointer-events: none; opacity: 0.6; }

/* the companion grid: auto-fit tiles, min width tunable per use (--card-min) */
.card-grid { display: grid; gap: var(--space-4);
  grid-template-columns: repeat(auto-fit, minmax(var(--card-min, 15rem), 1fr)); }

/* ---- AI / in-transit: the non-text grain — a card the AI is composing wears the dashed
   "terminal" edge (live pending, or any grain ancestor). Same vocabulary as badge + field. */
.card[data-commit="pending"],
[data-grade="grain"] .card { border-style: dashed; border-color: var(--ink); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/molecules/chat-message/chat-message.css */
/* molecules/chat-message — one line of the assistant conversation. `data-role="you"` is
   clean and right-aligned (your words, committed); `data-role="ai"` carries data-grade="grain"
   so the AI's speech stays grain (grain = AI, AI-INTERFACE §5). A CSS class composed in a
   flex chat log; the single writer emits matching markup over the door. */
.chat-message {
  max-width: 88%;
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--color-line); border-radius: var(--radius-md);
  font-size: var(--text-sm);
  /* Break long unbreakable runs (URLs, code identifiers, hashes) INSIDE the bubble. Without this a
     single long token overflows its own box and pushes the .assistant__log's scrollWidth past its
     width, so the whole conversation scrolls sideways (owner report: the AI chat scrolls
     horizontally). `anywhere` also lets the bubble shrink to fit narrow (mobile-sheet) widths. */
  overflow-wrap: anywhere;
  min-width: 0;
}
.chat-message[data-role="you"] { align-self: flex-end;   background: var(--color-bg); }
.chat-message[data-role="ai"]  { align-self: flex-start; background: transparent; }   /* grain via data-grade */
/* LEGIBILITY FLOOR (2026-07-14): a bubble is --text-sm — well under the >= --text-2xl floor where
   Redaction 50's texture reads as texture, not roughness (CLAUDE.md lesson 4). grain.css's
   `[data-grade="grain"]`/`[data-commit="pending"]` rules would otherwise hand this element
   `--font-grain` (Redaction 50) at 0-1-0 specificity; this rule is 0-2-0 (class + attribute), so it
   wins regardless of CSS bundle order and swaps in the fine face instead — same mechanism
   (`--type-font`), same idiom as `.field:focus-within` below in grain.css, just a different value.
   The "AI wrote this" signal survives (still a visibly non-smooth Redaction face); only the
   roughest strokes, which just turn to mush at this size, are gone. */
.chat-message[data-grade="grain"], .chat-message[data-commit="pending"] { --type-font: var(--font-grain-fine); }
.chat-message__who {
  display: block; margin-bottom: 2px;
  font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-muted);
}
/* the body reads the inherited grade font (a <span> isn't in grain.css's type selector), so the
   AI's line actually renders grain when the message carries data-grade="grain". */
.chat-message__body { display: block; font-family: var(--type-font); }

/* ACTIONABLE CHAT DIALOG: a message may offer action controls — buttons the AI puts in front of
   the person ("add to my notes", "see what's new") that fire an Intent through the ONE door
   (`data-action`/`data-target`, presence-gated with `data-ai-run`; the dispatcher fires them even
   though they live in the chat). They wrap into a row under the body. The offer TEXT stays grain
   (it's the AI speaking), but the affordances are the HUMAN's to click, so the row resets to the
   clean font — a grain-lettered button would misread as AI-authored value (grade doctrine). */
.chat-message__actions { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: var(--space-2);
  --type-font: var(--font-smooth); }
/* keep each offer label on ONE line — the ROW wraps between buttons (flex-wrap above), but a label
   must never break mid-phrase ("SEE WHAT'S / NEW") when the bubble is narrow. A short verb phrase is
   an atomic affordance; let a too-wide button overflow-scroll its own text rather than stack words. */
.chat-message__actions .btn { white-space: nowrap; }
/* the offered buttons sit inside a grain-graded message, so b-button's `[data-grade="grain"] .btn`
   rule would dash them and kill their pointer events (that rule is for a control the AI is USING,
   not one it's OFFERING). When the AI is ONLINE, undo that — the button is a live, clean, human
   affordance. When OFFLINE (or presence not yet known) we leave the grain/disabled look on
   purpose: the presence gate (`data-ai-run`) still holds, so an offered action reads honestly as
   "not available right now" rather than a dead click. Scoped to online so that gate keeps winning. */
body[data-ai-online="true"] .chat-message__actions .btn {
  border-style: solid; border-color: var(--color-line); pointer-events: auto;
}

/* CHOICES (pick-one) — an actionable dialog the AI hands over as a decision, not a menu of
   independent verbs. The dispatcher marks the group [data-resolved] and the picked button
   [data-chosen] on the first click, then disables the set. Show the answer: the chosen stays,
   the rest retire. */
.chat-message__actions[data-choices][data-resolved] .btn { pointer-events: none; }
.chat-message__actions[data-choices][data-resolved] .btn:not([data-chosen]) { opacity: 0.45; }
.chat-message__actions[data-choices] .btn[data-chosen] {
  border-color: var(--color-accent); color: var(--color-accent); opacity: 1;
}

/* THINKING — three pulsing dots for a pending AI bubble that has no token to show yet (model load,
   retrieval), so it's never a blank rectangle (reasoner-kit thinkingDots/thinkingOp). */
.chat-thinking { display: inline-flex; gap: 0.28em; align-items: center; padding: 0.15em 0; }
.chat-thinking > span {
  width: 0.4em; height: 0.4em; border-radius: 50%; background: var(--ink-faint);
  animation: chat-thinking-pulse 1.1s ease-in-out infinite;
}
.chat-thinking > span:nth-child(2) { animation-delay: 0.18s; }
.chat-thinking > span:nth-child(3) { animation-delay: 0.36s; }
@keyframes chat-thinking-pulse {
  0%, 80%, 100% { opacity: 0.25; transform: translateY(0); }
  40%           { opacity: 1;    transform: translateY(-0.16em); }
}
@media (prefers-reduced-motion: reduce) {
  .chat-thinking > span { animation: none; opacity: 0.6; }
}

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/molecules/chip-group/chip-group.css */
/* molecules/chip-group — one class per element; a set of selectable pills acting as a form
   control (single- or multi-select). Native inputs inside labels → zero JS, form-postable,
   keyboard + AX for free; checked state styled via :has()/:checked. Monochrome: a hairline pill,
   no fill — state is carried by ink vs muted, not by hue (DESIGN-SYSTEM §2), same doctrine as
   b-badge's "active". */
.chips {
  border: 0; margin: 0; padding: 0; min-inline-size: 0;
  display: flex; flex-wrap: wrap; gap: var(--space-2);
}
.chips__chip {
  display: inline-flex; align-items: center; gap: var(--space-1);
  padding: var(--space-1) var(--space-3); font-size: var(--text-sm);
  border: 1px solid var(--line-soft); border-radius: var(--radius-pill);
  color: var(--color-muted); cursor: pointer; user-select: none;
}
/* the native input stays focusable/keyboard-operable — sr-only, never display:none (that kills
   keyboard). The label is the visible target. */
.chips__chip input {
  position: absolute; width: 1px; height: 1px; opacity: 0; margin: 0;
}
/* selected (monochrome, like badge "active") */
.chips__chip:has(:checked) { color: var(--ink); border-color: var(--ink); }
/* focus ring (keyboard) */
.chips__chip:has(:focus-visible) { outline: 2px solid var(--color-accent); outline-offset: 1px; }
/* hover (subtle, muted → fg) */
.chips__chip:hover { color: var(--ink); }

/* data-select="single" (radios) needs no separate CSS — radios vs checkboxes are a markup
   swap; the :has(:checked) rule above covers both. Do not invent per-variant classes. */

/* ---- AI / in-transit: the non-text grain. A chip the AI is toggling (live pending, or any
   grain ancestor) wears the dashed "terminal" edge; the label already swaps to the grain family
   via --type-font. Same vocabulary as the button + field + badge. */
.chips[data-commit="pending"] .chips__chip,
[data-grade="grain"] .chips__chip { border-style: dashed; border-color: var(--ink); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/molecules/content-index/content-index.css */
/* molecules/content-index — a collection's listing (MILL's index route emits it): a quiet
   ruled list of entries, each meta → linked title → summary → tags. CSS-only: composed by
   MILL, nothing data-binds it. Human-authored content lists → clean ink. */
.content-index { list-style: none; margin: 0; padding: 0; }
.content-index__item { padding: var(--space-5) 0; border-bottom: 1px solid var(--color-line); }
.content-index__item:last-child { border-bottom: 0; }

.content-index__meta {
  font-size: var(--text-xs);
  color: var(--color-muted);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  margin: 0 0 var(--space-1);
}
/* date + readingTime ship as separate spans (mill/serve.ts) so the "log" variant below can put
   the date alone in a fixed gutter; the default listing joins them back inline with a rule. */
.content-index__readtime::before { content: " \00b7 "; }
.content-index__title { font-size: var(--text-xl); margin: 0 0 var(--space-2); }
.content-index__title a { color: var(--color-fg); text-decoration: none; }
.content-index__title a:hover { text-decoration: underline; }
.content-index__summary {
  color: var(--color-muted);
  line-height: var(--leading-relaxed);
  max-width: 60ch;
  margin: 0 0 var(--space-2);
}
.content-index .note__tags { margin: 0; }

/* AI / in-transit: dashed terminal edge (non-text grain idiom) — never from MILL itself */
.content-index__item[data-commit="pending"],
[data-grade="grain"] .content-index__item { border-bottom-style: dashed; }

/* variant: log — a collection read as `git log --graph`: a node + connecting rail down the
   left edge, a mono date gutter, the title as the commit message, tags as branch chips. Opt-in
   per collection (indexVariant, mill/serve.ts); default listing above is untouched for
   collections that don't ask for it.

   THREE grid columns: [graph] [date] [message]. The graph column holds no grid child — the
   node + rail are pseudo-elements on the LI itself (position:absolute is excluded from grid
   auto-placement, so they can't collide with the real columns), same technique as the
   side-rail's own icon-gutter line. `.content-index__meta`'s two children (date, readingTime —
   separate spans since mill/serve.ts) go `display:contents` so each can be grid-placed on its
   own: date into the fixed gutter, readingTime down with the message instead of crowding the
   gutter — that crowding (both joined in one nowrap string) was the overlap bug. */
.content-index[data-variant="log"] .content-index__item {
  position: relative;
  display: grid;
  grid-template-columns: 1.25rem 11ch 1fr;
  column-gap: var(--space-3);
}
/* the rail: one hairline down the graph column, full item height — trimmed at the first/last
   entry so it starts/stops at the tip and root commit instead of dangling past the list. */
.content-index[data-variant="log"] .content-index__item::before {
  content: ""; position: absolute; left: 0.625rem; top: 0; bottom: 0;
  width: 1px; background: var(--color-line);
}
.content-index[data-variant="log"] .content-index__item:first-child::before { top: 0.95rem; }
.content-index[data-variant="log"] .content-index__item:last-child::before { bottom: calc(100% - 0.95rem); }
/* the node: an accent dot on the rail, haloed in the page background so the rail reads as
   passing BEHIND it (the same halo trick a git-graph renderer uses). */
.content-index[data-variant="log"] .content-index__item::after {
  content: ""; position: absolute; left: 0.625rem; top: 0.95rem;
  width: 7px; height: 7px; margin-left: -3px; border-radius: 50%;
  background: var(--color-accent); border: 2px solid var(--color-bg);
}
.content-index[data-variant="log"] .content-index__meta { display: contents; }
.content-index[data-variant="log"] .content-index__date {
  grid-column: 2; grid-row: 1;
  font-family: var(--font-mono); text-transform: none; letter-spacing: normal; white-space: nowrap;
  color: var(--color-muted);
}
.content-index[data-variant="log"] .content-index__title {
  grid-column: 3; grid-row: 1;
  font-size: var(--text-base); font-weight: 600;
}
.content-index[data-variant="log"] .content-index__readtime {
  grid-column: 3; grid-row: 2;
  font-family: var(--font-mono); font-size: var(--text-xs); color: var(--color-muted);
}
.content-index[data-variant="log"] .content-index__readtime::before { content: ""; }   /* cancel the default " · " prefix — no longer beside the date */
.content-index[data-variant="log"] .content-index__summary { grid-column: 3; grid-row: 3; }
.content-index[data-variant="log"] .note__tags { grid-column: 3; grid-row: 4; display: flex; gap: var(--space-2); flex-wrap: wrap; }
.content-index[data-variant="log"] .note__tags .badge {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
}
.content-index[data-variant="log"] .note__tags .badge::before { content: "⎇ "; }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/molecules/content-source/content-source.css */
/* molecules/content-source — the Rendered/Source toggle on a MILL entry page: two `.tab`
   atoms (reuse, not a new control), Rendered active by default. Container only; the tab
   itself carries all the visual states (active/hover). */
.content-source {
  display: flex; border-bottom: 1px solid var(--color-line); margin: 0 0 var(--space-5);
}

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/molecules/data-table/data-table.css */
/* molecules/data-table — a DENSE table of records: the list view of a workspace app (rows you
   scan, sort and open), as opposed to the `table` molecule, which is a CONTENT table (MILL maps
   Markdown pipe tables to it, prose padding, first column flush left). Two components on purpose,
   not one variant: they differ in header treatment, density, rules and affordance, and reaching
   for the wrong one should be a decision, not an accident. See table.md.

   Monochrome and ruled, never zebra-striped (DESIGN-SYSTEM §2). CSS-only: nothing data-binds it —
   the rows are the consumer's, rendered server-side or by an island. */
.data-table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }

/* The header STICKS to the top of the scroll container — which is the container's job, not this
   file's: `position: sticky` resolves against the nearest scrollable ancestor, so in an app-shell
   that's `.app-shell__main` and it works, while in a page that scrolls on <body> it simply never
   engages. That's the parent-context requirement stated in data-table.md; it's opaque (its own
   background) so rows can't show through as they pass under it. */
.data-table thead th {
  position: sticky; top: 0; z-index: 1;
  text-align: left; font-size: var(--text-xs); font-weight: var(--font-weight-semibold);
  text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-muted);
  padding: var(--space-3) var(--space-4);
  background: var(--color-bg);
  border-bottom: 1px solid var(--color-line);
}
.data-table tbody td {
  padding: var(--space-3) var(--space-4); vertical-align: middle;
  color: var(--color-fg); border-bottom: 1px solid var(--color-line);
}
/* the last rule would double up with the border of whatever box wraps the table */
.data-table tbody tr:last-child td { border-bottom: 0; }
.data-table tbody tr:hover td { background: var(--color-bg); }

/* A whole row can be the link to its record. Grain shows the AFFORDANCE only (the cursor); the
   navigation is the consumer's — `data-href` is a plain markup hint, not a vocabulary verb, and
   nothing in grain reads it. Keep a real <a> in the row too: a pointer cursor is not a link, and
   a row that only works on click is unreachable by keyboard. */
.data-table tbody tr[data-href] { cursor: pointer; }
.data-table td a { color: var(--color-fg); font-weight: var(--font-weight-semibold); text-decoration: none; }
.data-table td a:hover { color: var(--color-accent); }
/* the quiet second line in a cell — an email under a name, a description under a title */
.data-table__sub { color: var(--color-muted); font-weight: var(--font-weight-regular); }
/* the empty state is a row, not a replacement for the table: the header stays, so the columns
   still say what WOULD be here once there's data. */
.data-table__empty td { color: var(--color-muted); text-align: center; padding: var(--space-6); }

/* AI / in-transit: dashed terminal edge (the non-text grain idiom, same as `table` and the atoms) */
.data-table[data-commit="pending"],
[data-grade="grain"] .data-table { border: 1px dashed var(--ink); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/molecules/doc-frame/doc-frame.css */
/* molecules/doc-frame — a document rendered INSIDE the page instead of replacing it. CSS-only
   (CONVENTIONS §4), composed by hand, nothing data-binds it.

   Why it exists: a link straight to a PDF hands the tab to the browser's viewer and the site is
   gone, chrome and all. Pointed at a route that composes this, the document becomes an ordinary
   page, which means it becomes an ordinary open tab (scripts/tabs.js projects visited paths), which
   means the reader can close it and still be where they were.

   <object> and not <iframe>: same element, better failure. A browser that cannot render the type
   inline renders the element's CHILDREN instead, so the fallback is markup rather than a script
   sniffing for support. Same origin only — this is for documents the site itself serves, and it is
   emphatically not a hole for third-party embeds.

   The escape hatch below the frame is NOT redundant with that fallback, and this is the part worth
   remembering: iOS Safari neither renders a multi-page PDF inline nor triggers the fallback. It
   draws page one in a dead box and stops. So the direct link is always visible, for everybody.

   Height is a knob (--doc-frame-height) because a slide deck and a two-column paper want different
   boxes; the default is most of the viewport, floored so a short window still shows a page. */
.doc-frame { margin: 0; }

.doc-frame__object {
  display: block;
  width: 100%;
  height: var(--doc-frame-height, max(28rem, 78vh));
  border: 1px solid var(--color-line);
  border-radius: var(--radius-md);
  background: var(--paper-2);
}

/* the <object>'s own children: shown only when the browser refuses the document inline */
.doc-frame__fallback {
  margin: 0;
  padding: var(--space-6);
  color: var(--color-muted);
  font-size: var(--text-sm);
  line-height: var(--leading-relaxed);
}

/* always visible, under the frame: the plain link out, plus whatever the consumer puts beside it */
.doc-frame__escape {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--space-3);
  margin-top: var(--space-3);
  font-size: var(--text-sm);
  color: var(--color-muted);
}

.doc-frame[data-commit="pending"] .doc-frame__object,
[data-grade="grain"] .doc-frame__object { border-style: dashed; border-color: var(--ink); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/molecules/figure/figure.css */
/* molecules/figure — an image with an optional caption for content prose. The `clipped`
   variant gives the editorial clipped-photo edge. CSS-only (CONVENTIONS §4): MILL maps a
   standalone Markdown image to <figure class="figure">; nothing data-binds it, no template. */
.figure { margin: 0 0 var(--space-6); }
.figure img { display: block; max-width: 100%; height: auto; border: 1px solid var(--color-line); }
.figure__caption {
  margin-top: var(--space-2);
  font-size: var(--text-sm);
  color: var(--color-muted);
  line-height: var(--leading-normal);
}

/* diagram variant: an inline SVG rather than an <img>. MILL emits this for a rendered
   mermaid fence. The SVG carries token references, so it re-colors with the theme; the only
   job here is fitting it to the column and letting a wide flowchart scroll rather than
   shrink to unreadable. No caption: a diagram fence has no caption source, and the prose
   convention (an italic line under the figure) stays. */
.figure[data-variant="diagram"] { max-width: 100%; overflow-x: auto; }
.figure[data-variant="diagram"] svg { display: block; max-width: 100%; height: auto; }

/* clipped-photo variant: a hard editorial clip instead of a straight rectangle */
.figure[data-variant="clipped"] img { border: 0; clip-path: polygon(0 0, 100% 0, 100% 94%, 96% 100%, 0 100%); }

/* ---- AI / in-transit: dashed terminal edge (non-text grain), same idiom as the atoms.
   MILL output is human-authored → never triggers; the catalog grain-flip demonstrates it. */
.figure[data-commit="pending"] img,
[data-grade="grain"] .figure img { border-style: dashed; border-color: var(--ink); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/molecules/form-grid/form-grid.css */
/* molecules/form-grid — the layout the field family never had. A .field is flex: 1 inside a column,
   which is a rule about how one field behaves and says nothing about how several sit together, so
   every form so far has laid itself out by hand. One class per element (BEM, component-scoped);
   variants are attributes, same pattern as the rest.

   CSS only, no .html: there is nothing to render, only a rule about children. It wraps controls —
   b-input, b-field, b-choice, b-memo, b-checkbox and their siblings — and it requires nothing of its
   own parent. */
.form-grid {
  /* The narrowest a field may be before the grid drops a column. A local custom property rather than
     a bare number, so a page with unusually long labels can raise it without forking this file. */
  --form-grid-min: 16rem;
  display: grid;
  /* min() is what keeps auto-fit honest below the track size: without it a 16rem floor on a 320px
     phone overflows the viewport horizontally, which is the classic auto-fit bug and it only ever
     shows up on a real device. */
  grid-template-columns: repeat(auto-fit, minmax(min(var(--form-grid-min), 100%), 1fr));
  gap: var(--space-4);
  /* Fields differ in height once one of them carries a hint or an error, and a stretched row would
     pull every control's border down to match the tallest. */
  align-items: start;
}

/* A control that wants the full width says so, and one kind of control never has to: a message box
   is a paragraph, and a paragraph in a half-width column is the layout mistake this rule exists to
   make unmakeable. Keyed off the control being present, so it holds for the hand-authored textarea
   and the generated one alike. */
.form-grid > [data-span="full"],
.form-grid > .field:has(.field__textarea) { grid-column: 1 / -1; }

/* ---- columns (attribute). auto-fit is the default and fits the most cases; a fixed count is for a
   form whose shape is a design decision rather than a consequence of width. minmax(0, 1fr) rather
   than a bare 1fr, so a long placeholder cannot push a track wider than its share. */
.form-grid[data-columns="1"] { grid-template-columns: 1fr; }
.form-grid[data-columns="2"] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.form-grid[data-columns="3"] { grid-template-columns: repeat(3, minmax(0, 1fr)); }

/* A fixed column count is a desktop decision. On a narrow screen it collapses, because two columns
   of form fields on a phone is not a smaller version of the design, it is a different and worse one. */
@media (max-width: 40rem) {
  .form-grid[data-columns] { grid-template-columns: 1fr; }
}

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/molecules/gallery/gallery.css */
/* molecules/gallery — a captioned image grid. CSS-only (CONVENTIONS §4): composed by hand, nothing
   data-binds it. The companion to card-grid, for pictures rather than facts: every tile is equal
   weight, cropped to one ratio, and carries its caption under it.

   Deliberately NOT a hero strip. A strip runs one photo big to carry a post; a gallery is the roll
   that hero was picked from, so a reader can scan it and move on. Tile width tunes with
   --gallery-min (default 15rem, same idiom as --card-min), the crop with --gallery-ratio.

   Lightbox: wrap the grid in [data-lightbox-group] and mark each tile [data-lightbox] (scripts/
   lightbox.js). One group per gallery, so prev/next never walks out of it into another strip on the
   same page. Keep the trigger an <a href> pointing at the full image: that is the no-JS fallback. */
.gallery {
  display: grid;
  gap: var(--space-4);
  grid-template-columns: repeat(auto-fit, minmax(var(--gallery-min, 15rem), 1fr));
}

.gallery__item { margin: 0; display: flex; flex-direction: column; gap: var(--space-2); }

/* fixed box per tile: a real roll mixes portrait and landscape, and a ragged grid reads like a
   mistake. object-fit crops to the box; the box reserves the space, so a lazily-loaded photo cannot
   shift the page under someone mid-scroll. */
.gallery__link {
  display: block;
  overflow: hidden;
  border: 1px solid var(--color-line);
  border-radius: var(--radius-md);
  background: var(--paper-2);
}
.gallery__link img {
  display: block; width: 100%; height: 100%;
  aspect-ratio: var(--gallery-ratio, 3 / 2);
  object-fit: cover;
}
a.gallery__link { transition: border-color 0.15s; }
a.gallery__link:hover { border-color: var(--ink); }

.gallery__caption {
  color: var(--color-muted);
  font-size: var(--text-xs);
  line-height: var(--leading-normal);
}

/* ---- AI / in-transit: a gallery the AI is still filling wears the dashed "terminal" edge on its
   tiles, the same vocabulary card + badge + field use. */
.gallery[data-commit="pending"] .gallery__link,
[data-grade="grain"] .gallery__link { border-style: dashed; border-color: var(--ink); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/molecules/lede/lede.css */
/* molecules/lede — the opening paragraph of a page or section: larger, muted, measured.
   CSS-only (CONVENTIONS §4): nothing data-binds it — compose by hand under a masthead.
   Emphasis inside a lede reads as underline, not italic (monochrome, DESIGN-SYSTEM §2). */
.lede {
  color: var(--color-muted);
  font-size: var(--text-lg);
  max-width: 56ch;
  line-height: var(--leading-relaxed);
}
.lede em { font-style: normal; text-decoration: underline; }

/* size is an attribute, not a second class: the small lede is a quiet aside — body-small,
   a touch wider measure */
.lede[data-size="sm"] { font-size: var(--text-sm); max-width: 60ch; }

/* ---- AI / in-transit: a lede is text — the global grade mechanism (data-grade →
   --type-font, styles/grain.css) already carries the grain; nothing bespoke needed. */

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/molecules/made-with/made-with.css */
/* molecules/made-with — the system byline: "made with GRAIN by tjakoen". The one footer
   line every GRAIN app mounts at the bottom of its shell, so provenance reads the same
   everywhere. CSS-only (CONVENTIONS §4): consumers emit the markup themselves or import
   the string helper (scripts/made-with.js) — one source of truth for the content. */
.made-with {
  display: block;
  padding: var(--space-6) var(--space-4) var(--space-4);
  text-align: center;
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  color: var(--color-muted);
}
.made-with a { color: inherit; text-decoration: none; }
.made-with a:hover,
.made-with a:focus-visible { color: var(--color-fg); text-decoration: underline; }

/* inside a status-bar (or any inline meta row) it flows as quiet meta-text, not a block —
   the row's own type treatment wins */
.made-with[data-inline] {
  display: inline;
  padding: 0;
  text-align: inherit;
  font-family: inherit;
  font-size: inherit;
}

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/molecules/media-card/media-card.css */
/* molecules/media-card — a card that leads with a picture. CSS-only (CONVENTIONS §4): composed by
   hand, nothing data-binds it. Card's vocabulary, not a second one: hairline box, no fill, no
   shadow (DESIGN-SYSTEM §2), data-pad for padding, and the whole tile navigable when the root is
   an <a>. Description and action row are both optional and both collapse to nothing when absent.

   Layout is an attribute, not a second component. The default stacks the text under the picture;
   [data-layout="overlay"] lays it over the bottom of the picture on a scrim. Those are the two
   shapes with a real consumer today, and they share every other rule they have: the box, the
   crop, the hover, the badge, the pending edge. Splitting them into two molecules would fork all
   five to move one block of text. A beside-the-picture layout is the same decision again and is
   deliberately not shipped until something asks for it: it is one more attribute value here, not
   a new component.

   It embeds nothing. The video poster consumer is one anchor over a still we host ourselves, so
   there is no iframe, no platform script, and no JS in this component at all, which makes the
   no-JS case the only case. The play badge is decorative (mark it aria-hidden); the title carries
   the meaning for a screen reader.

   Crop tunes with --media-ratio (default 16 / 9, the same idiom as --gallery-ratio). */
.media-card {
  position: relative;
  display: block;
  overflow: hidden;
  border: 1px solid var(--color-line);
  border-radius: var(--radius-md);
}

/* fixed box for the picture: the ratio reserves the space, so a lazily-loaded still cannot shift
   the page under someone mid-scroll, and object-fit crops whatever it is handed to that box. */
.media-card__media { display: block; position: relative; background: var(--paper-2); }
.media-card__image {
  display: block; width: 100%; height: 100%;
  aspect-ratio: var(--media-ratio, 16 / 9);
  object-fit: cover;
}

.media-card__body { padding: var(--space-5); }
.media-card[data-pad="sm"] .media-card__body { padding: var(--space-4); }

/* both rows are display: block because the navigable form composes them as <span>. Inside an <a>
   the whole tile is the link, and a <span> is the neutral element to reach for there; left inline
   it would run the title and the description together on one line, which is what happens the first
   time anyone copies the example. Declaring it here means the markup can be spans or an <h3> and a
   <p>, and both stack. */
.media-card__title { display: block; font-size: var(--text-base); margin: 0; }
.media-card__text  { display: block; color: var(--color-muted); font-size: var(--text-sm);
  line-height: var(--leading-normal); margin: var(--space-2) 0 0; }

/* the action row: buttons composed by hand, wrapping rather than overflowing on a narrow tile */
.media-card__actions { display: flex; flex-wrap: wrap; gap: var(--space-2);
  margin: var(--space-4) 0 0; }

/* a navigable tile: the whole card is the link, so the tap target is the picture. Quiet at rest,
   the edge picks up ink on hover, the way a gallery tile does. */
a.media-card { text-decoration: none; color: var(--color-fg); }
a.media-card:hover { border-color: var(--ink); }
a.media-card[aria-disabled="true"] { pointer-events: none; opacity: 0.6; }

/* the play badge sits centred over the picture, carrying its own scrim disc so it survives a
   bright frame. Decorative: the title is what a screen reader gets. */
.media-card__play {
  position: absolute; inset: 0; margin: auto;
  width: 4rem; height: 4rem;
  display: grid; place-items: center;
  border-radius: 50%;
  background: var(--scrim); color: var(--on-scrim);
  font-size: var(--text-2xl); line-height: 1; padding-left: .15em;
}
a.media-card:hover .media-card__play { background: var(--ink); }

/* ---- overlay: the text sits on the picture rather than under it. The gradient is the readability
   device, not decoration: a label over an arbitrary photograph has no contrast guarantee without
   it. Both scrim tokens hold their value across color schemes on purpose (variables.css). */
.media-card[data-layout="overlay"] .media-card__body {
  position: absolute; left: 0; right: 0; bottom: 0;
  padding: var(--space-4);
  color: var(--on-scrim);
  background: linear-gradient(to top, var(--scrim), transparent);
}
.media-card[data-layout="overlay"] .media-card__text { color: var(--on-scrim); opacity: 0.85; }

@media (prefers-reduced-motion: no-preference) {
  a.media-card { transition: border-color 0.15s; }
  .media-card__play { transition: background 0.15s; }
}

/* ---- AI / in-transit: a media card the AI is still composing wears the dashed "terminal" edge,
   the same vocabulary card + gallery + badge + field use. */
.media-card[data-commit="pending"],
[data-grade="grain"] .media-card { border-style: dashed; border-color: var(--ink); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/molecules/nav-item/nav-item.css */
/* molecules/nav-item — a rail navigation entry: an icon in the rail's shared ICON GUTTER, then
   the label — the two-column, editor-workspace look (icons align in their own vertical column;
   the rail draws the gutter line). A styled anchor composed with b-icon (not a custom tag, so it
   drops into a static page). Active = aria-current="page" (accessible) or data-active="true".
   Grid: icon column (fixed, --rail-icon-col) | label | trailing (chevron). Iconless items (e.g.
   a group's sub-entries) indent past the gutter instead — see side-rail. */
.nav-item {
  display: grid; grid-template-columns: var(--rail-icon-col, 2.5rem) 1fr auto;
  align-items: center; column-gap: var(--space-2);
  min-height: 2.25rem; padding-right: var(--space-2);
  border: 1px solid transparent; border-radius: var(--radius-sm);
  color: var(--color-muted); text-decoration: none; white-space: nowrap;
}
.nav-item > b-icon, .nav-item > .icon { justify-self: center; }   /* the icon sits centered in the gutter */
.nav-item:hover { background: var(--color-bg); color: var(--color-fg); }
.nav-item[aria-current="page"], .nav-item[data-active="true"] {
  color: var(--color-fg); background: var(--color-bg); border-color: var(--color-line);
}
.nav-item__label { font-size: var(--text-base); }
/* OPTIONAL trailing count — "how much is in there" beside the label, in the grid's trailing
   column (the same slot the group chevron uses; an item never carries both). Tabular figures so
   the column can't jitter as the number changes, and it follows the item's own state rather than
   staying muted under an active row. The collapsed rail hides it with the labels (side-rail.css,
   which owns every collapsed rule so there's one place to read them). */
.nav-item__count {
  font-size: var(--text-sm); color: var(--color-muted);
  font-variant-numeric: tabular-nums;
}
.nav-item:hover .nav-item__count,
.nav-item[aria-current="page"] .nav-item__count,
.nav-item[data-active="true"] .nav-item__count { color: var(--color-fg); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/molecules/readiness/readiness.css */
/* molecules/readiness — a verdict over its evidence: whether a set of boundary conditions
   holds, the one line saying why, and what is still in the way. The generic shape behind
   "ready to deploy", "ready to release", "ready to hand off". CSS-only (CONVENTIONS §4):
   composed by hand, nothing data-binds it.

   Parent context: none required. It fills its container and carries no width of its own —
   drop it inside a `.card` for a bordered tile, or leave it bare in a panel.

   Composition: the evidence rows are a `status-list`, NOT a shape this component invents.
   A readiness block that grows its own row markup is the thing to avoid — the marks, the
   in-flight lifecycle and the per-row `data-surface` hook all already live there.

   This is a READOUT, not an ask. It reports state and stops. A closed-set question with
   options and an escape belongs on CRUMB's decision ask, and having two surfaces put the
   same question to someone is the failure DECISIONS names outright. */

.readiness { display: flex; flex-direction: column; gap: var(--space-2); }

/* The verdict sits at display scale on purpose: grade texture only reads at >= --text-2xl
   (CLAUDE.md lesson 4), and a verdict is exactly the line an AI might compose — so put
   `data-grade="grain"` here and the provenance is legible instead of theoretical. */
.readiness__verdict {
  font-size: var(--text-2xl);
  line-height: var(--leading-tight);
  color: var(--color-fg);
}

.readiness__why {
  font-size: var(--text-sm);
  line-height: var(--leading-normal);
  color: var(--color-muted);
}

/* State reads as ink level and edge weight, never hue — the same rule stat-tile follows.
   `blocked` earns a solid-ink edge because it is the one meant to stop you; `waiting`
   recedes; `ready` and the default are identical, since a healthy verdict needs no
   shouting. */
.readiness[data-state="blocked"] {
  border-left: 2px solid var(--color-fg);
  padding-left: var(--space-3);
}
.readiness[data-state="waiting"] .readiness__verdict { color: var(--color-muted); }

/* In transit — the detector has not finished deciding. The shared idiom keyed off
   `data-commit="pending"` and an ancestor `[data-grade="grain"]`, expressed here as a
   dashed leading edge, never a bespoke "loading" state. */
.readiness[data-commit="pending"],
[data-grade="grain"] .readiness[data-commit="pending"] {
  border-left: 2px dashed var(--color-fg);
  padding-left: var(--space-3);
}

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/molecules/stat-tile/stat-tile.css */
/* molecules/stat-tile — a KPI tile: one big number, a small label, an optional sub-line.
   The standard dashboard primitive; grain had nothing between b-badge and card. One class per
   element (BEM); tone is an attribute. Monochrome: tone is carried by ink-level + border weight,
   never by hue (DESIGN-SYSTEM §2) — same doctrine as b-badge's "active". Tiles compose in a plain
   flex/grid row; no wrapper component (see the .md). */
.stat {
  display: flex; flex-direction: column; gap: var(--space-1);
  padding: var(--space-3) var(--space-4);
  border: var(--border); border-radius: var(--radius-md);
  min-inline-size: 0;
}
/* the number sits at display scale so grain texture reads if it's AI-written (grade doctrine) */
.stat__value {
  font-size: var(--text-2xl); font-weight: var(--font-weight-medium);
  line-height: var(--leading-tight); color: var(--ink);
}
.stat__label { font-size: var(--text-sm); color: var(--color-muted); }
.stat__sub { font-size: var(--text-xs); color: var(--ink-muted); }

/* ---- tone (attribute) — ink-level + border weight, NOT hue ----
   default : the calm baseline (a healthy/positive count needs no shouting).
   ok      : same calm baseline — an explicit "good" that reads identical, by design.
   bad     : draws the eye monochromatically — a solid-ink border + full-ink value.
   muted   : a context/secondary count (skipped, pending, a total) recedes to faint. */
.stat[data-tone="bad"] { border-color: var(--ink); }
.stat[data-tone="muted"] .stat__value { color: var(--ink-muted); }
.stat[data-tone="muted"] .stat__label { color: var(--ink-muted); }

/* ---- AI / in-transit: the non-text grain, same vocabulary as the button/field/badge
   (AI-INTERFACE §5). A tile whose number is mid-update wears the dashed "terminal" edge; a
   value the AI is writing carries data-grade="grain" so its digits render in the grain family
   via the inherited --type-font. */
.stat[data-commit="pending"],
[data-grade="grain"] .stat { border-style: dashed; border-color: var(--ink); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/molecules/status-list/status-list.css */
/* molecules/status-list — a list where each row carries a leading status mark, a title, and
   trailing meta (duration, target). A live pass/fail checklist, a job queue, a deploy pipeline, a
   task run-down — b-list is prose-marker only, this is a three-slot structural row. One class per
   element (BEM). Monochrome: the mark you place carries the signal (a glyph / b-badge / b-icon in
   currentColor), never a hue baked in here (DESIGN-SYSTEM §2).

   The load-bearing idea: an in-flight row wears data-commit="pending" (dashed leading edge +
   blinking caret) and settles on result — the grain control lifecycle (lesson 2) mapped onto a
   list item, no new "running" state invented. A not-yet-reached row reads faint via
   data-state="waiting". Rows take data-surface so an AI can address one row at a time. */
.status-list { list-style: none; margin: 0; padding: 0; }
.status-list__item {
  display: flex; align-items: baseline; gap: var(--space-3);
  padding: var(--space-2) var(--space-2) var(--space-2) var(--space-3);
  border-top: 1px solid var(--line-soft);
}
.status-list__item:first-child { border-top: 0; }
/* the leading mark slot: fixed width so titles align; author fills it with a glyph/badge/icon,
   rendered in the row's ink (monochrome) */
.status-list__mark { flex: 0 0 1.5em; text-align: center; color: var(--ink); }
.status-list__title { flex: 1 1 auto; min-inline-size: 0; color: var(--ink); }
.status-list__meta { flex: 0 0 auto; font-size: var(--text-sm); color: var(--color-muted); }

/* ---- waiting: a row the runner hasn't reached yet recedes to faint (generic queue/pending
   state — not a domain status) ---- */
.status-list__item[data-state="waiting"] .status-list__mark,
.status-list__item[data-state="waiting"] .status-list__title { color: var(--ink-muted); }

/* ---- AI / in-transit + "this row is being acted on": the grain control lifecycle (lesson 2),
   same vocabulary as the button/field. An in-flight row (a runner is acting on it, or the AI is)
   wears the dashed "terminal" leading edge; a blinking block caret trails the title. Keyed off
   the row's own pending state OR any grain ancestor. ---- */
.status-list__item[data-commit="pending"],
[data-grade="grain"] .status-list__item {
  border-left: 2px dashed var(--ink); padding-left: calc(var(--space-3) - 2px);
}
.status-list__item[data-commit="pending"] .status-list__title::after,
[data-grade="grain"] .status-list__item .status-list__title::after {
  content: ""; display: inline-block;
  width: 0.5ch; height: 1em; margin-left: 0.4ch; vertical-align: text-bottom;
  background: currentColor;
  animation: caret-blink 1s steps(1) infinite;   /* keyframes in grain/styles/grain.css */
}
@media (prefers-reduced-motion: reduce) {
  .status-list__item[data-commit="pending"] .status-list__title::after,
  [data-grade="grain"] .status-list__item .status-list__title::after { animation: none; }
}

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/molecules/tab/tab.css */
/* molecules/tab — one open view in the tab-bar, editor-style (the workspace metaphor): a boxed
   tab with an icon, a mono label, and a close affordance — the active tab carries a top accent
   line and merges into its pane. Composed: icon (.tab__icon, optional) + label text + close
   (.tab__close, optional). The close is PRESENTATIONAL by default (pointer-events off — a click
   lands on the tab link); a consumer with genuinely closable views wires it as a real button. */
.tab {
  display: flex; align-items: center; gap: var(--space-2);
  height: 2.5rem; padding: 0 var(--space-2) 0 var(--space-3);
  font-family: var(--font-mono); font-size: var(--text-xs); letter-spacing: 0.01em;
  color: var(--color-muted); text-decoration: none; white-space: nowrap;
  border-right: 1px solid var(--color-line);
}
.tab:hover { color: var(--color-fg); background: color-mix(in srgb, var(--color-bg) 55%, transparent); }
.tab__icon { display: inline-flex; color: var(--ink-faint); }
.tab[aria-current="page"], .tab[data-active="true"] {
  color: var(--color-fg); background: var(--color-bg);
  box-shadow: inset 0 2px 0 var(--color-accent, var(--ink));   /* the editor tab's top accent line */
}
.tab[aria-current="page"] .tab__icon, .tab[data-active="true"] .tab__icon { color: var(--color-accent, var(--ink)); }
.tab__close {
  display: inline-flex; align-items: center; justify-content: center;
  width: 1.5rem; height: 1.5rem;   /* 24px: the WCAG 2.2 target-size minimum (2.5.8) */ border-radius: var(--radius-sm);
  color: transparent; pointer-events: none;   /* hidden until the tab is hovered/active */
}
.tab:hover .tab__close, .tab[aria-current="page"] .tab__close, .tab[data-active="true"] .tab__close { color: var(--ink-muted); }
/* the PINNED marker: same box as .tab__close but ALWAYS visible and never interactive — it
   reads "this tab doesn't close" (a pinned tab ships a .tab__pin instead of a .tab__close). */
.tab__pin {
  display: inline-flex; align-items: center; justify-content: center;
  width: 1.5rem; height: 1.5rem;   /* 24px: the WCAG 2.2 target-size minimum (2.5.8) */ border-radius: var(--radius-sm);
  color: var(--ink-muted); pointer-events: none;
}
/* WCAG 2.4.7: a keyboard user tabbing onto a hidden × must see it, not just a ring round nothing. */
.tab__close:focus-visible { color: var(--ink-muted); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/molecules/table/table.css */
/* molecules/table — a content data table (MILL maps Markdown pipe tables to it), wrapped
   in .table-scroll so wide tables scroll inside their own box instead of breaking the page.
   Monochrome and ruled, not zebra-striped (DESIGN-SYSTEM §2). CSS-only: composed by MILL
   or written by hand; nothing data-binds it. */
.table-scroll { overflow-x: auto; margin: 0 0 var(--space-4); }
.table { border-collapse: collapse; width: 100%; font-size: var(--text-sm); }
.table th {
  text-align: left;
  font-weight: var(--font-weight-semibold);
  border-bottom: 2px solid var(--ink);
  padding: var(--space-2) var(--space-3);
}
.table td {
  border-bottom: 1px solid var(--color-line);
  padding: var(--space-2) var(--space-3);
  vertical-align: top;
  line-height: var(--leading-normal);
}
.table th:first-child, .table td:first-child { padding-left: 0; }

/* AI / in-transit: dashed terminal edge (non-text grain idiom, same as the atoms) */
.table[data-commit="pending"],
[data-grade="grain"] .table { border: 1px dashed var(--ink); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/molecules/trend-chart/trend-chart.css */
/* molecules/trend-chart — a neutral time-series display. Callers own the meaning and scale. */
.trend-chart {
  display: grid;
  gap: var(--space-3);
  min-inline-size: 0;
  margin: 0;
  padding: var(--space-4);
  border: var(--border);
  border-radius: var(--radius-md);
  color: var(--color-fg);
}

.trend-chart__head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-2) var(--space-4);
}

.trend-chart__title { margin: 0; font-size: var(--text-base); font-weight: var(--font-weight-semibold); }
.trend-chart__unit,
.trend-chart__legend,
.trend-chart__caption {
  margin: 0;
  color: var(--color-muted);
  font-size: var(--text-sm);
  line-height: var(--leading-normal);
}

.trend-chart__plot { min-inline-size: 0; }
.trend-chart__svg { display: block; inline-size: 100%; block-size: auto; overflow: visible; }
.trend-chart__grid { stroke: var(--color-line); stroke-width: 1; }
.trend-chart__axis-label { fill: var(--color-muted); font: var(--text-xs) var(--font-mono); }
.trend-chart__series { fill: none; stroke: var(--color-fg); stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; }
.trend-chart__point { fill: var(--color-bg); stroke: var(--color-fg); stroke-width: 2; }
.trend-chart__range { fill: var(--color-muted); fill-opacity: .18; stroke: none; }
.trend-chart__range-edge { fill: none; stroke: var(--color-muted); stroke-width: 1.25; stroke-dasharray: 4 4; }

.trend-chart__table-wrap { max-inline-size: 100%; overflow-x: auto; }
.trend-chart__table {
  inline-size: 100%;
  border-collapse: collapse;
  font-size: var(--text-sm);
  font-variant-numeric: tabular-nums;
}
.trend-chart__table caption { padding-block-end: var(--space-2); text-align: start; }
.trend-chart__table th,
.trend-chart__table td { padding: var(--space-2); border-block-start: 1px solid var(--color-line); text-align: start; }
.trend-chart__table thead th { color: var(--color-muted); font-size: var(--text-xs); font-weight: var(--font-weight-semibold); }
.trend-chart__table tbody th { font-weight: var(--font-weight-regular); }
.trend-chart__table td { white-space: nowrap; }

/* A pending value uses GRAIN's shared in-transit edge, never a chart-specific state. */
.trend-chart[data-commit="pending"],
[data-grade="grain"] .trend-chart { border-style: dashed; border-color: var(--color-fg); }

/* The chart is static by design. Keep the reduced-motion contract explicit if future styling adds motion. */
@media (prefers-reduced-motion: reduce) {
  .trend-chart, .trend-chart * { scroll-behavior: auto; }
}

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/organisms/activity-bar/activity-bar.css */
/* organisms/activity-bar — the VS Code ACTIVITY BAR: a slim vertical icon column at the far edge
   of the app-shell rail. Icon-only entries (an explorer toggle, app links); the consumer supplies
   the links + glyphs (persona-neutral, tokens only). Mounts as the FIRST child of
   `.app-shell__rail`, sibling of a nested `.side-rail` — it does NOT add a grid column (see the
   rejected alternative in activity-bar.md); it rides the rail's existing track + mobile drawer. */
.activity-bar {
  flex: 0 0 auto;
  display: flex; flex-direction: column; align-items: center; gap: var(--space-1);
  width: 3.25rem;                     /* matches the collapsed-rail track */
  padding: var(--space-2) 0;
  border-right: 1px solid color-mix(in srgb, var(--color-line) 55%, transparent);
}
/* ≥44px touch target (DESIGN-SYSTEM §7 — the same promise b-icon-button makes; this is a bespoke
   square, not a composed .icon-btn, so it has to hold the floor itself). Was 2.25rem/36px until
   the 2026-07-14 responsive-polish audit flagged the rail-toggle path as untappable on mobile; the
   3.25rem column still centers a 2.75rem item with 4px to spare on each side. flex-shrink:0 for the
   same reason b-icon-button carries it now — a squeezed rail column must never shrink the target. */
.activity-bar__item {
  display: flex; align-items: center; justify-content: center; flex-shrink: 0;
  width: 2.75rem; height: 2.75rem; border-radius: var(--radius-sm);
  position: relative;
  color: var(--color-muted); background: transparent; border: 0; cursor: pointer;
  text-decoration: none;
}
.activity-bar__item:hover { color: var(--color-fg); }
/* the CURRENT item: ink + a 2px accent left edge (mirrors the file-tree's open-file bar) */
.activity-bar__item[aria-current="true"], .activity-bar__item[aria-current="page"] { color: var(--color-fg); }
.activity-bar__item[aria-current="true"]::before, .activity-bar__item[aria-current="page"]::before {
  content: ""; position: absolute; top: 0.35rem; bottom: 0.35rem; left: -2px;
  width: 2px; background: var(--color-accent);
}
.activity-bar__spacer { flex: 1 1 auto; }

/* NB: the app-shell OPT-IN rule (`.app-shell__rail:has(> .activity-bar)` becomes a flex row) lives
   in app-shell.css — the SAME sheet as the mobile-drawer rules, so bundle order can't let the
   drawer's `position: fixed` lose to this. See activity-bar.md + app-shell.css. */

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/organisms/app-shell/app-shell.css */
/* organisms/app-shell — the WORKSPACE layout (the "work-y" archetype): a full-viewport
   grid of five regions — a left rail, a top bar, the main pane, a right aside (the
   assistant), and a bottom console. It HOSTS arbitrary page content, so it's a layout
   the page applies via region classes (the render engine can't project children into a
   component), not a data-bound tag. Re-skins via tokens like everything else. Coexists
   with the single-column editorial `.container`; the reference app composes it via portfolio-frame
   (tjakoen.github.io/components/organisms/portfolio-frame) on every page, incl. /loop. */

/* Two optional full-width rows bracket the workspace: "window" (the app-window title bar)
   and "status" (the status-bar). Auto rows collapse to zero when a page places nothing in
   them, so shells without the editor-window dressing are untouched. The ASIDE (assistant)
   spans the full right column from the topbar row down — it sits beside the tab bar, not
   under it (no dead space). The CONSOLE (terminal) docks under main only, as a bottom panel. */
.app-shell {
  display: grid;
  height: 100dvh;
  grid-template-columns: var(--shell-rail, 15rem) minmax(0, 1fr) var(--shell-aside, 20rem);
  /* main + console rows are minmax tracks whose endpoints are ALL lengths/fr — never `auto` or
     `min-content` — so grid-template-rows stays INTERPOLABLE and every console state change
     (closed→open band→full-screen) GLIDES instead of snapping (an auto/px↔fr endpoint makes the
     whole track list discrete → an instant jump; measured). The console height is driven entirely
     by the track here, not by the feed's content: `--shell-console-min` is the floor (bar height
     when closed, a band when open) and `--shell-console-fr` grows it to fill on expand. The feed
     is `flex:1 1 0` (console.css) so it has zero intrinsic height and simply fills whatever the
     track gives — which is what lets the floor be an exact length with no content-balloon. */
  grid-template-rows: auto auto minmax(0, var(--shell-main-fr, 1fr)) minmax(var(--shell-console-min, 2.75rem), var(--shell-console-fr, 0fr)) auto;
  grid-template-areas:
    "window window  window"
    "rail   topbar  aside"
    "rail   main    aside"
    "rail   console aside"
    "status status  status";
  background: var(--color-bg);
  color: var(--color-fg);
  overflow: hidden;
  /* panels glide in/out: the rail/aside columns animate their width (grid tracks interpolate);
     main/console share the SAME transition when the console grows (see data-console-expanded). */
  transition: grid-template-columns 0.26s ease, grid-template-rows 0.26s ease;
}
.app-shell[data-rail-collapsed="true"] { --shell-rail: 3.25rem; }   /* icons-only rail */
/* OPEN: the console grows from the slim bar to a docked BAND — the floor rises from the bar
   height to ~16rem. A length↔length change, so it glides (grid-rows transition above). NOTE:
   `data-acting` (a run) does NOT open the band — during a run the terminal stays collapsed and the
   CHAT previews the output; the person clicks "open in terminal" (→ data-console-open) to expand
   it (loop.e2e "takeover"). */
.app-shell[data-console-open] { --shell-console-min: 16rem; }
/* GROWN (console.css's `console__grow` toggle): the console takes over the WHOLE screen — main's
   fr goes to 0 (`minmax(0, 0fr)` collapses it) and the console's fr goes to 1 so it fills the
   freed space. Only the fr coefficients change against a fixed floor, so it glides both ways
   (measured: a smooth main-height ramp to 0, not a snap). */
.app-shell[data-console-expanded] { --shell-main-fr: 0fr; --shell-console-fr: 1fr; }

/* ---- OPT-IN: a `.rail-head` (full-width, brand/title row) stacks the rail vertically — the
   head sits at the SAME height as the topbar row beside it, and everything below it (the icon
   column + tree) starts on its own line underneath. A rail without one is untouched (flex-column
   with a single child behaves like the old block layout). ---- */
.app-shell__rail:has(> .rail-head) { display: flex; flex-direction: column; }
.app-shell__rail > .rail-body { min-height: 0; }
.app-shell__rail:has(> .rail-head) > .rail-body { flex: 1 1 auto; }

/* ---- OPT-IN: a rail hosting an `activity-bar` (VS Code icon column) becomes a flex row — the
   strip beside the tree. Composes with `.rail-head` above (activity-bar sits in `.rail-body` then)
   or stands alone as a direct child of `__rail`. A rail with NEITHER is untouched (these `:has()`
   rules never match). This rule lives HERE, alongside the mobile-drawer rules below, so bundle
   order can never let the drawer's `position: fixed` lose to it (the same trap that once broke the
   drawer — see the mobile block). Collapsed rail = the icon strip only (the tree hides, the
   activity bar stays). ---- */
.app-shell__rail:has(> .activity-bar), .app-shell__rail > .rail-body:has(> .activity-bar) {
  display: flex; flex-direction: row; align-items: stretch;
}
.app-shell__rail:has(> .activity-bar) > .side-rail,
.app-shell__rail > .rail-body:has(> .activity-bar) > .side-rail { flex: 1 1 auto; min-width: 0; }
.app-shell[data-rail-collapsed="true"] .app-shell__rail:has(> .activity-bar) > .side-rail,
.app-shell[data-rail-collapsed="true"] .app-shell__rail > .rail-body:has(> .activity-bar) > .side-rail { display: none; }

/* VS Code-style pane visibility (shell.js: data-shell="aside-toggle"). The aside animates to width
   0 (no display:none, so the glide is smooth); its border/scroll fold away at zero. */
.app-shell[data-aside-hidden="true"] { --shell-aside: 0rem; }
.app-shell[data-aside-hidden="true"] .app-shell__aside { border-left: 0; overflow: hidden; }
.app-shell[data-console-hidden="true"] .app-shell__console { display: none; }
@media (prefers-reduced-motion: reduce) { .app-shell { transition: none; } }

/* ---- the regions (grid slots) ---- */
.app-shell__window  { grid-area: window;  min-width: 0; }
.app-shell__status  { grid-area: status;  min-width: 0; }
/* the rail separates from the content with a SOFTENED hairline — present, but quieter than
   the rail's own icon-gutter line, so the eye reads gutter > rail-edge as two weights. */
.app-shell__rail    { grid-area: rail;    min-height: 0;
                      border-right: 1px solid color-mix(in srgb, var(--color-line) 55%, transparent); }
/* position:relative + a real z-index (not "auto") pins the topbar — and the rail-toggle hamburger
   inside it — above anything else painting in this stacking context; explicit pointer-events:auto
   guards against ever inheriting a stray `none` from an ancestor. Defensive: the 2026-07-14 audit
   found the mobile hamburger timing out in Playwright (present, but unclickable) at squeezed
   widths — the root cause was the grid-squeeze the @container fix above resolves, but a control
   this load-bearing (it's the ONLY way to reach the rail on mobile) shouldn't also depend on
   nothing else ever drifting a z-index/pointer-events value over it. */
.app-shell__topbar  { grid-area: topbar;  min-width: 0; display: flex; align-items: stretch;
                      min-height: var(--editor-header-h, 2.9rem);   /* aligns with the panel head (one line) */
                      border-bottom: 1px solid var(--color-line); background: var(--color-surface);
                      position: relative; z-index: 1; pointer-events: auto; }
/* __main owns the page scroll inside the shell — it must OPT INTO the smooth-scroll contract
   (global.css sets it on html, but scrollIntoView scrolls the nearest scrollable ancestor,
   which in a shell is this pane; without it the AI's focus jumps are instant). */
.app-shell__main    { grid-area: main;    min-width: 0; overflow: auto; scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) { .app-shell__main { scroll-behavior: auto; } }
.app-shell__aside   { grid-area: aside;   min-height: 0; overflow: auto;
                      border-left: 1px solid var(--color-line); background: var(--color-surface); }
.app-shell__console { grid-area: console; border-top: 1px solid var(--color-line);
                      background: var(--color-bg); }
.app-shell__scrim   { display: none; }   /* only used as the drawer scrim on mobile */

/* ---- CONTAINER (2026-07-14 responsive-polish fix): the two layout breakpoints below used to be
   `@media`, which reads the actual BROWSER viewport — so the viewport-toggle's cosmetic max-width
   clamp on `.app-shell` (see the bottom of this file) never made them fire; a squeezed real window
   between 768-1100px also just squeezed the fixed rail/aside tracks instead of collapsing (the
   `minmax(0,1fr)` main column went to zero, not the layout).

   Fix: `.app-shell` cannot query an `@container` condition on ITSELF (the CSS containment spec:
   a container never matches its own query, only its descendants' do) — so the container has to be
   an ANCESTOR. `.app-shell` has no wrapper of its own in any real composition (portfolio-frame,
   this file's own .md demo): it is always a DIRECT CHILD of `<body>`. So `<body>` becomes the named
   container (`shell-frame`), scoped with `:has()` so a page with no shell is untouched. Both
   `.app-shell` and its descendants (`.app-shell__aside`, `sidebar-panel`, `status-bar`) can then
   legally query it. The viewport-toggle's clamp moves to `body[data-viewport]` too (below) — so
   shrinking body for the toggle shrinks the SAME container real narrow windows shrink, and both
   paths now drive the identical mobile layout. `container-type: inline-size` only tracks/queries
   the inline axis, which is exactly the width-based breakpoints below — height stays content-sized
   as before (100dvh via .app-shell, unaffected). */
body:has(.app-shell) { container-type: inline-size; container-name: shell-frame; }

/* ---- narrow desktop: keep all three columns but tighten the aside so the assistant
   stays reachable (dropping it here stranded the chat on laptop-width windows) ---- */
@container shell-frame (max-width: 1100px) {
  .app-shell { --shell-aside: 16rem; }
}

/* ---- mobile: single column; the rail becomes an off-canvas drawer. The aside (assistant)
   becomes a bottom sheet (sidebar-panel.css, which loads after this file and wins the tie). ---- */
@container shell-frame (max-width: 768px) {
  .app-shell {
    grid-template-columns: minmax(0, 1fr);
    /* the last row only reserves room for the bottom sheet's peeking head (sidebar-panel.css),
       so the sheet never covers the end of <main>. The console strip and status bar are
       dropped here: on a phone they stacked 75px of chrome under the sheet for no new reading. */
    grid-template-areas: "window" "topbar" "main" "sheet";
    grid-template-rows: auto auto minmax(0, 1fr) 2.75rem;
  }
  .app-shell__aside { display: none; }
  .app-shell > .app-shell__console, .app-shell > .app-shell__status { display: none; }   /* two classes: status-bar.css sorts later */
  .app-shell[data-rail-collapsed="true"] { --shell-rail: 15rem; }   /* collapse-width is desktop-only */
  /* two classes ON PURPOSE: a composed rail (e.g. side-rail) may carry its own `position:
     relative` (the icon gutter), and the CSS bundle sorts side-rail AFTER app-shell — at equal
     specificity the drawer's `fixed` silently lost and the rail fell back into the grid flow.
     The extra ancestor class keeps the drawer winning regardless of bundle order. */
  .app-shell .app-shell__rail {
    position: fixed; inset: 0 auto 0 0; z-index: 40;
    width: min(78vw, 18rem);
    transform: translateX(-100%); transition: transform 0.3s ease;
    border-right: 1px solid var(--color-line); background: var(--color-surface);
  }
  .app-shell[data-rail-open="true"] .app-shell__rail { transform: none; }
  .app-shell__scrim {
    display: block; position: fixed; inset: 0; z-index: 39;
    background: color-mix(in srgb, var(--ink) 32%, transparent);
    opacity: 0; pointer-events: none; transition: opacity 0.3s ease;
  }
  .app-shell[data-rail-open="true"] .app-shell__scrim { opacity: 1; pointer-events: auto; }
}

@media (prefers-reduced-motion: reduce) {
  .app-shell__rail, .app-shell__scrim { transition: none; }
}

/* ---- viewport preview (shell.js: data-shell="viewport-toggle") — clamps `<body>`, the SAME
   element the @container queries above read (`shell-frame`), not `.app-shell`. That's what makes
   this a real (if imperfect) breakpoint drive now instead of a cosmetic frame: shrinking body's
   inline-size shrinks the container, so the toggle fires the identical mobile/tablet layout a real
   narrow window does — the rail becomes a drawer, the aside hides, etc. Still not a full device
   emulator (no simulated touch/DPR, and app-window.css's OWN backdrop/title-bar collapse — a
   separate `@media` — can't follow suit: that rule restyles `body.app-window-backdrop` itself,
   and a container may never query its own condition, only a descendant's; see that file). The
   shell's framed border stays keyed off `.app-shell` (a descendant of body, so this is legal) so
   the visual frame still hugs the shell's own edge, not body's padding box. */
body[data-viewport] { max-width: var(--shell-viewport-w, 100%); margin-inline: auto; }
body[data-viewport] .app-shell { border-inline: 1px solid var(--color-line); }
body[data-viewport="tablet"] { --shell-viewport-w: 48rem; }
body[data-viewport="mobile"] { --shell-viewport-w: 24.4rem; }

/* ---- single-page feel: the shell chrome PERSISTS across cross-document navigations (each name is
   present once per page, so it morphs in place); only <main> is in the root snapshot and cross-fades.
   Reduced-motion disables view transitions globally (grain/styles/global.css). ---- */
.app-shell__rail    { view-transition-name: shell-rail; }
.app-shell__topbar  { view-transition-name: shell-topbar; }
.app-shell__aside   { view-transition-name: shell-aside; }
.app-shell__console { view-transition-name: shell-console; }
.app-shell__window  { view-transition-name: shell-window; }
.app-shell__status  { view-transition-name: shell-status; }
/* the chrome SNAPS to its new state rather than morphing. The browser's default for a named
   element is to tween its box and cross-fade its two snapshots, so a tab strip that lost a tab,
   or a window that gained the aside, slid visibly on every navigation. Only <main> fades. */
::view-transition-group(shell-rail), ::view-transition-group(shell-topbar),
::view-transition-group(shell-aside), ::view-transition-group(shell-console),
::view-transition-group(shell-window), ::view-transition-group(shell-status),
::view-transition-new(shell-rail), ::view-transition-new(shell-topbar),
::view-transition-new(shell-aside), ::view-transition-new(shell-console),
::view-transition-new(shell-window), ::view-transition-new(shell-status) { animation: none; }
::view-transition-old(shell-rail), ::view-transition-old(shell-topbar),
::view-transition-old(shell-aside), ::view-transition-old(shell-console),
::view-transition-old(shell-window), ::view-transition-old(shell-status) { display: none; }
/* <main> gets its own transition rather than riding the root cross-fade: the old page leaves fast,
   and the new one fades in with a 4px rise, so a tab switch reads as a page settling into place
   rather than two pages blinking through each other. Short on purpose; the chrome around it holds
   still, so this is the only motion on screen. Reduced motion turns it off with the rest. */
.app-shell__main { view-transition-name: shell-main; }
::view-transition-group(shell-main) { animation: none; }
::view-transition-old(shell-main) { animation: 90ms ease-out both shell-main-out; }
::view-transition-new(shell-main) { animation: 200ms cubic-bezier(0.2, 0, 0, 1) 40ms both shell-main-in; }
@keyframes shell-main-out { to { opacity: 0; } }
@keyframes shell-main-in { from { opacity: 0; transform: translateY(4px); } }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/organisms/app-window/app-window.css */
/* organisms/app-window — the EDITOR-WINDOW frame: the whole app boxed in a bordered,
   radiused window sitting on a darker backdrop, with a title bar up top. CSS-only pattern
   (no .html — it dresses the app-shell; see app-window.md for the parent contract):

     <body class="app-window-backdrop">
       <div class="app-shell app-window">
         <header class="app-shell__window window-bar">…</header>   ← the title bar row
         … the shell's regions …
         <footer class="app-shell__status status-bar">…</footer>   ← see status-bar
       </div>
     </body>

   The app-shell grid owns the ROWS (app-shell.css: "window" / "status"); this file owns the
   LOOK: hairline frame, print-style solid offset shadow (no blur). The title bar hosts, left
   to right: the WINDOW NAV (functional icon buttons: back / refresh / forward — glyphs always
   visible, hover goes to our fg color, never OS traffic-lights), an optional control cluster
   (`__ctl`), a centered title OR search field (`__title` / `__search`), and a symmetry spacer
   (`__end`). Tokens only; re-themes for free. */

/* the backdrop: the page behind the window, a shade darker than the paper */
.app-window-backdrop {
  background: color-mix(in srgb, var(--ink) 12%, var(--paper));
  padding: clamp(var(--space-2), 2vh, var(--space-6));
  height: 100dvh;
  box-sizing: border-box;
}

/* the frame: pairs with .app-shell (which sets height: 100dvh — inside the padded
   backdrop the window fills what's left, hence the higher-specificity override) */
.app-shell.app-window { height: 100%; }
.app-window {
  border: 1px solid var(--ink);
  border-radius: var(--radius-md);
  overflow: hidden;
  box-shadow: 4px 4px 0 color-mix(in srgb, var(--ink) 30%, transparent);   /* print-style, zero blur */
}

/* ---- the title bar ---- */
.window-bar {
  display: flex; align-items: center; gap: var(--space-4);
  padding: 0.4rem 0.9rem;
  background: var(--color-surface);
  border-bottom: 1px solid var(--color-line);
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  color: var(--color-muted);
}

/* the window nav — OS-window CIRCLES, but carrying functional icons (back / refresh / forward):
   round hairline buttons with the glyph ALWAYS visible (quiet muted ink at rest); hover/focus
   goes to our fg + accent tint — no OS traffic-light colors. */
.window-bar__nav { display: flex; align-items: center; gap: 0.4rem; }
.window-bar__nav button {
  all: unset; box-sizing: border-box; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center;
  width: 1.15rem; height: 1.15rem; border-radius: 50%;
  border: 1px solid var(--color-muted);
  color: var(--color-muted); line-height: 1;
}
.window-bar__nav button:hover {
  color: var(--color-fg); border-color: var(--color-fg);
  background: color-mix(in srgb, var(--color-accent) 14%, transparent);
}
.window-bar__nav button:focus-visible { outline: 1px solid var(--color-accent); outline-offset: 2px; }
.window-bar__nav .icon { width: 0.72rem; height: 0.72rem; }

/* the control cluster — theme/scheme/view toggles. Sits on the RIGHT (the left is only the OS
   circle buttons); the flex:1 search between them keeps it pinned to the trailing edge. */
.window-bar__ctl { display: flex; align-items: center; gap: var(--space-3); flex: none; }
.window-bar__ctl button { all: unset; cursor: pointer; font: inherit; color: inherit; }
.window-bar__ctl button:hover { color: var(--color-fg); }
.window-bar__ctl button:focus-visible { outline: 1px solid var(--color-accent); outline-offset: 2px; }

/* centered title (plain text form) */
.window-bar__title { flex: 1; text-align: center; letter-spacing: 0.06em;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

/* centered SEARCH form: a button drawn as an input (it opens the ⌘K palette) — the editor's
   center search field. The placeholder text is the consumer's (e.g. the page's breadcrumb). */
.window-bar__search {
  all: unset; box-sizing: border-box; cursor: pointer;
  flex: 1; max-width: 34rem; margin-inline: auto;
  display: flex; align-items: center; gap: var(--space-2); justify-content: center;
  padding: 0.15rem 0.7rem;
  border: 1px solid var(--color-line); border-radius: var(--radius-sm);
  background: var(--color-bg);
  font: inherit; color: var(--color-muted);
  white-space: nowrap; overflow: hidden;
}
.window-bar__search:hover { border-color: var(--color-muted); }
.window-bar__search:focus-visible { outline: 1px solid var(--color-accent); outline-offset: 2px; }

/* ---- mobile: the window collapses — no backdrop padding, no frame, no title bar
   (the shell's drawer chrome carries the brand there); the status bar stays.

   SPLIT ON PURPOSE (2026-07-14): app-shell.css turns its own layout breakpoints into
   `@container shell-frame`, keyed to `<body>` — but `.app-window-backdrop` IS `<body>` itself
   (see the class on `<body>` in the comment up top), and a container can never legally query its
   own condition, only a descendant's (CSS containment spec). So the backdrop padding stays a real
   `@media`: it tracks a real narrow WINDOW correctly, but the shell's viewport-toggle preview
   (which clamps body's width, not the real viewport) can't collapse it — a known, harmless gap in
   a demo-only feature. `.app-shell.app-window` and `.window-bar`, though, are DESCENDANTS of body,
   not body itself, so they're free to follow the container — converted, so the toggle's preview at
   least drops the frame border/shadow/title-bar even though the backdrop margin lingers. ---- */
@media (max-width: 768px) {
  .app-window-backdrop { padding: 0; }
}
@container shell-frame (max-width: 768px) {
  .app-shell.app-window { border: 0; border-radius: 0; box-shadow: none; }
  .window-bar { display: none; }
}

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/organisms/chat-log/chat-log.css */
/* organisms/chat-log — a vertical thread of chat-message molecules. chat-message positions itself
   with align-self (you → right, the AI → left), which ONLY works inside a flex COLUMN — so the log
   is required, not optional. Composing messages in a chat-log makes correct alignment structural
   (correct-by-construction) instead of a silent trap. */
.chat-log { display: flex; flex-direction: column; gap: var(--space-2); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/organisms/code-editor/code-editor.css */
/* organisms/code-editor — the interactive tier ABOVE code-block (atoms/code-block). Where
   code-block is a static, hand-composed <pre> for MILL's blog prose, code-editor is the EDIT
   surface: a framed panel with a toolbar, a mount slot a real editor (Monaco) is injected into,
   a validation status strip, a diagnostics list, and a version-history drawer.

   CSS-ONLY, like every grain component — grain ships no JS bundler, so this component bundles NO
   editor. The consumer lazy-loads its own editor into `.code-editor__mount` (keeping Monaco's
   weight off every page that isn't editing) and toggles the state data-attrs below. That's the
   deliberate seam: grain owns the chrome + the monochrome grammar; the app owns the engine.

   Shares code-block's mono/surface tokens on purpose — code in a MILL blog and code in the
   editor read as ONE system. Signal is grain-monochrome throughout: no hue, states come from
   weight/opacity/dashed-edge, never colour (DESIGN-SYSTEM §2). */

.code-editor {
  display: flex; flex-direction: column; min-height: 0;   /* flex child that can shrink for the mount to scroll */
  background: var(--color-surface);
  border: 1px solid var(--color-line);
  border-radius: var(--radius-md);
  overflow: hidden;                                        /* clip the mount + drawer to the rounded frame */
  font-family: var(--font-mono);                           /* the editing surface is code — mono in either grade */
}

/* ---- toolbar: the file identity on the left, actions on the right ------------------------- */
.code-editor__bar {
  display: flex; align-items: center; gap: var(--space-2); flex: 0 0 auto;
  padding: var(--space-2) var(--space-3);
  border-bottom: 1px solid var(--color-line);
  background: var(--color-bg);
  font-family: var(--font-grain);                          /* chrome is machine-voice; only the code itself is mono */
  font-size: var(--text-sm);
}
.code-editor__title { color: var(--color-fg); font-family: var(--font-mono); min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }   /* long paths truncate, never wrap the bar */
/* a quiet "• unsaved" marker the app shows by flipping data-dirty on the root */
.code-editor__dirty { color: var(--color-muted); font-size: var(--text-xs); display: none; }
.code-editor[data-dirty="true"] .code-editor__dirty { display: inline; }
.code-editor__actions { display: flex; align-items: center; gap: var(--space-2); margin-left: auto; flex: 0 0 auto; }

/* ---- mount: where the app injects the editor. Empty, it reads as a quiet code surface so the
   panel never looks broken before the engine loads (progressive: SSR a <pre class="code-block">
   here and Monaco replaces it). ---------------------------------------------------------------- */
.code-editor__mount {
  flex: 1 1 0; min-height: 0;                              /* fills the frame beyond bar+strip; owns the scroll */
  overflow: auto;
  background: var(--color-surface);
  font-family: var(--font-mono); font-size: var(--text-sm); line-height: var(--leading-normal);
  color: var(--color-fg);
}
.code-editor__mount:focus-within { outline: 2px solid var(--color-accent); outline-offset: -2px; }  /* inset — the frame already clips */
/* a code-block dropped inside the mount (the pre-engine fallback) loses its own frame — the panel
   is the frame now. */
.code-editor__mount .code-block { margin: 0; border: 0; border-radius: 0; height: 100%; }

/* ---- status strip: one line under the mount, driven by data-state on the root. Monochrome:
   the WORD carries the signal (via ::before glyph), invalid adds WEIGHT not colour. ------------ */
.code-editor__status {
  flex: 0 0 auto; display: flex; align-items: center; gap: var(--space-2);
  padding: var(--space-1) var(--space-3);
  border-top: 1px solid var(--color-line);
  font-family: var(--font-grain); font-size: var(--text-xs); color: var(--color-muted);
}
.code-editor__status::before { content: ""; color: var(--color-fg); }
.code-editor[data-state="validating"] .code-editor__status::before { content: "◐ "; }
.code-editor[data-state="valid"]      .code-editor__status::before { content: "✓ "; }
.code-editor[data-state="invalid"]    .code-editor__status::before { content: "✕ "; }
.code-editor[data-state="saved"]      .code-editor__status::before { content: "✓ "; }
/* invalid is louder, not redder — heavier ink + weight, mirroring b-select:user-invalid's 2px */
.code-editor[data-state="invalid"] .code-editor__status { color: var(--color-fg); font-weight: var(--font-weight-medium); }
.code-editor[data-state="invalid"] { border-width: 2px; }

/* ---- diagnostics: the parse/validate errors, listed under the strip. Hidden until there are
   any (app sets data-state="invalid" / fills the list). Each row is line:col + message. -------- */
.code-editor__diagnostics { flex: 0 0 auto; display: none; max-height: 9rem; overflow: auto;
  border-top: 1px solid var(--color-line); padding: var(--space-2) var(--space-3);
  font-family: var(--font-mono); font-size: var(--text-xs); }
.code-editor[data-state="invalid"] .code-editor__diagnostics:not(:empty) { display: block; }
.code-editor__diag { display: flex; gap: var(--space-2); padding: var(--space-1) 0; color: var(--color-fg); }
.code-editor__diag-loc { color: var(--color-muted); flex: 0 0 auto; }   /* e.g. "12:4" — the click target the app wires to jump the caret */
.code-editor__diag[data-clickable] { cursor: pointer; }
.code-editor__diag[data-clickable]:hover .code-editor__diag-loc { color: var(--color-accent); text-decoration: underline; }

/* ---- history drawer: versions of this spec, newest first. Off-canvas to the right, revealed by
   data-history on the root (the app toggles it from a toolbar button). Each row: author + time +
   message; restore is a button the app wires (restore = write a NEW version, never mutate). ---- */
.code-editor__history {
  position: absolute; top: 0; right: 0; bottom: 0; width: min(20rem, 80%);
  transform: translateX(100%); transition: transform 0.2s ease;
  background: var(--color-bg); border-left: 1px solid var(--color-line);
  display: flex; flex-direction: column; overflow: auto;
  font-family: var(--font-grain); font-size: var(--text-sm);
}
.code-editor { position: relative; }                       /* the drawer anchors to the panel */
.code-editor[data-history="open"] .code-editor__history { transform: translateX(0); }
.code-editor__history-head { flex: 0 0 auto; display: flex; align-items: center; gap: var(--space-2);
  padding: var(--space-2) var(--space-3); border-bottom: 1px solid var(--color-line);
  text-transform: uppercase; letter-spacing: 0.06em; font-size: var(--text-xs); color: var(--ink-muted); }
.code-editor__version { display: flex; flex-direction: column; gap: 2px;
  padding: var(--space-2) var(--space-3); border-bottom: 1px solid var(--color-line); cursor: pointer; }
.code-editor__version:hover { background: var(--paper-2); }
.code-editor__version[data-current] { border-left: 2px solid var(--color-accent); }   /* the live version */
.code-editor__version-meta { display: flex; gap: var(--space-2); color: var(--color-muted); font-size: var(--text-xs); }
.code-editor__version-msg { color: var(--color-fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

@media (prefers-reduced-motion: reduce) { .code-editor__history { transition: none; } }

/* ---- AI / in-transit: same vocabulary as code-block + the atoms (CONVENTIONS §4). An editor the
   AI is writing into wears the dashed "terminal" edge (non-text grain). Human editing never
   triggers it; the catalog grain-flip demonstrates it. ---------------------------------------- */
.code-editor[data-commit="pending"],
[data-grade="grain"] .code-editor { border-style: dashed; border-color: var(--ink); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/organisms/console/console.css */
/* organisms/console — the AI's narration TERMINAL, a DOCKED bottom panel (VS Code style): it
   lives in the shell's `console` grid row under main, not floating. At rest it's a slim clickable
   bar ("Terminal ▸"); clicking it — or the AI taking over (`data-acting`) — opens the feed in
   place (the grid row grows to a fixed band). A second control (`console__grow`) expands that
   band further, to fill most of the shell's height. It narrates the AI's steps as `action-badge`
   lines. Persona-neutral. CSS-only pattern: compose `.console__box` inside `.app-shell__console`;
   see console.md. (Loads after app-shell.css in the sorted bundle.) */

.app-shell__console { display: flex; flex-direction: column; min-height: 0; background: var(--color-bg); }
/* the box fills the console grid row (whose HEIGHT is what animates, in app-shell.css); the bar is
   fixed, the feed flex-grows into the rest — so the row height alone drives open/expand and the
   feed carries no intrinsic height (see the feed's `flex: 1 1 0` below). */
.console__box { display: flex; flex-direction: column; width: 100%; flex: 1 1 auto; min-height: 0; overflow: hidden; background: var(--color-bg); }
.console__bar { display: flex; align-items: center; gap: var(--space-2); flex: 0 0 auto;
  padding: var(--space-2) var(--space-4); font-family: var(--font-grain); font-size: var(--text-sm); color: var(--color-muted); }
/* the expand control: a transparent, keyboard-operable button that looks like the bar text */
.console__expand { display: flex; align-items: center; gap: var(--space-2); flex: 1; min-width: 0;
  background: transparent; border: 0; padding: 0; margin: 0; font: inherit; color: inherit; text-align: left; cursor: pointer; }
.console__expand:focus-visible, .console__grow:focus-visible, .chat-thinking__toggle:focus-visible {
  outline: 2px solid var(--color-accent); outline-offset: 2px; }
.console__label { color: var(--color-fg); }
.console__acting { display: none; color: var(--color-fg); }   /* shown only while acting */
.console__chev { color: var(--ink-faint); transition: transform 0.2s ease; }   /* ▾ open · ▸ collapsed */
.app-shell:not([data-console-open]):not([data-acting="true"]) .console__chev { transform: rotate(-90deg); }
/* the feed carries NO intrinsic height (`flex: 1 1 0`) — it simply fills whatever the console grid
   row gives it beyond the bar. So closed (row = bar height) → feed 0 → hidden; open (row = the
   16rem band) → feed ~13rem; expanded (row = 1fr) → feed fills the screen. All the animation lives
   in the grid ROW height (app-shell.css), which glides because its endpoints are all lengths/fr —
   the feed just follows. This replaced a `max-height` reveal that snapped on the auto↔fr expand. */
.console__feed { display: flex; flex-direction: column; gap: var(--space-2); flex: 1 1 0;
  min-height: 0; overflow: auto; scroll-behavior: smooth;
  border-top: 0 solid var(--color-line);
  padding: 0 var(--space-4); font-family: var(--font-grain); font-size: var(--text-sm); }
/* fully CLOSED = the feed is genuinely gone (not a 1-2px sliver that reads as "visible"): the row
   is just the bar. It reappears when open/expanded, sliding in as the grid row grows around it.
   (A run alone — data-acting — keeps it closed; the chat previews instead.) */
.app-shell:not([data-console-open]):not([data-console-expanded]) .console__feed { display: none; }
/* border + vertical padding appear once there's a feed to show (open / expanded) */
.app-shell[data-console-open] .console__feed,
.app-shell[data-console-expanded] .console__feed { border-top-width: 1px; padding-top: var(--space-2); padding-bottom: var(--space-2); }
/* v3.1 tried boxing each line individually (mirrors chat-message) — reverted (owner, 2026-07-06):
   it read as harder to scan, not easier, and doubled up with the chat-thinking box's own border
   around the whole run's preview. A flowing stream, one row per line, is the readable form; the
   OUTPUT-level grouping (one bordered box per run) already comes from the surrounding container
   (.console__feed's border-top in the terminal, .chat-thinking's border in the chat preview). */
.console__line { display: flex; align-items: baseline; gap: var(--space-2); padding: var(--space-1) 0; }
.console__desc { color: var(--color-fg); }
.console__line[data-variant="cmd"] .console__prompt { color: var(--color-accent); }   /* the echoed human command */
.console__line[data-state="error"] .console__desc { color: var(--color-danger, var(--color-accent)); }
.console__line a { color: var(--color-accent); text-decoration: none; }
.console__line a:hover { text-decoration: underline; }
.console__pre { margin: 0; white-space: pre-wrap; word-break: break-word;
  font-family: var(--font-grain); font-size: var(--text-xs); color: var(--color-fg); line-height: 1.5; }

/* the INTERACTIVE command line (grain/scripts/terminal.js injects it into a
   `.console__box[data-terminal="interactive"]`). Shown only when the terminal is open — it rides
   the same open/close as the feed. The prompt is grain (machine), the typed text is clean (human,
   via .field-like grade); tokens only. */
.console__input { display: none; align-items: baseline; gap: var(--space-2); flex: 0 0 auto;
  border-top: 1px solid var(--color-line); padding: var(--space-2) var(--space-4);
  font-family: var(--font-grain); font-size: var(--text-sm); }
.app-shell[data-console-open] .console__box[data-terminal="interactive"] .console__input { display: flex; }
.console__prompt { color: var(--color-muted); flex: 0 0 auto; }
.console__cmd { flex: 1; min-width: 0; background: transparent; border: 0; padding: 0; margin: 0;
  color: var(--color-fg); font-family: var(--font-smooth); font-size: var(--text-sm); }
.console__cmd::placeholder { color: var(--ink-muted); }
.console__cmd:focus { outline: none; }
/* WCAG 2.4.7 and 1.4.1: a prompt that only changes color is not enough, so the row also takes a ring. */
.console__input:focus-within { outline: 2px solid var(--color-accent); outline-offset: -2px; }
.console__input:focus-within .console__prompt { color: var(--color-accent); }

/* the chat's mid-run "thinking" box: a compact indicator + a small LIVE PREVIEW of the terminal
   output (the last few lines, mirrored by shell.js), with a button to expand the full terminal. */
.chat-thinking { display: none; flex-direction: column; margin: 0 var(--space-4) var(--space-3);
  border: 1px dashed var(--ink-faint); border-radius: var(--radius-md); padding: var(--space-2) var(--space-3);
  background: color-mix(in srgb, var(--ink) 4%, transparent); }
.chat-thinking__head { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--space-1); }
.chat-thinking__who { font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-muted); }
/* compact icon button (saves space in the run-time thinking box) */
.chat-thinking__toggle { cursor: pointer; font: inherit; color: var(--color-fg);
  display: inline-flex; align-items: center; justify-content: center; width: 1.5rem; height: 1.5rem; padding: 0;
  background: transparent; border: 1px solid var(--color-line); border-radius: var(--radius-sm); }
.chat-thinking__toggle:hover { background: var(--paper-2); }
/* holds CLONED narration lines (each action in its own box, mirrored from the terminal) */
.chat-thinking__preview { display: flex; flex-direction: column; gap: var(--space-1);
  max-height: 6em; overflow: hidden; font-size: var(--text-xs); }
.chat-thinking__preview:empty { display: none; }

/* the GROW toggle: expand the docked terminal to fill most of the shell's vertical space (owner,
   2026-07-06) instead of the fixed 11rem band — for reading a long run back. Label via ::before,
   so it stays correct even when the state is reset externally. Repurposes the slot of the old
   chat⇄terminal toggle, dead since the chat and terminal started coexisting (owner, 2026-07-05). */
.console__grow { display: inline-flex; align-items: center; margin-left: auto; cursor: pointer;
  font: inherit; font-size: var(--text-xs); color: var(--color-fg); background: transparent;
  border: 1px solid var(--color-line); border-radius: var(--radius-sm); padding: 2px var(--space-2); }
.console__grow::before { content: "⤢ expand"; }
.app-shell[data-console-expanded] .console__grow::before { content: "⤡ collapse"; }
.console__grow:hover { background: var(--paper-2); }

/* TAKEOVER: on `data-acting` the docked terminal's bar becomes the "acting" indicator (grain's
   top label is hidden) and the feed opens in place. The chat stays put alongside it — thinking
   goes to the terminal, communication to the chat (owner, 2026-07-05). */
.app-shell[data-acting="true"] .console__acting { display: inline; }
.app-shell[data-acting="true"] ~ .ai-acting-label { display: none; }
/* the docked terminal stays ABOVE the spotlight veil so its narration is never dimmed/overlaid */
.app-shell[data-acting="true"] .app-shell__console { position: relative; z-index: var(--z-ai-surface, 9002); }
/* the chat shows a compact "thinking" indicator during a run (its full narration is the terminal) */
.app-shell[data-acting="true"] .chat-thinking { display: flex; }

@media (prefers-reduced-motion: no-preference) {
  .app-shell[data-acting="true"] .console__bar .icon { animation: console-blink 1s steps(1) infinite; }
}
@media (prefers-reduced-motion: reduce) { .console__feed, .console__chev { transition: none; } }
@keyframes console-blink { 50% { opacity: 0.25; } }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/organisms/drawer/drawer.css */
/* organisms/drawer — a modal side panel: a scrim over the page plus a column that slides in
   from the edge. Where a lightbox (styles/lightbox.css) centers one thing to LOOK at, a drawer
   docks a place to WORK — a create/edit form, a record's detail, a preview — beside the list you
   opened it from, so the context behind it stays visible.

   CSS-only (no `.html`); the behavior is `grain/scripts/drawer.js` (open/close, Escape, scrim
   click, focus). The visible/hidden state is the plain `hidden` attribute, so a server can ship
   an open drawer and a page with no JS at all still renders a closed one instead of an overlay
   stuck across the screen. */
.drawer[hidden] { display: none; }
.drawer { position: fixed; inset: 0; z-index: 60; }

/* the scrim: ink receding, the same mechanism as the AI veil — never a raw rgba (tokens only) */
.drawer__backdrop {
  position: absolute; inset: 0;
  background: color-mix(in srgb, var(--ink) 32%, transparent);
  animation: drawer-fade 0.15s ease;
}

/* The panel is `min(28rem, 100vw)` — a fixed comfortable column that becomes full-bleed rather
   than overflowing once the viewport is narrower than it. It's a flex column so a long form
   scrolls in the BODY while the head stays put; without that the head scrolls away and there's
   no close button on screen. */
.drawer__panel {
  position: absolute; inset-block: 0; inset-inline-end: 0;
  width: min(28rem, 100vw);
  display: flex; flex-direction: column; overflow: hidden;
  background: var(--color-surface);
  border-inline-start: 1px solid var(--color-line);
  box-shadow: -12px 0 40px color-mix(in srgb, var(--ink) 22%, transparent);
  animation: drawer-slide 0.18s ease;
}
@keyframes drawer-fade { from { opacity: 0; } }
@keyframes drawer-slide { from { transform: translateX(100%); } }
@media (prefers-reduced-motion: reduce) {
  .drawer__backdrop, .drawer__panel { animation: none; }
}

/* the head aligns with the shell's topbar (same height token) so an open drawer reads as part
   of the same chrome rather than a slab laid over it */
.drawer__head {
  display: flex; align-items: center; justify-content: space-between; gap: var(--space-3);
  flex: none; min-height: var(--editor-header-h, 2.9rem); padding: 0 var(--space-5);
  border-bottom: 1px solid var(--color-line);
}
.drawer__head h2 { margin: 0; font-size: var(--text-lg); font-weight: var(--font-weight-semibold); }
.drawer__body { flex: 1; min-height: 0; overflow-y: auto; padding: var(--space-5); }

/* ---- OPT-IN: `data-side="start"` docks the drawer to the leading edge instead (a filter or
   navigation panel, where the trailing edge is the working one). Only the anchoring and the
   direction of the slide change. ---- */
.drawer[data-side="start"] .drawer__panel {
  inset-inline: 0 auto;
  border-inline-start: none;
  border-inline-end: 1px solid var(--color-line);
  box-shadow: 12px 0 40px color-mix(in srgb, var(--ink) 22%, transparent);
  animation-name: drawer-slide-start;
}
@keyframes drawer-slide-start { from { transform: translateX(-100%); } }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/organisms/file-tree/file-tree.css */
/* organisms/file-tree — an EXPLORER file tree (the editor-workspace metaphor): folders as native
   <details>/<summary> (zero-JS collapse, COLLAPSED by default — author without `open`), files as
   real <a> links. Mono type, hairline indent guides, a chevron that rotates on open — VS Code's
   explorer re-spoken in GRAIN. A layout class composed of native elements, not a data-bound tag;
   what the paths MEAN (which file produces which page) is the consumer's contract — this only
   draws the tree. Lives naturally in the app-shell rail (side-rail[data-variant="explorer"]
   drops the icon gutter for it), but works in any column. */
.file-tree {
  display: flex; flex-direction: column;
  min-height: 0; overflow-y: auto; overscroll-behavior: contain;
  font-family: var(--font-mono); font-size: var(--text-xs); letter-spacing: 0.01em;
  scrollbar-width: thin;
}

/* ---- a folder: <details class="file-tree__dir"><summary>name/</summary><div class="file-tree__children">… ---- */
.file-tree__dir > summary {
  display: flex; align-items: center; gap: var(--space-1);
  padding: 0.28rem var(--space-2);
  color: var(--color-muted); cursor: pointer; user-select: none;
  list-style: none; white-space: nowrap;
}
.file-tree__dir > summary::-webkit-details-marker { display: none; }
.file-tree__dir > summary:hover { color: var(--color-fg); }
/* the chevron: one glyph, rotated by state — no per-state art */
.file-tree__dir > summary::before {
  content: "▸"; flex: 0 0 auto; width: 0.9em;
  color: var(--ink-muted); font-size: 0.9em;
  transition: transform 0.15s ease;
}
.file-tree__dir[open] > summary::before { transform: rotate(90deg); }
@media (prefers-reduced-motion: reduce) { .file-tree__dir > summary::before { transition: none; } }

/* ---- children indent under a hairline guide (the VS Code indent line) ---- */
.file-tree__children {
  display: flex; flex-direction: column;
  margin-left: calc(var(--space-2) + 0.45em);
  border-left: 1px solid color-mix(in srgb, var(--color-line) 60%, transparent);
}

/* ---- a file: a real link (hypermedia — the tree navigates with zero JS) ---- */
.file-tree__file {
  display: block; padding: 0.28rem var(--space-2);
  color: var(--color-muted); text-decoration: none;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.file-tree__file:hover { color: var(--color-fg);
  background: color-mix(in srgb, var(--color-surface) 70%, transparent); }
/* an index/entry file (a folder's own page, e.g. index.html) is DIMMED — the directory name
   above it carries the meaning, so the filename recedes. Hover + current win by later position. */
.file-tree__file[data-variant="index"] { color: var(--ink-muted); }
/* the OPEN file: ink + a quiet accent edge (mirrors the rail's current-item bar) */
.file-tree__file[aria-current="page"] {
  color: var(--color-fg); position: relative;
  background: color-mix(in srgb, var(--color-surface) 85%, transparent);
}
.file-tree__file[aria-current="page"]::before {
  content: ""; position: absolute; top: 0; bottom: 0; left: -1px;
  width: 2px; background: var(--color-accent);
}

/* a collection folder may make its NAME a link (the folder's index page): the summary's <a>
   inherits the folder register; navigating is the link's, toggling stays the summary's. */
.file-tree__dir > summary > a { color: inherit; text-decoration: none; }
.file-tree__dir > summary > a:hover { color: var(--color-fg); }

/* ---- a file that's also an OPEN TAB gets a close affordance (tabs.js injects `.file-tree__close`
   as a real child span, live-wired — mirrors `.tab__close`'s hidden-until-hover pattern). :has()
   opts a single row into the flex/space-between layout; a plain file-tree__file is untouched. ---- */
.file-tree__file:has(.file-tree__close) { display: flex; align-items: center; justify-content: space-between; }
.file-tree__close {
  display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto;
  width: 1.5rem; height: 1.5rem;   /* 24px: the WCAG 2.2 target-size minimum (2.5.8) */ border-radius: var(--radius-sm);
  color: transparent; pointer-events: none;
}
.file-tree__file:hover .file-tree__close, .file-tree__file[aria-current="page"] .file-tree__close {
  color: var(--ink-muted);
}
.file-tree__close:hover { color: var(--color-fg); background: color-mix(in srgb, var(--color-surface) 70%, transparent); }

/* ---- inside a collapsed shell rail the labels are gone — a tree without labels is noise ---- */
[data-rail-collapsed="true"] .file-tree { display: none; }

/* ---- explorer variant of the rail: the icon gutter is the NAV rail's look; a tree brings its
   own indent guides, so the host drops the gutter line ---- */
.side-rail[data-variant="explorer"]::before { display: none; }
/* WCAG 2.4.7: a keyboard user tabbing onto a hidden × must see it, not just a ring round nothing. */
.file-tree__close:focus-visible { color: var(--ink-muted); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/organisms/note/note.css */
/* organisms/note — the editorial content article MILL's default layout emits:
   .note / .note__head / .note__lede / .note__tags. CSS-only (CONVENTIONS §4): composed by
   MILL at request time, nothing data-binds it. MILL content is human-authored → the layout
   stamps data-grade="smooth", so the AI-mode idiom never triggers from MILL itself; the
   text grain would still apply if a consumer ever marked one in-transit. */
.note { max-width: 72ch; }
.note > p, .note > ul, .note > ol, .note > blockquote { line-height: var(--leading-relaxed); }

.note__head .eyebrow { text-transform: uppercase; letter-spacing: 0.08em; font-size: var(--text-xs); }
.note__head .masthead { font-size: var(--text-3xl); margin-bottom: var(--space-3); }
.note__lede {
  color: var(--color-muted);
  font-size: var(--text-lg);
  line-height: var(--leading-relaxed);
  max-width: 60ch;
  margin: 0 0 var(--space-4);
}
.note__tags { display: flex; flex-wrap: wrap; gap: var(--space-1); margin: 0 0 var(--space-4); }
.note__head > .rule { margin-top: var(--space-4); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/organisms/notepad/notepad.css */
/* organisms/notepad — the AI's MEMORY as a visible, editable surface (DEMO-PLAN piece 2). A rail
   pane with two modes: RENDERED (the notes, read-only, where note.append/note.replace ops land) and
   SOURCE (a textarea the human edits, then commits through the one door). One toggle flips between
   them; the canonical state is the markdown SOURCE (a client island mirrors it to localStorage from
   each entry's data-md — see grain/scripts/notepad.js). Persona-neutral: "the AI", never "the desk".
   CSS-only structure — compose the markup from notepad.md. AI-authored entries carry data-grade
   ="grain" (provenance persists, DESIGN-SYSTEM §3); a human commit settles clean. Tokens only.
   (Loads after side-rail.css in the sorted bundle; mounts inside the rail like any pane.) */

.notepad { display: flex; flex-direction: column; min-height: 0; flex: 1 1 auto;
  font-family: var(--font-smooth); font-size: var(--text-sm); color: var(--color-fg); }

/* header row: the pane title + the source⇄rendered toggle + (in source mode) the Commit control */
.notepad__head { display: flex; align-items: center; gap: var(--space-2); flex: 0 0 auto;
  padding: var(--space-2) var(--space-3); border-bottom: 1px solid var(--color-line); }
.notepad__title { font-family: var(--font-grain); font-size: var(--text-xs); text-transform: uppercase;
  letter-spacing: 0.06em; color: var(--ink-muted); margin-right: auto; }
.notepad__toggle, .notepad__commit { cursor: pointer; font: inherit; font-size: var(--text-xs);
  color: var(--color-fg); background: transparent; border: 1px solid var(--color-line);
  border-radius: var(--radius-sm); padding: 2px var(--space-2); }
.notepad__toggle:hover, .notepad__commit:hover { background: var(--paper-2); }
.notepad__toggle:focus-visible, .notepad__commit:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
/* the toggle's label reflects the pane's mode (::before, so it stays right if state is reset externally) */
.notepad__toggle::before { content: "◑ source"; }
.notepad[data-mode="source"] .notepad__toggle::before { content: "◐ rendered"; }
/* Commit only makes sense while editing the source; hidden in rendered mode */
.notepad__commit { display: none; }
.notepad[data-mode="source"] .notepad__commit { display: inline-flex; align-items: center; }

/* the two panes share the scroll area; only one shows at a time (driven by data-mode) */
.notepad__body, .notepad__source { flex: 1 1 0; min-height: 0; overflow: auto; scroll-behavior: smooth;
  padding: var(--space-3); }
.notepad[data-mode="source"] .notepad__body { display: none; }
.notepad:not([data-mode="source"]) .notepad__source { display: none; }

/* RENDERED: the notes. Each entry is one markdown block; AI entries grade grain, human clean. */
.notepad__body { display: flex; flex-direction: column; gap: var(--space-3); }
.notepad__body:empty::after,
.notepad__body:not(:has(.notepad__entry))::after {
  content: "Nothing noted yet."; color: var(--ink-muted); font-style: italic; }
/* min-width:0 lets the entry shrink below its content's intrinsic width (a flex child won't by
   default), and overflow-wrap breaks a long unbroken token instead of forcing the whole pane wide —
   together they stop a long AI line or pasted URL from giving the panel a horizontal scrollbar. */
.notepad__entry { min-width: 0; overflow-wrap: anywhere; }
/* a code block is the one thing that shouldn't hard-wrap — let it scroll on its own, capped to width */
.notepad__entry pre { max-width: 100%; overflow-x: auto; }
/* --color-grain was referenced here and defined nowhere, in this one line and no other in the
   estate, so the fallback was the only thing that ever rendered. There is no "grain colour" to
   point it at either: the grade vocabulary is carried by the FONT (--font-grain / --type-font),
   not by a hue. So the phantom goes and the fallback becomes the rule it always was. */
.notepad__entry[data-grade="grain"] { color: var(--color-muted); }
.notepad__entry > :first-child { margin-top: 0; }
.notepad__entry > :last-child { margin-bottom: 0; }
.notepad__entry a { color: var(--color-accent); text-decoration: none; }
.notepad__entry a:hover { text-decoration: underline; }
.notepad__entry code { font-family: var(--font-grain); font-size: 0.9em; }

/* SOURCE: the human's editable markdown. Clean ink (human authorship); mono for source legibility. */
.notepad__source { display: block; width: 100%; resize: none; box-sizing: border-box;
  background: var(--color-bg); border: 0; outline: none;
  color: var(--color-fg); font-family: var(--font-grain); font-size: var(--text-sm); line-height: 1.6; }
/* WCAG 2.4.7: the borderless source still owes a visible focus ring when reached by keyboard. */
.notepad__source:focus-visible { outline: 2px solid var(--color-accent); outline-offset: -2px; }
.notepad__source::placeholder { color: var(--ink-muted); }

@media (prefers-reduced-motion: reduce) { .notepad__body, .notepad__source { scroll-behavior: auto; } }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/organisms/presentation/presentation.css */
/* grain/components/organisms/presentation: the PRESENTATION organism.
 *
 * A deck: full-bleed slides, staged fragments, a fit-to-box pass, an overview grid, present mode,
 * a speaker-notes strip, a synced presenter window, and a print sheet. It knows nothing about any
 * particular talk: the app owns the slides and every figure on them.
 *
 * Sizing is one number. `--u0` is derived from the deck's own box with container query units, and
 * `--u` is that times `--fit`, the shrink factor the script computes per slide. Every size below is
 * a multiple of `--u`, so a slide that is too tall scales as one piece instead of reflowing.
 *
 * Behaviour: grain/scripts/presentation.js. Opt a deck in with `data-deck` on the root; without
 * the script the markup still renders whichever slide carries `data-current`, which is what the
 * static export and a print run get.
 */

.presentation {
  container-type: size;
  /* tuned for a deck that owns the whole window. A deck docked in a pane can raise this. */
  --u0: min(0.86cqw, 1.44cqh);
  --fit: 1;
  --u: calc(var(--u0) * var(--fit));
  position: relative;
  height: 100%;
  min-height: 0;
  overflow: hidden;
  background: var(--color-bg);
  color: var(--color-fg);
}


.presentation[data-present] {
  position: fixed; inset: 0; z-index: var(--z-presentation, 8000);
  height: 100dvh;
}


.presentation__slide {
  position: absolute; inset: 0;
  display: none;
  flex-direction: column; justify-content: center;
  
  padding: calc(var(--u) * 8) calc(var(--u) * 9) calc(var(--u) * 8);
  box-sizing: border-box; overflow: hidden;
  
  --u: calc(var(--u0) * var(--fit, 1));
}
.presentation__slide[data-current] { display: flex; }


.presentation__body { display: flex; flex-direction: column; width: 100%; min-width: 0; }
.presentation__slide[data-align="top"] { justify-content: flex-start; }
.presentation__slide[data-align="center"] { align-items: center; text-align: center; }
.presentation__slide[data-align="center"] .presentation__body { align-items: center; text-align: center; }



.presentation .slide-eyebrow {
  font-size: calc(var(--u) * 1.55); letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--color-muted); margin: 0 0 calc(var(--u) * 3);
}
.presentation .slide-head {
  font-size: calc(var(--u) * 6.2); line-height: var(--leading-tight); letter-spacing: -0.015em;
  font-weight: var(--font-weight-regular); margin: 0; max-width: 20ch;
}
.presentation .slide-head[data-size="sm"] { font-size: calc(var(--u) * 4.4); max-width: 26ch; }
.presentation .slide-head[data-size="xl"] { font-size: calc(var(--u) * 8.4); max-width: 15ch; }
.presentation .slide-lede {
  font-size: calc(var(--u) * 2.5); line-height: 1.45; color: var(--color-muted);
  max-width: 46ch; margin: calc(var(--u) * 4) 0 0;
}
.presentation .slide-lede[data-tone="loud"] { color: var(--color-fg); }
.presentation .slide-rule { border: 0; border-top: var(--rule); margin: calc(var(--u) * 3.5) 0 0;
  max-width: calc(var(--u) * 60); }
.presentation .slide-sig { font-size: calc(var(--u) * 1.9); color: var(--color-muted); margin: calc(var(--u) * 3) 0 0; }
.presentation .slide-sig b { color: var(--color-fg); font-weight: var(--font-weight-regular); }


.presentation .quote { font-size: calc(var(--u) * 6.6); line-height: 1.15; letter-spacing: -0.01em;
  max-width: 22ch; margin: 0; text-wrap: balance; }
.presentation .quote-mark { display: block; font-size: calc(var(--u) * 2); color: var(--color-muted);
  letter-spacing: 0.14em; text-transform: uppercase; margin-bottom: calc(var(--u) * 3); }


.presentation .beats { list-style: none; margin: calc(var(--u) * 4) 0 0; padding: 0;
  display: flex; flex-direction: column; gap: calc(var(--u) * 2.4); }
.presentation .beats li { font-size: calc(var(--u) * 3.4); line-height: 1.25; max-width: 30ch; }
.presentation .beats[data-size="sm"] li { font-size: calc(var(--u) * 2.6); }

.presentation .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }



.presentation__slide[data-current] { animation: pres-in 0.34s cubic-bezier(0.22, 0.61, 0.36, 1) both; }
.presentation[data-dir="back"] .presentation__slide[data-current] { animation-name: pres-in-back; }
@keyframes pres-in      { from { opacity: 0; transform: translateX(calc(var(--u) * 2.2)); } }
@keyframes pres-in-back { from { opacity: 0; transform: translateX(calc(var(--u) * -2.2)); } }


/* No type swap on arrival. An earlier build faded and re-faced every landing slide for a third of
   a second, and from the back of a room that reads as a rendering glitch rather than a signal. The
   script still sets [data-settling] on arrival, so a consumer that wants the effect can style it.
   Deliberate grain, on [data-grade="grain"], is untouched and is the honest signal. */

.presentation__slide [data-grade="grain"] { --type-font: var(--font-grain); }



.presentation .frag { opacity: 0; transition: opacity 0.32s ease, transform 0.32s ease; }
.presentation .frag.is-on { opacity: 1; transform: none; }
.presentation .frag[data-frag="rise"], .presentation .frag:not([data-frag]) { transform: translateY(calc(var(--u) * 0.9)); }
.presentation .frag[data-frag="fade"] { transform: none; }
.presentation .frag[data-frag="grow"] { transform: scaleX(0.82); transform-origin: left center; }
.presentation .frag[data-frag="dim"].is-on { opacity: 0.55; }


.presentation [data-draw] {
  stroke-dasharray: var(--draw-len, 1000);
  stroke-dashoffset: var(--draw-len, 1000);
  transition: stroke-dashoffset 0.9s cubic-bezier(0.4, 0, 0.2, 1);
}
.presentation [data-draw].is-drawn { stroke-dashoffset: 0; }

.presentation [data-lit] { opacity: 0; transition: opacity 0.4s ease 0.35s; }
.presentation [data-lit].is-drawn { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .presentation__slide[data-current] { animation: none; }
  .presentation .frag, .presentation [data-draw], .presentation [data-lit] { transition: none; }
  .presentation .frag { transform: none; }
}



.presentation__dots {
  position: absolute; left: calc(var(--u) * 11.7); bottom: calc(var(--u) * 3.4);
  display: flex; gap: 0; align-items: center;
  margin-left: calc((24px - var(--u) * 0.62) / -2);   /* the first dot still lines up with the text */
}
/* WCAG 2.5.8: the button is a 24px target and the dot is drawn inside it, so the row looks the
   same and every dot is still easy to hit. The dot is a control, so it uses muted ink (1.4.11). */
.presentation__dot {
  display: grid; place-items: center; width: max(24px, calc(var(--u) * 1.47)); height: 24px;
  background: transparent; border: 0; padding: 0; cursor: pointer; border-radius: 50%;
}
.presentation__dot::before {
  content: ""; width: calc(var(--u) * 0.62); height: calc(var(--u) * 0.62); border-radius: 50%;
  background: transparent; box-shadow: inset 0 0 0 1.5px var(--ink-muted);   /* unseen: a ring */
  transition: transform 0.24s ease, background 0.24s ease;
}
.presentation__dot[data-seen]::before { background: var(--color-muted); }
.presentation__dot[data-now]::before { background: var(--color-accent); transform: scale(1.9); }
.presentation__dot:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 3px; }


.presentation__bar {
  position: absolute; left: calc(var(--u) * 11.7); right: calc(var(--u) * 11.7); top: calc(var(--u) * 3);
  display: flex; justify-content: space-between; align-items: baseline;
  gap: calc(var(--u) * 3); min-width: 0;
  /* the bar is the FIRST child and a slide is inset:0, so without this the current slide paints
     over the bar and eats its clicks. It still looked fine, because a slide is transparent: the
     link was visible and dead. The dots and controls escape it only by sitting after the slides
     in the DOM. */
  z-index: 1;
}
.presentation__meta, .presentation__hint, .presentation__legend {
  margin: 0; font-size: calc(var(--u) * 1.4); color: var(--ink-muted);
  display: flex; gap: calc(var(--u) * 2.2); align-items: baseline; min-width: 0;
}
.presentation__hint { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.presentation__legend { justify-content: flex-end; flex-wrap: wrap; row-gap: calc(var(--u) * 0.6); }

.presentation__meta { flex: 0 0 auto; }
.presentation__hint kbd { font-family: var(--font-mono); font-size: 0.95em; }
.presentation__legend b, .presentation__meta b { font-weight: var(--font-weight-regular); color: var(--color-muted); }
.presentation__meta a { color: inherit; text-decoration: none; }
.presentation__meta a:hover { color: var(--color-muted); text-decoration: underline; }

.presentation__controls {
  position: absolute; right: calc(var(--u) * 11.7); bottom: calc(var(--u) * 2.4);
  display: flex; align-items: center; gap: calc(var(--u) * 0.8);
}
.presentation__ctl {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: calc(var(--u) * 3.4); height: calc(var(--u) * 3.4); padding: 0 calc(var(--u) * 1.1);
  background: transparent; border: 1px solid var(--color-line); border-radius: var(--radius-sm);
  color: var(--color-muted); font: inherit; font-size: calc(var(--u) * 1.5); line-height: 1; cursor: pointer;
  transition: border-color 0.18s ease, color 0.18s ease, background 0.18s ease;
}
.presentation__ctl:hover { border-color: var(--hairline); color: var(--color-fg); background: var(--paper-2); }
.presentation__ctl:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.presentation__ctl[aria-pressed="true"] { border-color: var(--color-accent); color: var(--color-accent); }
.presentation__ctl:disabled { opacity: 0.3; cursor: default; }
.presentation__ctl[data-wide] { font-family: var(--font-mono); font-size: calc(var(--u) * 1.35);
  letter-spacing: 0.04em; border-color: transparent; }
.presentation__ctl[data-wide]:hover { background: transparent; border-color: transparent; }


.presentation[data-present] .presentation__hint { display: none; }


[data-present-toggle] .on-present { display: none; }
.presentation[data-present] [data-present-toggle] .off-present { display: none; }
.presentation[data-present] [data-present-toggle] .on-present { display: inline; }


.presentation__grid {
  position: absolute; inset: 0; overflow: auto; padding: calc(var(--u) * 4);
  display: none; grid-template-columns: repeat(auto-fill, minmax(calc(var(--u) * 22), 1fr));
  gap: calc(var(--u) * 2); background: var(--color-bg); align-content: start;
}
.presentation[data-view="grid"] .presentation__slide { display: none; }
.presentation[data-view="grid"] .presentation__grid { display: grid; }
.presentation__cell {
  border: 1px solid var(--color-line); border-radius: var(--radius-md);
  padding: calc(var(--u) * 1.8); text-align: left; background: transparent;
  font: inherit; color: inherit; cursor: pointer; transition: border-color 0.2s ease;
}
.presentation__cell:hover { border-color: var(--hairline); }
.presentation__cell[data-now] { border-color: var(--color-accent); }
.presentation__n { font-family: var(--font-mono); font-size: calc(var(--u) * 1.3); color: var(--color-muted); }
.presentation__t { font-size: calc(var(--u) * 1.7); margin-top: calc(var(--u) * 0.8); line-height: 1.25; }



.presentation__notes {
  position: absolute; left: 0; right: 0; bottom: 0; display: none;
  background: var(--panel); border-top: 1px solid var(--color-line);
  padding: calc(var(--u) * 2) calc(var(--u) * 11.7); max-height: 32cqh; overflow: auto;
}
.presentation[data-notes] .presentation__notes { display: block; }
.presentation__notes li { font-size: calc(var(--u) * 1.7); line-height: 1.5; color: var(--color-muted); }
.presentation__slide .slide-notes { display: none; }   


.presenter { display: none; }
[data-mode="presenter"] .presenter {
  display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: auto 1fr;
  align-content: start; gap: var(--space-6); padding: var(--space-6); min-height: 100dvh;
  box-sizing: border-box; font-size: 1rem;
}
.presenter__now { grid-column: 1 / -1; display: flex; justify-content: space-between;
  align-items: baseline; border-bottom: var(--rule); padding-bottom: var(--space-3); }
.presenter__clock { font-family: var(--font-mono); font-size: 2.6rem; }
.presenter__clock[data-over] { color: var(--color-accent); }
.presenter__pos { font-family: var(--font-mono); color: var(--color-muted); }
.presenter__title { font-size: 1.8rem; margin: 0 0 var(--space-3); }
.presenter__notes li { font-size: 1.15rem; line-height: 1.55; margin-bottom: 0.55rem; }
.presenter__next h3 { font-size: 0.95rem; letter-spacing: 0.12em; text-transform: uppercase;
  color: var(--color-muted); margin: 0 0 var(--space-2); }
.presenter__next p { color: var(--color-muted); }
.presenter__hint { grid-column: 1 / -1; margin: 0; font-size: 0.85rem; color: var(--ink-muted);
  letter-spacing: 0.08em; text-transform: uppercase; }



@media print {
  .presentation {
    position: static; height: auto; overflow: visible; container-type: normal;
    --u0: min(1.05vw, 1.75vh);
  }
  .presentation__dots, .presentation__meta, .presentation__hint, .presentation__legend,
  .presentation__notes, .presentation__grid, .presentation__controls, .presentation__bar { display: none !important; }
  .presentation__slide {
    display: flex !important; position: relative; inset: auto;
    width: 100%; height: 100vh; box-sizing: border-box;
    
    padding: calc(var(--u) * 7) calc(var(--u) * 9);
    page-break-after: always; break-after: page; animation: none !important;
  }
  .presentation__slide:last-of-type { page-break-after: auto; break-after: auto; }
  .presentation .frag { opacity: 1 !important; transform: none !important; }
  .presentation [data-draw] { stroke-dashoffset: 0 !important; }
  .presentation [data-lit] { opacity: 1 !important; }
  
  body::before { display: none !important; }
}

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/organisms/side-rail/side-rail.css */
/* organisms/side-rail — the vertical navigation rail inside the app-shell: a brand at the
   top, nav-items, a spacer, then footer items (settings). The rail draws a shared ICON
   GUTTER (a fixed column + a hairline) so every item's glyph sits in its own vertical
   column beside the labels — the editor-workspace look. Collapses to icons-only when the
   shell carries data-rail-collapsed (labels are dropped, the gutter line goes). Composed
   with b-icon + nav-item; a layout class, not a data-bound tag. */
.side-rail {
  position: relative;
  display: flex; flex-direction: column; gap: 2px;
  height: 100%; padding: var(--space-3) var(--space-2);
  overflow: hidden;
}
/* the gutter line — one hairline the full height of the rail, at the icon column's edge */
.side-rail::before {
  content: ""; position: absolute; top: 0; bottom: 0;
  left: calc(var(--space-2) + var(--rail-icon-col, 2.5rem));
  width: 1px; background: var(--color-line);
}
.side-rail__brand {
  display: grid; grid-template-columns: var(--rail-icon-col, 2.5rem) 1fr;
  align-items: center; column-gap: var(--space-2);
  padding: var(--space-2) 0 var(--space-4);
  font-family: var(--font-accent); letter-spacing: 0.02em;
  white-space: nowrap;
}
.side-rail__brand > b-icon, .side-rail__brand > .icon { justify-self: center; }
/* the brand mark carries the theme's accent — hueless under Sourdough, a hue under Baguette/
   Brioche (the accent reaches the brand + the presence star; DESIGN-SYSTEM §2). */
.side-rail__brand > b-icon { color: var(--color-accent); }
.side-rail__spacer { flex: 1 1 auto; }

/* ---- OPTIONAL: a section heading between runs of items — for a rail with more destinations
   than a flat list reads well as. Not a `__group` (that's a collapsible <details>); this only
   separates. Indented past the gutter so it aligns with the LABELS, not the glyph column. ---- */
.side-rail__label {
  padding: var(--space-4) 0 var(--space-1) calc(var(--rail-icon-col, 2.5rem) + var(--space-2));
  font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 0.06em;
  color: var(--color-muted); white-space: nowrap;
}

/* ---- OPTIONAL: the foot — a mark plus two lines of identity, pinned under the spacer. Grain
   ships the SHAPE only; who it names (an account, a workspace, "signed out") is the consumer's,
   like the topbar's presence label. Same two-column grid as the brand, so the mark lands in the
   gutter and the text lines up with every label above it. ---- */
.side-rail__foot {
  display: grid; grid-template-columns: var(--rail-icon-col, 2.5rem) 1fr;
  align-items: center; column-gap: var(--space-2);
  margin-top: var(--space-2); padding-top: var(--space-3);
  border-top: 1px solid var(--color-line); white-space: nowrap;
}
.side-rail__avatar {
  width: 1.75rem; height: 1.75rem; border-radius: 50%; justify-self: center;
  display: grid; place-items: center;
  background: var(--color-accent-soft); color: var(--color-accent);
  font-size: var(--text-xs); font-weight: var(--font-weight-semibold);
}
.side-rail__foot > b-icon, .side-rail__foot > .icon { justify-self: center; }
/* min-width:0 lets the ellipsis below actually fire — a grid item's default `auto` minimum
   would let a long account name push the rail wider instead of truncating. */
.side-rail__who { min-width: 0; font-size: var(--text-xs); color: var(--color-muted); line-height: 1.2; }
.side-rail__who strong {
  display: block; overflow: hidden; text-overflow: ellipsis;
  font-size: var(--text-sm); font-weight: var(--font-weight-semibold); color: var(--color-fg);
}

/* ---- grouped nav — a collapsible section for nested navigation (e.g. a BREAD group over
   BATCH / GRAIN / MILL). Native <details>/<summary>, no JS: the summary IS a nav-item, the
   sub-items indent under it. Open/closed is the browser's; a rotating chevron shows state. ---- */
.side-rail__group { display: contents; }                 /* the <details> itself adds no box */
.side-rail__group > summary { list-style: none; cursor: pointer; }
.side-rail__group > summary::-webkit-details-marker { display: none; }
.side-rail__group > summary .nav-item__chev { margin-left: auto; color: var(--ink-faint);
  transition: transform 0.2s ease; }
.side-rail__group[open] > summary .nav-item__chev { transform: rotate(90deg); }
.side-rail__sub { display: flex; flex-direction: column; gap: 2px; }
/* iconless children: skip the gutter, align labels with the parent's label column */
.side-rail__sub .nav-item {
  grid-template-columns: 1fr auto;
  padding-left: calc(var(--rail-icon-col, 2.5rem) + var(--space-2));
}

/* ---- collapsed: drop the labels + the gutter line, center the glyphs ---- */
[data-rail-collapsed="true"] .side-rail::before { display: none; }
[data-rail-collapsed="true"] .side-rail__brand span,
[data-rail-collapsed="true"] .nav-item__label { display: none; }
[data-rail-collapsed="true"] .nav-item { grid-template-columns: var(--rail-icon-col, 2.5rem); }
[data-rail-collapsed="true"] .side-rail__brand { grid-template-columns: var(--rail-icon-col, 2.5rem); }
[data-rail-collapsed="true"] .side-rail__group > summary .nav-item__chev { display: none; }
[data-rail-collapsed="true"] .side-rail__sub .nav-item { padding-left: 0; }
/* the optional pieces collapse the same way: text goes, glyphs stay. A section label is ONLY
   text, so it goes entirely; the foot keeps its mark (the one thing still legible at 3.25rem). */
[data-rail-collapsed="true"] .side-rail__label { display: none; }
[data-rail-collapsed="true"] .side-rail__who { display: none; }
[data-rail-collapsed="true"] .nav-item__count { display: none; }
[data-rail-collapsed="true"] .side-rail__foot { grid-template-columns: var(--rail-icon-col, 2.5rem); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/organisms/sidebar-panel/sidebar-panel.css */
/* organisms/sidebar-panel — the workspace's right-hand panel that fills `.app-shell__aside`: a
   header / scrolling body / composer column. Its default use is the ASSISTANT (chat). During an AI
   takeover (the shell raises `data-acting`) it slides away so the console can narrate; on mobile it
   becomes a bottom sheet. Persona-neutral — the consumer supplies any label/name. CSS-only pattern:
   compose `.assistant` inside `.app-shell__aside`; see sidebar-panel.md. Re-skins via tokens.
   (Loads after app-shell.css in the sorted bundle, so its aside overrides win.) */

/* the panel (assistant form): header · scrolling log · composer */
.assistant { display: flex; flex-direction: column; height: 100%; }
.assistant__head { display: flex; align-items: center; justify-content: space-between; padding: 0 var(--space-4);
  min-height: var(--editor-header-h, 2.9rem); box-sizing: border-box;   /* aligns with the topbar/tab row (one line) */
  border-bottom: 1px solid var(--color-line); font-size: var(--text-sm); text-transform: uppercase; letter-spacing: 0.06em; color: var(--color-muted); }
/* OPTIONAL MODES: a panel may carry several PANES (chat / a catalog / …) switched by small tabs
   in the head. Markup: `.assistant[data-mode="…"]` whose head holds `.assistant__modes` (buttons
   `[data-shell-mode="…"]`), with sibling `.assistant__pane[data-pane="…"]` columns — the inactive
   ones `[hidden]` (SSR sets the initial state; shell.js flips data-mode / hidden / aria-selected).
   Value-agnostic: the consumer names the modes. A panel with no panes is untouched. */
.assistant__modes { display: flex; gap: var(--space-1); }
.assistant__modes [data-shell-mode] { background: transparent; border: 1px solid transparent;
  border-radius: var(--radius-sm); padding: 0 var(--space-2); font: inherit; color: var(--color-muted);
  cursor: pointer; }
.assistant__modes [data-shell-mode][aria-selected="true"] { color: var(--color-fg);
  border-color: var(--color-line); background: var(--color-surface); }
.assistant__pane { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.assistant__pane[hidden] { display: none; }

.assistant__log { flex: 1; overflow: auto; padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3);
  scroll-behavior: smooth; }
.assistant__log > :first-child { margin-top: auto; }   /* bottom-pin the conversation — no mid-panel dead space */
.assistant__composer { border-top: 1px solid var(--color-line); padding: var(--space-3); display: flex; gap: var(--space-2); }
.assistant__composer .field__input { flex: 1; font: inherit; font-size: var(--text-sm); color: var(--color-fg);
  background: var(--color-bg); border: 1px solid var(--color-line); border-radius: var(--radius-md); padding: var(--space-2) var(--space-3); }
.assistant__composer .field__input::placeholder { color: var(--ink-muted); }

/* TAKEOVER: the shell raises `data-acting` (grain/scripts/ai-dispatch.js). The terminal (console)
   narrates the AI's THINKING; the chat is for COMMUNICATION — the two are separate concerns and
   both stay visible during a run (owner, 2026-07-05: the chat no longer collapses when the AI
   works). The panel just lifts above the spotlight veil so it stays usable mid-run — a click in
   here is not an interrupt (ai-dispatch ignores aside/console clicks). */
.app-shell[data-acting="true"] .app-shell__aside { position: relative; z-index: var(--z-ai-surface, 9002); }

/* mobile (<768px): the panel becomes a bottom SHEET. The shell drops the aside column, so re-show it
   fixed to the bottom, collapsed to its header as a grab bar; tap the header to raise the full panel.
   `@container shell-frame`, not `@media` (app-shell.css establishes the container on `<body>` and
   converts its own layout breakpoints the same way — this one has to move in lockstep, or the
   viewport-toggle would hide the aside per app-shell.css's mobile block without this bottom-sheet
   override ever kicking in to replace it, since its old `@media` never fired off the toggle's
   clamp). Every selector here targets a descendant of body (`.app-shell__aside`, `.assistant__head`),
   never body itself, so the container-query self-query rule doesn't block this file. */
@container shell-frame (max-width: 768px) {
  .app-shell__aside {
    display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-ai-surface, 9002);
    height: 85dvh; opacity: 1;
    transform: translateY(calc(100% - 2.75rem));           /* collapsed: the head peeks as a bar */
    border: 0; border-top: 1px solid var(--ink); border-radius: var(--radius-md) var(--radius-md) 0 0;
  }
  .assistant__head { cursor: pointer; }                    /* the grab bar toggles the sheet */
  .assistant__head::after { content: "▴"; color: var(--ink-faint); }
  .app-shell[data-aside-open] .assistant__head::after { content: "▾"; }
  .app-shell[data-aside-open] .app-shell__aside { transform: translateY(0); }
  /* while acting, the floating console is the AI surface — hide the idle sheet unless the chat is
     explicitly opened, then raise it full. */
  .app-shell[data-acting="true"] .app-shell__aside { transform: translateY(100%); }
  .app-shell[data-acting="true"][data-chat-open] .app-shell__aside { transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
  .app-shell__aside { transition: none; }
  .assistant__log { scroll-behavior: auto; }
}

/* WCAG 2.4.11 (focus not obscured): the collapsed sheet peeks over the page bottom on a narrow
   frame, so the page's scrollers keep a focused element clear of that strip. */
@container shell-frame (max-width: 768px) {
  .app-shell__main { scroll-padding-bottom: 3.75rem; }
}

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/organisms/status-bar/status-bar.css */
/* organisms/status-bar — the editor-style STATUS ROW at the bottom of the app window:
   quiet mono meta-text (presence, build info) left, small controls (theme cycle, scheme
   toggle, ⌘K hint) right. CSS-only pattern — place a `.status-bar` in the app-shell's
   `.app-shell__status` grid row (app-shell.css owns the row; see status-bar.md).
   Controls are STATUS, not content: buttons here unstyle to plain text and only sharpen
   on hover/focus. Tokens only. */
.status-bar {
  display: flex; align-items: center; gap: var(--space-5);
  padding: 0.3rem 0.9rem;
  background: var(--color-surface);
  border-top: 1px solid var(--color-line);
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  color: var(--color-muted);
  white-space: nowrap;
}
.status-bar__spacer { flex: 1; }
.status-bar button {
  all: unset;                    /* a status control reads as text, not a button */
  cursor: pointer;
  font: inherit; color: inherit;
}
.status-bar button:hover { color: var(--color-fg); }
.status-bar button:focus-visible { outline: 1px solid var(--color-accent); outline-offset: 2px; }

/* narrow screens: keep presence + controls, let the middle meta-text go. `@container shell-frame`
   (app-shell.css establishes the container on `<body>`) so this fires off BOTH a real narrow
   window and the shell's viewport-toggle clamp, not just the real one — `.status-bar` is a
   descendant of body, never body itself, so it's legal to query here (see app-shell.css). */
@container shell-frame (max-width: 768px) {
  .status-bar { gap: var(--space-4); }
  .status-bar [data-status-optional] { display: none; }
}

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/organisms/tab-bar/tab-bar.css */
/* organisms/tab-bar — the top strip of open views inside the app-shell topbar. A
   horizontal, scrollable row of tabs; the page places controls (⌘K, presence) after it in
   the topbar. A layout class composed with tab. flex:1 so it fills the topbar's remaining
   width — that's what pins .tab-bar__close-all to the topbar's true far edge instead of
   floating right after the last tab with empty space beyond it. */
.tab-bar {
  display: flex; align-items: stretch; min-width: 0; flex: 1 1 auto;
  overflow-x: auto; scrollbar-width: none;
  /* scroll affordance: fade the far edge so hidden tabs read as "more to scroll"
     rather than a hard clip. Tabs are flex:none and left-packed, so when the
     strip is NOT overflowing the faded edge sits over empty space (invisible);
     it only bites a real tab once the row overflows. */
  mask-image: linear-gradient(to right, currentColor calc(100% - 1.5rem), transparent);
}
.tab-bar::-webkit-scrollbar { display: none; }
/* Where scroll-driven animation is supported, upgrade to both edges tracking the
   actual scroll position: right-only at the start, both in the middle, left-only
   at the end. Unsupported browsers keep the static right fade above. */
@supports (animation-timeline: scroll()) {
  .tab-bar { animation: tab-bar-edges linear both; animation-timeline: scroll(self inline); }
  @keyframes tab-bar-edges {
    from { mask-image: linear-gradient(to right, currentColor, currentColor calc(100% - 1.5rem), transparent); }
    50%  { mask-image: linear-gradient(to right, transparent, currentColor 1.5rem, currentColor calc(100% - 1.5rem), transparent); }
    to   { mask-image: linear-gradient(to right, transparent, currentColor 1.5rem, currentColor); }
  }
}
@media (prefers-reduced-motion: reduce) { .tab-bar { animation: none; } }

/* the close-all affordance — a small TAB-SHAPED cell pinned past the scrollable strip (a sibling
   in the topbar, never a child of .tab-bar), so it never scrolls out of reach no matter how many
   tabs are open. Same height as .tab for one continuous band; a left hairline (mirrors .tab's own
   border-right) reads it as its own cell, distinct from each tab's individual .tab__close.
   tabs.js toggles [hidden] when nothing closable is open (pinned tabs don't count) — `all: unset`
   resets `display` to its initial value (inline) UNCONDITIONALLY, and author CSS always beats
   the UA `[hidden]{display:none}` rule regardless of specificity, so the base rule alone already
   overrode `hidden` (the button showed, just un-flexed) — `[hidden]{display:none}` below is the
   fix: a class+attribute selector out-specifies the plain class rule, restoring `hidden`. */
.tab-bar__close-all {
  all: unset; box-sizing: border-box; cursor: pointer;
  display: flex; flex: none; align-items: center; justify-content: center;
  height: 2.5rem; width: 2.25rem;
  border-left: 1px solid var(--color-line);
  color: var(--color-muted);
}
.tab-bar__close-all[hidden] { display: none; }
.tab-bar__close-all:hover { color: var(--color-fg); background: color-mix(in srgb, var(--color-bg) 55%, transparent); }
.tab-bar__close-all:focus-visible { outline: 1px solid var(--color-accent); outline-offset: -2px; }
.tab-bar__close-all .icon { width: 0.85rem; height: 0.85rem; }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/organisms/timeline/timeline.css */
/* organisms/timeline — the INTERACTION TIMELINE: a read-only, push-only feed of every crossing of
   the one door, human AND AI, recorded identically at the single writer (AI-INTERFACE §5g). The AI
   only ever WRITES to it (`log` render ops at the `timeline` surface); nothing acts on it, so it's a
   push surface, never a SurfaceKind. Provenance is shown the GRAIN way — by GRADE, not a hue: an AI
   crossing renders in the grain font behind a dashed "terminal" edge (grain = AI); a human crossing
   in the smooth font behind a solid edge; a system rejection stays faint. Tokens only. CSS-only
   pattern (no .html): compose the markup below inside your page; see timeline.md. */

.timeline { display: flex; flex-direction: column; min-height: 0;
  border: 1px solid var(--color-line); border-radius: var(--radius-md); background: var(--color-bg); }

.timeline__head { display: flex; align-items: baseline; gap: var(--space-3); flex: 0 0 auto;
  padding: var(--space-2) var(--space-4); border-bottom: 1px solid var(--color-line);
  font-family: var(--font-grain); font-size: var(--text-sm); color: var(--color-fg); }
.timeline__hint { color: var(--color-muted); font-size: var(--text-xs); }

/* the scrolling feed — the `data-surface="timeline"` element `log` ops land in. It sizes to its
   content and CAPS at 18rem, scrolling inside there (pinned to newest by the dispatcher, since
   overflowY = auto). `flex: 0 1 auto` (not `1 1 0`): this component sits in normal document flow,
   so the feed must grow with its rows — a `1 1 0` basis would collapse it to a sliver here (that
   fill-the-row behaviour is for a fixed-height parent like the docked console). */
.timeline__feed { list-style: none; margin: 0; padding: var(--space-2) var(--space-3);
  display: flex; flex-direction: column; gap: var(--space-1);
  flex: 0 1 auto; min-height: 0; max-height: 18rem; overflow: auto; scroll-behavior: smooth; }
.timeline__feed:empty::after { content: "No crossings yet — ask the AI, or watch it act.";
  color: var(--ink-muted); font-family: var(--font-grain); font-size: var(--text-sm); padding: var(--space-2); }

/* one crossing. The left edge carries the provenance; the row reads verb + detail. */
.timeline__entry { display: flex; align-items: baseline; gap: var(--space-2);
  padding: var(--space-1) var(--space-2); padding-left: var(--space-3);
  border-left: 3px solid var(--color-accent); font-size: var(--text-sm); color: var(--color-fg); }

/* grade = provenance (DESIGN-SYSTEM §3): AI in the grain font behind a DASHED terminal edge (non-text
   grain = dashed edge); the human in the smooth font behind a solid edge; a system rejection faint. */
.timeline__entry[data-provenance="user"]   { font-family: var(--font-smooth); }
.timeline__entry[data-provenance="ai"]      { font-family: var(--font-grain); border-left-style: dashed; }
.timeline__entry[data-provenance="system"]  { font-family: var(--font-grain); border-left-color: var(--ink-faint); color: var(--ink-muted); }

/* a failed crossing (rejected request / rolled-back write) reads as struck through, still legible. */
.timeline__entry[data-ok="false"] { color: var(--ink-muted); }
.timeline__entry[data-ok="false"] .timeline__verb { text-decoration: line-through; }

.timeline__who { flex: 0 0 auto; min-width: 2.6rem; text-transform: uppercase;
  letter-spacing: 0.06em; font-size: var(--text-xs); color: var(--color-muted); }
.timeline__mark { flex: 0 0 auto; color: var(--color-accent); }
.timeline__entry[data-ok="false"] .timeline__mark { color: var(--ink-muted); }
.timeline__verb { flex: 0 0 auto; color: var(--color-fg); }
.timeline__detail { flex: 1 1 auto; min-width: 0; color: var(--color-muted);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

@media (prefers-reduced-motion: reduce) { .timeline__feed { scroll-behavior: auto; } }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/node_modules/@tjakoen/grain/components/organisms/topbar/topbar.css */
/* organisms/topbar — the app-shell top bar's CONTENT: a right-aligned control cluster (the theme +
   light/dark toggles, ⌘K, a presence indicator). CSS-only pattern — place a `.topbar-ctl` inside the
   shell's `.app-shell__topbar` region (the shell owns the bar's grid slot + border, app-shell.css).
   Persona-neutral: the toggles are grain `.icon-btn`s carrying the theming controls
   (`data-toggle-scheme`, `data-cycle-theme` — see grain/scripts/theme.js). Consumers supply the
   brand/presence text. See topbar.md. */
.topbar-ctl { display: flex; align-items: center; gap: var(--space-3); margin-left: auto; padding: 0 var(--space-4); }
.presence { display: inline-flex; align-items: center; gap: 0.35rem; color: var(--color-muted); font-size: var(--text-sm); }

/* ---- the LEFT slot: WHERE you are (a breadcrumb trail, a record title). Pairs with
   `.topbar-ctl`'s `margin-left: auto` with no change to it — put both in the region and the
   crumbs sit left, the controls right. `min-width: 0` so a long trail truncates instead of
   pushing the control cluster off the bar. ---- */
.topbar-crumbs {
  display: flex; align-items: center; gap: var(--space-2); min-width: 0;
  padding: 0 var(--space-4); font-size: var(--text-sm); color: var(--color-muted);
}
.topbar-crumbs strong { color: var(--color-fg); font-weight: var(--font-weight-semibold); }
.topbar-crumbs a { color: var(--color-muted); }
.topbar-crumbs a:hover { color: var(--color-fg); }

/* ---- a search / filter box for the bar. GRAIN SHIPS THE BOX AND NOTHING ELSE: whether typing
   in it filters rows on the page, queries a server, or opens a palette is the consumer's, wired
   to the input. That split is deliberate — a component that LOOKS like it searches but ships no
   search is a contract that fails silently (CLAUDE.md lesson 3), so the promise here is only
   "an input that belongs in a topbar". ---- */
.topbar-search { position: relative; flex: 1 1 auto; max-width: 22rem; align-self: center; }
.topbar-search input {
  width: 100%; font: inherit; font-size: var(--text-sm);
  padding: var(--space-2) var(--space-3) var(--space-2) 1.9rem;
  border: 1px solid var(--color-line); border-radius: var(--radius-md);
  background: var(--color-bg); color: var(--color-fg);
}
.topbar-search input:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 1px; }
.topbar-search::before {
  content: "⌕"; position: absolute; left: var(--space-2); top: 50%; transform: translateY(-50%);
  color: var(--color-muted); pointer-events: none;
}

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/atoms/flag/flag.css */
/* atoms/flag — portfolio: a small status flag for a work ("Planned", "Demo"). Uppercase
   whisper in a dashed pill. Portfolio-owned on purpose: inside GRAIN the dashed edge means
   "AI / in-transit" — a static flag borrowing it belongs to the site's voice, not the
   design system's vocabulary. */
.flag {
  display: inline-block; margin-top: var(--space-3);
  font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 0.1em;
  color: var(--color-muted);
  border: 1px dashed var(--ink); border-radius: var(--radius-pill);
  padding: 0 var(--space-2);
}

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/molecules/cv-chip/cv-chip.css */
/* molecules/cv-chip — a headline skill pill (display only, accent). Scoped to the résumé + About
   screens; bundled into /components.css. Parent .cv-core owns the flex-wrap layout. Print flattening
   (pill -> plain comma text) lives in components/pages/resume/resume.css (@media print). */
:is([data-screen="resume"], [data-screen="about"]) .cv-core { list-style: none; margin: var(--space-2) 0 var(--space-4);
  padding: 0; display: flex; flex-wrap: wrap; gap: var(--space-2); }
:is([data-screen="resume"], [data-screen="about"]) .cv-chip { font-size: var(--text-xs); font-family: var(--font-mono);
  color: var(--color-accent); border: 1px solid var(--color-accent); border-radius: var(--radius-pill, 999px);
  padding: var(--space-1) var(--space-3); line-height: 1.4; }
/* the chip's label is a nested <a>: it carries an href only when the skill has evidence in cv.json.
   With an href it reads as a link (inherits the accent, underlines on hover + focus); without one the
   renderer omits the attribute, so :not([href]) keeps a plain, unclickable pill. */
:is([data-screen="resume"], [data-screen="about"]) .cv-chip__link { color: inherit; text-decoration: none; }
:is([data-screen="resume"], [data-screen="about"]) .cv-chip__link[href]:is(:hover, :focus-visible) { text-decoration: underline; }
:is([data-screen="resume"], [data-screen="about"]) .cv-chip:has(.cv-chip__link[href]) { border-style: solid; }
:is([data-screen="resume"], [data-screen="about"]) .cv-chip:has(.cv-chip__link:not([href])) { border-style: dashed; opacity: 0.85; }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/molecules/cv-entry/cv-entry.css */
/* molecules/cv-entry — a résumé/education timeline entry. Scoped to the two screens it appears on
   (résumé page + About's CV tab; /cv is the résumé screen too) so nothing leaks. Bundled into
   /components.css via config.styleRoots. The collapse is a screen-only enhancement: no JS -> the
   detail is open; the cv-accordion island adds .is-collapsed to hide it behind the summary. The
   print-flattening lives in components/pages/resume/resume.css (@media print), not here. */
:is([data-screen="resume"], [data-screen="about"]) .cv-list { list-style: none; margin: var(--space-3) 0 var(--space-5); padding: 0;
  display: flex; flex-direction: column; gap: var(--space-4); }
:is([data-screen="resume"], [data-screen="about"]) .cv-entry { border: 1px solid var(--line-soft); border-radius: var(--radius-md);
  padding: var(--space-4); background: var(--paper-2); scroll-margin-top: var(--space-6); }

:is([data-screen="resume"], [data-screen="about"]) .cv-entry__title { margin: 0 0 var(--space-1); font-size: var(--text-md); }
:is([data-screen="resume"], [data-screen="about"]) .cv-entry__meta { margin: 0; display: flex; flex-wrap: wrap; gap: var(--space-2);
  align-items: baseline; font-family: var(--font-mono); font-size: var(--text-xs); }
:is([data-screen="resume"], [data-screen="about"]) .cv-entry__company { color: var(--ink); }
:is([data-screen="resume"], [data-screen="about"]) .cv-entry__dates { color: var(--ink-muted); }
:is([data-screen="resume"], [data-screen="about"]) .cv-entry__where { margin: var(--space-1) 0 0; font-family: var(--font-mono);
  font-size: var(--text-xs); color: var(--ink-muted); }
:is([data-screen="resume"], [data-screen="about"]) .cv-entry__where:empty { display: none; }
:is([data-screen="resume"], [data-screen="about"]) .cv-entry__summary { margin: var(--space-2) 0 0; color: var(--ink-muted); font-size: var(--text-sm); }
:is([data-screen="resume"], [data-screen="about"]) .cv-entry__summary:empty { display: none; }

/* the toggle is revealed by the island only (hidden attribute in the markup + no-JS) */
:is([data-screen="resume"], [data-screen="about"]) .cv-entry__toggle { margin: var(--space-2) 0 0; padding: 0;
  background: none; border: 0; cursor: pointer; font-family: var(--font-mono); font-size: var(--text-xs);
  color: var(--color-accent); }
:is([data-screen="resume"], [data-screen="about"]) .cv-entry__toggle:hover { text-decoration: underline; }

:is([data-screen="resume"], [data-screen="about"]) .cv-entry__detail { margin: var(--space-2) 0 0; }
:is([data-screen="resume"], [data-screen="about"]) .cv-entry.is-collapsed .cv-entry__detail { display: none; }

/* optional experience photo — the media row renders empty (and hides) when a role has no photo;
   @media print strips it (resume.css) so it never reaches the ATS/PDF sheet */
:is([data-screen="resume"], [data-screen="about"]) .cv-entry__media { margin: 0 0 var(--space-3); }
:is([data-screen="resume"], [data-screen="about"]) .cv-entry__media:empty { display: none; }
:is([data-screen="resume"], [data-screen="about"]) .cv-photo { display: block; max-width: 100%; height: auto;
  border: 1px solid var(--line-soft); border-radius: var(--radius-md); }
:is([data-screen="resume"], [data-screen="about"]) .cv-entry__bullets { margin: 0; padding-left: var(--space-4);
  display: flex; flex-direction: column; gap: var(--space-1); }
:is([data-screen="resume"], [data-screen="about"]) .cv-bullet { color: var(--ink-muted); font-size: var(--text-sm); line-height: var(--leading-relaxed); }

/* related feed/calendar posts under an entry — hides itself when the entry has no links */
:is([data-screen="resume"], [data-screen="about"]) .cv-entry__links { list-style: none; margin: var(--space-2) 0 0; padding: 0;
  display: flex; flex-wrap: wrap; gap: var(--space-2); }
:is([data-screen="resume"], [data-screen="about"]) .cv-entry__links:empty { display: none; }
:is([data-screen="resume"], [data-screen="about"]) .cv-link-item { font-family: var(--font-mono); font-size: var(--text-xs); }
:is([data-screen="resume"], [data-screen="about"]) .cv-link { color: var(--color-accent); text-decoration: none; }
:is([data-screen="resume"], [data-screen="about"]) .cv-link::before { content: "↗ "; }
:is([data-screen="resume"], [data-screen="about"]) .cv-link:hover { text-decoration: underline; }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/molecules/cv-skill/cv-skill.css */
/* molecules/cv-skill — one skill group card in the résumé Skills grid. Scoped to the résumé + About
   screens; bundled into /components.css. The grid wrapper (.cv-skills) is a simple auto-fill of these.
   Print flattening (single column) lives in components/pages/resume/resume.css (@media print). */
:is([data-screen="resume"], [data-screen="about"]) .cv-skills { display: grid; gap: var(--space-3);
  grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); margin: var(--space-3) 0 var(--space-5); }
:is([data-screen="resume"], [data-screen="about"]) .cv-skill { border: 1px solid var(--line-soft);
  border-radius: var(--radius-md); padding: var(--space-4); background: var(--paper-2); }
:is([data-screen="resume"], [data-screen="about"]) .cv-skill__group { margin: 0 0 var(--space-1); font-size: var(--text-sm); }
:is([data-screen="resume"], [data-screen="about"]) .cv-skill__items { margin: 0; font-size: var(--text-sm); color: var(--ink-muted); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/molecules/cv-stat/cv-stat.css */
/* molecules/cv-stat — the About highlights strip. The tile itself is GRAIN's .stat (stat-tile);
   this only adds the row layout (grain's stat-tile.md says the parent owns layout, not a wrapper)
   and hides an empty sub-line. Scoped to the About screen. */
[data-screen="about"] .cv-stats { display: grid; gap: var(--space-3); margin: 0 0 var(--space-5);
  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); }
[data-screen="about"] .cv-stats .stat__sub:empty { display: none; }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/molecules/docs-list/docs-list.css */
/* molecules/docs-list — portfolio: a ruled index of canonical docs (name + what it is).
   A quiet list, not a table: hairline rules between rows, name column fixed. */
.docs-list { margin-top: var(--space-4); }
.docs-list__item { display: flex; align-items: baseline; gap: var(--space-3);
  padding: var(--space-2) 0; border-top: 1px solid var(--color-line); }
.docs-list__name { font-size: var(--text-base); min-width: 12rem; }
.docs-list__what { color: var(--color-muted); font-size: var(--text-sm); }

@media (max-width: 42rem) {
  .docs-list__item { align-items: flex-start; flex-direction: column; gap: var(--space-1); }
  .docs-list__name { min-width: 0; }
}

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/molecules/event-deck/event-deck.css */
/* molecules/event-deck — where a deck, and the video, sit on a content page. The components
   themselves are GRAIN's (`attachment` for the deck row, `media-card` for the video, `doc-frame`
   for the viewer page); a GRAIN molecule owns no outer spacing, so the placement is here, which is
   where a consumer's layout decision belongs. Bundled into /components.css via config.styleRoots.

   Two placements, and they are opposites on purpose:

   - The DECK sits directly under the hero and above the prose, because for a talk the deck is often
     the thing the reader came for, and it used to be a link in the last paragraph.
   - The VIDEO sits below the prose, next to the gallery. It was under the hero, and a full-width
     16:9 poster there reads as the subject of the page and shouts over the writing the page is
     actually made of. Down here it is what it is: another thing from the day. It is also capped, so
     a wide screen does not hand it the whole column. */
.event-deck { margin: 0 0 var(--space-6); }

.event-video { margin: var(--space-7) 0 0; padding: var(--space-5) 0 0;
  border-top: 1px solid var(--line-soft); }
.event-video__heading, .event-gallery__heading { margin: 0 0 var(--space-4);
  font-size: var(--text-xs); font-family: var(--font-mono); text-transform: uppercase;
  letter-spacing: .04em; color: var(--ink-muted); font-weight: 500; }
/* the gallery below already opens with its own rule; two hairlines a screen apart read as a mistake */
.event-video + .event-gallery { border-top: 0; padding-top: var(--space-6); }
.event-video .media-card { max-width: 32rem; }

/* the viewer page (/decks/<file>): the document is the page, so the title stays quiet above it */
.deck-page__title { margin: 0 0 var(--space-4); font-size: var(--text-lg); font-weight: 500; }

.deck-index__list { display: grid; gap: var(--space-3); margin: var(--space-5) 0 0;
  padding: 0; list-style: none; }
.deck-index__list li { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: baseline; gap: var(--space-2);
  padding: var(--space-3) 0; border-top: 1px solid var(--line-soft); }
.deck-index__list span { color: var(--ink-muted); font: var(--text-xs) var(--font-mono); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/molecules/event-gallery/event-gallery.css */
/* molecules/event-gallery — the frame around a /calendar event's secondary photo grid (rendered by
   content.ts renderGallery from the entry's `gallery:` frontmatter). Bundled into /components.css
   via config.styleRoots. Not screen-scoped: it renders inside a MILL entry page's .board, and the
   only thing that puts one there is an event with a `gallery:` key.

   The GRID inside it is GRAIN's `gallery` molecule. This file owns only what is genuinely the
   portfolio's: the rule above it, the spacing that separates it from the prose, and the small
   uppercase heading that matches the share block's. New design work belongs up in grain. */
.event-gallery { margin: var(--space-7) 0 0; padding: var(--space-5) 0 0;
  border-top: 1px solid var(--line-soft); }
.event-gallery__heading { margin: 0 0 var(--space-4); font-size: var(--text-xs);
  font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .04em;
  color: var(--ink-muted); font-weight: 500; }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/molecules/feed-card/feed-card.css */
/* molecules/feed-card — one post in the /calendar social feed. Scoped to the calendar screen; bundled
   into /components.css via config.styleRoots. Parent-context requirement: a direct child of
   <ol class="feed__list">. Images-first, then text; the photo strip, links, location and tags each
   hide when empty (see feed-photo / feed-link css and the :empty rules below). */
[data-screen="calendar"] .feed__list { list-style: none; margin: var(--space-4) 0 0; padding: 0;
  display: flex; flex-direction: column; gap: var(--space-5); }
[data-screen="calendar"] .feed-card { border: 1px solid var(--line-soft); border-radius: var(--radius-md);
  padding: var(--space-4); background: var(--paper);
  scroll-margin-top: var(--space-6); }   /* room for a chip-click scroll-highlight to land clear of the header */

/* a brief ring when a month/week chip scrolls this card into view — the island toggles the class. */
[data-screen="calendar"] .feed-card--highlight { outline: 2px solid var(--color-accent);
  outline-offset: var(--space-2); }

[data-screen="calendar"] .feed-card__body { display: flex; flex-direction: column; gap: var(--space-1); }
[data-screen="calendar"] .feed-card__meta { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2);
  font-size: var(--text-xs); font-family: var(--font-mono); color: var(--ink-muted); margin: 0; }
[data-screen="calendar"] .feed-card__kind { text-transform: uppercase; letter-spacing: .04em; color: var(--ink-muted); }
[data-screen="calendar"] .feed-card__date { color: var(--ink-muted); }
[data-screen="calendar"] .feed-card__loc { color: var(--ink-muted); }
[data-screen="calendar"] .feed-card__loc:empty { display: none; }
[data-screen="calendar"] .feed-card__title { font-size: var(--text-lg); margin: 0; }
[data-screen="calendar"] .feed-card__title a { color: var(--ink); text-decoration: none; }
[data-screen="calendar"] .feed-card__title a:hover { text-decoration: underline; }
[data-screen="calendar"] .feed-card__summary { color: var(--ink-muted); line-height: var(--leading-relaxed); margin: 0; }
[data-screen="calendar"] .feed-card__tags { font-size: var(--text-xs); font-family: var(--font-mono);
  color: var(--color-accent); margin: var(--space-1) 0 0; }
[data-screen="calendar"] .feed-card__tags:empty { display: none; }

/* Per-kind colour, so a note and an event are told apart at a glance. Same ORDER of emphasis as the
   Month grid's chips (events loudest, then notes, then shipped) but not the same values: a 3px stripe
   in accent-soft is invisible on paper, so notes take a mid-grey instead. A card carries the signal
   twice — the edge stripe down the left and the kind label — because the stripe survives a skim and
   the label survives a screenshot. */
[data-screen="calendar"] .feed-card { border-left: 3px solid var(--line-soft); }
[data-screen="calendar"] .feed-card[data-event-kind="note"] { border-left-color: var(--ink-muted); }
[data-screen="calendar"] .feed-card[data-event-kind="note"] .feed-card__kind { color: var(--ink-muted); }
[data-screen="calendar"] .feed-card[data-event-kind="shipped"] .feed-card__kind { color: var(--ink-muted); }
[data-screen="calendar"] .feed-card[data-event-kind="hackathon"],
[data-screen="calendar"] .feed-card[data-event-kind="talk"],
[data-screen="calendar"] .feed-card[data-event-kind="student-highlight"] { border-left-color: var(--color-accent); }
[data-screen="calendar"] .feed-card[data-event-kind="hackathon"] .feed-card__kind,
[data-screen="calendar"] .feed-card[data-event-kind="talk"] .feed-card__kind,
[data-screen="calendar"] .feed-card[data-event-kind="student-highlight"] .feed-card__kind { color: var(--color-accent); }

/* a hidden card is filtered out by the feed tabs, not merely spaced away (the list is a flex column). */
[data-screen="calendar"] .feed-card[hidden] { display: none; }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/molecules/feed-link/feed-link.css */
/* molecules/feed-link — the related-links row under a feed card. Scoped to [data-screen="calendar"];
   bundled into /components.css via config.styleRoots. The row hides when an event carries no links. */
[data-screen="calendar"] .feed-card__links { list-style: none; margin: var(--space-2) 0 0; padding: 0;
  display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-4); }
[data-screen="calendar"] .feed-card__links:empty { display: none; }
[data-screen="calendar"] .feed-card__link-item { margin: 0; }
[data-screen="calendar"] .feed-card__link { font-size: var(--text-sm); color: var(--color-accent); text-decoration: none; }
[data-screen="calendar"] .feed-card__link:hover { text-decoration: underline; }
[data-screen="calendar"] .feed-card__link::before { content: "→ "; color: var(--ink-faint); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/molecules/feed-photo/feed-photo.css */
/* molecules/feed-photo — the photo strip shared by feed cards and event pages (both are the calendar
   screen). Scoped to [data-screen="calendar"]; bundled into /components.css via config.styleRoots.
   Airbnb-style gallery: one photo goes full-width 16:9; with two or more, the FIRST photo runs big
   down the left and the rest tile small on the right (up to a 2x2), so the strip reads like a photo
   post, not a row of thumbnails. Capped at five tiles here — clicking any tile opens GRAIN's image
   viewer (scripts/lightbox.js), which walks EVERY photo in the strip (data-lightbox-group), the
   hidden past-five ones included, so a big post is fully reachable without leaving the feed; every
   photo also still lives on the event page, which the card title links to. :empty hides an absent
   strip; object-fit + fixed boxes hold the layout so a lazy image can't shift it. */
[data-screen="calendar"] .feed-photos { display: grid; grid-template-columns: 1fr;
  gap: var(--space-2); margin: 0 0 var(--space-3); }
[data-screen="calendar"] .feed-photos:empty { display: none; }

[data-screen="calendar"] .feed-photo { display: block; overflow: hidden; border-radius: var(--radius-md);
  border: 1px solid var(--line-soft); background: var(--paper-2); min-height: 0; }
[data-screen="calendar"] .feed-photo img { display: block; width: 100%; height: 100%; object-fit: cover; }

/* one photo: a comfortable 16:9 hero (the img keeps its own aspect box, no fixed grid height) */
[data-screen="calendar"] .feed-photos:not(:has(.feed-photo:nth-child(2))) .feed-photo img {
  height: auto; aspect-ratio: 16 / 9; }

/* two or three photos: big left + one or two stacked small on the right */
[data-screen="calendar"] .feed-photos:has(.feed-photo:nth-child(2)):not(:has(.feed-photo:nth-child(4))) {
  grid-template-columns: 1.8fr 1fr; grid-auto-rows: 1fr; aspect-ratio: 2 / 1; }
[data-screen="calendar"] .feed-photos:has(.feed-photo:nth-child(3)):not(:has(.feed-photo:nth-child(4))) {
  grid-template-rows: 1fr 1fr; }
[data-screen="calendar"] .feed-photos:has(.feed-photo:nth-child(3)):not(:has(.feed-photo:nth-child(4))) .feed-photo:first-child {
  grid-row: 1 / span 2; }

/* four or five photos: big left + a 2x2 of small on the right (Airbnb layout) */
[data-screen="calendar"] .feed-photos:has(.feed-photo:nth-child(4)) {
  grid-template-columns: 1.6fr 1fr 1fr; grid-template-rows: 1fr 1fr; aspect-ratio: 5 / 3; }
[data-screen="calendar"] .feed-photos:has(.feed-photo:nth-child(4)) .feed-photo:first-child {
  grid-column: 1; grid-row: 1 / span 2; }

/* EXACTLY four: the 2x2-on-the-right layout has five slots, so a fourth photo leaves one cell empty.
   Keep the first photo the hero it is everywhere else and stack the other three beside it, one column
   of three, so nothing is empty and the lead photo still carries the post. */
[data-screen="calendar"] .feed-photos:has(.feed-photo:nth-child(4)):not(:has(.feed-photo:nth-child(5))) {
  grid-template-columns: 1.8fr 1fr; grid-template-rows: repeat(3, 1fr); aspect-ratio: 3 / 2; }
[data-screen="calendar"] .feed-photos:has(.feed-photo:nth-child(4)):not(:has(.feed-photo:nth-child(5))) .feed-photo:first-child {
  grid-column: 1; grid-row: 1 / span 3; }
[data-screen="calendar"] .feed-photos .feed-photo:nth-child(n+6) { display: none; }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/molecules/flow/flow.css */
/* molecules/flow — portfolio: a small left-to-right diagram of boxed nodes joined by arrows
   (e.g. BATCH → GRAIN → MILL, or the one-door pipeline). CSS-only layout pattern; compose the
   markup inline (see flow.md). Tokens only. Wraps on narrow widths. */
.flow { display: flex; flex-wrap: wrap; align-items: stretch; gap: var(--space-2); margin: var(--space-6) 0; }
.flow__node { flex: 1 1 8rem; border: 1px solid var(--ink); border-radius: var(--radius-md);
  padding: var(--space-3) var(--space-4); font-size: var(--text-sm); text-align: center;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; }
.flow__node small { color: var(--color-muted); }
.flow__node--door { border-width: 2px; }
.flow__node--code { font-family: var(--font-grain); }
.flow__arrow { display: flex; align-items: center; color: var(--color-muted); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/molecules/live-figure/live-figure.css */
/* molecules/live-figure — a figure that MOVES, usable in a deck slide or in the middle of a
   paragraph. It ships the same widget to both, which is the whole point: the talk and the post
   argue the same thing, so they should not be arguing it with two different pictures.

   Progressive enhancement, not a replacement. The markup a reader is SERVED is the static SVG
   from FIGURES; scripts/figures.js swaps in the live version only if it runs. No JS, a crawler, a
   print run and the static export all keep the figure that was always there.

   SIZING. Every rule below is a multiple of --u, which a deck slide already defines from its own
   box. Prose has no such box, so .live-fig supplies one in rem. That is the only difference
   between the two contexts. */

/* space on BOTH sides. A figure that hugs the paragraph above it reads as part of that
   paragraph, and the ratio made it worse: its guess marker and label are drawn ABOVE the bar,
   outside the widget's own box, so they reached up into the prose. */
.live-fig { --u: 0.58rem; --u0: 0.58rem; margin: var(--space-6) auto; max-width: 34rem; }
.live-fig .mult { margin-top: 0; }
/* the deck sizes its verdict for the back of a room; in a paragraph that shouts */
.live-fig .mult__verdict { font-size: calc(var(--u) * 2.1); margin-top: calc(var(--u) * 3); }
.live-fig .mult__control { margin-top: calc(var(--u) * 4); }
.live-fig__note { font-size: var(--text-xs); color: var(--ink-faint); margin: var(--space-2) 0 0; }

.mult { margin-top: calc(var(--u) * 4); width: min(100%, calc(var(--u0) * 78)); }
.mult__row { display: grid; grid-template-columns: calc(var(--u) * 13) 1fr calc(var(--u) * 14);
  align-items: center; gap: calc(var(--u) * 2); margin-bottom: calc(var(--u) * 2.2); }
.mult__label { font-size: calc(var(--u) * 1.9); color: var(--color-muted); }
.mult__track { height: calc(var(--u) * 2.6); border: 1px solid var(--color-line); position: relative; }
.mult__fill { position: absolute; inset: 0 auto 0 0; background: var(--ink);
  transition: width 0.32s cubic-bezier(0.22, 0.61, 0.36, 1); }
.mult__fill--out { background: var(--color-accent); }

/* the bracket hangs BELOW the output bar. Sitting on top of it, the label was cream text on a
   cream fill and simply could not be read from a seat. */
.mult__gap { position: absolute; top: 100%; height: 0; }
.mult__gap i { position: absolute; inset: 0; border-top: 1px dashed var(--color-muted); }
.mult__gap i::before, .mult__gap i::after {
  content: ""; position: absolute; top: calc(var(--u) * -0.5); width: 1px; height: calc(var(--u) * 1);
  background: var(--color-muted); }
.mult__gap i::before { left: 0; }
.mult__gap i::after { right: 0; }
.mult__gap b { position: absolute; left: 50%; top: calc(var(--u) * 0.6); transform: translateX(-50%);
  white-space: nowrap; font-size: calc(var(--u) * 1.4); font-weight: var(--font-weight-regular); color: var(--color-muted); }
.mult__control { display: flex; align-items: center; gap: calc(var(--u) * 2.4); margin-top: calc(var(--u) * 5.5); }
.mult__control input[type="range"] { flex: 1; accent-color: var(--color-accent); height: calc(var(--u) * 2.4); }
.mult__verdict { font-size: calc(var(--u) * 3); margin-top: calc(var(--u) * 3.5); min-height: 1.3em; max-width: 34ch; }
.mult__zero { font-size: calc(var(--u) * 1.7); color: var(--color-muted); margin-top: calc(var(--u) * 1.5); }


/* --- 11 · the unglamorous thing, with an artefact per beat ------------------- */
.did { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: calc(var(--u) * 5); align-items: start; margin-top: calc(var(--u) * 2); }
.did .beats { margin-top: 0; }
.did__cards { position: relative; min-height: calc(var(--u) * 26); }
.did__card { position: absolute; inset: 0; margin: 0;
  border: 1px solid var(--color-line); border-radius: var(--radius-md); padding: calc(var(--u) * 2.4);
  opacity: 0; transform: translateY(calc(var(--u) * 0.8));
  transition: opacity 0.28s ease, transform 0.28s ease; pointer-events: none; }
.did__card.is-on { opacity: 1; transform: none; }
.did__lab { margin: 0 0 calc(var(--u) * 1.8); font-size: calc(var(--u) * 1.4);
  color: var(--color-muted); letter-spacing: 0.12em; text-transform: uppercase; }
.did__code { font-family: var(--font-mono); font-size: calc(var(--u) * 2.1);
  margin: 0; color: var(--color-fg); white-space: pre-wrap; line-height: 1.5; }
.did__code i { font-style: normal; color: var(--color-muted); }
.did__card figcaption { margin-top: calc(var(--u) * 2.2);
  font-size: calc(var(--u) * 1.7); color: var(--color-muted); line-height: 1.4; }
.did__card figcaption b { color: var(--color-fg); font-weight: var(--font-weight-regular);
  font-family: var(--font-mono); font-size: 0.95em; }

.matrix { margin-top: calc(var(--u) * 4.5); width: min(100%, calc(var(--u0) * 88)); border-collapse: collapse; }
.matrix th, .matrix td {
  padding: calc(var(--u) * 1.6) calc(var(--u) * 1.4); text-align: left;
  border-bottom: 1px solid var(--color-line); font-size: calc(var(--u) * 1.95);
  font-weight: var(--font-weight-regular);
}
.matrix th { color: var(--color-muted); font-size: calc(var(--u) * 1.7); }
.matrix th:first-child, .matrix td:first-child { color: var(--color-fg); }
.matrix col { transition: background 0.3s ease; }
.matrix col.is-lit { background: var(--paper-2); }
.matrix__pick { background: transparent; border: 0; font: inherit; color: inherit; cursor: pointer;
  padding: 0; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 0.3em; }
.matrix__pick[aria-pressed="true"] { color: var(--color-accent); }
.matrix__same { margin-top: calc(var(--u) * 3); font-size: calc(var(--u) * 2.6); }
.matrix__same b { font-weight: var(--font-weight-regular); }



/* the tree and the bar sit side by side: the point is that the ratio is not an abstraction, it is
   what the top of the repository literally looks like when you open it */
.ratiowrap { display: grid; grid-template-columns: auto minmax(0, 1fr);
  gap: calc(var(--u) * 5); align-items: start; margin-top: calc(var(--u) * 3); }
.tree s { text-decoration: none; color: var(--ink-faint); font-size: 0.9em; }
.tree { font-family: var(--font-mono); font-size: calc(var(--u) * 1.5);
  line-height: 1.7; margin: 0; color: var(--color-muted); white-space: pre; }
.tree b { color: var(--color-fg); font-weight: var(--font-weight-regular); }
.tree i { font-style: normal; color: var(--color-accent); }
.tree u { text-decoration: none; color: var(--color-fg); }

.ratiowrap .ratio { margin-top: 0; width: 100%; }

.ratio { margin-top: calc(var(--u) * 4); width: min(100%, calc(var(--u0) * 80)); }
.ratio__bar { position: relative; height: calc(var(--u) * 5.5); border: 1px solid var(--hairline); display: flex; }
.ratio__code { background: var(--ink-faint); transition: width 0.7s cubic-bezier(0.22, 0.61, 0.36, 1); }
.ratio__prose { background: var(--ink); transition: width 0.7s cubic-bezier(0.22, 0.61, 0.36, 1); }
.ratio__guess { position: absolute; top: calc(var(--u) * -1.6); bottom: calc(var(--u) * -1.6);
  width: 2px; background: var(--color-accent); transition: left 0.14s ease; }
.ratio__guess::after { content: attr(data-label); position: absolute; left: calc(var(--u) * 0.9);
  top: calc(var(--u) * -2.6); font-size: calc(var(--u) * 1.5); color: var(--color-accent); white-space: nowrap; }
.ratio__keys { display: flex; justify-content: space-between; margin-top: calc(var(--u) * 2.4);
  font-size: calc(var(--u) * 1.8); color: var(--color-muted); }
.ratio__keys b { color: var(--color-fg); font-weight: var(--font-weight-regular); }
.ratio__nudge { font-size: calc(var(--u) * 1.4); color: var(--ink-faint); }
.ratio__punch { font-size: calc(var(--u) * 3.2); margin-top: calc(var(--u) * 4); max-width: 32ch; }

.sprint { margin-top: calc(var(--u) * 5); width: min(100%, calc(var(--u0) * 86)); }
.sprint__line { position: relative; height: calc(var(--u) * 6); border-bottom: 2px solid var(--color-line); }

.sprint__night { position: absolute; left: 22%; right: 6%; top: calc(var(--u) * 1); bottom: 0;
  background: var(--color-fg); opacity: 0.05; }
.sprint__tick { position: absolute; bottom: 0; width: 1.5px; height: calc(var(--u) * 3.4);
  background: var(--ink); opacity: 0; transform: scaleY(0.4); transform-origin: bottom;
  transition: opacity 0.18s ease, transform 0.18s ease; }
.sprint__tick.is-on { opacity: 1; transform: none; }
.sprint__ends { display: flex; justify-content: space-between; margin-top: calc(var(--u) * 1.4);
  font-size: calc(var(--u) * 1.7); color: var(--color-muted); font-family: var(--font-mono); }
.sprint__count { font-size: calc(var(--u) * 3.4); margin-top: calc(var(--u) * 3.5); font-family: var(--font-mono); }
.sprint__note { font-size: calc(var(--u) * 1.9); color: var(--color-muted); margin-top: calc(var(--u) * 1.2); }

.loop { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: calc(var(--u) * 6);
  align-items: center; margin-top: calc(var(--u) * 2); }
.loop__ring { position: relative; width: calc(var(--u) * 37); height: calc(var(--u) * 37); flex: none; }
.loop__ring .fig { position: absolute; inset: 0; margin: 0; }
.loop__node {
  position: absolute; left: 50%; top: 50%; margin: 0; cursor: pointer;
  
  transform: translate(-50%, -50%) rotate(calc(var(--i) * 60deg)) translateY(calc(var(--u) * -14)) rotate(calc(var(--i) * -60deg));
  background: var(--color-bg); border: 1px solid var(--color-line); border-radius: var(--radius-md);
  padding: calc(var(--u) * 0.9) calc(var(--u) * 1.4); font: inherit; color: var(--color-fg);
  font-size: calc(var(--u) * 1.75); line-height: 1.15; white-space: nowrap;
  transition: border-color 0.2s ease, color 0.2s ease;
}
.loop__node span { display: block; font-family: var(--font-mono);
  font-size: calc(var(--u) * 1.15); color: var(--color-muted); }
.loop__node:hover { border-color: var(--hairline); }
.loop__node[aria-expanded="true"] { border-color: var(--color-accent); color: var(--color-accent); }
.loop__node[aria-expanded="true"] span { color: var(--color-accent); }
.loop__node[data-node="0"] { --i: 0; }
.loop__node[data-node="1"] { --i: 1; }
.loop__node[data-node="2"] { --i: 2; }
.loop__node[data-node="3"] { --i: 3; }
.loop__node[data-node="4"] { --i: 4; }
.loop__node[data-node="5"] { --i: 5; }
.loop__hub { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
  margin: 0; text-align: center; font-size: calc(var(--u) * 1.5); color: var(--color-muted); line-height: 1.35; }
.loop__detail { min-height: calc(var(--u) * 22); }
.loop__all { margin: 0; padding-left: calc(var(--u) * 3); list-style: decimal-leading-zero;
  display: flex; flex-direction: column; gap: calc(var(--u) * 1.2); }
/* the script hides this list when a node opens, but `display: flex` outranks the browser's own
   [hidden] rule, so the list stayed up and every card landed jammed underneath it */
.loop__all[hidden] { display: none; }
.loop__all li { font-size: calc(var(--u) * 1.85); color: var(--color-muted); line-height: 1.25; }
.loop__all::marker, .loop__all li::marker {
  font-family: var(--font-mono); font-size: calc(var(--u) * 1.4); color: var(--ink-faint); }
.loop__card h3 { font-size: calc(var(--u) * 2.8); margin: 0 0 calc(var(--u) * 2);
  font-weight: var(--font-weight-regular); line-height: 1.15; }
.loop__card p { font-size: calc(var(--u) * 2); color: var(--color-muted);
  margin: 0; max-width: 40ch; line-height: 1.45; }
.loop__card { animation: loop-card 0.3s ease both; }
@keyframes loop-card { from { opacity: 0; transform: translateY(0.4rem); } }

.trap { display: grid; grid-template-columns: 1fr 1fr; gap: calc(var(--u) * 4);
  margin-top: calc(var(--u) * 2.5); width: min(100%, calc(var(--u0) * 84)); }
.trap__card { border: 1px solid var(--color-line); border-radius: var(--radius-md); padding: calc(var(--u) * 2); }
.trap__t { font-size: calc(var(--u) * 2.1); margin: 0 0 calc(var(--u) * 1.6); }
.trap__t span { color: var(--color-muted); font-size: calc(var(--u) * 1.5); display: block;
  letter-spacing: 0.12em; text-transform: uppercase; margin-bottom: calc(var(--u) * 0.8); }
.trap .btn { font-size: calc(var(--u) * 1.8); padding: calc(var(--u) * 1.2) calc(var(--u) * 2.4); }
.trap__out { font-size: calc(var(--u) * 1.8); min-height: calc(var(--u) * 4);
  display: flex; align-items: center; color: var(--color-muted); }
.trap__out b { color: var(--color-fg); font-weight: var(--font-weight-regular); }
.trap__lesson { font-size: calc(var(--u) * 2.4); margin-top: calc(var(--u) * 3); max-width: 40ch; }

/* prose-only: the two figures the deck drives off slide steps need a control of their own, and
   the ratio's payoff line is revealed by that control rather than by a fragment landing */
.live-fig__ctl { margin: var(--space-4) 0 0; }
.live-fig .ratio__punch { opacity: 0; transition: opacity 0.3s ease; }
.live-fig .ratio__punch.is-on { opacity: 1; }
.live-fig .matrix, .live-fig .sprint, .live-fig .cycle { margin-top: 0; width: 100%; }
/* headroom for the guess marker's label, which hangs above the bar by about 4u */
.live-fig .ratio { margin-top: 0; width: 100%; padding-top: calc(var(--u) * 4.5); }
.live-fig .loop { margin-top: 0; }
/* the deck sizes these for a wall; a paragraph needs them a notch down */
.live-fig .ratio__punch { font-size: calc(var(--u) * 2.2); margin-top: calc(var(--u) * 2.5); }
.live-fig .matrix__same { font-size: calc(var(--u) * 2); }
.live-fig .sprint__count { font-size: calc(var(--u) * 2.8); }
.live-fig .trap__lesson { font-size: calc(var(--u) * 2); }

/* --- the whiplash dial (notes/build-the-floor, talks/build-the-floor) ---------
   Generation scales, review capacity does not. The dots past the line are the queue, and the
   figure only turns accent once that queue exists, so the colour is the finding rather than
   decoration. */
.whip { display: grid; gap: .7rem; }
.whip__title { margin: 0; color: var(--color-muted); font-size: .9rem; }
.whip__grid {
  display: flex; flex-wrap: wrap; gap: 5px;
  padding: .8rem; border: 1px solid var(--color-line); border-radius: 6px;
}
.whip__dot {
  width: 12px; height: 12px; border-radius: 2px;
  border: 1px solid var(--color-line); background: transparent; opacity: .35;
  transition: background-color .12s linear, border-color .12s linear, opacity .12s linear;
}
/* produced and inside capacity: solid ink, the work that actually got looked at */
.whip__dot[data-on] { background: var(--color-fg); border-color: var(--color-fg); opacity: 1; }
/* produced and past capacity: the queue. Hollow at full strength, because in this design language
   status is WEIGHT and not hue (--color-accent resolves to ink here, so colour would say nothing).
   Filled means somebody looked at it. Hollow means it exists and nobody has. */
.whip__dot[data-wait] {
  background: transparent; border-color: var(--color-fg); border-width: 2px; opacity: 1;
}
/* the boundary, in the flow rather than floating over it */
.whip__mark {
  flex: 0 0 100%; display: flex; align-items: center; gap: .5rem;
  margin: .25rem 0; font-size: .72rem; color: var(--color-muted);
}
.whip__mark::after { content: ""; flex: 1 1 auto; height: 1px; background: var(--color-line); }
.whip__mark b { font-weight: 400; }
.whip__keys { display: flex; flex-wrap: wrap; gap: 1.1rem; margin: 0; font-size: .88rem; color: var(--color-muted); }
.whip__keys b { color: var(--color-fg); font-variant-numeric: tabular-nums; }
.whip__wait { opacity: .45; transition: opacity .15s linear; }
.whip[data-over] .whip__wait { opacity: 1; }
.whip[data-over] .whip__wait b { text-decoration: underline; text-underline-offset: 3px; }
.whip__ctl { display: flex; align-items: center; gap: .7rem; }
.whip__lab { font-size: .85rem; color: var(--color-muted); }
.whip__dial { flex: 1 1 auto; max-width: 320px; accent-color: var(--color-fg); }
.whip__out { font-size: .85rem; color: var(--color-fg); font-variant-numeric: tabular-nums; min-width: 3ch; }
.whip__punch { margin: 0; opacity: .35; transition: opacity .2s linear; }
.whip[data-over] .whip__punch { opacity: 1; }

/* --- build order -------------------------------------------------------------
   Four lanes of supervision and one human lane. In coder-first the four sit idle and every dot
   lands on the person, which is the point being made by absence rather than by a label. */
.border { display: grid; gap: .7rem; }
.border__title { margin: 0; color: var(--color-muted); font-size: .9rem; }
.border__lanes { display: grid; gap: .35rem; }
.border__lane {
  display: grid; grid-template-columns: 8.5rem 1fr auto; align-items: center; gap: .7rem;
  padding: .38rem .6rem; border: 1px solid var(--color-line); border-radius: 6px;
  transition: opacity .15s linear;
}
.border__lane[data-idle] { opacity: .38; }
.border__lane--human { border-color: var(--color-fg); }
.border__name { font-size: .88rem; color: var(--color-fg); }
.border__why { font-size: .74rem; color: var(--color-muted); text-align: right; }
.border__dots { display: flex; flex-wrap: wrap; gap: 4px; min-height: 12px; }
.border__dot { width: 11px; height: 11px; border-radius: 2px; background: var(--color-fg); }
.border__dot[data-kind="caught"] { background: transparent; border: 2px solid var(--color-fg); }
.border__keys { display: flex; flex-wrap: wrap; gap: 1.1rem; margin: 0; font-size: .88rem; color: var(--color-muted); }
.border__keys b { color: var(--color-fg); font-variant-numeric: tabular-nums; }
.border__caught { opacity: .4; transition: opacity .15s linear; }
.border[data-floor] .border__caught { opacity: 1; }
.border[data-floor] .border__caught b { text-decoration: underline; text-underline-offset: 3px; }
.border__ctl { display: flex; gap: .5rem; }
.border__ctl .btn[aria-pressed="true"] { background: var(--color-fg); color: var(--color-bg); }
.border__punch { margin: 0; opacity: .35; transition: opacity .2s linear; }
.border[data-floor] .border__punch { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .whip__dot, .whip__wait, .whip__punch, .border__lane, .border__caught, .border__punch { transition: none; }
}

/* --- instruction versus hook -------------------------------------------------
   Ten sessions against one rule. A kept run is a filled mark; a broken one is hollow, which is the
   same weight-not-hue encoding the whiplash grid uses. Under a hook the broken ones are struck
   through instead of removed, because the point is that the attempt still happened and the write
   is what did not land. */
.rule { display: grid; gap: .7rem; }
.rule__title { margin: 0; color: var(--color-muted); font-size: .9rem; }
.rule__runs {
  display: flex; flex-wrap: wrap; gap: 8px;
  padding: .9rem; border: 1px solid var(--color-line); border-radius: 6px;
}
.rule__run {
  position: relative; width: 34px; height: 34px; border-radius: 4px;
  border: 1px solid var(--color-line); display: grid; place-items: center;
}
.rule__mark { width: 14px; height: 14px; border-radius: 2px; background: var(--color-fg); }
/* honoured the rule under an instruction, or blocked before it could break it */
.rule__run[data-broke] .rule__mark { background: transparent; border: 2px solid var(--color-fg); }
.rule__run[data-broke] { border-style: dashed; }
.rule__run[data-blocked]::after {
  content: ""; position: absolute; inset: 6px auto 6px 50%;
  width: 2px; background: var(--color-fg); transform: rotate(45deg);
}
.rule__run[data-blocked] .rule__mark { opacity: .25; }
.rule__keys { display: flex; flex-wrap: wrap; gap: 1.1rem; margin: 0; font-size: .88rem; color: var(--color-muted); }
.rule__keys b { color: var(--color-fg); font-variant-numeric: tabular-nums; }
.rule__broke b { text-decoration: underline; text-underline-offset: 3px; }
.rule[data-hooked] .rule__broke { opacity: .4; }
.rule[data-hooked] .rule__broke b { text-decoration: none; }
.rule__ctl { display: flex; gap: .5rem; }
.rule__ctl .btn[aria-pressed="true"] { background: var(--color-fg); color: var(--color-bg); }
.rule__punch { margin: 0; opacity: .35; transition: opacity .2s linear; }
.rule[data-hooked] .rule__punch { opacity: 1; }

/* --- a figure slide gives the figure the stage --------------------------------
   Measured 2026-08-18: figures on this deck were occupying 7 to 17 percent of the slide area,
   because they inherited the prose column and because an earlier fix for a heading collision
   shrank the picture instead of changing the layout. On a figure slide the heading is a caption
   and the figure is the content, so it gets the width and the type scales with it. */
.presentation__slide[data-figure] {
  /* The organism sizes every slide as a one-column grid at a reading measure. That is right for
     prose and wrong for a picture, and it is the single thing that was making these figures small.
     Overriding the ROWS axis (the first attempt) did nothing, because the measure lives on the
     columns. */
  grid-template-columns: 1fr;
  align-content: start;
}
.presentation__slide[data-figure] .slide-eyebrow { margin-bottom: 0; }
.presentation__slide[data-figure] .slide-head {
  font-size: clamp(1.3rem, 2.4vw, 2.1rem); line-height: 1.15; margin: 0 0 .6rem;
}
.presentation__slide[data-figure] .live-fig,
.presentation__slide[data-figure] > svg[role="img"] {
  width: 100%; max-width: none; margin: 0; align-self: stretch;
}
.presentation__slide[data-figure] svg[role="img"] { width: 100%; max-width: none; max-height: 62vh; }

/* the widgets scale with the stage rather than sitting at reading size */
.presentation__slide[data-figure] .whip,
.presentation__slide[data-figure] .border,
.presentation__slide[data-figure] .rule { gap: 1.1rem; font-size: 1.15rem; }
.presentation__slide[data-figure] .whip__title,
.presentation__slide[data-figure] .border__title,
.presentation__slide[data-figure] .rule__title { font-size: 1.05rem; }
.presentation__slide[data-figure] .whip__grid { gap: 10px; padding: 1.4rem; }
.presentation__slide[data-figure] .whip__dot { width: 26px; height: 26px; border-radius: 4px; }
.presentation__slide[data-figure] .whip__mark { font-size: .95rem; margin: .5rem 0; }
.presentation__slide[data-figure] .whip__keys,
.presentation__slide[data-figure] .border__keys,
.presentation__slide[data-figure] .rule__keys { font-size: 1.15rem; gap: 2rem; }
.presentation__slide[data-figure] .whip__dial { max-width: 480px; }
.presentation__slide[data-figure] .border__lane { grid-template-columns: 12rem 1fr auto; padding: .7rem 1rem; }
.presentation__slide[data-figure] .border__name { font-size: 1.15rem; }
.presentation__slide[data-figure] .border__why { font-size: .95rem; }
.presentation__slide[data-figure] .border__dot { width: 20px; height: 20px; border-radius: 3px; }
.presentation__slide[data-figure] .border__dots { gap: 8px; }
.presentation__slide[data-figure] .rule__runs { gap: 14px; padding: 1.4rem; }
.presentation__slide[data-figure] .rule__run { width: 62px; height: 62px; border-radius: 8px; }
.presentation__slide[data-figure] .rule__mark { width: 26px; height: 26px; }
.presentation__slide[data-figure] .rule__run[data-blocked]::after { inset: 10px auto 10px 50%; width: 3px; }
.presentation__slide[data-figure] .whip__punch,
.presentation__slide[data-figure] .border__punch,
.presentation__slide[data-figure] .rule__punch { font-size: 1.1rem; }

/* The organism wraps slide content in .presentation__body at a reading measure (~414px on a
   1440 stage). That is correct for prose and wrong for a figure, so a figure slide opts out of it.
   This was the actual cause of the tiny figures; the SVG max-widths were a symptom. */
.presentation__slide[data-figure] .presentation__body { max-width: none; width: 100%; }

/* --- the roadmap, in HTML rather than SVG ------------------------------------
   A viewBox fixes a figure's proportions to whatever the author guessed; HTML lets the same figure
   fill a slide and a paragraph without two drawings. The month cursor is the reason it is worth the
   swap: a gantt you can only look at answers "how long", and this one answers "where am I". */
.road { display: grid; gap: .8rem; }
.road__title { margin: 0; color: var(--color-muted); font-size: .9rem; }
.road__scale { position: relative; height: 1.1em; margin-left: 11rem; font-size: .78rem; color: var(--color-muted); }
.road__scale span { position: absolute; transform: translateX(-50%); white-space: nowrap; }
.road__scale span:first-child { transform: none; }
.road__scale span:last-child { transform: translateX(-100%); }
.road__rows { position: relative; display: grid; gap: .3rem; }
.road__row {
  display: grid; grid-template-columns: 11rem 1fr 7rem; align-items: center; gap: .8rem;
  padding: .3rem 0; transition: opacity .15s linear;
}
.road__row[data-state="not started"] { opacity: .4; }
.road__name { font-size: .95rem; color: var(--color-fg); display: grid; }
.road__name em { font-style: normal; font-size: .76rem; color: var(--color-muted); }
.road__track { position: relative; height: 22px; border-bottom: 1px solid var(--color-line); }
.road__bar {
  position: absolute; top: 2px; height: 18px; border-radius: 3px;
  background: var(--color-fg); transition: opacity .15s linear;
}
/* running is hollow, done is filled: weight, not hue, same as every other figure here */
.road__row[data-state="running"] .road__bar { background: transparent; border: 2px solid var(--color-fg); }
.road__row[data-state="not started"] .road__bar { background: transparent; border: 1px dashed var(--color-line); }
.road__state { font-size: .76rem; color: var(--color-muted); text-align: right; }
.road__row[data-state="running"] .road__state { text-decoration: underline; text-underline-offset: 3px; }
/* The cursor rides a lane that mirrors the row grid, so its middle cell IS the track and a
   percentage inside it means a month. Positioning it against the row container put month 18 off
   the right edge, behind the label column's offset. */
.road__lane {
  position: absolute; inset: 0; display: grid; gap: .8rem; pointer-events: none;
  grid-template-columns: 11rem 1fr 7rem;
}
.road__lanetrack { position: relative; }
.road__cursor {
  position: absolute; top: 0; bottom: 0; width: 2px; background: var(--color-fg);
  transition: left .12s linear;
}
.road__ctl { display: flex; align-items: center; gap: .7rem; }
.road__lab { font-size: .85rem; color: var(--color-muted); }
.road__dial { flex: 1 1 auto; max-width: 340px; accent-color: var(--color-fg); }
.road__out { font-size: .85rem; font-variant-numeric: tabular-nums; }
.road__now { margin: 0; font-size: .9rem; color: var(--color-muted); min-height: 2.6em; }
.road__now b { color: var(--color-fg); }
.road__punch { margin: 0; opacity: .4; transition: opacity .2s linear; }
.road[data-moved] .road__punch { opacity: 1; }

/* on a slide the roadmap gets the stage, like the other live figures */
.presentation__slide[data-figure] .road { gap: 1.1rem; font-size: 1.15rem; }
.presentation__slide[data-figure] .road__title { font-size: 1.05rem; }
.presentation__slide[data-figure] .road__scale { margin-left: 15rem; font-size: .95rem; }
.presentation__slide[data-figure] .road__row { grid-template-columns: 15rem 1fr 9rem; padding: .5rem 0; }
.presentation__slide[data-figure] .road__name { font-size: 1.2rem; }
.presentation__slide[data-figure] .road__name em { font-size: .92rem; }
.presentation__slide[data-figure] .road__track { height: 34px; }
.presentation__slide[data-figure] .road__bar { top: 3px; height: 28px; }
.presentation__slide[data-figure] .road__state { font-size: .92rem; }
.presentation__slide[data-figure] .road__lane { grid-template-columns: 15rem 1fr 9rem; }
.presentation__slide[data-figure] .road__cursor { width: 3px; }
.presentation__slide[data-figure] .road__now { font-size: 1.05rem; }
.presentation__slide[data-figure] .road__dial { max-width: 460px; }

@media (prefers-reduced-motion: reduce) { .road__cursor, .road__row, .road__bar { transition: none; } }

/* --- the agent loop, running rather than drawn --------------------------------
   Horizontal so it suits a 16:9 stage, where the vertical SVG it replaces was 13% of the area.
   Node kind is carried by border weight, not hue: a model node is the heavy one, because "which
   boxes cost money" is the claim the figure exists to make. */
.loopfig { display: grid; gap: .8rem; }
.loopfig__title { margin: 0; color: var(--color-muted); font-size: .9rem; }
.loopfig__nodes {
  list-style: none; margin: 0; padding: 0;
  display: grid; grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr)); gap: .5rem;
}
.loopfig__node {
  display: grid; gap: .15rem; padding: .6rem .7rem; border-radius: 6px;
  border: 1px solid var(--color-line); opacity: .45;
  transition: opacity .15s linear, border-color .15s linear, background-color .15s linear;
}
.loopfig__node[data-kind="model"] { border-width: 2px; }
.loopfig__node[data-kind="human"] { border-color: var(--color-fg); }
.loopfig__node[data-visited] { opacity: 1; }
.loopfig__node[data-active] { background: var(--color-fg); }
.loopfig__node[data-active] .loopfig__name,
.loopfig__node[data-active] .loopfig__sub,
.loopfig__node[data-active] .loopfig__tag { color: var(--color-bg); }
/* the second visit to the tests is the retry, and it has to be visible or the cap means nothing */
.loopfig__node[data-again]::after {
  content: "second pass"; font-size: .68rem; color: var(--color-muted);
}
.loopfig__name { font-size: .92rem; color: var(--color-fg); }
.loopfig__sub { font-size: .72rem; color: var(--color-muted); }
.loopfig__tag { font-size: .66rem; color: var(--color-muted); letter-spacing: .06em; text-transform: uppercase; }
.loopfig__keys { display: flex; flex-wrap: wrap; gap: 1.1rem; margin: 0; font-size: .88rem; color: var(--color-muted); }
.loopfig__keys b { color: var(--color-fg); font-variant-numeric: tabular-nums; }
.loopfig__state { font-style: italic; }
.loopfig__punch { margin: 0; opacity: .35; transition: opacity .2s linear; }
.loopfig[data-done] .loopfig__punch { opacity: 1; }

.presentation__slide[data-figure] .loopfig { gap: 1.1rem; font-size: 1.15rem; }
.presentation__slide[data-figure] .loopfig__title { font-size: 1.05rem; }
.presentation__slide[data-figure] .loopfig__nodes { grid-template-columns: repeat(7, 1fr); gap: .7rem; }
.presentation__slide[data-figure] .loopfig__node { padding: 1rem .8rem; }
.presentation__slide[data-figure] .loopfig__name { font-size: 1.05rem; }
.presentation__slide[data-figure] .loopfig__sub { font-size: .85rem; }
.presentation__slide[data-figure] .loopfig__keys { font-size: 1.15rem; gap: 2rem; }
.presentation__slide[data-figure] .loopfig__punch { font-size: 1.1rem; }

/* --- the four gates, as a self-assessment ------------------------------------
   The last SVG in the note became this. A ladder you can only look at says the gates exist; one you
   can tick says which of them YOU fail, which is the question a reader actually has. */
.gates { display: grid; gap: .8rem; }
.gates__title { margin: 0; color: var(--color-muted); font-size: .9rem; }
.gates__list { list-style: none; margin: 0; padding: 0; display: grid; gap: .4rem; }
.gates__btn {
  width: 100%; display: grid; grid-template-columns: auto 1fr; align-items: start; gap: .8rem;
  padding: .7rem .9rem; border: 1px solid var(--color-line); border-radius: 6px;
  background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer;
  transition: border-color .15s linear, opacity .15s linear;
}
.gates__box {
  width: 18px; height: 18px; margin-top: .15rem; border-radius: 3px;
  border: 2px solid var(--color-line); transition: background-color .12s linear, border-color .12s linear;
}
.gates__gate[data-on] .gates__box {
  background: var(--color-fg); border-color: var(--color-fg);
  box-shadow: inset 0 0 0 3px var(--color-bg), inset 0 0 0 6px var(--color-fg);
}
.gates__gate[data-on] .gates__btn { border-color: var(--color-fg); }
.gates__gate:not([data-on]) .gates__btn { opacity: .62; }
.gates__text { display: grid; gap: .15rem; }
.gates__text b { font-weight: 600; }
.gates__text em { font-style: normal; font-size: .8rem; color: var(--color-muted); }
.gates__verdict { margin: 0; font-size: .92rem; color: var(--color-muted); min-height: 2.4em; }
.gates__verdict b { color: var(--color-fg); }
.gates__punch { margin: 0; opacity: .4; transition: opacity .2s linear; }
.gates[data-all] .gates__punch { opacity: 1; }

.presentation__slide[data-figure] .gates { gap: 1.1rem; font-size: 1.15rem; }
.presentation__slide[data-figure] .gates__title { font-size: 1.05rem; }
.presentation__slide[data-figure] .gates__btn { padding: 1rem 1.2rem; gap: 1.1rem; }
.presentation__slide[data-figure] .gates__box { width: 26px; height: 26px; }
.presentation__slide[data-figure] .gates__text b { font-size: 1.25rem; }
.presentation__slide[data-figure] .gates__text em { font-size: .95rem; }
.presentation__slide[data-figure] .gates__verdict { font-size: 1.1rem; }

/* --- the two paths -----------------------------------------------------------
   Five stops down one epic, walked twice. The encoding is the same weight-not-hue rule the whiplash
   grid uses: a filled mark is work a machine did, a hollow one is work that landed on a person, and
   a struck one is a repair, which is the only kind the figure is arguing against. The counters read
   in opposite directions on purpose, so flipping the toggle moves both at once. */
.path { display: grid; gap: .7rem; }
.path__title { margin: 0; color: var(--color-muted); font-size: .9rem; }
.path__stops {
  display: grid; gap: .35rem; margin: 0; padding: 0; list-style: none;
}
.path__stop {
  display: grid; grid-template-columns: 20px 1fr auto; align-items: center; gap: .7rem;
  padding: .5rem .8rem; border: 1px solid var(--color-line); border-radius: 6px;
  transition: opacity .15s linear, border-color .15s linear;
}
.path__mark {
  width: 12px; height: 12px; border-radius: 2px; justify-self: center;
  border: 2px solid var(--color-fg); background: transparent;
}
/* a skill or the model did it: filled, and the row carries no weight for the reader */
.path__stop[data-kind="skill"] .path__mark,
.path__stop[data-kind="pass"] .path__mark { background: var(--color-fg); }
.path__stop[data-kind="pass"] { opacity: .72; }
/* work done by hand before the code exists: hollow, because a person did it. Same three-state
   encoding as everywhere else here: filled is a machine, hollow is a person, struck is a repair. */
.path__stop[data-kind="spec"] .path__mark { opacity: .55; }
/* the repair: the only state the figure is arguing against, so it is the only one that is marked */
.path__stop[data-kind="repair"] { border-color: var(--color-fg); }
.path__stop[data-kind="repair"] .path__mark {
  background: transparent;
  box-shadow: inset 0 0 0 2px var(--color-bg), inset 0 -5px 0 0 var(--color-fg);
}
.path__stop[data-kind="human"] .path__mark { background: transparent; border-style: solid; }
.path__body { display: grid; gap: .1rem; min-width: 0; }
.path__name { font-size: .88rem; font-weight: 400; color: var(--color-fg); }
.path__note { font-size: .76rem; color: var(--color-muted); font-style: normal; }
.path__tag { font-size: .72rem; color: var(--color-muted); text-align: right; }
.path__keys { display: flex; flex-wrap: wrap; gap: 1.1rem; margin: 0; font-size: .88rem; color: var(--color-muted); }
.path__keys b { color: var(--color-fg); font-variant-numeric: tabular-nums; }
.path__spec { opacity: .4; transition: opacity .15s linear; }
.path[data-layered] .path__spec { opacity: 1; }
.path[data-layered] .path__spec b { text-decoration: underline; text-underline-offset: 3px; }
.path[data-layered] .path__repair { opacity: .4; }
.path__ctl { display: flex; gap: .5rem; }
.path__ctl .btn[aria-pressed="true"] { background: var(--color-fg); color: var(--color-bg); }
.path__punch { margin: 0; opacity: .35; transition: opacity .2s linear; }
.path[data-layered] .path__punch { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .path__stop, .path__spec, .path__repair, .path__punch { transition: none; }
}

/* Five rows plus a title, a key line, two buttons and a punch is the tallest figure on this deck, so
   it scales up less than the others do. Anything roomier pushed the punch line under the slide nav,
   which is the one line the room is meant to leave with. */
.presentation__slide[data-figure] .path { gap: .6rem; font-size: 1.05rem; }
.presentation__slide[data-figure] .path__title { font-size: .95rem; }
.presentation__slide[data-figure] .path__stop { padding: .45rem 1rem; }
.presentation__slide[data-figure] .path__name { font-size: 1.05rem; }
.presentation__slide[data-figure] .path__note { font-size: .85rem; }
.presentation__slide[data-figure] .path__tag { font-size: .8rem; }

/* --- six failures, one report -------------------------------------------------
   The marks never change between the two modes, and that is the entire figure: what turns out to
   have been wrong is the sentence, not the signal. So the mark stays a filled pass in both states
   and only the row's weight shifts, because a mark that went red on the flip would be telling the
   reader something the real report never told anybody. */
/* The queries below are @container, not @media, for the same reason welcome.css gives: what starves
   these columns is the SHELL's reading column, not the viewport. At a 1000px window with the file
   tree and the chat panel open, the column is about 380px, and a viewport query happily applies the
   wide layout to it. */
.fail { display: grid; gap: .7rem; container-type: inline-size; container-name: fig; }
.fail__title { margin: 0; color: var(--color-muted); font-size: .9rem; }
.fail__rows { display: grid; gap: .35rem; margin: 0; padding: 0; list-style: none; }
.fail__row {
  display: grid; grid-template-columns: 14px 8.5rem 1fr; align-items: baseline; gap: .6rem;
  padding: .4rem .7rem; border: 1px solid var(--color-line); border-radius: 6px;
  transition: border-color .15s linear;
}
/* Six rows stacked is already the tallest figure in the note. Below the measure where the third
   column starts wrapping to three lines, drop the name onto its own row instead: two short lines
   read faster than one column squeezed to four words. */
@container fig (max-width: 30rem) {
  .fail__row { grid-template-columns: 14px 1fr; row-gap: .15rem; }
  .fail__line { grid-column: 2; }
}
.fail__mark {
  width: 10px; height: 10px; border-radius: 2px; align-self: center; justify-self: center;
  background: var(--color-fg);
}
.fail__name { font-size: .88rem; color: var(--color-fg); }
.fail__line { font-size: .8rem; color: var(--color-muted); }
/* in the truth state the row is outlined, the way the two-path figure marks a repair */
.fail[data-real] .fail__row { border-color: var(--color-fg); }
.fail[data-real] .fail__line { color: var(--color-fg); }
.fail__keys { display: flex; flex-wrap: wrap; gap: 1.1rem; margin: 0; font-size: .88rem; color: var(--color-muted); }
.fail__keys b { color: var(--color-fg); font-variant-numeric: tabular-nums; }
.fail__real { opacity: .4; transition: opacity .15s linear; }
.fail[data-real] .fail__real { opacity: 1; }
.fail[data-real] .fail__real b { text-decoration: underline; text-underline-offset: 3px; }
.fail[data-real] .fail__pass { opacity: .45; }
.fail__ctl { display: flex; gap: .5rem; flex-wrap: wrap; }
.fail__ctl .btn[aria-pressed="true"] { background: var(--color-fg); color: var(--color-bg); }
.fail__punch { margin: 0; opacity: .35; transition: opacity .2s linear; }
.fail[data-real] .fail__punch { opacity: 1; }

/* --- the cost of waiting ------------------------------------------------------
   Four bars that never move and one that fills. The flat rows are not decoration: the argument only
   lands because four of the five visibly do nothing while the dial travels. */
.wait { display: grid; gap: .7rem; }
.wait__title { margin: 0; color: var(--color-muted); font-size: .9rem; }
.wait__rows { display: grid; gap: .35rem; margin: 0; padding: 0; list-style: none; }
.wait__row {
  display: grid; grid-template-columns: 4rem 1fr; align-items: center; gap: .8rem;
  padding: .45rem .8rem; border: 1px solid var(--color-line); border-radius: 6px;
}
.wait__row[data-flat] { opacity: .6; }
.wait__bar { display: block; height: 6px; background: var(--color-line); border-radius: 3px; overflow: hidden; }
.wait__bar > span { display: block; height: 100%; width: 0; background: var(--color-fg); transition: width .12s linear; }
.wait__body { display: grid; gap: .1rem; min-width: 0; }
.wait__name { font-size: .88rem; font-weight: 400; color: var(--color-fg); }
.wait__cost { font-size: .78rem; color: var(--color-muted); font-style: normal; }
.wait__row[data-losing] { border-color: var(--color-fg); opacity: 1; }
.wait__row[data-losing] .wait__cost { color: var(--color-fg); }
.wait__ctl { display: flex; align-items: center; gap: .7rem; }
.wait__lab { font-size: .85rem; color: var(--color-muted); }
.wait__dial { flex: 1 1 auto; max-width: 300px; accent-color: var(--color-fg); }
.wait__out { font-size: .85rem; color: var(--color-fg); font-variant-numeric: tabular-nums; min-width: 7ch; }
.wait__punch { margin: 0; opacity: .35; transition: opacity .2s linear; }
.wait[data-late] .wait__punch { opacity: 1; }

/* --- the promotion gate -------------------------------------------------------
   One dial, three bars at three places. The bar marker is an element on the track rather than a
   background gradient, so it stays put when the fill animates past it. */
.promo { display: grid; gap: .7rem; container-type: inline-size; container-name: fig; }
.promo__title { margin: 0; color: var(--color-muted); font-size: .9rem; }
.promo__rows { display: grid; gap: .35rem; margin: 0; padding: 0; list-style: none; }
/* Narrow first, and that is not caution. Inside the desk shell the reading column measures about
   380px, where a fixed 13rem name column plus a 6rem state column left the track 25 pixels and the
   figure became unreadable while every number in it stayed correct. The single-row version is the
   enhancement, not the base. */
.promo__row {
  display: grid; gap: .3rem .8rem; align-items: center;
  grid-template-columns: 1fr auto;
  grid-template-areas: "body state" "track track";
  padding: .5rem .8rem; border: 1px solid var(--color-line); border-radius: 6px;
}
.promo__body { grid-area: body; }
.promo__track { grid-area: track; }
.promo__state { grid-area: state; }
@container fig (min-width: 30rem) {
  .promo__row {
    /* minmax on the name column, so a long skill name wraps instead of eating the track */
    grid-template-columns: minmax(6rem, 12rem) minmax(90px, 1fr) auto;
    grid-template-areas: "body track state";
    gap: .8rem;
  }
}
.promo__body { display: grid; gap: .1rem; min-width: 0; }
.promo__name { font-size: .88rem; font-weight: 400; color: var(--color-fg); }
.promo__blast { font-size: .74rem; color: var(--color-muted); font-style: normal; }
.promo__track { position: relative; display: block; height: 8px; background: var(--color-line); border-radius: 4px; }
.promo__fill { position: absolute; inset: 0 auto 0 0; width: 0; background: var(--color-fg); border-radius: 4px; transition: width .12s linear; }
.promo__bar { position: absolute; top: -4px; bottom: -4px; width: 2px; background: var(--color-fg); opacity: .55; }
.promo__state { font-size: .78rem; color: var(--color-muted); text-align: right; font-variant-numeric: tabular-nums; }
.promo__row[data-ready] { border-color: var(--color-fg); }
.promo__row[data-ready] .promo__state { color: var(--color-fg); }
.promo__keys { display: flex; gap: 1.1rem; margin: 0; font-size: .88rem; color: var(--color-muted); }
.promo__keys b { color: var(--color-fg); font-variant-numeric: tabular-nums; }
.promo__ctl { display: flex; align-items: center; gap: .7rem; }
.promo__lab { font-size: .85rem; color: var(--color-muted); }
.promo__dial { flex: 1 1 auto; max-width: 300px; accent-color: var(--color-fg); }
.promo__out { font-size: .85rem; color: var(--color-fg); font-variant-numeric: tabular-nums; min-width: 4ch; }
.promo__punch { margin: 0; opacity: .35; transition: opacity .2s linear; }
.promo[data-any] .promo__punch { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .fail__row, .fail__real, .fail__punch, .wait__bar > span, .wait__punch,
  .promo__fill, .promo__punch { transition: none; }
}

.presentation__slide[data-figure] :is(.fail, .wait, .promo) { gap: 1rem; font-size: 1.05rem; }
.presentation__slide[data-figure] :is(.fail__title, .wait__title, .promo__title) { font-size: .95rem; }
.presentation__slide[data-figure] .fail__row { padding: .5rem 1rem; grid-template-columns: 16px 12rem 1fr; }
.presentation__slide[data-figure] .fail__name { font-size: 1.05rem; }
.presentation__slide[data-figure] .fail__line { font-size: .92rem; }
.presentation__slide[data-figure] :is(.wait__row, .promo__row) { padding: .55rem 1rem; }
.presentation__slide[data-figure] :is(.wait__name, .promo__name) { font-size: 1.05rem; }
.presentation__slide[data-figure] :is(.wait__cost, .promo__blast, .promo__state) { font-size: .9rem; }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/molecules/mail-folder/mail-folder.css */
/* molecules/mail-folder — a folder chip in the /mail toolbar. Scoped to the mail screen (it only
   appears there); bundled into /components.css via config.styleRoots. Parent-context: a child of
   nav.mailbox__folders, a horizontal chip row across the top of the mailbox. */
[data-screen="mail"] .mailbox__folder { display: inline-flex; align-items: center;
  gap: var(--space-2); padding: var(--space-1) var(--space-3); border-radius: var(--radius-pill);
  border: 1px solid var(--line-soft); font-size: var(--text-sm); color: var(--ink-muted); text-decoration: none; }
[data-screen="mail"] .mailbox__folder:hover { background: color-mix(in srgb, var(--ink) 6%, transparent); color: var(--ink); }
[data-screen="mail"] .mailbox__folder[aria-current="page"] { background: var(--color-accent-soft); color: var(--ink);
  border-color: var(--color-accent); font-weight: var(--font-weight-semibold); }
[data-screen="mail"] .mailbox__folder-count { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--ink-muted); }
/* WCAG 2.5.8: the chip keeps a 24px target outside the mail screen too (the catalog renders it
   without data-screen="mail", where the rules above do not reach). */
.mailbox__folder { display: inline-flex; align-items: center; min-height: 24px; min-width: 24px; }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/molecules/mail-reader/mail-reader.css */
/* molecules/mail-reader — one open message below the /mail list. Scoped to the mail screen; bundled
   into /components.css via config.styleRoots. Parent-context: a direct child of .mailbox__readers.
   The body is a single block with white-space: pre-line (the message text carries its own \n\n). */
[data-screen="mail"] .mailbox__reader { border: 1px solid var(--line-soft); border-radius: var(--radius-md);
  padding: var(--space-5); background: var(--paper); }
[data-screen="mail"] .mailbox__reader[hidden] { display: none; }
[data-screen="mail"] .mailbox__reader-head { display: flex; align-items: baseline; justify-content: space-between;
  gap: var(--space-3); margin-bottom: var(--space-1); }
[data-screen="mail"] .mailbox__reader-subject { font-family: var(--font-accent); font-size: var(--text-lg);
  font-weight: var(--font-weight-semibold); margin: 0; }
[data-screen="mail"] .mailbox__reader-meta { font-size: var(--text-xs); color: var(--ink-muted);
  font-family: var(--font-mono); margin: 0 0 var(--space-4); }
[data-screen="mail"] .mailbox__reader-tools { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-bottom: var(--space-4); }
[data-screen="mail"] .mailbox__reader-body { white-space: pre-line; line-height: var(--leading-relaxed); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/molecules/mail-related/mail-related.css */
/* molecules/mail-related — the Related-links row under a reader body. Scoped to the mail screen;
   bundled into /components.css via config.styleRoots. The row hides itself when a message carries no
   links (:empty), so readers without links show no empty affordance. */
[data-screen="mail"] .mailbox__reader-links { list-style: none; margin: var(--space-4) 0 0; padding: var(--space-4) 0 0;
  border-top: 1px solid var(--line-soft); display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-4); }
[data-screen="mail"] .mailbox__reader-links:empty { display: none; }
[data-screen="mail"] .mailbox__reader-link-item { margin: 0; }
[data-screen="mail"] .mailbox__reader-link { font-size: var(--text-sm); color: var(--color-accent); text-decoration: none; }
[data-screen="mail"] .mailbox__reader-link:hover { text-decoration: underline; }
[data-screen="mail"] .mailbox__reader-link::before { content: "→ "; color: var(--ink-faint); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/molecules/mail-row/mail-row.css */
/* molecules/mail-row — a message row in the /mail list. Scoped to the mail screen; bundled into
   /components.css via config.styleRoots. Parent-context: inside .mailbox__list's scrolling rows.
   Every sender here is "The Desk", so the row leads with the SUBJECT (the useful line): a small
   from + date on the top line, the subject bold below it, the snippet under that — a real mail-list
   row that stays legible in the narrow list column without wrapping the subject into a column. */
[data-screen="mail"] .mailbox__item { display: grid;
  grid-template-columns: 0.9rem minmax(0, 1fr) auto; grid-template-rows: auto auto auto;
  column-gap: var(--space-3); row-gap: 2px; align-items: baseline;
  padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--line-soft);
  text-decoration: none; color: inherit; }
[data-screen="mail"] .mailbox__item:last-child { border-bottom: 0; }
[data-screen="mail"] .mailbox__item:hover { background: var(--paper-2); }
[data-screen="mail"] .mailbox__item[aria-current="page"] { background: var(--color-accent-soft); }
[data-screen="mail"] .mailbox__item[hidden] { display: none; }

/* status dot: empty by default; the island paints it and bolds the subject when the row is unread
   (read state is localStorage-only, so this class is never server-rendered). */
[data-screen="mail"] .mailbox__item-dot { grid-column: 1; grid-row: 1; justify-self: center; align-self: center;
  width: 0.45rem; height: 0.45rem; border-radius: var(--radius-pill); background: transparent; }
[data-screen="mail"] .mailbox__item.is-unread .mailbox__item-dot { background: var(--color-accent); }
[data-screen="mail"] .mailbox__item.is-unread .mailbox__item-subject { font-weight: var(--font-weight-semibold); }

[data-screen="mail"] .mailbox__item-from { grid-column: 2; grid-row: 1; font-size: var(--text-xs);
  color: var(--ink-muted); }
[data-screen="mail"] .mailbox__item-when { grid-column: 3; grid-row: 1; font-size: var(--text-xs);
  color: var(--ink-muted); font-family: var(--font-mono); white-space: nowrap; text-align: right; }
[data-screen="mail"] .mailbox__item-subject { grid-column: 2 / -1; grid-row: 2; font-size: var(--text-sm);
  color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
[data-screen="mail"] .mailbox__item-snippet { grid-column: 2 / -1; grid-row: 3; font-size: var(--text-xs);
  color: var(--ink-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/molecules/note-card/note-card.css */
/* molecules/note-card — portfolio: the /notes feed (content.ts renderNotesFeedPage), a
   Reddit-ish read of the notes collection. CSS-only: server-composed HTML, nothing data-binds
   it (see note-card.md — no .html file). This file owns the feed controls + the card anatomy:
   `.feed-controls`/`.chips`/`.chip`, `.note-feed`, and every `.note-card__*` part. Tokens only
   (DESIGN-SYSTEM §2) — no hardcoded colors, mirrors work-card.css's convention. */

/* ---- toolbar: a search field + a New/Top segmented control + the "See what's new" AI trigger on
   one bar, then a reduced tag-chip row below. An app-like top bar, not a stack of checkbox rows.
   Hidden until the inline island in renderNotesFeedPage reveals it — see the [hidden] rule, the
   no-JS default. */
.notes-toolbar {
  display: flex; flex-direction: column; gap: var(--space-3);
  padding: var(--space-3) 0 var(--space-4);
  border-bottom: 1px solid var(--color-line);
  margin: 0 0 var(--space-4);
}
/* the no-JS default: the inline island reveals this by clearing [hidden] (renderNotesFeedPage). The
   flex display above matches [hidden]'s UA-sheet specificity, and an author rule loads after the UA
   sheet — without this override the flex display would win and the "hidden" toolbar would show on a
   no-JS load. */
.notes-toolbar[hidden] { display: none; }
.notes-toolbar__bar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3); }
.notes-toolbar__actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3); margin-left: auto; }
.notes-toolbar__ai { flex: 0 0 auto; }

.notes-search {
  flex: 1 1 16rem; min-width: 0;
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--color-line); border-radius: var(--radius-md);
  background: var(--paper, var(--color-bg)); color: var(--color-fg); font: inherit; font-size: var(--text-sm);
}
.notes-search::placeholder { color: var(--color-muted); }
.notes-search:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }

.chips { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; }
.chip {
  display: inline-flex; align-items: center; gap: var(--space-1);
  font-size: var(--text-xs); color: var(--color-muted);
  padding: var(--space-1) var(--space-3);
  border: 1px solid var(--color-line); border-radius: var(--radius-pill);
  cursor: pointer; user-select: none;
}
.chip:has(input:checked) { color: var(--color-fg); border-color: var(--color-fg); }
.chip input { accent-color: var(--color-accent); }

/* New/Top as a compact segmented pill */
.notes-sort { gap: 0; border: 1px solid var(--color-line); border-radius: var(--radius-pill); padding: 2px; }
.notes-sort .chip { border: 0; }
.notes-sort .chip:has(input:checked) { background: var(--color-accent-soft); color: var(--color-fg); }

/* reduced tag chips: a handful up front, the rest behind the "+N more" toggle (island-driven) */
.notes-tags__rest { display: contents; }
.notes-tags__rest[hidden] { display: none; }
.notes-tags__more {
  font-size: var(--text-xs); color: var(--color-accent);
  border: 0; background: none; cursor: pointer; padding: var(--space-1) var(--space-2);
}
.notes-tags__more:hover { text-decoration: underline; }
.notes-tag-filters { font-size: var(--text-xs); }
.notes-tag-filters > summary { width: fit-content; color: var(--color-muted); cursor: pointer; }
.notes-tag-filters[open] > summary { margin-bottom: var(--space-2); }

/* ---- the feed: a plain vertical list, one hairline separator per card (content-index's own
   convention) rather than a boxed tile — the "front page" read is a rule-separated column, not
   a grid of cards. */
.note-feed { list-style: none; margin: 0; padding: 0; }
/* The ?tag= deep-link empty state: shown only when a requested tag has no notes yet (island-revealed). */
.feed-empty {
  font-size: var(--text-sm); color: var(--color-muted); font-style: italic;
  margin: 0 0 var(--space-4);
}
.feed-empty[hidden] { display: none; }
.note-card {
  display: flex; gap: var(--space-4);
  padding: var(--space-5) 0;
  border-bottom: 1px solid var(--color-line);
}
.note-card:last-child { border-bottom: 0; }
.note-card[hidden] { display: none; }

/* A small bridge from the writing shelf to the work it documents. It sits after the pinned
   flagship so the first note stays the first thing to read. */
.notes-paths {
  padding: var(--space-3) 0;
  border-bottom: 1px solid var(--color-line);
}
.notes-paths nav { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2) var(--space-4); }
.notes-paths__label { color: var(--color-muted); font-size: var(--text-xs); }
.notes-paths a { color: var(--color-fg); font-size: var(--text-sm); }
.notes-paths a:hover { color: var(--color-accent); }

@media (max-width: 40rem) {
  .notes-paths nav { gap: var(--space-2) var(--space-3); }
  .notes-paths__label { flex-basis: 100%; }
}

@media (min-width: 40.001rem) {
  /* The feed script opens filters on desktop, where the full toolbar still fits comfortably. */
  .notes-tag-filters > summary { display: none; }
}

/* the flagship, pinned to the top of the feed (content.ts FLAGSHIP_NOTE_SLUG): a quiet "featured"
   inset — an accent rule down the left edge + an accent-colored pin tag in the byline. Subtle
   enough to sit among the border-separated rows without shouting. */
.note-card--pinned {
  border-inline-start: 3px solid var(--color-accent);
  padding-inline-start: var(--space-4);
}
.note-card__pin { color: var(--color-accent); font-weight: var(--font-weight-medium, 600); white-space: nowrap; }

/* the vote glyph: a narrow fixed gutter on the left, same idea as content-index's log-variant
   graph column. The number is real reading minutes (renderNotesFeedPage), never a vote tally —
   the title="" tooltip on the element itself says so. */
.note-card__vote {
  flex: 0 0 auto; width: 2.5rem;
  display: flex; flex-direction: column; align-items: center; gap: 2px;
  padding-top: 2px;
  font-size: var(--text-xs); color: var(--color-muted);
}
.note-card__score { font-family: var(--font-mono); font-size: var(--text-sm); color: var(--color-fg); }

.note-card__main { min-width: 0; flex: 1 1 auto; display: flex; flex-direction: column; gap: var(--space-1); }
.note-card__byline {
  font-size: var(--text-xs); color: var(--color-muted);
  font-family: var(--font-mono);
  margin: 0;
}
.note-card__title { font-size: var(--text-lg); margin: 0; }
.note-card__title a { color: var(--color-fg); text-decoration: none; }
.note-card__title a:hover { text-decoration: underline; }
.note-card__summary {
  color: var(--color-muted); line-height: var(--leading-relaxed);
  max-width: 60ch; margin: 0;
}
.note-card__foot {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3);
  margin: var(--space-1) 0 0;
  font-size: var(--text-xs);
}
.note-card__sections { color: var(--color-muted); text-decoration: none; }
.note-card__sections:hover { text-decoration: underline; }
.note__tags { display: flex; flex-wrap: wrap; gap: var(--space-2); margin: 0; }

/* AI / in-transit idiom (grain lesson 3): a card the AI is currently traveling through wears the
   dashed terminal edge, same vocabulary as content-index's own [data-commit="pending"] rule. */
.note-card[data-commit="pending"],
[data-grade="grain"] .note-card { border-bottom-style: dashed; }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/molecules/note-contents/note-contents.css */
/* note-contents — the summary and section list that sit under a long note's masthead.
 *
 * Emitted by src/content.ts (renderNoteContents) for any /notes entry with eight or more sections,
 * spliced in after the note head. Two elements, deliberately siblings: the progress rule sticks to
 * the top of the reading pane for the whole article, which it can only do while its parent box is
 * the full-height board rather than the short aside beside it.
 *
 * Everything here is theme tokens, so it inverts with the page like the flow figures do. No colour
 * carries meaning: the summary is set apart by a rule and a measure, not by a tint.
 */

/* --- the progress rule -------------------------------------------------------
   Sticky rather than fixed, so it belongs to the reading column and cannot end up floating over
   the shell chrome on a narrow window. The fill is driven by --read, set by scripts/note-progress.js;
   with no JS the custom property never resolves, the fill is zero-width, and what is left is a
   hairline that reads as part of the rule under the masthead. */
/* The TRACK is transparent, not a hairline. The note head already ends in a rule, and a second
   full-width line two pixels under it reads as a mistake rather than as a control. Only the fill
   is ever drawn, so at the top of a note there is nothing there at all. */
.note-progress {
  position: relative;
  position: sticky; top: 0; z-index: 4;
  height: 2px; margin: 0 0 var(--space-5);
}
.note-progress__fill {
  display: block; height: 100%;
  width: calc(var(--read, 0) * 100%);
  background: var(--color-fg);
}
/* Where you are, pinned to the rule because the rule is the only part of this block still on screen
   once somebody is reading. Hidden at the top, where the masthead already answers the question. */
.note-progress__where {
  position: absolute; right: 0; top: 0;
  font-size: .72rem; line-height: 1; color: var(--color-muted);
  /* It overlays flowing prose, so it needs a solid ground and real padding. Without both, the
     paragraph behind it shows through the gaps and the whole thing reads as a rendering fault
     rather than as a control. */
  background: var(--color-bg);
  border: 1px solid var(--color-line); border-top: 0;
  border-radius: 0 0 4px 4px;
  padding: .35rem .6rem;
  max-width: 70%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.note-progress__where[hidden] { display: none; }

/* --- the summary -------------------------------------------------------------
   The frontmatter summary, which every note already has and which used to be visible only on the
   index card. Indented behind a rule so it reads as an abstract rather than as the first paragraph
   of the note, which is the one thing it must not be mistaken for. */
.note-contents { margin: 0 0 var(--space-6); }
.note-contents__summary {
  margin: 0 0 var(--space-4);
  padding-left: var(--space-4);
  border-left: 2px solid var(--color-line);
  color: var(--color-muted);
  max-width: 34rem;
}

/* --- the contents ------------------------------------------------------------
   A details element, so it costs one line closed and needs no script to open. The marker is the
   browser's own, rotated by the open state, because a hand-drawn triangle is one more thing to keep
   in step with a theme. */
.note-contents__more { margin: 0; }
.note-contents__toggle {
  cursor: pointer; list-style: none;
  display: flex; align-items: baseline; gap: .6rem;
  font-size: .92rem; color: var(--color-fg);
  padding: .35rem 0;
}
.note-contents__toggle::-webkit-details-marker { display: none; }
.note-contents__toggle::before {
  content: "›"; display: inline-block;
  transition: transform .15s linear;
  color: var(--color-muted);
}
.note-contents__more[open] .note-contents__toggle::before { transform: rotate(90deg); }
.note-contents__toggle:hover { text-decoration: underline; text-underline-offset: 3px; }
.note-contents__meta { color: var(--color-muted); font-size: .85rem; }

.note-contents__list {
  margin: .4rem 0 0; padding: 0 0 0 1.9rem;
  display: grid; gap: .3rem;
  font-size: .92rem;
}
.note-contents__list a { color: var(--color-muted); text-decoration: none; }
.note-contents__list a:hover { color: var(--color-fg); text-decoration: underline; text-underline-offset: 3px; }
/* A jumped-to heading should not land under the sticky rule. */
.board :is(h2, h3)[id] { scroll-margin-top: var(--space-5); }

@media (prefers-reduced-motion: reduce) {
  .note-contents__toggle::before { transition: none; }
}

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/molecules/page-foot/page-foot.css */
/* molecules/page-foot — portfolio: the byline footer that closes every landing page.
   A ruled, muted sign-off; links keep full ink so they read against the muted prose. */
.page-foot { margin-top: var(--space-8); padding-top: var(--space-5);
  border-top: 1px solid var(--color-line);
  color: var(--color-muted); font-size: var(--text-sm); }
.page-foot a { color: var(--color-fg); }
.page-foot em { font-style: normal; text-decoration: underline; }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/molecules/profile-card/profile-card.css */
/* molecules/profile-card — a person header: avatar + id block (eyebrow, name, role, tagline) + an
   actions group, wrapping on narrow widths. Shared by /about and /resume (promoted out of both pages'
   inline styles). Tokens only; re-skins with the theme. CSS-only component (no .html) — the markup is
   authored per page; see profile-card.md. */
.profile-card { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap;
  padding: var(--space-5); margin: var(--space-5) 0 var(--space-4);
  border: 1px solid var(--line-soft); border-radius: var(--radius-md); background: var(--paper-2); }
.profile-card__avatar { flex: 0 0 auto; width: 3.5rem; height: 3.5rem; border-radius: var(--radius-pill);
  background: var(--paper); border: 1px solid var(--line-soft); display: flex; align-items: center;
  justify-content: center; font-size: 1.6rem; }
.profile-card__id { flex: 1 1 16rem; min-width: 0; }
.profile-card__id .eyebrow { margin: 0 0 var(--space-1); }
.profile-card__name { margin: 0 0 var(--space-1); }
.profile-card__role { margin: 0 0 var(--space-1); color: var(--ink-muted); font-size: var(--text-sm); }
.profile-card__tagline { margin: 0; font-family: var(--font-mono); font-size: var(--text-xs); color: var(--ink-muted); }
.profile-card__actions { flex: 0 0 auto; display: flex; gap: var(--space-2); flex-wrap: wrap; }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/molecules/share-block/share-block.css */
/* molecules/share-block — the ready-to-post copy of a /calendar event. Bundled into /components.css
   via config.styleRoots. Not screen-scoped: it renders inside a MILL entry page's .board, and the
   only thing that puts one there is an event with a `social:` block. */
.share-block { margin: var(--space-6) 0 0; padding: var(--space-4); background: var(--paper-2);
  border: 1px solid var(--line-soft); border-radius: var(--radius-md); }
/* folded by default: the copy is a tool for the author, not part of what a reader came to read. */
.share-block__head { font-size: var(--text-xs); font-family: var(--font-mono); text-transform: uppercase;
  letter-spacing: .04em; color: var(--ink-muted); cursor: pointer; }
.share-block__head:hover { color: var(--ink); }
.share-block[open] .share-block__head { margin: 0 0 var(--space-3); }
.share-block__text { margin: 0; white-space: pre-wrap; font-family: inherit; font-size: var(--text-sm);
  line-height: var(--leading-relaxed); color: var(--ink); }
.share-block__row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3);
  margin: var(--space-3) 0 0; }
.share-block__copy { font: inherit; font-size: var(--text-xs); font-family: var(--font-mono);
  padding: var(--space-1) var(--space-3); border: 1px solid var(--line-soft);
  border-radius: var(--radius-sm); background: var(--paper); color: var(--ink); cursor: pointer; }
.share-block__copy:hover { border-color: var(--color-accent); }
.share-block__copy[data-copied] { border-color: var(--color-accent); color: var(--color-accent); }
.share-block__link { font-size: var(--text-xs); font-family: var(--font-mono); color: var(--ink-muted);
  overflow-wrap: anywhere; }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/molecules/shot/shot.css */
/* molecules/shot — portfolio: a screenshot of a real running interface, shown as evidence.
   Raster, unlike the inline-SVG figures FIGURES covers: the whole point of these is that they are
   unretouched captures of the app actually running, so they cannot be scaffolded as SVG. Hairline
   frame so the capture reads as a framed artefact rather than bleeding into the page, caption
   underneath in the muted voice. Consumers set loading="lazy" plus width/height on the img so the
   page reserves space and never blocks on a screenshot. */
.shot { margin: var(--space-4) 0; }
.shot__img { display: block; width: 100%; height: auto; border: 1px solid var(--color-line); }
.shot__cap { margin-top: var(--space-2); color: var(--color-muted); font-size: var(--text-sm); }

/* There is deliberately no side-by-side variant. The board's content column is around 688px, so a
   two-up grid would render each capture near 340px wide, and at that size the UI text these shots
   exist to show is unreadable. Captures stack full width; if two belong together, say so in their
   captions instead of shrinking them. */

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/molecules/stack-diagram/stack-diagram.css */
/* molecules/stack-diagram — the shared four-layers figure (see stack-diagram.html).
   Deliberate FIGURES deviation: the palette is the SITE's live theme tokens, not the fixed
   FIGURES hex, so the figure re-skins with sourdough / baguette / brioche and light↔dark. No
   chromatic accent — the only emphasis is ink-fill (current) vs outline (the rest). */
.stack-diagram {
  display: block;
  width: 100%;
  max-width: 560px;
  height: auto;
  margin: 0 0 var(--space-5);
  font-family: Georgia, "Times New Roman", serif;   /* FIGURES: serif always */
}

/* --- resting state: every layer outlined, PANTRY frame quiet, direction cue muted --- */
.stack-diagram .frame { fill: none; stroke: var(--color-line); stroke-width: 1; }
.stack-diagram .frame-label { fill: var(--color-muted); font-size: 12.5px; }

.stack-diagram .bar { fill: none; stroke: var(--color-muted); stroke-width: 1; }
.stack-diagram .bar-label { fill: var(--color-fg); font-size: 14px; letter-spacing: 0.08em; }

.stack-diagram .dir-line,
.stack-diagram .dir-head { stroke: var(--color-muted); stroke-width: 1.5;
  stroke-linecap: round; stroke-linejoin: round; }

/* --- current layer = ink-filled, its name in paper (one rule per member; CSS can't test
       [data-current] value-equals [data-layer], so the four are spelled out) --- */
.stack-diagram[data-current="batch"] [data-layer="batch"] .bar,
.stack-diagram[data-current="grain"] [data-layer="grain"] .bar,
.stack-diagram[data-current="mill"]  [data-layer="mill"]  .bar,
.stack-diagram[data-current="proof"] [data-layer="proof"] .bar,
.stack-diagram[data-current="crumb"] [data-layer="crumb"] .bar {
  fill: var(--color-fg);
  stroke: var(--color-fg);
}
.stack-diagram[data-current="batch"] [data-layer="batch"] .bar-label,
.stack-diagram[data-current="grain"] [data-layer="grain"] .bar-label,
.stack-diagram[data-current="mill"]  [data-layer="mill"]  .bar-label,
.stack-diagram[data-current="proof"] [data-layer="proof"] .bar-label,
.stack-diagram[data-current="crumb"] [data-layer="crumb"] .bar-label {
  fill: var(--color-bg);
}

/* --- /pantry: the composition-root frame is the subject, so emphasize it --- */
.stack-diagram[data-current="pantry"] .frame { stroke: var(--color-fg); stroke-width: 2; }
.stack-diagram[data-current="pantry"] .frame-label { fill: var(--color-fg); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/molecules/task-card/task-card.css */
/* molecules/task-card */
.task-card {
  display: flex; align-items: center; gap: var(--space-3);
  background: var(--color-surface); border: 1px solid var(--color-line);
  border-radius: var(--radius-md); padding: var(--space-3) var(--space-4);
}
.card-title { font-weight: var(--font-weight-semibold); flex: 1; margin: 0; }

/* grade = commit state: in-transit reads grain — dimmed with a dashed outline; the
   title already swaps to the grain family via --type-font. */
.task-card { transition: opacity .2s ease, outline-color .2s ease; }
.task-card[data-commit="pending"],
[data-grade="grain"] .task-card {
  opacity: .6;
  outline: 1px dashed var(--color-line);
  pointer-events: none;
}
@media (prefers-reduced-motion: reduce) { .task-card { transition: none; } }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/molecules/walk-latest/walk-latest.css */
/* molecules/walk-latest — the feed walkthrough on the Welcome page. It IS a .walk (see
   walkthrough-card.css for the card itself); the only thing this file adds is how the bound title
   and date sit inside the body, since the text comes from the newest post rather than a sentence
   someone wrote for the card. */
.walk--latest .walk-latest__title { display: block; color: var(--ink); }
.walk--latest .walk-latest__date { display: block; margin-top: 0.15rem;
  font-size: var(--text-xs); font-family: var(--font-mono); color: var(--ink-muted); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/molecules/walkthrough-card/walkthrough-card.css */
/* molecules/walkthrough-card — portfolio: a Welcome page "Walkthroughs" card (bordered link
   card: icon + head, optional mono badge, muted body). No progress meters: a meter that
   measures nothing is decoration pretending to be data (honest-status doctrine) — they return
   if/when they can mean something real. */
.walks { display: flex; flex-direction: column; gap: 0.9rem; }
.walk { position: relative; display: block; text-decoration: none; color: var(--ink);
  background: var(--panel); border: 1px solid var(--color-line); padding: 1rem 1.1rem 1.2rem; }
.walk:hover { border-color: var(--ink); }
/* the featured walkthrough (the flagship, pinned to the top): an accent rule down the left edge,
   the same quiet "featured" signal the pinned note card wears in the /notes feed. */
.walk[data-featured] { border-inline-start: 3px solid var(--color-accent); }
.walk__head { display: flex; align-items: center; flex-wrap: wrap; gap: 0.6rem; font-weight: var(--font-weight-medium); }
.walk__title { flex: 1 1 12ch; min-width: 0; }
.walk__badge { font-size: var(--text-xs); font-family: var(--font-mono); letter-spacing: 0.05em;
  border: 1px solid var(--color-line); padding: 0.1rem 0.45rem; color: var(--ink-muted); margin-inline-start: auto; }
.walk__body { color: var(--ink-muted); font-size: var(--text-sm); margin: 0.45rem 0 0; }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/molecules/welcome-recent/welcome-recent.css */
/* molecules/welcome-recent — portfolio: the Welcome page's "Recent" feed rows (name + muted
   mono path — the notes as the editor's recent files). The wrapping .recent column is part
   of this component's vocabulary; each row is the data-bound welcome-recent tag. */
.recent { display: flex; flex-direction: column; gap: 0.55rem; container: recent-list / inline-size; }
.recent__item { display: grid; grid-template-columns: minmax(0, 1fr) auto;
  align-items: baseline; gap: var(--space-1) var(--space-4); width: 100%; min-width: 0;
  text-decoration: none; font-size: var(--text-base); }
.recent__name { color: var(--color-accent, var(--ink)); }
.recent__item:hover .recent__name { text-decoration: underline; }
.recent__path { max-width: min(40vw, 18rem); overflow: hidden; color: var(--ink-muted);
  font-size: var(--text-sm); font-family: var(--font-mono); text-overflow: ellipsis; white-space: nowrap; }

@container recent-list (max-width: 32rem) {
  .recent__item { grid-template-columns: minmax(0, 1fr); gap: var(--space-1); }
  .recent__path { max-width: 100%; white-space: normal; overflow-wrap: anywhere; }
}

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/molecules/welcome-start/welcome-start.css */
/* molecules/welcome-start — portfolio: the Welcome page's "Start" list (icon + link rows,
   the VS Code Start list). CSS-only; the rows are real <a>s — zero-JS navigable. */
.start { display: flex; flex-direction: column; gap: 0.65rem; margin-bottom: 2.5rem; }
.start__item { display: flex; align-items: center; gap: 0.7rem; width: fit-content;
  color: var(--color-accent, var(--ink)); text-decoration: none; font-size: var(--text-base); }
.start__item:hover { text-decoration: underline; }
.start__item .icon { color: var(--ink-muted); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/molecules/work-card/work-card.css */
/* molecules/work-card — portfolio: a navigable tile on the home "Work" grid. Composes
   GRAIN's a.card (requires class="card work-card" on the root — the border, hover, and
   AI-mode edge come from card.css); this file owns only the work-specific anatomy. */
.work-card { display: flex; flex-direction: column; gap: var(--space-2); }
.work-card__head { display: flex; align-items: baseline; justify-content: space-between;
  gap: var(--space-3); }
.work-card__name { font-size: var(--text-xl); }
.work-card__kind { font-size: var(--text-xs); text-transform: uppercase;
  letter-spacing: 0.08em; color: var(--color-muted); }
.work-card__go { font-size: var(--text-sm); text-decoration: underline; margin-top: auto; }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/organisms/empty-state/empty-state.css */
/* organisms/empty-state */
.empty-state { color: var(--color-muted); text-align: center; padding: var(--space-6); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/organisms/portfolio-frame/portfolio-frame.css */
/* organisms/portfolio-frame — product-specific chrome for the PORTFOLIO (the reusable shell CSS is
   grain: app-shell / side-rail / sidebar-panel / console / topbar). Only the portfolio's choices
   live here: the flat e-ink tone, the current-nav accent, and the brand. Sorts last in the bundle
   ("portfolio" > "grain"/"project"), so these overrides win. */

/* flat e-ink tone: one paper surface separated by hairlines (DESIGN-SYSTEM §6) — no tinted strips */
.app-shell__topbar, .app-shell__aside { background: var(--color-bg); }

/* the brand (owner, 2026-07-06): a full-width row at the top of the RAIL — the same height as the
   tab rail beside it (var(--editor-header-h)), with the sidebar (.rail-body: activity-bar +
   explorer) starting on its own line below. Its icon sits in a 3.25rem column matching the
   activity-bar's width directly beneath it, so the two align in one continuous left column. */
.rail-head {
  display: grid; grid-template-columns: 3.25rem auto; align-items: center;
  min-height: var(--editor-header-h, 2.9rem);
  border-bottom: 1px solid color-mix(in srgb, var(--color-line) 55%, transparent);
  font-family: var(--font-accent); color: var(--color-fg); white-space: nowrap;
}
.rail-head > b-icon, .rail-head > .icon { justify-self: center; color: var(--color-accent); }

/* current main-menu item (rail): the selection indicator runs FLUSH to the rail's left edge
   (a full-height ink accent bar breaking out past the rail's own padding), plus a quiet fill.
   "All the way to the left" — the bar sits at x=0 of the rail, not inset by the padding. */
.side-rail .nav-item[aria-current="page"] {
  background: var(--color-bg); border-color: transparent; color: var(--color-fg);
  position: relative;
}
.side-rail .nav-item[aria-current="page"]::before {
  content: ""; position: absolute; top: 0; bottom: 0;
  left: calc(-1 * var(--space-2));                 /* cancel the rail's left padding → flush to the edge */
  width: 2px; background: var(--color-accent);
}

/* THE EDITOR v2 nav model (owner, 2026-07-05): the RAIL is the EXPLORER (grain file-tree of the
   real sources); the TABS are the OPEN PAGES (one global strip, grain/scripts/tabs.js). The old
   per-section tab groups (data-tabs-for) are gone. The tree takes the rail's stretch between the
   brand and the spacer, and scrolls inside itself. */
.side-rail .file-tree { flex: 0 1 auto; min-height: 0; margin: 0 calc(-1 * var(--space-2)); }
/* full-bleed tree rows (the negative margin above) still start their text at the rail padding */
.side-rail .file-tree__dir > summary,
.side-rail .file-tree > .file-tree__dir > summary { padding-left: var(--space-3); }

/* THE EDITOR v2 nav model, restructured (owner, 2026-07-13): the RAIL BODY is now a flex COLUMN
   with two zones — the EXPLORER (.rail-explorer: the grain activity-bar icon toggle + the
   file-tree, side by side) on top, and the APP DOCK (labeled app rows) pinned full-width below
   it. Wrapping the activity-bar + side-rail in .rail-explorer takes .activity-bar out from being
   a DIRECT child of .rail-body, so grain's app-shell.css `:has(> .activity-bar)` flex-ROW opt-in
   never matches — this sheet owns the column/row split instead (bundle order still wins: this
   file sorts after app-shell.css/activity-bar.css). */
.rail-body { display: flex; flex-direction: column; min-height: 0; }
.rail-explorer { display: flex; flex-direction: row; align-items: stretch; flex: 1 1 auto; min-height: 0; }
.rail-explorer > .side-rail { flex: 1 1 auto; min-width: 0; }
/* collapsed rail (icons-only): grain's own `:has(> .activity-bar)` collapse rule can't reach the
   tree either, for the same reason — restate it here so the collapse still hides it. */
[data-rail-collapsed="true"] .rail-explorer > .side-rail { display: none; }

/* ---- the APP DOCK: fixed, labeled app rows pinned to the rail's bottom. Each row's icon sits in
   a 3.25rem leading gutter — the SAME width as the activity-bar column directly above it — so the
   glyphs line up in one continuous left column from the brand icon down through the dock. The
   gutter carries the SAME right-border "line" as grain's .activity-bar (owner, 2026-07-13: "keep
   the line of the icon column, text should be on the column next to it"), full row height on every
   item, so the divider reads as ONE continuous vertical line from the activity-bar down through
   every dock row — not a line that stops partway. ---- */
.app-dock { flex: 0 0 auto; display: flex; flex-direction: column;
  padding-top: var(--space-2);
  border-top: 1px solid color-mix(in srgb, var(--color-line) 55%, transparent); }
.app-dock__item {
  display: grid; grid-template-columns: 3.25rem 1fr; align-items: stretch;
  /* taller rows give the dock the vertical breathing room the rows lacked. FIXED height (not
     min-height) + border-box so every row is EXACTLY the same regardless of a row's content, the
     mono font's metrics, or browser zoom — no row can grow past its neighbors. Height, not
     padding-block, so the icon gutter's full-height border-right stays edge-to-edge and the divider
     reads as ONE continuous line from the activity-bar down (see block comment above). */
  box-sizing: border-box; height: 2.9rem; padding: 0 var(--space-3) 0 0;
  color: var(--color-muted); text-decoration: none; position: relative;
}
/* a dock ACTION (the CRUMB tour launcher): a <button> wearing the dock-item layout — strip ALL
   native button chrome (incl. the UA padding/line-height that made it 2px off the <a> rows) so it
   reads pixel-identical to the anchors. */
.app-dock__item--action { appearance: none; border: 0; margin: 0; padding: 0 var(--space-3) 0 0;
  background: transparent; font: inherit; line-height: normal; text-align: left; width: 100%; cursor: pointer; }
/* the divider that opens the META group (About + Tour) — a quiet horizontal rule, same weight as
   the dock's own top border. Drawn as a border-top so border-box keeps the row's height uniform. */
.app-dock__item--divide { border-top: 1px solid color-mix(in srgb, var(--color-line) 55%, transparent); }
.app-dock__icon { display: flex; align-items: center; justify-content: center;
  border-right: 1px solid color-mix(in srgb, var(--color-line) 55%, transparent); }
.app-dock__icon svg { width: 1.15rem; height: 1.15rem; }
.app-dock__label { font-family: var(--font-mono); font-size: var(--text-xs); letter-spacing: 0.01em;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; align-self: center;
  padding-inline-start: var(--space-3); }
.app-dock__item:hover { color: var(--color-fg);
  background: color-mix(in srgb, var(--color-surface) 70%, transparent); }
/* the ACTIVE app: the same flush accent left-bar + quiet fill as the open file in the tree above
   (.side-rail .nav-item[aria-current="page"]), run across the FULL-WIDTH row — not the
   activity-bar's floating chip. The rail has no left padding down here, so the bar sits at x=0
   with no offset needed to cancel it (unlike the tree's ::before, which cancels the tree's own
   padding). */
.app-dock__item[aria-current="page"] { color: var(--color-fg); background: transparent; }
.app-dock__item[aria-current="page"]::before {
  content: ""; position: absolute; top: 0; bottom: 0; left: 0;
  width: 2px; background: var(--color-accent);
}
.app-dock__item:focus-visible { outline-offset: -3px; }
/* collapsed rail: labels are noise at icon-only width (mirrors the file-tree's own collapsed rule
   in grain's file-tree.css) */
[data-rail-collapsed="true"] .app-dock__label { display: none; }

/* Project pages share a compact set of next steps below the introduction. Long case studies add a
   separate jump list, so the useful paths stay near the top without turning every page into a card. */
.project-links {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2) var(--space-4);
  margin: var(--space-4) 0 var(--space-6);
  padding-bottom: var(--space-3);
  border-bottom: 1px solid var(--color-line);
}
.project-links a {
  color: var(--color-fg);
  text-decoration-color: var(--color-line);
  text-underline-offset: 0.22em;
}
.project-links a:hover { text-decoration-color: currentColor; }
.project-toc {
  margin: 0 0 var(--space-7);
  padding: var(--space-3) var(--space-4);
  border-left: 2px solid var(--color-accent, var(--color-muted));
  background: color-mix(in srgb, var(--color-surface) 55%, transparent);
}
.project-toc__label {
  margin: 0 0 var(--space-2);
  color: var(--color-muted);
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
.project-toc__links { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-4); }
.project-toc__links a { text-underline-offset: 0.22em; }
.project-toc--compact { margin: var(--space-2) 0 var(--space-4); padding: 0; border: 0; background: transparent; }
.project-toc--compact .project-toc__links { gap: var(--space-2) var(--space-4); font-size: var(--text-sm); }
.project-toc ~ [id] { scroll-margin-top: var(--space-5); }

/* the open-tabs strip (grain's tab-bar, overflow-x: auto, native scrollbar hidden): two
   affordance fixes for when it overflows (owner, 2026-07-13).
   (a) OVERFLOW CUE: grain hides the native scrollbar outright (scrollbar-width: none /
   ::-webkit-scrollbar{display:none} in tab-bar.css), so a scrollable strip gives no visual hint
   there's more — a quiet trailing fade, STUCK to the scrollport's own right edge (so it travels
   with the view instead of scrolling away with the content), signals "more tabs" without ever
   drawing a scrollbar. Self-conditional with no JS: when nothing overflows, the strip's trailing
   space is already this same background color, so the gradient is invisible; it only reads once
   a real tab scrolls in underneath it.
   (b) THE "ELCOME" CLIP: site.js's boot-time scrollIntoView({block:"nearest"}) (the current tab
   can render off-screen with enough open tabs) scrolls the strip just enough to reveal the
   CURRENT tab — which can leave the PINNED Welcome tab only partially in view at the left edge
   (verified: /mill with 12 tabs open left it reading "elcome", the W and pin icon cropped).
   scroll-padding on the container does NOT protect a bystander element from this (only the
   scrollIntoView target's own alignment) — the fix is to make the pinned tab itself un-croppable:
   sticky to the scrollport's own left edge, so the OTHER tabs scroll underneath it instead of it
   ever scrolling out from under itself. */
.app-shell__tabs.tab-bar a[data-pinned] {
  position: sticky; left: 0; z-index: 2;
  background: var(--color-bg);
}
.app-shell__tabs.tab-bar::after {
  content: ""; position: sticky; right: 0; align-self: stretch;
  width: 1.5rem; margin-left: -1.5rem; flex: none; z-index: 1;
  pointer-events: none;
  background: linear-gradient(to right, transparent, var(--color-bg));
}

/* Chat⇄Catalog modes are available sitewide (no per-section gating). */

/* presence show/hide is the grain AI module's (ai.css keys the three .presence__* states off
   <body data-ai-online>); only the wording here is the portfolio's. */

/* status bar: the breadcrumb (moved here from the title bar) reads as quiet mono meta next to
   presence; the byline + contact keep the muted register and sharpen on hover. */
.status-bar { font-family: var(--type-font); }
.status-bar__crumb { color: var(--color-muted); }
/* the breadcrumb's segments are LINKS (site.js builds them): quiet until hovered, like the rest
   of the status bar; the current (last) segment stays plain text. */
.status-bar__crumb a { color: inherit; text-decoration: none; }
.status-bar__crumb a:hover { color: var(--color-fg); text-decoration: underline; }
/* explicit mono (not just inherited) so the byline/contact links can't drift off the status
   bar's font if a more specific `a` rule ever lands ahead of them in the bundle */
.status-bar__byline, .status-bar__contact { color: inherit; text-decoration: none; }
.status-bar__byline { display: inline-flex; align-items: center; gap: var(--space-1); }
.status-bar__byline:hover, .status-bar__contact:hover { color: var(--color-fg); }
/* the view counts (src/analytics.ts fills them at build time): the quietest thing on the bar, so
   the numbers are there to be looked up rather than announcing themselves. :empty collapses the
   gap entirely when there is no data, which is the normal state on a local run. */
.status-bar__views { color: var(--color-muted); }
.status-bar__views:empty { display: none; }


/* assistant empty-state: centered prompt shown until the first message lands. The higher
   specificity (p.assistant__empty) beats sidebar-panel's `> :first-child { margin-top:auto }`
   bottom-pin, so the prompt centers instead of sticking to the composer. */
.assistant__log > p.assistant__empty { margin: auto; max-width: 26ch; text-align: center;
  color: var(--color-muted); font-size: var(--text-sm); line-height: var(--leading-relaxed); }
.assistant__log:has(.chat-message) .assistant__empty { display: none; }

/* suggestion chips: a fast lane into the SAME one door as the composer. They stay visible the whole
   conversation (the desk swaps in follow-ups drawn from what it just answered) — only Desk Offline
   hides them (below). Sits just above the composer so it reads as "or try one of these". */
/* Compact: the chips were eating ~5 rows above the composer (a "TRY ASKING" label + 4 chips that
   wrapped in the narrow aside). Now ONE quiet scrolling row — label dropped (the composer placeholder
   already says "Ask the desk"), chips on a single line that scrolls sideways, with a THIN scrollbar
   that cues the overflow (owner, 2026-07-23: a hidden bar left no hint the row scrolled), so the
   conversation keeps the vertical space. */
.assistant__suggest { display: flex; flex-direction: column; gap: 0;
  padding: var(--space-2) var(--space-4); }
.assistant__suggest-label { display: none; }
.assistant__suggest-chips { display: flex; flex-wrap: nowrap; gap: var(--space-2);
  overflow-x: auto; scrollbar-width: thin; scrollbar-color: var(--color-line) transparent;
  padding-bottom: 2px; }
.assistant__suggest-chips::-webkit-scrollbar { height: 6px; }
.assistant__suggest-chips::-webkit-scrollbar-thumb { background: var(--color-line); border-radius: 999px; }
.assistant__suggest-chips::-webkit-scrollbar-track { background: transparent; }
.suggest-chip { flex: 0 0 auto; }
.suggest-chip { font: inherit; font-size: var(--text-sm); color: var(--color-fg);
  background: var(--color-bg); border: 1px solid var(--color-line); border-radius: 999px;
  padding: var(--space-1) var(--space-3); cursor: pointer; white-space: nowrap;
  transition: border-color 0.15s ease, background 0.15s ease; }
.suggest-chip:hover { border-color: var(--ink); background: var(--color-surface); }
.suggest-chip:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }

/* the desk's model-load progress bar (rendered into the chat bubble while the local model downloads).
   A real track + accent fill, honest about the one-time ~350MB cost. */
.desk-load { display: inline-flex; flex-direction: column; gap: var(--space-1); width: 100%; }
.desk-load__title { color: var(--color-muted); font-size: var(--text-sm); line-height: var(--leading-snug); }
.desk-load__bar { display: block; height: 6px; width: 100%; max-width: 16rem; overflow: hidden;
  background: var(--color-line); border-radius: 999px; }
.desk-load__fill { display: block; height: 100%; background: var(--color-accent);
  transition: width 0.3s ease; }
.desk-load__pct { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--color-muted); }

/* the desk's "Read more" citation — the deterministic source link under a grounded answer (the
   route comes from the top retrieval chunk, written by code, never the model). */
.desk-cite { display: block; margin-top: var(--space-2); padding-top: var(--space-1);
  border-top: 1px solid var(--color-line); font-size: var(--text-sm); color: var(--color-muted); }
.desk-cite a { color: var(--color-accent); }

/* the desk's "Thinking…" indicator — shown in the bubble the instant a turn starts, until real
   content arrives, so there's never dead air. */
.desk-typing { color: var(--color-muted); font-size: var(--text-sm); }
.desk-typing i { display: inline-block; width: 4px; height: 4px; margin-left: 3px; border-radius: 50%;
  background: currentColor; opacity: 0.3; animation: desk-blink 1.2s ease-in-out infinite; }
.desk-typing i:nth-child(2) { animation-delay: 0.15s; }
.desk-typing i:nth-child(3) { animation-delay: 0.3s; }
@keyframes desk-blink { 0%, 65%, 100% { opacity: 0.25; } 32% { opacity: 0.9; } }
@media (prefers-reduced-motion: reduce) { .desk-typing i { animation: none; opacity: 0.55; } }

/* New chat: a quiet reset in the assistant head. Left-group the title + modes (override grain's
   space-between) and push this to the far right; hidden with the composer when the desk is offline
   (there's no conversation to reset then). */
/* flex-wrap so the header (mode tabs + New chat) can never force a horizontal scroll in the narrow
   panel — if it can't fit on one row it drops to a second instead of overflowing sideways. */
.assistant__head { justify-content: flex-start; gap: var(--space-2); flex-wrap: wrap; }
.assistant__newchat { margin-left: auto; font: inherit; font-size: var(--text-xs);
  font-family: var(--font-mono); color: var(--color-muted); background: none;
  border: 1px solid var(--color-line); border-radius: 999px; padding: var(--space-1) var(--space-3);
  cursor: pointer; white-space: nowrap; transition: border-color 0.15s ease, color 0.15s ease; }
.assistant__newchat:hover { color: var(--color-fg); border-color: var(--ink); }
.assistant__newchat:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
body[data-desk="offline"] .assistant__newchat { display: none; }
/* on mobile the head IS the grab bar (grain adds a tap-to-open + a chevron) — a button there would
   fight the tap and crowd the narrow bar, so drop it (the sheet has its own affordances). */
@media (max-width: 768px) { .assistant__newchat { display: none; } }

/* Desk Offline (body[data-desk="offline"], set by the desk door when the local browser model can't
   run — no WebGPU, or a load/generation failure). This is a portfolio-owned, CHAT-SPECIFIC marker,
   deliberately NOT the global presence flag (data-ai-online): the door + stub demos stay ONLINE, so
   "See what's new" and the /grain demo keep working; only the ways to TALK to the local model hide.
   Offline hides the WHOLE chat surface — greeting, log, thinking preview, composer, chips — and
   centers a single honest notice in the pane. Nothing to talk to, so nothing chat-shaped should
   linger (the old "keep the log for a stub digest" behavior left the greeting bubble visible). */
.assistant__offline { display: none; }
body[data-desk="offline"] .assistant__composer,
body[data-desk="offline"] .assistant__suggest,
body[data-desk="offline"] .assistant__empty,
body[data-desk="offline"] .assistant__log,
body[data-desk="offline"] .chat-thinking { display: none; }
body[data-desk="offline"] .assistant__offline {
  display: flex; flex: 1; flex-direction: column; align-items: center; justify-content: center;
  gap: var(--space-2); text-align: center; padding: var(--space-6) var(--space-4);
  color: var(--color-muted); font-size: var(--text-sm); line-height: var(--leading-relaxed); margin: 0; }
body[data-desk="offline"] .assistant__offline b-icon { color: var(--ink-faint); flex: none; }

/* The status-bar presence tracks the DOOR (grain's data-ai-online), which stays online when the
   client loopback comes up — so it reads "desk online" even while the CHAT model can't run. That
   misleads: the visitor can't actually talk to the desk. When the desk chat is offline, force the
   offline label + faint star here, whatever the transport presence says. The `.presence` in the
   chain lifts specificity above grain's ai.css `body[data-ai-online=...] .presence__*` rules so this
   wins regardless of bundle order. */
body[data-desk="offline"] .presence .presence__on,
body[data-desk="offline"] .presence .presence__wait { display: none; }
body[data-desk="offline"] .presence .presence__off { display: inline; }
body[data-desk="offline"] .presence .presence__star { color: var(--ink-faint); }

/* Catalog pane: the embedded /catalog fills the pane; the footer sits where the chat composer
   sits (the hover hint + the full-page way out). Scroll stays contained to the pane. */
.catalog-pane__frame { flex: 1; width: 100%; border: 0; background: var(--color-bg);
  overscroll-behavior: contain; }
.catalog-pane__foot { border-top: 1px solid var(--color-line); padding: var(--space-3) var(--space-4);
  display: flex; align-items: center; justify-content: space-between; gap: var(--space-3);
  font-size: var(--text-xs); color: var(--color-muted); }
.catalog-pane__expand { color: var(--color-muted); text-decoration: none;
  border-bottom: 1px solid var(--color-line); white-space: nowrap; }
.catalog-pane__expand:hover { color: var(--ink); border-color: var(--ink); }
/* the hover-to-reveal hint only where a hovering pointer exists (on touch the pane shows the
   full scrollable catalog instead — catalog-peek.js) */
.catalog-pane__hint { display: none; }
@media (hover: hover) and (pointer: fine) { .catalog-pane__hint { display: block; } }

/* (The standalone .assistant__title label was removed — the mode tabs are the label now.) */

/* the catalog wants a little more width than the chat column, but not so much that the main
   content gets cramped: a measured widen (the peek shows one component entry at a time). It's a
   grid-column change, so the content SHIFTS aside (never overlaid). */
.app-shell:has(.assistant[data-mode="catalog"]) { --shell-aside: clamp(18rem, 26vw, 24rem); }


/* ═══ MOBILE: the assistant as a BOTTOM SHEET ═══════════════════════════════════════════════
   grain's app-shell.css hides the aside on mobile ("hidden here until its bottom-sheet form
   lands") but its shell.js ALREADY ships the mechanism — `data-aside-open` is toggled by the
   grab bar (`.assistant__head`) and the mode tabs. This is the missing PRESENTATION: the whole
   point of the site — "I direct, Claude types" — was unreachable on a phone. The aside re-appears
   as a bottom sheet COLLAPSED TO ITS HEADER (a peeking grab bar); tapping it raises the full chat,
   the scrim or another tap lowers it. Portfolio-owned override (bundled after grain), ships
   without a grain repin. Design contract lives in e2e/mobile.e2e.ts. */
@media (max-width: 768px) {
  /* the aside is a fixed sheet; when closed it's pushed down so only the header peeks (a grab
     bar), instead of vanishing (grain's display:none can't animate and left no way back in). */
  .app-shell.app-window .app-shell__aside {
    --sheet-peek: 3.5rem;
    display: flex; flex-direction: column;
    position: fixed; inset: auto 0 0 0; z-index: 44;
    height: min(85vh, 40rem); max-height: 85vh;
    border-top: 1px solid var(--color-line);
    border-top-left-radius: var(--radius-md); border-top-right-radius: var(--radius-md);
    background: var(--color-surface);
    box-shadow: 0 -0.5rem 2rem color-mix(in srgb, var(--ink) 22%, transparent);
    transform: translateY(calc(100% - var(--sheet-peek))); transition: transform 0.3s ease;
  }
  .app-shell.app-window[data-aside-open] .app-shell__aside { transform: none; }
  @media (prefers-reduced-motion: reduce) {
    .app-shell.app-window .app-shell__aside { transition: none; }
  }

  /* the head IS the grab bar: a pill handle + a clear, full-width tap target that raises/lowers
     the sheet (wired in grain's shell.js). Extra top padding leaves room for the handle. */
  .app-shell.app-window .assistant__head { position: relative; cursor: pointer;
    min-height: var(--sheet-peek); padding-top: var(--space-4); }
  .app-shell.app-window .assistant__head::before {
    content: ""; position: absolute; top: var(--space-2); left: 50%; transform: translateX(-50%);
    width: 2.25rem; height: 0.25rem; border-radius: var(--radius-pill);
    background: color-mix(in srgb, var(--ink) 24%, transparent); }

  /* the shared mobile scrim (grain sets its base rules) also veils behind the open sheet. */
  .app-shell.app-window[data-aside-open] .app-shell__scrim { opacity: 1; pointer-events: auto; }

  /* THE NAV DRAWER IS A MODAL LAYER. grain stacks the off-canvas rail at z-index 40 and its scrim
     at 39, but this file lifts the assistant bottom-sheet to z-index 44 (above) — so with the
     drawer open, the sheet's peeking grab bar pokes UP through the drawer and clips the app dock's
     lowest rows (About, half of Mail hid behind "ASSISTANT · Chat"). When the drawer is open it
     should be the topmost layer: lift its scrim above the sheet, and the drawer above the scrim,
     so the dock reads in full and a scrim tap still dismisses. */
  .app-shell.app-window[data-rail-open="true"] .app-shell__scrim { z-index: 45; }
  .app-shell.app-window[data-rail-open="true"] .app-shell__rail { z-index: 46; }

  /* the dock's last row sat flush to the screen's bottom edge; give it safe-area breathing room so
     it clears a phone's home indicator (no-op on devices without an inset). */
  .app-shell.app-window .app-dock { padding-bottom: env(safe-area-inset-bottom, 0px); }
}

/* ═══ MOBILE TITLE BAR: keep theme + the viewport toggle reachable ══════════════════════════════
   grain hides the WHOLE window-bar when the shell is narrow (`@container shell-frame` in app-window.css)
   — which also removes the theme (◆) + light/dark (◐) controls AND the viewport toggle (⬚), the only
   way back OUT of the desktop mobile-width PREVIEW (toggle in, the button vanishes, you're stuck).
   Keep a COMPACT bar with just those. Must be a @container query, not @media, so it also covers the
   preview (which clamps the container, not the real viewport). Higher specificity than grain's
   `.window-bar { display:none }`, so it wins in the same container regardless of bundle order. */
@container shell-frame (max-width: 768px) {
  .app-shell.app-window .window-bar { display: flex; justify-content: flex-end; }
  .app-shell.app-window .window-bar__nav,
  .app-shell.app-window .window-bar__search { display: none; }
  .app-shell.app-window .window-bar__ctl [data-shell="aside-toggle"],
  .app-shell.app-window .window-bar__ctl [data-shell="console-toggle"],
  .app-shell.app-window .window-bar__ctl [data-xray-toggle] { display: none; }
}

/* ---- MILLED CONTENT: a `mermaid` fenced block (site-wide override; this file sorts last).
   FIGURES keeps FLOWS in mermaid (a text DSL) and quantitative figures in inline SVG. But the
   no-build live site ships zero framework JS, so mermaid can't render client-side — the real fix is
   MILL converting `mermaid`→SVG at render/export time (tracked: CONTENT-BACKLOG "MILL must render
   mermaid", FIGURES render matrix). Until that lands, MILL emits the diagram's SOURCE as a
   `.code-block[data-lang="mermaid"]`, which otherwise reads as a broken render. Label it so it reads
   as an intentional, honest source block instead. ---- */
/* .board context raises specificity above grain's own `.code-block[data-lang]::before` lang eyebrow
   (which otherwise wins on source order), so this replaces the bare "MERMAID" tag with the honest why. */
.board .code-block[data-lang="mermaid"] { position: relative; }
.board .code-block[data-lang="mermaid"]::before {
  content: "◇ mermaid diagram — shown as source until MILL renders it live";
  display: block; margin-bottom: var(--space-2);
  font-family: var(--font-mono); font-size: var(--text-xs);
  color: var(--color-muted); letter-spacing: 0.02em; text-transform: none; white-space: normal;
}

/* ---- MILLED CONTENT: long references stay inside the app pane. MILL emits semantic tables and
   code blocks; at narrow widths, keep the table and code scrolling local to their own surface. ---- */
.app-shell__main .board table.table {
  display: block;
  max-width: 100%;
  overflow-x: auto;
}
.app-shell__main .board .code-block { max-width: 100%; }
.app-shell__main .board .code-inline { overflow-wrap: anywhere; }
.board .code-block .hljs-comment,
.board .code-block .hljs-quote { color: var(--ink-muted); font-style: italic; }
.board .code-block .hljs-keyword,
.board .code-block .hljs-selector-tag,
.board .code-block .hljs-literal,
.board .code-block .hljs-built_in,
.board .code-block .hljs-type { color: var(--color-accent); font-weight: 600; }
.board .code-block .hljs-string,
.board .code-block .hljs-attr,
.board .code-block .hljs-attribute,
.board .code-block .hljs-template-tag { color: var(--ink-muted); }
.board .code-block .hljs-number,
.board .code-block .hljs-symbol,
.board .code-block .hljs-bullet { color: var(--color-accent); font-weight: 600; }
.board .code-block .hljs-title,
.board .code-block .hljs-function,
.board .code-block .hljs-section { color: var(--ink); font-weight: 600; }
.board .code-block .hljs-meta,
.board .code-block .hljs-meta .hljs-keyword { color: var(--ink-muted); }
.docs-context-link { margin: var(--space-2) 0 0; font-size: var(--text-sm); }

/* ── the skip link ──────────────────────────────────────────────────────────────
   Hidden by clipping, not by moving it off-screen, and the difference is the whole fix. The first
   attempt parked it at translateY(-120%), which reads fine and tests fine in isolation. But
   `.app-shell` is `overflow: hidden`, so an element pushed above it is clipped with no way to
   scroll back to it, and Chromium drops a permanently-clipped element out of sequential focus
   navigation. It stayed focusable by script and unreachable by Tab, which is the exact failure a
   skip link exists to avoid, and only a real Tab press caught it. A 1px clipped box stays rendered
   inside the container, so it keeps its place in the tab order, and focus restores it to full size.
   Tokens only, so it follows the theme like everything else. */
.skip-link {
  position: absolute; top: 0; left: 0; z-index: 100;
  width: 1px; height: 1px; padding: 0; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
  background: var(--paper); color: var(--ink);
  font-size: var(--text-sm); text-decoration: underline;
}
.skip-link:focus {
  width: auto; height: auto; overflow: visible; clip-path: none;
  padding: var(--space-2) var(--space-4);
  border: var(--border); border-radius: 0 0 var(--radius-md) 0;
}

/* ── window-bar control hit areas ───────────────────────────────────────────────
   grain's `.window-bar__ctl button { all: unset }` leaves each control hugging its glyph, so the
   light/dark toggle measured 8 by 20 CSS pixels: a real miss for a finger or a shaky hand. This
   brings every one of them to 24 by 24, which is WCAG 2.5.8 at AA. It deliberately does NOT go to
   44, the 2.5.5 AAA figure the audit graded against, because the whole bar is 34 pixels tall and a
   44 pixel target would either burst it or overlap the tab strip below. The honest state is AA met,
   AAA not, rather than a number chosen to make a report look clean. */
.app-shell.app-window .window-bar__ctl button {
  min-width: 24px; min-height: 24px;
  display: inline-flex; align-items: center; justify-content: center;
}
.app-shell.app-window .window-bar__ctl button[data-cycle-theme],
.app-shell.app-window .window-bar__ctl button[data-shell="viewport-toggle"] {
  gap: var(--space-1);
  white-space: nowrap;
}

/* x-ray v2 (src/ai/xray-panel.ts): the status-bar switch and the legend panel. The button reads like
   the rest of the bar until it is on, when it takes the accent so the state is visible at a glance. */
.status-bar__xray { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.status-bar__xray:hover { color: var(--color-fg); }
.status-bar__xray[aria-pressed="true"] { color: var(--color-accent); }
.xray-panel {
  position: fixed; right: var(--space-4); bottom: 2.5rem; z-index: 60;
  width: min(22rem, calc(100vw - 2 * var(--space-4))); max-height: 70vh; overflow: auto;
  padding: var(--space-3) var(--space-4); font-family: var(--font-mono); font-size: 0.8125rem;
  color: var(--color-fg); background: var(--color-surface); border: 1px solid var(--color-line); border-radius: var(--radius-md);
}
.xray-panel__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-2); }
.xray-panel__close { font: inherit; font-size: 1rem; line-height: 1; color: var(--color-muted); background: none; border: 0; cursor: pointer; }
.xray-panel__close:hover { color: var(--color-fg); }
.xray-panel__group { padding-block: var(--space-2); border-top: 1px solid var(--color-line); }
.xray-panel__group h2 { margin: 0; font-size: inherit; color: var(--color-accent); }
.xray-panel__lede, .xray-panel__empty, .xray-panel__foot { margin: var(--space-1) 0; color: var(--color-muted); }
.xray-panel__list { margin: var(--space-1) 0 0; padding: 0; list-style: none; display: grid; gap: var(--space-1); }
.xray-panel__list li { display: flex; justify-content: space-between; gap: var(--space-2); }
.xray-panel__verb { color: var(--color-muted); white-space: nowrap; }
.xray-panel__surfaces { padding-top: var(--space-2); border-top: 1px solid var(--color-line); }
.xray-panel__surfaces summary { cursor: pointer; }
.xray-panel__foot { padding-top: var(--space-2); border-top: 1px solid var(--color-line); }

/* WCAG 2.4.11 (focus not obscured): on a phone the assistant sheet peeks over the bottom of the
   page, so whichever element scrolls the page keeps its focus target clear of that strip. */
@media (max-width: 768px) {
  html, .app-shell__main { scroll-padding-bottom: 4.5rem; }
}

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/organisms/task-list/task-list.css */
/* organisms/task-list */
.task-list { display: grid; gap: var(--space-2); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/organisms/welcome/welcome.css */
/* organisms/welcome — portfolio: the editor's Welcome page layout (the VS Code start screen,
   re-spoken in GRAIN). Owns the page scaffold only; the lists inside are welcome-start /
   welcome-recent / walkthrough-card. Tokens only. */
.welcome { max-width: 72rem; margin: 0 auto; padding: clamp(var(--space-8), 8vh, 6rem) var(--page-pad) var(--space-8); }
.welcome__title { font-family: var(--font-accent); font-size: clamp(2.2rem, 5vw, 3.4rem); margin: 0; }
.welcome__sub { color: var(--ink-muted); font-size: var(--text-lg); margin: 0.4rem 0 var(--space-3); }
.welcome__proof { max-width: 68ch; margin: 0 0 var(--space-4); color: var(--ink-muted); font-size: var(--text-sm); }
.welcome__proof-links { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-4); max-width: 72ch; margin-top: var(--space-3); }
.welcome__proof-links a { font-size: var(--text-sm); text-underline-offset: 0.2em; }
.welcome__cols { display: grid; grid-template-columns: 1.1fr 1fr; gap: clamp(var(--space-8), 6vw, 6rem); margin-top: var(--space-8); }
/* collapse to one column when the SHELL FRAME is narrow — a @container query, not @media, so the
   viewport-toggle's mobile PREVIEW (which clamps the frame, not the real viewport) also collapses
   it. Under plain @media the two columns stayed side by side in the preview and the Walkthroughs
   column overflowed off the frame's right edge. The container is <body> (grain's shell-frame);
   on a real window its width tracks the viewport, so narrow real windows collapse exactly as before. */
@container shell-frame (max-width: 56rem) { .welcome__cols { grid-template-columns: 1fr; } }
.welcome h2 { font-size: var(--text-xl); font-weight: var(--font-weight-regular); margin: 0 0 1rem; }

/* bottom: the pill CTA + the (functional) startup checkbox */
.welcome__foot { display: flex; flex-direction: column; align-items: center; gap: var(--space-5); margin-top: calc(var(--space-8) * 2); }
.welcome__cta { display: inline-flex; align-items: center; gap: 0.6rem; font-size: var(--text-base);
  border: 1px solid var(--ink); border-radius: 999px; padding: 0.7rem 1.6rem;
  color: var(--ink); text-decoration: none; background: var(--panel); }
.welcome__cta:hover { background: color-mix(in srgb, var(--ink) 8%, transparent); }
.welcome__startup { display: flex; align-items: center; gap: 0.5rem;
  color: var(--ink-muted); font-size: var(--text-sm); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/pages/about/about.css */
/* pages/about — the profile app's own layout (bundled into /components.css, scoped to the about
   screen). The profile header is the shared profile-card molecule now; only the tabbed-panel nav +
   the "Now" list live here. Tabs are grain's `.tab` anchor semantics used standalone (a jump-link
   nav that works with no JS); the island just shows one panel at a time. */
[data-screen="about"] .about-tabs { display: flex; flex-wrap: wrap; border-bottom: 1px solid var(--line-soft);
  margin: 0 0 var(--space-5); }

/* WITH JS the island shows one panel; WITHOUT JS no panel carries [hidden], so every panel stays
   visible and the tabs are plain in-page anchors. */
[data-screen="about"] .about-panel[hidden] { display: none; }
[data-screen="about"] .about-panel + .about-panel { margin-top: var(--space-6); }

/* Role details stay folded below the three-line philosophy. Native details keeps them available
   without JavaScript; the page's small tab island opens one when a role link is followed. */
[data-screen="about"] .role-accordion { border-top: 1px solid var(--line-soft); padding: var(--space-2) 0; }
[data-screen="about"] .role-accordion__summary { display: flex; align-items: center; justify-content: space-between;
  gap: var(--space-3); cursor: pointer; list-style: none; padding: var(--space-2) 0; }
[data-screen="about"] .role-accordion__summary::-webkit-details-marker { display: none; }
[data-screen="about"] .role-accordion__summary::after { content: "+"; flex: 0 0 auto; color: var(--color-accent);
  font: var(--text-md) var(--font-mono); }
[data-screen="about"] .role-accordion[open] > .role-accordion__summary::after { content: "−"; }
[data-screen="about"] .role-accordion__summary:focus-visible { outline: 2px solid var(--color-accent);
  outline-offset: 3px; }
[data-screen="about"] .role-accordion__summary h3 { margin: 0; font-size: var(--text-lg); font-weight: var(--font-weight-semibold); }
[data-screen="about"] .role-accordion > p { margin: var(--space-2) 0; }
[data-screen="about"] .role-list { margin: 0 0 var(--space-2); padding-left: var(--space-5);
  display: flex; flex-direction: column; gap: var(--space-2); }
[data-screen="about"] .role-list li { font-size: var(--text-sm); color: var(--ink-muted); line-height: var(--leading-relaxed); }
[data-screen="about"] .role__proof-link { margin: 0 0 var(--space-2); font-size: var(--text-xs); font-family: var(--font-mono); }
[data-screen="about"] .role__proof-link a { color: var(--color-accent); text-decoration: none; }
[data-screen="about"] .role__proof-link a:hover { text-decoration: underline; }
[data-screen="about"] .role__reading { margin: 0 0 var(--space-3); font-size: var(--text-xs); color: var(--ink-muted); }
[data-screen="about"] .role__reading a { color: var(--color-accent); text-decoration: underline; text-underline-offset: 2px; }
/* the role anchors sit a little below the fixed nothing, but give the scroll target breathing room */
[data-screen="about"] [id^="role-"] { scroll-margin-top: var(--space-4); }

[data-screen="about"] .now-list { list-style: none; margin: var(--space-3) 0 var(--space-5); padding: 0;
  display: flex; flex-direction: column; gap: var(--space-2); }
[data-screen="about"] .now-list li { font-size: var(--text-sm); color: var(--ink-muted); }
[data-screen="about"] .now-list time { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--ink-muted);
  margin-right: var(--space-2); }

/* Profile quotes-hero: a first-person manifesto that leads the Profile panel. Each line deep-links
   to its role accordion (the island's hashchange routing opens it). All three render stacked and visible
   with no JS; with motion allowed they fade in with a small stagger, settling to the same visible state
   (so the visual baseline is deterministic). Reduced motion + Playwright's disabled-animations both land
   on all-visible. */
[data-screen="about"] .quotes-hero { margin: 0 0 var(--space-5); }
[data-screen="about"] .quotes-hero__title { margin: 0 0 var(--space-3); font-size: var(--text-base);
  font-weight: var(--font-weight-regular); color: var(--ink-muted); }
[data-screen="about"] .quotes-hero__list { list-style: none; margin: 0; padding: 0;
  display: flex; flex-direction: column; gap: var(--space-3); }
[data-screen="about"] .quote { font-size: var(--text-lg); line-height: var(--leading-snug); color: var(--ink);
  max-width: 44rem; }
[data-screen="about"] .quote a { color: inherit; text-decoration: none; }
[data-screen="about"] .quote a:hover { text-decoration: underline; text-decoration-color: var(--color-accent); }
[data-screen="about"] .quote__role { color: var(--color-accent); font-weight: 600; }
@media (prefers-reduced-motion: no-preference) {
  [data-screen="about"] .quote { animation: quote-in 0.5s ease both; }
  [data-screen="about"] .quote:nth-child(1) { animation-delay: 0s; }
  [data-screen="about"] .quote:nth-child(2) { animation-delay: 0.08s; }
  [data-screen="about"] .quote:nth-child(3) { animation-delay: 0.16s; }
}
@keyframes quote-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }

/* CV tab: the summary line + the download/actions row above the shared cv-* molecules. */
[data-screen="about"] .cv-tab__intro { margin: 0 0 var(--space-4); color: var(--ink-muted); }
[data-screen="about"] .cv-tab__actions { display: flex; flex-wrap: wrap; gap: var(--space-2); margin: 0 0 var(--space-5); }

/* Contact tab's form: b-field/b-choice each render a .field label, which is display:flex so it
   blockifies on its own — this just stacks the fields with a shared gap and gives the Send row the
   same bar shape as /mail's compose__foot, without pulling in mail.css (that block is scoped to
   [data-screen="mail"] and never loads here). */
[data-screen="about"] .contact-form { display: flex; flex-direction: column; gap: var(--space-3);
  max-width: 26rem; margin: var(--space-3) 0 var(--space-5); }
[data-screen="about"] .contact-form__foot { display: flex; align-items: center; flex-wrap: wrap;
  gap: var(--space-2) var(--space-4); }
[data-screen="about"] .contact-form__hint { font-size: var(--text-xs); color: var(--ink-muted); }
[data-screen="about"] .contact-form__hint a { color: var(--ink-muted); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/pages/badges/badges.css */
/* The badges hub: one block per subject, one card per badge design showing the real medallion.
   Flat on the paper: hairlines and space carry the structure, no shadows and no gradients. */
[data-screen="badges"] .badges-board { max-width: 1080px; }
[data-screen="badges"] .badge-stats { display: flex; flex-wrap: wrap; gap: var(--space-6); margin: var(--space-6) 0 0;
  padding: var(--space-4) 0; border-top: 1px solid var(--color-line); border-bottom: 1px solid var(--color-line); }
[data-screen="badges"] .badge-stats div { display: grid; gap: var(--space-1); }
[data-screen="badges"] .badge-stats dt { color: var(--color-muted); font: var(--text-xs) var(--font-mono);
  text-transform: uppercase; letter-spacing: .06em; }
[data-screen="badges"] .badge-stats dd { margin: 0; font-size: var(--text-2xl, var(--text-xl)); line-height: 1; }

[data-screen="badges"] .badge-course { margin-top: var(--space-8, var(--space-6)); padding-top: var(--space-5, var(--space-4));
  border-top: 2px solid var(--color-fg); }
[data-screen="badges"] .badge-course__head { display: grid; gap: var(--space-1); margin-bottom: var(--space-5, var(--space-4)); }
[data-screen="badges"] .badge-course__head h2 { margin: 0; font-size: var(--text-xl); line-height: 1.2; }
[data-screen="badges"] .badge-course__code,
[data-screen="badges"] .badge-course__count { margin: 0; color: var(--color-muted); font: var(--text-xs) var(--font-mono);
  letter-spacing: .06em; text-transform: uppercase; }
[data-screen="badges"] .badge-course__desc { margin: 0; max-width: 68ch; color: var(--color-muted); font-size: var(--text-sm); }

[data-screen="badges"] .badge-designs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-5, var(--space-4)); }
[data-screen="badges"] .badge-design { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0;
  padding: var(--space-4); border: 1px solid var(--color-line); border-radius: var(--radius-md); background: var(--color-surface); }
[data-screen="badges"] .badge-design__art { display: block; align-self: center; width: min(100%, 13rem); margin-bottom: var(--space-2); }
[data-screen="badges"] .badge-design__art img { display: block; width: 100%; height: auto; }
[data-screen="badges"] .badge-design__art:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 4px; }
[data-screen="badges"] .badge-design__term { margin: 0; color: var(--color-muted); font: var(--text-xs) var(--font-mono);
  text-transform: uppercase; letter-spacing: .06em; }
[data-screen="badges"] .badge-design h3 { margin: 0; font-size: var(--text-lg); line-height: 1.25; }
[data-screen="badges"] .badge-design__criteria { margin: 0; color: var(--color-muted); font-size: var(--text-sm); }
[data-screen="badges"] .badge-design__count { margin: auto 0 0; padding-top: var(--space-2); font-size: var(--text-sm); }
[data-screen="badges"] .badge-design__sections { display: flex; flex-wrap: wrap; gap: var(--space-1) var(--space-3);
  margin: 0; padding: var(--space-2) 0 0; border-top: 1px solid var(--color-line); list-style: none;
  font: var(--text-xs) var(--font-mono); }
[data-screen="badges"] .badge-design__sections span { color: var(--color-muted); }

@media (max-width: 56rem) {
  [data-screen="badges"] .badge-designs { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 36rem) {
  [data-screen="badges"] .badge-designs { grid-template-columns: minmax(0, 1fr); }
  [data-screen="badges"] .badge-stats { gap: var(--space-4); }
}

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/pages/bread/bread.css */
[data-screen="bread"] .bread-flow {
  list-style: none;
  margin: var(--space-4) 0 var(--space-5);
  padding: 0;
  counter-reset: bread-step;
}

[data-screen="bread"] .bread-flow li {
  display: grid;
  grid-template-columns: 2rem minmax(7rem, 0.3fr) minmax(0, 1fr);
  align-items: baseline;
  gap: var(--space-2) var(--space-3);
  padding: var(--space-3) 0;
  border-top: 1px solid var(--color-line);
  counter-increment: bread-step;
}

[data-screen="bread"] .bread-flow li::before {
  content: "0" counter(bread-step);
  color: var(--color-muted);
  font-family: var(--font-mono);
  font-size: var(--text-xs);
}

[data-screen="bread"] .bread-flow strong { font-family: var(--font-mono); }
[data-screen="bread"] .bread-flow span { color: var(--color-muted); font-size: var(--text-sm); }

@media (max-width: 38rem) {
  [data-screen="bread"] .bread-flow li { grid-template-columns: 2rem minmax(0, 1fr); }
  [data-screen="bread"] .bread-flow span { grid-column: 2; }
}

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/pages/builder/builder.css */
/* pages/builder: the page-builder demo, as a WORKBENCH rather than an essay.
   Why this file changed shape on 2026-08-14: the page was built out of the same parts as a note, a
   masthead and a lede and five muted paragraphs down the shared reading column, so it read as an
   article that happened to contain a builder. The tool vocabulary it wants already existed on this
   site (see mail.css: a wider board, a full-height grid, panes with their own scroll and heads, mono
   for counts), and this borrows it rather than inventing a second one.
   Empty vs. result is a data attribute the SERVER sets (builder-page.ts's buildBuilderView, bound
   via data-bind-data-builder-state on .board), and the browser sets the same one when it composes
   without a server: with no JS at all the right section is already the only one visible. Same
   convention for the finer-grained flags (matched-nothing / has-form / has-unsupported): each is a
   marker string or an absent attribute, field-matcher.ts's own "never boolean text a boolean HTML
   attribute can't bind from" rule, so a plain attribute-presence selector is all any of this needs. */
[data-screen="builder"] .board { max-width: 82rem; }
.builder__heading { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; clip-path: inset(50%); }

/* --- the workbench: prompt bar on top, canvas beside the block rail ---------------------------
   The prompt sits at the TOP rather than under the canvas, and that was a mockup's finding: a
   command line below the work area lands exactly where the terminal dock already is, and two things
   wanting the same edge of the screen is not something styling fixes. */
[data-screen="builder"] .wb {
  display: grid; grid-template-columns: minmax(0, 1fr) minmax(15rem, 21rem);
  gap: var(--space-4); align-items: stretch;
  height: calc(100dvh - 16rem); min-height: 26rem; margin: 0 0 var(--space-5);
}
[data-screen="builder"] .wb-bar { display: flex; align-items: flex-end; gap: var(--space-3);
  margin: 0 0 var(--space-2); }
[data-screen="builder"] .wb-bar .field { flex: 1 1 auto; }
/* The prompt is a bar, not a document. b-textarea sets a comfortable min-height for a message box,
   which is right where it is used and wrong here, so the one control that is a toolbar gets a
   toolbar's height back and keeps the drag handle for anyone typing a long description. */
[data-screen="builder"] .wb-bar .field__textarea { min-height: 0; height: 3.4rem; resize: vertical; }
[data-screen="builder"] .wb-bar__note { font-size: var(--text-xs); margin: 0 0 var(--space-4); }
[data-screen="builder"] .wb-bar__evidence { font-size: var(--text-xs); margin: 0 0 var(--space-4); }
[data-screen="builder"] .wb-bar__evidence summary { cursor: pointer; }
[data-screen="builder"] .wb-bar__evidence > p { margin: var(--space-2) 0 0; }
/* Undo sits in the composer bar rather than the rail, because it puts back the last change whatever
   made it, and the desk's changes arrive through this box. Full strength rather than the chip's
   resting 0.55: the other chips live inside a row that lifts on hover, and this one has no row to
   lift with, so at rest it would read as disabled. It is hidden when there is nothing to undo, so
   visible already means pressable. */
[data-screen="builder"] .wb-bar .wb-chip { opacity: 1; align-self: center; padding-bottom: var(--space-2); }
[data-screen="builder"] .wb-bar .wb-chip[hidden] { display: none; }
/* What the last prompt was read as: the verb it chose, or the reason it chose none. Mono, because
   everything on this page that names an id or a count is mono and this line names both. Status is
   weight here rather than hue, the same rule the span chips follow: a refusal reads at full ink and
   a chosen verb reads muted, since the refusal is the one that wants reading. */
[data-screen="builder"] .wb-bar__said { font-family: var(--font-mono); font-size: var(--text-xs);
  margin: 0 0 var(--space-2); color: var(--ink); }
/* A chosen verb, the desk waiting, and the desk answering without acting all read quiet. A refusal
   is the only one that keeps full ink, because it is the only one asking to be read. */
[data-screen="builder"] .wb-bar__said[data-read="command"],
[data-screen="builder"] .wb-bar__said[data-read="thinking"],
[data-screen="builder"] .wb-bar__said[data-read="reply"],
[data-screen="builder"] .wb-bar__said[data-read="file"] { color: var(--ink-muted); }
[data-screen="builder"] .wb-bar__said[hidden] { display: none; }
[data-screen="builder"] .wb-bar__proposal { display: flex; align-items: center; justify-content: space-between;
  gap: var(--space-3); margin: 0 0 var(--space-2); padding: var(--space-3) var(--space-4);
  border: 1px solid var(--line-soft); border-radius: var(--radius-md); background: var(--paper-2); }
[data-screen="builder"] .wb-bar__proposal[hidden] { display: none; }
[data-screen="builder"] .wb-bar__proposal p { margin: 0; font-size: var(--text-sm); color: var(--ink); }
[data-screen="builder"] .wb-bar__proposal-actions { display: flex; align-items: center; gap: var(--space-2); flex: 0 0 auto; }
[data-screen="builder"] .wb-bar__draft { margin: 0 0 var(--space-3); padding: var(--space-4);
  border: 1px solid var(--line-soft); border-radius: var(--radius-md); background: var(--paper-2); }
[data-screen="builder"] .wb-bar__draft[hidden] { display: none; }
[data-screen="builder"] .wb-bar__draft-head { display: flex; align-items: flex-start; justify-content: space-between;
  gap: var(--space-4); }
[data-screen="builder"] .wb-bar__draft-head h2 { margin: 0 0 var(--space-1); font-size: var(--text-md); }
[data-screen="builder"] .wb-bar__draft-head p { margin: 0; font-size: var(--text-sm); }
[data-screen="builder"] .wb-bar__draft-brief { margin-top: var(--space-3); padding: var(--space-3);
  border: 1px solid var(--line-soft); border-radius: var(--radius-sm); background: var(--paper); }
[data-screen="builder"] .wb-bar__draft-brief h3 { margin: 0 0 var(--space-1); font: 600 var(--text-xs)/1.4 var(--font-mono); }
[data-screen="builder"] .wb-bar__draft-brief p { margin: 0; font-size: var(--text-sm); overflow-wrap: anywhere; }
[data-screen="builder"] .wb-bar__draft-brief p + p { margin-top: var(--space-2); }
[data-screen="builder"] .wb-bar__draft-brief .muted { font-size: var(--text-xs); }
[data-screen="builder"] .wb-bar__draft-brief .wb-bar__draft-summary { margin-top: var(--space-2); }
[data-screen="builder"] .wb-bar__draft-blocks { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
  gap: var(--space-2); margin: var(--space-3) 0 0; padding: 0; list-style: none; }
[data-screen="builder"] .wb-bar__draft-block { min-width: 0; padding: var(--space-3);
  border: 1px solid var(--line-soft); border-radius: var(--radius-sm); background: var(--paper); }
[data-screen="builder"] .wb-bar__draft-block h3 { margin: 0 0 var(--space-2); font: 600 var(--text-sm)/1.4 var(--font-mono); }
[data-screen="builder"] .wb-bar__draft-block p { margin: 0; font-size: var(--text-sm); overflow-wrap: anywhere; }
[data-screen="builder"] .wb-bar__draft-block p + p { margin-top: var(--space-2); }
[data-screen="builder"] .wb-bar__draft-block .muted { font-size: var(--text-xs); }
[data-screen="builder"] .wb-bar__draft-field { display: block; margin: 0; }
[data-screen="builder"] .wb-bar__draft-field + .wb-bar__draft-field { margin-top: var(--space-2); }
[data-screen="builder"] .wb-bar__draft-field .field__label { display: block; margin-bottom: var(--space-1);
  color: var(--ink-muted); font: 500 var(--text-xs)/1.4 var(--font-mono); text-transform: capitalize; }
[data-screen="builder"] .wb-bar__draft-text { display: block; width: 100%; min-height: 3.2rem; resize: vertical;
  overflow-wrap: anywhere; }
[data-screen="builder"] .wb-bar__draft-evidence { margin: var(--space-1) 0 0; font-size: var(--text-xs); overflow-wrap: anywhere; }
[data-screen="builder"] .wb-bar__draft-confirm { display: flex; align-items: flex-start;
  gap: var(--space-2); margin-top: var(--space-3); color: var(--ink); font-size: var(--text-sm); }
[data-screen="builder"] .wb-bar__draft-confirm input { flex: 0 0 auto; margin-top: 0.2em; }
[data-screen="builder"] .wb-bar__draft-actions { display: flex; justify-content: flex-end;
  gap: var(--space-2); margin-top: var(--space-3); }

/* The pane: one treatment, three users (canvas, rail, and the rail's own refusal block). Borrowed
   from mail.css rather than forked from it, down to the --paper-2 head, because two tool screens
   that dress their panes differently read as two apps. */
[data-screen="builder"] .mk-pane { display: flex; flex-direction: column; min-width: 0; min-height: 0;
  overflow: hidden; background: var(--paper);
  border: 1px solid var(--line-soft); border-radius: var(--radius-md); }
[data-screen="builder"] .wb__canvas { container: builder-canvas-pane / inline-size; }
[data-screen="builder"] .mk-pane__head { display: flex; align-items: baseline; justify-content: space-between;
  gap: var(--space-3); padding: var(--space-2) var(--space-4);
  border-bottom: 1px solid var(--line-soft); background: var(--paper-2); }
[data-screen="builder"] .mk-pane__name { font-size: var(--text-sm); font-weight: var(--font-weight-semibold);
  color: var(--ink); }
[data-screen="builder"] .mk-pane__meta { font-size: var(--text-xs); color: var(--ink-muted);
  font-family: var(--font-mono); }
[data-screen="builder"] .wb__canvas .mk-pane__head > .mk-pane__meta { white-space: nowrap; }
[data-screen="builder"] .mk-pane__body { flex: 1 1 auto; min-height: 0; overflow-y: auto;
  padding: var(--space-4); }

/* The file name, and it is the one piece of the artboard mockup worth keeping: a single mono line
   saying what you are building does more than a plate and a drop shadow. */
[data-screen="builder"] .wb__file { font-family: var(--font-mono); font-size: var(--text-sm); color: var(--ink); }

/* --- taking it away ---------------------------------------------------------------------------
   The three exports and the one import live at the file-name edge, because that line already says
   what you are building and a file is what these hand you. The head goes from two children to
   three, so the meta sits next to the name and the controls take the right edge on their own rather
   than by being spread apart from it: space-between across three items would push the count into
   the middle, where it reads as a heading for nothing.
   Only the canvas pane, deliberately. The rail's head carries a collapse and a count and would be
   the wrong place to also carry a file menu. */
[data-screen="builder"] .wb__canvas .mk-pane__head { justify-content: flex-start; }
[data-screen="builder"] .wb__take { display: flex; align-items: baseline; gap: var(--space-3);
  margin-left: auto; font-family: var(--font-mono); font-size: var(--text-xs); }
[data-screen="builder"] .wb__take-group { display: flex; align-items: baseline; gap: var(--space-2); }
/* Nothing to export until there is something on the canvas. Hidden rather than disabled: a disabled
   control still says the thing it does is available here and now, and on an empty page it is not.
   The same marker-string-or-absent-attribute convention every other flag on this page uses. */
[data-screen="builder"] .wb__take-group { display: none; }
[data-screen="builder"] .wb__take-group[data-has-blocks] { display: flex; }
/* Full ink at rest, unlike the rail's chips. Those are quiet because there are three per row and
   the row is a list you scan; these are four controls in a head and the whole point of the page is
   that you can leave with what you built. */
[data-screen="builder"] .wb__take .wb-chip { opacity: 1; }

/* The canvas can be narrow on a wide screen when the block rail and desk panel are both open.
   Wrap its file controls at the pane width, so the count stays on one line and the actions get a
   clean row of their own instead of clipping against the canvas edge. */
@container builder-canvas-pane (max-width: 34rem) {
  [data-screen="builder"] .wb__canvas .mk-pane__head { flex-wrap: wrap; align-items: center; row-gap: var(--space-2); }
  [data-screen="builder"] .wb__file { flex: 1 1 auto; }
  [data-screen="builder"] .wb__canvas .mk-pane__head > .mk-pane__meta { margin-left: auto; }
  [data-screen="builder"] .wb__take { flex: 1 0 100%; justify-content: space-between; flex-wrap: wrap;
    gap: var(--space-2); margin-left: 0; }
}

/* --- the canvas: the one grid the whole composition lays out in -------------------------------
   Six columns because it is the smallest count the three layout words divide into cleanly (full 6,
   half 3, third 2), so a span is a span rather than a rounding. The cell carries the span and the
   block inside stays layout-free, which is what grain's card and stat-tile docs each ask.
   The breakpoint is a CONTAINER query, not a media query, and that is the whole point: half has to
   mean half of what you can SEE. The canvas pane is not the window. It shrinks when the assistant
   column opens, when the preview-width toggle goes to tablet, and when the rail is beside it, and a
   viewport breakpoint is wrong in all three. Below the reading width there are no columns left to
   divide, so every block goes full: a third of a phone screen is not a layout decision anyone made. */
[data-screen="builder"] .wb__stage { container-type: inline-size; container-name: canvas; }
[data-screen="builder"] .canvas {
  display: grid; grid-template-columns: repeat(6, 1fr); gap: var(--space-4); align-items: start;
}
[data-screen="builder"] .canvas__cell { min-width: 0; }
[data-screen="builder"] .canvas__cell[data-span="full"] { grid-column: span 6; }
[data-screen="builder"] .canvas__cell[data-span="half"] { grid-column: span 3; }
[data-screen="builder"] .canvas__cell[data-span="third"] { grid-column: span 2; }
@container canvas (max-width: 34rem) {
  [data-screen="builder"] .canvas__cell[data-span] { grid-column: span 6; }
}

[data-screen="builder"] [data-builder-state="result"] .builder-empty { display: none; }
[data-screen="builder"] .builder-none { display: none; }
[data-screen="builder"] .builder-none[data-matched-nothing] { display: block; }
[data-screen="builder"] .builder-empty__example { margin: var(--space-3) 0 0; }

/* --- the rail: the blocks, as things you can operate -----------------------------------------
   This is what makes the page a builder rather than a page that renders a result. It lives in the
   main column and deliberately NOT as a fourth mode of the assistant panel: these are controls, and
   a control a chrome toggle can close is a control you cannot rely on. */
[data-screen="builder"] .wb__rows { flex: 0 1 auto; min-height: 0; overflow-y: auto; }
[data-screen="builder"] .wb__rail-empty { margin: 0; padding: var(--space-4);
  font-size: var(--text-sm); color: var(--ink-muted); }
[data-screen="builder"] .wb__rail-empty p { margin: 0; }
[data-screen="builder"] [data-builder-state="result"] .wb__rail-empty { display: none; }
[data-screen="builder"] .wb__try-edits { border-top: 1px solid var(--line-soft); padding: var(--space-3); }
[data-screen="builder"] .wb__try-edits[hidden] { display: none; }
[data-screen="builder"] .wb__try-edits p { margin: 0 0 var(--space-2); font-size: var(--text-xs); }
[data-screen="builder"] .wb__try-title { color: var(--ink); font-weight: var(--font-weight-semibold); }
[data-screen="builder"] .wb__try-edits .wb-chip { opacity: 1; }
[data-screen="builder"] .wb__try-edits .wb-chip[hidden],
[data-screen="builder"] .wb__try-edits p[hidden] { display: none; }
[data-screen="builder"] .wb__try-edits .muted { color: var(--ink-muted); }

/* One row, and it is deliberately QUIET at rest. Every control stays present, clickable, tabbable
   and testable: what changes is weight, not function. Hiding a remove button until hover would read
   as tidy and cost a touch user the control entirely, so the row keeps all seven hit targets and
   simply stops shouting about six of them. The active span is the one thing that carries ink,
   because it is the only per-row state worth reading at a glance. */
[data-screen="builder"] .wb-row { display: flex; align-items: center; gap: var(--space-2);
  padding: var(--space-2) var(--space-3); border-bottom: 1px solid var(--line-soft);
  font-family: var(--font-mono); font-size: var(--text-xs); }
[data-screen="builder"] .wb-row:last-child { border-bottom: 0; }
[data-screen="builder"] .wb-row__id { color: var(--ink-muted); width: 2rem; flex: 0 0 auto; }
[data-screen="builder"] .wb-row__name { color: var(--ink); flex: 1 1 auto; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; }
[data-screen="builder"] .wb-row__spans { display: flex; gap: var(--space-1); flex: 0 0 auto; }

/* Borderless at rest. Three bordered chips per row turned a 20rem column into a wall of boxes, and
   the border was never the information: which one is ON is. Status here is weight, not a frame and
   not a hue, which is the same rule the rest of this estate follows. */
[data-screen="builder"] .wb-chip,
[data-screen="builder"] .wb-row__move,
[data-screen="builder"] .wb-row__x {
  font: inherit; color: var(--ink-muted); background: transparent; border: 0;
  padding: 0; cursor: pointer; flex: 0 0 auto; transition: opacity 120ms, color 120ms;
}
/* No resting opacity: at 0.55 the muted ink fell below 4.5:1 (WCAG 1.4.3). Rest is muted, hover and
   pressed are full ink, so the lift still reads, through colour and weight rather than fade. */
[data-screen="builder-preview"] .wb-chip { font: inherit; color: var(--ink-muted); background: transparent;
  border: 0; padding: 0 var(--space-1); cursor: pointer; }
[data-screen="builder-preview"] .wb-chip[aria-pressed="true"] { color: var(--ink); font-weight: 600; }
[data-screen="builder-preview"] .wb-chip:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 1px; }
[data-screen="builder"] .wb-row__move,
[data-screen="builder"] .wb-row__x { padding: 0 var(--space-1); }
/* The whole row lifts together, so one hover reveals every control on the block you are aiming at
   rather than making you find each one. focus-within does the same for a keyboard, which is the
   half a hover-only treatment always forgets. */
[data-screen="builder"] .wb-row:hover .wb-chip,
[data-screen="builder"] .wb-row:hover .wb-row__move,
[data-screen="builder"] .wb-row:hover .wb-row__x,
[data-screen="builder"] .wb-row:focus-within .wb-chip,
[data-screen="builder"] .wb-row:focus-within .wb-row__move,
[data-screen="builder"] .wb-row:focus-within .wb-row__x { opacity: 1; }
[data-screen="builder"] .wb-chip:hover,
[data-screen="builder"] .wb-row__move:hover,
[data-screen="builder"] .wb-row__x:hover { color: var(--ink); }
[data-screen="builder"] .wb-chip:focus-visible,
[data-screen="builder"] .wb-row__move:focus-visible,
[data-screen="builder"] .wb-row__x:focus-visible { opacity: 1; color: var(--ink);
  outline: 2px solid var(--color-accent); outline-offset: 1px; }
/* The pressed span is full ink and full weight, at rest and always: it is the row's state, not a
   control you have to go looking for. */
[data-screen="builder"] .wb-chip[data-on] { color: var(--ink); opacity: 1;
  font-weight: var(--font-weight-semibold); text-decoration: underline; text-underline-offset: 3px; }

/* --- collapsing the rail ----------------------------------------------------------------------
   A workbench where the tool panel cannot get out of the way is a workbench with a permanent 20rem
   tax on the thing you are actually looking at. Collapsed, the rail becomes its own head: the count
   stays readable, so you never lose track of what is on the page, and one press brings the controls
   back. Driven by an attribute on the workbench so the CSS owns the layout change, which is the same
   shape grain's own aside and console toggles use. */
[data-screen="builder"] .wb__collapse { font: inherit; font-family: var(--font-mono);
  font-size: var(--text-xs); color: var(--ink-muted); background: transparent; border: 0;
  padding: 0 var(--space-1); cursor: pointer; }
[data-screen="builder"] .wb__collapse:hover { color: var(--ink); }
[data-screen="builder"] .wb[data-rail-collapsed="true"] { grid-template-columns: minmax(0, 1fr) auto; }
[data-screen="builder"] .wb[data-rail-collapsed="true"] .wb__rows,
[data-screen="builder"] .wb[data-rail-collapsed="true"] .wb__rail-empty,
[data-screen="builder"] .wb[data-rail-collapsed="true"] .wb__try-edits,
[data-screen="builder"] .wb[data-rail-collapsed="true"] .wb__refusals { display: none; }
/* Only as tall as its own head. Without align-self the pane keeps the grid row's full height and
   collapses into a tall empty box, which reads as something broken rather than something put away. */
[data-screen="builder"] .wb[data-rail-collapsed="true"] .wb__rail { width: max-content; align-self: start; }
[data-screen="builder"] .wb[data-rail-collapsed="true"] .mk-pane__head { gap: var(--space-2); }

/* The refusals: a second head inside the rail rather than a pane of its own, because it is the same
   list about the same prompt and a third box would be a third thing to look at. */
[data-screen="builder"] .wb__refusals { display: none; border-top: 1px solid var(--line-soft); }
[data-screen="builder"] .wb__refusals[data-has-unsupported] { display: block; }
[data-screen="builder"] .builder-unsupported { margin: 0; padding: var(--space-2) var(--space-3);
  list-style: none; }
[data-screen="builder"] .builder-unsupported__item { padding: var(--space-2) 0; }
[data-screen="builder"] .builder-unsupported__item + .builder-unsupported__item {
  border-top: 1px solid var(--line-soft); }
[data-screen="builder"] .builder-unsupported__token { display: block; font-family: var(--font-mono);
  font-size: var(--text-xs); margin: 0 0 var(--space-1); }
[data-screen="builder"] .builder-unsupported__reason { color: var(--ink-muted); font-size: var(--text-xs); }

/* --- below the tool: the drawers ---------------------------------------------------------------
   The honest-limits prose is good writing aimed at a reader of an argument, and this page's job
   changed. It stays here for anyone who came for it, closed, under the thing they came to use. */
[data-screen="builder"] .wb-drawer { margin: 0 0 var(--space-3); border-top: 1px solid var(--line-soft);
  padding-top: var(--space-3); max-width: var(--content-max); }
[data-screen="builder"] .wb-drawer > summary { cursor: pointer; color: var(--ink);
  font-size: var(--text-sm); font-weight: var(--font-weight-semibold); }
[data-screen="builder"] .wb-drawer[data-has-form] { display: block; }
[data-screen="builder"] .wb-drawer:not([open]) > *:not(summary) { display: none; }

/* The composer, and the two <pre> blocks (the render loop, the pretty-printed spec): a quiet
   monospace panel, the same treatment a code sample gets everywhere else on the site. */
[data-screen="builder"] .builder-tag,
[data-screen="builder"] .builder-json {
  font-family: var(--font-mono); font-size: var(--text-xs); line-height: var(--leading-normal);
  background: var(--color-surface); border: 1px solid var(--color-line); border-radius: var(--radius-sm);
  padding: var(--space-3); margin: var(--space-2) 0 var(--space-4);
  overflow-x: auto; white-space: pre;
}

/* The live form inside a form block: b-field/b-choice each render a .field label, display:flex, so
   it blockifies on its own; this just stacks the fields with a shared gap. No submit control (the
   template carries onsubmit="return false"); the drawer below says why. */
[data-screen="builder"] .builder-form { display: flex; flex-direction: column; gap: var(--space-3);
  margin: 0; }

/* The pre-rendered template library the browser clones from. Real markup produced by the real
   renderer, parked in the document rather than in a template element so the browser has nodes to
   clone without parsing a string. Hidden from sight, from layout and from the accessibility tree,
   and `display: none` is what keeps it out of all three at once. */
[data-screen="builder"] .builder-library { display: none; }

/* Narrow: the rail goes under the canvas rather than beside it, and the whole thing stops trying to
   be one screen tall. A workbench that scrolls is fine; a workbench squeezed into a phone-height
   grid is two panes of nothing. */
@media (max-width: 60rem) {
  [data-screen="builder"] .wb { grid-template-columns: 1fr; height: auto; min-height: 0; }
  [data-screen="builder"] .wb-bar .field { min-width: 0; }
  [data-screen="builder"] .wb-bar > .btn { flex: 0 0 auto; white-space: nowrap; }
  [data-screen="builder"] .wb__canvas .mk-pane__head { flex-wrap: wrap; align-items: center; row-gap: var(--space-2); }
  [data-screen="builder"] .wb__file { flex: 1 1 auto; }
  [data-screen="builder"] .wb__canvas .mk-pane__head > .mk-pane__meta { margin-left: auto; }
  [data-screen="builder"] .wb__take { flex: 1 0 100%; justify-content: space-between; flex-wrap: wrap;
    gap: var(--space-2); margin-left: 0; }
  [data-screen="builder"] .wb-bar__proposal { align-items: stretch; flex-direction: column; }
  [data-screen="builder"] .wb-bar__proposal-actions { justify-content: flex-start; }
  [data-screen="builder"] .wb-bar__draft-head { flex-direction: column; align-items: stretch; }
  [data-screen="builder"] .wb-bar__draft-actions { justify-content: flex-start; flex-wrap: wrap; }
  [data-screen="builder"] .wb__rows { max-height: 18rem; }
  [data-screen="builder"] .wb__stage { max-height: none; }
}

/* ---- /grain/builder/preview (P5) --------------------------------------------------------------------
   The composed page on its own. Almost nothing here is new styling: the blocks bring their own, the
   pane is the same mk-pane every workbench section uses, and the point of the screen is that what
   you see is the page rather than a frame around it. So this file only says the three things the
   preview needs and the workbench does not. */

/* The stage is the canvas grid with the workbench's own six-column layout, because a preview that
   laid the blocks out differently would be previewing a page that does not exist. Kept as its own
   selector rather than reusing .canvas: the canvas is a surface a verb can address and this is not,
   and giving them one class would advertise an operation nothing here can perform. */
[data-screen="builder-preview"] .preview-stage {
  display: grid; grid-template-columns: repeat(6, 1fr); gap: var(--space-4);
}
[data-screen="builder-preview"] .preview-stage > * { grid-column: span 6; }
[data-screen="builder-preview"] .preview-stage > [data-span="half"] { grid-column: span 3; }
[data-screen="builder-preview"] .preview-stage > [data-span="third"] { grid-column: span 2; }
[data-screen="builder-preview"] .preview-stage[hidden] { display: none; }

/* The markup pane. Monospace, wrapped rather than scrolling sideways, because reading a long tag is
   what this pane is for and a horizontal scrollbar is where that stops happening. */
[data-screen="builder-preview"] .preview-markup {
  font-family: var(--font-mono); font-size: var(--text-xs); line-height: 1.6;
  white-space: pre-wrap; word-break: break-word; margin: 0;
  color: var(--ink-muted); background: var(--color-surface);
  padding: var(--space-4); border-radius: var(--radius-sm);
}

/* The switch. Which one is ON is weight, not a frame and not a hue, the same rule the rail's chips
   follow: aria-pressed is the state, so the attribute a screen reader reads is the attribute the
   stylesheet reads, and there is no class shadowing it that could disagree. */
[data-screen="builder-preview"] .preview-switch { display: inline-flex; gap: var(--space-3); }
[data-screen="builder-preview"] .preview-switch .wb-chip[aria-pressed="true"] {
  opacity: 1; color: var(--ink); font-weight: var(--weight-medium);
}
[data-screen="builder-preview"] .preview-empty[hidden],
[data-screen="builder-preview"] .preview-empty[data-empty="false"] { display: none; }
[data-screen="builder-preview"] .preview-empty { display: grid; justify-items: start; gap: var(--space-3); }
[data-screen="builder-preview"] .preview-empty p { margin: 0; }
[data-screen="builder-preview"] .preview-note { font-size: var(--text-xs); margin-top: var(--space-4); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/pages/calendar/calendar.css */
/* pages/calendar — the calendar panel layout (bundled into /components.css, scoped to the calendar
   screen). ONE real dataset (calendarEvents, server-composed): the Month grid is a read-only render
   plotted client-side from the Feed's own server-rendered <feed-card>s (one source of truth, no
   fetch), shown ABOVE the feed. The feed is always on the page (the whole page with no JS — the month
   grid needs JS and ships hidden). The feed card and photo styles live with their molecules (feed-card
   / feed-photo / feed-link). */
[data-screen="calendar"] .board { max-width: 68rem; }   /* a feed with big Airbnb-style photo strips + the month grid needs the room */

/* An event entry's featured video (content.ts renderVideoCard) is GRAIN's media-card in its overlay
   layout, and a GRAIN molecule owns no outer spacing on purpose: where it sits relative to the prose
   under it is this page's layout decision, not the component's. So this page owns that gap. The
   molecule it retired carried the same value as its own bottom margin, which is why it disappeared
   the moment the composition changed. */
[data-screen="calendar"] .board > .media-card { margin: 0 0 var(--space-5); }

[data-screen="calendar"] .cal { border: 1px solid var(--line-soft); border-radius: var(--radius-md);
  overflow: hidden; margin: 0 0 var(--space-6); }
[data-screen="calendar"] .cal[hidden] { display: none; }
[data-screen="calendar"] .cal__head { display: flex; align-items: baseline; justify-content: space-between;
  gap: var(--space-3); padding: var(--space-4) var(--space-5);
  border-bottom: 1px solid var(--line-soft); background: var(--paper-2); }
[data-screen="calendar"] .cal__month { font-family: var(--font-accent); font-size: var(--text-lg);
  font-weight: var(--font-weight-semibold); color: var(--ink); }
[data-screen="calendar"] .cal__to-feed { font-size: var(--text-sm); font-family: var(--font-mono);
  color: var(--color-accent); text-decoration: none; }
[data-screen="calendar"] .cal__to-feed:hover { text-decoration: underline; }
/* The year strip. One row per year that actually holds something, twelve month cells across, and a
   dot per dated thing inside the cell. It replaced a month-of-days grid, which was the wrong unit:
   most months here are empty, so that grid spent its area proving nothing had happened. */
[data-screen="calendar"] .cal__year { display: flex; align-items: stretch;
  border-bottom: 1px solid var(--line-soft); }
[data-screen="calendar"] .cal__year:last-child { border-bottom: 0; }
[data-screen="calendar"] .cal__year-label { flex: 0 0 auto; display: flex; align-items: center;
  padding: 0 var(--space-4); font-family: var(--font-mono); font-size: var(--text-sm);
  color: var(--ink-muted); background: var(--paper-2);
  border-right: 1px solid var(--line-soft); }
[data-screen="calendar"] .cal__months { flex: 1 1 auto; min-width: 0;
  display: grid; grid-template-columns: repeat(12, 1fr); }
/* The whole cell is the button that opens its month, so it resets the UA button styling and keeps
   only the border that makes it a grid cell. */
[data-screen="calendar"] .cal__mon { min-width: 0; min-height: 4.4rem;
  padding: var(--space-2) var(--space-1); font: inherit; text-align: left; cursor: pointer;
  display: flex; flex-direction: column; gap: var(--space-2);
  background: transparent; border: 0; border-right: 1px solid var(--line-soft); }
[data-screen="calendar"] .cal__mon:hover { background: var(--color-accent-soft); }
[data-screen="calendar"] .cal__mon:focus-visible { outline: 2px solid var(--color-accent);
  outline-offset: -2px; }
[data-screen="calendar"] .cal__mon:last-child { border-right: 0; }
/* An empty month is dimmed rather than hidden: the gaps between things are part of the record. */
[data-screen="calendar"] .cal__mon--empty { background: var(--paper-2); }
[data-screen="calendar"] .cal__mon-label { font-size: var(--text-xs); font-family: var(--font-mono);
  color: var(--ink-muted); }
[data-screen="calendar"] .cal__mon--empty .cal__mon-label { color: var(--ink-muted); }
[data-screen="calendar"] .cal__mon--now .cal__mon-label { color: var(--color-accent-contrast);
  background: var(--color-accent); border-radius: var(--radius-sm);
  padding: 0 var(--space-1); align-self: flex-start;
  font-weight: var(--font-weight-semibold); }
[data-screen="calendar"] .cal__dots { display: flex; flex-wrap: wrap; gap: 3px; align-content: flex-start; }
/* Marks, not controls: the cell around them is the button. 50% rather than a radius token because a
   month cell is about 44px wide at twelve across, so the mark is under 9px and every rounding token
   in the scale is large enough to square it off. */
[data-screen="calendar"] .cal__dot { display: block; width: .55rem; height: .55rem;
  border-radius: 50%; background: var(--color-accent-soft); }
/* A busy month would otherwise stack fifteen marks five rows deep and drag the year row with it, so
   the marks cap and the remainder becomes a count. The month grid behind the cell has the detail. */
[data-screen="calendar"] .cal__more { font-size: 9px; font-family: var(--font-mono);
  line-height: .55rem; color: var(--ink-muted); }

/* ---- the month grid: the drill-in, reached by clicking a month above ---- */
[data-screen="calendar"] .cal__nav { display: flex; align-items: baseline; gap: var(--space-3); }
[data-screen="calendar"] .cal__back,
[data-screen="calendar"] .cal__step { font: inherit; font-size: var(--text-sm);
  font-family: var(--font-mono); cursor: pointer; color: var(--color-accent);
  background: transparent; border: 1px solid transparent; border-radius: var(--radius-sm);
  padding: 0 var(--space-2); }
[data-screen="calendar"] .cal__back:hover,
[data-screen="calendar"] .cal__step:hover:not(:disabled) { border-color: var(--color-accent); }
/* Disabled at the ends of the record rather than hidden: a control that vanishes reads as a bug,
   one that greys out says the record simply stops here. */
[data-screen="calendar"] .cal__step:disabled { color: var(--ink-faint); cursor: default; }
[data-screen="calendar"] .cal__grid { display: grid; grid-template-columns: repeat(7, 1fr); }
[data-screen="calendar"] .cal__dow { padding: var(--space-2); text-align: center; font-size: var(--text-xs);
  font-family: var(--font-mono); color: var(--ink-muted); text-transform: uppercase;
  letter-spacing: .04em; border-bottom: 1px solid var(--line-soft); }
[data-screen="calendar"] .cal__cell { min-width: 0; min-height: 4.6rem; padding: var(--space-2);
  border-right: 1px solid var(--line-soft); border-bottom: 1px solid var(--line-soft);
  display: flex; flex-direction: column; gap: var(--space-1); }
[data-screen="calendar"] .cal__cell:nth-child(7n) { border-right: 0; }
[data-screen="calendar"] .cal__cell--out { background: var(--paper-2); }
[data-screen="calendar"] .cal__num { font-size: var(--text-xs); font-family: var(--font-mono); color: var(--ink-muted); }
[data-screen="calendar"] .cal__cell--out .cal__num { color: var(--ink-faint); }
[data-screen="calendar"] .cal__cell--today .cal__num { color: var(--color-accent-contrast);
  background: var(--color-accent); border-radius: var(--radius-pill);
  width: 1.5rem; height: 1.5rem; display: inline-flex; align-items: center;
  justify-content: center; font-weight: var(--font-weight-semibold); }
[data-screen="calendar"] .cal__event { display: block; width: 100%; text-align: left; font: inherit;
  font-size: var(--text-xs); line-height: var(--leading-tight); cursor: pointer;
  padding: 2px var(--space-2); border-radius: var(--radius-sm); border: 0;
  background: var(--color-accent-soft); color: var(--ink);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* per-kind chips: notes (base accent-soft), shipped posts (bordered), and events filled accent. */
[data-screen="calendar"] .cal__event--shipped { background: var(--paper-2); color: var(--ink-muted);
  border: 1px solid var(--line-soft); }
[data-screen="calendar"] .cal__event--hackathon,
[data-screen="calendar"] .cal__event--talk,
[data-screen="calendar"] .cal__event--workshop,
[data-screen="calendar"] .cal__event--career-fair,
[data-screen="calendar"] .cal__event--student-highlight {
  background: var(--color-accent); color: var(--color-accent-contrast); }
[data-screen="calendar"] .cal__event:hover { outline: 1px solid var(--color-accent); }
/* per-kind dots: notes (base accent-soft), shipped posts (bordered), and events (hackathon / talk /
   student-highlight / workshop / career-fair) filled accent, so the taught and coached days read
   loudest in the strip. */
[data-screen="calendar"] .cal__dot--shipped { background: var(--paper-2);
  border: 1px solid var(--line-soft); }
[data-screen="calendar"] .cal__dot--hackathon,
[data-screen="calendar"] .cal__dot--talk,
[data-screen="calendar"] .cal__dot--workshop,
[data-screen="calendar"] .cal__dot--career-fair,
[data-screen="calendar"] .cal__dot--student-highlight {
  background: var(--color-accent); }
[data-screen="calendar"] .cal__dot:hover,
[data-screen="calendar"] .cal__dot:focus-visible { outline: 2px solid var(--color-accent);
  outline-offset: 1px; }
[data-screen="calendar"] .cal__legend { display: flex; flex-wrap: wrap; gap: var(--space-4);
  font-size: var(--text-xs); color: var(--ink-muted); margin: 0;
  padding: var(--space-3) var(--space-5); border-top: 1px solid var(--line-soft); background: var(--paper-2); }
[data-screen="calendar"] .cal__swatch { display: inline-block; width: .8rem; height: .8rem; border-radius: var(--radius-sm);
  vertical-align: -1px; margin-right: var(--space-1); }
[data-screen="calendar"] .cal__swatch--event { background: var(--color-accent); }

/* the feed's filter tabs (Events / Notes / All). Ship hidden and are revealed by the island, so a
   no-JS reader gets the whole feed rather than a control that does nothing. */
[data-screen="calendar"] .feed__tabs { display: flex; gap: var(--space-1); margin: var(--space-4) 0 0; }
[data-screen="calendar"] .feed__tabs[hidden] { display: none; }
[data-screen="calendar"] .feed__tab { font: inherit; font-size: var(--text-xs); font-family: var(--font-mono);
  text-transform: uppercase; letter-spacing: .04em; cursor: pointer;
  padding: var(--space-1) var(--space-3); border: 1px solid var(--line-soft);
  border-radius: var(--radius-pill); background: transparent; color: var(--ink-muted); }
[data-screen="calendar"] .feed__tab:hover { color: var(--ink); border-color: var(--ink-faint); }
[data-screen="calendar"] .feed__tab[aria-selected="true"] { background: var(--color-accent);
  border-color: var(--color-accent); color: var(--color-accent-contrast); }
[data-screen="calendar"] .feed__empty { margin: var(--space-5) 0 0; color: var(--ink-muted);
  font-size: var(--text-sm); }
[data-screen="calendar"] .feed__empty[hidden] { display: none; }

/* Twelve months across is too many on a phone, so the year wraps to two rows of six and the year
   label moves above its own months rather than squeezing the strip from the side. */
@media (max-width: 640px) {
  [data-screen="calendar"] [data-cal-panel="years"] .cal__head {
    flex-direction: column; align-items: flex-start; gap: var(--space-2);
  }
  [data-screen="calendar"] [data-cal-panel="years"] .cal__to-feed { white-space: nowrap; }
  [data-screen="calendar"] .cal__year { flex-direction: column; }
  [data-screen="calendar"] .cal__year-label { border-right: 0;
    border-bottom: 1px solid var(--line-soft); padding: var(--space-2) var(--space-4); }
  [data-screen="calendar"] .cal__months { grid-template-columns: repeat(6, 1fr); }
  [data-screen="calendar"] .cal__mon { min-height: 3.6rem; }
  [data-screen="calendar"] .cal__mon:nth-child(6n) { border-right: 0; }
  [data-screen="calendar"] .cal__mon:nth-child(-n+6) { border-bottom: 1px solid var(--line-soft); }
  [data-screen="calendar"] .cal__cell { min-height: 3.6rem; }
  [data-screen="calendar"] .cal__event { padding: 1px var(--space-1); }
}
[data-screen="calendar"] .cal__more::after { content: attr(data-more); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/pages/docs/docs.css */
/* pages/docs — the docs MAP: a clickable variant of the shared four-layers figure
   (molecules/stack-diagram), reusing its classes so it inherits the theme-following palette (no new
   colors). The shared component stays a static figure; this map wraps each layer + the PANTRY frame
   in an <a> into that member's docs. Bars are outlined (fill:none), so pointer-events:all makes the
   WHOLE bar clickable, and hover ink-fills it exactly like the figure's "current" state — pickable
   without a new accent. Bundled into /components.css, scoped to the docs screen. */
[data-screen="docs"] .docs-map { margin: var(--space-4) 0 var(--space-6); }
[data-screen="docs"] .docs-map .stack-diagram { margin: 0 auto var(--space-2); }
[data-screen="docs"] .docs-map a { cursor: pointer; }
[data-screen="docs"] .docs-map a .bar { pointer-events: all; transition: fill 120ms ease, stroke 120ms ease; }
[data-screen="docs"] .docs-map a:hover .bar,
[data-screen="docs"] .docs-map a:focus-visible .bar { fill: var(--color-fg); stroke: var(--color-fg); }
[data-screen="docs"] .docs-map a:hover .bar-label,
[data-screen="docs"] .docs-map a:focus-visible .bar-label { fill: var(--color-bg); }
[data-screen="docs"] .docs-map a:hover .frame,
[data-screen="docs"] .docs-map a:focus-visible .frame { stroke: var(--color-fg); stroke-width: 2; }
[data-screen="docs"] .docs-map a:hover .frame-label,
[data-screen="docs"] .docs-map a:focus-visible .frame-label { fill: var(--color-fg); }
[data-screen="docs"] .docs-map figcaption { text-align: center; font-size: var(--text-sm); color: var(--color-muted); margin: 0; }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/pages/grain/grain.css */
/* pages/grain — the /grain demo-site LAYOUT only (NOT the design system; this page is GRAIN's own
   marketing site, composed from GRAIN's components on the default theme). Bundled into /components.css,
   scoped to the grain screen so nothing leaks. Tokens only — no hardcoded colours. */
[data-screen="grain"] .site { --site-max: 60rem; }

/* persistent chrome: a quiet sticky bar (wordmark · demo flag · ⌘K · Inspect) */
[data-screen="grain"] .site__bar {
  position: sticky; top: 0; z-index: 50;
  display: flex; align-items: center; gap: var(--space-4);
  padding: var(--space-3) var(--page-pad);
  background: color-mix(in srgb, var(--paper) 88%, transparent);
  backdrop-filter: blur(6px);
  border-bottom: 1px solid var(--color-line);
}
[data-screen="grain"] .site__brand { display: flex; align-items: baseline; gap: var(--space-3); min-width: 0; }
[data-screen="grain"] .site__wordmark { font-family: var(--font-accent); font-size: var(--text-xl); letter-spacing: 0.02em; }
[data-screen="grain"] .site__tagline { color: var(--color-muted); font-size: var(--text-sm); white-space: nowrap;
  overflow: hidden; text-overflow: ellipsis; }
[data-screen="grain"] .site__flag { font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 0.1em;
  color: var(--color-muted); border: 1px dashed var(--ink); border-radius: var(--radius-pill);
  padding: 0 var(--space-2); }
[data-screen="grain"] .site__bar-actions { display: flex; align-items: center; gap: var(--space-3); margin-left: auto; }

/* one editorial column, rules between sections */
[data-screen="grain"] .site__section { max-width: var(--site-max); margin: 0 auto; padding: var(--space-7) var(--page-pad); }
[data-screen="grain"] .site__section + .site__section { border-top: 1px solid var(--color-line); }
[data-screen="grain"] .site__lede { color: var(--color-muted); font-size: var(--text-lg); max-width: 54ch; line-height: var(--leading-relaxed); }
[data-screen="grain"] .site__why { color: var(--color-muted); font-size: var(--text-sm); max-width: 60ch; }

/* hero */
[data-screen="grain"] .hero { padding-top: var(--space-8); padding-bottom: var(--space-8); }
[data-screen="grain"] .hero__head { font-size: var(--display); line-height: var(--leading-tight); letter-spacing: -0.01em;
  margin: 0 0 var(--space-5); max-width: 18ch; }
[data-screen="grain"] .hero__cta { display: flex; flex-wrap: wrap; gap: var(--space-3); margin-top: var(--space-6); }

/* the signature micro-moment: the AI types a line — in grain, and it STAYS grain */
[data-screen="grain"] .signature { display: block; margin-top: var(--space-6); font-size: var(--text-2xl); line-height: 1.3; }
[data-screen="grain"] .signature__line { min-height: 1.3em; }
[data-screen="grain"] .signature__cap { display: block; margin-top: var(--space-3); font-size: var(--text-sm); color: var(--color-muted); }
[data-screen="grain"] .sig-replay { background: transparent; border: 0; padding: 0; cursor: pointer; color: var(--color-muted);
  font: inherit; font-size: var(--text-sm); text-decoration: underline; }
[data-screen="grain"] .sig-replay:hover { color: var(--ink); }

/* grade-as-signal panel */
[data-screen="grain"] .grade { display: grid; gap: var(--space-6); margin-top: var(--space-6);
  grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); }
[data-screen="grain"] .grade__card { border: 1px solid var(--color-line); border-radius: var(--radius-md); padding: var(--space-5); }
[data-screen="grain"] .grade__card h3 { font-size: var(--text-lg); margin: 0 0 var(--space-3); }
[data-screen="grain"] .grade__card .field { margin-bottom: var(--space-3); }
[data-screen="grain"] .grade__card .list__item { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); }
[data-screen="grain"] .grade__legend { display: flex; flex-wrap: wrap; gap: var(--space-5); margin-top: var(--space-6);
  font-size: var(--text-sm); color: var(--color-muted); }
[data-screen="grain"] .grade__legend b { color: var(--color-fg); font-weight: var(--font-weight-regular); }

/* the headline proof: the SAME words, large, clean vs grain — so you can see the texture change. */
[data-screen="grain"] .grade-show { margin: var(--space-6) 0; display: flex; flex-direction: column; gap: var(--space-4); }
[data-screen="grain"] .grade-show__row { display: flex; align-items: baseline; gap: var(--space-4); margin: 0; flex-wrap: wrap; }
[data-screen="grain"] .grade-show__tag { flex: 0 0 6rem; font-size: var(--text-xs); text-transform: uppercase;
  letter-spacing: 0.08em; color: var(--color-muted); }
[data-screen="grain"] .grade-show__text { font-size: var(--text-3xl); line-height: 1.2; }

/* a real composed surface (rail + main), not a specimen grid — every part is a grain component */
[data-screen="grain"] .surface { display: grid; grid-template-columns: minmax(7rem, 12rem) 1fr; gap: var(--space-5);
  margin-top: var(--space-6); border: 1px solid var(--color-line); border-radius: var(--radius-md);
  padding: var(--space-5); }
[data-screen="grain"] .surface__rail { display: flex; flex-direction: column; gap: var(--space-1);
  border-right: 1px solid var(--color-line); padding-right: var(--space-4); }
[data-screen="grain"] .surface__main { display: flex; flex-direction: column; gap: var(--space-5); min-width: 0; }
[data-screen="grain"] .surface__main .list__item { display: flex; align-items: center; gap: var(--space-3); }
[data-screen="grain"] .surface__main .list__item .task-label { flex: 1; }
[data-screen="grain"] .surface__main .list__item.is-done .task-label { text-decoration: line-through; color: var(--color-muted); }
[data-screen="grain"] [data-ai-run] { font-family: var(--font-grain); }   /* the AI trigger wears grain — the machine's move */
/* while acting it holds the standard AI-mode state (dashed edge + caret); lift it above the veil. */
[data-screen="grain"] [data-ai-run][data-commit="pending"] { position: relative; z-index: var(--z-ai-surface, 9002);
  background: var(--paper); color: var(--ink); }
[data-screen="grain"] .surface__compose { display: flex; align-items: flex-end; gap: var(--space-3); flex-wrap: wrap;
  border-top: 1px solid var(--color-line); padding-top: var(--space-4); }
[data-screen="grain"] .surface__compose .field { flex: 1; }
@media (max-width: 34rem) {
  [data-screen="grain"] .surface { grid-template-columns: 1fr; }
  [data-screen="grain"] .surface__rail { flex-direction: row; flex-wrap: wrap; border-right: 0; padding-right: 0;
    border-bottom: 1px solid var(--color-line); padding-bottom: var(--space-3); }
}

/* how it works — a small monochrome diagram */
[data-screen="grain"] .flow { display: flex; flex-wrap: wrap; align-items: stretch; gap: var(--space-2); margin: var(--space-6) 0; }
[data-screen="grain"] .flow__node { flex: 1 1 8rem; border: 1px solid var(--ink); border-radius: var(--radius-md);
  padding: var(--space-3) var(--space-4); font-size: var(--text-sm); text-align: center;
  display: flex; align-items: center; justify-content: center; }
[data-screen="grain"] .flow__node--door { border-style: solid; border-width: 2px; }
[data-screen="grain"] .flow__node--code { font-family: var(--font-grain); }
[data-screen="grain"] .flow__arrow { display: flex; align-items: center; color: var(--color-muted); }
[data-screen="grain"] .how__points { margin-top: var(--space-6); }

/* footer */
[data-screen="grain"] .site__foot { max-width: var(--site-max); margin: 0 auto; padding: var(--space-8) var(--page-pad) var(--space-16);
  border-top: 1px solid var(--color-line); color: var(--color-muted); font-size: var(--text-sm); }
[data-screen="grain"] .site__foot-links { display: flex; flex-wrap: wrap; gap: var(--space-5); margin-bottom: var(--space-4); }
[data-screen="grain"] .site__foot em { font-style: normal; text-decoration: underline; }

/* Themes tab — one card per flavor with a live "Use this theme" button (theme.js, data-set-theme) */
[data-screen="grain"] .themes { display: grid; gap: var(--space-4); margin-top: var(--space-5);
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); }
[data-screen="grain"] .theme-card { border: 1px solid var(--color-line); border-radius: var(--radius-md); padding: var(--space-5);
  display: flex; flex-direction: column; gap: var(--space-3); }
[data-screen="grain"] .theme-card__name { font-size: var(--text-lg); margin: 0; }
[data-screen="grain"] .theme-card__desc { color: var(--color-muted); font-size: var(--text-sm); margin: 0; flex: 1; }
[data-screen="grain"] .theme-card .btn { align-self: flex-start; }

/* offline degradation copy: shown only when the door's reply channel is down (dispatcher stamps
   body[data-ai-online="false"] by outcome). Persona-worded here; the gating mechanism is grain's. */
[data-screen="grain"] .offline-hint { display: none; color: var(--color-muted); font-size: var(--text-sm); }
body[data-screen="grain"][data-ai-online="false"] .offline-hint { display: inline; }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/pages/mail/mail.css */
/* pages/mail — the mailbox layout (bundled into /components.css, scoped to the mail screen). The row,
   folder, reader and related-link styles live with their molecules (mail-row, mail-folder,
   mail-reader, mail-related); this file owns only the board width and the two-pane grid. Everything
   in .mailbox is set dressing; the .compose form below is a static contact form whose Send builds a
   mailto: and hands off to the visitor's own mail app, so the site stays plain files with no backend.
   Like a real mail client, but shaped for the shell's content width (the app rail already eats the
   left of the viewport, so a third side-column would squeeze the reader to nothing): a folder toolbar
   across the top, then the message list and the reading pane side by side, filling the app window. The
   list and the pane scroll independently; Compose lives INSIDE the reading pane and swaps in over the
   open letter (island). Nothing here is real except Compose. Grid areas keep the HTML order (rail,
   list, pane) while laying the rail across the top. */
[data-screen="mail"] .board { max-width: 78rem; }
.mailbox__heading { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; clip-path: inset(50%); }

[data-screen="mail"] .mailbox { display: grid;
  grid-template-columns: minmax(13rem, 20rem) 1fr;
  grid-template-rows: auto 1fr;
  grid-template-areas: "rail rail" "list pane";
  gap: var(--space-3) var(--space-4); align-items: stretch; margin: 0 0 var(--space-3);
  height: calc(100dvh - 10rem); min-height: 28rem; }

/* --- rail: a top toolbar — Compose (the one live control) then the folders as a chip row --- */
[data-screen="mail"] .mailbox__rail { grid-area: rail; display: flex; flex-wrap: wrap; align-items: center;
  gap: var(--space-2) var(--space-3); }
[data-screen="mail"] .mailbox__compose { flex: 0 0 auto; }
[data-screen="mail"] .mailbox__folders { display: flex; flex-wrap: wrap; gap: var(--space-1); }

/* --- list: a header naming the folder + count, then the scrolling rows --- */
[data-screen="mail"] .mailbox__list { grid-area: list; min-width: 0; min-height: 0; display: flex; flex-direction: column;
  border: 1px solid var(--line-soft); border-radius: var(--radius-md); overflow: hidden; background: var(--paper); }
[data-screen="mail"] .mailbox__list-head { display: flex; align-items: baseline; justify-content: space-between;
  gap: var(--space-3); padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--line-soft);
  background: var(--paper-2); }
[data-screen="mail"] .mailbox__list-folder { font-size: var(--text-sm); font-weight: var(--font-weight-semibold); color: var(--ink); }
[data-screen="mail"] .mailbox__list-count { font-size: var(--text-xs); color: var(--ink-muted); font-family: var(--font-mono); }
[data-screen="mail"] .mailbox__rows { flex: 1 1 auto; min-height: 0; overflow-y: auto; }

/* --- pane: the reading surface; readers render borderless inside it, and Compose swaps in here --- */
[data-screen="mail"] .mailbox__pane { grid-area: pane; min-width: 0; min-height: 0; overflow-y: auto;
  border: 1px solid var(--line-soft); border-radius: var(--radius-md); background: var(--paper);
  padding: var(--space-6); }
[data-screen="mail"] .mailbox__readers { display: flex; flex-direction: column; gap: var(--space-6); }
[data-screen="mail"] .mailbox__pane .mailbox__reader { border: 0; border-radius: 0; padding: 0; background: transparent; }

[data-screen="mail"] .mailbox__note { margin: 0 0 var(--space-6); font-size: var(--text-xs); }
[data-screen="mail"] .mailbox__note a { color: var(--ink-muted); }

@media (max-width: 820px) {
  /* stack into a single scrolling column: folder toolbar, list, then the pane below */
  [data-screen="mail"] .mailbox { grid-template-columns: 1fr; grid-template-rows: auto auto auto;
    grid-template-areas: "rail" "list" "pane"; height: auto; min-height: 0; }
  [data-screen="mail"] .mailbox__rows { max-height: 20rem; }
}

/* compose panel: a static contact form (the Send button builds a mailto: and hands off). */
[data-screen="mail"] .compose-panel[hidden] { display: none; }
[data-screen="mail"] .compose { border: 1px solid var(--line-soft); border-radius: var(--radius-md);
  overflow: hidden; margin: 0; background: var(--paper); }
[data-screen="mail"] .compose__bar { display: flex; align-items: center; gap: var(--space-3);
  padding: var(--space-3) var(--space-4); background: var(--paper-2);
  border-bottom: 1px solid var(--line-soft); }
[data-screen="mail"] .compose__title { font-family: var(--font-mono); font-size: var(--text-sm);
  color: var(--ink); font-weight: var(--font-weight-semibold); }
[data-screen="mail"] .compose__dots { margin-left: auto; display: flex; gap: var(--space-1); }
[data-screen="mail"] .compose__dot { width: .55rem; height: .55rem; border-radius: var(--radius-pill);
  background: var(--line-soft); }

[data-screen="mail"] .compose__row { display: flex; align-items: baseline; gap: var(--space-3);
  padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--line-soft); }
[data-screen="mail"] .compose__key { flex: 0 0 auto; min-width: 4rem; font-size: var(--text-sm);
  font-family: var(--font-mono); color: var(--ink-muted); }
[data-screen="mail"] .compose__to { font-size: var(--text-sm); font-weight: var(--font-weight-semibold); color: var(--ink); }
[data-screen="mail"] .compose__to small { font-weight: var(--font-weight-regular); color: var(--ink-muted);
  font-family: var(--font-mono); }
[data-screen="mail"] .compose__field { flex: 1; min-width: 0; }
[data-screen="mail"] .compose__input, [data-screen="mail"] .compose__textarea { width: 100%; border: 0; padding: 0; margin: 0;
  background: transparent; color: var(--ink); font-family: var(--type-font);
  font-size: var(--text-base); }
[data-screen="mail"] .compose__input::placeholder, [data-screen="mail"] .compose__textarea::placeholder { color: var(--ink-muted); }
[data-screen="mail"] .compose__input:focus-visible, [data-screen="mail"] .compose__textarea:focus-visible {
  outline: 2px solid var(--color-accent); outline-offset: 3px; border-radius: var(--radius-sm); }
[data-screen="mail"] .compose__body { padding: var(--space-4); }
[data-screen="mail"] .compose__textarea { min-height: 12rem; resize: vertical; line-height: var(--leading-relaxed); }

[data-screen="mail"] .compose__foot { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-3) var(--space-4);
  padding: var(--space-3) var(--space-4); border-top: 1px solid var(--line-soft); background: var(--paper-2); }
[data-screen="mail"] .compose__hint { font-size: var(--text-xs); color: var(--ink-muted); flex: 1; min-width: 12rem; }
[data-screen="mail"] .compose__hint a { color: var(--ink-muted); }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/pages/pantry/pantry.css */
[data-screen="pantry"] .pantry-first-run .docs-list__item {
  display: grid;
  grid-template-columns: minmax(9rem, 0.8fr) minmax(0, 1.2fr);
  align-items: start;
  gap: var(--space-2) var(--space-3);
}

[data-screen="pantry"] .pantry-first-run .docs-list__name,
[data-screen="pantry"] .pantry-first-run__commands { min-width: 0; }

[data-screen="pantry"] .pantry-first-run__commands {
  display: grid;
  gap: var(--space-2);
}

[data-screen="pantry"] .pantry-first-run__commands code {
  overflow-wrap: anywhere;
  word-break: break-word;
}

@media (max-width: 38rem) {
  [data-screen="pantry"] .pantry-first-run .docs-list__item {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--space-1);
  }
}

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/pages/projects/projects.css */
/* Selected work hub: a short path from the portfolio overview to each project story. */
[data-screen="projects"] .projects-board { max-width: 1080px; }
[data-screen="projects"] .app-shell__main { container: projects-main / inline-size; }

[data-screen="projects"] .projects-index {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin: var(--space-5) 0 var(--space-7);
  padding-bottom: var(--space-3);
  border-bottom: 1px solid var(--color-line);
}

[data-screen="projects"] .projects-index a,
[data-screen="projects"] .project-feature__secondary {
  color: var(--color-muted);
  text-decoration: underline;
  text-decoration-color: var(--color-line);
  text-underline-offset: 0.22em;
}

[data-screen="projects"] .projects-index a:hover,
[data-screen="projects"] .project-feature__secondary:hover { color: var(--color-fg); }

[data-screen="projects"] .projects-group { margin: 0 0 var(--space-8); scroll-margin-top: var(--space-4); }
[data-screen="projects"] .projects-group > .eyebrow { margin-bottom: var(--space-1); }
[data-screen="projects"] .section-head { margin-top: 0; }

[data-screen="projects"] .project-feature {
  display: grid;
  grid-template-columns: minmax(0, 1.15fr) minmax(260px, 0.85fr);
  gap: clamp(var(--space-4), 5vw, var(--space-7));
  align-items: center;
  padding: var(--space-4) 0;
  border-bottom: 1px solid var(--color-line);
}

[data-screen="projects"] .project-feature__visual {
  display: block;
  overflow: hidden;
  border: 1px solid var(--color-line);
  border-radius: var(--radius-md, 8px);
  background: var(--color-bg-subtle, var(--color-surface));
}

[data-screen="projects"] .project-feature__visual img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 8 / 5;
  object-fit: cover;
}

[data-screen="projects"] .project-feature__visual--diagram {
  display: grid;
  min-height: 260px;
  padding: var(--space-4);
  place-items: center;
}

[data-screen="projects"] .project-feature__visual--diagram .stack-diagram { max-width: 620px; }
[data-screen="projects"] .project-feature__copy h3,
[data-screen="projects"] .project-related h3 { margin: 0 0 var(--space-2); font-size: var(--type-3, 1.5rem); }
[data-screen="projects"] .project-feature__copy h3 a,
[data-screen="projects"] .project-related h3 a { color: var(--color-fg); text-decoration: none; }
[data-screen="projects"] .project-feature__copy h3 a:hover,
[data-screen="projects"] .project-related h3 a:hover { text-decoration: underline; text-underline-offset: 0.16em; }
[data-screen="projects"] .project-feature__copy p,
[data-screen="projects"] .project-related p { max-width: 58ch; }
[data-screen="projects"] .project-feature__kicker { margin: 0 0 var(--space-1); color: var(--color-muted); font-size: var(--type--1, 0.875rem); }
[data-screen="projects"] .project-proof { padding-left: var(--space-3); border-left: 2px solid var(--color-accent, var(--color-muted)); }
[data-screen="projects"] .project-feature__link { display: inline-block; margin-top: var(--space-2); font-weight: 600; }
[data-screen="projects"] .project-feature__actions { display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: baseline; }
[data-screen="projects"] .project-feature__actions .project-feature__link { margin-top: var(--space-2); }
[data-screen="projects"] .project-feature__secondary { font-size: var(--type--1, 0.875rem); }

[data-screen="projects"] .project-related-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--space-3);
  margin-top: var(--space-3);
}

[data-screen="projects"] .project-related {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: var(--space-3);
  align-items: center;
  padding: var(--space-4);
  border: 1px solid var(--color-line);
  background: var(--color-bg-subtle, var(--color-surface));
}

[data-screen="projects"] .project-related p:last-child { margin-bottom: 0; }
[data-screen="projects"] .project-related .project-feature__link { margin-top: 0; }

@container projects-main (max-width: 52rem) {
  [data-screen="projects"] .projects-index { gap: var(--space-2) var(--space-3); }
  [data-screen="projects"] .project-feature { grid-template-columns: minmax(0, 1fr); gap: var(--space-3); }
  [data-screen="projects"] .project-feature__copy { order: 2; }
  [data-screen="projects"] .project-feature__visual { order: 1; }
  [data-screen="projects"] .project-feature__visual--diagram { min-height: 200px; }
  [data-screen="projects"] .project-related-grid { grid-template-columns: minmax(0, 1fr); }
}

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/pages/reading/reading.css */
/* pages/reading — the furniture for /reading (bundled into /components.css, scoped to the reading
   screen). Two shapes: a bookshelf for the works, where each spine is a link standing on a board,
   and a social-style feed for the people, where each post is what I took from one person. Neither
   would be wanted by a second product, so neither goes up into grain. Spine colours mix the theme's
   own ink and accent, so the shelf follows sourdough, baguette and brioche without its own palette. */

/* ── The bookshelf ─────────────────────────────────────────────────────────────────────────── */
[data-screen="reading"] .shelf { margin: var(--space-4) 0 var(--space-6); }
/* The shelf wraps: every line of books is 15rem tall and stands on its own board, painted by the
   row's repeating background so a second line gets a second board with no extra markup. */
[data-screen="reading"] .shelf__row { --shelf-line: 17.5rem; --shelf-board: 0.75rem;
  list-style: none; margin: 0; padding: 0 var(--space-4);
  display: flex; flex-wrap: wrap; align-items: flex-end; gap: var(--shelf-board) var(--space-1);
  background: repeating-linear-gradient(to bottom, transparent 0 var(--shelf-line),
    color-mix(in srgb, var(--ink) 55%, var(--paper)) var(--shelf-line) calc(var(--shelf-line) + var(--shelf-board)));
  padding-bottom: var(--shelf-board); }
[data-screen="reading"] .shelf__board { display: none; }
[data-screen="reading"] .book { flex: none; height: var(--shelf-line); display: flex; align-items: flex-end; }

[data-screen="reading"] .book__spine { display: flex; flex-direction: column; justify-content: space-between;
  align-items: center; box-sizing: border-box; width: 3.25rem; height: 15rem; padding: var(--space-2) 0;
  border-radius: 3px 3px 0 0; text-decoration: none; color: var(--paper);
  background: var(--color-accent);   /* page-colored text on full accent: 4.9:1 at worst (WCAG 1.4.3) */
  box-shadow: inset 0.25rem 0 0 color-mix(in srgb, var(--paper) 18%, transparent),
              inset -0.2rem 0 0 color-mix(in srgb, var(--ink) 30%, transparent);
  transition: transform 160ms ease; }
[data-screen="reading"] .book:nth-child(3n+2) .book__spine { height: 16.5rem; width: 3rem;
  background: color-mix(in srgb, var(--color-accent) 35%, var(--paper)); color: var(--ink); }
[data-screen="reading"] .book:nth-child(3n) .book__spine {
  background: color-mix(in srgb, var(--color-accent) 20%, var(--paper)); color: var(--ink); }
[data-screen="reading"] .book[data-kind="essay"] .book__spine { width: 1.75rem; height: 13rem; }
[data-screen="reading"] .book__spine:hover,
[data-screen="reading"] .book__spine:focus-visible { transform: translateY(-0.6rem); }
@media (prefers-reduced-motion: reduce) { [data-screen="reading"] .book__spine { transition: none; } }

[data-screen="reading"] .book__title,
[data-screen="reading"] .book__author { writing-mode: vertical-rl; text-orientation: mixed; white-space: nowrap; }
[data-screen="reading"] .book__title { font-weight: 600; font-size: 0.95rem; letter-spacing: 0.02em; }
[data-screen="reading"] .book__author { font-family: var(--font-mono); font-size: 0.7rem; }
[data-screen="reading"] .book[data-kind="essay"] .book__author { display: none; }
[data-screen="reading"] .book[data-long] .book__spine { width: 4rem; height: 16.5rem; }
[data-screen="reading"] .book[data-long] .book__title { white-space: normal; inline-size: 10.5rem; font-size: 0.85rem; }

/* One card under the shelf at a time. A spine links to its card's id, and :target opens it, so
   picking a book needs no script; the hint shows until a book is picked. */
[data-screen="reading"] .shelf__cards { margin: var(--space-4) 0 var(--space-6); max-width: 40rem; }
/* With no book picked yet, the first book on the shelf is open, so the card is never empty. */
[data-screen="reading"] .shelf__cards:not(:has(.book-card:target)) .book-card:first-of-type { display: block; }
[data-screen="reading"] .book-card { display: none; scroll-margin-top: 40vh; padding: var(--space-4);
  border: 1px solid var(--line-soft); border-left: 3px solid var(--color-accent); border-radius: var(--radius-md); }
[data-screen="reading"] .book-card:target { display: block; }
[data-screen="reading"] .book-card__meta { margin: 0; font-family: var(--font-mono); font-size: 0.8rem; color: var(--ink-muted); }
[data-screen="reading"] .book-card__title { margin: var(--space-1) 0 var(--space-2); }
[data-screen="reading"] .book-card__what { margin: 0 0 var(--space-2); }
[data-screen="reading"] .book-card__why { margin: 0 0 var(--space-4); color: var(--ink-muted); }
[data-screen="reading"] .book-card__why strong { color: var(--ink); }
/* The picked book is pulled partway off the shelf: it rises, grows toward the reader, and casts a
   shadow on the books beside it, instead of glowing. */
[data-screen="reading"] .book__spine[aria-current] { position: relative; z-index: 1;
  transform: translateY(-1.5rem) scale(1.1);
  box-shadow: inset 0.25rem 0 0 color-mix(in srgb, var(--paper) 18%, transparent),
              0.75rem 1.25rem 1.5rem 0 color-mix(in srgb, black 75%, transparent); }

/* ── The people, as a feed ────────────────────────────────────────────────────────────────── */
[data-screen="reading"] .people { list-style: none; margin: var(--space-4) 0 var(--space-6); padding: 0;
  display: grid; gap: var(--space-4); max-width: 40rem; }
[data-screen="reading"] .post { display: grid; grid-template-columns: 3rem 1fr; gap: var(--space-2) var(--space-4);
  padding: var(--space-4); border: 1px solid var(--line-soft); border-radius: var(--radius-md); }
[data-screen="reading"] .post__avatar { width: 3rem; height: 3rem; border-radius: 50%; display: grid;
  place-items: center; font-weight: 700; color: var(--paper);
  background: color-mix(in srgb, var(--color-accent) 70%, var(--ink)); position: relative; overflow: hidden; }
[data-screen="reading"] .post__avatar img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
[data-screen="reading"] .post__main { min-width: 0; }
[data-screen="reading"] .post__head { margin: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 var(--space-2); }
[data-screen="reading"] .post__name { font-weight: 700; color: var(--ink); }
[data-screen="reading"] .post__handle { font-family: var(--font-mono); font-size: 0.8rem; color: var(--ink-muted); }
[data-screen="reading"] .post__body { margin: var(--space-2) 0 var(--space-4); }
[data-screen="reading"] .post__embed { display: block; padding: var(--space-2) var(--space-4); border: 1px solid var(--line-soft);
  border-radius: var(--radius-md); text-decoration: none; color: inherit; }
[data-screen="reading"] .post__embed:hover { border-color: var(--color-accent); }
[data-screen="reading"] .post__embed-site { display: block; font-family: var(--font-mono); font-size: 0.75rem; color: var(--ink-muted); }
[data-screen="reading"] .post__embed-title { font-weight: 600; }
[data-screen="reading"] .post__foot { margin: var(--space-2) 0 0; font-size: 0.85rem; color: var(--ink-muted);
  display: flex; flex-wrap: wrap; gap: 0 var(--space-4); }

/* The category sits at the foot of each spine under a rule, the way a library label would. */
[data-screen="reading"] .book__cat { align-self: stretch; text-align: center; margin-top: var(--space-1);
  padding-top: var(--space-1); border-top: 1px solid color-mix(in srgb, currentColor 45%, transparent);
  font-family: var(--font-mono); font-size: 0.55rem; font-weight: 700; letter-spacing: 0.04em; opacity: 0.85; }
[data-screen="reading"] .book__cat .visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap; }
[data-screen="reading"] .book__title, [data-screen="reading"] .book__author, [data-screen="reading"] .book__cat { flex-shrink: 0; }

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/pages/reference/reference.css */
[data-screen="reference"] .reference-context {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-3);
  margin: var(--space-4) 0 var(--space-6);
  font-size: var(--text-sm);
}

[data-screen="reference"] .reference-context span { color: var(--color-muted); }

[data-screen="reference"] .reference-scroll-hint { display: none; }

@media (max-width: 42rem) {
  [data-screen="reference"] .reference-scroll-hint {
    display: block;
    margin: calc(-1 * var(--space-4)) 0 var(--space-3);
    color: var(--color-muted);
    font-size: var(--text-xs);
  }
}

[data-screen="reference"] .reference-table-scroll {
  max-width: 100%;
  margin: var(--space-3) 0 var(--space-6);
  overflow-x: auto;
  overscroll-behavior-inline: contain;
}

[data-screen="reference"] .reference-table-scroll table {
  min-width: 100%;
  width: max-content;
}

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/pages/resume/resume.css */
/* pages/resume — résumé page layout (bundled into /components.css, scoped to the résumé screen so
   nothing leaks to other pages). The timeline itself is the shared cv-entry/cv-bullet/cv-link/cv-skill
   molecules (data/cv.json, rendered here and on About's CV tab); only the page's own Languages/
   Certifications trim and the print rules live here. Print = a flat, single-column, white ATS sheet. */

[data-screen="resume"] .cv-languages { margin: var(--space-2) 0 var(--space-5); color: var(--ink-muted); }
[data-screen="resume"] .resume-philosophy-link { margin: calc(var(--space-3) * -1) 0 var(--space-5);
  color: var(--ink-muted); font-size: var(--text-xs); }
[data-screen="resume"] .resume-philosophy-link a { color: inherit; }
[data-screen="resume"] .cv-certs { list-style: none; margin: var(--space-2) 0 var(--space-5); padding: 0;
  display: flex; flex-direction: column; gap: var(--space-1); }
[data-screen="resume"] .resume-contact { display: flex; flex-wrap: wrap; gap: var(--space-1) var(--space-3);
  margin: var(--space-3) 0 var(--space-5); color: var(--ink-muted); font-size: var(--text-xs); }
[data-screen="resume"] .resume-contact a { color: inherit; text-decoration: none; }

@media (max-width: 42rem) {
  [data-screen="resume"] .profile-card__actions {
    flex: 1 1 100%;
    min-width: 0;
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
  [data-screen="resume"] .profile-card__actions > * {
    min-width: 0;
    justify-content: center;
    padding-inline: var(--space-2);
    font-size: var(--text-xs);
    white-space: nowrap;
  }
}

/* PRINT: drop the editor chrome + all interactive bits and print a flat, single-column résumé on white.
   The point is an ATS-readable sheet: the cv-entry accordions are FORCED OPEN regardless of their
   collapsed screen state, the Show/Hide toggles and the related-post links are removed, and the skills
   grid collapses to one column. <portfolio-frame/> expands into the app-shell's chrome children, so hide
   every app-shell child EXCEPT the main column. Scoped to the résumé screen (also serves /cv). */
@media print {
  @page { size: letter; margin: 0.42in 0.48in; }
  body[data-screen="resume"].app-window-backdrop { background: var(--color-surface) !important; color: var(--color-fg) !important;
    font-family: Arial, Helvetica, sans-serif !important; font-size: 8.5pt; line-height: 1.22; }
  body[data-screen="resume"] .app-shell > *:not(.app-shell__main) { display: none !important; }
  body[data-screen="resume"] .app-shell,
  body[data-screen="resume"] .app-shell__main,
  body[data-screen="resume"] .board { display: block !important; width: auto !important; max-width: none !important;
    height: auto !important; min-height: 0 !important; overflow: visible !important; margin: 0 !important; padding: 0 !important;
    background: var(--color-surface) !important; box-shadow: none !important; border: 0 !important;
    font-family: Arial, Helvetica, sans-serif !important; }
  body[data-screen="resume"] .board * { font-family: Arial, Helvetica, sans-serif !important; }
  body[data-screen="resume"] .profile-card__actions,
  body[data-screen="resume"] .profile-card__avatar,
  body[data-screen="resume"] .profile-card__tagline,
  body[data-screen="resume"] .cv-entry__toggle,
  body[data-screen="resume"] .cv-entry__links,
  body[data-screen="resume"] .cv-entry__media,
  body[data-screen="resume"] .page-foot { display: none !important; }
  body[data-screen="resume"] .board > p.lede:last-of-type { display: none !important; }
  /* force every accordion open no matter its screen state, so no bullet is hidden from the PDF/ATS */
  body[data-screen="resume"] .cv-entry.is-collapsed .cv-entry__detail,
  body[data-screen="resume"] .cv-entry__detail { display: block !important; }
  /* Compact typography and hierarchy keep the page readable while matching the source résumé. */
  body[data-screen="resume"] .section-head { break-after: avoid; }
  body[data-screen="resume"] .profile-card { display: block !important; margin: 0 0 2pt !important; padding: 0 !important; }
  body[data-screen="resume"] .profile-card__name { margin: 0 !important; font: 700 19pt/1.08 Arial, Helvetica, sans-serif !important;
    letter-spacing: 0.04em; color: #213f6b !important; text-transform: uppercase; }
  body[data-screen="resume"] .profile-card__role { margin: 2pt 0 0 !important; font-size: 9pt !important; color: var(--color-fg) !important; }
  body[data-screen="resume"] .profile-card .eyebrow { margin: 0 0 2pt !important; font-size: 8pt !important;
    text-transform: uppercase; letter-spacing: 0.08em; }
  body[data-screen="resume"] .resume-contact { gap: 2pt 9pt; margin: 4pt 0 9pt !important; font-size: 8.3pt !important;
    line-height: 1.2; }
  body[data-screen="resume"] .resume-contact a { color: #1456a0 !important; }
  body[data-screen="resume"] .section-head { margin: 8pt 0 3pt !important; padding: 0 0 2pt !important;
    border-bottom: 1px solid #213f6b !important; font: 700 10pt/1.2 Arial, Helvetica, sans-serif !important;
    color: #213f6b !important; text-transform: uppercase; letter-spacing: 0.045em; }
  body[data-screen="resume"] .lede { margin: 0 0 4pt !important; max-width: none !important; font-size: 9.3pt !important; line-height: 1.25 !important; color: #222 !important; }
  body[data-screen="resume"] .cv-core { display: block !important; margin: 0 0 2pt !important; padding: 0 !important;
    font-size: 9.5pt !important; line-height: 1.3 !important; }
  body[data-screen="resume"] .cv-core { display: flex !important; flex-wrap: wrap !important; gap: 0 5pt !important; }
  body[data-screen="resume"] .cv-chip { display: inline !important; }
  body[data-screen="resume"] .cv-chip:not(:last-child)::after { content: " • "; }
  body[data-screen="resume"] .cv-list { display: block !important; margin: 2pt 0 5pt !important; }
  body[data-screen="resume"] .cv-entry { margin: 0 0 4pt !important; break-inside: avoid; }
  /* Keep the current leadership and teaching roles on page one; move earlier work together to page two. */
  body[data-screen="resume"] .cv-list > .cv-entry:nth-child(5) { break-before: page; }
  body[data-screen="resume"] .cv-entry__head { display: block !important; font-size: 0 !important; }
  body[data-screen="resume"] .cv-entry__title { display: inline !important; margin: 0 !important; font-size: 10.5pt !important;
    line-height: 1.15 !important; font-weight: 700 !important; color: #111 !important; }
  body[data-screen="resume"] .cv-entry__meta { display: contents !important; }
  body[data-screen="resume"] .cv-entry__company { display: inline !important; font-size: 10.5pt !important;
    font-weight: 700 !important; color: #111 !important; }
  body[data-screen="resume"] .cv-entry__company::before { content: ", "; }
  body[data-screen="resume"] .cv-entry__dates { float: right; font-size: 9pt !important; font-weight: 700 !important; }
  body[data-screen="resume"] .cv-entry__where { clear: both; margin: 1pt 0 0 !important; font: 8.3pt/1.2 Arial, Helvetica, sans-serif !important; }
  body[data-screen="resume"] .cv-entry__summary { display: none !important; }
  body[data-screen="resume"] .cv-entry__detail { margin: 2pt 0 0 !important; }
  body[data-screen="resume"] .cv-entry__bullets { gap: 1pt !important; padding-left: 13pt !important; }
  body[data-screen="resume"] .cv-bullet { color: #222 !important; font-size: 9.8pt !important; line-height: 1.25 !important; }
  body[data-screen="resume"] .cv-entry__bullets:empty { display: none !important; }
  body[data-screen="resume"] .cv-projects,
  body[data-screen="resume"] .docs-list { margin: 2pt 0 4pt !important; }
  body[data-screen="resume"] .docs-list__item { display: grid !important; grid-template-columns: 1.45in minmax(0, 1fr) !important;
    width: 100% !important; box-sizing: border-box; gap: 5pt !important; margin: 2pt 0 !important; padding: 3pt 0 !important;
    font-size: 8pt !important; }
  body[data-screen="resume"] .docs-list__name { min-width: 0 !important; font-size: 8.4pt !important; }
  body[data-screen="resume"] .docs-list__what { min-width: 0 !important; color: #222 !important; font-size: 8.8pt !important; line-height: 1.2 !important; }
  body[data-screen="resume"] .cv-skills { grid-template-columns: 1fr 1fr !important; gap: 2pt 12pt !important; margin: 2pt 0 4pt !important; }
  body[data-screen="resume"] .cv-skill { padding: 0 !important; }
  body[data-screen="resume"] .cv-skill__group { margin: 0 !important; font-size: 8pt !important; }
  body[data-screen="resume"] .cv-skill__items { margin: 0 !important; color: #222 !important; font-size: 7.6pt !important; line-height: 1.2 !important; }
  body[data-screen="resume"] .cv-certs { flex-direction: row !important; flex-wrap: wrap !important; gap: 0 7pt !important; margin: 2pt 0 3pt !important; }
  body[data-screen="resume"] .cv-certs .cv-bullet:not(:last-child)::after { content: " • "; }
  body[data-screen="resume"] .cv-languages { margin: 0 !important; font-size: 8pt !important; }
  /* Single-column flow, no card chrome; links remain visible to ATS parsers. */
  body[data-screen="resume"] .profile-card,
  body[data-screen="resume"] .cv-entry,
  body[data-screen="resume"] .cv-skill { background: var(--color-surface) !important; border: 0 !important; }
  body[data-screen="resume"] .cv-entry { padding-top: 0 !important; padding-bottom: 0 !important; }
  /* the primary-skill chips flatten to a plain bullet-separated list, not a row of screen-only pills */
  body[data-screen="resume"] .cv-core { gap: 0 !important; }
  body[data-screen="resume"] .cv-chip { border: 0 !important; padding: 0 !important; color: inherit !important; font-family: inherit !important; }
  body[data-screen="resume"] .cv-chip:not(:last-child)::after { content: " • "; }
  /* the chip label is a nested <a>; in the flat comma list it must read as plain text, not a link */
  body[data-screen="resume"] .cv-chip__link { color: inherit !important; text-decoration: none !important; }
  body[data-screen="resume"] a { color: inherit !important; text-decoration: none !important; }
}

/* /home/runner/work/tjakoen.github.io/tjakoen.github.io/view/components/pages/talk/talk.css */
/* pages/talk — the furniture for ONE talk, "Ten Times Zero Is Still Zero" (bundled into
   /components.css, scoped to the talk screen). The deck itself is GRAIN's `presentation` organism;
   everything here is this talk's own figures: the multiplier slider, the life grid, the guess bar,
   the sprint line, the playbook ring, the trap demo, the cold-session panel, the session dots, the
   correction log, and the rest. None of it belongs up in grain, because none of it would be wanted
   by a second product. The behaviour half is scripts/talk.js, which hangs off the organism's one
   seam and reaches into nothing.

   `body[data-deck-only]` is the page contract: a talk owns the window, so this page carries no app
   shell and the deck takes the full height the shell would otherwise hand it. */

body[data-deck-only] { margin: 0; padding: 0; height: 100dvh; overflow: hidden; background: var(--color-bg); }
body[data-deck-only] > .presentation { height: 100dvh; }


@media print {
  body[data-deck-only] { height: auto; overflow: visible; background: none; }
  body[data-deck-only] > .presentation { height: auto; }
}


[data-screen="talk"] .presentation__notes [data-grade="grain"],
[data-screen="talk"] .years__total[data-grade="grain"] { --type-font: var(--font-grain-fine); }


[data-screen="talk"] .claim {
  font-size: calc(var(--u) * 4.2); line-height: 1.2; color: var(--color-fg);
  margin: calc(var(--u) * 4) 0 0; max-width: 30ch;
}


[data-screen="talk"] .fig { display: block; width: 100%; height: auto; margin: calc(var(--u) * 3) 0 0; }


[data-screen="talk"] .twohands { display: grid; grid-template-columns: 1fr 1fr; gap: calc(var(--u) * 5);
  align-items: center; margin-top: calc(var(--u) * 2); }
[data-screen="talk"] .twohands .fig { max-width: 100%; margin-top: 0; }
[data-screen="talk"] .twohands .beats { margin-top: 0; }



[data-screen="talk"] .years { display: grid; grid-template-columns: auto 1fr; gap: calc(var(--u) * 5);
  align-items: start; margin-top: calc(var(--u) * 4); }
[data-screen="talk"] .years__side { display: flex; flex-direction: column; }
[data-screen="talk"] .years__grid { display: grid; grid-template-rows: repeat(12, auto); grid-auto-flow: column;
  gap: calc(var(--u) * 0.9); }
[data-screen="talk"] .years__cap { font-size: calc(var(--u) * 1.4); color: var(--ink-muted);
  margin: calc(var(--u) * 1.8) 0 0; max-width: calc(var(--u) * 26); line-height: 1.35; }
[data-screen="talk"] .years__dot {
  width: calc(var(--u) * 1.15); height: calc(var(--u) * 1.15); border-radius: 50%;
  background: var(--ink-faint); opacity: 0.35;
  transition: background 0.3s ease, opacity 0.3s ease, transform 0.3s ease;
}
[data-screen="talk"] .years__dot[data-on] { background: var(--ink); opacity: 1; }
[data-screen="talk"] .years__dot[data-now] { background: var(--color-accent); transform: scale(1.35); }
[data-screen="talk"] .years__key { display: flex; flex-direction: column; gap: calc(var(--u) * 1.1); }
[data-screen="talk"] .years__row { display: flex; justify-content: space-between; align-items: baseline;
  gap: calc(var(--u) * 3); margin: 0; padding-bottom: calc(var(--u) * 0.9);
  border-bottom: 1px solid var(--color-line); }
[data-screen="talk"] .years__row b { font-size: calc(var(--u) * 2.1); font-weight: var(--font-weight-regular); }
[data-screen="talk"] .years__row span { font-size: calc(var(--u) * 1.6); color: var(--color-muted); text-align: right; }
[data-screen="talk"] .years__total { font-size: calc(var(--u) * 2.1); margin: calc(var(--u) * 1.5) 0 0; }


[data-screen="talk"] .scaffold { max-width: calc(var(--u0) * 74); }


[data-screen="talk"] .end { display: flex; align-items: center; gap: calc(var(--u) * 6); margin-top: calc(var(--u) * 5); }
[data-screen="talk"] .end__qr { width: calc(var(--u) * 20); height: calc(var(--u) * 20); color: var(--color-fg); flex: none; }
[data-screen="talk"] .end__qr svg { width: 100%; height: 100%; shape-rendering: crispEdges; }
[data-screen="talk"] .end__url { font-family: var(--font-mono); font-size: calc(var(--u) * 1.9);
  color: var(--color-muted); margin-top: calc(var(--u) * 2); word-break: break-all; }
[data-screen="talk"] .end__url a { color: inherit; }


/* --- 6a · what the first number is made of ---------------------------------
   Four claims you can only earn. Checking them drives the same bar the napkin used, so the
   callback is visual and nobody has to be told it is a callback. */
[data-screen="talk"] .bring { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
  gap: calc(var(--u) * 5); align-items: start; margin-top: calc(var(--u) * 3.5); }
[data-screen="talk"] .bring__list { list-style: none; margin: 0; padding: 0;
  display: flex; flex-direction: column; gap: calc(var(--u) * 1.2); }
[data-screen="talk"] .bring__item { display: block; width: 100%; text-align: left; cursor: pointer;
  background: transparent; border: 1px solid var(--color-line); border-radius: var(--radius-md);
  padding: calc(var(--u) * 1.5) calc(var(--u) * 2); font: inherit; color: var(--color-fg);
  transition: border-color 0.2s ease, background 0.2s ease; }
[data-screen="talk"] .bring__item b { display: block; font-size: calc(var(--u) * 2.1);
  font-weight: var(--font-weight-regular); line-height: 1.2; }
[data-screen="talk"] .bring__item span { display: block; font-size: calc(var(--u) * 1.5);
  color: var(--color-muted); margin-top: calc(var(--u) * 0.5); line-height: 1.35; }
[data-screen="talk"] .bring__item:hover { border-color: var(--hairline); }
[data-screen="talk"] .bring__item[aria-pressed="true"] { border-color: var(--color-accent); background: var(--paper-2); }
[data-screen="talk"] .bring__item[aria-pressed="true"] b { color: var(--color-accent); }
[data-screen="talk"] .bring__meter { padding-top: calc(var(--u) * 1); }
[data-screen="talk"] .bring__num { margin: 0; display: flex; align-items: baseline; gap: calc(var(--u) * 1.6); }
[data-screen="talk"] .bring__num span { font-size: calc(var(--u) * 6); font-family: var(--font-mono); line-height: 1; }
[data-screen="talk"] .bring__num i { font-style: normal; font-size: calc(var(--u) * 1.7); color: var(--color-muted); }
[data-screen="talk"] .bring__track { display: block; height: calc(var(--u) * 2.6); border: 1px solid var(--color-line);
  position: relative; margin-top: calc(var(--u) * 2); }
[data-screen="talk"] .bring__fill { position: absolute; inset: 0 auto 0 0; background: var(--color-accent);
  transition: width 0.32s cubic-bezier(0.22, 0.61, 0.36, 1); }
[data-screen="talk"] .bring__verdict { font-size: calc(var(--u) * 2.3); margin-top: calc(var(--u) * 2.6);
  min-height: 3.6em; max-width: 32ch; line-height: 1.35; }
[data-screen="talk"] .bring__foot { font-size: calc(var(--u) * 1.6); color: var(--color-muted);
  margin-top: calc(var(--u) * 1.5); max-width: 34ch; line-height: 1.4; }


/* --- 20a · context engineering ---------------------------------------------
   Three files, one cold session, eight honest outcomes. The point is that the prompt never
   changes: only the room does. */
[data-screen="talk"] .ctx { display: grid; grid-template-columns: minmax(0, 0.85fr) minmax(0, 1fr);
  gap: calc(var(--u) * 5); align-items: start; margin-top: calc(var(--u) * 3); }
[data-screen="talk"] .ctx__files { list-style: none; margin: 0; padding: 0;
  display: flex; flex-direction: column; gap: calc(var(--u) * 1.2); }
[data-screen="talk"] .ctx__file { display: flex; align-items: center; gap: calc(var(--u) * 1.4); width: 100%;
  cursor: pointer; background: transparent; border: 1px solid var(--color-line); border-radius: var(--radius-md);
  padding: calc(var(--u) * 1.3) calc(var(--u) * 1.8); font: inherit; color: var(--color-muted);
  font-family: var(--font-mono); font-size: calc(var(--u) * 1.7); text-align: left;
  transition: border-color 0.2s ease, color 0.2s ease; }
[data-screen="talk"] .ctx__file::before { content: "○"; font-size: calc(var(--u) * 1.5); }
[data-screen="talk"] .ctx__file[aria-pressed="true"] { border-color: var(--color-accent); color: var(--color-accent); }
[data-screen="talk"] .ctx__file[aria-pressed="true"]::before { content: "●"; }
[data-screen="talk"] .ctx__file i { font-style: normal; font-family: var(--font-sans, inherit);
  font-size: calc(var(--u) * 1.4); color: var(--ink-muted); }
[data-screen="talk"] .ctx__run { border: 1px solid var(--color-line); border-radius: var(--radius-md);
  padding: calc(var(--u) * 2.2); }
[data-screen="talk"] .ctx__lab { margin: 0; font-size: calc(var(--u) * 1.4); color: var(--color-muted);
  letter-spacing: 0.12em; text-transform: uppercase; }
[data-screen="talk"] .ctx__prompt { font-family: var(--font-mono); font-size: calc(var(--u) * 1.9);
  margin: calc(var(--u) * 1.2) 0 calc(var(--u) * 2.4); color: var(--color-fg); }
[data-screen="talk"] .ctx__out { font-size: calc(var(--u) * 2.1); margin: calc(var(--u) * 1.2) 0 0;
  min-height: 4.2em; line-height: 1.35; }
[data-screen="talk"] .ctx__out[data-good="1"] { color: var(--color-accent); }
[data-screen="talk"] .ctx__test { font-size: calc(var(--u) * 2.2); margin-top: calc(var(--u) * 3); max-width: 46ch; }


/* --- the four moves, and the four gates: same shape, used twice -------------
   A numbered row of steps. Counters rather than list markers, so the number can sit above the
   label at a size that still reads from the back of a room. */
[data-screen="talk"] .moves, [data-screen="talk"] .gates {
  list-style: none; counter-reset: step; margin: calc(var(--u) * 3) 0 0; padding: 0;
  display: grid; grid-template-columns: repeat(4, 1fr); gap: calc(var(--u) * 3);
}
[data-screen="talk"] .moves li, [data-screen="talk"] .gates li { counter-increment: step;
  border-top: 1px solid var(--color-line); padding-top: calc(var(--u) * 1.4); }
[data-screen="talk"] .moves li::before, [data-screen="talk"] .gates li::before {
  content: "0" counter(step); display: block; font-family: var(--font-mono);
  font-size: calc(var(--u) * 1.3); color: var(--ink-muted); margin-bottom: calc(var(--u) * 0.8); }
[data-screen="talk"] .moves b, [data-screen="talk"] .gates b { display: block;
  font-size: calc(var(--u) * 2.2); font-weight: var(--font-weight-regular); line-height: 1.15; }
[data-screen="talk"] .moves span, [data-screen="talk"] .gates span { display: block;
  font-size: calc(var(--u) * 1.45); color: var(--color-muted); line-height: 1.35;
  margin-top: calc(var(--u) * 0.8); }
[data-screen="talk"] .gates { margin-top: calc(var(--u) * 2.5); }
/* the third column is the one nobody runs, so it is the one that gets the accent */
[data-screen="talk"] .moves li:nth-child(3) b, [data-screen="talk"] .gates li:nth-child(3) b { color: var(--color-accent); }


/* --- 20b · loop engineering -------------------------------------------------
   Same six sessions, run two ways. The dots are the whole argument: one column keeps
   collecting things nobody checked, the other keeps closing them. */
[data-screen="talk"] .cycle { margin-top: calc(var(--u) * 3); width: min(100%, calc(var(--u0) * 88)); }
[data-screen="talk"] .cycle__cards { display: grid; grid-template-columns: 1fr 1fr; gap: calc(var(--u) * 4); }
[data-screen="talk"] .cycle__card { border: 1px solid var(--color-line); border-radius: var(--radius-md);
  padding: calc(var(--u) * 2.2); }
[data-screen="talk"] .cycle__t { font-size: calc(var(--u) * 2.1); margin: 0 0 calc(var(--u) * 1.8); }
[data-screen="talk"] .cycle__t span { display: block; color: var(--color-muted); font-size: calc(var(--u) * 1.4);
  letter-spacing: 0.12em; text-transform: uppercase; margin-bottom: calc(var(--u) * 0.7); }
[data-screen="talk"] .cycle__dots { display: flex; flex-wrap: wrap; gap: calc(var(--u) * 1); margin: 0;
  min-height: calc(var(--u) * 5.2); align-content: flex-start; }
[data-screen="talk"] .cycle__dot { width: calc(var(--u) * 1.5); height: calc(var(--u) * 1.5); border-radius: 50%;
  border: 1px solid var(--color-line); background: transparent;
  transition: background 0.25s ease, border-color 0.25s ease, opacity 0.25s ease; }
[data-screen="talk"] .cycle__dot[data-on="open"] { background: var(--ink-faint); border-color: var(--ink-faint); }
[data-screen="talk"] .cycle__dot[data-on="closed"] { background: var(--color-accent); border-color: var(--color-accent); }
[data-screen="talk"] .cycle__leg { font-size: calc(var(--u) * 1.35); color: var(--ink-muted);
  margin: calc(var(--u) * 1.1) 0 0; }
[data-screen="talk"] .cycle__out { font-size: calc(var(--u) * 1.75); color: var(--color-muted);
  margin: calc(var(--u) * 1.8) 0 0; min-height: 4.4em; line-height: 1.4; }
[data-screen="talk"] .cycle__out b { color: var(--color-fg); font-weight: var(--font-weight-regular); }
[data-screen="talk"] .cycle__cost { font-size: calc(var(--u) * 2.4); line-height: 1.3;
  margin: calc(var(--u) * 3) 0 0; min-height: 2.6em; max-width: 54ch;
  color: var(--color-muted); opacity: 0; transition: opacity 0.3s ease; }
[data-screen="talk"] .cycle__cost[data-on] { opacity: 1; color: var(--color-fg); }
[data-screen="talk"] .cycle__run { display: flex; gap: calc(var(--u) * 1.6); margin-top: calc(var(--u) * 2.2); }
[data-screen="talk"] .cycle .btn { font-size: calc(var(--u) * 1.7); padding: calc(var(--u) * 1.1) calc(var(--u) * 2.2); }


/* --- 20c · the correction that never sticks ---------------------------------
   Click "new session" and say the same sentence again. The fix is one line long and the
   demo is the argument, so the tool gets two lines and no more. */
[data-screen="talk"] .repeat { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 0.8fr);
  gap: calc(var(--u) * 5); align-items: start; margin-top: calc(var(--u) * 3); }
[data-screen="talk"] .repeat__log { list-style: none; margin: 0; padding: 0; min-height: calc(var(--u) * 20);
  display: flex; flex-direction: column; gap: calc(var(--u) * 1); }
[data-screen="talk"] .repeat__log li { font-family: var(--font-mono); font-size: calc(var(--u) * 1.6);
  color: var(--color-muted); line-height: 1.35; animation: loop-card 0.25s ease both; }
[data-screen="talk"] .repeat__log li[data-fixed] { color: var(--color-accent); }
[data-screen="talk"] .repeat__log li b { color: var(--color-fg); font-weight: var(--font-weight-regular); }
[data-screen="talk"] .repeat__count { font-size: calc(var(--u) * 2.1); margin: 0 0 calc(var(--u) * 2.4); }
[data-screen="talk"] .repeat__count span { font-family: var(--font-mono); font-size: calc(var(--u) * 4.4);
  display: block; line-height: 1.05; }
[data-screen="talk"] .repeat__btns { display: flex; flex-wrap: wrap; gap: calc(var(--u) * 1.4); margin: 0; }
[data-screen="talk"] .repeat .btn { font-size: calc(var(--u) * 1.7); padding: calc(var(--u) * 1.1) calc(var(--u) * 2.2); }
[data-screen="talk"] .repeat__fix { font-size: calc(var(--u) * 1.75); color: var(--color-muted);
  margin: calc(var(--u) * 2.4) 0 0; min-height: 4.2em; line-height: 1.4; max-width: 34ch; }
[data-screen="talk"] .repeat__fix b { color: var(--color-fg); font-weight: var(--font-weight-regular); }


/* --- 25b · the audit receipts ------------------------------------------------
   Four numbers, read flat. The mono face is doing the work: these are counts out of a repo,
   not a claim about how diligent anybody is. */
[data-screen="talk"] .stats { display: grid; grid-template-columns: repeat(4, 1fr);
  gap: calc(var(--u) * 3.5); margin: calc(var(--u) * 3.5) 0 0; }
[data-screen="talk"] .stats div { border-top: 1px solid var(--color-line); padding-top: calc(var(--u) * 1.4); }
[data-screen="talk"] .stats dt { font-family: var(--font-mono); font-size: calc(var(--u) * 4.6);
  line-height: 1; color: var(--color-fg); }
[data-screen="talk"] .stats dd { margin: calc(var(--u) * 1.2) 0 0; font-size: calc(var(--u) * 1.5);
  color: var(--color-muted); line-height: 1.35; }
/* the three lines under the counts run wider than a normal beat list, so the slide does not sit
   in its own left-hand column with half the wall empty */
[data-screen="talk"] .audit__beats { display: grid; grid-template-columns: repeat(3, 1fr);
  gap: calc(var(--u) * 4); margin-top: calc(var(--u) * 4.5); }
[data-screen="talk"] .audit__beats li { max-width: none; }


/* --- 28 · where to start ----------------------------------------------------
   A single ordered column rather than a grid: the order is the content, and a grid invites the
   room to read number three first, which is exactly the mistake the slide is about. */
[data-screen="talk"] .start { list-style: none; counter-reset: st; margin: calc(var(--u) * 3) 0 0;
  padding: 0; display: flex; flex-direction: column; gap: calc(var(--u) * 1.6); }
[data-screen="talk"] .start li { counter-increment: st; display: grid;
  grid-template-columns: calc(var(--u) * 5) minmax(0, 26ch) minmax(0, 1fr);
  gap: calc(var(--u) * 2); align-items: baseline;
  border-top: 1px solid var(--color-line); padding-top: calc(var(--u) * 1.3); }
[data-screen="talk"] .start li::before { content: "0" counter(st); font-family: var(--font-mono);
  font-size: calc(var(--u) * 1.5); color: var(--ink-muted); }
[data-screen="talk"] .start b { font-size: calc(var(--u) * 2.05); font-weight: var(--font-weight-regular);
  line-height: 1.2; }
[data-screen="talk"] .start span { font-size: calc(var(--u) * 1.6); color: var(--color-muted); line-height: 1.35; }


/* --- 29 · orchestration ----------------------------------------------------- */
[data-screen="talk"] .orch { display: grid; grid-template-columns: 1fr 1fr; gap: calc(var(--u) * 4);
  margin-top: calc(var(--u) * 3); }
[data-screen="talk"] .orch__card { border: 1px solid var(--color-line); border-radius: var(--radius-md);
  padding: calc(var(--u) * 2.4); }
[data-screen="talk"] .orch__card[data-orch-good] { border-color: var(--color-accent); }
[data-screen="talk"] .orch__t { font-size: calc(var(--u) * 2.2); margin: 0 0 calc(var(--u) * 2); }
[data-screen="talk"] .orch__t span { display: block; color: var(--color-muted); font-size: calc(var(--u) * 1.4);
  letter-spacing: 0.12em; text-transform: uppercase; margin-bottom: calc(var(--u) * 0.7); }
[data-screen="talk"] .orch ul { list-style: none; margin: 0; padding: 0;
  display: flex; flex-direction: column; gap: calc(var(--u) * 1.3); }
[data-screen="talk"] .orch li { font-size: calc(var(--u) * 1.7); line-height: 1.35; color: var(--color-muted);
  padding-left: calc(var(--u) * 2); position: relative; }
[data-screen="talk"] .orch li::before { content: "\2014"; position: absolute; left: 0; color: var(--ink-faint); }
[data-screen="talk"] .orch__card[data-orch-good] li { color: var(--color-fg); }


/* --- 31 · sources ------------------------------------------------------------ */
[data-screen="talk"] .srcs { list-style: none; margin: calc(var(--u) * 2.5) 0 0; padding: 0;
  display: grid; grid-template-columns: 1fr 1fr; gap: calc(var(--u) * 2) calc(var(--u) * 4); }
[data-screen="talk"] .srcs li { border-top: 1px solid var(--color-line); padding-top: calc(var(--u) * 1.1); }
[data-screen="talk"] .srcs b { font-size: calc(var(--u) * 1.85); font-weight: var(--font-weight-regular); }
[data-screen="talk"] .srcs i { font-style: normal; font-size: calc(var(--u) * 1.45); color: var(--color-muted); }
[data-screen="talk"] .srcs span { display: block; font-size: calc(var(--u) * 1.35); color: var(--color-muted);
  line-height: 1.35; margin: calc(var(--u) * 0.5) 0; }
[data-screen="talk"] .srcs code { font-family: var(--font-mono); font-size: calc(var(--u) * 1.3);
  color: var(--color-accent); }


/* --- 24a · Monday ----------------------------------------------------------
   The same hour, spent two ways. Nothing here is about my repo. */
[data-screen="talk"] .monday { margin-top: calc(var(--u) * 3); width: min(100%, calc(var(--u0) * 90)); }
[data-screen="talk"] .monday__switch { display: flex; gap: calc(var(--u) * 1.6); margin: 0 0 calc(var(--u) * 3); }
[data-screen="talk"] .monday__switch .btn { font-size: calc(var(--u) * 1.8); padding: calc(var(--u) * 1.2) calc(var(--u) * 2.4); }
[data-screen="talk"] .monday__switch .btn[aria-pressed="false"] { color: var(--color-muted);
  border-color: var(--color-line); background: transparent; }
[data-screen="talk"] .monday__switch .btn[aria-pressed="true"] { color: var(--color-fg);
  border-color: var(--color-fg); background: var(--paper-2); }
[data-screen="talk"] .monday__grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: calc(var(--u) * 3.5); margin: 0; }
[data-screen="talk"] .monday__grid dt { font-size: calc(var(--u) * 1.4); color: var(--color-muted);
  letter-spacing: 0.12em; text-transform: uppercase; }
[data-screen="talk"] .monday__grid dd { margin: calc(var(--u) * 1.2) 0 0; font-size: calc(var(--u) * 1.95);
  line-height: 1.35; min-height: 5em; }
[data-screen="talk"] .monday__grid dd[data-mono] { font-family: var(--font-mono); font-size: calc(var(--u) * 1.7); }
[data-screen="talk"] .monday__rule { font-size: calc(var(--u) * 2.6); margin-top: calc(var(--u) * 3.5); max-width: 44ch; }
[data-screen="talk"] .monday__rec { font-size: calc(var(--u) * 2); line-height: 1.4; max-width: 62ch;
  margin: calc(var(--u) * 3) 0 0; padding-top: calc(var(--u) * 2);
  border-top: 1px solid var(--color-line); color: var(--color-muted); }
[data-screen="talk"] .monday__rec b { color: var(--color-fg); font-weight: var(--font-weight-regular); }
