/* /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-faint); }

/* ---- 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.
   Accent is three CSS vars scoped to .btn; a status attribute repoints them, so
   every variant inherits the new color for free.
   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 {
  /* accent token set — [data-status] overrides these */
  --btn-accent: var(--color-primary);
  --btn-accent-hover: var(--color-primary-hover);
  --btn-accent-contrast: var(--color-primary-contrast);
  --btn-accent-soft: var(--color-primary-soft);

  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;

  /* 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): repoint the accent token set ---- */
.btn[data-status="success"] {
  --btn-accent: var(--color-success); --btn-accent-hover: var(--color-success-hover);
  --btn-accent-contrast: var(--color-success-contrast); --btn-accent-soft: var(--color-success-soft);
}
.btn[data-status="danger"] {
  --btn-accent: var(--color-danger); --btn-accent-hover: var(--color-danger-hover);
  --btn-accent-contrast: var(--color-danger-contrast); --btn-accent-soft: var(--color-danger-soft);
}

/* ---- 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); }

.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-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-faint); }
/* 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; }

/* ---- 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/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/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-faint);
}
/* 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/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);
}

/* 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/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/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); }

/* /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-faint); }

/* ---- 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-faint); }
.stat[data-tone="muted"] .stat__label { color: var(--ink-faint); }

/* ---- 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-faint); }

/* ---- 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.15rem; height: 1.15rem; 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-faint); }
/* 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.15rem; height: 1.15rem; border-radius: var(--radius-sm);
  color: var(--ink-faint); pointer-events: none;
}

/* /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/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);
    grid-template-areas: "window" "topbar" "main" "console" "status";
    grid-template-rows: auto auto minmax(0, 1fr) auto auto;
  }
  .app-shell__aside { display: none; }
  .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; }

/* /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(0.5rem, 2vh, 1.5rem);
  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-faint); }
.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-faint); }
.console__cmd:focus { outline: none; }
.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-faint); }
/* 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/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-faint); 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-faint); }
/* 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.15rem; height: 1.15rem; 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-faint);
}
.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; }

/* /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-faint); 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-faint); 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; }
.notepad__entry[data-grade="grain"] { color: var(--color-grain, 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; }
.notepad__source::placeholder { color: var(--ink-faint); }

@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/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; }

/* ---- 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; }

/* /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-faint); }

/* 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; }
}

/* /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-faint); 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); }

/* /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-faint); }
: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-faint); }
: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); }

/* /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-faint); }
[data-screen="calendar"] .feed-card__loc { color: var(--ink-faint); }
[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 accent on the meta line's kind label — mirrors the Month/Week chip colours (calendar.css). */
[data-screen="calendar"] .feed-card[data-event-kind="hackathon"] .feed-card__kind { color: var(--color-accent); }
[data-screen="calendar"] .feed-card[data-event-kind="talk"] .feed-card__kind { color: var(--color-accent); }
[data-screen="calendar"] .feed-card[data-event-kind="student-highlight"] .feed-card__kind { color: var(--color-accent); }

/* /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; }
[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/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-faint); }

/* /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-faint);
  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-faint); 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-faint); 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; }

/* ---- 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; }

/* 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/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-faint); }
.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/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/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; gap: 0.6rem; font-weight: var(--font-weight-medium); }
.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); }
.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; }
.recent__item { display: flex; gap: 1rem; align-items: baseline; width: fit-content;
  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 { color: var(--ink-faint); font-size: var(--text-sm); font-family: var(--font-mono); }

/* /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;
  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: var(--color-bg); }
.app-dock__item[aria-current="page"]::before {
  content: ""; position: absolute; top: 0; bottom: 0; left: 0;
  width: 2px; background: var(--color-accent);
}
/* 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; }

/* 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__crumb { font-family: var(--font-mono); 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 { font-family: var(--font-mono); color: inherit; text-decoration: none; }
.status-bar__byline:hover, .status-bar__contact:hover { color: var(--color-fg); }


/* 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;
}

/* /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(2rem, 8vh, 6rem) var(--page-pad) 3rem; }
.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 3rem; }

.welcome__cols { display: grid; grid-template-columns: 1.1fr 1fr; gap: clamp(2rem, 6vw, 6rem); }
/* 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: 1.6rem; margin-top: 4.5rem; }
.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); }

/* Profile role sections (Manager / Tech lead / Educator): what each role taught me, stacked inside
   the Profile panel (no nested tabs — the page already has its main tabs). Each is a section-head
   anchor (#role-<role>, the quotes-hero deep-link target), a short intro paragraph, a bullet list,
   and a link to that work's tagged notes. */
[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__notes-link { margin: 0 0 var(--space-2); font-size: var(--text-xs); font-family: var(--font-mono); }
[data-screen="about"] .role__notes-link a { color: var(--color-accent); text-decoration: none; }
[data-screen="about"] .role__notes-link a:hover { text-decoration: underline; }
/* 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-faint);
  margin-right: var(--space-2); }

/* Profile quotes-hero: a first-person manifesto that leads the Profile panel. First three lines deep-link
   to their Lessons role (the island's hashchange routing opens it). All five 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__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; }
  [data-screen="about"] .quote:nth-child(4) { animation-delay: 0.24s; }
  [data-screen="about"] .quote:nth-child(5) { animation-delay: 0.32s; }
}
@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); }

/* /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 */

[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; }
[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: 5.2rem; 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 month/week chips: notes (base accent-soft), shipped posts (bordered), and events
   (hackathon / talk / student-highlight) as a filled accent so the coached/taught days stand out. */
[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--student-highlight {
  background: var(--color-accent); color: var(--color-accent-contrast); }
[data-screen="calendar"] .cal__event:hover { outline: 1px solid var(--color-accent); }
[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); }

@media (max-width: 640px) {
  [data-screen="calendar"] .cal__cell { min-height: 3.6rem; }
  [data-screen="calendar"] .cal__event { padding: 1px var(--space-1); }
}

/* /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; }

[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-faint); 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-faint);
  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-faint); }
[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-faint); 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/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"] .cv-certs { list-style: none; margin: var(--space-2) 0 var(--space-5); padding: 0;
  display: flex; flex-direction: column; gap: var(--space-1); }

/* 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 {
  body[data-screen="resume"].app-window-backdrop { background: #fff !important; }
  body[data-screen="resume"] .app-shell > *:not(.app-shell__main) { display: none !important; }
  body[data-screen="resume"] .app-shell { display: block !important; height: auto !important; }
  body[data-screen="resume"] .app-shell__main { display: block !important; overflow: visible !important; height: auto !important; }
  body[data-screen="resume"] .profile-card__actions,
  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; }
  /* 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; }
  /* single column, no card chrome — the plain look ATS parsers prefer */
  body[data-screen="resume"] .cv-skills { grid-template-columns: 1fr !important; }
  body[data-screen="resume"] .profile-card,
  body[data-screen="resume"] .cv-entry,
  body[data-screen="resume"] .cv-skill { background: #fff !important; border: 0 !important; padding-left: 0 !important; padding-right: 0 !important; }
  /* the primary-skill chips flatten to a plain comma list (no pills), so the print/ATS sheet stays text */
  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; text-decoration: underline; }
}
