  :root{
    --paper:#FDFDFD; --ink:#17181C; --accent:#B4371F;
    --sans:ui-monospace, 'SF Mono', 'JetBrains Mono', 'IBM Plex Mono', Menlo, Consolas, monospace;
    --header-h: 3.25rem;
  }
  *{ box-sizing:border-box; }
  html,body{ margin:0; scroll-behavior:smooth; overflow-x:hidden; overscroll-behavior:none; }

  /* pointer-following guide lines (ui-ux.html only) — full-length, 1px, same
     grey as the .hl grid rules. main.js only ever writes a transform to these,
     so their rest position (off-screen) never actually shows. */
  .cursor-guide-v, .cursor-guide-h{
    position:fixed;
    z-index:5;
    background:#dfe2e5;
    pointer-events:none;
    will-change:transform;
  }
  .cursor-guide-v{ top:0; left:0; bottom:0; width:1px; transform:translateX(-100vw); }
  /* stops at .hl-1 / .hl-4 (3% / 97%) rather than running edge to edge, so it
     never crosses the grid's outer verticals */
  .cursor-guide-h{ left:3%; top:0; right:3%; height:1px; transform:translateY(-100vh); }

  /* 4 long vertical lines across the home hero */
  .home-lines{ position:fixed; top:0; left:0; right:0; bottom:0; z-index:1; pointer-events:none; }
  .home-lines .hl{
    position:absolute;
    top:0; bottom:0;
    width:1px; height:100%;
    background:#dfe2e5;
  }
  .home-lines .hl-1{ left:3%; }    /* near left end */
  .home-lines .hl-2{ left:33%; }   /* between */
  .home-lines .hl-3{ left:67%; }   /* between */
  .home-lines .hl-4{ left:97%; }   /* near right end */
  /* dead centre — not part of the 3/33/67/97 grid, so it carries no plus marks.
     Ink rather than the grid's grey: it is a marker for the line the measure
     scale's numbers come to rest on, not another rule of the grid. */
  .home-lines .hl-mid{
    left:50%;
    background:var(--ink);
    bottom:auto;                    /* release .hl's full-height stretch */
    /* Runs down to the foot of the ruler. main.js shortens it from here as a
       number arrives on the line, so it clears the digits instead of striking
       through them, and lets it back down once the number has passed. This value
       is the resting length and what shows if the script never runs. */
    height:calc(83.5% + 2.1rem);
  }
  /* Sits just under the foot of that line, on the same 50% centre, apex up so it
     points at the line. Built from borders rather than a glyph so it stays crisp
     at any size: a zero-size box whose coloured bottom border tapers to a point
     between the two transparent side borders. Tied to the same calc as .hl-mid's
     height, so line and arrow move together if the ruler is repositioned. */
  .home-lines .mid-arrow{
    position:absolute;
    left:50%;
    top:calc(83.5% + 2.1rem + 0.7rem);
    transform:translateX(-50%);
    width:0; height:0;
    border-bottom:0.875rem solid var(--ink);
    border-left:0.5rem solid transparent;
    border-right:0.5rem solid transparent;
  }
  .home-lines .hlh{
    position:absolute;
    left:0; right:0;
    width:100%; height:1px;
    background:#dfe2e5;
  }
  .home-lines .hlh-1{ top:33%; }
  .home-lines .hlh-2{ top:67%; }
  /* The same grid on pages other than the home hero, drawn OVER the content:
     the lines and plus marks cross the contact boxes rather than passing behind
     them. z-index 1 clears .contact-inner, which creates a stacking context of
     its own via transform and so paints at the 0 level. pointer-events:none is
     already on .home-lines, so the boxes underneath stay clickable.
     No --dx / --dy on these plus marks: those only feed the home intro's
     fly-out, which never runs anywhere else. */
  .home-lines.page-lines{ z-index:1; }

  /* ui-ux only (keyed off .grid-fill, that page's own element, which sits just
     before .home-lines): the horizontal rules keep only their two end stubs,
     outside the verticals, and the whole run between them is dropped. Painted as
     a gradient rather than by resizing the element, because the line has to
     exist on BOTH sides of the gap and one element has only one left and one
     right edge. */
  .grid-fill ~ .home-lines .hlh{
    background:linear-gradient(to right,
      #dfe2e5 0 3%, transparent 3% 97%, #dfe2e5 97% 100%);
  }


  /* ===== Measure scale =====
     The reference is a vertical ruler; this is that turned on its side and then
     flipped, so the ticks rise from a shared bottom edge and the numbers sit
     above them rather than below. The track carries half a viewport of padding
     at each end (see main.js), so it rests with the 1 on the centre line and
     scrolls until the 30 lands there too.
     z-index 2 puts it just above .home-lines. The band has no background, so the
     grid still shows straight through the gaps between ticks — but a tick that
     lands on a grid line now wins, instead of being blanked by it. That matters
     most at the centre line, which the numbers come to rest on: below the grid,
     the 1 lost its tick to the very line marking where it had landed.
     Ticks are built by main.js — see the "Measure scale" block there. */
  .scale{
    position:fixed;
    left:0; right:0;
    z-index:2;
    /* 83.5% is the midpoint of row 3 (the 67% rule to the foot of the page), and
       -50% centres the whole band on it — numbers and ticks together, rather
       than standing the tick baseline on that line. */
    top:83.5%;
    transform:translateY(-50%);
    /* tick band plus the numbers above it: a 2.8rem major, the 0.55rem gap and
       the ~0.62rem digits come to 3.97rem, so this clears them. overflow-y is
       hidden, so anything taller than this would be cut off rather than spill. */
    height:4.2rem;
    overflow-x:auto;                 /* one number to the next is 1.5 grid cells,
                                        so 30 of them run far past the viewport */
    overflow-y:hidden;
    scrollbar-width:none;
    -ms-overflow-style:none;
  }
  .scale::-webkit-scrollbar{ display:none; }
  /* Ticks are placed absolutely rather than spaced by flex, so each one sits on
     an exact multiple of the pitch. A gap-based layout would instead accumulate
     each tick's own width along the run and drift off the 1.5-cell spacing. */
  .scale-track{ position:relative; height:100%; }
  .scale-tick{
    position:absolute; bottom:0;
    width:1px; height:1.1rem;
    background:#a8adb3;
    /* main.js scales the minor ticks vertically to build the arc around the
       centre line; growing from the baseline keeps their feet on it */
    transform-origin:bottom;
  }
  .scale-tick.is-major{
    /* taller than the arc's 2.35x peak on a 1.1rem minor (41px), so the numbered
       tick still reads as the tallest mark even at the middle of the swell */
    width:1px; height:2.8rem;
    background:var(--ink);
  }
  .scale-num{
    position:absolute;
    bottom:calc(100% + 0.55rem);     /* above the tick, which now grows upward */
    left:50%;
    transform:translateX(-50%);
    font-family:var(--sans);
    font-weight:300;
    font-size:0.62rem;
    line-height:1;
    color:var(--ink);
    opacity:0.75;
    white-space:nowrap;
  }
  /* ===== Boxes between the numbers =====
     One per gap, centred on the midpoint. They live in their own full-height
     layer rather than inside .scale: that band is only 4.2rem tall with
     overflow-y hidden, so anything reaching up into rows 1 and 2 would be cut
     off. The layer is translated by the ruler's scrollLeft each frame instead of
     scrolling itself, which keeps the two exactly in step with no second
     scrollbar. z-index 2 puts it above .home-lines (1), so the boxes cover the
     rules and plus marks rather than being crossed by them. */
  .scale-field{
    position:fixed; inset:0;
    overflow:hidden;
    pointer-events:none;
    z-index:2;
  }
  .scale-field-track{ position:absolute; inset:0; will-change:transform; }
  /* Square, and sized in vh rather than rem so it scales with the rows it has to
     sit inside. The rows are percentages of the viewport height, so a rem-sized
     box keeps its pixel height as the window shortens and eventually overruns
     row 1; at 28vh it always leaves the same 5vh of headroom. */
  .scale-box{
    position:absolute;
    width:28vh; height:28vh;
    margin-left:-14vh;               /* left is the box's centre, not its edge */
  }
  /* The box is one photograph cut out by a single path, not a row of slices.
     Slices could only ever approximate the wave as a staircase — each one has a
     flat top, so sixty of them give sixty little steps, visible however small
     the motion gets. A path bends continuously, so the edge is a true curve at
     any amplitude. overflow:visible lets the crest swell past the box's own
     bounds; main.js draws the picture past those bounds to meet it. */
  .scale-svg{
    position:absolute; inset:0;
    display:block;
    width:100%; height:100%;
    overflow:visible;
  }
  /* The box's border. Same curve as the picture inside it — main.js points both
     at one path — so it rides the wave and the curl rather than staying a
     straight rectangle around a bending photograph. Matches the 1px edge every
     other box on the site carries. stroke and fill are inherited properties, so
     setting them on the <use> reaches the shape it clones.
     Width is held at 1 device pixel by vector-effect (set in main.js), so the
     opening sheet does not arrive with a border scaled up six times over. */
  .scale-outline{
    fill:none;
    stroke:rgba(23,24,28,0.15);
    stroke-width:1;
  }
  /* The corner marks. A shade firmer than the box's own edge so they read as
     marks rather than as part of the frame, and held at 1 device pixel by
     vector-effect (set in main.js) so they stay hairlines whatever the box does.
     They sit outside the box, which .scale-svg's overflow:visible permits. */
  .scale-mark{
    fill:none;
    stroke:rgba(23,24,28,0.6);
    stroke-width:1.2;
    stroke-linecap:square;
  }
  /* The name of the piece the centre line is standing over, set under the
     arrow. Same calc as .mid-arrow's top, carried a little further down, so the
     three of them — ruler, arrow, name — stay stacked if the band is ever moved.
     Its own line across the full width and centred, rather than sized to the
     text, so a long name and a short one are both centred on the line without
     the box itself moving. */
  .scale-name{
    position:fixed;
    left:0; right:0;
    top:calc(83.5% + 2.1rem + 2.5rem);
    z-index:2;
    text-align:center;
    font-family:var(--sans);
    font-weight:400;
    font-size:0.72rem;
    letter-spacing:0.09em;
    text-transform:uppercase;
    color:var(--ink);
    white-space:nowrap;
    pointer-events:none;
    user-select:none;
  }
  /* Free horizontally, but only ever resting on one of three lines: the foot of
     row 1, the head of row 2, the foot of row 2.
     The +1px on the two bottom-anchored spots is a fill-rule correction, not a
     nudge. A box ending at 33% paints its last row at 33% - 1, while the rule
     itself paints AT 33%, so the border landed one row short of the line and the
     two read as a doubled edge with a seam. The top-anchored spot never had the
     problem: its first row is 33%, exactly where the rule is. */
  .scale-box.is-r1b{ top:calc(33% - 28vh + 1px); }
  .scale-box.is-r2t{ top:33%; }
  .scale-box.is-r2b{ top:calc(67% - 28vh + 1px); }

  /* the number currently on the centre line reads [7] rather than 7 */
  .num-bracket{ opacity:0; transition:opacity 0.18s ease; }
  .scale-tick.is-current .scale-num{ opacity:1; }
  .scale-tick.is-current .num-bracket{ opacity:1; }

  /* A single field filling columns 2 and 3 across all three rows — the six cells
     read as one shape rather than six. Bounded by .hl-2 (33%) and .hl-4 (97%) and
     the full viewport height, so its edges land exactly on the grid. z-index 0
     keeps it under .home-lines, which means the rules and plus marks still cross
     it instead of being buried. */
  .grid-fill{
    position:fixed;
    top:0; bottom:0;
    left:33%; right:3%;
    /* the same paper as the page: the field paints nothing now, and with the
       horizontal rules gone from that page it no longer shapes anything either.
       Kept only as a marker for the columns it covers. */
    background:var(--paper);
    z-index:0;
    pointer-events:none;
  }

  /* Work list on ui-ux.html — name on the left, tags/year on the right, in the
     same 33%-97% field as .grid-fill. Rows sit dim until hovered, the same
     emphasis-on-contact language as the rest of the site. */
  .work-list{
    position:fixed;
    left:3%; right:3%;
    top:50%;
    transform:translateY(-50%);
    z-index:2;
    padding:0 1.6rem;
    box-sizing:border-box;
  }
  .work-row{
    display:flex;
    align-items:baseline;
    justify-content:space-between;
    gap:1.5rem;
    padding:0.9rem 0;
    cursor:default;
    user-select:none;
  }
  .work-name{
    font-family:var(--sans);
    font-weight:300;
    font-size:0.9rem;
    color:rgba(23,24,28,0.4);
    white-space:nowrap;
    transition:color 0.2s ease;
  }
  .work-meta{
    font-family:var(--sans);
    font-weight:300;
    font-size:0.82rem;
    letter-spacing:0.01em;
    color:rgba(23,24,28,0.4);
    text-align:right;
    white-space:nowrap;
    transition:color 0.2s ease;
  }
  .work-row:hover .work-name,
  .work-row:hover .work-meta{ color:var(--ink); }

  /* centre-of-page logo shown while a work row is hovered — see .work-row's
     data-preview / .work-preview-img's data-name pairing in main.js */
  .work-preview{
    position:fixed;
    top:50%; left:50%;
    transform:translate(-50%, -50%);
    z-index:6;
    pointer-events:none;
  }
  .work-preview-img{
    position:absolute;
    top:50%; left:50%;
    transform:translate(-50%, -50%);
    max-width:220px;
    max-height:220px;
    width:auto; height:auto;
    opacity:0;
    transition:opacity 0.25s ease;
  }
  /* solid, edge-to-edge mark vs. GAir's line-art wordmark with room around it —
     same bounding box read as noticeably heavier, so it's scaled down to match
     visual weight rather than pixel dimensions */
  .work-preview-img[data-name="solevault"]{
    max-width:170px;
    max-height:170px;
  }
  .work-preview-img.is-active{ opacity:1; }

  /* ===== Intro grid-build (loader -> home) =====
     The grid lines draw out from center while the plus marks fly out from
     the center to their own intersections, then the page content reveals.
     Each plus carries its center offset in --dx / --dy (see index.html).    */
  /* Intro (JS / Web Animations API): the two horizontal lines collapse into a
     single line at center, spin a full 360°, then split apart to their rows —
     nothing fades away, the spinning line becomes the grid. The vertical lines
     and plus marks fly out from center meanwhile. Keep those hidden until the
     fly-out drives them (fallback; JS overrides). */
  body.grid-build .home-lines .hl,
  body.grid-build .home-lines .plus{ opacity:0; }

  /* paragraph in the left-middle grid cell (between 3%-33% x, 33%-67% y) */
  .home-blurb{
    position:fixed;
    left:3%; top:27.5%;
    width:30%; height:34%;
    box-sizing:border-box;
    padding:1.4rem 1.6rem;
    margin:0;
    display:flex; align-items:center;
    font-family:var(--sans);
    font-weight:300;
    font-size:0.82rem;
    line-height:1.7;
    color:var(--ink);
    z-index:2;
  }
  .home-blurb .blurb-text{ display:block; width:100%; }
  .home-blurb u{ white-space:nowrap; }   /* keep multi-word phrases on one line */
  /* in-blurb links ("Interested in collaborating?" -> contact, "UI/UX design" -> ui-ux)
     inherit the surrounding type so only the <u> underline marks them */
  .home-blurb a.collab-link,
  .home-blurb a.blurb-link{ color:inherit; text-decoration:none; cursor:pointer; }
  /* big letter C in row 1, column 3 (67%-97% x, 0-33% y) */
  .cell-c{
    position:fixed;
    left:67%; top:12%;
    width:30%; height:33%;
    display:flex; align-items:center; justify-content:flex-start;
    padding-left:1.6rem;
    box-sizing:border-box;
    font-family:var(--sans);
    font-weight:300;
    font-size:3.8vh;
    line-height:1;
    color:var(--ink);
    user-select:none;
    white-space:nowrap;
    z-index:2;
  }
  /* the "ONTACT" is collapsed (only "C" shows); it expands + fades in when
     hovering the collaborating line -> C becomes CONTACT */
  .cell-c-rest{
    /* Empty until hovered — main.js holds the text and scrambles it in, so there
       is no width or opacity to animate here. */
    overflow:hidden;
    white-space:nowrap;
  }
  /* letter A -> ABOUT ME on hover, row 3 column 1 (bottom-left) */
  .cell-a{
    position:fixed;
    left:34.5%; top:61%;
    display:flex; align-items:center;
    font-family:var(--sans);
    font-weight:300;
    font-size:3.8vh;
    line-height:1;
    color:var(--ink);
    white-space:nowrap;
    user-select:none;
    z-index:2;
  }
  .cell-a-rest{
    /* Empty until hovered — main.js holds the text and scrambles it in, so there
       is no width or opacity to animate here. */
    overflow:hidden;
    white-space:nowrap;
  }
  /* letter A -> AI/ML Engineering on hover, row 2 column 2 (center) */
  .cell-ai{
    position:fixed;
    left:34.5%; top:35%;
    display:flex; align-items:center;
    font-family:var(--sans);
    font-weight:300;
    font-size:3.8vh;
    line-height:1;
    color:var(--ink);
    white-space:nowrap;
    user-select:none;
    z-index:2;
  }
  .cell-ai-rest{
    /* Empty until hovered — main.js holds the text and scrambles it in, so there
       is no width or opacity to animate here. */
    overflow:hidden;
    white-space:nowrap;
  }
  /* letter J -> JEWELRY DESIGN on hover, row 3 column 3 (bottom-right) */
  .cell-j{
    position:fixed;
    left:68.5%; top:94%;
    display:flex; align-items:center;
    font-family:var(--sans);
    font-weight:300;
    font-size:3.8vh;
    line-height:1;
    color:var(--ink);
    white-space:nowrap;
    user-select:none;
    z-index:2;
  }
  .cell-j-rest{
    /* Empty until hovered — main.js holds the text and scrambles it in, so there
       is no width or opacity to animate here. */
    overflow:hidden;
    white-space:nowrap;
  }
  /* letter D -> DATA SCIENCE on hover, row 2 column 3 (right-aligned) */
  .cell-d{
    position:fixed;
    left:67%; top:46%;
    width:30.5%; height:34%;
    box-sizing:border-box;
    padding-right:1.6rem;
    display:flex; align-items:center; justify-content:flex-end;
    font-family:var(--sans);
    font-weight:300;
    font-size:3.8vh;
    line-height:1;
    color:var(--ink);
    white-space:nowrap;
    user-select:none;
    z-index:2;
  }
  .cell-d-rest{
    /* Empty until hovered — main.js holds the text and scrambles it in, so there
       is no width or opacity to animate here. */
    overflow:hidden;
    white-space:nowrap;
  }
  /* letter S -> SOFTWARE DEVELOPMENT on hover, row 1 column 1 (right-aligned) */
  .cell-s{
    position:fixed;
    left:3%; top:12%;
    width:30%; height:33%;
    box-sizing:border-box;
    padding-right:1.6rem;
    display:flex; align-items:center; justify-content:flex-end;
    font-family:var(--sans);
    font-weight:300;
    font-size:3.8vh;
    line-height:1;
    color:var(--ink);
    white-space:nowrap;
    user-select:none;
    z-index:2;
  }
  .cell-s-rest{
    /* Empty until hovered — main.js holds the text and scrambles it in, so there
       is no width or opacity to animate here. */
    overflow:hidden;
    white-space:nowrap;
  }
  /* the letter cells are display-only (triggered by hovering the text), so
     their boxes must not intercept hover on the paragraphs beneath them */
  .cell-c, .cell-a, .cell-ai, .cell-j, .cell-d, .cell-s, .cell-u{ pointer-events:none; }
  /* stacked U's -> UI (top) / UX DESIGN (bottom) on hover, row 3 column 1 */
  .cell-u{
    position:fixed;
    left:4.5%; top:90%;
    display:flex; flex-direction:column;
    font-family:var(--sans);
    font-weight:300;
    font-size:3.8vh;
    line-height:1.1;
    color:var(--ink);
    user-select:none;
    z-index:2;
  }
  .cell-u-line{ display:flex; align-items:center; white-space:nowrap; }
  .cell-u-rest{
    /* Empty until hovered — main.js holds the text and scrambles it in, so there
       is no width or opacity to animate here. */
    overflow:hidden;
    white-space:nowrap;
  }
  .home-blurb .ref{
    font-size:0.6em;
    vertical-align:super;
    line-height:0;
    margin-left:1px;
    color:var(--ink);
  }
  /* row 3, column 2 cell (33%-67% x, 67%-100% y) */
  .home-blurb-2{
    left:33%; top:75%;
    width:34%; height:25%;
    justify-content:flex-end;
    text-align:right;
  }
  /* column 3, row 3 cell (67%-97% x, 67%-100% y) */
  .home-blurb-3{
    left:67%; top:67%;
    width:30%; height:33%;
    align-items:flex-start;
    justify-content:flex-end;
    text-align:right;
  }
  /* emphasized "+" at each line intersection */
  .home-lines .plus{
    position:absolute;
    transform:translate(calc(-50% + 0.5px), calc(-50% + 0.5px));
    width:9px; height:9px;
    z-index:2;
    color:#b3b9bf;   /* arm color (via currentColor) — animated on intro */
  }
  .home-lines .plus::before,
  .home-lines .plus::after{
    content:"";
    position:absolute;
    background:currentColor;
  }
  .home-lines .plus::before{ left:0; right:0; top:50%; height:1.5px; transform:translateY(-50%); }
  .home-lines .plus::after{ top:0; bottom:0; left:50%; width:1.5px; transform:translateX(-50%); }
  body{
    background:var(--paper); color:var(--ink);
    font-family:var(--sans);
    font-weight:300;
    padding-top:var(--header-h);
  }
  .home-cluster{
    display:flex; flex-direction:row; align-items:center;
    gap:0.5rem;
    position:absolute;
    left:50%;
    margin-left:-2rem;   /* half the 4rem logo -> true horizontal center */
    margin-top:-0.35rem;
  }
  .header-logo{
    display:block;
    width:4rem; height:auto;
    margin-left:0;
  }
  .v-logo{
    position:relative;
    width:0.9rem; height:1.03rem;
    flex-shrink:0;
    margin-left:-0.7rem;
  }
  .v-logo .menu-label{
    position:absolute;
    left:50%;
    top:50%;
    transform:translate(-50%, -50%) scale(0.7);
    font-family:var(--sans);
    font-weight:300;
    font-size:0.7rem;
    line-height:0.85;
    letter-spacing:0.04em;
    text-transform:uppercase;
    text-align:center;
    color:var(--ink);
    white-space:nowrap;
    opacity:0;
    pointer-events:none;
    transition:opacity 0.25s ease, transform 0.25s ease;
  }
  .v-logo .menu-label span{
    display:block;
  }
  .v-logo:hover .menu-label{
    opacity:1;
    transform:translate(-50%, -50%) scale(1);
  }
  .v-logo .menu-lines{
    position:absolute;
    left:50%; top:50%;
    width:0.6rem;
    transform:translate(-50%, -50%) scale(0.7);
    display:flex;
    flex-direction:column;
    gap:6px;
    opacity:0;
    pointer-events:none;
    animation:v-swipe-lines 3.5s ease-in-out infinite;
  }
  .v-logo .menu-lines span{
    display:block;
    height:1.3px;
    background:#000;
  }
  .v-logo:hover .menu-lines{
    animation:none;
    opacity:0;
  }
  body.menu-open .v-logo .menu-lines{
    animation:none;
    opacity:0;
  }
  @keyframes v-swipe-lines{
    0%, 35%{ opacity:0; transform:translate(-50%, -50%) scale(0.7); }
    50%, 85%{ opacity:1; transform:translate(-50%, -50%) scale(1); }
    100%{ opacity:0; transform:translate(-50%, -50%) scale(0.7); }
  }
  .v-divider{
    position:fixed;
    top:0;
    width:1px;
    background:#fff;
    mix-blend-mode:difference;
    z-index:9996;
    pointer-events:none;
  }
  .v-logo .px{
    position:absolute;
    width:calc(100% / 7);
    height:calc(100% / 8);
    background:#000;
    transition:transform 0.35s cubic-bezier(.4,0,.2,1);
    animation:v-swipe-px 3.5s ease-in-out infinite;
  }
  .v-logo:hover .px{
    animation:none;
  }
  body.menu-open .v-logo .px{
    animation:none;
  }
  @keyframes v-swipe-px{
    0%, 35%{ transform:translate(0, 0) scale(1); }
    50%, 85%{ transform:translate(var(--dx), var(--dy)) scale(0.2); }
    100%{ transform:translate(0, 0) scale(1); }
  }
  .v-logo-close{
    position:absolute;
    left:50%; top:50%;
    width:5rem; height:2.4rem;
    transform:translate(-50%, -50%) scale(0.5);
    display:flex;
    align-items:center;
    justify-content:center;
    opacity:0;
    pointer-events:none;
    transition:opacity 0.25s ease, transform 0.25s ease;
  }
  .v-logo-close .close-text{
    font-family:var(--sans);
    font-weight:500;
    font-size:0.7rem;
    letter-spacing:0.01em;
    text-transform:uppercase;
    color:var(--ink);
    white-space:nowrap;
  }
  body.menu-open .v-logo .px{ opacity:0; }
  body.menu-open .v-logo .menu-label{ opacity:0 !important; }
  body.menu-open .v-logo-close{
    opacity:1;
    transform:translate(-50%, -50%) scale(1);
  }

  body.menu-open, body.menu-open *{
    cursor:url("../../assets/icons/mac-cursor.svg") 2 2, auto;
  }
  body.on-black-divider .cursor-box{
    display:none;
  }
  body.menu-open .cursor-box{
    display:none;
  }
  body.menu-open .menu-image-box:hover,
  body.menu-open .menu-image-box:hover *,
  body.menu-open .menu-scatter:hover,
  body.menu-open .menu-scatter:hover *,
  body.menu-open .v-logo:hover,
  body.menu-open .v-logo:hover *{
    cursor:url("../../assets/icons/mac-hand-cursor.png") 13 8, pointer;
  }
  .menu-overlay{
    position:fixed;
    inset:0;
    z-index:9990;
    background:rgba(23,24,28,0.4);
    backdrop-filter:blur(4px);
    -webkit-backdrop-filter:blur(4px);
    display:flex;
    align-items:center;
    justify-content:center;
    opacity:0;
    visibility:hidden;
    transition:opacity 0.3s ease, visibility 0.3s ease;
  }
  .menu-overlay.is-open{
    opacity:1;
    visibility:visible;
  }
  .menu-panel{
    width:100%;
    height:100%;
    background:var(--paper);
    border-radius:0;
    display:flex;
    align-items:center;
    justify-content:center;
    overflow:hidden;
    transform:scale(0.9);
    transition:transform 0.3s ease;
  }
  .menu-panel img{
    width:100%;
    height:100%;
    object-fit:contain;
    filter:contrast(1.08) saturate(1.15);
    transform:scale(1.8);
  }
  .menu-scatter{
    position:absolute;
    display:flex;
    flex-direction:column;
    align-items:center;
    gap:0.4rem;
  }
  .menu-scatter img{
    object-fit:contain;
    border-radius:0.15rem;
    box-shadow:0 8px 32px rgba(0,0,0,0.25);
    transition:transform 0.2s ease;
    transform-origin:center bottom;
  }
  .menu-scatter:hover img{
    transform:scale(1.15) !important;
    border:7px solid rgba(255,255,255,0.35);
    box-shadow:0 8px 32px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.5);
  }
  .menu-scatter-label{
    font-family:-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', Arial, sans-serif;
    font-weight:700;
    font-size:0.58rem;
    letter-spacing:0.02em;
    text-transform:uppercase;
    color:#fff;
    text-shadow:0 1px 3px rgba(0,0,0,0.6);
    white-space:nowrap;
    padding:0.22rem 0.55rem;
    border-radius:0.2rem;
    background:transparent;
    transition:background 0.2s ease;
  }
  .menu-money:hover .menu-scatter-label,
  .menu-cat1:hover .menu-scatter-label,
  .menu-cat2:hover .menu-scatter-label,
  .menu-dog:hover .menu-scatter-label{ background:#2A5FDA; }
  .menu-money{
    left:27%; top:28%;
  }
  .menu-money img{
    width:5.5rem; height:5.5rem;
    transform:none;
    filter:none;
  }
  .menu-cat1{
    right:39%; top:28%;
  }
  .menu-cat1 img{
    width:5.5rem; height:5.5rem;
    transform:none;
    filter:none;
  }
  .menu-cat2{
    left:44%; bottom:36%;
    transform:translateX(-50%);
  }
  .menu-cat2 img{
    width:auto; height:auto;
    max-width:8rem;
    max-height:8rem;
    transform:none;
    filter:none;
  }
  .menu-dog{
    right:26%; bottom:32%;
  }
  .menu-dog img{
    width:5.5rem; height:5.5rem;
    transform:none;
    filter:none;
  }
  .menu-image-box{
    position:absolute;
    left:50%; bottom:2.5rem;
    width:8.5rem; height:4rem;
    transform:translateX(-50%);
    border-radius:1rem;
    background:rgba(255,255,255,0.25);
    backdrop-filter:blur(16px);
    -webkit-backdrop-filter:blur(16px);
    box-shadow:0 8px 32px rgba(0,0,0,0.15);
    display:flex;
    align-items:center;
    justify-content:center;
    gap:1.6rem;
    overflow:hidden;
  }
  .menu-image-box img{
    height:1.75rem;
    width:auto;
    object-fit:contain;
  }
  .menu-overlay.is-open .menu-panel{
    transform:scale(1);
  }

  header{
    position:fixed; top:0; left:0; width:100%; z-index:9996;
    min-height:var(--header-h);
    display:flex; justify-content:space-between;
    align-items:flex-start;
    padding:0.6rem 2rem;
    background:transparent;
    box-sizing:border-box;
  }
  /* live cursor / fps readout — column 2, row 1, directly under the logo.
     Centred with left/right:0 rather than a transform, because .reveal animates
     translateY on this element and a transform here would override it. */
  .hud{
    position:fixed;
    /* the column-2 / row-1 cell: vertical lines at 33%/67%, row line at 33% */
    left:33%; right:33%;
    top:33%;
    margin-top:-1.9rem;          /* sit the row above the cell's bottom line */
    z-index:9995;
    /* X hugs the bottom-left corner, Y the bottom-right, FPS centred between them */
    display:flex; justify-content:space-between; align-items:flex-end;
    padding:0 1.5rem;            /* pulls X and Y inward, off the cell edges */
    font-family:var(--sans);
    font-size:0.72rem;
    font-weight:400;
    letter-spacing:0.06em;
    color:var(--ink);
    opacity:0.45;
    white-space:pre;
    pointer-events:none;
    user-select:none;
  }
  /* ===== Page transition =====
     The home grid's two outermost lines (.hl-1 at 3%, .hl-4 at 97%) pull inward
     until they meet, wiping the page to blank paper behind them; on the page you
     land on they push straight back out to 3% / 97% again.

     Each "line" is really the leading edge of a paper-coloured panel — the panel
     is what erases the page, the 1px rule is what you see moving. Drawn as
     ::before/::after on <html> rather than injected elements, so the wipe is
     already painted on the new page's FIRST frame and reopens on its own:
     nothing waits on main.js, and if JS never runs the page still uncovers. */
  html.pt{
    /* How far the panels sit off-centre at rest, as a share of their own width.
       100% parks each leading edge exactly on a page edge — the default, for any
       page with no grid to rest on. 94% parks them on 3% / 97% instead: the home
       grid's .hl-1 / .hl-4. The close picks its own via .pt-grid, which main.js
       sets at click time on any page that draws a grid; the open is set by the
       option block further down. */
    --pt-travel:100%;
  }
  html.pt-grid{ --pt-travel:94%; }

  html.pt::before,
  html.pt::after{
    content:'';
    position:fixed;
    top:0;
    /* the spare 1px makes the two halves overlap by exactly their rule width, so
       the two lines land on top of each other as one centred line when they meet
       — and no sub-pixel gap shows through on odd viewport widths */
    width:calc(50% + 1px);
    height:100%;
    /* The panels carry the grid's two horizontal rules across themselves, at the
       same 33% / 67% the real .hlh lines sit at. Those lines overhang past .hl-1
       and .hl-4, so without this the panels blank out their last 3% for the whole
       transition — the ends look snipped off and then snap back the instant the
       wipe clears. Painted on, the horizontal rules read as continuous and dead
       still throughout, and they line up frame-for-frame across the page swap,
       so there is nothing to give the seam away either.

       The +0.33px / +0.67px are not fudge factors. A percentage in
       background-position resolves against (height - image height), so plain 33%
       puts the rule at 0.33*(H-1) while the real .hlh sits at top:33% = 0.33*H —
       short by exactly p px, whatever the viewport height. Correcting it makes
       the two land on the same subpixel and therefore snap to the same device
       pixel; without it there is a visible 1px jog where panel meets page.
       (Hairlines as gradient colour stops instead of positioned layers do not
       survive the gradient rasteriser — one of the two bands drops out.) */
    background:
      linear-gradient(#dfe2e5, #dfe2e5) 0 calc(33% + 0.33px) / 100% 1px no-repeat,
      linear-gradient(#dfe2e5, #dfe2e5) 0 calc(67% + 0.67px) / 100% 1px no-repeat,
      var(--paper);
    background-origin:border-box;
    z-index:100000;
    pointer-events:none;
  }
  /* same 1px #dfe2e5 rule as .home-lines .hl, so the moving line is visibly the
     same line the home grid is drawn with */
  html.pt::before{ left:0;  border-right:1px solid #dfe2e5; }
  html.pt::after { right:0; border-left:1px solid #dfe2e5; }

  /* Leaving a page that draws a grid: the real end lines step aside while the
     moving ones are out, so you see one line travelling rather than a copy
     detaching from a stationary original. The arrival has its own version of
     this, further down, because the two options want opposite answers. */
  html.pt-grid .home-lines .hl-1,
  html.pt-grid .home-lines .hl-4{ opacity:0; }

  /* Arrived through the transition (e.g. the back button): the preloader is for
     first arrivals only — the two lines are the whole animation. These kill the
     loader before it can paint, since main.js runs behind a render-blocking CDN
     script and the wipe may already be retracting by the time it executes.
     main.js also adds body.loaded in the same case, which is what holds the page
     revealed once .pt-open is dropped. */
  html.pt-open .loader{ display:none; }
  html.pt-open .reveal{ opacity:1; transform:none; transition:none; }

  /* leaving: pull the two lines together — easeInQuart, so they barely creep at
     first and then rush the last stretch into the middle */
  html.pt-close::before{ animation:pt-close-l 780ms cubic-bezier(0.5, 0, 0.75, 0) forwards; }
  html.pt-close::after { animation:pt-close-r 780ms cubic-bezier(0.5, 0, 0.75, 0) forwards; }
  /* ===== ARRIVAL: TWO OPTIONS, PICK ONE =====
     The pull inward is the same either way. This is only about how far the two
     lines push back out once you land, and what happens to them there.

     Option 1 — they stop on the grid. The lines travel to 3% / 97% and come to
       rest exactly on .hl-1 / .hl-4, so the moving line BECOMES the page's end
       line. The real ones are held out of sight underneath for the trip and the
       moving rule fades over the last 30% of it, which is what makes the handover
       invisible — without the fade the line blinks off when the class is dropped.

     Option 2 — they run off the page. The lines carry on past the grid, out to
       the far left and right edges, and leave. There is nothing to hand over to,
       so the real end lines stay visible the whole time and are simply uncovered
       as the panels sweep past them, and the moving rule keeps its colour right
       up to the moment it goes over the edge.

     TO SWITCH THE DEFAULT: comment out the live block below and uncomment the
     other. Every arrival that does not name an option for itself reads these
     three. To give one page its own answer instead, put .pt-edge-in on its
     <html> and it takes option 2 whatever the default is — that is how
     jewelry.html gets it while the rest of the site stays on option 1.

     The close is a separate choice, since a page can want one and not the other:
     .pt-edge-out on the <html> makes the lines pull in from the page edges when
     you leave. main.js reads it at click time and withholds .pt-grid. jewelry
     carries both, so its lines live at the edges in both directions; contact
     carries only -out, so it is arrived at on the grid and left from the edges. */
  html.pt-open{
    /* ---- Option 1: stop on the grid (live default) ---- */
    --pt-travel:94%;            /* how far out the lines go: 94% = 3% / 97% */
    --pt-ends:0;                /* the real .hl-1 / .hl-4 while the wipe is out */
    --pt-rest:transparent;      /* what the moving rule ends up as */

    /* ---- Option 2: run off the page ----
    --pt-travel:100%;
    --pt-ends:1;
    --pt-rest:#dfe2e5;
    */
  }
  /* Per-page opt-in to option 2 on arrival. Written into the page's own <html>
     rather than added by script, so it is there on the very first frame — the
     same reason the arrival class itself is set inline. */
  html.pt-edge-in.pt-open{
    --pt-travel:100%;
    --pt-ends:1;
    --pt-rest:#dfe2e5;
  }
  html.pt-open .home-lines .hl-1,
  html.pt-open .home-lines .hl-4{ opacity:var(--pt-ends); }

  /* arriving: they start met in the middle and push back out — easeOutQuart, the
     exact mirror of the close: they break away fast and coast to their rest
     position. The fade is a second animation rather than a 70% stop inside
     pt-open-*, so the eased transform stays one uninterrupted 0->100% interval
     with no stutter mid-flight. On option 2 it resolves to the colour the rule
     already is, so it is there but does nothing. */
  html.pt-open::before{ animation:pt-open-l 900ms cubic-bezier(0.25, 1, 0.5, 1) forwards,
                                  pt-fade-l 900ms linear forwards; }
  html.pt-open::after { animation:pt-open-r 900ms cubic-bezier(0.25, 1, 0.5, 1) forwards,
                                  pt-fade-r 900ms linear forwards; }

  /* Percentages all the way, never vw — this way the travel resolves against the
     same containing block .home-lines uses and can't drift by the scrollbar
     width. At 94% a (50% + 1px) panel parks its leading edge on 3% / 97% to
     within 0.06px; at 100% it parks exactly on the page edge. */
  @keyframes pt-close-l{ from{ transform:translateX(calc(var(--pt-travel) * -1)); } to{ transform:translateX(0); } }
  @keyframes pt-close-r{ from{ transform:translateX(var(--pt-travel)); } to{ transform:translateX(0); } }
  @keyframes pt-open-l { from{ transform:translateX(0); } to{ transform:translateX(calc(var(--pt-travel) * -1)); } }
  @keyframes pt-open-r { from{ transform:translateX(0); } to{ transform:translateX(var(--pt-travel)); } }

  @keyframes pt-fade-l{ 0%, 70%{ border-right-color:#dfe2e5; } 100%{ border-right-color:var(--pt-rest); } }
  @keyframes pt-fade-r{ 0%, 70%{ border-left-color:#dfe2e5;  } 100%{ border-left-color:var(--pt-rest);  } }

  /* Set by main.js at click time when the link points at the contact page, so
     only that one closing wipe is slowed. It has to be a class on the page being
     left rather than a rule on contact's own stylesheet — the close finishes
     before the destination has loaded, so contact never gets to speak for it. */
  html.pt-slow.pt-close::before{ animation:pt-close-l 1120ms cubic-bezier(0.5, 0, 0.75, 0) forwards; }
  html.pt-slow.pt-close::after { animation:pt-close-r 1120ms cubic-bezier(0.5, 0, 0.75, 0) forwards; }

  .h-bottom-line{
    position:fixed;
    left:0;
    width:100%;
    height:1px;
    background:#fff;
    mix-blend-mode:difference;
    z-index:9996;
    pointer-events:none;
  }
  header a{ color:var(--ink); text-decoration:none; }
  .home-link{
    font-family:var(--sans), 'Noto Sans Khmer', 'Noto Sans Thai', sans-serif;
    font-weight:400;
    color:var(--ink);
    font-size:0.8rem;
    letter-spacing:0.02em;
    width:16rem;
    height:1.8rem;
    margin-left:1.8rem;
    margin-top:-0.3rem;
    display:flex;
    align-items:center;
    justify-content:flex-start;
    text-align:left;
    white-space:nowrap;
    overflow:hidden;
  }
  .home-link:lang(km), .home-link:lang(th){
    font-size:0.88rem;
  }
  .time-badge{
    justify-self:end;
    margin-right:1.7rem;
    margin-top:0.15rem;
    display:inline-flex; align-items:center; gap:0.5rem;
    color:var(--ink);
    font-family:var(--sans);
    font-size:0.8rem;
    font-weight:500;
    letter-spacing:0.03em;
    text-transform:uppercase;
    white-space:nowrap;
  }
  .time-badge .dot{
    width:8px; height:8px; border-radius:50%;
    background:var(--ink);
    flex-shrink:0;
    animation:blink-dot 1s ease-in-out infinite;
  }
  @keyframes blink-dot{
    0%, 100%{ opacity:1; }
    50%{ opacity:0; }
  }
  nav{ grid-column:2; display:flex; gap:2.5rem; font-size:0.7rem; font-weight:200; letter-spacing:0.03em; text-transform:uppercase; }
  nav a{ opacity:0.6; transition:opacity 0.15s; }
  nav a:hover, nav a.current{ opacity:1; }
  nav a::before{ content:'['; opacity:0; transition:opacity 0.15s; }
  nav a::after{ content:']'; opacity:0; transition:opacity 0.15s; }
  nav a:hover::before, nav a:hover::after{ opacity:1; }

  .cursor-box{
    position:fixed;
    top:0; left:0;
    width:12px; height:12px;
    background:#fff;
    mix-blend-mode:difference;
    border-radius:50%;
    pointer-events:none;
    z-index:9998;
    display:flex; align-items:center; justify-content:center;
    transform:translate(-50%, -50%);
    transition:width 0.25s ease, height 0.25s ease, border-radius 0.25s ease, background 0.25s ease;
  }
  .cursor-box.is-viewing{
    width:5rem; height:2rem;
    border-radius:0;
    background:var(--paper);
    border:1px solid var(--ink);
    gap:0.4rem;
    mix-blend-mode:normal;
  }
  .cursor-label, .cursor-arrow{
    opacity:0;
    font-family:var(--sans);
    font-weight:400;
    font-size:0.7rem;
    letter-spacing:0.04em;
    text-transform:uppercase;
    color:var(--ink);
    transition:opacity 0.2s ease;
  }
  .cursor-box.is-viewing .cursor-label,
  .cursor-box.is-viewing .cursor-arrow{ opacity:1; }

  .cursor-box.is-menu{
    width:1.7rem; height:1.7rem;
    background:transparent;
    border-radius:0;
    mix-blend-mode:normal;
  }
  .cursor-corner{
    position:absolute;
    width:5px; height:5px;
    border-radius:50%;
    background:#000;
    opacity:0;
    transition:opacity 0.2s ease;
  }
  .cursor-box.is-menu .cursor-corner{ opacity:1; }
  .cursor-corner.tl{ top:-4px; left:-4px; }
  .cursor-corner.tr{ top:-4px; right:-4px; }
  .cursor-corner.bl{ bottom:-4px; left:-4px; }
  .cursor-corner.br{ bottom:-4px; right:-4px; }

  .hero-greet{
    position:relative;
    z-index:1;
    margin:0;
    align-self:flex-start;
    width:100%;
    margin-top:-1.2rem;
    margin-bottom:90vh;
    padding-left:clamp(1.25rem, 3.7vw, 4rem);
    font-family:var(--sans), 'Noto Sans Khmer', 'Noto Sans Thai', sans-serif;
    font-size:clamp(0.6rem, 1.25vw, 1rem);
    font-weight:400;
    letter-spacing:0.04em;
    color:var(--ink);
    text-align:left;
    white-space:nowrap;
    height:2.6rem;
    line-height:2.6rem;      /* fixed line box so scripts don't shift the layout */
  }
  .hero-greet > span{
    display:inline-block;
    vertical-align:middle;   /* keep every script centered in the fixed line box */
    line-height:1.15;
  }
  /* Latin-script phrases render in the mono font, which looks larger than the
     Khmer/Thai/CJK fallbacks at the same size — shrink them to match. */
  #heroGreet:lang(en),
  #heroGreet:lang(es),
  #heroGreet:lang(fr),
  #heroGreet:lang(vi){ font-size:0.82em; }
  .hero{
    position:sticky; top:0;
    z-index:0;
    isolation:isolate;
    background:var(--paper);
    min-height:calc(100vh - var(--header-h));
    display:flex; flex-direction:column; align-items:center; justify-content:center;
    gap:2rem;
    overflow:hidden;
    padding:2rem;
  }
  a.btn{
    text-decoration:none;
    font-size:0.7rem;
    font-weight:300;
    letter-spacing:0.05em;
    text-transform:uppercase;
    padding:0.9em 1.6em;
    border:1px solid var(--ink);
    color:var(--ink);
    transition:background 0.15s, color 0.15s;
  }
  a.btn:hover{ background:var(--accent); border-color:var(--accent); color:var(--paper); }

  .projects{
    position:relative;
    z-index:1;
    min-height:calc(150vh - var(--header-h));
    background:var(--paper);
    display:flex; flex-direction:column; align-items:center; justify-content:flex-start;
    gap:0.25rem;
    padding:0.5rem 2rem 6rem;
    border-top:1px solid rgba(23,24,28,0.1);
  }
  .projects h2{
    align-self:flex-start;
    font-family:var(--sans);
    font-weight:300;
    text-transform:uppercase;
    letter-spacing:0.02em;
    font-size:clamp(1.8rem, 4.5vw, 3rem);
    opacity:0.9;
    margin:3.5rem 0 0 4rem;
    flex-shrink:0;
  }
  .projects-grid{
    width:100%;
    max-width:650px;
    max-height:480px;
    margin-top:4rem;
    flex:1;
    min-height:0;
    display:grid;
    grid-template-columns:repeat(12, 1fr);
    grid-template-rows:repeat(12, 1fr);
    gap:0.2rem;
  }
  .project-box{
    position:relative;
    border:2px solid rgba(23,24,28,0.3);
    padding:1.5rem;
    display:flex; flex-direction:column; justify-content:space-between;
    color:var(--ink);
    background:transparent;
    opacity:0.6;
    text-decoration:none;
    box-shadow:0 8px 20px rgba(23,24,28,0.06);
    transition:background 0.2s, transform 0.2s, box-shadow 0.2s, opacity 0.2s;
  }
  .corner-dot{
    position:absolute;
    width:6px; height:6px;
    border-radius:50%;
    background:currentColor;
    pointer-events:none;
  }
  .corner-dot.tl{ top:-4px; left:-4px; }
  .corner-dot.tr{ top:-4px; right:-4px; }
  .corner-dot.bl{ bottom:-4px; left:-4px; }
  .corner-dot.br{ bottom:-4px; right:-4px; }
  .project-box:nth-child(1){ grid-column:1/8; grid-row:1/7; transform:rotate(-1.4deg); z-index:3; }
  .project-box:nth-child(2){ grid-column:7/13; grid-row:1/5; transform:rotate(1.1deg) translateY(0.75rem); z-index:2; }
  .project-box:nth-child(3){ grid-column:2/6; grid-row:6/13; transform:rotate(1deg) translateY(-0.5rem); z-index:1; }
  .project-box:nth-child(4){ grid-column:6/13; grid-row:5/13; transform:rotate(-0.7deg); z-index:2; }
  a.project-box:hover{
    background:var(--ink);
    color:var(--paper);
    opacity:1;
    transform:rotate(0deg) scale(1.03);
    z-index:10;
    box-shadow:0 14px 28px rgba(23,24,28,0.14);
  }
  a.project-box:hover .num,
  a.project-box:hover .title{ color:var(--paper); }
  a.project-box:nth-child(1):hover{
    background:#33FF57;
    color:var(--ink);
  }
  a.project-box:nth-child(1):hover .num,
  a.project-box:nth-child(1):hover .title{ color:var(--ink); }
  a.project-box:nth-child(2):hover{
    background:#FFC300;
    color:var(--ink);
  }
  a.project-box:nth-child(2):hover .num,
  a.project-box:nth-child(2):hover .title{ color:var(--ink); }
  a.project-box:nth-child(3):hover{
    background:#3357FF;
    color:var(--paper);
  }
  a.project-box:nth-child(3):hover .num,
  a.project-box:nth-child(3):hover .title{ color:var(--paper); }
  a.project-box:nth-child(4):hover{
    background:#FF5733;
    color:var(--paper);
  }
  a.project-box:nth-child(4):hover .num,
  a.project-box:nth-child(4):hover .title{ color:var(--paper); }
  .project-box.is-placeholder{ opacity:0.4; }
  .project-box .num{
    font-family:var(--sans);
    font-weight:400;
    font-size:0.75rem;
    letter-spacing:0.03em;
    color:var(--ink);
    transition:color 0.2s;
  }
  .project-box .title{
    font-family:var(--sans);
    font-weight:400;
    font-size:clamp(1.2rem, 3vw, 1.7rem);
    color:var(--ink);
    transition:color 0.2s;
  }

  @media (max-width:600px){
    .projects-grid{
      grid-template-columns:1fr;
      grid-template-rows:none;
      gap:0.75rem;
    }
    .project-box{
      grid-column:auto !important;
      grid-row:auto !important;
      transform:none !important;
    }
  }

  .black-divider{
    position:relative; z-index:2;
    min-height:100vh;
    background:#000;
    display:flex;
    align-items:center;
    justify-content:center;
    overflow:hidden;
  }
  .black-divider .cursor-circle{
    position:absolute;
    left:0; top:0;
    width:16px; height:16px;
    margin:-8px 0 0 -8px;
    border-radius:50%;
    background:#fff;
    pointer-events:none;
    z-index:2;
    opacity:0;
    transition:opacity 0.15s ease-out;
  }
  .dot-grid{
    position:absolute;
    inset:0;
    z-index:0;
    width:100%; height:100%;
    display:block;
    pointer-events:none;
  }
  .black-divider span{
    position:absolute;
    top:1.5rem;
    left:3.5rem;
    z-index:1;
    color:#fff;
    font-family:'Press Start 2P', var(--sans);
    font-weight:700;
    letter-spacing:0.06em;
    font-size:clamp(1.1rem, 2.5vw, 1.6rem);
    text-transform:uppercase;
  }
  .black-divider span .lead{
    font-size:1em;
  }
  .about{
    position:relative; z-index:2;
    min-height:calc(160vh - var(--header-h));
    display:flex; flex-direction:column; justify-content:flex-start;
    background:#EBFF00; color:var(--ink);
    padding:0 2rem 4rem;
  }
  .about h2{
    position:relative;
    z-index:1;
    pointer-events:none;
    color:#fff;
    mix-blend-mode:difference;
    font-family:'Silkscreen', var(--sans);
    font-weight:400;
    text-transform:uppercase;
    font-size:clamp(6rem, 34vw, 24rem);
    letter-spacing:0.03em;
    margin:-5.5rem 0 1.25rem;
    text-align:center;
    transform:translateX(0.5rem);
  }
  .cartoon-eyes{
    position:absolute;
    z-index:2;
    top:calc(50% + 4rem); left:50%;
    transform:translate(-50%, -50%);
    display:flex;
    flex-direction:column;
    align-items:center;
    gap:1rem;
    pointer-events:none;
  }
  .cartoon-eyes .eyes-row{
    display:flex;
    align-items:center;
    justify-content:center;
    gap:1.1rem;
  }
  .cartoon-eyes .caption-row{
    display:flex;
    align-items:center;
    gap:1.5rem;
    transform:translateX(4rem);
  }
  .cartoon-eyes .woody-img{
    width:5rem;
    height:6.8rem;
    object-fit:contain;
    flex-shrink:0;
    transform:translate(-0.75rem, -0.2rem);
  }
  .cartoon-eyes .eyes-caption{
    font-family:'Boogaloo', var(--sans);
    font-size:3.2rem;
    font-weight:400;
    line-height:1.20;
    text-align:center;
    color:var(--ink);
    margin:0;
    white-space:nowrap;
  }
  .cartoon-eyes .eye{
    position:relative;
    width:3.5rem;
    height:4.75rem;
    background:#fff;
    border-radius:50% / 60%;
    transform:rotate(-6deg);
    overflow:hidden;
    transition:transform 0.12s ease;
  }
  .cartoon-eyes .eye:last-child{
    transform:rotate(6deg);
  }
  .cartoon-eyes .eye.is-blinking{
    transform:rotate(-6deg) scaleY(0.08);
  }
  .cartoon-eyes .eye:last-child.is-blinking{
    transform:rotate(6deg) scaleY(0.08);
  }
  .cartoon-eyes .pupil{
    position:absolute;
    left:50%; top:50%;
    width:1.3rem;
    height:1.3rem;
    background:#000;
    border-radius:50%;
    transform:translate(-50%, -50%);
    will-change:transform;
  }
  .about-divider{
    position:relative;
    z-index:1;
    pointer-events:none;
    width:auto;
    height:1px;
    background:#fff;
    mix-blend-mode:difference;
    /* full-bleed by escaping the section's 2rem padding — scrollbar-safe (no 100vw) */
    margin:16vh -2rem 2rem;
  }
  .about p{
    font-size:0.85rem;
    font-weight:300;
    line-height:1.7;
    opacity:0.65;
    max-width:60ch;
    margin:3rem 0 0;
  }
  .tag-cloud{
    position:absolute;
    top:0; bottom:0;
    left:0;
    width:100%;
    z-index:0;
    overflow:hidden;
  }
  .tag-badge{
    position:absolute;
    top:0; left:0;
    display:inline-flex;
    align-items:center;
    gap:0.45rem;
    white-space:nowrap;
    cursor:grab;
    touch-action:none;
    user-select:none;
    font-family:var(--sans);
    font-size:0.85rem;
    font-weight:500;
    text-transform:uppercase;
    letter-spacing:0.05em;
    color:var(--ink);
    background:transparent;
    border:1px solid rgba(23,24,28,0.35);
    border-radius:999px;
    padding:0.65rem 1.1rem;
    box-shadow:0 6px 14px rgba(23,24,28,0.18);
    will-change:transform;
    transition:background 0.15s ease, color 0.15s ease;
  }
  .tag-badge:hover{
    background:var(--ink);
    color:#EBFF00;
  }
  .tag-badge svg{
    width:1rem;
    height:1rem;
    flex-shrink:0;
  }
  .tag-badge.is-circle{
    width:3.2rem;
    height:3.2rem;
    padding:0;
    border-radius:50%;
    justify-content:center;
  }
  .tag-badge.is-circle svg{
    width:1.35rem;
    height:1.35rem;
  }
  .tag-badge.is-dragging{
    box-shadow:0 18px 30px rgba(23,24,28,0.32);
    z-index:80;
    cursor:grabbing;
  }
  .contact{
    position:relative; z-index:3;
    min-height:calc(100vh - var(--header-h));
    display:flex; flex-direction:column; justify-content:center;
    background:var(--paper); color:var(--ink);
    border-top:1px solid rgba(23,24,28,0.1);
    padding:4rem 2rem;
  }
  /* translateY is the knob for the whole block's vertical position — it moves the
     progress bar and the boxes together without changing layout, so the row's
     fixed height keeps doing its job. Less negative = lower on the page. */
  .contact-inner{ max-width:100%; margin:0; transform:translateY(calc(-4.25rem + 0.5px)); }
  .contact h2.contact-marquee{
    margin:auto -2rem -4rem;
    overflow:hidden;
    width:auto;
  }
  .contact-marquee-track{
    display:inline-flex;
    width:max-content;
    white-space:nowrap;
    will-change:transform;
  }
  .contact-marquee-track span{
    display:inline-block;
    padding:0 0.6rem;
    font-family:var(--sans);
    /* weight 300 and no stroke, so this renders identically to the home page's
       type (.home-blurb / .cell-*) — same family, same weight. It was always the
       same family; the 900 weight and the 3.5px stroke were what made it read as
       a different typeface. Size, spacing and the marquee itself are unchanged. */
    font-weight:300;
    text-transform:uppercase;
    font-size:clamp(2.5rem, 8vw, 6rem);
    letter-spacing:0.01em;
  }
  .contact-progress{
    /* Pinned to the middle of the grid's row 1 / column 2 cell: row 1 spans
       0-33% so its centre is 16.5vh, and the cell spans 33-67% horizontally,
       whose centre is the page centre. translateY(-50%) puts the bar's own
       centre on that line rather than its top edge. */
    position:fixed;
    top:16.5vh;
    left:0; right:0;
    transform:translateY(-50%);
    z-index:4;
    display:flex;
    align-items:center;
    justify-content:center;
    gap:0.5rem;
    margin:0;
    pointer-events:none;
  }
  .contact-progress .tick{
    width:2px;
    height:1rem;
    background:currentColor;
    opacity:0.3;
    border-radius:1px;
    transition:width 0.2s ease-out, opacity 0.2s ease-out, background 0.2s ease-out;
  }
  .contact-progress .tick.active{
    width:2.4rem;
    height:1rem;
    background:none;
    border:1px solid currentColor;
    opacity:1;
  }
  /* ===== Contact boxes intro =====
     While the intro plays the boxes are dot-sized, and a 1px border scaled down
     to a few percent is invisible — so each one is painted as solid ink to read
     as a dot at all, with its contents and label held back until it has grown.
     !important because several boxes set their own background further down. */
  .contact-boxes.is-intro .contact-box{
    opacity:var(--intro-alpha, 1);
    transition:none !important;
  }
  /* Every box, the info one included, resolves out of ink — that is what makes
     them read as one dot to begin with. The info box was excluded from this for
     a while because its labels and values appeared to change colour as the ink
     lifted; that turned out to be the per-character on-circle test being re-run
     against a box mid-transform, and it is fixed at the source now (main.js
     holds the settled assignment for the duration), so the ink is safe here. */
  .contact-boxes.is-intro .contact-box{
    /* An inset shadow rather than a background, so it lays ink OVER whatever the
       box already paints — the Instagram gradient included — instead of replacing
       it. Both values are driven per frame from JS and land exactly on the box's
       normal appearance (alpha 0 ink, 0.15 border), so when .is-intro is finally
       dropped nothing changes and there is no pop. */
    box-shadow:inset 0 0 0 9999px rgba(23, 24, 28, var(--intro-ink, 1));
    border-color:rgba(23, 24, 28, calc(0.15 + 0.85 * var(--intro-ink, 1)));
  }
  /* the contents fade up as the ink lifts, so a box arrives at its own colours
     rather than switching to them */
  .contact-boxes.is-intro .contact-box > *{ opacity:var(--intro-show, 0); }
  /* the label's settled opacity is 0.55, so it has to fade TO that, not to 1 —
     driving it to 1 left it too dark and it jumped when the intro released */
  .contact-boxes.is-intro .contact-box::before{ opacity:calc(0.55 * var(--intro-show, 0)); }

  .contact-boxes{
    --box-w: 42rem;
    /* --box-shrunk is the width the boxes take once scrolling shrinks them. Both
       the box width and the row's end padding are written out from these two
       constants rather than routed through a variable that changes: Chrome will
       not re-resolve a transitioned property when the var() behind it changes,
       so width/padding simply never updated when that was tried. */
    --box-shrunk: calc(42rem * 0.7);
    --box-gap: 1.5rem;
    display:flex;
    align-items:center;
    /* Constant row height so nothing above it ever moves. The row is
       align-items:center, so the boxes still grow, shrink and slide exactly as
       before — they now centre inside a fixed-height row instead of resizing it.
       Without it the row's height followed whichever box was tallest, so it
       collapsed as the boxes shrank on scroll and dragged the progress bar down
       the page with it. Sized to fit the boxes exactly at their largest — 25rem
       min-height + 2px border + this row's own 2.25rem/0.5rem padding — so there
       is no dead space in the row, while still staying constant when the boxes
       shrink to 17.5rem on scroll. */
    height:calc(34vh + 1px + 2.75rem);
    gap:var(--box-gap);
    margin:3.5rem -2rem 0;
    padding-top:2.25rem;
    padding-bottom:0.5rem;
    /* no -2rem here: the row's own -2rem side margins already cancel .contact's
       2rem padding, so it spans the full viewport and subtracting again pushed
       every box 32px left of centre */
    padding-left:max(0px, calc((100vw - var(--box-w)) / 2));
    padding-right:max(0px, calc((100vw - var(--box-w)) / 2));
    overflow-x:auto;
    scrollbar-width:none;
    scroll-snap-type:none;
    /* padding rides along with the shrink so the end padding stays in step with
       the box width instead of snapping to its new value in one frame */
    transition:transform 0.4s ease-out, padding 0.4s ease-out;
  }
  .contact-boxes::-webkit-scrollbar{ display:none; }
  .contact-boxes.is-scrolled{
    /* No translateY here any more. The 2.5rem drop existed to offset the row
       getting shorter as the boxes shrank, which used to lift them; now the row
       has a fixed height and the boxes centre inside it, so nothing lifts and
       that offset showed up as the boxes sitting ~40px below centre once you
       started scrolling. */
    /* end padding follows the shrunken box width, so the first and last box can
       still reach dead centre. Left at the full-size value it fell ~100px short
       and every box sat left of centre from 20px of scroll onward. */
    padding-left:max(0px, calc((100vw - var(--box-shrunk)) / 2));
    padding-right:max(0px, calc((100vw - var(--box-shrunk)) / 2));
  }
  .contact-box{
    position:relative;
    background:var(--paper);
    color:var(--ink);
    text-decoration:none;
    display:flex; align-items:center;
    flex-shrink:0;
    width:var(--box-w);
    /* Every box spans the grid rows — 34vh is the gap between the .hlh lines at
       33% and 67%, and the +1px lands the bottom border ON the 67% line rather
       than one row short of it (a box spanning [a,b] paints its last row at b-1,
       while a 1px line at b paints AT b). Applied in both states so the info box
       lines up too: it is only ever centred at scrollLeft 0, where .is-scrolled
       is off, so a shrunken-only rule never reached it.

       A definite height, not min-height: the artwork inside is capped with
       max-height:100%, and a percentage cannot resolve against a parent whose
       height is driven by that same content — so with min-height the caps were
       ignored and the taller marks still pushed their box off the grid. */
    height:calc(34vh + 1px);
    padding:3.25rem;
    border:1px solid rgba(23,24,28,0.15);
    font-family:var(--sans);
    font-size:2.3rem;
    font-weight:400;
    letter-spacing:0.03em;
    text-transform:uppercase;
    transition:background 0.15s, color 0.15s, width 0.4s ease-out, min-height 0.4s ease-out, padding 0.4s ease-out, font-size 0.4s ease-out;
  }
  .contact-box[data-label]::before{
    content:attr(data-label);
    position:absolute;
    bottom:100%; left:0;
    margin-bottom:0.6rem;
    font-size:0.78rem;
    font-weight:400;
    letter-spacing:0.05em;
    text-transform:uppercase;
    color:var(--ink);
    opacity:0.55;
  }
  .contact-boxes.is-scrolled .contact-box{
    width:var(--box-shrunk);
    padding:2.28rem;
    font-size:1.61rem;
  }
  .contact-info{
    position:relative;
    overflow:hidden;
    flex-direction:column; align-items:stretch; justify-content:center;
    gap:0;
    font-size:1.1rem;
    font-weight:400;
    letter-spacing:0;
    text-transform:none;
    opacity:1;
    border-top:1px solid rgba(23,24,28,0.2);
  }
  .info-circle{
    position:absolute;
    top:50%;
    /* rest position (matches what the scroll handler computes at scrollLeft 0:
       diameter 30rem, left -radius) so the very first paint is already correct */
    left:-15rem;
    width:30rem; height:30rem;
    border-radius:50%;
    background:#0000FF;
    transform:translateY(-50%);
    z-index:0;
    pointer-events:none;
  }
  .info-row span.on-circle{
    color:#fff;
  }
  .info-row{
    position:relative;
    z-index:1;
    display:flex;
    justify-content:space-between;
    align-items:center;
    gap:2rem;
    padding:1.4rem 0;
    border-bottom:1px solid rgba(23,24,28,0.2);
  }
  /* the rule reads as a divider between rows, so the last one has nothing to
     divide — drop it rather than leave a line hanging under City */
  .info-row:last-child{ border-bottom:none; }
  .info-label{ color:var(--ink); opacity:1; }
  a.contact-box:hover{ background:var(--ink); color:var(--paper); }
  /* just-copied confirmation — listed after :hover, and repeated with :hover, so it
     still shows green while the cursor is still sitting on the box after the click */
  a.contact-box.is-copied,
  a.contact-box.is-copied:hover{ background:#00B34A; color:var(--paper); }
  .contact-box[data-label="Instagram"]{
    background:linear-gradient(45deg, #405DE6, #5851DB, #833AB4, #C13584, #E1306C, #FD1D1D, #F56040, #F77737, #FCAF45, #FFDC80, #405DE6);
    background-size:400% 400%;
    animation:insta-flow 2.8s ease infinite;
  }
  a.contact-box[data-label="Instagram"]:hover{
    background:linear-gradient(45deg, #405DE6, #5851DB, #833AB4, #C13584, #E1306C, #FD1D1D, #F56040, #F77737, #FCAF45, #FFDC80, #405DE6);
    background-size:400% 400%;
  }
  @keyframes insta-flow{
    0%{ background-position:0% 50%; }
    50%{ background-position:100% 50%; }
    100%{ background-position:0% 50%; }
  }
  .contact-box[data-label="LinkedIn"]{
    text-transform:none;
    justify-content:center;
    color:#0A66C2;
    font-size:13rem;
    font-weight:700;
  }
  a.contact-box[data-label="LinkedIn"]:hover{
    background:#0A66C2;
    color:#fff;
  }
  .li-clip{
    position:absolute;
    inset:0;
    display:flex;
    align-items:center;
    justify-content:center;
    overflow:hidden;
  }
  .li-letter{
    display:inline-block;
    /* LinkedIn's mark is set in a geometric humanist sans in a very heavy weight.
       Avenir Next / Futura ship on macOS and Century Gothic / Segoe UI cover
       Windows; the rest of the stack degrades to whatever geometric sans is
       around. Nothing else about the box changes — this only swaps the typeface
       off the inherited mono, plus the weight that "Black" implies. */
    font-family:'Avenir Next', 'Avenir', 'Myriad Pro', 'Myriad', 'Futura',
                'Century Gothic', 'Segoe UI', system-ui, 'Helvetica Neue', Arial, sans-serif;
    /* 700, not 800 — Avenir Next has no face between Bold and Heavy, so 800 and
       900 both render as Heavy and only 700 actually reads lighter */
    font-weight:700;
  }
  .contact-box[data-label="GitHub"]{
    justify-content:center;
  }
  a.contact-box[data-label="GitHub"]:hover .github-icon{
    filter:invert(1);
  }
  /* The marks are sized in rem but the boxes are sized to the grid rows in vh,
     so on a shorter window the artwork was taller than its row and pushed the
     box past the 33% / 67% lines. Capping them keeps every box on the grid. */
  .github-icon, .threads-icon{
    max-width:100%;
    max-height:100%;
    object-fit:contain;
  }
  .github-icon{
    width:11rem;
    height:11rem;
    image-rendering:pixelated;
    image-rendering:crisp-edges;
  }
  .contact-boxes.is-scrolled .contact-box[data-label="LinkedIn"]{
    font-size:9.5rem;
  }
  .contact-box[data-label="Resume/CV"]{
    padding:1.5rem;
    background:#FFFF00;
    color:#000;
  }
  a.contact-box[data-label="Resume/CV"]:hover{
    background:#FF0000;
    color:#B9D7D4;
  }
  .resume-line{
    position:absolute;
    display:block;
    font-size:2.5rem;
    font-weight:300;
    line-height:1;
  }
  .resume-line.corner-tl{ top:1.5rem; left:1.5rem; }
  .resume-line.corner-tr{ top:1.5rem; right:1.5rem; }
  .resume-line.corner-bl{ bottom:1.5rem; left:1.5rem; }
  .resume-line.corner-br{ bottom:1.5rem; right:1.5rem; }
  .contact-box[data-label="Threads"]{
    justify-content:center;
  }
  a.contact-box[data-label="Threads"]:hover{
    background:#D3D3D3;
  }
  .threads-icon{
    width:22rem;
    height:12.5rem;
    border-radius:20%;
  }
  .contact-box[data-label="X"]{
    background:var(--paper);
    color:#000;
    justify-content:center;
  }
  a.contact-box[data-label="X"]:hover{
    background:#000;
    color:#fff;
  }
  .x-glyph{
    font-family:'Press Start 2P', var(--sans);
    font-size:9rem;
    line-height:1;
    /* Out of flow so only the X animates. In flow, its 9rem -> 22rem scaling fed
       straight into the box's height (line-height:1), so the whole box grew and
       shrank with it. Absolutely centred, the box keeps its min-height like every
       other one and the glyph scales inside it. */
    position:absolute;
    top:50%; left:50%;
    /* The +0.0625em drops the letterform onto the box's true centre. Centring the
       element only centres its em box, and this face carries no descender under
       the X: the ink runs from the baseline up a full em, leaving its centre
       0.0625em high (measured from the font's own metrics, and the same ratio at
       every size). Stated in em so it tracks the 9rem -> 22rem scaling instead of
       drifting as the glyph grows. */
    transform:translate(-50%, calc(-50% + 0.0625em));
  }
  .contact-box[data-label="Email"]{
    justify-content:center;
    text-align:center;
    font-size:11.5rem;
    font-weight:900;
    white-space:nowrap;
  }
  .contact-boxes.is-scrolled .contact-box[data-label="Email"]{
    font-size:8.1rem;
  }
  .email-clip{
    position:absolute;
    inset:0;
    display:flex;
    align-items:center;
    justify-content:center;
    overflow:hidden;
  }
  .email-glyph{
    display:inline-block;
  }
  @media (max-width:600px){
    .contact{ padding:3rem 1.25rem; }
  }

  .top-btn{
    position:absolute; right:0.5rem; top:0.5rem; z-index:20;
    display:flex; align-items:center; justify-content:center;
    width:4.5rem; height:4.5rem;
    background:none;
    cursor:pointer;
    border:none;
    color:var(--ink);
    opacity:0.7;
    transition:color 0.15s, opacity 0.15s;
  }
  .top-btn-wrap{
    position:relative;
    display:inline-block;
    width:1.8rem; height:1.8rem;
    overflow:hidden;
  }
  .top-btn-stack{
    display:flex; flex-direction:column;
    transform:translateY(0);
    transition:transform 0.4s ease;
  }
  .top-btn:hover .top-btn-stack{ transform:translateY(-1.8rem); }
  .top-btn svg{
    width:1.8rem; height:1.8rem;
    flex-shrink:0;
    stroke:currentColor;
    fill:none;
    stroke-width:1.5;
    stroke-linecap:round;
    stroke-linejoin:round;
  }
  .top-btn:hover{ color:var(--accent); opacity:1; }
  /* back button: arrows sit side-by-side and slide LEFT on hover (reads as "back") */
  .back-btn .top-btn-stack{ flex-direction:row; }
  .back-btn:hover .top-btn-stack{ transform:translateX(-1.8rem); }

  /* Close control on the contact page, standing in for the logo and back arrow.
     Reads CLOSE, and gains its brackets on hover -> [CLOSE]. Type matches the
     home page paragraphs (.home-blurb) exactly. The brackets are always in the
     layout at opacity 0, so they only fade in and the word never shifts. */
  .close-btn{
    position:absolute; left:3.6rem; top:0.5rem; z-index:20;
    display:inline-flex; align-items:center; justify-content:center;
    padding:0.6rem 0.4rem;
    font-family:var(--sans);
    font-weight:300;
    font-size:0.82rem;
    line-height:1.7;
    color:var(--ink);
    text-decoration:none;
    cursor:pointer;
    user-select:none;
  }
  .close-bracket{ opacity:0; transition:opacity 0.18s ease; }
  .close-label{ padding:0 0.08em; }        /* room for the brackets to fade into */
  .close-btn:hover .close-bracket{ opacity:1; }

  .reveal{
    opacity:0;
    transform:translateY(22px);
    transition:opacity 0.9s cubic-bezier(.16,1,.3,1),
               transform 0.9s cubic-bezier(.16,1,.3,1);
    will-change:opacity, transform;
  }
  body.loaded .reveal{
    opacity:1;
    transform:translateY(0);
  }
  .reveal-1{ transition-delay:40ms; }
  .reveal-2{ transition-delay:120ms; }
  .reveal-3{ transition-delay:260ms; }
  .reveal-4{ transition-delay:380ms; }
  .reveal-5{ transition-delay:460ms; }

  /* Home-page hero logo (static) */
  .hero-logo-wrap{
    display:inline-block;
    line-height:0;
  }
  .hero-logo{
    width:clamp(8rem, 22vw, 18rem);
    height:auto;
  }

  /* Loading animation: full-screen white overlay with a counting percentage */
  .loader{
    position:fixed; left:0; right:0; top:0;
    width:100%; height:100vh;
    z-index:99999;            /* above header and everything else */
    pointer-events:none;
    color:#17181C;
    transition:opacity 0.8s cubic-bezier(0.65,0,0.35,1);
  }
  /* fade the loading page out as the video settles in beneath it */
  .loader.is-done{ opacity:0; }
  .loader-panels{
    position:absolute; inset:0; z-index:1;
    display:flex;
  }
  .loader-panels span{
    flex:1; height:100%;
    background:#fff;
    opacity:1;
    transition:opacity 1s cubic-bezier(0.37,0,0.63,1);
  }
  .loader-content{
    position:absolute; inset:0; z-index:2;
    display:flex; flex-direction:column; justify-content:flex-end;
  }
  .loader-count-wrap{
    display:flex; align-items:flex-end;
    padding:0 2rem 1.4rem;
    font-family:var(--sans);
    font-weight:400;
    line-height:0.85;
    letter-spacing:-0.03em;
  }
  .loader-count{
    font-size:clamp(3rem, 10vw, 8rem);
    font-variant-numeric:tabular-nums;
  }
  /* one span per digit so the count can dissolve character by character —
     inline-block so each carries its own opacity without reflowing the rest */
  .loader-char{ display:inline-block; }
  .loader-pct{
    display:inline-block;
    width:clamp(1.2rem, 2.8vw, 2.1rem);
    opacity:0.5;
    margin:0 0 0.5rem 0.3rem;
  }
  .loader-pct svg{ display:block; width:100%; height:auto; }
  .loader-line{
    position:absolute; left:0; top:50%;
    transform:translateY(-50%);
    /* must stay in step with the height the grid's .hlh lines start at in the
       build animation (see "the loader bar stays as a single horizontal line" in
       main.js) — the bar becomes those lines, so a mismatch shows as a jump */
    width:100%; height:2.5px;
    background:rgba(23,24,28,0.12);
  }
  .loader-line-fill{
    display:block; height:100%; width:0%;
    background:#17181C;
  }
  body.is-loading{ overflow:hidden; }
