  /* ---------- typefaces ---------- */
  @font-face{font-family:'ABC Marist';src:url('../assets/fonts/ABCMarist-Regular-Trial.otf') format('opentype');font-weight:400;font-style:normal;font-display:swap;}
  @font-face{font-family:'ABC Marist';src:url('../assets/fonts/ABCMarist-Book-Trial.otf') format('opentype');font-weight:300;font-style:normal;font-display:swap;}
  @font-face{font-family:'ABC Marist';src:url('../assets/fonts/ABCMarist-BookItalic-Trial.otf') format('opentype');font-weight:300;font-style:italic;font-display:swap;}
  @font-face{font-family:'Geist';src:url('../assets/fonts/Geist-VariableFont_wght.ttf') format('truetype');font-weight:100 900;font-style:normal;font-display:swap;}
  @font-face{font-family:'Geist';src:url('../assets/fonts/Geist-Italic-VariableFont_wght.ttf') format('truetype');font-weight:100 900;font-style:italic;font-display:swap;}
  @font-face{font-family:'Geist Mono';src:url('../assets/fonts/GeistMono-VariableFont_wght.ttf') format('truetype');font-weight:100 900;font-style:normal;font-display:swap;}
  @font-face{font-family:'Geist Mono';src:url('../assets/fonts/GeistMono-Italic-VariableFont_wght.ttf') format('truetype');font-weight:100 900;font-style:italic;font-display:swap;}

  :root{
    --bg:#fbfbfb;
    --panel:#ffffff;
    --ink:#161614;
    --ink-soft:#6b6b64;
    --line-soft:#dedcd2;
    --accent:#33409e;
    --mono: ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace;
    --serif: Georgia, 'Times New Roman', serif;
    --sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;

    /* ---------- Figma design system ---------- */
    --n900:#000000;
    --n600:#7e7e7e;
    --n500:#b7b7b7;
    --n400:#cdcdcd;
    --n300:#f2f2f2;
    --n200:#fbfbfb;
    --n100:#ffffff;

    --font-title:'ABC Marist', Georgia, 'Times New Roman', serif;
    --font-body:'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
    --font-mono:'Geist Mono', ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace;

    /* Font Size primitives, read from the Figma variable library.
       XXS/XS/S came down 2px in an earlier update; Font Size/L was updated
       in Figma from 32 to 24 (confirmed on the project widget's Item Title
       layer, 64:260). M is unchanged.
       Expressed in rem off a 16px root so they scale with user text size. */
    --fs-xxs:0.625rem;  /* 10px */
    --fs-xs:0.75rem;    /* 12px */
    --fs-s:0.875rem;    /* 14px */
    --fs-m:1rem;        /* 16px */
    --fs-l:1.5rem;      /* 24px */

    /* Social buttons and the layout toggle render at this multiple of their
       Figma component size (ToggleOption 26px, Toggle 107.6x43.6). */
    --ui-scale:1.2;
    --n-bullet:#d9d9d9;
    --accent-case:#4387f6;
    --accent-leaf:#a3a800;

    --gap-xs:6px;
    --gap-s:8px;
    --gap-m:12px;
    --gap-l:18px;

    /* iOS Safari's bottom toolbar/home-indicator area -- 0px on every
       browser that doesn't report it (desktop, Android, older Safari),
       real on iPhones with viewport-fit:cover (see app/layout.tsx's
       viewport export). Only consumed by the mobile block below, so it
       has zero effect above the 767px breakpoint. */
    --safe-bottom: env(safe-area-inset-bottom, 0px);
    /* Same idea, top edge -- the status bar/notch/Dynamic Island area.
       Needed because viewport-fit:cover (added for --safe-bottom above)
       makes the page render full-bleed on iOS; without this the top
       toggle would sit exactly at its old fixed 14px, which used to be
       safely below the status bar only because iOS was auto-insetting
       the page away from it. */
    --safe-top: env(safe-area-inset-top, 0px);

    /* The real on-screen rhythm between two Text/Column sections (e.g.
       Design -> Lab): #layout-overlay-inner's own flex gap (--gap-l) on
       either side of the .ov-divider spacer between them, plus the
       divider's own height (8px, its literal CSS value below -- not a
       token itself, just mirrored here). Exists so anything that needs to
       match "the same spacing as between sections" -- like the mobile
       catalogue pill's bottom clearance below -- reuses this exact
       formula instead of a new guessed number. */
    --ov-section-gap:calc(var(--gap-l) + 8px + var(--gap-l));

    --pad-h-xs:4px;
    --pad-v-xs:4px;
    --pad-h-s:18px;
    --pad-v-s:10px;
    --pad-v-m:16px;

    --curve-xs:4px;
    --curve-s:8px;
    --curve-m:10px;

    --outline-s:0.4px;
    --outline-m:0.8px;

    /* Chrome snaps any border-width under 1px up to a full pixel, so a 0.4px
       border paints exactly as heavy as a 1px one. Painting the stroke as an
       inset shadow instead keeps it a true sub-pixel hairline, and costs no
       layout space -- where Figma's stated size includes the stroke, the
       padding is bumped by the same amount to keep the box model identical. */
    --hair-s:inset 0 0 0 var(--outline-s) var(--n400);
    --hair-m:inset 0 0 0 var(--outline-m) var(--n400);
    --shadow-badge:0 1px 1.45px rgba(0,0,0,.15);
    --shadow-badge-hover:inset 0 1px 2.9px rgba(0,0,0,.1);
    --shadow-badge-pressed:inset 0 -1px 2.9px rgba(255,255,255,.15), inset 0 1px 2.9px rgba(0,0,0,.15);
    --shadow-tag:0 1px 0.15px rgba(0,0,0,.05), inset 0 1px 1px rgba(255,255,255,.5);
    /* Image/video cards (the graph's zoom cards, the PA artifact cards):
       davidtheophine.com's --shadow-card, light theme -- three soft layers.
       Its first layer, a 0.5px ring, is left out on purpose: the cards keep
       their own n400 hairline as their outline. The graph's zoom cards are
       SVG, so they use these same layers as drop-shadow() filters
       (.zoom-card-open) instead of this box-shadow. */
    --shadow-card:0 1px 1px rgba(0,0,0,.05), 0 2px 3px rgba(0,0,0,.05), 0 5px 8px rgba(0,0,0,.04);
  }

  /* ---------- dashed rules ----------
     CSS `border-style:dashed` picks its own dash length, so the design's
     4px dash / 4px gap is painted as a background gradient instead. */
  .rule-b, .rule-t{background-repeat:no-repeat;
    background-image:repeating-linear-gradient(to right, var(--n400) 0 4px, transparent 4px 8px);}
  .rule-b{background-size:100% var(--outline-m);background-position:left bottom;}
  .rule-t{background-size:100% var(--outline-m);background-position:left top;}

  /* ---------- icon primitives ----------
     The per-icon .icon-* mask rules live in css/icons.css, which is generated
     from assets/icons/*.svg by gen-icons.mjs. See that file's header for why
     the SVGs are embedded rather than referenced by path. */
  .icon{display:block;width:var(--icon-size,18px);height:var(--icon-size,18px);flex-shrink:0;background-color:currentColor;
    -webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;
    -webkit-mask-position:center;mask-position:center;
    -webkit-mask-size:var(--icon-size,18px) var(--icon-size,18px);mask-size:var(--icon-size,18px) var(--icon-size,18px);}

  /* ---------- badge icon button ---------- */
  .badge-icon{position:relative;display:flex;align-items:center;justify-content:center;flex-shrink:0;border:none;
    padding:calc(var(--pad-v-xs) + var(--outline-s)) calc(var(--pad-h-xs) + var(--outline-s));
    background:var(--n100);
    border-radius:var(--curve-xs);
    box-shadow:var(--shadow-badge), var(--hair-s);
    color:var(--n900);cursor:pointer;line-height:0;}
  .badge-icon:hover{box-shadow:var(--shadow-badge), var(--shadow-badge-hover), var(--hair-s);}
  .badge-icon:active{box-shadow:var(--shadow-badge-pressed), var(--hair-s);}
  /* tooltip reuses the same Tags/Hover Tip chip as the layout switcher
     (66:3881). This USED to be a ::after pseudo-element on the button,
     centered with left:50%/translateX like .contact-btn and .ls-btn's
     tooltips below -- but that pseudo-element lives inside .sidebar, and
     .sidebar sets overflow-y:auto with no overflow-x. Per the CSS overflow
     spec, a used value of 'visible' on one axis is forced to 'auto' when
     the other axis isn't visible -- so .sidebar was ALSO clipping
     horizontally the whole time, no matter how the ::after was positioned
     or nudged (confirmed: explicitly setting overflow-x:visible on
     .sidebar doesn't override this -- the spec coercion applies to the
     USED value regardless of the authored one).

     First fix pulled the tooltip out of .sidebar into #panel instead, with
     JS clamping it to .panel's own rect -- but .panel ALSO sets
     overflow:hidden, and the About button sits close enough to .panel's
     right edge that the clamped tip kept landing right at that edge, i.e.
     still effectively cut off. Now it's out of #panel entirely (a
     position:fixed body-level element, sibling of #trail-layer/
     .hover-preview in index.html, viewport-positioned like those), and
     deliberately hangs to the LEFT of the button/cursor rather than
     centering under it -- so its resting position is already clear of
     .panel's edge instead of needing a last-second clamp against it. */
  .about-tip{position:fixed;background:var(--n200);color:var(--n600);border-radius:var(--curve-xs);
    box-shadow:var(--shadow-tag), var(--hair-s);
    font-family:var(--font-mono);font-weight:400;font-size:var(--fs-xs);line-height:normal;letter-spacing:0;text-transform:uppercase;
    padding:var(--pad-v-xs) var(--pad-h-xs);white-space:nowrap;opacity:0;pointer-events:none;
    transform:translate(-100%, -50%);
    /* z-index:150, not 40 -- on mobile, .app.panel-expanded .panel jumps to
       z-index:100 (below) so the expanded catalogue widget can beat
       .layout-switcher. Since #about-tip is positioned relative to the
       Learn More button, which lives INSIDE that same panel, a lower
       z-index here let the panel's own opaque background paint over the
       tooltip the instant the panel is expanded -- it was actually
       showing (opacity:1) the whole time, just hidden behind the panel.
       150 clears every other on-screen z-index in this file with room to
       spare. */
    transition:opacity .18s ease, transform .22s cubic-bezier(.4,0,.2,1);z-index:150;}
  .about-tip.show{opacity:1;}
  *{box-sizing:border-box;}
  html,body{margin:0;height:100%;overflow:hidden;background:var(--bg);color:var(--ink);font-family:var(--sans);
    -webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;
    font-synthesis-weight:none;font-synthesis-style:none;}
  .app{position:relative;height:100vh;overflow:hidden;}
  /* iOS/Android mobile browsers report 100vh as the viewport size with
     the browser chrome (address bar, etc.) fully collapsed, which is
     taller than what's actually visible on first paint -- so .app (and
     everything positioned relative to it: the top toggle, the panel)
     rendered taller than the real visible viewport, pushing content
     below the fold and forcing a page-level drag/scroll to reach it.
     100dvh tracks the browser's actual current visible viewport instead
     and is layered on as a progressive enhancement -- the 100vh above
     stays as the fallback for browsers that don't support dvh. */
  .app{height:100dvh;}
  .graph-wrap{position:absolute;inset:0;overflow:hidden;transition:filter .3s ease, opacity .3s ease;}
  .app.case-open .graph-wrap{filter:blur(7px);opacity:.45;pointer-events:none;}
  /* Keeps the Shift+B roll genuinely running the whole time a widget is
     open (per request: it should keep rotating/moving, not pause and
     restart) while making it read exactly like the rest of the graph --
     blurred/dimmed to the same 45% as .graph-wrap right above, and
     naturally covered by the opaque widget wherever the two overlap,
     staying visible (blurred, like everything else around the widget's
     edges) anywhere the widget doesn't reach.
     Two things this has to work around, both specific to Safari's
     cube-overlay (the real-HTML stand-in for the cube-roll, see graph.js's
     IS_SAFARI note -- Chrome's <foreignObject> cube lives inside
     .graph-wrap itself and already inherits its blur/z-order for free,
     nothing below is needed there):
     1) .cube-overlay is appended straight to <body>, a sibling of #app,
        not a descendant -- so a plain ".app.case-open .cube-overlay"
        selector can never match it. Rewritten with :has() (already used
        elsewhere in this file) so it can reach a body-level sibling of
        #app from #app's own state.
     2) .cube-overlay's resting z-index is 500 -- deliberately far above
        the plain graph canvas so it floats over the flat node square it
        replaces during a roll -- but that also puts it above every widget
        (.panel:10, .case-panel:20/100, .layout-switcher:30). Dropped to 1
        here specifically while a widget is open, which is still above the
        unelevated (z-index:auto) graph canvas underneath it but below
        every widget, so an opaque widget occludes it exactly like it
        occludes the rest of the graph, rather than floating in front of
        one. */
  body:has(.app.case-open) .cube-overlay{filter:blur(7px);opacity:.45;z-index:1;}
  /* overflow:visible -- zoomed in (zoom-bar.js), the spread graph runs past
     the canvas edges and is panned into view; .graph-wrap still clips. */
  svg#graph{width:100%;height:100%;display:block;touch-action:none;overflow:visible;}
  /* Top and bottom edge scrims -- an exact port of davidtheophine.com's
     bottom-navbar scrim (.scrim / .scrim__blur): a progressive blur from
     four stacked backdrop-filter layers (1/3/6/12px), each masked to start
     further toward the edge (25-55%, 50-78%, 75-100%), under a faint wash
     of the page colour (22% at the edge, gone by 80%). Height trimmed from
     their ~103px (safe area + --space-m + 52px island + 24px) to 64px. The top
     strip is the same scrim mirrored (scaleY(-1)), so both fade toward the
     canvas identically. Fallbacks as theirs: no backdrop-filter support ->
     a solid gradient; prefers-reduced-transparency -> no blur. */
  .edge-fade{position:absolute;left:0;right:0;pointer-events:none;z-index:6;
    height:calc(env(safe-area-inset-bottom, 0px) + 64px);
    background:linear-gradient(to top, color-mix(in oklab, var(--bg), transparent 78%) 0%, transparent 80%);}
  .edge-fade-bottom{bottom:0;}
  .edge-fade-top{top:0;transform:scaleY(-1);
    height:calc(env(safe-area-inset-top, 0px) + 64px);}
  .edge-fade-blur{position:absolute;inset:0;}
  /* every layer fades in from 0 at the strip's inner edge -- an unmasked layer
     starts blurring at full strength there and draws a visible seam */
  .edge-fade-blur.b1{backdrop-filter:blur(1px);-webkit-backdrop-filter:blur(1px);
    -webkit-mask-image:linear-gradient(#0000 0%, #000 25% 100%);mask-image:linear-gradient(#0000 0%, #000 25% 100%);}
  .edge-fade-blur.b2{backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);
    -webkit-mask-image:linear-gradient(#0000 25%, #000 55% 100%);mask-image:linear-gradient(#0000 25%, #000 55% 100%);}
  .edge-fade-blur.b3{backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);
    -webkit-mask-image:linear-gradient(#0000 50%, #000 78% 100%);mask-image:linear-gradient(#0000 50%, #000 78% 100%);}
  .edge-fade-blur.b4{backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);
    -webkit-mask-image:linear-gradient(#0000 75%, #000 100%);mask-image:linear-gradient(#0000 75%, #000 100%);}
  @supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){
    .edge-fade{background:linear-gradient(to top, var(--bg) 0%, color-mix(in oklab, var(--bg), transparent 65%) 45%, transparent 100%);}
  }
  @media (prefers-reduced-transparency: reduce){
    .edge-fade-blur{backdrop-filter:none;-webkit-backdrop-filter:none;}
    .edge-fade{background:linear-gradient(to top, var(--bg) 0%, color-mix(in oklab, var(--bg), transparent 65%) 45%, transparent 100%);}
  }
  /* stroke-width bumped from 1 to 1.3 (a slight increase, not matching the
     hub's own 1 -- the hub and project edges were already the same width;
     this makes the dashed project edges themselves a bit heavier, per
     request). Applies to every dashed edge (.arc and .arc.cross both share
     this base rule) -- .arc-hub overrides its own stroke separately below
     and is unaffected. */
  .arc{fill:none;stroke:var(--n500);stroke-width:1.3;opacity:.9;stroke-dasharray:4,4;animation:flow 1.4s linear infinite;transition:opacity .15s;}
  /* Hub -> category edges (28:9): neutral/600, heavier than the
     category -> project edges beneath them, which keep neutral/500. Also a
     plain solid stroke with no marching-dash animation -- this used to be a
     Shift+B toggle state (a plain-dashed default, with these three edges to
     Design/Lab/Words switching to solid on demand); it's now the only look,
     the toggle itself removed. The edges to the smaller project nodes
     (category -> project, plain .arc above, not .arc-hub) keep the
     dashed/animated look, unchanged. */
  .arc.arc-hub{stroke:var(--n600);stroke-width:1;stroke-dasharray:none;animation:none;}
  .arc.cross{stroke:var(--n400);stroke-dasharray:4,4;opacity:.5;animation-duration:2s;}
  .arc.dim{opacity:.06;}
  /* Dotted edges (dot-line.js): a <g> of circles rather than a stroked
     path, so the inherited stroke/dash/flow above must not reach the
     circles. `color` is the resting dot ink, --dot-emph the ink a hovered
     edge's dots ripple to; opacity/.dim/entrance fade still come from the
     .arc rules above. */
  .arc.arc-dots{stroke:none;animation:none;color:var(--n500);--dot-emph:var(--n900);}
  /* Full opacity (tuned 2026-10-24); :not(.dim)/:not(.cross) so it never
     overrides the hover dim (.arc.dim) or the cross-links' lighter .5. */
  .arc.arc-dots:not(.dim):not(.cross){opacity:1;}
  .arc.arc-dots.cross{color:var(--n400);}
  @keyframes flow{to{stroke-dashoffset:-18;}}
  @keyframes appear-up{from{opacity:0;transform:translateY(9px);filter:blur(7px);}to{opacity:1;transform:translateY(0);filter:blur(0);}}
  @keyframes appear-fade{from{opacity:0;filter:blur(7px);}to{opacity:1;filter:blur(0);}}
  /* will-change hints the browser to promote the element onto its own
     compositor layer for the properties this animation actually touches,
     before the animation starts rather than reacting mid-animation --
     filter/blur is the expensive one here (it forces a repaint of the
     element's own content every frame instead of a cheap compositor-only
     transform), and that cost multiplies when several staggered rows are
     mid-blur at once, which is what made opening a section feel laggy.
     The hint is scoped to the class itself, so it's only in effect while
     .appear/.appear-fade is applied and disappears the moment the
     existing animationend cleanup strips the class -- no separate cleanup
     needed, and the animation's own timing/look is unchanged. */
  .appear{animation:appear-up 1.05s cubic-bezier(.4,0,.2,1) both;will-change:opacity, transform, filter;}
  .appear-fade{animation:appear-fade 1.05s cubic-bezier(.4,0,.2,1) both;will-change:opacity, filter;}
  /* Root cause of the "Safari skips the blur" bug (previously worked
     around by deleting the blur for Safari entirely -- replaced here with
     an actual fix): .layout-overlay, .sidebar and .case-body are all
     overflow-y:auto scroll containers, which WebKit gives their own
     compositing/scrolling layer. Their .appear/.appear-fade children ALSO
     get promoted to a layer the instant will-change:filter is present
     (that's the whole point of will-change -- it promotes pre-emptively
     instead of reacting mid-animation). WebKit's overlap/clip test between
     a child layer and its *scrolling-container* layer has a long-standing
     bug where the child's clip rect is computed once, against the
     scroller's un-scrolled bounds, and never revisited -- which is exactly
     "permanently top-clipped, not just mid-animation" and exactly why
     plain filter TRANSITIONS elsewhere in this file (.graph-wrap, .node --
     both inside overflow:hidden, non-scrolling ancestors, so this
     interaction never triggers) are unaffected. Chrome/Firefox tolerate
     the same layer combination fine; Safari specifically mishandles it.
     Two-part fix, no blur removed for anyone:
     1) give the scroller itself a stable, real compositing layer up front
        (see -webkit-transform:translateZ(0) on .sidebar/.layout-overlay/
        .case-body below) so its clip geometry is settled before any child
        animates, rather than being (re)computed on the fly;
     2) Safari only, drop will-change:filter specifically (keep
        opacity/transform) so the child stops pre-emptively promoting a
        second, separately-clipped layer nested inside that scroller --
        the animation itself (same @keyframes, same blur, same timing)
        is completely unchanged for every browser. This is a best-effort
        fix based on WebKit's documented compositing/overlap behavior;
        it hasn't been confirmed live against real Safari from this
        environment (no interactive Safari access here) the way the rest
        of this file's Safari fixes were -- verify on-device, and if any
        clipping survives, the previous blur-free fallback is one git
        revert away (see history on this block). */
  @supports (-webkit-hyphens:none){
    .appear{will-change:opacity, transform;}
    .appear-fade{will-change:opacity;}
  }
  @keyframes panel-appear{from{opacity:0;filter:blur(10px);top:60%;}to{opacity:1;filter:blur(0);top:50%;}}
  .panel-appear{animation:panel-appear .9s cubic-bezier(.4,0,.2,1) both;}
  .trail-layer{position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:200;}
  /* fill is per-dot (canvas-interactions.js sets it inline, cycling through
     the project colours) -- var(--n500) here is only a fallback if that's
     ever missing, and opacity/transition/etc. stay the same for every dot. */
  .trail-dot{fill:var(--n500);opacity:.3;pointer-events:none;transform-box:fill-box;transform-origin:center;transition:transform 2.4s ease, opacity 2.4s ease;}
  .node{cursor:grab;transition:opacity .35s ease, filter .35s ease;}
  .node.dragging{cursor:grabbing;}
  .node.faded{opacity:.22;filter:blur(2px);}
  /* Graph parity with the Text/Column live-hover dim (.sec-list/.ov-section
     rules below): hovering a project node holds it at 100% and dims every
     OTHER project node to 60%, undimming together on mouseleave -- same
     :has()-driven, no-JS pattern as those rules, scoped to #graph instead
     of a section. Gated off while a node is already selected/open
     (:not(:has(.node.selected)), the graph's equivalent of :not(.is-frozen)
     over there) so this and the existing .faded selection-fade above never
     fight over the same node's opacity. Every node type takes part --
     hovering a project, Design/Lab/Words or Quang Mai dims all the others,
     hub and categories included (they used to be left out, which made
     them look permanently highlighted next to the dimmed projects). */
  #graph:not(:has(.node.selected)):has(.node:hover) .node:not(:hover):not(.hover-keep){opacity:.6;}
  /* Zoomed in (the globe, body.graph-zoomed), the same hover dim goes
     deeper, since 60% barely registers against the big cards: everything
     except the hovered node fades to 25%. Same gate and .35s fade. */
  body.graph-zoomed #graph:not(:has(.node.selected)):has(.node:hover) .node:not(:hover):not(.hover-keep){opacity:.25;}
  /* Hovering Quang Mai or a category keeps the nodes directly connected to
     it (.hover-keep, set by graph.js) at full opacity -- its categories, or
     its projects (never Quang Mai: it dims unless it's the one hovered) --
     while everything else dims as above. */
  #graph:not(:has(.node.selected)):has(.node:hover) .node.hover-keep{opacity:1;}   /* full even on the globe's far side, over its depth fade */
  .node .dot{transform-box:fill-box;transform-origin:center;transition:transform .5s cubic-bezier(.34,1.56,.64,1);}
  .node.selected .dot{transform:scale(1.6);}
  .node:hover .dot{transform:scale(1.3);}
  .pulse-ring{fill:none;stroke:var(--accent-leaf);stroke-width:1;opacity:0;transform:scale(.6);transform-box:fill-box;transform-origin:center;transition:transform .55s cubic-bezier(.34,1.56,.64,1), opacity .4s ease;pointer-events:none;}
  .node.selected .pulse-ring{opacity:.4;transform:scale(3);}
  /* Default clicked state, sub-project nodes only, per Figma node 121:2251:
     a thinner dashed ring that hugs the node closely, in the project's own
     color (set inline from n.color at ring creation in graph.js) rather
     than the universal --accent-leaf hub/category rings still use.
     vector-effect:non-scaling-stroke keeps the 2px stroke and 4px/4px dash
     pattern exact regardless of the transform:scale() used for the pop-in
     animation below -- without it the stroke/dasharray would get scaled up
     right along with the rect's own size. The scale(1.85) target (vs the
     old scale(3)) was derived from the Figma reference's own ring-to-dot
     padding ratio (~0.89x the dot's size) applied to this node's selected
     dot size (15px base * the existing 1.6x .selected scale = 24px),
     giving a ~66px ring that hugs the dot the way the reference does. */
  .node-project .pulse-ring{stroke-width:2;stroke-dasharray:4,4;vector-effect:non-scaling-stroke;}
  .node-project.selected .pulse-ring{transform:scale(1.85);}
  /* The selected project's outline is now drawn as marching dots in its
     colour (.sel-dots, graph.js updateSelDots) -- the dashed strokes of the
     square's ring and the zoomed card's ring are hidden. The dots take
     over the ring's pop-in spring and fade. */
  .node-project .pulse-ring, .node-project .zoom-ring{visibility:hidden;}
  .sel-dots{opacity:0;transform:scale(.6);transform-box:fill-box;transform-origin:center;pointer-events:none;
    transition:transform .55s cubic-bezier(.34,1.56,.64,1), opacity .4s ease;}
  .node-project.selected .sel-dots{opacity:1;transform:scale(1);}

  /* The CLICKED sub-project node's own ring stays visible and its cube
     auto-rolls, permanently, for whichever project node is selected -- this
     used to be one of three states cycled via Shift+B; that toggle is gone
     and this (ring + 3D-cube together) is simply the only state now. The
     underlying click/select behavior, dot enlargement, and subtext reveal
     above are unaffected. The cube itself rolls automatically on a timer
     whenever a project node is selected, not gated by hover (see
     refreshWobble() in graph.js).

     A real 3D cube (6 HTML faces via <foreignObject>, all sharing this
     project's own flat color n.color with no per-face shading -- kept
     deliberately plain/uniform) layered exactly over the flat square,
     invisible/inert (opacity 0, pointer-events none, so it can never
     intercept the node's own click/hover/drag) until graph.js's
     triggerWobble() briefly reveals it for the ~0.7s of an actual roll,
     then hides it again -- the flat colored rect underneath (with its
     usual inset shadow) is what's visible the rest of the time, between
     rolls. Plain <rect>s can't paint true CSS 3D perspective (confirmed
     while building this -- the rotation matrix computes right but Chromium
     never paints the foreshortening), which is the whole reason this one
     piece is real HTML content instead of more SVG. The roll itself
     (rotate3d(1,1,0,...), diagonal top-left<->bottom-right axis,
     alternating direction each trigger) is driven by chained CSS
     transitions from graph.js, not @keyframes -- keyframes hit the same
     no-perspective-inside-foreignObject issue that plain SVG rects did. */
  foreignObject{overflow:visible;}
  /* visibility, not opacity, hides the cube at rest (and reveals it during
     a roll) -- opacity, even toggled to the identity value 1, creates a CSS
     stacking context on the element carrying it, which per spec flattens
     any preserve-3d 3D transform in the subtree underneath. That's exactly
     why the roll rendered with no foreshortening (or visibly clipped) in
     Safari: Chromium is lenient in practice about 3D-under-opacity, Safari
     enforces the spec correctly. visibility has no such side effect on 3D
     rendering in any engine, so it's the safe way to reveal/hide this
     subtree -- see triggerWobble()/graph.js, which toggles this instead of
     opacity now. -webkit- prefixes on perspective/transform-style/backface-
     visibility below are for the same reason: Safari's 3D transform support
     inside a <foreignObject> specifically has historically needed the
     prefixed form honored alongside the unprefixed one to establish a real
     3D rendering context there. */
  .cube-fo{visibility:hidden;pointer-events:none;overflow:visible;}
  /* Safari's real-HTML stand-in for the cube-fo foreignObject above --
     see the IS_SAFARI comment in graph.js. Floats over the whole page
     (outside the SVG, so WebKit will actually render its 3D transform)
     and is repositioned per-node by positionCubeOverlay(); hidden except
     for the ~0.7s a roll is in flight. */
  .cube-overlay{position:fixed;z-index:500;visibility:hidden;pointer-events:none;transition:filter .3s ease, opacity .3s ease;}
  .cube-wrap{width:15px;height:15px;perspective:66px;-webkit-perspective:66px;}
  .cube{width:100%;height:100%;position:relative;transform-style:preserve-3d;-webkit-transform-style:preserve-3d;}
  .cube-face{position:absolute;inset:0;backface-visibility:hidden;-webkit-backface-visibility:hidden;}
  .cube-face.front{transform:translateZ(7.5px);}
  .cube-face.back{transform:rotateY(180deg) translateZ(7.5px);}
  .cube-face.top{transform:rotateX(90deg) translateZ(7.5px);}
  .cube-face.bottom{transform:rotateX(-90deg) translateZ(7.5px);}
  .cube-face.left{transform:rotateY(-90deg) translateZ(7.5px);}
  .cube-face.right{transform:rotateY(90deg) translateZ(7.5px);}

  /* graph labels sit centred beneath their mark, per the Figma graph frame */
  .node .label{font-family:var(--font-body);font-size:16px;letter-spacing:0;fill:var(--n900);text-anchor:middle;dominant-baseline:hanging;pointer-events:none;user-select:none;}
  .node-hub .label{font-family:var(--font-body);font-weight:400;font-size:18px;}
  .node-category .label{font-family:var(--font-mono);font-weight:500;font-size:16px;}
  .node-project .label{font-family:var(--font-body);font-weight:400;font-size:14px;}
  .node .dot{color:var(--n900);}
  /* Opaque backdrops (0.8px padding) behind graph text -- rectangular,
     snug to the label -- and behind the hub squiggle and DESIGN/LAB/WORDS
     icon marks -- fully circular, matching the canvas bg -- so a crossing
     edge never cuts through a letterform or the icon's own ink. */
  .label-bg{fill:var(--bg);pointer-events:none;}
  /* Unlike the text backdrops, this one also has to catch the pointer: the
     icon paths underneath (the hub squiggle, asterisk, braces, ampersand)
     are thin vector ink with almost no fillable area, so drag/click was
     only registering on the exact stroke pixels. The circle is already
     sized and painted to match the icon visually, so making it hit-testable
     just gives Quang Mai and DESIGN/LAB/WORDS a real grab target without
     changing how anything looks. */
  .icon-bg{fill:var(--bg);pointer-events:auto;}
  .sub-bg{fill:var(--bg);pointer-events:none;opacity:0;transition:opacity .2s;}
  .node .sub{font-family:var(--font-mono);font-weight:400;font-size:var(--fs-xs);letter-spacing:0;fill:var(--n500);text-anchor:middle;dominant-baseline:hanging;pointer-events:none;user-select:none;opacity:0;transition:opacity .2s;}
  .node:hover .sub, .node.selected .sub, .node:hover .sub-bg, .node.selected .sub-bg{opacity:1;}
  /* As soon as a project node is clicked/selected -- not gated on the
     background actually being blurred yet, since on desktop that blur only
     kicks in a step later once "VIEW FULL CASE" opens the full case study --
     that node's own SUBTITLE/TAG backdrop goes transparent: the opaque
     #fbfbfb chip behind it reads as an odd crisp little rectangle once the
     graph-wrap eventually blurs, so it's dropped the moment the node
     becomes the selected one rather than waiting for case-open. The
     title's own backdrop (.label-bg) is intentionally NOT included here --
     it keeps its normal #fbfbfb padding at all times, selected or not, per
     request. Every other (non-selected) node's subtitle backdrop is
     untouched -- this is scoped to the one clicked node -- and it reverts
     the instant the node is deselected. */
  .node-project.selected .sub-bg{fill:none;}
  .hint{position:absolute;right:18px;bottom:18px;font-family:var(--font-mono);font-weight:400;font-size:var(--fs-xxs);letter-spacing:0;color:var(--n500);text-transform:uppercase;z-index:8;}
  .panel{position:absolute;top:50%;left:14px;transform:translateY(-50%);width:340px;max-height:calc(100vh - 28px);background:var(--n300);border-radius:var(--curve-m);padding:4px;display:flex;flex-direction:column;overflow:hidden;transition:width .25s ease, border-radius .25s ease;}
  /* No shadow at rest -- only while actively dragging, using the exact
     shadow the panel always used to carry (unchanged value, just now
     conditional on .dragging instead of being on all the time). */
  .panel.dragging{box-shadow:0 4px 9.35px rgba(0,0,0,.05);}
  .panel.collapsed{width:200px;border-radius:var(--curve-m);}
  .panel.collapsed .sidebar{display:none;}
  .panel-handle{cursor:grab;padding:calc(var(--pad-v-s) + var(--outline-s)) calc(var(--pad-h-s) + var(--outline-s));font-family:var(--font-mono);font-weight:400;font-size:var(--fs-xs);letter-spacing:0;color:var(--n900);background:var(--n200);box-shadow:var(--hair-s);border-radius:var(--curve-s) var(--curve-s) 0 0;user-select:none;flex-shrink:0;display:flex;align-items:center;gap:var(--gap-xs);white-space:nowrap;}
  .panel.collapsed .panel-handle{border-radius:var(--curve-s);}
  .panel-handle .wh-label{flex:1;min-width:0;}
  .panel.snapping{transition:left .25s ease, top .25s ease;}
  /* Desktop collapse/expand gliding to its corner / centre (panel.js
     dockDesktopPanel) -- the site's ease-out curve, alongside the width
     change it already had. */
  .panel.docking{transition:left .38s cubic-bezier(.16,1,.3,1), top .38s cubic-bezier(.16,1,.3,1), width .25s ease, border-radius .25s ease;}
  /* -webkit-transform:translateZ(0) forces this scroll container onto its
     own stable compositing layer up front -- see the .appear/.appear-fade
     comment above for why: it's the container half of the two-part Safari
     blur-clip fix (harmless on Chrome/Firefox, which already handle this
     layer combination correctly either way). */
  .sidebar{padding:var(--gap-l) var(--pad-h-s);overflow-y:auto;background:var(--n100);border-radius:0 0 var(--curve-s) var(--curve-s);
    display:flex;flex-direction:column;gap:var(--gap-xs);
    -webkit-transform:translateZ(0);transform:translateZ(0);
    box-shadow:inset var(--outline-s) 0 0 var(--n400), inset calc(-1 * var(--outline-s)) 0 0 var(--n400), inset 0 calc(-1 * var(--outline-s)) 0 var(--n400);}
  .sec{display:flex;flex-direction:column;gap:var(--gap-xs);}
  /* Divider Container: an 8px spacer between groups that draws nothing --
     the design has it, deliberately invisible. It only appears after an open
     group; the collapsed widget stacks its headers on the plain 6px rhythm. */
  .ov-divider,.case-rule{height:8px;flex-shrink:0;width:100%;max-width:600px;}
  .sec-divider{display:none;height:8px;flex-shrink:0;}
  .sec-cat.is-open + .sec-divider{display:block;}

  .sec-head{display:flex;align-items:center;gap:var(--gap-xs);cursor:pointer;
    font-family:var(--font-mono);font-weight:500;font-size:var(--fs-xs);letter-spacing:0;
    color:var(--n900);text-transform:uppercase;
    padding-bottom:calc(var(--gap-l) + var(--pad-v-s));
    background-repeat:no-repeat;background-position:left calc(100% - var(--pad-v-s));background-size:100% var(--outline-m);
    background-image:repeating-linear-gradient(to right, var(--n500) 0 3px, transparent 3px 6px);}
  .sec-head.is-open{color:var(--n500);padding-bottom:var(--pad-v-s);background-image:none;}
  .sec-about .sec-head.is-open{padding-bottom:var(--gap-s);}
  /* Figma's widget (1:71) draws no rule -- no Divider Container, nothing --
     between the last section and the toggle/contact row that follows it, so
     the last category's closed-state rule is suppressed the same way. */
  .sec-cat-last .sec-head:not(.is-open){background-image:none;padding-bottom:var(--pad-v-s);}
  .sec-head-left{flex:1;min-width:0;}
  .sec-head .icon{color:currentColor;}
  /* the header's trailing control is a real button so it can carry its own
     label and focus ring without swallowing the header's own click target */
  .sec-icon-btn{position:relative;background:none;border:none;padding:0;margin:0;cursor:pointer;color:inherit;display:flex;align-items:center;line-height:0;}
  /* the ABOUT row's open arrow stays black regardless of the header dimming
     to neutral/500 when open -- that dimming is for the ABOUT label, not
     this control */
  .sec-about .sec-icon-btn{color:var(--n900);}
  /* The direct-arrow icon drops to 60% opacity on hover -- the ABOUT row's
     open arrow and the case widget's "view full case" arrow both use it. */
  .sec-head:hover .icon-direct, .case-cta:hover .icon-direct{opacity:.6;}
  /* The open/close (+/-) glyph on each category header stays neutral/900
     regardless of the header's own open/closed colour, dropping to 40%
     opacity on hover and press (Numbers/Opacity/S) rather than recolouring. */
  .sec-head .icon-open, .sec-head .icon-close{color:var(--n900);}
  .sec-head:hover .icon-open, .sec-head:hover .icon-close,
  .sec-head:active .icon-open, .sec-head:active .icon-close{opacity:.4;}
  .sec-head-return{color:var(--n500);font-size:var(--fs-xs);padding-bottom:var(--gap-s);background-image:none;}
  .sec-body{margin:0;}
  .sec-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--gap-m);}
  .sec-list.collapsed{display:none;}

  /* ---------- item row ---------- */
  .item-row{display:flex;flex-direction:column;gap:var(--gap-s);justify-content:center;
    padding:0 0 var(--pad-v-m);cursor:pointer;
    background-repeat:no-repeat;background-position:left bottom;background-size:100% 1px;
    background-image:repeating-linear-gradient(to right, rgba(205,205,205,.4) 0 3px, transparent 3px 6px);
    opacity:1;transition:opacity .35s cubic-bezier(.4,0,.2,1);}
  .item-row:last-child{background-image:none;}
  /* Pressed drops the whole row to 60% (Numbers/Opacity/S) -- kept instant
     (no transition) so it still reads as a press, not a fade, matching the
     original behaviour exactly; the eased transition above is only for the
     Words section's hover-dim effect below. */
  .item-row:active{opacity:.6;transition:none;}
  /* Every section (Design, Lab, Words alike), in every format: main widget
     list (.sec-list wraps each category's <ul>), Text overlay (every
     category renders as .item-row there), and the Column overlay's own
     Words fallback (Column has no visual media for Words, so it renders
     the same rows as Text). Hovering one item dims every other item in
     its OWN section to 60%, with the same ease-in-out curve the appear
     entrance uses, and undims smoothly on mouseleave. Scoped with :has()
     per container (.sec-list / .ov-section) so no JS/listeners are needed
     and hovering in one section never touches a different one.
     !important on both: every row still carries its entrance 'appear'
     class for up to ~1.5s after the section opens/the layout is switched
     to (fill-mode:both keeps it "in effect" until its animationend fires
     and the class is stripped), and a running CSS Animation outranks a
     normal-priority rule for the same property -- so without !important,
     hovering during that window would silently do nothing. !important is
     the one thing that still wins over an animation, so this stays
     correct regardless of timing rather than racing the entrance.

     :not(.revealing) is the one exception: while a section's own staggered
     entrance is still in progress (see toggleSec()/revealSequence() in
     sidebar.js, which toggle .revealing on this exact .sec-list), the dim
     and the item-bullet square below are both suppressed entirely -- hover
     only starts doing anything once every row has actually finished
     blurring/sliding into place, not mid-entrance. */
  .sec-list:not(.revealing):has(.item-row:hover) .item-row{opacity:.6 !important;}
  /* :not(:active) so pressing the hovered row itself still falls through
     to the ordinary press state below (also 0.6, but instant) instead of
     this rule pinning it to 1. */
  .sec-list:not(.revealing):has(.item-row:hover) .item-row:hover:not(:active){opacity:1 !important;}
  /* Overlay-wide hover dim (Text/Column formats only -- the sidebar's own
     .sec-list rule above is untouched: it only ever shows one section's
     items at a time, so there's nothing outside it to dim). Scoped to
     #layout-overlay-inner instead of the individual .ov-section, so
     hovering an item in ANY section dims every OTHER item across EVERY
     section -- Design, Lab and Words together read as one catalogue with
     a single focal point, not three independently-dimming lists. The
     :has() condition lists both .item-row and .ov-card so Column format
     (a mix of media cards for Design/Lab and text rows for Words, all in
     the same overlay) cross-dims correctly regardless of which type was
     actually hovered. :not(.is-frozen) suppresses this rule entirely the
     moment ANYTHING is frozen (see freezeHover()/applyFrozenHover() in
     overlay.js) -- confirmed with the user: once a widget is open, that
     click is the one stable focal point everywhere, and hovering a
     different item elsewhere has no visual effect until that widget
     closes or something else is clicked. */
  #layout-overlay-inner:not(.is-frozen):has(.item-row:hover, .ov-card:hover) .item-row,
  #layout-overlay-inner:not(.is-frozen):has(.item-row:hover, .ov-card:hover) .ov-card{opacity:.6 !important;}
  #layout-overlay-inner:not(.is-frozen):has(.item-row:hover, .ov-card:hover) .item-row:hover:not(:active),
  #layout-overlay-inner:not(.is-frozen):has(.item-row:hover, .ov-card:hover) .ov-card:hover{opacity:1 !important;}
  /* freezeHover()'s locked-in equivalent of the two rules above -- same
     values, but driven by a real class instead of live :hover, so the
     dimmed/highlighted state survives the pointer moving away once a
     project widget opens from a click (freezeHover/clearFrozenHover in
     overlay.js). The live :has() rules just above are gated with
     :not(.is-frozen) so the two mechanisms never fight over the same
     property on the same section. */
  .is-frozen-dim{opacity:.6 !important;}
  .is-frozen-hover{opacity:1 !important;}
  .item-row.is-frozen-hover .item-bullet{display:block;}
  .item-head{display:flex;gap:var(--gap-s);align-items:flex-start;width:100%;}
  /* Sidebar dropdown rows (.sec-list) only get this hover shift once
     :not(.revealing) -- same entrance-gating as the dim rule above, so the
     bullet-square + head realignment stay fully inert during the staggered
     entrance instead of popping in mid-blur. The Text/Column overlay's own
     rows (#layout-overlay-inner) are unaffected and keep reacting to hover
     immediately, per scope confirmed with the user. */
  .sec-list:not(.revealing) .item-row:hover .item-head,
  #layout-overlay-inner .item-row:hover .item-head,
  .item-row.is-frozen-hover .item-head{align-items:center;}
  /* the hover bullet is absent in the default state, so it takes up no room
     until the row is hovered -- matching the shift the design shows */
  /* background is set per-row inline (itemRowHTML, sidebar.js) to that row's
     project colour; the inset shadow is the graph square's own (black, 25%,
     Y4/blur4) scaled proportionally to this 8px size from the graph's 18px
     reference: 8/18 * 4 = 1.78px. */
  .item-bullet{display:none;width:8px;height:8px;flex-shrink:0;background:var(--n-bullet);
    box-shadow:inset 0 1.78px 1.78px rgba(0,0,0,.25);}
  .sec-list:not(.revealing) .item-row:hover .item-bullet,
  #layout-overlay-inner .item-row:hover .item-bullet{display:block;}
  .item-title{flex:1;min-width:0;font-family:var(--font-title);font-weight:400;font-size:var(--fs-s);line-height:normal;letter-spacing:0;color:var(--n900);}
  .item-meta{flex-shrink:0;font-family:var(--font-mono);font-weight:400;font-size:var(--fs-xs);line-height:normal;letter-spacing:0;color:var(--n500);white-space:nowrap;}
  .item-desc{margin:0;font-family:var(--font-body);font-weight:400;font-size:var(--fs-xs);line-height:normal;letter-spacing:0;color:var(--n600);}
  .item-tags{display:flex;gap:var(--gap-s);align-items:flex-start;flex-wrap:wrap;}
  .tag{display:inline-flex;align-items:center;justify-content:center;
    padding:calc(var(--pad-v-xs) + var(--outline-s)) calc(var(--pad-h-xs) + var(--outline-s));background:var(--n200);
    border-radius:var(--curve-xs);
    box-shadow:var(--shadow-tag), var(--hair-s);
    font-family:var(--font-mono);font-weight:400;font-size:var(--fs-xs);line-height:normal;letter-spacing:0;color:var(--n600);
    text-transform:uppercase;white-space:nowrap;}
  /* ---------- ABOUT bio + contact row ---------- */
  /* Bio Text (44:854, bound to Font Size/S = 14px) runs flush with the
     widget's own padding on every side -- no first-line indent. */
  .bio-p{font-family:var(--font-body);font-weight:400;font-size:var(--fs-s);line-height:normal;letter-spacing:0;color:#7E7E7E;margin:0;}
  .bio-p + .bio-p{margin-top:19px;margin-top:1lh;}
  .sec-body-about{padding-bottom:24px;}
  /* The profile's own name (.bio-name, wrapped by sidebar.js's
     bioParaHTML()) stays pure black against the dimmed #7E7E7E bio text
     around it, rather than blending in -- an explicit color here wins
     over .bio-p's own color regardless of specificity/cascade order. */
  .bio-name{color:var(--n900);}
  /* The orgs/schools called out in the bio (SAP Business Data Cloud,
     Google, Cornell, UNDP -- .bio-link, wrapped by sidebar.js's
     bioParaHTML() from DATA.profile.bioLinks) are real links, each
     opening its own site in a new tab. Styled per Figma node 120:2048's
     underline spec: dotted, 8% thickness, 24% offset, colored
     --n400/Neutral-400 rather than the bio's own dimmed #7E7E7E text
     color, same pure-black text as .bio-name right above. text-decoration
     is split into its longhands (not the text-decoration shorthand)
     because the shorthand can't carry a percentage thickness/offset.
     Hover dims to 60% opacity, same bare "opacity:.6 on :hover, no
     transition" treatment already used elsewhere for a direct hover
     state (see .sec-head:hover .icon-direct above). */
  .bio-link{color:var(--n900);text-decoration-line:underline;text-decoration-style:dotted;
    text-decoration-thickness:8%;text-underline-offset:24%;text-decoration-skip-ink:auto;
    text-decoration-color:var(--n400);cursor:pointer;}
  .bio-link:hover{opacity:.6;}
  .contact-row{display:flex;gap:calc(var(--gap-xs) * var(--ui-scale));padding-top:24px;}
  /* ToggleOption's four states (50:1375), read from Figma variables per
     symbol: Default icon neutral/600 on neutral/300 (was neutral/900 --
     lightened per feedback so the socials read less heavy at rest), Hover
     icon neutral/500 on neutral/200, Pressed icon neutral/600 on
     neutral/200, Empty icon neutral/500 on neutral/100. Socials sit in
     Default at rest -- there is no "chosen" one, every button starts fully
     on. */
  .contact-btn:hover{background:var(--n200);color:var(--n500);}
  .contact-btn:active{background:var(--n200);color:var(--n600);}
  .contact-btn.is-empty{background:var(--n100);color:var(--n500);cursor:default;}
  .contact-btn.is-empty:hover{background:var(--n200);color:var(--n500);}
  .contact-btn{--icon-size:calc(18px * var(--ui-scale));
    display:flex;align-items:center;justify-content:center;flex-shrink:0;
    width:calc(26px * var(--ui-scale));height:calc(26px * var(--ui-scale));
    padding:calc(var(--pad-v-xs) * var(--ui-scale)) calc(var(--pad-h-xs) * var(--ui-scale));
    background:var(--n300);border-radius:calc(var(--curve-xs) * var(--ui-scale));
    color:var(--n600);overflow:visible;text-decoration:none;
    /* Permanent 0.4px #CDCDCD hairline stroke (--hair-s, already exactly
       this width/color) -- no longer gated behind the old Shift+B toggle. */
    box-shadow:var(--hair-s);}

  /* ---------- project widget, states 1 and 2 (Figma 63:1765 / 69:11545) ---------- */
  .case-meta{font-family:var(--font-mono);font-weight:400;font-size:var(--fs-xs);line-height:normal;letter-spacing:0;color:var(--n500);display:block;}
  .case-titlerow{display:flex;align-items:center;gap:var(--gap-s);}
  .case-bullet{width:12px;height:12px;flex-shrink:0;background:var(--accent-case);}
  .case-name{font-family:var(--font-title);font-weight:400;font-size:var(--fs-l);line-height:normal;letter-spacing:0;color:var(--n900);margin:0;}

  .case-section{font-family:var(--font-mono);font-weight:500;font-size:var(--fs-xs);line-height:normal;letter-spacing:0;color:var(--n500);text-transform:uppercase;margin:0;}
  .case-text{font-family:var(--font-body);font-weight:400;font-size:var(--fs-s);line-height:normal;letter-spacing:0;color:var(--n900);margin:0;}
  /* Badge Icon with a text label (42:411, buttonText variant) */
  .case-media{width:100%;}
  .case-cta{display:inline-flex;align-self:flex-start;align-items:center;gap:var(--gap-xs);cursor:pointer;
    background:var(--n100);border:none;border-radius:var(--curve-xs);
    box-shadow:var(--shadow-badge), var(--hair-s);
    padding:calc(var(--pad-v-xs) + var(--outline-s)) calc(var(--pad-h-xs) + var(--outline-s));color:var(--n900);}
  .case-cta:hover{box-shadow:var(--shadow-badge), var(--shadow-badge-hover), var(--hair-s);}
  .case-cta:active{box-shadow:var(--shadow-badge-pressed), var(--hair-s);}
  .case-cta .label{font-family:var(--font-mono);font-weight:500;font-size:var(--fs-xs);
    line-height:normal;letter-spacing:0;color:var(--n900);white-space:nowrap;padding-left:var(--pad-h-xs);}

  .media-ph{position:relative;width:100%;aspect-ratio:4/3;background:var(--n200);box-shadow:var(--hair-s);border-radius:var(--curve-s);display:flex;align-items:center;justify-content:center;}
  .media-ph .ph-icon{font-family:var(--font-mono);font-weight:400;font-size:var(--fs-xxs);letter-spacing:0;color:var(--n500);text-transform:uppercase;}

  /* Figma 69:12469 ("hover image/video") is a single flat 350x214 box --
     n200 fill, curve-s radius, hairline outline, drop shadow -- with no
     inset card/padding around it, so the wrapper below carries no
     background/padding/shadow of its own any more; that treatment now
     lives on .media-ph directly, right below.

     The box's WIDTH is 340px, not the Figma spec's literal 350 -- matching
     the main widget's own width (.panel{width:340px}) exactly, on request,
     so the two floating layers read as the same size. The 350/214 aspect
     ratio (below, on .media-ph) is kept as-is -- it's a proportion, not a
     pixel count, so at 340px wide it still renders the same shape, just
     340/(350/214) ≈ 208px tall instead of 214. */
  .hover-preview{position:fixed;z-index:60;width:340px;opacity:0;transform:translateY(6px);transition:opacity .18s ease, transform .18s ease;pointer-events:none;}
  .hover-preview.show{opacity:1;transform:translateY(0);}
  /* Figma's drop shadow: #000000, 5% opacity, Y 4px, blur 8px -- stacked
     with the same hairline outline every n200 placeholder box gets. */
  /* .media-ph's own align-items/justify-content:center (base rule above)
     already centers whatever's placed inside it, so the eventual image/
     video content lands centered with no extra rule needed here. The
     title caption that used to sit below the box (hp-title) is gone --
     this popup is just the media box now. */
  .hover-preview .media-ph{aspect-ratio:350/214;box-shadow:0 4px 8px rgba(0,0,0,.05), var(--hair-s);}

  .carousel{margin:0;}
  .carousel-viewport{display:flex;overflow-x:auto;overflow-y:hidden;gap:8px;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;scrollbar-width:none;cursor:grab;border-radius:6px;}
  .carousel-viewport::-webkit-scrollbar{display:none;}
  .carousel-viewport.dragging{cursor:grabbing;scroll-snap-type:none;user-select:none;}
  .carousel-viewport .slide{flex:0 0 82%;scroll-snap-align:start;}
  .carousel .media-ph{aspect-ratio:495/274;}
  .carousel-hint{margin-top:7px;font-family:var(--font-mono);font-weight:400;font-size:var(--fs-xxs);letter-spacing:0;color:var(--n500);text-transform:uppercase;}

  /* 644px so the actual content column inside (.case-body's children, after
     .case-panel's own 4px padding and .case-body's own --pad-h-s padding)
     measures 600px -- the same content width .ov-section/.ov-grid use for
     the Text and Column formats -- rather than the 540px outer width that
     used to leave the content itself at 496px. */
  .case-panel{position:absolute;top:40px;width:644px;height:auto;max-height:calc(100vh - 80px);background:var(--n300);border-radius:var(--curve-m);padding:4px;display:flex;flex-direction:column;overflow:hidden;z-index:20;}
  /* No shadow at rest -- only while actively dragging, matching .panel's
     own dragging-only shadow above (same trigger: makeDraggable() toggles
     .dragging on this element too), same exact value. Covers both the
     About widget and the project detail widget, since both render through
     this shared .case-panel element. */
  .case-panel.dragging{box-shadow:0 4px 9.35px rgba(0,0,0,.05);}
  .case-panel.expanded{max-height:none;z-index:100;}
  .case-panel.snapping{transition:left .25s ease, top .25s ease;}
  .case-panel.resizing{transition:left .3s ease, top .3s ease, width .3s ease, height .3s ease;}
  .case-body{flex:1;min-height:0;}
  .case-header{display:flex;align-items:center;gap:var(--gap-xs);padding:calc(var(--pad-v-s) + var(--outline-s)) calc(var(--pad-h-s) + var(--outline-s));
    background:var(--n200);box-shadow:var(--hair-s);border-radius:var(--curve-s) var(--curve-s) 0 0;
    cursor:grab;user-select:none;flex-shrink:0;white-space:nowrap;}
  .case-header:active{cursor:grabbing;}
  .case-header-left{flex:1;min-width:0;font-family:var(--font-mono);font-weight:400;font-size:var(--fs-xs);letter-spacing:0;
    color:var(--n900);text-transform:uppercase;overflow:hidden;text-overflow:ellipsis;}
  .case-header-actions{display:flex;gap:var(--gap-xs);flex-shrink:0;}
  /* Same compositing-layer promotion as .sidebar above, same reason. */
  .case-body{padding:var(--gap-l) var(--pad-h-s);overflow-y:auto;background:var(--n100);border-radius:0 0 var(--curve-s) var(--curve-s);
    display:flex;flex-direction:column;gap:var(--gap-xs);align-items:flex-start;
    -webkit-transform:translateZ(0);transform:translateZ(0);
    box-shadow:inset var(--outline-s) 0 0 var(--n400), inset calc(-1 * var(--outline-s)) 0 0 var(--n400), inset 0 calc(-1 * var(--outline-s)) 0 var(--n400);}
  .case-title{font-family:var(--font-title);font-weight:400;font-size:var(--fs-l);letter-spacing:0;margin:0;}

  .panel{z-index:10;}

  /* Graph zoom bar (zoom-bar.js) -- Figma 430:2442: an n200 pill with the
     hairline (--hair-s, not a border -- see the token note), 10/4px padding,
     curve/xs, 32.8px wide; 13 2px ticks on a gap/s rhythm, curve/m ends.
     Tick width/colour are set per frame by zoom-bar.js (the airla-style
     wave), so only their resting values live here. Centred vertically on
     the right edge, on the same 14px margin as the PA button above it. */
  .zoom-bar{position:absolute;right:14px;top:50%;transform:translateY(-50%);z-index:30;
    box-sizing:border-box;width:32.8px;padding:10px 4px; /* Figma padding/vertical/xs = 10px; this file's --pad-v-xs is 4px */background:var(--n200);box-shadow:var(--hair-s);
    border-radius:var(--curve-xs);display:flex;justify-content:center;overflow:hidden;cursor:ns-resize;touch-action:none;
    transition:opacity .3s ease, visibility .3s ease;}
  /* No focus ring while using it -- the bar looks the same focused or not
     (it's still keyboard-operable). */
  .zoom-bar:focus, .zoom-bar:focus-visible{outline:none;}
  /* CHANGE MODE button (zoom-bar.js) -- Figma 436:2494 "ToggleOption": an
     n200 square with the hairline, 4.8px padding and corners, holding the
     21.6px arrows icon (assets/icons/ChangeMode.svg, unmodified). Sits just
     above the zoom bar (bar: 142px tall, centred; 8px gap). Only shown once
     zoomed in (body.graph-zoomed) -- the modes only exist there -- and not
     over Text/Column. Hover tag: the PA button's tip, opening to the left
     since the button is on the right edge. The icon half-turns on every
     switch (--mode-turn). */
  .zoom-mode{position:absolute;right:14px;top:calc(50% - 71px - 8px - 31.2px);z-index:30;
    box-sizing:border-box;width:31.2px;height:31.2px;padding:4.8px;border:none;border-radius:4.8px;
    background:var(--n200);box-shadow:var(--hair-s);color:var(--n600);cursor:pointer;line-height:0;
    opacity:0;visibility:hidden;pointer-events:none;transition:opacity .3s ease, visibility .3s ease;}
  body.graph-zoomed .zoom-mode{opacity:1;visibility:visible;pointer-events:auto;}
  .graph-wrap:has(.layout-overlay.show) .zoom-mode{opacity:0;visibility:hidden;pointer-events:none;}
  .zoom-mode:focus{outline:none;}
  .zoom-mode .icon{--icon-size:21.6px;transform:rotate(var(--mode-turn,0deg));transition:transform .45s cubic-bezier(.16,1,.3,1);}
  .icon-change-mode{--m:url("/legacy/assets/icons/ChangeMode.svg");-webkit-mask-image:var(--m);mask-image:var(--m);-webkit-mask-size:100% 100%;mask-size:100% 100%;}
  .zoom-mode::after{content:attr(data-tip);position:absolute;top:50%;right:calc(100% + 8px);
    transform:translate(4px,-50%);
    background:var(--n200);color:var(--n600);border-radius:var(--curve-xs);
    box-shadow:var(--shadow-tag), var(--hair-s);
    font-family:var(--font-mono);font-weight:400;font-size:var(--fs-xs);letter-spacing:0;text-transform:uppercase;
    line-height:normal;
    padding:var(--pad-v-xs) var(--pad-h-xs);white-space:nowrap;opacity:0;pointer-events:none;
    transition:opacity .18s ease, transform .22s cubic-bezier(.4,0,.2,1);}
  .zoom-mode:hover::after{opacity:1;transform:translate(0,-50%);}
  .zoom-ticks{display:flex;flex-direction:column;align-items:center;gap:var(--gap-s);width:24px;pointer-events:none;}
  /* Fully rounded ends on both sides (Figma's curve/m on a 2px line). */
  .zoom-tick{display:block;flex-shrink:0;height:2px;width:12px;border-radius:999px;background:var(--n400);
    transition:width .15s ease, background-color .15s ease;}
  /* Graph-only: fades out (not display:none -- see CLAUDE.md's parity
     notes) while Text/Column cover the graph. */
  .graph-wrap:has(.layout-overlay.show) .zoom-bar{opacity:0;visibility:hidden;pointer-events:none;}
  /* A project's zoom card (graph.js applyZoomCards): the hover state's
     media box. Hairline and soft shadow only once it has opened up --
     while it's still the size of the square it IS the square. */
  .zoom-card.zoom-card-open{stroke:var(--n400);stroke-width:var(--outline-s);
    filter:drop-shadow(0 1px 1px rgba(0,0,0,.05)) drop-shadow(0 2px 3px rgba(0,0,0,.05)) drop-shadow(0 5px 8px rgba(0,0,0,.04));}   /* = --shadow-card */
  /* Zoomed in (body.graph-zoomed, graph.js): the coloured squares only
     exist in the default view -- they fade out as the cards open, along
     with the cube-roll that rides on them (Chrome's .cube-fo, Safari's
     body-level .cube-overlay; both toggle visibility inline, hence
     !important) -- and the selected node's dashed outline moves from the
     square onto its card: .zoom-ring traces the card, in the project's
     colour, with the square's own 2px / 4-4 dash. */
  .node-project .leaf-square{transition:opacity .3s ease;}
  body.graph-zoomed .node-project .leaf-square{opacity:0;}
  body.graph-zoomed .node-project .cube-fo, body.graph-zoomed .cube-overlay{visibility:hidden !important;}
  body.graph-zoomed .node-project .pulse-ring{opacity:0;}
  /* While the globe is being turned by a drag (canvas-interactions.js's
     startGlobeTurn, which takes no pointer capture), nodes ignore the
     sweeping pointer so it doesn't trigger their hover states, and the
     page doesn't start a text selection. */
  body.globe-turning #graph .node{pointer-events:none;}
  /* Zoomed in, Quang Mai's and Design/Lab/Words' names are click targets
     too (their backdrop takes the click; the text itself never does), so
     clicking the word works like clicking the mark. */
  body.graph-zoomed #graph .node:not(.node-project) .label-bg{pointer-events:auto;cursor:pointer;}
  /* A card or category brought to the front of the globe sits right in
     front of the core, where Quang Mai is -- fade the hub out meanwhile. */
  #graph .node-hub{transition:opacity .3s ease;}
  body.globe-facing #graph .node-hub{opacity:0;pointer-events:none;}
  body.globe-turning{user-select:none;-webkit-user-select:none;cursor:grabbing;}
  .zoom-ring{fill:none;stroke-width:2;stroke-dasharray:4,4;vector-effect:non-scaling-stroke;opacity:0;transition:opacity .3s ease;pointer-events:none;}
  body.graph-zoomed .node-project.selected .zoom-ring{opacity:1;}
  /* The selected card is brought round to the front, flat -- it IS the
     project's hover state, so its title and tag fade out beneath it (any
     zoom level past the default; back as soon as it's deselected or the
     view returns to the default). */
  .node-project .label, .node-project .label-bg{transition:opacity .2s;}
  body.graph-zoomed .node-project.selected .label, body.graph-zoomed .node-project.selected .label-bg,
  body.graph-zoomed .node-project.selected .sub, body.graph-zoomed .node-project.selected .sub-bg{opacity:0;}
  /* Zooming in, every project's title and tag fade out (--zoom-label, set
     on #graph by graph.js: 1 up to 35% zoom, 0 from 50%) -- only Quang
     Mai's and Design/Lab/Words' labels remain. A wrapper <g> isn't
     available here, so the fade is applied to each piece; for the tag it
     multiplies its own hover/selected visibility rather than forcing it on. */
  body.graph-zoomed .node-project .label, body.graph-zoomed .node-project .label-bg{opacity:var(--zoom-label,1);}
  body.graph-zoomed .node-project:not(.selected):not(:hover) .sub,
  body.graph-zoomed .node-project:not(.selected):not(:hover) .sub-bg{opacity:0;}
  body.graph-zoomed .node-project:not(.selected):hover .sub,
  body.graph-zoomed .node-project:not(.selected):hover .sub-bg{opacity:var(--zoom-label,1);}
  .layout-switcher{position:absolute;top:var(--switcher-top,14px);left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:10px;z-index:30;}
  /* Figma Toggle (54:1421): neutral/300 track, white inner group with a 0.8px
     border, three 26px ToggleOptions */
  /* Padding matches the main widget's own outer padding (.panel: 4px flat) --
     this is the toggle's outermost layer just like .panel is the widget's,
     so the two should read as the same inset. It was scaling with
     --ui-scale (4.8px) while .panel stays a flat 4px, which is what read
     as visibly thicker here. */
  .ls-track{background:var(--n300);border-radius:calc(var(--curve-m) * var(--ui-scale));padding:var(--pad-v-xs) var(--pad-h-xs);}
  .ls-group{position:relative;display:flex;gap:calc(var(--gap-xs) * var(--ui-scale));background:var(--n100);
    /* Was inset var(--outline-m) scaled by --ui-scale (0.96px) -- nearly 2.5x
       the main widget's own hairline stroke (--hair-s: 0.4px n400, unscaled,
       used by .badge-icon/.panel-handle/.sidebar/etc), which read as visibly
       thicker. Matching --hair-s brings it in line with every other stroke
       in the widget. */
    box-shadow:var(--hair-s);
    border-radius:calc(var(--curve-s) * var(--ui-scale));
    padding:calc((var(--pad-v-xs) + var(--outline-m)) * var(--ui-scale)) calc((var(--pad-h-xs) + var(--outline-m)) * var(--ui-scale));}
  /* A single sliding pill carries the Hover fill and follows the pointer
     between options instead of each button flipping its own background on
     -- that per-button swap is what read as abrupt/clipped. It sits behind
     the buttons (z-index 0); the selected option's own Default fill paints
     over it unchanged. Fast-out, slow-in easing (ease-out-expo-ish) so the
     motion reads as a deliberate slide rather than a snap. */
  .ls-hover{position:absolute;top:calc(var(--pad-v-xs) * var(--ui-scale));left:0;
    width:calc(26px * var(--ui-scale));height:calc(26px * var(--ui-scale));
    border-radius:calc(var(--curve-xs) * var(--ui-scale));
    background:var(--n200);opacity:0;pointer-events:none;z-index:0;
    transition:transform .5s cubic-bezier(.16,1,.3,1), opacity .18s ease;}
  /* Same four ToggleOption states as the socials, but here they map to
     selection: the active layout is Default (icon neutral/600, neutral/300
     -- was neutral/900, lightened to match the socials' Default state), the
     other two options sit in Empty at rest (icon neutral/500, neutral/100)
     and pick up Hover / Pressed like any other button. The active option
     holds its Default look through hover/press -- selection doesn't waver
     on a stray pointer move. */
  .ls-btn{--icon-size:calc(18px * var(--ui-scale));
    position:relative;z-index:1;width:calc(26px * var(--ui-scale));height:calc(26px * var(--ui-scale));flex-shrink:0;border:none;
    border-radius:calc(var(--curve-xs) * var(--ui-scale));
    background:transparent;padding:calc(var(--pad-v-xs) * var(--ui-scale)) calc(var(--pad-h-xs) * var(--ui-scale));overflow:visible;
    display:flex;align-items:center;justify-content:center;cursor:pointer;color:var(--n500);}
  .ls-btn:hover{color:var(--n500);}
  .ls-btn:active{color:var(--n600);}
  .ls-btn.active{background:var(--n300);color:var(--n600);}
  .ls-btn.active:hover, .ls-btn.active:active{background:var(--n300);color:var(--n600);}
  /* tooltip reuses the Tags/Hover Tip chip (66:3881) */
  .contact-btn{position:relative;}
  .contact-btn[data-tip]::after{content:attr(data-tip);position:absolute;bottom:calc(100% + 8px);left:50%;
    transform:translateX(-50%) translateY(4px);
    background:var(--n200);color:var(--n600);border-radius:var(--curve-xs);
    box-shadow:var(--shadow-tag), var(--hair-s);
    font-family:var(--font-mono);font-weight:400;font-size:var(--fs-xs);letter-spacing:0;text-transform:uppercase;
    padding:var(--pad-v-xs) var(--pad-h-xs);white-space:nowrap;opacity:0;pointer-events:none;
    transition:opacity .18s ease, transform .22s cubic-bezier(.4,0,.2,1);z-index:40;}
  .contact-btn[data-tip]:hover::after{opacity:1;transform:translateX(-50%) translateY(0);}
  /* The gap is measured from .ls-track's own outer edge, not .ls-btn's --
     .ls-btn sits inset inside .ls-group's padding AND .ls-track's own
     padding on top of that, so an 8px offset measured from just the
     button's bottom landed the chip well inside where the track's grey
     chrome still is (no visible gap at all, unlike the socials tooltip,
     which IS measured from its own tight button edge because .contact-btn
     has no such wrapping chrome). Adding both wrapper's paddings back in
     before the 8px reproduces the same "gap from the real outer edge"
     that .pa-trigger's tooltip uses against its own tray. */
  .ls-btn::after{content:attr(data-tip);position:absolute;
    top:calc(100% + (var(--pad-v-xs) + var(--outline-m)) * var(--ui-scale) + var(--pad-v-xs) + 8px);
    left:50%;
    transform:translateX(-50%) translateY(-4px);
    background:var(--n200);color:var(--n600);border-radius:var(--curve-xs);
    box-shadow:var(--shadow-tag), var(--hair-s);
    font-family:var(--font-mono);font-weight:400;font-size:var(--fs-xs);letter-spacing:0;text-transform:uppercase;
    padding:var(--pad-v-xs) var(--pad-h-xs);white-space:nowrap;opacity:0;pointer-events:none;
    transition:opacity .18s ease, transform .22s cubic-bezier(.4,0,.2,1);z-index:40;}
  .ls-btn:hover::after{opacity:1;transform:translateX(-50%) translateY(0);}

  /* clear the switcher: its own offset, plus its scaled height, plus the 45px
     the design leaves between the toggle and the first section header */
  /* Same compositing-layer promotion as .sidebar above, same reason. */
  .layout-overlay{position:absolute;inset:0;background:var(--bg);z-index:5;overflow-y:auto;display:none;
    -webkit-transform:translateZ(0);transform:translateZ(0);
    padding:calc(var(--switcher-top, 14px) + 43.6px * var(--ui-scale) + 45px) 32px 32px;}
  /* Centred in the viewport, but never closer than --ov-clear to the panel.
     The offset is a floor on the centring inset rather than an extra margin --
     align-items:center would otherwise re-centre around any margin and absorb
     half of it. Both terms are measured inside the overlay's own padding. */
  #layout-overlay-inner{display:flex;flex-direction:column;gap:var(--gap-l);align-items:flex-start;
    --ov-clear:calc(14px + 340px + 32px - 32px); /* panel edge + gap, less the overlay's padding */
    padding-left:max(var(--ov-clear), calc((100% - 600px) / 2));}
  .layout-overlay.show{display:block;}
  /* the Figma text and column views both run in a 718px content column */
  .ov-section{width:100%;max-width:600px;margin:0;display:flex;flex-direction:column;gap:var(--gap-l);}

  /* Section Header "Write-up" state: label, then a rule filling the rest of the row */
  .ov-section-title{display:flex;align-items:center;gap:var(--gap-m);margin:0;padding:0 0 var(--pad-v-s);border:none;
    font-family:var(--font-mono);font-weight:500;font-size:var(--fs-xs);letter-spacing:0;color:var(--n900);text-transform:uppercase;}
  .ov-section-title::after{content:"";flex:1;height:var(--outline-m);
    background-repeat:repeat-x;background-position:left center;background-size:6px var(--outline-m);
    background-image:linear-gradient(to right, var(--n500) 0 3px, transparent 3px 6px);}
  .ov-grid{column-gap:var(--gap-l);width:100%;max-width:600px;margin:0;}
  .ov-grid.cols-2{column-count:2;}
  .ov-grid.cols-3{column-count:3;}
  .ov-card{position:relative;display:block;width:100%;cursor:pointer;border-radius:var(--curve-s);overflow:hidden;margin:0 0 var(--gap-l);break-inside:avoid;-webkit-column-break-inside:avoid;
    opacity:1;transition:opacity .35s cubic-bezier(.4,0,.2,1);}
  .ov-card .media-ph{border-radius:var(--curve-s);}
  .ov-card-caption{position:absolute;inset:0;display:flex;align-items:flex-end;padding:12px;background:linear-gradient(to top, rgba(22,22,20,.65), transparent 55%);opacity:0;transition:opacity .2s ease;}
  .ov-card:hover .ov-card-caption, .ov-card.is-frozen-hover .ov-card-caption{opacity:1;}
  /* Column overlay's media cards (Design, Lab) share the same overlay-wide
     hover-dim rule as .item-row now (see the combined #layout-overlay-inner
     rule above, near .sec-list) -- cards have no press/:active state of
     their own, so no :not(:active) exception was ever needed for them. */
  .ov-card-caption span{font-family:var(--font-title);font-size:var(--fs-s);color:#fff;}

  .ov-matrix{max-width:720px;margin:0 auto;position:relative;padding:40px 54px;}
  .mx-axis-label{position:absolute;font-family:var(--mono);font-size:11px;letter-spacing:.12em;color:var(--ink-soft);white-space:nowrap;}
  .mx-axis-top{top:2px;left:50%;transform:translateX(-50%);}
  .mx-axis-bottom{bottom:2px;left:50%;transform:translateX(-50%);}
  .mx-axis-left{left:2px;top:50%;transform:translateY(-50%);}
  .mx-axis-right{right:2px;top:50%;transform:translateY(-50%);}
  .mx-grid{display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;border-top:1px solid var(--ink);border-left:1px solid var(--ink);}
  .mx-quadrant{border-right:1px solid var(--ink);border-bottom:1px solid var(--ink);padding:18px 20px;min-height:200px;}
  .mx-q-label{font-family:var(--mono);font-size:10px;letter-spacing:.08em;color:var(--ink-soft);margin-bottom:14px;}
  .mx-q-cards{display:flex;flex-direction:column;}
  .mx-card{display:flex;justify-content:space-between;align-items:baseline;gap:12px;padding:9px 0;border-top:1px dashed var(--line-soft);cursor:pointer;font-family:var(--sans);font-size:14px;}
  .mx-card:first-child{border-top:none;}
  .mx-card:hover{color:var(--accent);}
  .mx-card .t{font-family:var(--mono);font-size:10.5px;color:var(--ink-soft);flex-shrink:0;}
  .mx-empty{font-family:var(--mono);font-size:11px;color:var(--ink-soft);opacity:.5;}

  /* ============================================================
     MOBILE (<=767px) -- shared across Graph/Text/Column per spec.
     Nothing above this block changes for desktop; every rule here
     only takes effect under the breakpoint, and only overrides the
     specific properties called out in the request (position/size/
     entrance-motion of the two floating widgets, the overlay's
     side padding, and Column's column-count) -- everything else
     (colors, shadows, radii, hover states, drag mechanics, spring
     physics, entrance timing/easing) is inherited unchanged. ---- */
  @media (max-width:767px){
    /* Both switcher's own position (top:var(--switcher-top,14px)) and the
       Text/Column overlay's top padding (which clears the switcher via
       that same variable, see its rule below) read off this one custom
       property, so overriding it here keeps the two in agreement
       automatically -- the same 14px as desktop (every floating control
       sits 14px from the edges), plus whatever the status bar/notch
       actually reserves on this device. (It was trimmed to 12px for a
       while; back to 14px with everything else.) */
    .app{--switcher-top:calc(14px + var(--safe-top, 0px));}

    /* Catalogue widget, mobile two-state system. Both the collapsed pill's
       position (bottom-left) and the expanded widget's position/size (true
       center-center, capped so it's never clipped) are computed and set as
       explicit inline pixel values by panel.js's own positionMobilePanel()
       -- mirroring exactly how case-study.js's toggleCaseExpand() already
       handles the full project widget's own expand/shrink. A CSS-only rule
       (e.g. the old top:auto;bottom:16px) can't smoothly transition to/from
       `auto`, which is exactly what caused the reported height-stretch/
       can't-drag-down bug (see panel.js's makeDraggable comment) -- using
       explicit pixel top/left for both states sidesteps that entirely. The
       declarations below are only a pre-JS fallback (this mobile branch
       runs synchronously before first paint, so in practice they're never
       actually visible) plus the width cap the expanded state's exact
       pixel width is computed against. */
    .panel{
      top:50%;left:50%;right:auto;bottom:auto;
      transform:translate(-50%,-50%);
      width:calc(100vw - 32px);max-width:340px;
      max-height:calc(100vh - 32px);
    }

    /* Smart-Animate-style transition for the collapse/expand toggle,
       applied/removed by panel.js's toggleMobilePanel() around the state
       change -- same transition list/timing as .case-panel.resizing (the
       full project widget's own expand/shrink), so both widgets feel
       identical. */
    .panel.mobile-resizing{
      transition:left .3s ease, top .3s ease, width .3s ease, height .3s ease;
    }

    /* Collapsed: reuses the EXACT existing desktop .panel.collapsed shape
       (width:200px, border-radius:var(--curve-m), handle styling) -- only
       repositioned, nothing new invented. height/max-height are reset so
       the pill always hugs its own (tiny) content -- no leftover height,
       border or empty space can carry over from the expanded state. */
    .panel.collapsed{
      height:auto;max-height:none;
    }

    /* The instructional hint ("drag any node - click to open") is anchored
       right:36px;bottom:24px everywhere else, which is fine against the
       desktop panel (top:50%, out of the way entirely) but collides with
       the collapsed mobile pill -- both now share the same bottom-right
       corner, and the pill's own right edge (18px left margin + 200px
       width = 218px) sits well past where the hint's text starts. Found
       during this audit (not one of the originally reported bugs) rather
       than left as an unrelated visual bug: the hint moves to the right of
       the pill instead, at the pill's own bottom margin, wrapping onto
       multiple right-aligned lines within the remaining width rather than
       overlapping it. */
    /* bottom:14px matches the pill's own resting margin (panel.js's
       SNAP_MARGIN, the same inset as the other mobile corner controls);
       left is that margin + the pill's 200px + a 12px gap -- --safe-bottom
       stacks on top of it so the text still clears that same margin,
       now measured from above iOS's own bottom toolbar/home-indicator
       instead of from the literal (sometimes covered) edge of the
       screen. */
    .hint{left:226px;right:14px;bottom:calc(14px + var(--safe-bottom));top:auto;text-align:right;line-height:1.4;}

    /* That same fixed spot can collide with the FULL widget once it's
       expanded -- a widget with unusually tall content is capped at
       calc(100vh - 32px) and centered, so in the extreme case its own
       bottom edge lands within 16px of the viewport bottom too, right on
       top of this row. The hint is only ever meant to read against the
       small pill (see spec: "the small state should still show the
       instructional text"), so it fades out whenever the catalogue widget
       isn't collapsed, rather than trying to out-position a widget whose
       own height varies with its content. */
    .app:not(:has(.panel.collapsed)) .hint{opacity:0;pointer-events:none;}

    /* The site loads already collapsed on mobile (panel.js sets this
       synchronously, before first paint), so the entrance animation has to
       play against the collapsed pill's own resting spot instead of the
       expanded widget's -- a plain blur/fade + short rise, same feel as
       the desktop entrance, with no horizontal-centering transform since
       the pill isn't centered any more. It only touches `transform` (never
       left/top), so panel.js's own inline left/top for the collapsed
       position stay in full effect underneath it throughout. */
    @keyframes panel-appear{
      from{opacity:0;filter:blur(10px);transform:translateY(24px);}
      to{opacity:1;filter:blur(0);transform:translateY(0);}
    }

    /* The opened full project widget now covers/competes with the
       catalogue widget for the same narrow width, so it joins the
       existing blur-out treatment (desktop leaves .panel alone since
       there's room for both side by side; on mobile there isn't). */
    .app.case-open .panel{filter:blur(7px);opacity:.45;pointer-events:none;}

    /* Expanded catalogue widget (.panel-expanded, toggled by panel.js's
       positionMobilePanel() -- see its own comment): reuses the SAME
       "above everything, background blurred" treatment .case-panel gets
       when IT expands, rather than inventing a separate one.
       z-index:100 is literally .case-panel.expanded's own value (below,
       .case-panel/.case-panel.expanded), which is already how that widget
       wins over .layout-switcher's z-index:30 -- the exact bug reported
       here for the catalogue widget. The blur/dim values are copied
       verbatim from the .app.case-open rules just above/below (blur(7px),
       opacity .45, pointer-events:none), applied to whichever background
       is actually on screen -- .graph-wrap in Tree mode, .layout-overlay
       in Text/Column mode (only one is ever visible at a time; blurring
       the hidden one is a no-op). :not(.case-open) keeps this from ever
       fighting the case-open rules above if a project widget is opened
       while the catalogue widget still happens to be in its expanded
       state -- the actually-open project widget stays the one on top. */
    .app.panel-expanded:not(.case-open) .panel{z-index:100;}
    .app.panel-expanded:not(.case-open) .graph-wrap,
    .app.panel-expanded:not(.case-open) .layout-overlay{filter:blur(7px);opacity:.45;pointer-events:none;}

    /* Full project widget: a viewport-width card with side margins
       instead of the fixed 644px desktop width -- still the same
       element with its own drag handle, Expand/Shrink and Close
       buttons, unchanged (case-study.js's positionCaseStudy() centers
       it on mobile instead of anchoring it beside the catalogue
       widget; toggleCaseExpand()'s own expand math already reads the
       live viewport size, so it needs no separate mobile branch). */
    .case-panel{width:calc(100vw - 32px);}

    /* Text/Column overlay: the desktop padding-left biases the content
       toward clearing the panel that sits to ITS left (--ov-clear).
       That panel now lives at the bottom instead, so the bias is gone
       on mobile -- simple, symmetric side padding, still cleared
       vertically under the toggle by the padding-top above (unchanged). */
    /* padding-bottom:0 overrides the desktop rule's own inherited 32px
       (the old fixed value the original bug report was about) -- the pill
       clearance below fully replaces it rather than stacking on top of
       it, so the total gap stays exactly footprint + one section's worth
       of breathing room, not that plus a leftover flat 32px. */
    .layout-overlay{padding-left:16px;padding-right:16px;padding-bottom:0;}
    /* Bottom clearance for the collapsed catalogue pill: it's a
       position:fixed element (see .panel's mobile rules + panel.js) that
       floats over whatever this overlay is showing, so the LAST row of
       content needs enough padding underneath it to clear the pill and
       still read as one deliberate gap, not an overlap. --pill-footprint
       is the pill's own real measured distance from the viewport bottom
       (its rendered height + panel.js's 16px resting margin) -- kept live
       by positionMobilePanel() every time the pill is (re)placed, so this
       always matches the ACTUAL pill, not a guessed constant that could
       drift out of sync with its real size. --ov-section-gap (defined
       near --gap-l at the top of this file) adds exactly the same
       breathing room already used BETWEEN two sections (Design -> Lab) on
       top of that -- reusing the existing spacing system for the "extra"
       part rather than inventing a new number. */
    #layout-overlay-inner{padding-left:0;
      padding-bottom:calc(var(--pill-footprint, 0px) + var(--ov-section-gap));}

    /* Column format wraps to a single column -- items already fill in
       DOM order (column 1's items, then column 2's), so column-count:1
       alone preserves that same read order top-to-bottom, no markup
       changes needed. */
    .ov-grid.cols-2, .ov-grid.cols-3{column-count:1;}

    /* .ov-section/.ov-grid's own max-width:600px (desktop's fixed content
       column, see the .case-panel comment above) is still in effect here
       -- width:100% already resolves against #layout-overlay-inner's real
       width immediately on every resize, no lag, but max-width:600px kept
       clamping that 100% down to a flat 600px whenever the available
       width was anything more than 600px, which most mobile viewports
       in landscape or on a larger phone/small tablet still are. That's
       what actually produced the reported right-side gap and the
       "only fills in after resizing smaller" symptom: below roughly
       632px wide (600px + this padding) the cap stops binding and
       width:100% suddenly reads as the real, smaller available width, so
       it LOOKED like a delayed recalculation but was really just the cap
       still being wider than the viewport. Dropping the cap here removes
       it starting at the mobile breakpoint itself, so the content column
       fills the full available width immediately, not only once the
       viewport happens to be narrower than 600px too. */
    .ov-section, .ov-grid{max-width:none;}
  }

