/* projects.css — section 4: the copy block and its rule. Spec: 257 §4.
   Report: 258. The conveyor this file described until 2026-09-16 is deleted
   (253 §1); what follows is a new section at the same path, so index.html's
   <link> and main.js's import are unchanged.

   ⚠️ THE THREE @font-face BLOCKS THAT WERE HERE MOVED TO css/fonts.css ON
   2026-08-31 (106 §3.5). They are not gone and nothing about the faces
   changed — but they were document-scoped declarations living in a section
   sheet, so every other section's type worked only because THIS file happens
   to load. ⚠️ TWO COMMENTS ELSEWHERE IN THE TREE STILL POINT HERE FOR THE
   FAMILY NAMES AND HAVE BEEN WRONG SINCE THAT MOVE: js/chrome/badge/card.js:291
   cites "projects.css:26,31" and css/chrome/bar.css:189 says "the name
   projects.css declares". Both are read-only citations, neither is in 257's
   scope, and neither is corrected here — recorded in 258 instead.

   ⚠️ STILL TRUE HERE AND STILL LOAD-BEARING, in its new form: font-display is
   swap, the swap changes every advance, and this section MEASURES text — the
   rule's width is the sentence's longest rendered line. index.js therefore
   re-measures on document.fonts.ready. Do not remove that listener on the
   grounds that the faces are declared elsewhere now.

   ── ⚠️ RETAINED RECORD — THE touch-action REGION RULE ────────────────────────
   The conveyor is gone but its most-cited finding is not, and FOUR live
   comments point at the lines it used to occupy — js/sections/hero/trail.js:411,
   css/sections/work.css:84, css/sections/work.css:124 and
   css/sections/hero-trail.css:17, all written as "projects.css:131-143".
   257 §3.6 forbids touching work.css, so those citations cannot be repointed
   in this round. They resolve HERE, by content rather than by line number:

       ⚠️ pan-y ON THE TRACK, none ON THE CARDS (076 §5.3), AND THE PM'S EARLIER
       PROPOSAL WAS THE OTHER WAY ROUND AND WRONG. `touch-action: pan-y` on the
       track alone would have meant vertical always scrolls the page — and in
       PORTRAIT the arc is steep and the cards travel mostly VERTICALLY, so that
       would have handed the section's primary gesture to the page scroller and
       left the conveyor unusable by its own natural motion.

       The split is by REGION, not by axis. The browser intersects touch-action
       from the hit element up to the nearest scrollable ancestor, so a card
       inside a pan-y track resolves to `none` and everything else resolves to
       pan-y. Grip therefore activates only when the touch starts on a card,
       which falls out of the same rule rather than needing a second one.

   Kept verbatim, and kept deliberately: 003 §8.2's ethic is that evidence about
   a TECHNIQUE outlives the thing it was measured on. Section 4 declares no
   touch-action of its own — it has no gesture — so nothing below contradicts it.
   ── end retained record ─────────────────────────────────────────────────── */

/* ── SIZING: hero.css's APPROACH, NOT work.css's (257 §4.2) ──────────────────
   The site has exactly two answers to responsive type and this is the second:
   ONE custom property carrying a viewport-unit expression, declared once on the
   section, with every consumer deriving from it in calc (hero.css:256-263 is
   the pattern). The first answer — measure the viewport on resize and write px
   from JS, work.css:543-545 — is NOT used here, and the reason is section 4's
   own flag guard: an unrecognised ?proj* flag THROWS (257 §4.7), so init() has
   a real path on which it never reaches a layout call. With the sizes in JS
   that path leaves the copy at the browser's default 16px at every viewport;
   with them here it leaves a correctly set, correctly placed section that is
   simply missing its sweep. C10 exercises that path.

   ⚠️ EVERY NUMBER BELOW IS "px AT 1440 CSS px WIDE", MEASURED ON THE PAGE ON
   2026-09-16 AT 1440x900, and written in that form so it can be re-checked
   without decoding a ratio:
     44.36     .work__lead's computed font-size  (257 §4.2's default title size)
     16.5153   .hero__body's computed font-size  (257 §4.2's default sentence size)
     33        Xinlu's sketch: copy line 1 is about 33% of the frame width (254 §3)
     13        Xinlu's sketch: the copy's left edge, % of the frame width (254 §3)
     12        Xinlu's sketch: the copy's top, % of the frame HEIGHT (254 §3)

   ⚠️ THE MEASURE AND BOTH SIZES SCALE WITH THE SAME 100vw, so the sentence's
   line count is scale-invariant by construction between the two clamp bounds —
   which is the property C7 tests at 1280, 1440 and 1920.

   ⚠️ THE CLAMP BOUNDS ARE THE SOURCE ELEMENTS' OWN, NOT INVENTED. 20/72 is
   panel.js:294's px(content * LEAD_RATIO, 20, 72) for .work__lead; 15/20 is
   hero.css:319's clamp(15px, …, 20px) for .hero__body. Where each binds is
   reported in 258 rather than left to be rediscovered: at 1280 the sentence
   sits on its 15px FLOOR (unclamped it would be 14.68) and at 1920 on its 20px
   CEILING (unclamped 22.02).

   ⚠️ AND THE FLAG MULTIPLIER IS APPLIED AFTER THE CLAMP, DELIBERATELY. Applied
   before it, ?projbody=1 and ?projbody=1.12 would both clamp to 20px at 1920
   and the flag would move no pixel there — "a flag that cannot move a pixel is
   a defect this site has now shipped four times" (238 session-13-handover §4.2).
   After the clamp it always moves one. */
#projects {
  --proj-left: 13vw;
  /* ⚠️ 12 % OF THE VIEWPORT HEIGHT, NOT OF A BAND (268 §3.1 #3). It was
     `12%` while the copy lived inside a one-viewport-tall band, which is
     the same number; the band is gone and the percentage would now resolve
     against the whole flowing section, so it is restated as the quantity it
     always meant — 254 §3's measured sketch, and 258 §4.3's 108.00 px at
     1440x900, unchanged. */
  --proj-top: calc(var(--section-h) * 0.12);

  /* ⚠️ THE GAP XINLU REJECTED, NOW A DIAL (268 §1 #1, §3.5). Measured on
     267's build: rule bottom to rearmost card top ran 571 / 765 / 874 px at
     1280x720 / 1440x900 / 1920x1080 — 0.79 to 0.85 of a viewport. His words:
     "the copy part of section 4 is quite far from the stacked video card
     part, as if these two parts aren't one whole within section 4."
     ?projstackgap= writes this; the fallback is the default. */
  --proj-stack-gap: calc(var(--section-h) * 0.07);
  /* The tail from the dots to section 5, mirroring the copy's 12 % top. */
  --proj-tail: calc(var(--section-h) * 0.12);

  /* ── ?projfloor= — max(), NEVER min() (259 §3.4) ──────────────────────────
     33vw alone runs the sentence to 4 and 5 lines between 768 px and about
     1,060 px, because across that whole range the type is pinned at its 15 px
     floor while the measure keeps shrinking (258 §7.3, cause measured in 260).
     max() can only ever WIDEN the measure, so wherever 33vw already exceeds
     the floor — 1440 and 1920 among them — this expression is 33vw to the
     pixel and round 1's composition is untouched. F3 is that check and its
     control is this same line written with min().

     ⚠️ THE FLOOR CANNOT REACH THE MOBILE STOPGAP, BY CONSTRUCTION rather than
     by a second guard: the @media block at the foot of this file overrides
     --proj-measure outright below 768 px, so --proj-floor is simply unused
     there. index.js writes --proj-floor only when the flag is present; unset,
     var() falls back to 0px and max(33vw, 0px) is 33vw. */
  --proj-measure: max(33vw, var(--proj-floor, 0px));

  --proj-title-size: clamp(20px, calc(100vw * 44.36 / 1440), 72px);
  --proj-body-size:  clamp(15px, calc(100vw * 16.5153 / 1440), 20px);

  /* ?projtitle= · ?projbody= · ?projgap=. index.js writes these ONLY when the
     flag is present, so a thrown init leaves all three at their defaults here
     rather than at nothing. */
  --proj-title-px: calc(var(--proj-title-size) * var(--proj-title-mul, 1));
  --proj-body-px:  calc(var(--proj-body-size)  * var(--proj-body-mul, 1));

  /* ── ⚠️ THE STYLESHEET DECLARES THE END STATE, NOT THE RESTING GREY ────────
     ⚠️⚠️ THIS REVERSES ROUNDS 1 AND 2 ON PURPOSE (261 §2.3, §3.2), AND THE
     REASON IS THE NEW DEFAULT GREY. Rounds 1-2 declared the resting grey here
     so that a reveal which never ran was visibly grey and could not hide
     behind the stylesheet — 157 §4.5's failure, read in reverse. That argument
     was sound while the resting grey was #8A8F9C at 6.49 : 1 or #5F6168 at
     3.40 : 1. At 261's #333333 it is 1.66 : 1, and the same line would turn
     ANY failure — a thrown flag guard, a module error caught by main.js's
     loop, a stylesheet that loads while a script does not — into near-invisible
     copy on the shop window (000 §1). **Fail readable wins.**

     ⚠️ IT IS ALSO THE SITE'S OWN STILL-PATH RULE, not a new idea: section 3's
     still path is the END of its timeline, tl.progress(1) (work/panel.js's
     header), for reduced motion, for no WebGL and for a lost context alike.
     Section 4 now says the same thing in CSS.

     ⚠️ WHAT IT COSTS, MEASURED RATHER THAN WAVED AT: between first paint and
     init()'s first paint the words show the END colours, so a reload with
     section 4 already on screen mid-sweep shows light words that then darken.
     262 §4 measures that window and says what it looks like. The trade was
     taken deliberately: the failure it removes is permanent and public, the one
     it adds is a few hundred milliseconds and only on a mid-sweep reload.

     ⚠️ CHANNELS, NOT COLOUR STRINGS, AND ONE LITERAL EACH. index.js reads these
     two properties back through getComputedStyle and interpolates from them, so
     the CSS fallback and the sweep's endpoint are the SAME number rather than
     two that must be kept in step — the drift 000 §5 catalogues. Space-separated
     channels mean the JS side needs three Number()s and no colour parser.
       hero = .hero__title's computed colour  (hero.css:194; read on the page,
              260 §1.2 and again in 262 §1.2)
       soft = .work__lead-b's computed colour at shipped defaults — color-mix(in
              srgb, #8A8F9C 45%, #FFFFFF), which Chrome reports as
              color(srgb 0.793529 0.802353 0.825294), i.e. these three numbers.
     ⚠️ Both are literals because no section imports another (core/input.js's
     header). W1 is what keeps each honest against the element it names. */
  --proj-white-hero: 255 255 255;
  --proj-white-soft: 202.35 204.6 210.45;

  /* ⚠️ ONE LITERAL, TWO CONSUMERS (266 §5.4). .work__rule's computed
     background at shipped defaults — work.css:648-655 with --work-rule
     written from ?workrulea=0.16 at panel.js:338, read on the page at
     1440x900 on 2026-09-16 (258 §3). The rule under the sentence used it
     as an inline literal until 266; the card border needs the same ink,
     and two copies of one value is the drift 000 §5 catalogues. */
  --proj-rule-ink: rgba(174, 182, 200, 0.16);

  background: #000;
}

/* ⚠️ NOT .section__inner. layout.css gives that class `text-align: center` and
   `will-change: opacity`, and section 4 animates no opacity — the property
   would promote a full-viewport layer for nothing. The old conveyor carried
   both and overrode them; this is the same decision made once instead.
   Absolute, so `top: 12%` resolves against the SECTION's height and `13vw`
   against the viewport, which is what 254 §3's two numbers mean. .section is
   position: relative (layout.css) and min-height: var(--section-h), so the
   section is at least one viewport tall by that existing convention and
   nothing new is needed for 257 §4.3's last line. */
.projects {
  /* ⚠️ ONE FLOWING COMPOSITION AS OF 268 §3.1: copy, gap, stack, dots, tail.
     Round 4 held two viewport-tall bands and 267's pin needed the second; with
     the pin gone the section is simply as tall as what is in it. */
  align-self: stretch;
  width: 100%;
  text-align: left;
  /* ⚠️ THE TAIL, OR THE ROOM THE DIVE NEEDS, WHICHEVER IS LARGER (270 §4.4 #2).
     .section carries overflow: hidden (layout.css:18), so anything leaving
     section 4's box is cut — and the pushed card travels BELOW the stack on its
     way around to the back. stack.js solves the dive depth that lets the one
     order change happen with no overlap, measures the deepest painted edge over
     the whole path, and writes what is still needed BELOW THE DOTS here. At the
     default card size that is a smaller number than the tail already is, so at
     rest the composition does not move at all; M6 checks it at three viewports
     against every ?projcard=. */
  padding-bottom: max(var(--proj-tail), var(--proj-diveroom, 0px));
}

/* ⚠️ EXACTLY ONE VIEWPORT, WHICH IS WHAT KEEPS THE COPY'S GEOMETRY UNMOVED.
   .projects__copy is placed at `top: 12%` — 254 §3's measured sketch — and that
   percentage resolves against THIS element. While section 4 was one viewport
   tall the two were the same box; now that the stage sits below, they are not,
   and the band is what preserves 258 §4.3's reading of 108.00 px at 1440x900.
   267 §5.1 re-measures it either side of the change. */
/* ⚠️ NO VIEWPORT-HEIGHT MINIMUM ANY MORE (268 §3.1 #2). It existed so `top: 12%`
   on the copy resolved against one viewport; the copy is in flow now and the
   padding is that same 12 %, so the number survives and the empty screen under
   it does not. padding rather than a margin on the copy, because a first child's
   top margin would collapse through and move the whole section instead. */
.projects__band {
  position: relative;
  padding-top: var(--proj-top);
}

.projects__copy {
  /* ⚠️ IN FLOW AS OF 268. Absolutely positioned it gave the band no height, so
     the stack below it would have overlapped the copy. margin-left and width are
     the same two numbers `left` and `width` carried, so the painted box does not
     move — 269 §3 measures it either side. */
  position: relative;
  margin-left: var(--proj-left);
  /* ⚠️ THE BLOCK IS WIDE AND THE SENTENCE IS NARROW, and they are two different
     widths on purpose. The measure below belongs to the SENTENCE; the title is
     display type and is allowed to run past it — at ?projtitle=1.2 it is 546 px
     against a 475 px measure, and constraining it would wrap it, which C7 reads
     as a failure. The right gutter mirrors the left so the block can never
     reach the edge. */
  width: calc(100% - var(--proj-left) * 2);
}

.projects__title {
  margin: 0;
  /* Blinker for titles — 156 §1.12's standing rule, and the whole of
     .work__lead's treatment rather than only its family: work.css:540-547 is
     weight 300, line-height 1.22, letter-spacing -0.005em. ⚠️ NEVER the bare
     'Blinker' — that family name matches nothing on this site and falls back
     to system-ui in silence (102 §8, fonts.css:9-15). ⚠️ AND 300 MEANS 300
     ONLY BECAUSE fonts.css:141-145 DECLARES A 300 FACE: browsers synthesise
     bold and never light, so with no 300 face this would resolve to 400 with
     no warning and a completely plausible render (110 §1.3). */
  font-family: 'Blinker Subset', system-ui, -apple-system, sans-serif;
  font-weight: 300;
  font-size: var(--proj-title-px);
  line-height: 1.22;
  letter-spacing: -0.005em;
  /* The END of the title's sweep — see the #projects block. init() darkens it
     to the resting grey when it creates the trigger, and not before. */
  color: rgb(var(--proj-white-hero));
}

.projects__body {
  /* ⚠️ THE GAP IS A CONSTANT, NOT A FLAG. 257 §4.7's table has no lead-gap
     entry and none was invented; if Xinlu wants it that is a flag to add, not a
     constant to edit — panel.js:252 records the same call for section 3's type
     ratios. 0.5em of the TITLE, so it tracks the title rather than the
     sentence. Reported in 258. */
  margin: calc(var(--proj-title-px) * 0.5) 0 0 0;
  max-width: var(--proj-measure);
  /* Poppins for body — 156 §1.12. Weight and line-height are .hero__body's,
     read off the page at 1440x900: 300 and 1.73 (hero.css:317-318).
     ⚠️ .hero__body's `opacity: 0.82` is NOT carried. It dims the hero's
     sentence against the sky; here it would dim the end white the sweep exists
     to arrive at, and 257 §4.4 fixes that value exactly. */
  font-family: 'Poppins Subset', system-ui, sans-serif;
  font-weight: 300;
  font-size: var(--proj-body-px);
  line-height: 1.73;
  /* ⚠️ THE SENTENCE ENDS SOFTER THAN THE TITLE, AND THAT IS XINLU'S (261 §1):
     "The copy's color can't be the same white as the title after the sweep."
     It is the hierarchy section 3 already carries — figures at 255.0 over a
     sentence at #CACDD2 (work.css:550-570). ?projbodywhite=hero puts them back
     level so the two can be compared. */
  color: rgb(var(--proj-white-soft));
}

/* ── THE RULE (257 §4.5) ─────────────────────────────────────────────────────
   Xinlu: "below the copy sentence, we need to add a faint horizontal line, like
   the faint horizontal lines at the four slots in section 3. The line shouldn't
   run across the whole page — just slightly longer than the sentence."

   ⚠️ ITS WIDTH IS WRITTEN BY index.js AND CANNOT BE WRITTEN HERE. "Slightly
   longer than the sentence" means longer than the longest RENDERED LINE, and no
   stylesheet can see a line box — the fallback below is the measure, which is
   what a reader sees if the script never runs, and is visibly not the same
   thing. C6 is the check that tells the two apart.

   ⚠️ aria-hidden IN THE MARKUP, exactly as section 3's three rules are
   (index.html:760-762): it is decoration and C8's accessible text must not
   change. 1 px and rgba(174, 182, 200, 0.16) are .work__rule's computed
   background at shipped defaults — work.css:648-655 with --work-rule written
   from ?workrulea=0.16 at panel.js:338 — read on the page at 1440x900 on
   2026-09-16 and copied as a literal, because no section imports another. */
.projects__rule {
  margin-top: calc(var(--proj-body-px) * var(--proj-gap-em, 1));
  width: var(--proj-measure);
  height: 1px;
  background: var(--proj-rule-ink);
  pointer-events: none;
}

/* Each word of the title and the sentence. Inline, so the lines still break
   exactly where they would without the spans, and colour is the ONLY property
   the reveal writes. */
.projects__word { color: inherit; }

/* ══ THE VIDEO CARD STACK (266 §5) ═══════════════════════════════════════════
   Four 4:3 cards, video only, always all four visible — Xinlu, 265 §1 #6: "the
   four stacked cards never disappear from the page; instead, they just keep
   changing their front-to-back order as the mouse scrolls." ⚠️ THE REFERENCE
   FADES PASSED CARDS TO OPACITY 0 AND THAT IS THE ONE THING HE REJECTED
   (265 §2.3 #5). Every card keeps opacity 1 here, at rest and mid-move, and S3
   is the check.

   ⚠️ NO TITLES, NO COUNTER, NO ARROWS, NO PLAY BUTTON — "the cards only hold
   videos … no play or stop buttons should appear on the card videos, because
   this card isn't a player" (266 §1 #4, #5). The two videos the PM opened carry
   their own captions baked in bottom-left (265 §3), so an overlay title would
   print a second one over the first. */

/* Centred horizontally — 254 §3 measured the sketch's box as centred to within
   0.1 % of the frame width. ⚠️ IN FLOW AND MARGIN-SPACED AS OF 268 §3.1: its top
   margin IS the gap Xinlu rejected, and its own top edge is the REARMOST card's
   painted top, because .projects__cards carries the lift as its own margin. So
   the gap H10 measures — rule bottom to rearmost card top — is exactly this
   margin. The width is written per layout by stack.js, because the card is sized
   against width AND height, whichever binds (266 §5.4). */
.projects__stack {
  position: relative;
  width: var(--proj-card-w, 52vw);
  margin: var(--proj-stack-gap) auto 0;
  /* ⚠️ PADDING, NOT A MARGIN ON .projects__cards, AND THE DIFFERENCE IS THE GAP
     FLAG (269 §4). A first child's top margin collapses THROUGH its parent and
     merges with the parent's own — so while the lift (131 px as round 4 computed
     it) exceeded the gap (63 px at the default), max() won and ?projstackgap=
     moved nothing at all. Padding does not collapse, so the element's top edge is
     now exactly the rearmost card's painted top and the margin above it is
     exactly the gap H10 measures. */
  padding-top: var(--proj-lift, 0px);
}

/* The FRONT card's box. The cards behind are lifted above it, so the stack's
   painted top is this box's top minus the lift, which stack.js writes as this
   element's margin. */
.projects__cards {
  position: relative;
  width: 100%;
  aspect-ratio: 4 / 3;
}

.projects__card {
  position: absolute;
  inset: 0;
  overflow: hidden;
  /* 2 % of card WIDTH, not 2 % per axis: a percentage border-radius resolves
     against each axis separately and would give an elliptical corner on a 4:3
     box. stack.js writes --proj-card-w in px. */
  border-radius: calc(var(--proj-card-w, 52vw) * 0.02);
  /* ⚠️ WITHOUT THIS A DARK BACK CARD VANISHES AGAINST THE BLACK GROUND (266
     §5.4) — which would reproduce by accident the disappearance Xinlu rejected.
     The ink is section 4's own rule colour, declared once at the top of this
     file. No shadow. */
  border: 1px solid var(--proj-rule-ink);
  background: #000;
  transform-origin: 50% 50%;
  will-change: transform;
  backface-visibility: hidden;
}

/* ⚠️ "THIS CARD ISN'T A PLAYER" (266 §1 #5, §5.5). No controls attribute, and
   the element takes no pointer either — a click or a tap on a card must change
   neither the order nor the playback, and S8 asserts both. The two WebKit
   pseudo-elements are the start-playback overlay Safari paints over a paused
   inline video; without them a native play button appears on exactly the path
   §5.5 says must never show one. */
.projects__video {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  pointer-events: none;
}
.projects__video::-webkit-media-controls { display: none !important; }
.projects__video::-webkit-media-controls-start-playback-button {
  display: none !important;
  -webkit-appearance: none;
}

/* ── ⚠️ THE HOVER LIFT IS THE ENGAGEMENT SIGNAL (268 §3.2 #7) ────────────────
   Xinlu: "I really like your suggestion — when the mouse is over the cards, the
   front card lifts slightly." ⚠️ IT IS NOT A :hover RULE, and that is the whole
   point. The lift shows exactly when a wheel gesture starting now would move a
   card: the pointer is over the painted stack area, the front card is entirely
   in the viewport, and under ?projgrab=pause no page-scroll glide is still
   running. A lift that appears while the wheel would scroll the page instead is
   a promise the page breaks — so stack.js drives it and CSS declares nothing.
   H14 is the check, and its control is exactly the :hover rule not written here. */

/* ── THE DOTS (266 §1 #3, §5.3) ──────────────────────────────────────────────
   Xinlu: "make a few small dots below the stacked cards, each dot representing
   one card, and visitors can click these 'dots' to quickly view the card video
   they want to see." ⚠️ This overrules 265 §5.2 #1's "no dots", which was the
   PM's suggestion and is now his decision.

   8 px circles, 12 px apart, 28 px below the lowest card edge — 266 §5.3's
   defaults, reported and not dialled. */
.projects__dots {
  display: flex;
  justify-content: center;
  gap: 12px;
  margin-top: 28px;
}

.projects__dot {
  width: 8px;
  height: 8px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: #5F6168;                 /* `mid` — flags.js computes the same triple */
  cursor: pointer;
  transition: background-color 160ms ease;
}
.projects__dot:hover { background: #CACDD2; }
.projects__dot[aria-current='true'] { background: #FFFFFF; }
/* A visible keyboard ring (266 §5.3). :focus-visible only, so a mouse click
   does not leave one behind. */
.projects__dot:focus-visible {
  outline: 2px solid #FFFFFF;
  outline-offset: 3px;
}

@media (prefers-reduced-motion: reduce) {
  .projects__dot { transition: none; }
}

/* ══ BELOW 768 px — THE PHONE LAYOUT (272 §4) ═══════════════════════════════
   ⚠️ XINLU'S OWN DESCRIPTION, AND NOT ONE NUMBER FROM HIS SKETCH (272 §1 #7):
   "On mobile, the layout is copy on top and the stacked cards below. For the
   copy part, we don't want it in the exact center of the phone screen — placing
   it near the left side of the screen is fine. Note, not at the very left edge
   of the screen; the copy should have a margin from the left edge of the
   screen, and the title and copy should just be left-aligned. The stacked cards
   go at the bottom, centered horizontally."

   ⚠️ THE SIDE MARGIN IS THE SITE'S OWN, MEASURED (273 §2). Section 3's phone
   copy sits 19.80 px in at 360, 21.44 at 390 and 22.66 at 412 — 5.5 % of the
   viewport at all three, and work/panel.js:293 names that number in a comment
   of its own. ?projmside= halves it and adds half again; index.js writes the
   chosen value here because it is a flag and flags are read in JS.

   ⚠️ AND THE TYPE IS SECTION 3's TOO. work/panel.js:294 sizes .work__lead as
   clamp(20, w * 0.89 / 13, 72) px and writes it per layout rather than in CSS
   — "this file has never contained [a clamp]". Section 4 has no such ticker on
   a phone, so the same curve is written as the clamp it describes: it reproduces
   24.65 / 26.70 / 28.21 px at the three widths, which is what the page paints.
   The SENTENCE needs nothing: --proj-body-size already floors at 15 px, which
   is .hero__body's own phone size (273 §2).

   768 is BREAKPOINT_PX (core/scroll.js:14); index.js reads it from there. */
@media (max-width: 767px) {
  #projects {
    /* --proj-mside is written by index.js from ?projmside=; the 5.5vw here is
       the measured default and the fallback if the module never runs. */
    --proj-left: var(--proj-mside, 5.5vw);
    --proj-measure: calc(100vw - var(--proj-mside, 5.5vw) * 2);
    --proj-title-size: clamp(20px, calc(100vw * 0.89 / 13), 72px);
  }

  /* ⚠️ LEFT-ALIGNED AND OFF THE EDGE, which is the whole of his instruction.
     .projects already carries text-align: left, so this is not a change of
     alignment — it is the guarantee that nothing below overrides it, and the
     place a reader looks for it. */
  .projects__copy,
  .projects__title,
  .projects__body { text-align: left; }

  /* ⚠️ THE STACK IS CENTRED AND ITS EDGES MEET THE COPY'S. Its width is
     100vw - 2 * the side margin, written by stack.js into --proj-card-w, so the
     left edge of the cards lands on the left edge of the title. `auto` margins
     centre it; --proj-stack-gap above it is ?projmgap=. */
  .projects__stack { margin-left: auto; margin-right: auto; }

  /* ⚠️ NO TAIL ON A PHONE — ONLY THE ROOM THE DIVE NEEDS (275 §3). --proj-tail
     is 12 % of the viewport, a DESKTOP composition choice from 258: space before
     section 5 begins. On a phone the stack's position is now derived by centring
     (274 §2.3), so a tail on top of it reserves the same space twice — and it is
     precisely the "space left empty at the bottom of the screen" Xinlu asked to
     remove. Keeping it would also cap how far ?projmshift= can move the block:
     at 360 x 780 the +0.04 nudge would have overflowed the content box by 14.5 px
     and clamped. The dive's room stays, because that is what stops it clipping. */
  .projects { padding-bottom: var(--proj-diveroom, 0px); }

  /* ⚠️ 8 px TO THE EYE, 44 x 44 px TO THE FINGER (272 §2.5), AND THE GAP IS
     WHAT MAKES THAT SAFE. The dot keeps its 8 px box; the ::after below extends
     the button's hit area to 44 square. At the desktop's 12 px gap those areas
     would overlap by 24 px and a tap between two dots would land on whichever
     came later in the markup — so on a phone the gap becomes 36 px, which puts
     the centres exactly 44 px apart and tiles the targets edge to edge with no
     overlap at all. The row is then 140 px wide inside a 347 px stack. */
  .projects__dots { gap: 36px; }
  .projects__dot { position: relative; }
  .projects__dot::after {
    content: '';
    position: absolute;
    left: 50%;
    top: 50%;
    width: 44px;
    height: 44px;
    transform: translate(-50%, -50%);
  }
}

/* ⚠️ TAP-TO-ENLARGE IS GONE (274 §2.1), AND SO IS EVERY RULE THAT SERVED IT.
   The overlay, its backdrop and the enlarged box were removed with the feature:
   Xinlu tested it on his own phone over several rounds and concluded the phone
   already does the job — "visitors can completely zoom the page in and out with
   their fingers". What remains of that decision lives in touch.js, which keeps
   pinch-zoom in both touch-action states so the replacement actually works. */

/* ── ?projwheellog=1 — THE WHEEL PANEL (270 §3.1) ──────────────────────────
   ⚠️ A DIAGNOSTIC, NOT PART OF THE COMPOSITION. It is painted only when the
   flag is on, never takes the pointer, and is hidden from assistive tech, so
   it cannot change what the page does or what C8/S12 read. Fixed to the
   viewport so it stays put while the page scrolls under it. */
.projects__wheellog {
  position: fixed;
  top: 12px;
  right: 12px;
  z-index: 9999;
  pointer-events: none;
  max-width: min(92vw, 560px);
  padding: 10px 12px;
  border-radius: 8px;
  background: rgba(8, 10, 14, 0.92);
  border: 1px solid rgba(174, 182, 200, 0.28);
  color: #E6E9EF;
  font: 11px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  white-space: pre;
  overflow-x: auto;
}
.projects__wheellog b {
  display: block;
  margin-bottom: 6px;
  color: #9FD1FF;
  font-weight: 600;
}
.projects__wheellog pre { margin: 0; font: inherit; }
