/* --- Typefaces ------------------------------------------------------------
   Self-hosted, because the drawing is measured rather than guessed. script.js
   computes the opening framing from the live DOM at load, before it can know
   whether a webfont has landed; every millisecond a face spends in flight is a
   millisecond the camera might frame Courier New's metrics instead of these.
   Same-origin and preloaded, the two faces the first screen is made of are
   usually decoded before the first frame, where a third-party stylesheet cost
   a DNS lookup, a TLS handshake and a CSS round trip before the fonts were
   even requested. tools/layout-check.mjs documents the residue that is left.

   Two voices, per the brand: Nohemi for what the sheet is titled, IBM Plex
   Mono for everything the sheet says. Handwriting is the third and stays on
   Caveat -- see --font-hand below.

   Only the weights the stylesheet actually asks for are here. Nohemi ships
   nine and IBM Plex Mono seven; loading a weight nothing sets is bandwidth
   spent on nothing, and worse, it invites the next change to reach for a tier
   that is not in the ladder. Nohemi's Black was here until the wordmark became
   artwork -- it set OVERRIDING and nothing else, so when the logo replaced the
   type it left behind a 18.5kB face on the preload path that no element asked
   for. Two weights left: 600 for every callout, 700 for the name.

   Latin only. The page is English and Italian, and every character either one
   uses -- including the accented vowels, the curly apostrophe and the em dash
   -- is inside the range declared below. The two glyphs on the sheet that fall
   outside it, the home mark on the reset control and the cross on the index
   close, are not in IBM Plex Mono at any subset; the unicode-range hands them
   straight to the generic monospace, which is where they already resolved. */

@font-face {
    font-family: 'Nohemi';
    src: url('fonts/Nohemi-SemiBold.woff2') format('woff2');
    font-weight: 600;
    font-style: normal;
    font-display: swap;
}
@font-face {
    font-family: 'Nohemi';
    src: url('fonts/Nohemi-Bold.woff2') format('woff2');
    font-weight: 700;
    font-style: normal;
    font-display: swap;
}

/* The 700 is the .woff of the pair rather than the .woff2, because the kit
   this came from ships no .woff2 at this weight in the Latin subset. It is
   13.2kB against the 400's 14.7kB, so the older container costs nothing here;
   listing a .woff2 that does not exist would cost a 404 and a second request
   before the browser fell back to this same file. */
@font-face {
    font-family: 'IBM Plex Mono';
    src: url('fonts/IBMPlexMono-Latin-400.woff2') format('woff2');
    font-weight: 400;
    font-style: normal;
    font-display: swap;
    unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
                   U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
                   U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
                   U+FEFF, U+FFFD;
}
@font-face {
    font-family: 'IBM Plex Mono';
    src: url('fonts/IBMPlexMono-Latin-700.woff') format('woff');
    font-weight: 700;
    font-style: normal;
    font-display: swap;
    unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
                   U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
                   U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
                   U+FEFF, U+FFFD;
}

:root {
    /* Blueprint blue is the identity, not a theme: it is the surface the whole
       drawing is on, so every legibility number in this file is a ratio against
       this one value and moving it moves all of them at once. Measured, white
       on #10416A, so the next change to it can be checked against the same
       list rather than eyeballed:

         white                    10.6:1   all body text, every callout
         white at 90% (--ink)      8.9:1   the four hand notes
         white at 80%              7.4:1   the CORE line under the wordmark
         white at 70%              6.1:1   the stack's group labels
         white at 65%              5.5:1   the status stamps -- the quietest
                                           tier, and so the binding one
         white on the 14% plate    7.1:1   the current-sector control
         blue on white            10.6:1   an inverted control on hover

       The floor that matters is 4.5:1 for body text, and the tier that reaches
       it first is the 65% status stamp. Anything lighter than this blue should
       be checked against that row before it ships. Up from #0a3161, which ran
       the same list about 1.2x higher throughout. */
    --bg-color: #10416A;
    --fg-color: #ffffff; /* Blueprint White */
    /* Three voices, and the split is the brand rather than a preference.
       --font-display titles the sheet: the wordmark, the name under it, the
       numbered sector callouts and the six app names. --font-mono is what the
       sheet says -- prose, dimensions, status stamps, controls, every label --
       and it is the document default, so anything that does not ask for a
       voice gets the drawing's own. --font-hand is the author writing in the
       margin. Nothing is set in a fourth.

       Renamed from --font-heading when Nohemi replaced Inter, because the
       property no longer describes the body text: Inter was the default and
       the headings both, and the two have come apart. */
    --font-display: 'Nohemi', 'Helvetica Neue', sans-serif;
    --font-mono: 'IBM Plex Mono', 'Courier New', monospace;
    --font-hand: 'Caveat', cursive;

    /* Ease-out quint. The sheet's travel and the rows drawn onto it share one
       curve so the two read as a single movement rather than two effects. */
    --ease-drawer: cubic-bezier(0.22, 1, 0.36, 1);

    /* Semantic layers, so overlay z-indexes stay comparable. */
    --z-grid: -2;
    --z-overlay: -1;
    --z-controls: 100;
    /* One step above, for a control that sits ON another control rather than
       beside it. Only the phone bar needs it: see #zoom-controls below. */
    --z-controls-raised: 101;
}

body, html {
    margin: 0; padding: 0; width: 100%; height: 100%;
    background-color: var(--bg-color); color: var(--fg-color);
    overflow: hidden; /* Prevent native scroll */
    overscroll-behavior: none; /* No pull-to-refresh over the canvas */
    /* The document default is the mono, not the display face. Most of the
       sheet is annotation, so the majority voice is the one worth inheriting
       and the eight elements that are titles ask for the other one by name.
       This used to be Inter, which meant a handful of paragraphs that had
       simply never been given a voice -- the CORE line under the wordmark, the
       status stamps -- were quietly in the heading face; on the mono they read
       as the drawing's own labels, which is what they always were. */
    font-family: var(--font-mono);
}

#viewport {
    width: 100vw; height: 100dvh; position: relative; overflow: hidden;
    /* Contains #grid's negative z-index. position: relative alone does not open
       a stacking context, so once the grid moved out of #canvas -- which opens
       one via will-change -- its z-index of -2 escaped to the root and painted
       behind the opaque background on body, which showed as no grid at all. */
    isolation: isolate;
    /* Hand every gesture to the pointer handlers in script.js. Without this the
       browser claims touch pans and pinches and the canvas is dead on mobile. */
    touch-action: none;
    -webkit-tap-highlight-color: transparent;
}
#viewport:focus-visible {
    outline: 2px solid var(--fg-color);
    outline-offset: -4px;
}
/* Text stays selectable at rest; selection is suppressed only while panning,
   so a drag never turns into a text highlight. */
#viewport.is-dragging,
#viewport.is-dragging * {
    user-select: none;
}

#canvas {
    display: block;
    width: 6000px; height: 6000px;
    position: absolute; top: 50%; left: 50%; /* Center origin at start */
    transform-origin: 0 0;
    /* No-JavaScript fallback framing only. With JS, script.js computes the
       opening framing from the viewport and paints it before the first frame,
       so this value is never the one a visitor sees. */
    transform: translate(-1181px, -1127px) scale(0.4);
    will-change: transform;

    /* Every rule drawn in canvas units shrinks with the camera, and below one
       screen pixel a rasteriser cannot draw it consistently: the coverage lands
       differently from frame to frame and the line flickers. At the phone
       zoom-out floor a 2px heading rule measured 0.74 device px and the 1px
       hairlines 0.37, which is what made the lines under the titles shimmer on
       the way out. This is the same failure the grid used to have, and the same
       floor it fixed it with -- one screen pixel, never thinner.

       --rule-scale is written by script.js, coarsely quantised and always
       downward, so these can only ever err thick: the quotient lands between
       one and 1.41 screen px and never under. It is deliberately not
       --canvas-scale, which updates only once the camera settles -- fine for a
       fade nobody is watching mid-gesture, useless for a line that flickers
       while you are still moving.

       The quotient is not what gets drawn, and that is why the borders carry a
       round() the strokes do not. Measured off rendered pixels: a border-width,
       an outline-width and a filled box are all snapped to a whole device pixel
       -- a 1.414px border paints 1.0, a 2.828px border paints 2.0 -- while an
       SVG stroke-width is honoured as asked, painting 2.816 for 2.828. So a
       border can only ever be a whole number of pixels wide, and what it
       renders is that whole number times the camera scale.

       It rounds DOWN, and the direction is the whole point. Rounding up looks
       like the safer choice -- it is the one that keeps every line at a full
       screen pixel -- but the band it buys is 1.00 to 1.99 screen px, and the
       top of that band sits immediately below scale 1: leaving reading zoom by
       a hair took a 1px border to 1.90 and the dashed box around the badge
       visibly doubled on the way out. Rounding down gives 0.71 to 1.41 over the
       same range, centred on the one pixel the line is meant to be rather than
       starting there, and 0.95 at scale 0.95. A tenth of a pixel of extra
       thinness is not visible; a border twice its weight is.

       Which makes this round() the engine's own snapping written down rather
       than a correction to it, so the @supports fallback below is behaviourally
       identical and not merely adequate. What is left on the tier is solid --
       .sector-title and its aside -- where a stepped width is a sub-pixel change
       in weight and nothing else. The quiet rules were dashed when this was
       written, and stepped their dash length with it, which is visible; they are
       solid now and still off the tier, for the smaller reason given below.

       The stroke tiers need none of this. A fractional stroke is drawn
       fractionally, so max(1, 1/--rule-scale) lands them in 1.00 to 1.41
       directly -- tighter than any whole-pixel border can be -- and
       --stroke-ink carries the sketched connectors' authored 3-unit weight on
       the same terms. The tiers are named for what they are authored at, so a
       mark keeps its designed weight where there is room for it.

       tools/stroke-check.mjs holds both ends of the band in a real browser
       across four viewport/DPR pairs: too thin flickers, too thick is the
       regression above, and a check with only a floor passes the second one. */
    --rule-scale: 1;

    /* The quiet rules do not use any of the tiers above, and what is left of the
       reason is worth keeping straight, because most of it has been removed.

       They used to be dashed, and the dash was the whole problem. A CSS border
       welds three things together: the box it sits on, the weight of the line,
       and -- because a dashed border derives its dash length from its own width,
       measured at about 3x -- the rhythm of the dashes. Widening the border to
       keep the line drawable therefore lengthened the dashes by the same factor,
       and the tiers step: --rule-hair goes 1, 2, 4, 5, 8 canvas px on the way
       out, so at scale 0.707 the rendered dash period jumped from 3.5 to 7.3
       screen px between one frame and the next. A sub-pixel change in weight is
       invisible; the dash pattern changing size is not, and it was reported as
       soon as the badge joined the tier. Holding them at a flat 1px and fading
       them was the way out of that.

       The dashes are gone now -- they rendered poorly at the zooms this drawing
       is actually read at, which is the failure the flat 1px was mitigating
       rather than fixing -- so that argument no longer applies to anything.
       These could join --rule-hair today.

       They stay off it anyway, for a smaller reason. --rule-hair steps to 2px as
       the sheet zooms out, and .project h3 wears one of these rules: a border
       that grows a pixel grows the card, moves the cluster and moves the content
       box tools/layout-check.mjs asserts. There is nothing to buy with that. A
       flat 1px in canvas units is also what a drawn line on a drawing does -- it
       scales with the drawing -- and the fade below takes these away before the
       width gets thin enough for a rasteriser to make a mess of.

       The fade itself is kept, and it was never really about dashes. Losing the
       quiet rules at the overview is a level-of-detail decision: they carry
       hierarchy, not structure, and structure is what an overview is for. The
       solid rules that carry it keep their tier and stay.

       Ramped on --rule-scale rather than on the live scale. A 1px border renders
       at the camera scale, so this is that width in screen px, quantised: full
       ink at 0.9 and gone by 0.35. Writing an unquantised value per frame was
       measured instead and costs what style.css already says an inherited
       custom property costs -- p95 17.6ms to 24.9ms with 9 frames of 90 over
       budget on a 8x-throttled CPU -- so the steps stay, and .lod.lod's
       transition carries border-color to smooth them out. */
}

/* The tiers are derived here rather than on #canvas, and script.js writes
   --rule-scale to exactly these elements for the same reason.

   A custom property that inherits invalidates style for everything below the
   element it is set on, and WebKit does that whether or not a descendant reads
   it -- measured: registering the property with @property, fencing subtrees with
   contain: style, and even pinning every stroke that used it all left the cost
   unchanged. On #canvas that subtree is the whole sheet, and each write cost
   37ms in WebKit against 0.7ms in Blink. It is written about eight times across
   a pinch, so those were eight dropped frames, and the reason the canvas felt
   heavy on an iPhone and fine on Android.

   Written to the consumers instead, the same value inherits into a few dozen
   nodes rather than the whole drawing: p95 86ms to 33ms over a full pinch, with
   dropped frames going from 11 to 2 -- as cheap as not doing the work at all,
   while the lines stay correct during the gesture rather than going stale.

   #canvas keeps --rule-scale: 1 above so this all still resolves with no
   JavaScript, where nothing is ever written and every tier sits at its authored
   weight. */
.sector-title,
.sketch,
#canvas .project h3,
.stack dt,
.contact-links a {
    --rule-hair: max(1px, calc(1px / var(--rule-scale)));
    --rule-thick: max(2px, calc(1px / var(--rule-scale)));
    --stroke-thick: max(2, calc(1 / var(--rule-scale)));   /* .sketch .thin-ink */
    --stroke-ink: max(3, calc(1 / var(--rule-scale)));
    --rule-ink: clamp(0, calc((var(--rule-scale) - 0.35) / 0.55), 1);
    --rule-quiet: rgba(255, 255, 255, var(--rule-ink));
}

/* The borders' round(down), kept behind a support query, because the failure
   mode without one is not a thinner line -- it is no line.

   round() is the newest thing in this file, and an engine that does not know it
   still parses the declaration: a custom property accepts almost any token
   stream, so the value survives until it is substituted and only then fails to
   parse. `border-bottom: var(--rule-thick) solid` invalid at computed-value time
   falls back to border-style: none, so an unsupporting browser would lose the
   rule under every heading rather than draw it a little thin. Measured on the
   shape this had when the dashed boxes were on the tier too: unguarded gives
   border-style: none and 0px padding, guarded gives the plain quotient.

   And the plain quotient is the same line. The engine snaps a border-width down
   to a whole pixel on its own, so an engine without round() paints exactly what
   one with it paints. This is a support query for a syntax, not for a
   behaviour -- it is here because the failure mode of getting it wrong is a
   missing rule, not a thin one.

   Two declarations in one block would not do it, for the same reason as above:
   the second would win at parse time and fail later, taking the first with it. */
@supports (border-width: round(down, 1.5px, 1px)) {
    .sector-title,
    .sketch,
    #canvas .project h3,
    .stack dt,
    .contact-links a {
        --rule-hair: max(1px, round(down, calc(1px / var(--rule-scale)), 1px));
        --rule-thick: max(2px, round(down, calc(1px / var(--rule-scale)), 1px));
    }
}

/* Screen space, the size of the viewport, sitting behind #canvas rather than
   inside it. script.js sets --grid-pitch from the camera scale and slides
   --grid-x / --grid-y to wherever canvas (0,0) currently lands, so the lines
   stay locked to canvas coordinates without the camera's transform carrying
   them.

   The move out of #canvas is the point. Inside it, at inset:-3000px, the grid
   stretched the bounds of a will-change layer from 6000px square to 12000px
   square -- four times the area for the compositor to hold and re-rasterise on
   every zoom. On a phone that is where the flickering came from, and it took
   unrelated fixed chrome down with it once the GPU started dropping tiles.

   The lines are a flat 1px here and never need scaling. That removes the whole
   --grid-line mechanism: in canvas space a 1px line had to be widened as
   1/scale or it fell under one screen pixel and rasterisers either dropped it
   (Firefox went completely flat at the overview) or kept a ragged subset of it.
   A screen-space line is 1px by construction at every zoom.

   What a flat 1px does not fix is weight. In canvas space the lines at least
   got finer as the camera pulled back; in screen space they hold 1px while the
   spacing between them collapses, so zooming out packs the same ink into less
   and less room. At the phone zoom-out floor the coarse lines land 11px apart
   and the sheet reads as a dense mesh laid over the drawing rather than as a
   grid behind it -- exactly the "too thick when zoomed out" the canvas-space
   version was accused of, arrived at from the opposite direction.

   Thinning the line back below 1px is not the answer: a fractional stop in a
   repeating gradient lands on pixel boundaries unevenly, so some lines come
   out solid and their neighbours faint, and the grid moires. Line weight as
   the eye reads it is coverage -- width times alpha -- and only one of those
   two is stuck at a whole pixel. So the width holds at 1px and --grid-coarse
   carries the scaling: 1px at 0.15 pulls back to 1px at 0.06, which is the
   same ink as a 0.4px line and none of the rasterising. script.js computes it
   from the rendered spacing, the way --grid-fine already fades.

   The declared values are the no-JavaScript fallback, matching the scale 0.4
   framing that #canvas falls back to: 100 and 20 canvas px at 0.4. */
/* The repetition lives inside the gradients, and the element is grown by one
   coarse period so a single un-repeated copy of each covers the viewport. Both
   halves of that are load-bearing, and neither is how this started.

   It started as four `linear-gradient`s one line tall, tiled by background-size
   at the pitch. On an iPhone -- Safari and Brave both, Android fine -- the lines
   shimmered while pinching: some solid, their neighbours faint, the pattern
   crawling as the spacing moved. The cause is the coarse and fine grids being
   two independently tiled layers. In canvas units they are exactly aligned,
   100 / 20 = 5, so every fifth fine line sits underneath a coarse one; but each
   layer's tile is rounded to whole device pixels on its own, so that alignment
   breaks by a fraction of a pixel and some coarse lines end up with a fine line
   beside them rather than under them. Measured on the pitches this file actually
   produces, across the phone's zoom range: 28 of 40 frames carried a 23% mean
   spread in line weight in WebKit, against 2.5% in Blink, which is exactly the
   "Android is good" in the report. A repeating gradient is computed from one
   ramp instead of stamped from a tile, and measures 0.0% at every frame in both.

   The element has to grow, though, and that is the part a straight swap misses.
   With background-size left auto a gradient's image is exactly the element box,
   so offsetting it by background-position -- which is how the grid stays locked
   to canvas coordinates -- exposes a strip of the next copy along, and the
   phase there only lines up if the viewport happens to be a whole number of
   periods wide. It generally is not: keeping background-position seamed 47 of 87
   frames, with gaps off by up to 193 device px, which is far worse than the
   shimmer it was meant to fix. So the offset moves onto the element's own
   position instead. Anchoring the left edge one full period out at
   --grid-x minus --grid-pitch puts a coarse line back exactly on --grid-x, and
   the fine period divides the coarse one, so the fine lines land on it too.
   right/bottom stay at 0, so the box is only ever grown by the part of a period
   the offset has not used -- and #viewport clips the overhang.

   script.js is untouched by this: --grid-x and --grid-y are still the origin
   wrapped modulo the pitch, which is all this needs. */
/* Moved by transform on a promoted layer, not by left/top.

   left/top on an unpromoted element is a paint, and this element's paint is
   four full-viewport gradients. --grid-x and --grid-y are rewritten on every
   frame of a pan, so the whole stack re-rasterised every frame: measured in a
   trace over 60 pan frames at 390x844 DPR3 with the CPU throttled 6x, 525ms of
   RasterTask, about 8.8ms per frame, against 0ms with the grid's background
   removed entirely. That is the delay before the grid appears on a weak phone
   and the shimmer while it is moved, and the opening resetView() spends its
   whole 800ms doing it while the fonts are still arriving.

   Pinning the box size was the obvious suspect and is not the cause: --grid-x
   also changes the box's width, because right/bottom were 0, and holding the
   size still saved 15ms of the 525. The cost is the move itself. On a promoted
   layer a move is a compositor transform and costs no raster at all: 0ms over
   the same 60 frames, the same number as not drawing the grid.

   One pitch of slack, not two. The offset is wrapped modulo the pitch, so the
   translate is always between -pitch and 0 and 100% + pitch always reaches the
   far edge. Two pitches also measures 0ms, and costs layer memory for nothing --
   this is a phone-sized layer at device pixel ratio and it is allocated for the
   life of the page.

   will-change is doing work here rather than being a superstition: without it
   the same transform measured 12ms rather than 0.

   The translate arrives pre-computed and snapped to a whole device pixel, which
   is the one thing this could not take from the old version. A layer moved by a
   fractional transform is resampled rather than redrawn, and resampling a grid
   of 1px lines is the shimmer this drawing has already been fixed for once:
   unsnapped, grid-check ran 3.8% line spread twice and 10.6% on the third pass,
   where it had never left 4.3%. Snapped, it sits at 6.7% on every run -- worse
   than the old number and stable, and the two are indistinguishable in a
   screenshot of the phone overview.

   So the offset cannot be assembled here. calc() would have to subtract the
   pitch from the origin, and the pitch is fractional, which puts the result back
   between device pixels however carefully the origin was rounded. refreshGrid()
   does the subtraction and the rounding together and writes the finished
   translate; see the note there. That is the one thing the screen-space grid
   asks of script.js beyond the origin it was already writing. */
#grid {
    position: absolute;
    left: 0;
    top: 0;
    width: calc(100% + var(--grid-pitch));
    height: calc(100% + var(--grid-pitch));
    transform: translate3d(var(--grid-tx), var(--grid-ty), 0);
    will-change: transform;
    z-index: var(--z-grid);
    pointer-events: none;
    --grid-pitch: 40px;
    --grid-fine-pitch: 8px;
    --grid-tx: 0px;
    --grid-ty: 0px;
    --grid-coarse: 0.139;
    --grid-fine: 0.033;
    background-image:
        repeating-linear-gradient(rgba(255, 255, 255, var(--grid-coarse)) 0 1px, transparent 1px var(--grid-pitch)),
        repeating-linear-gradient(90deg, rgba(255, 255, 255, var(--grid-coarse)) 0 1px, transparent 1px var(--grid-pitch)),
        repeating-linear-gradient(rgba(255, 255, 255, var(--grid-fine)) 0 1px, transparent 1px var(--grid-fine-pitch)),
        repeating-linear-gradient(90deg, rgba(255, 255, 255, var(--grid-fine)) 0 1px, transparent 1px var(--grid-fine-pitch));
    /* One copy of each, sized to the box by default. Tiling is what this rule
       exists to avoid, so it must not creep back in through the shorthand. */
    background-repeat: no-repeat;
}

/* Present to assistive tech, absent from the drawing. Used for the name of the
   index trigger, whose visible half is the readout of where the camera is. The
   clip-path pair rather than display:none or visibility:hidden, both of which
   take the text out of the accessibility tree along with the layout. */
.visually-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    border: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
}

/* --- Canvas primitives ---------------------------------------------------- */

.sector {
    position: absolute;
    left: var(--x);
    top: var(--y);
    width: var(--w, 800px);
}

#blueprint-overlay {
    position: absolute;
    inset: 0;
    pointer-events: none;
    opacity: 0.15;
    z-index: var(--z-overlay);
}

/* Blueprint decoration. Stroke weights are classes rather than repeated
   presentation attributes on every element. */
.bp { position: absolute; }
.bp circle,
.bp line,
.bp rect {
    fill: none;
    stroke: var(--fg-color);
}
/* Pinned, where every other stroke on the sheet is compensated, and it is a
   performance decision rather than a drawing one. These 46 children were half
   of what --rule-scale had to invalidate on every step of a pinch; dropping
   them out of it is worth as much as scoping the write everywhere else put
   together -- p95 54ms to 33ms on top of that change.

   Affordable because this is texture. #blueprint-overlay draws at 0.15, so at
   the zoom-out floor a 1-unit line carries about 3% ink: whatever a rasteriser
   does with it at that width, it is an order of magnitude under the badge's
   box that started all of this, and it is behind the drawing rather than part of
   it. */

   The two dash patterns that used to live here, .dashed-wide and .dashed-tight,
   are gone rather than converted: they distinguished nothing except dash length,
   and .thin and .thick already carry the weight difference the circle and the
   cross actually need. */
.bp .thin { stroke-width: 1; }
.bp .thick { stroke-width: 2; }
.bp-label {
    fill: var(--fg-color);
    stroke: none;
    font-family: var(--font-mono);
    font-size: 14px;
}
.bp-label.small { font-size: 12px; }

/* Hand-drawn connectors: one heavy "ink" stroke per arrow, shaft and head.
   Each of these used to be drawn twice -- the ink pass plus a lighter "ghost"
   pass offset a couple of units down and right, for a sketched, twice-drawn
   look.

   The offset never overlapped. Authored about 2 units diagonally, so 2.8, it
   stood the ghost's outer edge some 2.1 units clear of the 3-unit ink stroke it
   was meant to be hiding under, at every zoom -- the two are canvas units and
   scale together, so the separation is a fixed fraction of the stroke and no
   camera position ever closes it. What the camera changes is only whether 2.1
   units is visible: half a screen pixel at the phone's zoom-out floor, where
   this read as intended grain, and over six by 3x, where it reads as a second
   arrow lying under the first, complete with its own head.

   An arrow is the one mark on this drawing that has to say exactly one thing,
   so it is a single stroke now. The twice-drawn look is left to #drag-hint,
   which is screen-space chrome at a fixed 24px and never zooms at all. */
.sketch { position: absolute; }
.sketch path {
    fill: none;
    stroke: var(--fg-color);
    stroke-linecap: round;
    stroke-linejoin: round;
}
/* Floored like every other stroke on the sheet. 3 units is 0.71 device px at
   the wide layout's zoom-out floor on a dpr-1 display -- a dpr-3 phone gets
   2.11 and never saw it, which is why this outlasted the rules under the
   headings. --stroke-ink holds the authored 3 wherever there is room for it. */
.sketch .ink { stroke-width: var(--stroke-ink); }
.sketch .thin-ink { stroke-width: var(--stroke-thick); }

.hand-note {
    font-family: var(--font-hand);
    position: absolute;
    /* Pulled back by the padding below, so it is still the ink and not the box
       that starts at --x. A note is placed by its left edge, and widening it
       symmetrically without this walks all four of them 14px to the right of
       the coordinates the composition below is reasoned in. */
    left: calc(var(--x) - 0.35em);
    top: var(--y);
    /* These are <p> elements, and on an absolutely positioned box a default
       margin still offsets it: every note was rendering ~40px below the --y it
       was authored at, which is what pushed them into their own connectors. */
    margin: 0;
    /* Room for the ink to reach past the advance width the box is laid out by
       -- the same fault as #drag-hint's two lessons further down, and the same
       fix. Caveat leans its glyphs right and hangs ink outside the advance on
       both sides: 0.196em past the end for the "!" that ends "Let's talk!" and
       "Check these out!", and 0.125em before the origin for the "W" of "What I
       build with". Chrome paints the overhang anyway; iOS Safari paints the
       text only as far as the box, and on an iPhone every one of the four
       notes arrived with its last letter sliced off.

       0.35em is the hint's value and covers the worse of the two languages:
       14px of room against 7.6px of overhang, the Italian "Dagli
       un'occhiata!" included. tools/layout-check.mjs measures the overhang
       from canvas metrics in both languages and fails if it stops covering
       it. */
    padding: 0 0.35em;
    transform: rotate(var(--rot, 0deg));
    font-size: 2.5rem;
    color: var(--fg-color);
    white-space: nowrap;
    pointer-events: none;
    --ink: 0.9;
    opacity: var(--ink);
}

/* --- Layout: wide --------------------------------------------------------- */

/* Every coordinate on the sheet, in one place. These used to be inline `style`
   attributes, which outrank any stylesheet selector: nothing could override
   them without `!important` on all 24 elements. Out here the wide and tall
   compositions can be read side by side, and the markup carries content
   instead of geometry.

   Only position lives here. An SVG figure's own geometry -- cx, r, x1, the
   dimension line's label -- stays on the element, because it is drawing, not
   layout.

   A figure wrapper's width and height are its own geometry too, not its
   position, so #fig-circle, #dim-sector-03 and #fig-structure declare them
   once here; the tall block below restates left/top only and inherits the
   rest, the same way --rot carries forward for the hand notes. */

#sector-home        { --x: 2500px; --y: 2540px; --w: 840px; }
#sector-whoiam      { --x: 1500px; --y: 2280px; }
#sector-stack       { --x: 1900px; --y: 3040px; --w: 460px; }
#sector-works-title { --x: 3000px; --y: 3060px; }
#sector-contact     { --x: 3700px; --y: 2390px; --w: 400px; }

/* Slots, not apps. The cards are numbered by where they sit on the sheet so
   that nothing here has to name an unreleased app -- this file is served, and a
   name in a selector is a name in View Source. It reads better as geometry
   anyway: 01-03 across the first row, 04-06 across the second. */
#app-01 { --x: 3000px; --y: 3240px; }
#app-02 { --x: 3360px; --y: 3240px; }
#app-03 { --x: 3720px; --y: 3240px; }
#app-04 { --x: 3000px; --y: 3560px; }
#app-05 { --x: 3360px; --y: 3560px; }
#app-06 { --x: 3720px; --y: 3560px; }

/* --rot is the note's own handwriting, not its position, so it is declared
   once here and never restated per layout. */
#note-nutshell { --x: 2180px; --y: 2490px; --rot: -5deg; }
#note-talk     { --x: 3400px; --y: 2350px; --rot: -4deg; }
#note-check    { --x: 2950px; --y: 2840px; --rot: -3deg; }
#note-build    { --x: 2500px; --y: 3040px; --rot: -3deg; }

#lead-whoiam  { left: 2150px; top: 2550px; width: 350px; height: 100px; }
#lead-stack   { left: 2209px; top: 2755px; width: 520px; height: 355px; }
#lead-contact { left: 3355px; top: 2390px; width: 335px; height: 200px; }
#lead-apps    { left: 2780px; top: 2755px; width: 200px; height: 355px; }

#fig-circle    { left: 2200px; top: 2200px; width: 1400px; height: 1400px; }
#dim-sector-03 { left: 3000px; top: 3173px; width: 1000px; height: 50px; }
#fig-structure { left: 2550px; top: 3360px; width: 400px; height: 400px; }

/* --- Layout: tall --------------------------------------------------------- */

/* Most of the sheet only moves between the two compositions, and a moved box
   is a coordinate. One thing is not: a connector needs a re-routed bezier,
   which is not a CSS property, so those four elements are authored twice and
   toggled here.

   The dimension line used to be a fifth, because it states a width and the tall
   cluster was two columns wide against the wide sheet's three. Both are three
   columns at the same pitch now, so both measure 1000, and it went back to
   being one figure with two positions.

   display:none rather than visibility:hidden on purpose. boundsOf() skips a
   zero-size rect -- `if (!r.width && !r.height) return;`, commented "hidden at
   this width" -- so a hidden figure drops out of the camera's bounds instead of
   dragging them across the sheet. A hidden connector still gets a correct
   arrowhead: drawArrowheads() derives every head at load, before either set is
   hidden, and getTotalLength() reports real geometry on a display:none path
   (verified in Chrome 151 and Firefox 153).

   block, not revert: every duplicated element is a .bp or a .sketch, and both
   are position:absolute. */
[data-layout="tall"] { display: none; }

/* The threshold is the shape of the window, not its width, because the problem
   is the shape of the drawing against it. The two layouts fit equally well at
   2860/3600 = 0.79, so 1/1 deliberately runs the tall sheet past its own
   break-even: the band between is the non-fullscreen desktop window, where
   filling the tall axis beats the last tenth of scale. A phone turned sideways
   is 2.16 and correctly gets the wide sheet back.

   Independent of the 900px breakpoint below, which governs chrome only. A
   960x1080 window gets this composition and the desktop nav at once. */
@media (max-aspect-ratio: 1/1) {
    [data-layout="wide"] { display: none; }
    [data-layout="tall"] { display: block; }

    /* Centre column at 2950. Bounds 2250-3650 by 1800-4582, which is
       1660 x 3042 with CONTENT_MARGIN.

       The sheet ran to 5143 not long ago, and both cuts came from measuring
       rather than choosing. The gaps between the sectors were sized when a
       different sector sat on the other side of each of them; the two that hold
       a connector are sized from it now -- 34 units of clearance below the box a
       line leaves, its own drop, then the 40 above the heading it points at,
       which is the clearance every leader on both sheets keeps -- giving 204,
       147 and 194 for 240 units. Then the apps cluster went from two columns to
       three, for 320 more: at 1000 wide inside 1400 it always fitted across the
       sheet, and the third row was buying nothing.

       That is the end of it on a phone, and worth knowing before anyone goes
       looking for more. The two axes trade: 1660 x 3042 is an aspect of 0.546
       against the 0.503 that 390x844 leaves once the chrome is out, so this
       sheet is now bound by its width there and rests at exactly 390/1660 =
       0.2349. Another 300 units of height would buy nothing at all. Height
       still pays on a window that is tall rather than phone-shaped: 960x1080
       has no width limit worth the name and took the full 10%. */
    #sector-home        { --x: 2530px; --y: 1800px; }
    /* 640 rather than the 800 default costs nothing: .bio is capped at 60ch,
       about 633px, so the wide box was carrying 167px of empty sheet. Measured
       630px tall before and 635px after. It buys 160px of sheet width free. */
    #sector-whoiam      { --x: 2250px; --y: 2360px; --w: 640px; }
    /* The three sectors below Who I Am read down the sheet in the order the nav
       numbers them: [02], [03], [04]. They did not until the cycle that put
       Stack here, My Apps where Stack was and Contact at the foot -- before it
       [04] CONTACT was the second thing a visitor met, one row under the
       wordmark and above both of the sections it is the answer to.

       Each one moved into a slot another was leaving, so the sheet's rhythm is
       untouched: this row still spans 2250-3650 about the 2950 centre line, the
       apps cluster still starts on Stack's old 3360, and the three leaders kept
       their shapes and swapped names with their targets.

       400 rather than the wide sheet's 460 for the same reason Who I Am takes
       640 here, and this one is free outright: .stack is capped at 46ch and
       measures 451px tall at both widths, in both languages, so the narrower
       box drops no line and hangs no empty sheet. It is what lets Stack sit in
       Contact's column exactly, keeping the row centred and leaving the 360px
       channel between the two sectors for the leader that runs down it. */
    #sector-stack       { --x: 3250px; --y: 2360px; --w: 400px; }
    #sector-works-title { --x: 2450px; --y: 3200px; }
    /* Centred on 2950, like every full-width item on this sheet. It closes the
       drawing: 194px under the last card row, which is 34 of clearance, the
       leader's own 120 of drop, and the 40 every arrowhead on either sheet
       keeps from the heading it points at. */
    #sector-contact     { --x: 2750px; --y: 4140px; }

    /* Three columns and two rows, the same shape the wide sheet lays the six
       cards out in and at the same 360 pitch, so the cluster spans 1000 in both
       and the rows land on the same two y values. Only the columns differ, and
       only because this sheet centres them: 2450-3450 about the 2950 centre
       line, against 3000-4000 over there.

       It was two columns and three rows, which cost 320 units of sheet for a
       cluster that fits across it either way -- 1000 wide inside 1400, with 200
       clear on each side. Read left to right and top to bottom it is still the
       order the six cards have in the DOM, so nothing moves in the index or the
       nav. First row 180 under the heading. */
    #app-01 { --x: 2450px; --y: 3380px; }
    #app-02 { --x: 2810px; --y: 3380px; }
    #app-03 { --x: 3170px; --y: 3380px; }
    #app-04 { --x: 2450px; --y: 3700px; }
    #app-05 { --x: 2810px; --y: 3700px; }
    #app-06 { --x: 3170px; --y: 3700px; }

    /* The captions travel with the sectors they name: "What I build with"
       follows Stack, "Check these out!" the apps cluster, "Let's talk!" Contact.

       The first two sit on one line at 2180, one above each arc, as far into
       their own margin as the other is into theirs -- 20 units off the left
       bound, 17 off the right. "What I build with" spent a while below Stack
       instead, because at 283 wide it crossed the arc anywhere in the crook
       above the heading; pulling both arcs 100 units inward to aim them at
       their headings is what opened the room, and a caption hanging under the
       block it names was reading as an afterthought rather than as a label.

       "Check these out!" stood to the left of the apps heading until the
       cluster widened to three columns and took that margin down to 200 against
       the note's 240; it is on the other side of the heading now, over the third
       column, which is the side the arrow it captions arrives on anyway. It sat
       level with that heading for a while, 30 units below its top, which is the
       one place a caption cannot go -- beside a 4rem callout it reads as a
       second heading rather than as an aside. 180 above it is where the other
       two sit above theirs, and it puts the note in the 393-unit band between
       Stack and the cluster that nothing else was using.

       "Let's talk!" sat 350 out from the leader it belongs to, which put it over
       FIG 3.1 and read as a caption for the figure rather than for the arrow.
       90 out is enough to clear the line and still be its. */
    #note-nutshell { --x: 2270px; --y: 2180px; }
    #note-build    { --x: 3350px; --y: 2180px; }
    #note-check    { --x: 3100px; --y: 3020px; }
    #note-talk     { --x: 3040px; --y: 4060px; }

    /* Both arcs sit 100 units further in than they were mirrored to, and they
       moved together so they are still each other's exact reflection -- 2430-2730
       against 3170-3470 about the 2950 centre line.

       Mirroring the pair about the sheet was not enough on its own, because the
       two headings they point at are not the same length: [01] WHO I AM is 374
       units of ink and [02] STACK is 288. A reflected tip therefore landed 100
       into the first heading and 300 into the second, which is 12 past the end
       of it -- the arrow arrived over open sheet, and the eye followed the whole
       descending line out there with it. Aiming at the headings instead of at
       each other puts both tips 200 in from their own heading's left edge, and
       the pair stays symmetric because both moved by the same amount. */
    #lead-whoiam-tall  { left: 2430px; top: 2170px; width: 300px;  height: 180px; }
    #lead-stack-tall   { left: 3170px; top: 2170px; width: 300px;  height: 180px; }
    #lead-apps-tall    { left: 2930px; top: 2180px; width: 200px;  height: 1020px; }
    #lead-contact-tall { left: 2870px; top: 3960px; width: 160px;  height: 160px; }

    /* The circle has to come off the wordmark here. In the wide sheet it
       centres on Home and still sits inside CONTENT, because Home is not the
       topmost thing -- Who I Am is, 220px higher. In this one Home is topmost,
       and the geometry then pushes the circle 192px outside the content box at
       any size: its ink reaches 500 above its centre, Home's centre is 178
       below its own top, and CONTENT starts only 130 above that.

       Left there it would be cropped by the viewport edge, by a different
       amount on every screen height, and reachable only by pushing into the
       pan slack -- which is the exact fault recorded against FIG 3.1 in the
       wide sheet. So it centres on the upper assembly instead: (2950, 2400),
       ink 1900-2900, sitting behind Home's prose, both leaders and the tops of
       Who I Am and Contact, with the wordmark resting just above its top arc. */
    #fig-circle         { left: 2250px; top: 1700px; }
    #dim-sector-03      { left: 2450px; top: 3313px; }
    /* The dead space on this sheet is beside [04] CONTACT at the foot: that
       sector is 400 wide on a 1400 sheet and nothing follows it. The columns
       either side of the card cluster are the other two gaps and they are 200
       each now that it is three wide, so a 400x400 figure fits neither. */
    #fig-structure      { left: 3200px; top: 4140px; }

    /* No-JavaScript fallback framing only, the tall counterpart of the value on
       #canvas. With JS, script.js computes the opening framing from the
       viewport and paints it before the first frame, so this is never the value
       a visitor sees. Centres (2950, 3191) -- the tall content centre -- at
       0.22. */
    #canvas {
        transform: translate(-649px, -702px) scale(0.22);
    }
}

/* --- Typography ----------------------------------------------------------- */

/* The wordmark, and the anchor of the whole sheet.

   It used to be two things on one flex line: a cropped <use> of the logomark
   at 170px beside an <h1> set in the display face at 5rem, with a 28px gap and
   align-items: center holding them together. The gap, the vertical centring
   and the optical weight match between the mark and the type were three CSS
   values approximating a relationship the logo already states, and they had to
   be re-tuned every time the display face changed -- the last round of that is
   still recorded in the letter-spacing note this rule replaces.

   One drawing now, so none of that is layout's business. What is left is a
   width. It was 700px, the old lockup's own measure: 170 for the mark, 28 for
   the gap and 502 for OVERRIDING at 5rem. It is 840 now, a fifth larger so the
   mark holds the centre of the sheet, and [00] HOME is 840 wide to match --
   which moved the contact leader's tail out past the end of the wordmark.

   Deliberately not in LOD_SELECTOR: the lockup stays at full strength when the
   prose around it fades out at the overview. */
.brand {
    margin: 0;
    /* The <h1> is a block whose only visible child is the artwork, so its own
       font properties would otherwise still reserve a line box around it. */
    font-size: 0;
    line-height: 0;
}
.brand-logo {
    display: block;
    width: 840px;
    height: auto;
    color: var(--fg-color);
}
/* The wordmark used to be followed by the name, set in Nohemi 700 at 2.5rem
   with 10px over and under it, and then by a boxed MOBILE APP DEVELOPER badge.
   Both are gone: the name is a sentence inside [01] WHO I AM now, the sector
   where the person is described rather than labelled, and the badge said as a
   label what the bio's first sentence says as a sentence. The wordmark is
   followed by prose. The 91px the badge held is still held, by
   #sector-home .bio below, for the camera reason written there.

   The badge is worth remembering rather than only deleting, because it is where
   every rule-weight decision above came from. Its box was dashed and it crawled
   on every pinch: at the phone's zoom-out floor a flat 1px border rendered 0.71
   device px at the 56% opacity the LOD fade gives it there, and a part-
   transparent sub-pixel line is the most visible version of that fault there is.
   Putting it on --rule-hair fixed the flicker and bought two more reports -- the
   box doubling in weight below scale 1, then the dashes changing size on the way
   out, because a dashed border's rhythm follows its width -- which is what the
   round(down) and the --rule-ink fade were both written for. Those still hold
   the badge's lesson for every rule that outlived it, and the dashes have since
   gone from the drawing entirely. */
.bio {
    font-family: var(--font-mono);
    margin-top: 30px;
    max-width: 60ch;
    font-size: 1.1rem;
    line-height: 1.6;
    text-wrap: pretty;
}

/* Air under the mark, and only as much as the title block wants.

   This was 121px: the 30 above plus the 91 a MOBILE APP DEVELOPER badge used to
   hold between the wordmark and this prose -- its own 60px top margin and a 31px
   box. The badge went, the space stayed, and it was twice defended on camera
   grounds rather than tightened. [00] HOME's height is read by framingFor(), a
   shorter sector is framed larger, and at 844x390 a larger HOME laid its
   top-left corner under #lang-toggle.

   That defence was measured and real, and it was defending the wrong thing. At
   121px HOME cleared the toggle by 4px of sector height -- its framing came out
   at 0.747 against the 0.755 where the fit turns width-bound and the box starts
   reaching into the corners -- so the layout was never choosing this gap. It was
   holding the only gap a corner control happened to allow, and any future line
   of prose in here would have broken it again.

   The fix belonged in the camera, and is there now: framingFor() reserves the
   top and bottom chrome bands (focusInsets() in script.js), so a focused sector
   cannot reach a corner control at any height. The gap is free to be a design
   value again.

   48px is a little over half the wordmark's own 89px height: air under a title
   rather than a dropped element. It costs the drawing nothing -- the sheet's
   content box is unchanged at 2860x1880 and 1660x3012 -- and it buys framing.
   HOME now frames at 0.817 where 121px framed it at 0.747, clearing #lang-toggle
   by 50px and #zoom-controls by 8px at the viewport this rule was written for.

   Scoped to this sector: .bio is also [01] WHO I AM's prose, which has its own
   spacing and no camera argument attached to it. */
#sector-home .bio {
    margin-top: 48px;
}
/* The sector headings are the drawing's callouts. At 2rem they sat only a shade
   above the card titles inside SECTOR_03 (1.8rem), so the sheet had no clear
   tier between "this is a section of the drawing" and "this is one item in it".
   3rem opens that gap, and it buys legibility at the overview for free: at the
   phone's zoom-out floor a 2rem heading landed at 3.9 screen px, inside the
   range where the fade in this file has to start dimming it, where a 3rem
   heading lands at 5.9px and is drawn at full strength. */
.sector-title {
    /* The callouts left the mono when Nohemi arrived. A drawing's callout is
       lettered, not typed: the mono is the voice of what the sheet reports --
       dimensions, stamps, prose -- and a heading that shares it has to rely on
       size alone to say it is a heading, which is why these needed 3rem to
       separate from 1.8rem card titles in the first place. In the display face
       they are a different kind of mark, so the size ladder below is free to
       mean what it says.

       600, and the tier above it is not type at all: the wordmark at the
       centre of the sheet is the logo, so these have to defer to a drawn mark
       rather than to a heavier cut of the same face. SemiBold has the weight
       to hold a 2px rule under it and still sit under that. Every callout on
       the drawing is set at this one weight --
       sectors, the [1.1] detail, the six app names -- so rank stays carried by
       size, which is the convention the rest of this file already keeps. */
    font-family: var(--font-display);
    font-weight: 600;
    font-size: 3rem;
    margin: 0 0 40px 0;
    border-bottom: var(--rule-thick) solid var(--fg-color);
    display: inline-block;
    padding-bottom: 10px;
}

/* A callout that jumps to its own sector: the four numbered headings, and the
   wordmark, which is [00] HOME's callout in drawn form. script.js adds
   .is-jump, so this can only ever describe one that really has the handler.

   The cursor is the whole affordance, and the drawing does not change. The rule
   under a callout is a rule on a drawing -- it is what says "this is a heading
   of this rank", the 2px against the 1px that [1.1] carries -- so spending it
   on a hover state would make a line that means one thing mean two. The sheet
   has somewhere to put control states already: the chrome, where the nav, the
   zoom pair and the language toggle all invert. Type sitting in the drawing is
   not chrome, and the honest way for it to say it can be clicked is the
   pointer, which every visitor already reads and which costs the sheet no ink.

   Nothing here is inside @media (hover: hover), because there is no hover state
   to guard: `cursor` is answering a pointer that is already on the page, and a
   touch device never consults it. */
.sector-title.is-jump,
.brand-logo.is-jump {
    cursor: pointer;
}

/* An <svg> is hit-tested shape by shape, so without this the wordmark answers
   on its letters and not in the gaps between them -- the cursor would turn to a
   pointer over OVERRIDING and then quietly stop meaning it in the counters of
   the O and the D. bounding-box makes the target the rectangle the visitor
   sees, which is the one they are aiming at.

   Under .is-jump rather than on .brand-logo, so the mark is inert artwork again
   the moment it is not wired. Firefox does not implement bounding-box and falls
   back to the painted shapes: the wordmark still answers there, just on its ink
   -- the affordance degrades, it does not break. */
.brand-logo.is-jump {
    pointer-events: bounding-box;
}

/* [03] MY APPS used to be the one heading not like the others, at 4rem against
   the 3rem the rest carry, on the argument that a visitor wants to see what was
   built before they read about who built it and that a drawing expresses that
   ranking by callout size.

   Reversed, and the typeface change is what made it worth reversing. The
   argument was sound while the callouts were mono, where 4rem against 3rem is
   the same letterform a third larger and reads as emphasis. Nohemi at 600 is a
   wider, heavier mark, so the same ratio stopped reading as "this one first"
   and started reading as a different tier of heading altogether -- one sector
   shouting over four that are its equals. The four are peers. They are
   numbered [01] to [04], they sit at the four corners of one sheet, and a
   drawing that gives one of them a bigger callout is saying they are not the
   same kind of thing.

   The ranking it was doing is still done, and done better by the composition:
   [03] is the largest cluster on the sheet, it owns the widest sector, and
   three of the four connectors point into it. Nothing about the order a
   visitor reads in depended on the extra rem.

   Rank that is a real difference in kind still shows in the size: see
   .sector-title.aside below, which is a numbered detail of a sector rather
   than a sector, and says so at 2rem with a hairline rule. */
/* A numbered detail of a sector rather than a sector of its own. It used to
   float above the wordmark as [FIG 0.1] and read at the full 3rem, which was
   right while it was a standalone annotation with nothing above it. Inside
   [01] it has a heading directly above it, and two callouts at the same size
   in one column say the second one starts something new -- which is the one
   thing it does not do. 2rem is the tier below, so the eye reads [01] first
   and [1.1] as part of it, and it keeps the drawing's convention that rank is
   carried by callout size.

   The hairline rule under it does the same work in the other direction: 1px
   against the sectors' 2px. Together with the number itself, that is what says
   "detail", so nothing is left resting on the type size alone. */
.sector-title.aside {
    font-size: 2rem;
    border-bottom-width: var(--rule-hair);
    margin-bottom: 24px;
}

/* Proximity is the only thing that says whether [1.1] opens a new block or
   continues this one, and it is measured on both sides of the heading at once.
   At 52px above and the .bio's own 30px below, the two were near enough to a
   tie that the callout read as floating between the paragraph above it and the
   paragraph below -- attached to neither. Three to one settles it: the heading
   sits with its own prose, and the pair sits under [01].

   Below scales with the callout rather than matching [01]'s: 40px under a 3rem
   heading and 24px under a 2rem one are the same gap in the type's own terms. */
.subsector {
    margin-top: 72px;
}
.subsector .bio {
    margin-top: 18px;
}

/* --- Projects ------------------------------------------------------------- */

.project-list {
    list-style: none;
    margin: 0;
    padding: 0;
}
/* Square to the grid. The cards used to carry a per-card --rot of 2-3deg on
   top of a 40px jitter in their --y, which read as sloppiness rather than
   style: the background is a dead-straight blueprint grid, so a tilted card
   fights the one thing the drawing is about. Rotation is gone from the rule
   itself, not just unset per card, and the two rows share one --y each, so the
   icons, titles and description tops all sit on a line. Handwriting is where
   the wobble belongs -- see .hand-note, which keeps its --rot. */
.project {
    position: absolute;
    left: var(--x);
    top: var(--y);
    width: 280px;
}
/* The real app icon, which is what tells the cards apart now that the six
   connectors from the heading are gone. All six are the same artwork system: a
   squircle with transparent corners, so the drawing shows through them and no
   frame of our own is needed. */
.project-icon {
    display: block;
    width: 72px;
    height: 72px;
    margin: 0 0 18px 0;
}
/* The app name is the one thing on a card a visitor came for, so it carries
   the display face and the same 600 the sector callouts do. The card's own
   ladder is unchanged -- 1.8rem name, 0.9rem description, 0.75rem stamp -- but
   the name is now a different voice from the two lines under it rather than a
   larger size of the same one, which is the whole reason the rule under it was
   there. */
/* Two declarations here exist only so a sealed card can letter this line in the
   hand while the released one keeps the display face, and the card height comes
   out the same either way. Both are needed; either alone moves the sheet.

   `line-height: normal` resolves out of the font's own metrics, so swapping the
   face changed the box: Caveat took the wide sheet from 1880 to 1887. Pinning it
   in rem gives both faces one number whatever size each is set at.

   `vertical-align: top` is the half of this that is easy to miss. This is an
   inline-block -- it has to be, or the border under it would run the full card
   width instead of hugging the text -- and an inline-block sits on its
   baseline, so the line box around it is sized from where that baseline falls
   inside the box. That is still a font metric, and two faces at two sizes put it
   in two different places even with the same line-height. Aligning to the top
   takes the baseline out of the measurement and leaves the line box equal to the
   element's own height, which is the thing this rule can actually fix.

   2.0625rem, 33px, is what reproduces the wide sheet's 1880 exactly. It is not
   what Nohemi's `normal` resolved to (28px): pinning that number lost 5px,
   because half-leading had been putting the baseline somewhere the top alignment
   no longer asks about. The number was measured against tools/layout-check.mjs
   rather than reasoned out, which is the only way to get it right. */
.project h3 {
    font-family: var(--font-display);
    font-weight: 600;
    font-size: 1.8rem;
    line-height: 2.0625rem;
    vertical-align: top;
    margin: 0;
    border-bottom: 1px solid var(--rule-quiet);
    display: inline-block;
    padding-bottom: 5px;
}
/* The description used to sit 6px under the title, below a stack line that
   carried the spacing. With that line gone it needs the gap itself. */
.project p {
    font-family: var(--font-mono);
    font-size: 0.9rem;
    line-height: 1.5;
    margin: 14px 0 0 0;
    text-wrap: pretty;
}
/* Reserve three lines -- the longest description in the set -- so the status
   below it lands on the same line on all six cards. Expressed in em rather
   than px so it tracks the font size: 4.5em is exactly 3 x line-height 1.5.
   Without it the two-line cards pull their stamp up and the row goes ragged
   again, which is the thing the straightening was for. */
.project-desc {
    min-height: 4.5em;
}
/* The card's last line: a status stamp while an app is private, a store link
   once it ships. Both live here with the same footprint and the same baseline,
   so publishing is a copy change plus an <a> -- no re-layout. Quiet by
   design: this is metadata, and it must not compete with the app name. White
   at 65% over the blueprint blue measures 5.5:1, past AA for body text. It was
   6.3:1 against the darker blue this sheet used to be drawn on; 65% is the
   quietest tier here and so the first one a lighter ground would push under
   4.5:1, which is why it is stated rather than left to be rediscovered.
   When it becomes a link, drop the opacity and let it pick up the "> LABEL"
   idiom the contact links already use. */
/* Selector carries .project so it outweighs `.project p` above, which is also
   a class plus a type and would otherwise keep the font size at 0.9rem. */
.project .project-status {
    font-size: 0.75rem;
    letter-spacing: 0.08em;
    --ink: 0.65;
    opacity: var(--ink);
}

/* --- Sealed cards ----------------------------------------------------------

   Five of the six apps are unannounced, so their cards are drawn instead of
   described: the shared hatched plate stands in for the icon, the slot number
   for the name, three hatched bars for the description.

   Worth being precise about what this is. It is not a covering laid over
   content that is still there underneath -- the markup has no name and no
   description to cover. These rules draw the appearance of a redaction over a
   card that is genuinely empty, which is the only kind that survives someone
   opening View Source. A blur would have been the other kind.

   Every sealed rule is a state, never a position: the six coordinate lines
   above stay the single place a card's place on the sheet is decided, so
   releasing an app is a change of markup and nothing here moves. */

/* Where the description will go. A scribble, not a bar.

   The hatched redaction this replaced was the better-looking mark and the
   wrong one: a bar over a line of text says the text is there and has been
   taken out, and nothing has been taken out of these cards. Nothing was ever
   written on them. Three scribbled strokes say the sentence has not been
   written yet, which is the true statement, and it is the author saying it
   rather than the sheet -- the register this whole card half belongs in.

   The file is sized to this box exactly (280 x 65 units against 280px x 4.5em),
   so background-size: 100% 100% maps one unit to one pixel at the size the card
   is authored at and the strokes carry the weight they were drawn at.

   --sketch-w is what keeps five cards from sharing one signature. The strokes
   are identical in every card that uses this file, which is a tell handwriting
   cannot afford, so each card scales the drawing to a slightly different width:
   the curves fall at different places along the line and the short last stroke
   ends somewhere new. Mirroring would have been the cheaper trick and a worse
   one -- it throws the ragged last line over to the right edge, which no
   paragraph does. */
.project-desc--sealed {
    background-image: url("icons/sketch-lines.svg");
    background-repeat: no-repeat;
    background-size: var(--sketch-w, 100%) 100%;
}

/* The plate wobbles; the card never does. Rotation was taken off .project on
   purpose -- a tilted card fights the dead-straight grid behind it, which is
   the one thing the drawing is about -- and the note there settled where the
   wobble does belong. This is that: a transform on the plate alone, which
   reflows nothing and leaves the card square to the grid it sits on. */
.project[data-state="sealed"] .project-icon {
    transform: rotate(var(--plate-rot, 0deg));
}

/* One handwriting per card, declared once. These are the card's hand and not
   its position, so they stay out of the coordinate blocks and are never
   restated per layout -- the same rule --rot follows for the margin notes.
   A released card simply stops reading them. */
#app-02 { --plate-rot: -1.6deg; --sketch-w: 100%; }
#app-03 { --plate-rot:  1.1deg; --sketch-w: 93%;  }
#app-04 { --plate-rot: -0.6deg; --sketch-w: 97%;  }
#app-05 { --plate-rot:  1.9deg; --sketch-w: 90%;  }
#app-06 { --plate-rot: -1.2deg; --sketch-w: 99%;  }

/* The slot number, in the author's hand.

   An unreleased app is the one thing on this sheet the system cannot assert
   anything about: there is no listing, no link and no data behind it, only
   someone intending to build it. That is the handwriting's job by the same
   division that gives the mono every dimension and stamp, so the number is
   lettered rather than typed -- and the mono stamp underneath keeps its voice,
   which puts both of them on one card.

   2.2rem against the title's 1.8rem: Caveat has a much smaller x-height than
   Nohemi and matching it by number would have left the slot looking like a
   caption. The line box does not move with it, because .project h3 pins
   line-height in rem above.

   No letter-spacing. Tracking is something you do to type; the hand notes set
   none and this is one of them.

   0.8 rather than the 0.7 the typed number carried. Caveat is a lighter mark at
   the same ink, and the number still has to sit under the one real app name in
   the cluster without disappearing under the stamp below it.

   Two classes, matching .project .project-status above, so .lod.lod later in
   the file still wins on equal specificity and this fades with its own card at
   the overview instead of hanging there alone. */
.project .project-slot {
    font-family: var(--font-hand);
    font-size: 2.2rem;
    font-weight: 400;
    --ink: 0.8;
    opacity: var(--ink);
}

/* The released card's last line, where the stamp's reasoning above said this
   would end up: the slot keeps its size and its baseline and becomes a link.

   Full ink, against the stamp's 0.65. The stamp is metadata and was told not to
   compete with the app name; this is the one thing on the sheet a visitor who
   came from a store listing or a video actually wants, so on a released card it
   is allowed to win.

   An <a> in a <p>'s place needs the <p>'s box back. `.project p` gave the stamp
   its mono, its block display and its 14px, and a link matches none of those
   selectors -- without this it would sit inline against the description in the
   UA's link blue, on the wrong line entirely. The mono it does inherit, because
   the document default is the mono.

   No padding, deliberately. The contact links reserve a 10px pad and a 1px
   border for their hover, which is right for a column of links with nothing
   under them; here it would push this line 10px below the five stamps it shares
   a row with, and holding all six on one line is the whole reason
   .project-desc reserves three. So the hover is an underline instead: a
   bottom border grows downward out of the box and moves no text, and it is the
   mark the card title already wears.

   Two classes, so it outweighs .project .project-status above on source order
   while still tying with .lod.lod below, which keeps the link fading with the
   rest of the card at the overview instead of hanging there alone. */
.project .project-store {
    --ink: 1;
    opacity: var(--ink);
    display: block;
    width: max-content;
    margin: 14px 0 0 0;
    color: var(--fg-color);
    text-decoration: none;
    border-bottom: 1px solid transparent;
    transition: border-color 0.2s;
}
@media (hover: hover) {
    .project .project-store:hover {
        border-bottom-color: var(--rule-quiet);
    }
}

/* --- Stack ---------------------------------------------------------------- */

.stack {
    font-family: var(--font-mono);
    margin: 0;
    max-width: 46ch;
}
.stack dt {
    font-size: 0.8rem;
    font-weight: 700;
    letter-spacing: 0.08em;
    --ink: 0.7;
    opacity: var(--ink);
    border-bottom: 1px solid var(--rule-quiet);
    padding-bottom: 6px;
}
.stack dd {
    margin: 10px 0 28px 0;
    font-size: 1rem;
    line-height: 1.7;
    text-wrap: pretty;
}
.stack dd:last-of-type {
    margin-bottom: 0;
}

/* --- Contact -------------------------------------------------------------- */

.contact-links {
    display: flex;
    flex-direction: column;
    gap: 15px;
}
.contact-links a {
    font-family: var(--font-mono);
    font-size: 1.5rem;
    color: var(--fg-color);
    text-decoration: none;
    transition: background 0.2s, color 0.2s;
    /* Transparent, so it rasterises nothing and cannot flicker -- it is here to
       reserve the space the hover border occupies so hovering does not shift the
       link. Both are a flat 1px, so the reservation matches what it reserves for
       at every zoom without anything having to be given back. */
    border: 1px solid transparent;
    padding: 10px;
    width: max-content;
}
@media (hover: hover) {
    .contact-links a:hover {
        background: var(--fg-color);
        color: var(--bg-color);
        border: 1px solid var(--rule-quiet);
    }
}

/* --- Controls ------------------------------------------------------------- */

#control-panel,
#zoom-controls,
#lang-toggle {
    user-select: none;
}

#control-panel {
    position: fixed;
    top: 30px;
    right: 30px;
    display: flex;
    gap: 10px;
    z-index: var(--z-controls);
    /* Never grow far enough left to reach #lang-toggle, which is fixed at
       left: 30px and about 90px wide. This panel is right-anchored and does not
       wrap, so without a ceiling it grew straight into the toggle and painted
       over it -- both sit at --z-controls and this one comes later in the DOM.
       At 769px the [00] HOME button covered 79x40px of [ IT ] and the toggle
       could not be clicked at all. The breakpoint below is what keeps the two
       apart at the widths this happened at; this is the backstop for the case a
       breakpoint cannot predict, a visitor whose default font size makes the
       buttons wider than these numbers assume. Then it scrolls instead of
       overlapping. */
    max-width: calc(100vw - 190px);
    overflow-x: auto;
    scrollbar-width: none;
}
#control-panel::-webkit-scrollbar {
    display: none;
}
#control-panel button {
    background: var(--bg-color);
    color: var(--fg-color);
    border: 1px dashed var(--fg-color);
    padding: 10px 20px;
    font-family: var(--font-mono);
    font-weight: bold;
    cursor: pointer;
    transition: all 0.2s;
}
/* Where the camera is now. Solid rather than dashed because on a technical
   drawing a dashed line is the hypothetical one and a solid line is the drawn
   one, plus a faint fill so it reads as a raised plate at a glance. Deliberately
   quieter than :hover, which inverts fully -- the current sector is a status,
   not an invitation, and the two must not look like the same thing.
   White on this plate measures 7.1:1, down from 8.5:1 on the darker blue.

   Declared BEFORE the :hover rule on purpose. Both selectors have identical
   specificity, so order is the only thing that separates them: with this rule
   last it won the background while :hover kept its inverted blue text, which
   put blue on a light blue plate -- the current button was the least legible
   one in the panel. Hover has to win outright, and it only ever applies to the
   one button under the pointer. */
#control-panel button.is-current {
    border-style: solid;
    background: rgba(255, 255, 255, 0.14);
}
@media (hover: hover) {
    #control-panel button:hover {
        background: var(--fg-color);
        color: var(--bg-color);
    }
}

#zoom-controls {
    position: fixed;
    bottom: 30px;
    right: 30px;
    display: flex;
    flex-direction: column;
    gap: 0;
    z-index: var(--z-controls);
}
#zoom-controls button {
    background: var(--bg-color);
    color: var(--fg-color);
    border: 1px dashed var(--fg-color);
    width: 40px;
    height: 40px;
    font-size: 1.5rem;
    font-family: var(--font-mono);
    cursor: pointer;
    transition: all 0.2s;
    display: flex;
    align-items: center;
    justify-content: center;
}
#zoom-controls button:first-child {
    border-bottom: none;
}
/* The reset control carries the same current-view state as the nav buttons: it
   frames the whole drawing, which is where the opening move ends and where
   every visitor starts. Declared before :hover for the same reason as the nav
   rule -- equal specificity, so order is all that separates them. */
#zoom-controls button.is-current {
    border-style: solid;
    background: rgba(255, 255, 255, 0.14);
}
@media (hover: hover) {
    #zoom-controls button:hover {
        background: var(--fg-color);
        color: var(--bg-color);
    }
}

#lang-toggle {
    position: fixed;
    top: 30px;
    left: 30px;
    background: var(--bg-color);
    color: var(--fg-color);
    border: 1px dashed var(--fg-color);
    padding: 10px 20px;
    font-family: var(--font-mono);
    font-weight: bold;
    cursor: pointer;
    transition: all 0.2s;
    z-index: var(--z-controls);
}
@media (hover: hover) {
    #lang-toggle:hover {
        background: var(--fg-color);
        color: var(--bg-color);
    }
}

/* The tall sheet is narrow, so at these widths the drawing sits in the middle
   of the viewport with a wide empty margin on each side -- 270 to 690 of 960 --
   while the nav is anchored across the top at 232 to 930. It collides with the
   drawing while sitting in space this layout has just freed up. Standing it up
   in the right margin removes the collision and, with it, any need to reserve
   height at the top.

   Below 901px the nav is already replaced by the index bar along the bottom,
   which is chrome this cannot improve on: a phone has no margin to spare.

   Declared after the base #control-panel/#zoom-controls rules above, not next
   to the other tall-composition rules near the top of the sheet: both are
   #control-panel at equal specificity, so with this block earlier in the file
   the base rule's unconditional `top: 30px` was winning the cascade over this
   one's `top: 50%` even while the media query matched, leaving the panel
   pinned near the top edge instead of centred. */
@media (max-aspect-ratio: 1/1) and (min-width: 901px) {
    #control-panel {
        flex-direction: column;
        align-items: stretch;
        top: 50%;
        right: 30px;
        transform: translateY(-50%);
    }

    /* Out from under the nav, which now owns the middle of the right edge.
       Five buttons at about 41px with four 10px gaps is 245px, so at 1080 the
       nav occupies 417-662 and this cluster sits clear at about 924-1050. */
    #zoom-controls {
        top: auto;
        bottom: 30px;
        right: 30px;
        transform: none;
    }
}

/* --- Drag hint ------------------------------------------------------------ */

/* The one thing on screen addressed to the visitor rather than to the drawing,
   and the only reason it is chrome and not a note on the canvas: a canvas note
   scales with the camera, so at the opening framing it rendered at 16px, the
   same size as the decorative notes beside it. Fixed, it holds its size at
   every zoom.

   It starts hidden and script.js reveals it once the opening move lands, so a
   visitor with no JavaScript -- who cannot drag anything -- is never told to
   drag, and so it arrives at the moment the camera is still. */
#drag-hint {
    position: fixed;
    left: 50%;
    bottom: 40px;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 2px;
    transform: translateX(-50%) rotate(-3deg);
    font-family: var(--font-hand);
    font-size: 2.4rem;
    line-height: 1.1;
    color: var(--fg-color);
    white-space: nowrap;
    pointer-events: none;
    opacity: 0;
    transition: opacity 600ms ease;
    z-index: var(--z-controls);
}
#drag-hint.is-visible {
    opacity: 0.95;
}
#drag-hint .ink,
#drag-hint .ghost {
    fill: none;
    stroke: var(--fg-color);
    stroke-linecap: round;
    stroke-linejoin: round;
}
#drag-hint .ink { stroke-width: 2.4; }
#drag-hint .ghost { stroke-width: 1.2; opacity: 0.6; }

/* Room for the ink to reach past the advance width it is measured by. Both
   lessons end in "!", and Caveat draws that character leaning right, with its
   ink hanging 0.196em beyond the advance -- so the last thing either sentence
   says falls outside the box the browser lays out. Chrome paints the overhang
   anyway; iOS Safari paints the text only as far as the box, and on an iPhone
   the "!" arrived with its top right sliced off.

   On the spans rather than on #drag-hint, because the span is what gets
   clipped: it is a block inside a centred column, so it shrink-wraps to the
   sentence and its edge is the one the ink crosses. Padding here widens the
   container with it, so both boxes clear.

   In em, so the one value covers the 2.4rem hint and the 2rem phone hint: 11.2
   and 13.4px of room against 6.3 and 7.9px of overhang. Symmetric, so the
   sentence stays centred and nothing moves for anyone who was never seeing it
   cut. tools/layout-check.mjs measures the overhang from canvas metrics and
   fails if the padding stops covering it. */
#drag-hint .hint-drag,
#drag-hint .hint-zoom {
    padding: 0 0.35em;
}

/* One of the two is shown; script.js decides which by measuring whether the
   buttons can already deliver readable text at this size. */
#drag-hint .hint-zoom,
#drag-hint.teaches-zoom .hint-drag {
    display: none;
}
#drag-hint.teaches-zoom .hint-zoom {
    display: block;
}

/* Out of the drawing's centre column on the tall sheet, because down there the
   centre column is [04] CONTACT and the hint was sitting on the five links --
   the only things on the canvas a visitor can actually press.

   That is a fact of the composition rather than of one screen size: Contact is
   400 units centred on the sheet's own centre line, the overview centres that
   line on the viewport, and the hint is centred too, so the two coincide at
   every width the tall sheet is shown at. Measured on the links themselves,
   they end at 49% of the viewport at 390x844 and 38% at 960x1080; 55% clears
   both, and clears the zoom controls at the other end -- they sit in the bottom
   bar under 900px and at the right edge above it, and the hint stops 17px short
   of them at 901x1400, the tightest of the four.

   The cap is what is left of the viewport past that, less a 16px gutter. A
   phone has 159px of it, which is narrower than either sentence, so there the
   note wraps to two lines rather than running off the edge -- which is why the
   nowrap comes off. Nothing wraps above about 700px wide. */
@media (max-aspect-ratio: 1/1) {
    #drag-hint {
        left: 55%;
        max-width: calc(45vw - 16px);
        transform: rotate(-3deg);
        white-space: normal;
        text-align: center;
    }
}

/* The part that does the work. A visitor who does not stop to read the words
   still sees something moving the way their hand should: the mark slides for
   drag, and turns on its side and breathes for zoom, which is the diagonal
   every resize handle uses and the only motion that means bigger. */
.drag-hint-mark {
    animation: drag-nudge 2.6s ease-in-out infinite;
}
@keyframes drag-nudge {
    0%, 100% { transform: translateX(-8px); }
    50%      { transform: translateX(8px); }
}
#drag-hint.teaches-zoom .drag-hint-mark {
    animation: zoom-breathe 2.6s ease-in-out infinite;
}
@keyframes zoom-breathe {
    0%, 100% { transform: rotate(45deg) scale(0.78); }
    50%      { transform: rotate(45deg) scale(1.2); }
}

/* --- Sheet index (phone navigation) --------------------------------------- */

/* Desktop reaches every sector from the top bar, so the index is phone-only.
   #sheet-index needs no display rule of its own: a closed dialog is already
   display:none, and the trigger is the only thing that can open it. Forcing it
   visible here would show it permanently. */
#index-open {
    display: none;
}

#sheet-index {
    /* Fills the screen. Five entries with real labels need the room, and a
       partial sheet floating over a canvas nobody can read at this width is the
       worst of both. */
    position: fixed;
    inset: 0;
    width: 100%;
    max-width: none;
    height: 100%;
    max-height: none;
    margin: 0;
    padding: 0;
    border: none;
    background: var(--bg-color);
    color: var(--fg-color);
}
#sheet-index::backdrop {
    background: var(--bg-color);
}

/* --- Sheet motion ---
   The sheet is a drawer pulled up out of the bar that opens it, so it travels
   from the bottom edge rather than arriving in place: the direction says where
   it came from, and a dismiss visibly puts it back there.

   Travel only, no fade on the sheet itself. It is opaque and it covers the
   whole screen, and fading an opaque surface in over the drawing it replaces
   reads as a dissolve rather than as a movement. The backdrop carries the fade
   instead, and that is what makes the travel legible: the drawing stays visible
   above the rising edge and dims as it is covered.

   display and overlay are in the transition list with allow-discrete so the
   closing move gets to run at all. Without them a close() drops the dialog out
   of the top layer on the first frame and the sheet disappears instead of
   leaving. Where @starting-style is unsupported the sheet simply appears, which
   is exactly what it did before this block existed. */
#sheet-index {
    translate: 0 100%;
    transition:
        translate 340ms var(--ease-drawer),
        display 340ms allow-discrete,
        overlay 340ms allow-discrete;
}
#sheet-index::backdrop {
    opacity: 0;
    transition:
        opacity 340ms var(--ease-drawer),
        display 340ms allow-discrete,
        overlay 340ms allow-discrete;
}
/* Scoped to [open] deliberately. A closed dialog is display:none from the UA
   stylesheet, so declaring a display on the bare selector would pin the sheet
   open at every width. */
#sheet-index[open] {
    display: flex;
    flex-direction: column;
    translate: 0 0;
}
#sheet-index[open]::backdrop {
    opacity: 1;
}
/* The state to transition *from* on open. A dialog goes from display:none
   straight to shown, and without a starting style there is no previous value to
   interpolate away from, so the sheet would land with no travel at all. */
@starting-style {
    #sheet-index[open] {
        translate: 0 100%;
    }
    #sheet-index[open]::backdrop {
        opacity: 0;
    }
}

/* A drawing's title block, at the foot of the sheet. It used to set the mark
   beside the name, because the mark alone is close to 2:1 and side by side was
   the arrangement that fitted the strip. The full logo is 7.9:1 and already
   contains the name, so there is no second thing to sit next to: the block
   stacks, the logo takes the width it is given, and the role reads under it
   the way a title block's subtitle does. */
#sheet-index-block {
    margin-top: auto;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    padding: 20px 20px calc(20px + env(safe-area-inset-bottom));
    border-top: 1px dashed var(--fg-color);
    font-family: var(--font-mono);
}
#sheet-index-block .brand-logo {
    width: 240px;
}
#sheet-index-role {
    margin: 8px 0 0 0;
    font-size: 0.7rem;
    letter-spacing: 0.08em;
    opacity: 0.7;
}

#sheet-index-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: calc(20px + env(safe-area-inset-top)) 20px 20px;
}
#sheet-index-head h2 {
    font-family: var(--font-mono);
    font-size: 1.4rem;
    margin: 0;
}
#index-close {
    background: var(--bg-color);
    color: var(--fg-color);
    border: 1px dashed var(--fg-color);
    font-family: var(--font-mono);
    font-size: 1.2rem;
    width: 44px;
    height: 44px;
    cursor: pointer;
}

.sheet-entry {
    display: block;
    width: 100%;
    min-height: 56px;
    text-align: left;
    background: none;
    color: var(--fg-color);
    border: none;
    border-top: 1px dashed var(--fg-color);
    font-family: var(--font-mono);
    font-size: 1.1rem;
    padding: 18px 20px;
    cursor: pointer;
}
.sheet-entry:last-child {
    border-bottom: 1px dashed var(--fg-color);
}

/* The rows are drawn onto the sheet once it has landed, the way an index is
   filled in on a drawing rather than arriving printed. Deliberately short: this
   is navigation, and someone who already knows where they are going should not
   be waiting on it. The first row starts as the sheet arrives and the last is
   in place 140ms later, inside the travel it follows.

   An animation rather than a class-gated transition, and with no forwards fill,
   so the resting state is the row's own CSS. If it never runs -- reduced
   motion, a headless render, a browser that skipped it -- the index is still a
   complete readable list rather than a blank sheet. --i is set by script.js,
   which is what builds the rows; the fallback keeps the rule honest if it is
   ever missing. */
@keyframes sheet-entry-draw {
    from {
        opacity: 0;
        translate: 0 12px;
    }
}
#sheet-index[open] .sheet-entry {
    animation: sheet-entry-draw 300ms var(--ease-drawer) backwards;
    animation-delay: calc(150ms + var(--i, 0) * 28ms);
}
/* Same current-view treatment as the two control clusters, and declared before
   any press state for the same specificity reason. */
.sheet-entry.is-current {
    background: rgba(255, 255, 255, 0.14);
}
.sheet-entry:active {
    background: var(--fg-color);
    color: var(--bg-color);
}

/* --- Press feedback ------------------------------------------------------- */

/* :hover is gated behind (hover: hover) above, because on a touchscreen it
   sticks to whatever was tapped last. :active is what a finger actually gets. */
#control-panel button:active,
#zoom-controls button:active,
#lang-toggle:active,
.contact-links a:active {
    background: var(--fg-color);
    color: var(--bg-color);
}

/* --- Focus ---------------------------------------------------------------- */

#control-panel button:focus-visible,
#zoom-controls button:focus-visible,
#lang-toggle:focus-visible,
.contact-links a:focus-visible {
    outline: 2px solid var(--fg-color);
    outline-offset: 3px;
}

/* --- No JavaScript -------------------------------------------------------- */

#noscript-note {
    position: fixed;
    left: 50%;
    bottom: 30px;
    transform: translateX(-50%);
    margin: 0;
    max-width: min(90vw, 60ch);
    padding: 12px 16px;
    background: var(--bg-color);
    border: 1px dashed var(--fg-color);
    font-family: var(--font-mono);
    font-size: 0.85rem;
    text-align: center;
    z-index: var(--z-controls);
}

/* --- Small screens -------------------------------------------------------- */

/* Below this width the top row cannot hold the language toggle on the left and
   the five nav buttons on the right at the same time, so the nav moves to a
   scrollable bottom bar -- within thumb reach on a phone, and simply out of the
   way of the toggle on a small window -- and the zoom cluster goes with it.
   The threshold is 900px because the five buttons measure 698px and the toggle
   ends at 120px, so they start overlapping at 848px: it was 768px, which left
   the 769-848px band sharing one row and the toggle unclickable below ~810px.
   The 52px of headroom above 848px covers variation in the rendered width of
   the labels between the mono font and its fallback. */
@media (max-width: 900px) {
    /* The five buttons are replaced by the index at this width rather than
       scrolled: 385px of a 609px bar sat off screen at 390px, so four of the
       five sections had no reachable button. They stay in the DOM because the
       index rows are generated from them and the current-view state still
       reports through them. */
    #control-panel {
        display: none;
    }

    /* .is-ready is added by script.js once the index exists. Without
       JavaScript the dialog cannot be opened at all, and a trigger that does
       nothing is the same mistake as telling a visitor to drag a canvas that
       cannot pan -- the noscript note already explains the situation. */
    /* Above the bar rather than behind it. */
    #drag-hint {
        bottom: calc(84px + env(safe-area-inset-bottom));
        font-size: 2rem;
    }

    #index-open.is-ready {
        display: flex;
        align-items: center;
        justify-content: flex-start;
        gap: 10px;
        position: fixed;
        right: 0;
        bottom: 0;
        left: 0;
        /* Right padding leaves the two zoom buttons their corner of the bar,
           and the min-height gives them room to sit inside it: at 44px tall
           they broke the bar's top border by 6px without it. */
        padding: 8px 112px calc(8px + env(safe-area-inset-bottom)) 16px;
        min-height: 60px;
        background: var(--bg-color);
        border: none;
        border-top: 1px dashed var(--fg-color);
        color: var(--fg-color);
        font-family: var(--font-mono);
        font-weight: bold;
        font-size: 0.8rem;
        cursor: pointer;
        z-index: var(--z-controls);
    }
    .index-mark {
        flex: 0 0 auto;
    }
    .index-mark line {
        stroke: var(--fg-color);
        stroke-width: 2;
        stroke-linecap: square;
    }
    /* The readout is the only text in the bar now, so it sits beside the mark
       and the pair reads as one control. It used to be pushed to the far end,
       opposite an "[ INDEX ]" label, and to be set quieter than that label
       because it was the status and the label was the action. With the label
       gone it is both: full weight, full strength, and one step up in size,
       because a 12.8px status tucked in a corner is not a primary control.
       The bar's own justify-content goes with it, from space-between to
       flex-start: there is no longer a second item for it to push apart. */
    #index-current {
        font-size: 0.9rem;
    }

    /* Into the bar, not floating above it. Anything fixed over a canvas that
       fills the viewport will cover content somewhere; the difference is
       whether it reads as chrome or as debris. A strip along one edge that
       content passes under is the former, which is how a fixed header behaves
       on any scrolling page. A three-button island parked halfway up the right
       edge is the latter -- it sat in the middle of THE NAME's prose, and
       before that on the wordmark and on a card's description.
       Reset is not here: the index carries it as WHOLE DRAWING, which is a
       label rather than a glyph, and the state still reports through the row.

       Raised, or it is not in the bar at all. The bar reserves 112px of right
       padding for this cluster and the cluster is positioned into that gap,
       but both sat at --z-controls and the bar comes later in the DOM, so an
       opaque full-width bar painted straight over the top of it. The buttons
       were invisible and every tap aimed at + or - opened the index instead,
       on every viewport under 900px -- while the hint in the middle of the
       screen was telling phone visitors to zoom in to read. Pinch still
       worked, which is why it survived: the drawing did zoom, just never from
       the control. */
    #zoom-controls {
        flex-direction: row;
        top: auto;
        bottom: calc(8px + env(safe-area-inset-bottom));
        right: 10px;
        transform: none;
        gap: 8px;
        z-index: var(--z-controls-raised);
    }
    #zoom-controls button {
        width: 44px;
        height: 44px;
    }
    /* The vertical stack shared edges between the buttons; a row does not. */
    #zoom-controls button:first-child {
        border-bottom: 1px dashed var(--fg-color);
    }
    #zoom-controls button[data-reset] {
        display: none;
    }

    #lang-toggle {
        top: calc(12px + env(safe-area-inset-top));
        left: 12px;
        padding: 12px 14px;
        font-size: 0.75rem;
    }

    #noscript-note {
        bottom: 80px;
    }
}

/* --- Reduced motion ------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
    .drag-hint-mark {
        animation: none;
    }
    /* The rotation is orientation, not motion, so it stays. */
    #drag-hint.teaches-zoom .drag-hint-mark {
        animation: none;
        transform: rotate(45deg);
    }
    .contact-links a,
    #control-panel button,
    #zoom-controls button,
    #lang-toggle {
        transition: none;
    }

    /* The sheet still has to arrive and leave over some duration -- the
       allow-discrete transition is what keeps a closing dialog in the top layer
       long enough to be seen at all -- so it crossfades in place rather than
       travelling, and the rows arrive with it instead of being drawn in. */
    #sheet-index {
        translate: none;
        opacity: 0;
        transition:
            opacity 120ms linear,
            display 120ms allow-discrete,
            overlay 120ms allow-discrete;
    }
    #sheet-index[open] {
        opacity: 1;
    }
    @starting-style {
        #sheet-index[open] {
            opacity: 0;
        }
    }
    #sheet-index[open] .sheet-entry {
        animation: none;
    }

    /* The fade itself stays -- it is legibility, not decoration -- but it
       arrives instantly rather than easing in. */
    .lod.lod {
        transition: none;
    }
}

/* --- Legibility at low zoom ------------------------------------------------ */

/* At the overview the whole 6000px drawing is fitted to the viewport, and on a
   phone that is a reduction of about 1:15. Measured at that framing: sector
   titles land at 2.8 screen px, card descriptions at 1.8, status stamps at 1.5.
   Below roughly 3px a glyph is smaller than the features that make it a glyph,
   so the rasteriser returns grey smear -- the text does not read as small, it
   reads as broken.

   So stop drawing what cannot be read, and let it fall back to tone. A block of
   prose at low opacity still shows where the annotation sits and how big it is,
   which is what an overview is for; it just stops pretending to be words. That
   is the semantic zoom a drawing already implies: from across the room you see
   the layout and the headings, and you step closer to read the notes.

   The fade is driven by each element's own authored size times the current
   scale, so every tier crosses its threshold when it personally becomes
   illegible rather than at one scale picked for all of them. script.js measures
   the authored size into --fs and tags the elements, re-measuring on resize
   because the breakpoints change some of those sizes.

   Fully drawn at 4.5 screen px and above, floor of 0.22 at 2.5 and below,
   linear between. Chosen so the desktop overview is untouched -- its smallest
   text lands at 4.8px, above the threshold -- and the phone overview, where
   nothing clears 3px except the wordmark and the hand notes, is the case that
   changes.

   Multiplied by --ink rather than replacing it. Four of these tiers are already
   deliberately quiet (the status stamp at 0.65, the hand notes at 0.9), and
   those two decisions are about hierarchy, not about zoom; overriding one with
   the other would lose whichever came second. It also keeps the contrast
   reasoning on .project-status intact, because at any zoom where the text is
   meant to be read this factor is exactly 1.

   Doubled class for specificity: it has to outweigh `.stack dt` and
   `.project .project-status`, which set --ink and would otherwise win on their
   own opacity declaration. */
.lod.lod {
    /* The fade is applied once the camera settles rather than on every frame of
       a zoom, so it arrives as one step. The transition is what turns that step
       into the drawing resolving as you stop moving it.

       border-color rides along for the quiet rules. Theirs is a different
       fade -- --rule-ink, which tracks --rule-scale and so does move during the
       gesture -- but it is quantised to half-octaves and would otherwise arrive
       as four or five visible pops on the way out. Every element that carries a
       quiet rule is already tagged .lod, so this is the one place it needs
       saying. */
    transition: opacity 220ms ease, border-color 220ms ease;
    --lod-f: var(--lod-floor, 0.22);
    opacity: calc(
        var(--ink, 1) * clamp(
            var(--lod-f),
            calc(var(--lod-f) + (1 - var(--lod-f)) * (var(--fs, 16) * var(--canvas-scale, 0.4) - 2.5) / 2),
            1
        )
    );
}

/* The headings used to carry a --lod-floor here, holding them visible at the
   overview because at 2rem they faded to nearly the same weight as the card
   titles inside them. Sizing them properly removed the reason: a 3rem heading
   clears the legibility threshold on its own, so the fade can be left to
   measure what it measures rather than being argued with. */

