/* ==========================================================================
   khushin.com — homepage
   ========================================================================== */

.rail__sub { color: var(--ink-3); letter-spacing: 0.09em; }

/* The rail is a real column on wide screens, with a hairline that ties it to
   the section it labels. On narrow screens it becomes an inline kicker. */
.ruled > .rail {
  position: relative;
  padding-top: 0.55rem;
}
.ruled > .rail::before {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  width: 1.75rem;
  height: 1px;
  background: var(--hairline-2);
}
@media (max-width: 800px) {
  .ruled > .rail { padding-top: 0; padding-bottom: 0.25rem; }
  .ruled > .rail::before { display: none; }
}

/* ---------- hero ---------- */

.hero { padding-top: clamp(4rem, 11vw, 9rem); }

/* The headline is allowed past the prose measure — held to 40rem it wrapped to
   three short lines with a third of the sheet empty beside it, which reads as
   an accident rather than as asymmetry. */
.hero .body { max-width: 48rem; }
.hero .display--1 { max-width: 17ch; }

.hero__lede {
  margin-top: clamp(1.5rem, 3vw, 2.25rem);
  max-width: 30rem;
}

/* The hero clock's rules lived here and were removed 2026-07-28. The clock
   moved into the masthead on 2026-07-26 and nothing has carried
   .hero__clock / -label / -value since; the rules were left behind then
   because a different worker held this file. Its styling now lives with
   .wordmark-clock in base.css. */

.hero__acts {
  margin-top: clamp(2rem, 4vw, 2.75rem);
  display: flex;
  flex-wrap: wrap;
  gap: 0.7rem;
}

/* ---------- why ---------- */

.pull {
  margin: 0 0 clamp(1.75rem, 3.5vw, 2.5rem);
  padding-left: clamp(1rem, 2.5vw, 1.6rem);
  border-left: 2px solid var(--hairline-2);
  color: var(--ink);
}
.pull p { line-height: 1.18; }
.pull em { color: var(--ink-2); }

.why__prose { color: var(--ink-2); max-width: 36rem; }
.why__cta { margin-top: clamp(1.5rem, 3vw, 2rem); }

/* ---------- the slope ---------- */

.slope__head { margin-bottom: clamp(2.5rem, 5vw, 3.5rem); }
.slope__head .lede { margin-top: 1rem; max-width: 33rem; }

/* Four figures on one ruled line. Not cards — a tally, like the top of a
   ledger page. Uneven column widths so it reads as set, not gridded. */
.tally {
  margin: 0 0 clamp(2rem, 4vw, 3rem);
  display: grid;
  grid-template-columns: 1.15fr 1.15fr 1fr 0.9fr;
  gap: 1px;
  background: var(--hairline);
  border-block: 1px solid var(--hairline);
}
.tally__cell {
  background: var(--paper);
  padding: clamp(1rem, 2.2vw, 1.5rem) clamp(0.75rem, 1.8vw, 1.35rem);
}
.tally__cell:first-child { padding-left: 0; }

/* Entrance, added because the tally previously just appeared. The figures
   themselves never move — law 5 forbids a count-up on anything true — so what
   arrives is the cell around the number: opacity and a rise, nothing that
   touches the digits. Staggered like the plates, at a smaller scale for four
   cells rather than twenty. Reveal target and stagger live in home.js. */
.js .tally__cell {
  opacity: 0;
  transform: translateY(22px);
  transition: opacity 560ms var(--ease-structural), transform 560ms var(--ease-structural);
}
.tally__cell.is-in { opacity: 1; transform: none; }
.tally__cell:nth-child(1) { transition-delay: 0ms; }
.tally__cell:nth-child(2) { transition-delay: 70ms; }
.tally__cell:nth-child(3) { transition-delay: 140ms; }
.tally__cell:nth-child(4) { transition-delay: 210ms; }
html.no-js .tally__cell { opacity: 1; transform: none; }
.tally dt { margin-bottom: 0.5rem; }
.tally__n {
  margin: 0;
  font-size: clamp(2rem, 1.2rem + 2.6vw, 3.1rem);
  line-height: 1;
  color: var(--ink);
  display: flex;
  align-items: baseline;
  gap: 0.32em;
}
.tally__of {
  font-family: var(--sans);
  font-size: 0.8rem;
  letter-spacing: 0;
  color: var(--ink-3);
  font-feature-settings: normal;
}

/* 2×2 all the way down, and no narrower breakpoint beneath it. Four numbers
   in one column is a screen and a half of scrolling for four short figures
   with four short labels; the pair of pairs reads in a glance and halves the
   height. The figures shrink with the clamp long before the cells do. */
@media (max-width: 720px) {
  .tally { grid-template-columns: 1fr 1fr; }
  .tally__cell:nth-child(3) { padding-left: 0; }
}

/* ---------- the plot ---------- */

.plot { position: relative; }

/* --live is the ember, scoped here: the plot is live data, so it is the one
   place the accent carries a whole element rather than a detail. */
.gp {
  --live: var(--ember);
  width: 100%;
  height: auto;
  overflow: visible;
  touch-action: pan-y;
}

/* The plot is a real control, so it takes focus. Chrome's UA default for a
   plain :focus on an SVG with tabindex is a 5px amber ring, which is the one
   colour this page does not have — and it fires on click-to-scrub, which is
   the normal way people touch this chart. The global :focus-visible rule
   below is correct and stays; this only stops the UA falling through on
   pointer focus, and gives the keyboard case a hairline in the site's own
   vocabulary rather than a ring floating off the SVG's bounding box. */
.gp:focus { outline: none; }
.gp:focus-visible { outline: none; }
.plot:has(.gp:focus-visible) {
  outline: 1px solid var(--hairline-2);
  outline-offset: clamp(0.5rem, 1.5vw, 0.9rem);
  border-radius: var(--r);
}

.gp-grid { stroke: var(--hairline); stroke-width: 1; vector-effect: non-scaling-stroke; }

/* SVG text is drawn in user units, so its rendered size is the viewBox scale —
   1200 units wide against a ~350px chart on a phone is a third of the nominal
   size. 11px therefore rendered at roughly 3.5px on mobile and 8px on desktop:
   present, and unreadable, in both themes. These step up as the chart shrinks,
   and the y-axis values stop being drawn at the point where they would need to
   be so large they crowd the curve. Readable or absent — not decorative. */
.gp-gridlabel,
.gp-ticklabel {
  font-family: var(--mono);
  font-size: 13px;
  fill: var(--ink-3);
  letter-spacing: 0.08em;
}
@media (max-width: 1000px) {
  .gp-gridlabel, .gp-ticklabel { font-size: 17px; }
}
@media (max-width: 760px) {
  .gp-ticklabel { font-size: 25px; }
  .gp-gridlabel { display: none; }
}
.gp-tick { stroke: var(--hairline); stroke-width: 1; stroke-dasharray: 2 4; vector-effect: non-scaling-stroke; }

.gp-bar { fill: var(--ink-3); opacity: 0.42; }

.gp-area { fill: url(#gpFill); }
.gp-curve {
  fill: none;
  stroke: var(--live);
  stroke-width: 2.25;
  stroke-linecap: round;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
}

/* The draw. Scroll-linked via --draw (0→1) set by home.js; without JS the
   curve is simply complete, which is the correct fallback for a record. */
.js .gp-curve {
  stroke-dasharray: var(--len);
  stroke-dashoffset: calc(var(--len) * (1 - var(--draw, 1)));
}
.js .gp-area { opacity: var(--draw, 1); }

.gp-mark__halo {
  fill: var(--live);
  opacity: 0.14;
  /* The group already carries a translate as an attribute; scaling the child
     circle about its own origin keeps the two transforms from colliding. */
  transition: transform 520ms var(--ease-structural), opacity 520ms var(--ease-structural);
}
.gp-mark__dot { fill: var(--live); stroke: var(--paper); stroke-width: 1.5; }
.js .gp-mark { opacity: 0; transition: opacity 420ms var(--ease-inplace); }
.js .gp-mark.is-lit { opacity: 1; }

/* The mark the tour is currently naming. It swells rather than blinks —
   a blink is an alert, and nothing here is an alert. */
.gp-mark.is-focus .gp-mark__halo { transform: scale(2.1); opacity: 0.2; }
.gp-mark.is-focus .gp-mark__dot { r: 4.4; }

.gp-label line { stroke: var(--hairline-2); stroke-width: 1; vector-effect: non-scaling-stroke; }
.gp-label text {
  font-family: var(--sans);
  font-size: 12.5px;
  fill: var(--ink-2);
  font-weight: 500;
  letter-spacing: -0.005em;
}
.js .gp-label { opacity: 0; transition: opacity 480ms var(--ease-inplace); }
.js .gp-label.is-lit { opacity: 1; }
/* The tour has taken over the naming; the static end labels stand down. */
.gp.is-touring .gp-label.is-lit { opacity: 0; }

.gp-cursor { opacity: 0; transition: opacity 140ms var(--ease-inplace); }
.gp-cursor.is-on { opacity: 1; }
.gp-cursor__line { stroke: var(--ink-3); stroke-width: 1; stroke-dasharray: 3 3; vector-effect: non-scaling-stroke; }
.gp-cursor__dot { fill: var(--live); stroke: var(--paper); stroke-width: 2; }

/* The scrub readout. Sits over the plot, mono, never moves the layout. */
.plot__readout {
  position: absolute;
  top: 0;
  right: 0;
  display: none;
  gap: 0.9rem;
  align-items: baseline;
  font-family: var(--mono);
  font-size: 0.72rem;
  letter-spacing: 0.06em;
  color: var(--ink-2);
  background: color-mix(in srgb, var(--paper) 90%, transparent);
  padding: 0.4rem 0.6rem;
  border: 1px solid var(--hairline);
  border-radius: var(--r);
  pointer-events: none;
}
.plot__readout.is-on { display: flex; }
.plot__readout b { font-weight: 400; color: var(--ink); }

/* ---------- the tour ----------
   The plot walks its own twelve turning points and names each one here. The
   row is a fixed height whatever it is saying, because a caption that grows
   and shrinks would shove the whole page up and down every two seconds. */

.plot__tourbar {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 1.5rem;
  margin-top: 1.1rem;
  padding-top: 0.9rem;
  border-top: 1px solid var(--hairline);
  min-height: 4.75rem;
}

.plot__tour { opacity: 0; transition: opacity 340ms var(--ease-inplace); }
.plot__tour.is-on { opacity: 1; }

.plot__tour-when { color: var(--ink-3); }
.plot__tour-what {
  font-family: var(--serif);
  font-size: clamp(1.15rem, 1rem + 0.7vw, 1.55rem);
  line-height: 1.2;
  letter-spacing: -0.016em;
  color: var(--ink);
  margin-top: 0.3rem;
  text-wrap: balance;
}
.plot__tour-n { font-size: 0.72rem; color: var(--ink-3); margin-top: 0.3rem; letter-spacing: 0.06em; }

/* The one control on the page that is a real toggle. It reads "auto" while
   the tour runs, because that is what it is telling you — not what it does. */
.plot__play {
  flex: none;
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  min-height: 44px;
  padding: 0 0.75rem;
  border: 1px solid var(--hairline);
  border-radius: var(--r);
  background: transparent;
  color: var(--ink-3);
  font-family: var(--mono);
  font-size: 0.68rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  cursor: pointer;
  transition: color var(--t-inplace) var(--ease-inplace),
              border-color var(--t-inplace) var(--ease-inplace);
}
.plot__play:hover { color: var(--ink); border-color: var(--hairline-2); }

/* Two bars while playing (press to pause), one triangle while paused. Drawn
   with borders and a box-shadow rather than clip-path or an icon font —
   nine pixels of glyph does not need a network request or a polygon. */
.plot__play-glyph { flex: none; width: 3px; height: 9px; background: currentColor; }
.plot__play[data-state='playing'] .plot__play-glyph { box-shadow: 5px 0 0 currentColor; margin-right: 5px; }
.plot__play[data-state='paused'] .plot__play-glyph {
  width: 0;
  height: 0;
  background: none;
  border-left: 8px solid currentColor;
  border-top: 5px solid transparent;
  border-bottom: 5px solid transparent;
}

/* On one column the control goes ABOVE the caption, not below it. Stacked the
   other way the pill ended up marooned roughly 300px under the chart with
   nothing around it — the caption is a serif line that wraps to four or five
   lines on a phone, and it was pushing the only control on the plot off the
   bottom of its own component. Reversed, the pill sits directly under the
   chart's rule and the caption runs full width beneath it, so the control is
   adjacent to both the thing it controls and the thing it is narrating, and
   the caption never has to share its measure with a button. */
@media (max-width: 560px) {
  .plot__tourbar { flex-direction: column-reverse; align-items: stretch; gap: 0.85rem; min-height: 0; }
  .plot__play { align-self: flex-start; }
}

@media (prefers-reduced-motion: reduce) {
  .js .gp-curve { stroke-dashoffset: 0; }
  .js .gp-area { opacity: 1; }
  .js .gp-mark, .js .gp-label { opacity: 1; }
  .js .tally__cell,
  .js .piece,
  .js .build {
    opacity: 1;
    transform: none;
    transition: none;
    transition-delay: 0ms;
  }
  .shot,
  .shot .device,
  .shot:hover,
  .shot:hover .device {
    transition: none;
    transform: none;
    box-shadow: none;
  }
  .shot--wide:hover img { box-shadow: none; }
  .build:hover .build__name,
  .build:hover .build__state,
  .build:hover .build__state .dot { transform: none; transition: none; }
  .entry:hover .entry__date,
  .entry:hover .entry__title,
  .entry:hover .entry__meta { transform: none; }
}

/* ---------- the log ---------- */

.logstrip__h { max-width: 24ch; margin-bottom: clamp(2rem, 4vw, 2.75rem); }

.entries { border-top: 1px solid var(--hairline); }

.entry {
  display: grid;
  grid-template-columns: 6.5rem 1fr auto;
  gap: 0 clamp(1rem, 3vw, 2.25rem);
  align-items: baseline;
  padding-block: clamp(0.9rem, 1.8vw, 1.15rem);
  border-bottom: 1px solid var(--hairline);
  position: relative;
}
/* The row itself now arrives, not just its underline — base.js already marks
   every .entry .is-in the moment it crosses the viewport (see its "reveal"
   block; .entry is one of the two selectors it watches with no template
   change needed), so this rides an observer that already existed rather than
   adding one. Final state equals rest state, so reduced motion needs nothing
   extra here beyond base.css's blanket near-zero transition duration. */
.js .entry {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 620ms var(--ease-structural), transform 620ms var(--ease-structural);
}
.entry.is-in { opacity: 1; transform: none; }
html.no-js .entry { opacity: 1; transform: none; }
.entry__date { color: var(--ink-3); }
.entry__title {
  font-family: var(--serif);
  font-size: clamp(1.05rem, 0.95rem + 0.5vw, 1.35rem);
  line-height: 1.25;
  letter-spacing: -0.014em;
  color: var(--ink);
}
.entry__meta {
  display: flex;
  align-items: baseline;
  gap: 0.85rem;
  color: var(--ink-3);
  font-size: 0.72rem;
  white-space: nowrap;
}
.entry__n { color: var(--ink-2); }

/* Motion Kit's text-scramble-hover, mounted by base.js on .entry__meta .mono
   only — see page.css's copy of this comment for the full reasoning. Kept in
   sync with page.css the same way .entry itself already is. */
.tsh-label {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
}
.tsh-visual { display: inline-block; }
.tsh-glyph { display: inline-block; min-width: 0.08em; transition: color var(--t-inplace) var(--ease-inplace); }

/* The running total sits in a hairline that grows with the count — the row
   itself is a tiny bar chart, so the acceleration is legible in the list. */
.entry::after {
  content: '';
  position: absolute;
  left: 0;
  bottom: -1px;
  height: 1px;
  width: var(--w, 0%);
  background: var(--ember);
  opacity: 0.55;
  transform: scaleX(0);
  transform-origin: left center;
  transition: transform 900ms var(--ease-structural);
}
.entry.is-in::after { transform: scaleX(1); }
html.no-js .entry::after { transform: scaleX(1); }

/* Hovering a turning point darkens its rule and nudges the row toward the
   reading direction. Nothing lifts or shadows — these are lines on paper,
   and paper does not float — but "nudge" was a 3px hair; escalated to a move
   you actually see, with the count riding along a beat behind the title so
   the row reads as one thing shifting together, not two unrelated tweens. */
@media (hover: hover) and (pointer: fine) {
  .entry {
    transition: border-color var(--t-inplace) var(--ease-inplace);
  }
  .entry__date,
  .entry__title,
  .entry__meta { transition: transform 320ms var(--ease-inplace), color var(--t-inplace) var(--ease-inplace); }
  /* No fill — see the long note on `.entry:hover` in page.css. The block a
     `--paper-2` row painted had hard vertical edges landing on the type, and
     in colour mode it read as a highlighter pen across the record. The row is
     a line on paper; the line darkens and the row nudges, nothing fills. */
  .entry:hover { border-bottom-color: var(--ink-3); }
  .entry:hover .entry__date { color: var(--ink-2); transform: translateX(4px); }
  .entry:hover .entry__title { transform: translateX(10px); }
  .entry:hover .entry__meta { transform: translateX(6px); transition-delay: 20ms; }
  .entry:hover::after { opacity: 1; }
}

@media (max-width: 640px) {
  .entry { grid-template-columns: 1fr auto; gap: 0.15rem 1rem; }
  .entry__date { grid-column: 1; font-size: 0.68rem; }
  .entry__meta { grid-column: 2; grid-row: 1; }
  .entry__title { grid-column: 1 / -1; grid-row: 2; }
}

.logstrip__cta { margin-top: clamp(1.5rem, 3vw, 2rem); }

/* ---------- the work ---------- */

.work__intro { margin-bottom: clamp(2.5rem, 5vw, 4rem); }
.work__intro .display--2 { max-width: 20ch; }

.piece {
  display: grid;
  align-items: center;
  gap: clamp(2rem, 5vw, 4.5rem);
}

/* Entrance: the whole article rises in as the section reaches the reading
   line. Reveal target lives in home.js, alongside the tally and the builds —
   three sections that previously had no arrival at all. */
.js .piece {
  opacity: 0;
  transform: translateY(38px);
  transition: opacity 700ms var(--ease-structural), transform 700ms var(--ease-structural);
}
.piece.is-in { opacity: 1; transform: none; }
html.no-js .piece { opacity: 1; transform: none; }

/* The device frames get weight on hover — a screenshot that lifts toward you
   reads as a real object being picked up, which is the physicality KP asked
   for. Scoped to the frame itself, not the whole row, so approaching the text
   half of a piece does not set the picture bobbing for no reason. */
@media (hover: hover) and (pointer: fine) {
  .shot { transition: transform 520ms var(--ease-structural); }
  .shot .device { transition: box-shadow 520ms var(--ease-structural); }
  .shot:hover { transform: translateY(-10px) scale(1.03); }
  .shot:hover .device { box-shadow: var(--lift-flat); }
  /* The wide desktop capture has no .device wrapper (it is texture, not a
     demo — see the comment on .piece--major .piece__media below), so it gets
     the lift without a shadow escalation to invent. */
  .shot--wide:hover img { box-shadow: var(--lift-flat); border-radius: var(--r); }
}

/* Unequal on purpose. Garden gets the larger share and leads with text;
   Kapers is narrower, leads with the image, and sits shorter.

   **Every product asset was stripped from the site 2026-07-27 (KP), to be
   hand-picked later.** The two-column geometry is deliberately left intact and
   gated on `:has(.piece__media)` instead of being deleted: dropping a media
   block back into the article restores the layout with no other edit, and
   until one exists the words get the full measure rather than half a row with
   a hole in it. Do not "simplify" this by removing the two-column rules — the
   slot is the point. */
.piece:not(:has(.piece__media)) {
  grid-template-columns: minmax(0, 1fr);
  max-width: 46rem;
}

.piece--major {
  grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);
  padding-bottom: clamp(3.5rem, 8vw, 6.5rem);
}
.piece--minor {
  grid-template-columns: minmax(0, 0.62fr) minmax(0, 1fr);
  padding-top: clamp(2.5rem, 5vw, 4rem);
  border-top: 1px solid var(--hairline);
}

@media (max-width: 860px) {
  .piece--major,
  .piece--minor { grid-template-columns: 1fr; }
  /* On one column the image always follows the words. */
  .piece--minor .piece__media { order: 2; }
  .piece--minor .piece__text { order: 1; }
}

.piece__kicker { margin-bottom: 0.9rem; }
.piece__text .display--3 { margin-bottom: 1rem; max-width: 20ch; }
.piece__text .prose { color: var(--ink-2); max-width: 38ch; }
.piece__text p + p { margin-top: 1.15rem; }

.piece__media { position: relative; }

/* Garden: the wide desktop capture with the phone overlapping its lower-left
   corner. Overlap, not a two-up grid — the pair reads as one object. */
/* The phone overlaps on the RIGHT, not the left. On the left it sat directly
   over the net-worth figure: the capture scales to about 0.38, the phone
   covered the first 313px of it, and `$41,754.08` — the single number carrying
   the whole pitch — rendered as `754.08`. The right-hand third of that capture
   is empty parchment below the chart, so the same overlap costs nothing there.

   Measured, not guessed: the figure sits at x 272–462 in the 1440px source. */
.piece--major .piece__media { padding-bottom: 3.5rem; padding-right: 3rem; }
.piece--major .shot--wide img { width: 100%; }
.piece--major .shot--phone {
  position: absolute;
  right: 0;
  bottom: 0;
  width: 27%;
  max-width: 172px;
}

@media (max-width: 860px) {
  .piece--major .piece__media { padding-right: 2rem; padding-bottom: 2.5rem; }
  .piece--major .shot--phone { width: 30%; }
}

/* Below this the desktop capture is texture, not a demo — 1440px of dense UI
   rendered into ~330px is about four points of type, and no one has ever read
   it. One legible thing beats two illegible ones, so the phone stands alone
   and the full desktop set lives on /garden/ where it has the room. */
@media (max-width: 560px) {
  .piece--major .piece__media { padding: 0; display: flex; justify-content: center; }
  .piece--major .shot--wide { display: none; }
  .piece--major .shot--phone {
    position: static;
    width: 100%;
    max-width: 260px;
  }
}

/* Kapers: two phones, held small. It is the younger product and the layout
   still says so without a word — but one phone left the right half of the row
   empty, because a 1320×2868 capture is twice as tall as the paragraph beside
   it and `align-items: center` split the difference into two voids.

   The second screen fills that with the actual story rather than with padding:
   capture, then review. It sits lower and smaller, overlapping into the
   gutter, so the pair reads as one object the way Garden's does — inverted,
   because this row leads with the image. */
.piece--minor .piece__media { padding-right: 3.5rem; }
.piece--minor .shot--phone { max-width: 244px; }
.piece--minor .shot--aside {
  position: absolute;
  right: 0;
  bottom: -8%;
  width: 62%;
  max-width: 152px;
}

@media (max-width: 860px) {
  .piece--minor .piece__media { padding-right: 2.5rem; margin-top: 2rem; }
  .piece--minor .shot--phone { max-width: 220px; }
  .piece--minor .shot--aside { width: 58%; max-width: 132px; bottom: -6%; }
}
/* One phone below this. Two overlapping screens at 150px each is two things
   you cannot read instead of one you can. */
@media (max-width: 560px) {
  .piece--minor .piece__media { padding-right: 0; display: flex; justify-content: center; }
  .piece--minor .shot--aside { display: none; }
  .piece--minor .shot--phone { max-width: 260px; }
}

.device { box-shadow: var(--lift-device); }

/* ---------- betas ---------- */

.betas__lede { margin-top: 1rem; max-width: 36rem; margin-bottom: clamp(2rem, 4vw, 2.75rem); }

.builds { border-top: 1px solid var(--hairline); }

.build {
  display: grid;
  grid-template-columns: minmax(0, 14rem) minmax(0, 1fr) auto;
  gap: 0.4rem clamp(1rem, 3vw, 2.5rem);
  align-items: center;
  padding-block: clamp(1.1rem, 2.2vw, 1.5rem);
  border-bottom: 1px solid var(--hairline);
  position: relative;
}

/* Entrance, staggered by row. Reveal target lives in home.js. */
.js .build {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 520ms var(--ease-structural), transform 520ms var(--ease-structural), background var(--t-inplace) var(--ease-inplace);
}
.build.is-in { opacity: 1; transform: none; }
.build:nth-child(1) { transition-delay: 0ms; }
.build:nth-child(2) { transition-delay: 70ms; }
.build:nth-child(3) { transition-delay: 140ms; }
.build:nth-child(4) { transition-delay: 210ms; }
.build:nth-child(5) { transition-delay: 280ms; }
html.no-js .build { opacity: 1; transform: none; }

/* Same physical response as the log's .entry rows: a nudge in the reading
   direction and the row's own tint, not a lift — these are still lines on a
   ledger, not cards. */
@media (hover: hover) and (pointer: fine) {
  .build { transition: background var(--t-inplace) var(--ease-inplace), opacity 520ms var(--ease-structural), transform 520ms var(--ease-structural); }
  .build__name,
  .build__state { transition: transform 260ms var(--ease-inplace); }
  .build:hover { background: var(--paper-2); }
  .build:hover .build__name,
  .build:hover .build__state { transform: translateX(4px); }
  .build:hover .build__state[data-state='ready'] .dot { transform: scale(1.3); }
  .build__state .dot { transition: transform 260ms var(--ease-inplace); }
}
.build__name {
  font-family: var(--serif);
  font-size: 1.32rem;
  letter-spacing: -0.016em;
  line-height: 1.1;
  margin-bottom: 0.2rem;
}
.build__state {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  font-size: 0.9rem;
  color: var(--ink-2);
}
.build__state .dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  flex: none;
  background: var(--ink-3);
}
.build__state[data-state='ready'] { color: var(--ink); }
.build__state[data-state='ready'] .dot { background: var(--ember); }
.build__state[data-state='soon'] .dot {
  background: transparent;
  border: 1px solid var(--hairline-2);
}
.build__act { justify-self: end; font-size: 0.9rem; }

@media (max-width: 700px) {
  .build { grid-template-columns: 1fr auto; }
  .build__id { grid-column: 1; }
  .build__act { grid-column: 2; grid-row: 1; }
  .build__state { grid-column: 1 / -1; margin-top: 0.35rem; }
}

.builds__note {
  margin-top: 1.25rem;
  font-size: 0.85rem;
  color: var(--ink-3);
  max-width: 44ch;
}

/* ---------- close ---------- */

.close__line { max-width: 18ch; }
.close__line em { color: var(--ember); }
.close__sub { margin-top: clamp(1.25rem, 2.5vw, 1.75rem); max-width: 34rem; }
.close__mail { margin-top: clamp(1.75rem, 3.5vw, 2.5rem); }

.colophon__note { color: var(--ink-3); font-size: 0.875rem; max-width: 40ch; }
