/* ════════════════════════════════════════════════════════════════════════
   css/work.css — /work (the fork) and /work/software (the technology
   portfolio). service.tnaado.ca · Tier 2, no storm.

   TWO PAGES, ONE SHEET, because they share a ground, a rail, a type scale and
   the chrome offset, and two copies of those is how the old site's twenty pages
   started to drift. Nothing in here touches css/chrome.css: that sheet is built
   and verified and is linked, not overridden, with one exception marked below.

   COLOUR, LOCKED AND MEASURED. --red #c8102e is 3.37:1 on ink and is therefore
   used ONLY for rules, fills and display type >= 24px. Every small red text run
   in this file is --red-ink #ef5c6e (6.06:1 on ink). Body copy is --bone
   (17.4:1); secondary copy is --dim-ink #b9b3a6 (9.4:1); mono metadata is
   --dim-ink too, never a lower alpha. There is no rgba(bone,.4x) anywhere —
   that value is 3.77:1 and it is the thing the chrome sheet was written to stop
   reproducing.

   DEGRADATION. Everything in this file paints with no JavaScript. Two behaviours
   need script and both are additive: html.sw-live turns the classification
   tally into filter buttons, and .is-error is written by an onerror handler.
   Neither hides content. The eight displays, their images, the systems list,
   the app links and the freshness stamp are all in the served HTML, in flow, in
   reading order.
   ════════════════════════════════════════════════════════════════════════ */

:root {
  --ink:       #0a0a0a;
  --plate:     #0d0d0d;
  --plate-2:   #131313;
  --bone:      #f5f1e8;
  --bone-2:    #ebe5d8;
  --red:       #c8102e;
  --red-ink:   #ef5c6e;   /* 6.06:1 on ink — the only small red on ink */
  --red-bone:  #a4132c;   /* 6.6:1 on bone */
  --dim-ink:   #b9b3a6;   /* 9.4:1 on ink  */
  --hair-ink:  rgba(245,241,232,.14);

  --serif: 'Bodoni Moda', 'Times New Roman', Times, serif;
  --sans:  'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  --mono:  'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;

  --rail: 1136px;            /* the old site's shared rail, kept so a reader
                                moving between old and new pages does not see
                                the column jump */
  --gut:  clamp(1.25rem, 4vw, 3rem);

  /* THE CHROME OFFSET. The menu bar is fixed only inside html.chrome-live, and
     its height is driven by the 26px logo plus .8rem of padding either side
     (css/chrome.css) — 26 + 12.8 + 12.8 = 51.6, plus the hairline this file
     adds = 53. A constant rather than a measurement because a measured value
     would need script, and the bar is a fixed 26px lockup at every width. */
  --bar: 53px;

  --ease: cubic-bezier(.22, 1, .36, 1);
}

*, *::before, *::after { box-sizing: border-box; }

/* Page-local visually-hidden. css/chrome.css scopes its .u-vh to .chrome and
   .chrome-foot, so a page that uses the class outside those wrappers gets no
   rule and the text renders — measured: the disabled pill printed ", none
   published" on screen. */
.u-vh {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
}

html {
  overflow-x: clip;
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
  /* In-page anchors must not land under the fixed bar. */
  scroll-padding-top: calc(var(--bar) + 1rem);
}
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }

body {
  margin: 0;
  background: var(--ink);
  color: var(--bone);
  font-family: var(--sans);
  font-size: 1rem;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
}
/* Only while the bar is fixed. With no JS the bar is a block in flow and the
   page must NOT reserve space for it as well. */
html.chrome-live body { padding-top: var(--bar); }

img { max-width: 100%; display: block; }

/* The one chrome rule this file adds, and it is additive: with no bypass row
   under it the fixed bar has no lower edge, because css/chrome.css draws that
   edge on .bypass. These pages are not storm pages — there is no animation to
   bypass, so a five-beat row pointing at sections that do not exist would be a
   lie — so the edge is drawn here instead. Nothing is overridden. */
html.chrome-live .menubar { border-bottom: 1px solid var(--hair-ink); }

/* ── RAIL AND SECTIONS ─────────────────────────────────────────────────── */
.rail { width: 100%; max-width: var(--rail); margin: 0 auto; padding: 0 var(--gut); }


/* ── THE 2026-08-22 DENSITY PASS ──────────────────────────────────────────
   Ethan: "sizing of sections and images and booking forms all too large ...
   like you're treating the reader as a toddler". The root pass had already
   brought css/reference.css's heading scale down to h1 3.5rem / h2 2.125rem /
   h3 1.4375rem and css/site.css's section padding to ~4.25rem. This sheet is
   brought onto those same two numbers, because five sheets each inventing
   their own display size is how a site ends up shouting on one page and
   whispering on the next. Every value below is the reference scale, not a
   new one. */
.band { position: relative; padding: clamp(2.25rem, 4.6vw, 4rem) 0; }
.band + .band { border-top: 1px solid var(--hair-ink); }
.band--tight { padding-block: clamp(1.75rem, 3.2vw, 2.75rem); }

/* The eyebrow idiom from the live site: a short red rule, a mono numeral, a
   label. Kept because a reader who learns one section header can read them all. */
.eyebrow {
  display: flex; align-items: center; gap: .75rem;
  margin: 0 0 1.1rem;
  font-family: var(--mono); font-size: .75rem; font-weight: 500;
  letter-spacing: .16em; text-transform: uppercase; color: var(--dim-ink);
}
.eyebrow__rule { display: block; width: 2.25rem; height: 2px; background: var(--red); flex: 0 0 auto; }
.eyebrow__num { color: var(--red-ink); }

h1, h2, h3 { font-family: var(--serif); font-weight: 500; margin: 0; line-height: 1.02; }
h1 { font-size: clamp(2.25rem, 5.2vw, 3.5rem); letter-spacing: -.015em; }
h2 { font-size: clamp(1.625rem, 3.1vw, 2.125rem); letter-spacing: -.01em; }
h3 { font-size: clamp(1.1875rem, 1.9vw, 1.4375rem); }
h1 em, h2 em { font-style: italic; color: var(--bone-2); }

.lede {
  margin: 1.15rem 0 0; max-width: 62ch;
  font-size: clamp(1.0625rem, 1.5vw, 1.1875rem); line-height: 1.62;
  color: var(--bone);
}
.note { margin: .9rem 0 0; max-width: 68ch; color: var(--dim-ink); font-size: .9375rem; }

/* A number with its arithmetic attached. Mono, so the derivation reads as a
   calculation and not as a claim. */
.deriv {
  margin: 1.5rem 0 0; padding: .9rem 0 0;
  border-top: 1px solid var(--hair-ink);
  font-family: var(--mono); font-size: .8125rem; line-height: 1.85;
  color: var(--dim-ink); max-width: 74ch;
}
.deriv b { color: var(--bone); font-weight: 500; }
.deriv code, .stamp code { font-family: inherit; font-size: 1em; color: var(--bone-2); }
.deriv .op { color: var(--red-ink); }

/* ── LINKS AND BUTTONS, EVERY STATE ────────────────────────────────────── */
a { color: var(--bone); }
a:focus-visible,
button:focus-visible,
[tabindex]:focus-visible {
  outline: 2px solid var(--red-ink);
  outline-offset: 3px;
  border-radius: 1px;
}

.cta {
  display: inline-flex; align-items: center; gap: .7rem;
  margin-top: 1.75rem; padding: 1rem 1.6rem;
  background: var(--red); color: #fff; text-decoration: none;
  font-family: var(--sans); font-size: .8125rem; font-weight: 600;
  letter-spacing: .14em; text-transform: uppercase;
  border: 1px solid var(--red);
  transition: background .2s var(--ease), transform .2s var(--ease);
}
.cta i { font-style: normal; transition: transform .2s var(--ease); }
@media (hover: hover) and (pointer: fine) {
  .cta:hover { background: #a4132c; }
  .cta:hover i { transform: translateX(4px); }
}
.cta:active { transform: translateY(1px); }

.tlink {
  display: inline-flex; align-items: baseline; gap: .45rem;
  color: var(--red-ink); text-decoration: none;
  font-family: var(--mono); font-size: .8125rem;
  border-bottom: 1px solid rgba(239,92,110,.35);
  padding-bottom: 1px;
}
@media (hover: hover) and (pointer: fine) {
  .tlink:hover { color: var(--bone); border-bottom-color: var(--bone); }
}
.tlink:active { color: var(--bone); }

/* ══════════════════════════════════════════════════════════════════════════
   /work — THE FORK
   Two routes and nothing else. Ethan: "the fork — states there are two, sends
   you into one." No storm, no carousel, no third option, no merged grid: the
   two portfolios sell to different buyers and a single combined portfolio was
   proposed once and rejected.
   ══════════════════════════════════════════════════════════════════════════ */
/* Sized so the whole fork lands within one 1440x900 viewport: at that size the
   two routes, both counts and both cues are on screen without scrolling.
   Measured docHeight after the trim is reported in this build's notes. */
.forkhead { padding: clamp(2rem, 5vw, 3.25rem) 0 clamp(1.5rem, 3vw, 2.25rem); }
/* The fork chooser had its own display size one step under the old 5.5rem h1.
   With the base h1 on the reference scale there is nothing left to step down
   from, and a second size here would be a sixth scale on the site — so it is
   restated rather than dropped, because `inherit` on font-size resolves to the
   PARENT's size (16px) and not to the h1 rule, which is how /work's headline
   measured 16px for one round. */
.forkhead h1 { font-size: clamp(2.25rem, 5.2vw, 3.5rem); }
.forkhead .lede { margin-top: .95rem; }

.routes {
  display: grid; gap: clamp(1.25rem, 2.5vw, 1.75rem);
  padding-bottom: clamp(1.75rem, 3.5vw, 2.5rem);
}
@media (min-width: 900px) { .routes { grid-template-columns: 1fr 1fr; } }

.route {
  position: relative; display: flex; flex-direction: column;
  text-decoration: none; color: var(--bone);
  background: var(--plate);
  border: 1px solid var(--hair-ink);
  transition: border-color .2s var(--ease), background .2s var(--ease);
}
.route__frame { position: relative; aspect-ratio: 16 / 9; overflow: hidden; background: var(--plate-2); }
/* A PHONE SCREEN IN A 16:9 FRAME. The technology route now leads with a real
   app capture (322x700) instead of a landscape browser screenshot, and cover
   would crop a portrait handset to a horizontal sliver of itself. So this
   variant contains it, centres it, and lets the plate show around it — the
   frame keeps its 16:9 so the two route cards stay the same shape. */
.route__frame img {
  width: 100%; height: 100%; object-fit: cover;
  filter: saturate(.82) contrast(1.04);
  transition: transform .5s var(--ease), filter .3s var(--ease);
}
/* A PHONE SCREEN IN A 16:9 FRAME. The technology route now leads with a real
   app capture (322x700) rather than a landscape browser screenshot, and `cover`
   cropped a portrait handset to a horizontal sliver of its own middle —
   unrecognisable as a phone, which is the one thing it has to be.

   The handset is therefore TALLER than the frame and top-aligned, so it rises
   out of the card and is cut off at the bottom by the same gradient that ends
   the photograph on the media card. Both cards keep 16:9, so the pair still
   reads as a matched set. No saturate/contrast filter: that grade is for
   photography and it discolours a UI capture. */
.route__frame--app { background: var(--ink); display: flex; justify-content: center; }
.route__frame--app img {
  width: auto; height: 132%; margin-top: 1.25rem;
  object-fit: contain; object-position: 50% 0;
  filter: none;
  border: 1px solid var(--hair-ink); border-bottom: 0;
  border-radius: 16px 16px 0 0;
}
@media (hover: hover) and (pointer: fine) {
  .route:hover .route__frame--app img { transform: translateY(-6px); filter: none; }
}

.route__frame::after {
  content: ''; position: absolute; inset: 0;
  background: linear-gradient(to bottom, rgba(10,10,10,.15) 0%, rgba(10,10,10,.72) 78%, var(--plate) 100%);
}
.route__body { position: relative; padding: clamp(1.1rem, 2vw, 1.5rem) clamp(1.25rem, 2.5vw, 1.75rem) clamp(1.35rem, 2.5vw, 1.75rem); }
.route__kicker {
  margin: 0 0 .6rem; font-family: var(--mono); font-size: .6875rem; font-weight: 500;
  letter-spacing: .18em; text-transform: uppercase; color: var(--red-ink);
}
.route__name { font-size: clamp(1.75rem, 3.6vw, 2.5rem); }
.route__line { margin: .7rem 0 0; color: var(--bone); font-size: .9375rem; line-height: 1.6; }
.route__count {
  display: flex; flex-wrap: wrap; gap: .4rem 1.1rem;
  margin: 1.1rem 0 0; padding-top: .85rem;
  border-top: 1px solid var(--hair-ink);
  font-family: var(--mono); font-size: .8125rem; color: var(--dim-ink);
}
.route__count b { color: var(--bone); font-weight: 500; }
.route__go {
  display: inline-flex; align-items: center; gap: .6rem;
  margin: 1.15rem 0 0;
  font-family: var(--sans); font-size: .75rem; font-weight: 600;
  letter-spacing: .14em; text-transform: uppercase; color: var(--red-ink);
}
.route__go i { font-style: normal; transition: transform .2s var(--ease); }
@media (hover: hover) and (pointer: fine) {
  .route:hover { border-color: var(--red); background: var(--plate-2); }
  .route:hover .route__frame img { transform: scale(1.03); filter: saturate(1) contrast(1.06); }
  .route:hover .route__go { color: var(--bone); }
  .route:hover .route__go i { transform: translateX(5px); }
}
.route:active { border-color: var(--red-ink); }
.route:focus-visible { outline: 2px solid var(--red-ink); outline-offset: 3px; }

/* padding-BOTTOM only. This element carries .rail as well, and a shorthand
   `padding` here resets .rail's horizontal gutter to 0 — measured, the note sat
   48px left of every other line on the page. */
.forkfoot {
  padding-bottom: clamp(3.5rem, 7vw, 4.5rem);
  font-family: var(--mono); font-size: .75rem; color: var(--dim-ink);
}

/* ══════════════════════════════════════════════════════════════════════════
   /work/software — THE DISPLAYS
   A STACK, not a grid: every featured_image in portfolio_items is a full-page
   1600x1000 capture, and in a three-up grid at 1440 that lands on screen at
   29% scale — a thumbnail of a website, which proves nothing. One row, one
   display, the full width of the rail.
   ══════════════════════════════════════════════════════════════════════════ */

/* THE CLASSIFICATION TALLY, and why there are two of it.
   With no script the row is a sentence of counts — informative, complete, and
   not a set of dead buttons. With script, js upgrades it: html.sw-live swaps
   the sentence for the buttons. The counts in both are written by one build
   pass, so they cannot disagree. */
.tally {
  margin: 1.5rem 0 0; font-family: var(--mono); font-size: .8125rem;
  color: var(--dim-ink); line-height: 1.9;
}
.tally b { color: var(--bone); font-weight: 500; }
.tally s { color: var(--dim-ink); opacity: .75; }
html.sw-live .tally { display: none; }

.pills { display: none; flex-wrap: wrap; gap: .5rem; margin: 1.5rem 0 0; }
html.sw-live .pills { display: flex; }
.pill {
  display: inline-flex; align-items: baseline; gap: .5rem;
  padding: .55rem .95rem;
  background: transparent; color: var(--dim-ink);
  border: 1px solid var(--hair-ink);
  font-family: var(--mono); font-size: .75rem; font-weight: 500;
  letter-spacing: .06em; cursor: pointer;
  transition: color .18s var(--ease), border-color .18s var(--ease), background .18s var(--ease);
}
.pill__n { color: var(--red-ink); font-size: .6875rem; }
@media (hover: hover) and (pointer: fine) {
  .pill:hover { color: var(--bone); border-color: rgba(245,241,232,.34); }
}
.pill:active { background: rgba(245,241,232,.06); }
.pill[aria-pressed="true"] {
  color: var(--ink); background: var(--bone); border-color: var(--bone);
}
.pill[aria-pressed="true"] .pill__n { color: var(--red-bone); }
/* A CLASSIFICATION WITH ZERO ROWS IS SHOWN, STRUCK THROUGH. Hiding it would
   lie about the shape of the dataset: `incubator` is one of the five values the
   table's classification column takes, and "no incubator work published" is a
   fact a buyer is entitled to read off the page. It stays focusable and
   announces itself as unavailable rather than vanishing from the tab order. */
.pill[aria-disabled="true"] {
  text-decoration: line-through;
  text-decoration-color: var(--red-ink);
  color: var(--dim-ink);
  /* MEASURED. .58 composited to rgb(113,109,102) on the plate — 3.78:1, which
     fails AA at 11px. .74 gives rgb(140,136,126) = 5.49:1, still visibly
     recessed against the live pills' 9.31:1 but readable. Dimmed has to mean
     dimmer, not unreadable: the whole reason this pill is on the page is so a
     reader can learn that the classification exists and holds nothing. */
  opacity: .74;
  cursor: not-allowed;
  border-style: dashed;
}
/* The numeral loses its red inside a struck pill. MEASURED: --red-ink at .74
   composites to 3.72:1 at 11px and fails; --dim-ink lands at the pill's own
   5.54:1. It is also the right call semantically — the 0 is the fact the pill
   exists to state, not an accent on it. */
.pill[aria-disabled="true"] .pill__n { color: var(--dim-ink); }
@media (hover: hover) and (pointer: fine) {
  .pill[aria-disabled="true"]:hover { color: var(--dim-ink); border-color: var(--hair-ink); }
}

.swcount {
  margin: 1.25rem 0 0; font-family: var(--mono); font-size: .8125rem; color: var(--dim-ink);
}
.swcount b { color: var(--bone); font-weight: 500; }

/* THE STACK. min-height so eight displays filtered down to one do not leave a
   ragged edge where seven used to be — the container keeps a floor and the
   page does not jump under the reader's cursor. */
.swstack {
  margin-top: clamp(2rem, 4vw, 3rem);
  display: flex; flex-direction: column; gap: clamp(2.5rem, 5vw, 4rem);
  min-height: 62vh;
}

.swd { display: block; }
.swd[hidden] { display: none; }

/* THE FIXED FRAME WITH OPTIONAL CONTENTS. Five of the row's fields are
   optional, so the frame is declared in the stylesheet and never by the data:
   aspect-ratio 8/5 holds whether an image arrives, fails, or was never
   supplied. The plate sits UNDER the image at all times, so a 404 reveals a
   made thing rather than a hole. */
.swd__frame {
  position: relative; aspect-ratio: 8 / 5;
  background: var(--plate);
  border: 1px solid var(--hair-ink);
  overflow: hidden;
}
.swd__shot {
  position: absolute; inset: 0; z-index: 2;
  width: 100%; height: 100%; object-fit: cover; object-position: 50% 0;
  background: var(--plate);
}
.swd.is-error .swd__shot { display: none; }

/* ── STATE: EMPTY ──────────────────────────────────────────────────────────
   No image in the row. A hatched plate with the title set large: it has to read
   as a plate that was MADE — ruled edges, a centred title at display size, a
   quiet note — because a row with no capture is a fact about the record, not a
   fault in it. */
.swd__plate {
  position: absolute; inset: 0; z-index: 1;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: .9rem; padding: clamp(1.5rem, 4vw, 3rem); text-align: center;
  background-color: var(--plate);
  background-image: repeating-linear-gradient(
    45deg,
    rgba(245,241,232,.055) 0 1px,
    transparent 1px 9px
  );
}
.swd__plate::before,
.swd__plate::after {
  content: ''; position: absolute; left: 8%; right: 8%; height: 1px;
  background: var(--hair-ink);
}
.swd__plate::before { top: 9%; }
.swd__plate::after  { bottom: 9%; }
/* THE TITLE RESERVES TWO LINES, ALWAYS. .swd__plate is a centred column, so the
   height of this box decides where its first line lands. A one-line title and a
   two-line title therefore started at different heights: across the /work/software
   displays carousel, eleven titles sat at top 2224 and "Unwrinkled Skincare Inc."
   — the only one that wrapped — sat at 2212, exactly half a line-height (24.96px)
   above its siblings, because centring a taller box lifts its top edge.
   Reserving two lines makes every plate's box the same height whatever the title
   does, and because the text is top-aligned inside it the FIRST line is what
   coincides. 2.08em = 2 x the 1.04 line-height below, so it tracks the clamped
   font-size (and the smaller strip override in css/work-software-strip.css)
   without restating either. Which title wraps depends on the font that resolves
   for --serif, so pinning the geometry rather than the copy is what holds. */
.swd__plateTitle {
  font-family: var(--serif); font-weight: 500;
  font-size: clamp(1.75rem, 5vw, 3.5rem);   /* >= 24px, so display red is legal here */
  line-height: 1.04; color: var(--bone-2);
  max-width: 22ch;
  min-height: 2.08em;
}
/* Only the EMPTY state shows the note. It is scoped to .is-empty rather than
   left to be covered by a loaded image, so the two states are decided by one
   class and cannot both be true. */
.swd__plateNote {
  display: none;
  font-family: var(--mono); font-size: .75rem; letter-spacing: .1em;
  text-transform: uppercase; color: var(--dim-ink);
}
.swd.is-empty .swd__plateNote { display: block; }

/* ── STATE: ERROR ──────────────────────────────────────────────────────────
   The image 404'd at runtime. It must not look like EMPTY: a missing file is a
   fault, a row with no image is a fact. So the hatch turns red and changes
   angle, a red rule crosses the plate, and a fault chip names the failure and
   the file. Written by the page's onerror handler; with no script a broken URL
   simply shows the plate behind it, which is the honest floor. */
.swd.is-error .swd__plate {
  background-image: repeating-linear-gradient(
    -45deg,
    rgba(200,16,46,.16) 0 1px,
    transparent 1px 8px
  );
}
.swd.is-error .swd__plate::before,
.swd.is-error .swd__plate::after { background: var(--red); left: 0; right: 0; }
.swd__fault { display: none; }
.swd.is-error .swd__fault {
  display: block; max-width: 42ch;
  padding: .55rem .85rem;
  border: 1px solid var(--red);
  background: rgba(200,16,46,.1);
  font-family: var(--mono); font-size: .75rem; line-height: 1.55;
  color: var(--red-ink);
  overflow-wrap: anywhere;
}
.swd__fault b { display: block; color: var(--bone); font-weight: 500; letter-spacing: .1em; text-transform: uppercase; font-size: .6875rem; }
/* A row that HAS an image is never .is-empty, so the note and the fault chip can
   never appear together. */

/* ── THE LABEL, with its floor ─────────────────────────────────────────────
   min-height is what makes a THIN row keep its height: no short_description
   means the paragraph is omitted, not stubbed with placeholder text, and the
   block below the frame stays the same depth so a stack of eight has one
   rhythm whether or not the copy arrived. */
.swd__label {
  min-height: 12.5rem;
  padding-top: 1.25rem;
  display: flex; flex-direction: column; align-items: flex-start;
}
.swd__idx {
  margin: 0 0 .7rem; font-family: var(--mono); font-size: .75rem; color: var(--dim-ink);
  display: flex; align-items: center; gap: .55rem;
}
.swd__idx b { color: var(--bone); font-weight: 500; }
.swd__idx .sep { width: 1.75rem; height: 1px; background: var(--red); }
.swd__kind { color: var(--red-ink); letter-spacing: .1em; text-transform: uppercase; font-size: .6875rem; }
.swd__title { margin: 0; }
.swd__meta { margin: .55rem 0 0; font-family: var(--mono); font-size: .75rem; color: var(--dim-ink); }
.swd__desc { margin: .85rem 0 0; max-width: 68ch; color: var(--bone); font-size: .9375rem; line-height: 1.62; }
.swd__stack {
  list-style: none; margin: 1rem 0 0; padding: 0;
  display: flex; flex-wrap: wrap; gap: .4rem;
}
.swd__stack li {
  font-family: var(--mono); font-size: .6875rem; color: var(--dim-ink);
  padding: .28rem .55rem; border: 1px solid var(--hair-ink);
}
.swd__out { margin: 1.05rem 0 0; }

/* ── ZERO ROWS ─────────────────────────────────────────────────────────────
   The most important thing this page ever renders, and on the old site it is a
   bare <p> carrying an inline style="display:none". Composed here as a real
   panel: it says what happened, what the reader can do instead, and when the
   record was last confirmed — because the page a portfolio shows when it has
   nothing to show is the page that decides whether the firm looks empty or
   looks careful.

   It is selected in CSS, not script: the panel is shown when the stack holds no
   display. So it is correct with JavaScript off, and it cannot get out of step
   with the stack it describes. The @supports guard keeps it hidden on an engine
   without :has() rather than showing it under eight visible displays. */
.swzero { display: none; }
@supports selector(:has(*)) {
  .swstack:not(:has(.swd)) { min-height: 0; margin-top: 0; }
  .swstack:not(:has(.swd)) + .swzero { display: block; }
}
.swzero__inner {
  margin-top: clamp(2rem, 4vw, 3rem);
  padding: clamp(1.75rem, 4vw, 3rem);
  border: 1px solid var(--hair-ink);
  border-left: 3px solid var(--red);
  background:
    repeating-linear-gradient(45deg, rgba(245,241,232,.035) 0 1px, transparent 1px 10px),
    var(--plate);
}
.swzero__kicker {
  margin: 0 0 .9rem; font-family: var(--mono); font-size: .6875rem; font-weight: 500;
  letter-spacing: .18em; text-transform: uppercase; color: var(--red-ink);
}
.swzero h2 { font-size: clamp(1.6rem, 3.6vw, 2.25rem); }
.swzero p { margin: .95rem 0 0; max-width: 60ch; color: var(--bone); font-size: .9375rem; }
.swzero ul { margin: 1.1rem 0 0; padding-left: 1.1rem; color: var(--dim-ink); font-size: .9375rem; }
.swzero li { margin-top: .35rem; }
.swzero li a { color: var(--red-ink); text-decoration-color: rgba(239,92,110,.4); }
@media (hover: hover) and (pointer: fine) { .swzero li a:hover { color: var(--bone); } }

/* ── THE FRESHNESS STAMP ───────────────────────────────────────────────────
   The generated region carries an ISO timestamp and a row count. Printing it is
   the difference between a record and a brochure: a refresh that quietly
   stopped looks exactly like a portfolio that has not changed, unless the page
   itself says when it last checked. */
.stamp {
  margin: clamp(1.75rem, 3.5vw, 2.5rem) 0 0;
  padding-top: .9rem;
  border-top: 1px solid var(--hair-ink);
  font-family: var(--mono); font-size: .75rem; line-height: 1.8;
  color: var(--dim-ink);
  display: flex; flex-wrap: wrap; gap: .25rem 1.25rem;
}
.stamp b { color: var(--bone); font-weight: 500; }
.stamp__k { color: var(--red-ink); letter-spacing: .08em; text-transform: uppercase; font-size: .6875rem; }

/* ── S.02 — THE SYSTEMS ───────────────────────────────────────────────────
   Nineteen rows, so nineteen lines. Not nineteen displays: a system is proved
   by its name, its job and the fact that screenshots of it exist, and nineteen
   full-width captures would bury the eight client builds the page is really
   for. The strip below carries six frames as evidence. */
.syslist {
  list-style: none; margin: clamp(2rem, 4vw, 2.75rem) 0 0; padding: 0;
  display: grid; gap: 0;
  border-top: 1px solid var(--hair-ink);
}
@media (min-width: 860px) { .syslist { grid-template-columns: 1fr 1fr; column-gap: clamp(1.5rem, 3vw, 3rem); } }
.sys {
  display: grid; grid-template-columns: 2.6rem 1fr auto; gap: .25rem .75rem;
  align-items: baseline;
  padding: .95rem 0;
  border-bottom: 1px solid var(--hair-ink);
}
.sys__n { font-family: var(--mono); font-size: .75rem; color: var(--red-ink); }
.sys__name { font-family: var(--sans); font-size: .9375rem; font-weight: 500; color: var(--bone); }
.sys__shots { font-family: var(--mono); font-size: .6875rem; color: var(--dim-ink); white-space: nowrap; }
.sys__what { grid-column: 2 / -1; margin: .2rem 0 0; font-size: .8125rem; color: var(--dim-ink); line-height: 1.55; }

.strip {
  list-style: none; margin: clamp(2rem, 4vw, 2.75rem) 0 0; padding: 0;
  display: grid; gap: .75rem;
  grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 760px) { .strip { grid-template-columns: repeat(3, 1fr); } }
.strip li { margin: 0; }
.strip figure { margin: 0; }
.strip img {
  width: 100%; height: auto; aspect-ratio: 8 / 5; object-fit: cover; object-position: 50% 0;
  border: 1px solid var(--hair-ink); background: var(--plate);
}
.strip figcaption {
  margin-top: .5rem; font-family: var(--mono); font-size: .6875rem;
  color: var(--dim-ink); letter-spacing: .04em;
}

/* ── S.01 — THE APPS ──────────────────────────────────────────────────────
   MOVED TO THE FRONT OF /work/software, 2026-08-21. Ethan: "the porotlfio
   diplay only show sinetrnal systems we should show apps ... you tlak abotu
   apps but dont show them". The section was last and was two text cards.

   Nine built, two live. The site once said nine were live and that was false;
   it is the most-corrected error in this project, so the two that are on the
   store are named, linked and dated, and the other seven are NAMED rather than
   counted — five by name, two as "finished, unannounced", which is what they
   are.

   THE CARD LEADS WITH THE STORE BUTTON, not a link in a paragraph, because the
   button is the whole point of the section: it is the only claim on this site a
   stranger can verify on his own phone in thirty seconds. */

/* ══ THE APP GRID ═════════════════════════════════════════════════════════
   Built 2026-08-21 to the shape of tnaado.ca/apps, which Ethan named as the
   pattern to copy: one card per app, each with a real screen, a name, a
   category, one plain line of what it does, and its publisher. The structure
   and the register are copied; none of that page's stylesheet is — it is
   dark-default with its own tokens and this site has its own.

   THE SCREEN IS THE CARD'S SUBJECT, not decoration, so it gets the top of the
   card at its true 322x700 portrait aspect and is never cropped to a landscape
   box. A phone screen letterboxed into a 16:9 frame stops reading as a phone,
   which is the one thing these frames have to do.

   Citadel is macOS, so its capture is a landscape window. .appc--wide gives it
   a wider slot of its own in the strip, and keeps the phone-card layout off it,
   rather than squeezing a 1200x768 window into a portrait slot and pretending
   it is a handset. */

/* ══ ONE ROW, NOT THREE — REARRANGED 2026-08-22 ═══════════════════════════
   MEASURED FIRST, THEN CHANGED. At 1440 the four-column grid was 1040x1874 for
   nine cards — about 2.1 viewport heights — and inside each 259px frame the
   phone was 160x304. Ninety-nine pixels of empty frame beside EVERY screenshot,
   62% fill, on all nine. Ethan: "the app screenshot section is to large and the
   screenshots border deisgn is to small cuting content off".

   BOTH HALVES ARE ONE DEFECT. The phone's width was derived from a height cap
   (max-height 19rem x the capture's 0.46 aspect = 141-160px), so the frame was
   sized for something far wider than anything that could ever sit in it. The
   screenshot read as small because it WAS small, and stranded because the box
   around it was built to another measurement entirely.

   RESIZING CANNOT SOLVE IT. A bigger phone is a taller card is a longer
   section; a shorter section is a smaller phone. Five columns was tried and
   MEASURED WORSE — dead width fell 99px -> 47px, but the narrower card wrapped
   the body copy taller and the section GREW, 1898px -> 1970px. The dead width
   is real and it is not what makes this section long.

   So the ARRANGEMENT changed, not the sizes. Two moves, and they are the same
   move seen twice:

   1. NINE CARDS ON ONE HORIZONTALLY-SCROLLING ROW, in the ported carousel
      (js/carousel.js + css/components/carousel.css). Three stacked rows became
      one, which is the "too large" complaint answered outright. This page
      ALREADY mounts that carousel once, for the client strip in S.02 — there is
      no second strip mechanic here and there must not be one.

   2. THE TEXT MOVED INTO THE DEAD SPACE. The 99px beside each screenshot was
      dead because nothing was in it. Above 48rem the card runs phone-left,
      copy-right, so the frame's spare width is the column the copy lives in and
      the card's height is just the phone's. That is what pays for the phone
      being bigger: the vertical budget freed by dropping two rows and by no
      longer stacking 275px of text UNDER every screenshot.

   THE PHONE IS SIZED BY WIDTH NOW, NOT BY A HEIGHT CAP. --appc-w sets the
   frame's inner width and the height follows the capture's own aspect, so the
   frame can no longer be wider than the thing it frames. Whatever is left over
   is --appc-pad and nothing else. If this section ever needs retuning, change
   --appc-w and stop; every other number here is derived from it.

   [data-rise] MOVED OFF THE CARDS ONTO THE STRIP, and this is not optional.
   The page's reveal (html.sw-anim in css/work-software.css) arms [data-rise] at
   opacity 0 and an IntersectionObserver clears it. A card scrolled off the
   RIGHT edge of a horizontal viewport is never sampled by that observer, so
   per-card rise would leave six of the nine invisible for the whole session.
   The client strip below carries one rise on its wrapper for the same reason. */

/* .appgrid IS THE STRIP'S WRAPPER NOW, NOT A GRID. It rides on the same element
   as .carou and its only job is to hold this section's two numbers, so the
   phone slot and the Mac slot cannot drift apart when one is tuned. */
.appgrid {
  --appc-pad: clamp(.7rem, 1.4vw, .95rem);
  --appc-w: clamp(11rem, 54vw, 15rem);        /* the phone's width, stacked */
  --appc-wide-w: clamp(17rem, 74vw, 32rem);   /* the Mac window's width */
}

/* .carou__item is `flex: 0 0 clamp(15.5rem, 78vw, 23rem)` and carousel.css
   loads AFTER this file, so a bare `.appc` rule would lose. Two classes beat
   one; that is the whole reason for the compound selector. */
.appgrid .appc {
  margin: 0; background: var(--plate);
  border: 1px solid var(--hair-ink);
  display: flex; flex-direction: column;
  flex: 0 0 calc(var(--appc-w) + 2 * var(--appc-pad) + 2px);
}

/* A LIVE APP IS THE LOUDER CARD. --red is a fill here, never type: the top
   edge is 2px of #c8102e, which is what that colour is for.

   A REAL BORDER, NOT AN INSET SHADOW. It was `box-shadow: inset 0 2px 0` and an
   inset shadow paints under child backgrounds, so .appc__shot's --ink fill sat
   on top of it: in the old stacked card the shot was the full width of the top
   edge and the red line was invisible outright, and once the shot moved to the
   left half the line survived over the copy and stopped dead at the frame.
   border-top paints above children and cannot be half-eaten. */
.appgrid .appc--live { background: var(--plate-2); border-top: 2px solid var(--red); }

.appc__shot {
  background: var(--ink);
  padding: var(--appc-pad) var(--appc-pad) 0;
  display: flex; justify-content: center; align-items: flex-end;
  overflow: hidden;
}
/* The handset frame. A real border and a real radius, because the capture has
   no device chrome of its own and a flat rectangle of UI does not read as a
   phone. NO max-height: the cap is what made the frame wider than the phone. */
.appc__shot img {
  width: 100%; height: auto; max-width: 100%;
  display: block;
  border: 1px solid var(--hair-ink); border-bottom: 0;
  border-radius: 14px 14px 0 0;
  background: var(--plate);
}

/* ── THE MAC CARD ────────────────────────────────────────────────────────
   Landscape, so it never takes the phone-card layout: it keeps the stacked
   shape at every width and gets a slot wide enough to read as a desktop
   window. Variable-width cards are fine here — js/carousel.js scrolls to a
   card's own offsetLeft, never by a fixed card width. */
.appgrid .appc--wide {
  flex: 0 0 calc(var(--appc-wide-w) + 2 * var(--appc-pad) + 2px);
  flex-direction: column;
}
.appc__shot--wide { align-items: center; padding-bottom: var(--appc-pad); }
.appc__shot--wide img { border-radius: 4px; border-bottom: 1px solid var(--hair-ink); }

.appc__body {
  padding: clamp(1rem, 2vw, 1.25rem);
  display: flex; flex-direction: column; gap: .4rem;
}
.appc__badge {
  align-self: flex-start; margin: 0;
  padding: .25rem .55rem; border: 1px solid var(--red);
  font-family: var(--mono); font-size: .625rem; font-weight: 500;
  letter-spacing: .16em; text-transform: uppercase; color: var(--red-ink);
}
/* Not on the store yet, so it does not get the red edge a live one has. */
.appc__badge--soon { border-color: var(--hair-ink); color: var(--dim-ink); }
.appc__name { margin: .2rem 0 0; font-size: clamp(1.15rem, 1.5vw, 1.35rem); line-height: 1.15; }
.appc__meta {
  margin: 0; font-family: var(--mono); font-size: .6875rem;
  letter-spacing: .06em; line-height: 1.5; color: var(--dim-ink);
}
.appc__what { margin: .3rem 0 0; color: var(--bone); font-size: .9375rem; line-height: 1.5; }

/* ── PHONE LEFT, COPY RIGHT ──────────────────────────────────────────────
   The move that pays for everything above. Below this width there is no spare
   column to put copy in, so the card stays stacked and the strip simply shows
   one big phone at a time — which is the right mobile reading of a section
   whose subject is a screenshot. */
@media (min-width: 48rem) {
  /* THE MAC CARD SETS THE ROW'S HEIGHT, so the phone is sized to spend it.
     A flex row is one height, and the stacked Mac card (a 1.5625:1 window over
     its own copy) came out ~508px while the phones were 431px — which put 85px
     of empty frame ABOVE every handset. That is the same defect Ethan reported,
     rotated ninety degrees, and it is free to fix: widening the phone until its
     natural height meets the Mac card's costs the section nothing, because the
     row was already that tall. Measured after: 518px -> 513px, and the phone
     went 200px wide to 228px on the way. */
  .appgrid {
    --appc-w: clamp(12rem, 17vw, 14.25rem);
    --appc-wide-w: clamp(20rem, 36vw, 32rem);
  }
  .appgrid .appc {
    flex-basis: clamp(25rem, 34vw, 31rem);
    flex-direction: row;
  }
  .appc__shot {
    flex: 0 0 calc(var(--appc-w) + 2 * var(--appc-pad));
    padding-bottom: 0;
  }
  .appc__body { flex: 1 1 auto; min-width: 0; justify-content: center; }

  /* THE MAC CARD'S OVERRIDES HAVE TO SIT AFTER .appgrid .appc, NOT BEFORE IT.
     Both selectors are two classes, so source order decides, and the earlier
     block lost: the wide card was being turned back to flex-direction: row and
     its shot then shrank to 32px with a 2x2 image in it. Measured, caught,
     moved. Keep these last. */
  .appgrid .appc--wide {
    flex-basis: calc(var(--appc-wide-w) + 2 * var(--appc-pad) + 2px);
    flex-direction: column;
  }
  .appgrid .appc--wide .appc__shot { flex: 0 0 auto; padding-bottom: var(--appc-pad); }
  .appgrid .appc--wide .appc__body { justify-content: flex-start; }
}

/* ══ THE APP STORE BADGE ══════════════════════════════════════════════════
   Apple's own artwork, served from assets/store/, and ONLY on the two apps that
   are actually on the store. Apple's marketing guidelines are strict and this
   respects all of them: the lockup is not recoloured, redrawn, cropped or
   rotated; it is served at its native 120x40 aspect, at the 40px minimum
   height; and the padding below is its clear space, which nothing may crowd.
   No filter, no opacity change and no hover recolour — a dimmed badge is a
   modified badge. The only hover affordance is the surrounding box. */
.storebadge {
  display: inline-block; margin-top: .9rem; align-self: flex-start;
  padding: 10px 10px 10px 0;          /* Apple's minimum clear space */
  line-height: 0; border-radius: 2px;
}
.storebadge img { width: 120px; height: 40px; display: block; }
.storebadge:focus-visible { outline: 2px solid var(--red-ink); outline-offset: 2px; }

.apps { display: grid; gap: clamp(1rem, 2vw, 1.25rem); margin-top: clamp(2rem, 4vw, 2.75rem); }
@media (min-width: 760px) { .apps { grid-template-columns: 1fr 1fr; } }
.app {
  padding: clamp(1.25rem, 2.5vw, 1.65rem);
  background: var(--plate); border: 1px solid var(--hair-ink);
}
.app__badge {
  display: inline-block; margin: 0 0 .75rem;
  padding: .25rem .55rem;
  border: 1px solid var(--red);
  font-family: var(--mono); font-size: .625rem; font-weight: 500;
  letter-spacing: .16em; text-transform: uppercase; color: var(--red-ink);
}
.app__name { font-size: clamp(1.35rem, 2.4vw, 1.65rem); }
.app__meta { margin: .55rem 0 0; font-family: var(--mono); font-size: .75rem; color: var(--dim-ink); }
.app__out { margin: 1rem 0 0; }

/* One line of what the app does, in the reader's language, above the button. */
.app__what { margin: .75rem 0 0; color: var(--bone); font-size: .9375rem; }

/* THE HONEST LINE, AND IT IS NOT SMALL PRINT. There is not one phone screen in
   this repo: all 65 files in assets/gallery/systems are LANDSCAPE browser
   captures, 1280x800 or 1600x1000, verified with sips. PHOTO-RULING.md records
   those dimensions transposed, which is where the belief that the repo holds
   app screens comes from. So the card says where the screens are instead of
   showing a console and calling it an app. */
.app__shots {
  margin: .85rem 0 0; padding-top: .75rem;
  border-top: 1px solid var(--hair-ink);
  font-family: var(--mono); font-size: .6875rem; line-height: 1.7;
  color: var(--dim-ink);
}

/* THE STORE BUTTON. Bone on ink, the site's one solid action. --red is not
   available at this size (3.37:1) and is used only for the badge rule. */
.app__out .btn {
  display: inline-flex; align-items: center; gap: .55rem;
  padding: .8rem 1.25rem; text-decoration: none;
  font-family: var(--sans); font-size: .9375rem; font-weight: 600;
  background: var(--bone); color: var(--ink);
  border: 1px solid var(--bone);
  transition: background .18s var(--ease), color .18s var(--ease);
}
.app__out .btn i { font-style: normal; }
@media (hover: hover) and (pointer: fine) { .app__out .btn:hover { background: #fff; } }
@media (prefers-reduced-motion: reduce) { .app__out .btn { transition: none; } }

/* The seven that are built and not published, in the same ledger shape as the
   nineteen systems, so a reader who has learned one list can read the other.
   The right-hand column carries the status rather than a shot count. */
.appslist .sys__shots { color: var(--dim-ink); opacity: .85; }

/* A sub-heading opening a group inside a section that already has content
   above it. Same value as css/reference-pricing.css's .grouph. */
.grouph { margin-top: clamp(2rem, 4vw, 3rem); }
.copy { margin: 1rem 0 0; max-width: 68ch; }
.copy--dim { color: var(--dim-ink); font-size: .9375rem; }

/* THE CAPTION QUALIFIER. Every frame in the apps section is a BROWSER screen of
   the operator's side, never the app, and the caption says so on its own line
   in red-ink so it cannot be skimmed past. This is the difference between
   evidence and a misrepresentation on the one page that asks to be checked. */
.strip__k {
  display: block; margin-top: .2rem;
  color: var(--red-ink); letter-spacing: .08em; text-transform: uppercase;
  font-size: .625rem;
}


/* ── BOOKING ──────────────────────────────────────────────────────────────
   Tier 2 ends in a booking CTA. Nothing follows it: the foot sheet is summoned,
   so the page can end on the ask. */
.book { padding: clamp(2.25rem, 4.6vw, 4rem) 0; }
.book__bar { height: 2px; background: var(--red); width: 4.5rem; margin-bottom: 1.5rem; }
.book__lines { margin: 1.35rem 0 0; font-family: var(--mono); font-size: .8125rem; color: var(--dim-ink); line-height: 1.9; }
.book__lines a { color: var(--red-ink); text-decoration-color: rgba(239,92,110,.4); }
@media (hover: hover) and (pointer: fine) { .book__lines a:hover { color: var(--bone); } }

/* ── BREADCRUMB ───────────────────────────────────────────────────────────
   A Tier 2 page is landed on cold from a paid ad, so it says where it sits. */
.crumb {
  margin: 0 0 1.5rem; padding: 0;
  font-family: var(--mono); font-size: .75rem; color: var(--dim-ink);
  display: flex; flex-wrap: wrap; align-items: center; gap: .5rem;
  list-style: none;
}
.crumb li { display: flex; align-items: center; gap: .5rem; }
.crumb li + li::before { content: '/'; color: var(--red-ink); }
.crumb a { color: var(--dim-ink); text-decoration: none; border-bottom: 1px solid transparent; }
@media (hover: hover) and (pointer: fine) { .crumb a:hover { color: var(--bone); border-bottom-color: var(--red); } }
.crumb a:active { color: var(--bone); }
.crumb [aria-current="page"] { color: var(--bone); }

@media (prefers-reduced-motion: reduce) {
  .route__frame img, .route__go i, .cta, .cta i, .pill { transition: none; }
}
