@import url("tokens/colors.css?v=cursor-ui-20261022-close");
@import url("tokens/motion.css");
@import url("tokens/spacing.css");
@import url("tokens/typography.css");
@import url("tokens/radius.css");
@import url("../vendor/lenis/lenis.css"); /* vendored smooth-scroll base rules — see scroll.mjs */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
html {
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  scroll-behavior: smooth;
  /* Moved here from `.row` (22/08/2026) — see the note where `.row` used to
     declare this for why the guard exists and why `-x` only. Scoping it per
     row meant a same-element `box-shadow` (the 22/08 hover lift) got clipped
     on the same axis as the guard: a box-shadow is painted relative to its
     own element's box, so any overflow value other than `visible` on that
     element clips its own shadow too, not just runaway children. The guard's
     actual job — stop a nowrap descendant from widening the document — works
     identically from the document root, and nothing here has its own
     box-shadow to lose. */
  overflow-x: clip;
}
[id] {
  scroll-margin-top: var(--space-96);
}
body {
  font-family: var(--font-sans);
  font-optical-sizing: auto;
  font-synthesis: none;
  font-size: var(--fs-15);
  line-height: var(--lh-base);
  background: var(--bg);
  color: var(--ink);
  padding: var(--space-120) var(--space-24) var(--space-128);
}
@media (max-width: 560px) {
  body {
    padding-top: var(--space-60);
  }
}
/* 640px, not 560: at one 16px size the measure is ~40em — emilkowal.ski's
   column is 644px on the same type, and it's what lets prose breathe and the
   two-column plan hold a readable line. Measured, not guessed (16/08/2026). */
main {
  max-width: var(--space-640);
  margin-inline: auto;
  /* Explicit, not just the default (22/08/2026): `main` is the one container
     every `.row` hover fill (12px past the column) has to bleed past on its
     way to the viewport. Was already `visible` by inheritance, but that's
     one accidental `overflow: hidden`/`clip` away from silently clipping
     every list's hover on the page — pin it here so it can't regress
     quietly. (Until 29/08/2026 the hover also had a shadow to clip.) */
  overflow: visible;
}
::selection {
  background: var(--ink);
  color: var(--bg);
}
h1 {
  font-size: var(--fs-15);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-tight);
}
/* Section rhythm after the emilkowal.ski audit (16/08/2026): 24px title →
   prose, 16px title → list; the between-section gap is explained below. */
h2 {
  font-size: var(--fs-15);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-tight);
  /* 72px between sections (16/08/2026, evening): the 128px measured on
     emilkowal.ski was tried for a day of real use and read too loose on a
     page with eight sections and short lists — 56px came off at Otavio's
     request. Same number the site had before the audit, kept now on
     purpose rather than by default. */
  margin-top: var(--space-72);
  margin-bottom: var(--space-24);
}
/* Rows carry their own top padding, so the title sits closer to a list than
   to a paragraph — same optical distance to the first line of text either way. */
section:has(> h2 + .row) > h2 {
  margin-bottom: var(--space-8);
}
@media (max-width: 560px) {
  h2 {
    margin-top: var(--space-64);
  }
}
h3 {
  font-size: var(--fs-15);
  font-weight: var(--fw-medium);
}
p {
  color: var(--muted);
  text-wrap: pretty;
  cursor: pointer;
  transition-property: color, opacity;
  transition-duration: var(--duration-250);
  transition-timing-function: var(--ease);
}
p:hover {
  color: var(--ink);
}
.subtitle {
  color: var(--muted);
  margin-top: var(--space-4);
}
/* Consecutive paragraphs in a section: 16px apart, the emilkowal.ski
   measurement (his `mt-4` between prose paragraphs). */
main > section > p + p {
  margin-top: var(--space-16);
}
.label {
  font-size: var(--fs-12);
  font-weight: var(--fw-medium);
  color: var(--faint);
  user-select: none;
}
a {
  color: var(--ink);
  text-decoration: none;
  transition-property: color;
  transition-duration: var(--duration-200);
  transition-timing-function: var(--ease);
}
a:active {
  color: var(--muted);
}
.inline-link {
  text-decoration: underline;
  text-decoration-color: var(--line);
  text-underline-offset: var(--space-3);
  transition-property: color, text-decoration-color;
  transition-duration: var(--duration-200);
  transition-timing-function: var(--ease);
}
.inline-link:hover {
  text-decoration-color: var(--ink);
}
.fav {
  width: var(--space-13);
  height: var(--space-13);
  margin-right: var(--space-7);
  vertical-align: var(--space-n1-5);
  border-radius: var(--radius-3);
  opacity: 0.9;
  user-select: none;
}
/* The sideways-page-scroll guard (a flex item's default `min-width: auto`
   means a `white-space: nowrap` child — the tag pills at 375px, originally —
   refuses to shrink and pushes the document wider than the viewport) moved to
   `overflow-x: clip` on `html` (22/08/2026, see that rule for why). It used
   to live here on `.row` itself, which quietly broke the moment `.row` got a
   `box-shadow` (below): the shadow is relative to this same box, so any
   non-`visible` overflow on it clips its own shadow along with any runaway
   child. Moving the guard to the document root fixes that without giving up
   the guard. */
/* No rule between rows (16/08/2026): the hairlines read as clutter once the
   page opened up, so vertical rhythm carries the separation alone — 12px of
   padding each side, ~24px of air between adjacent rows' text (tried 14/28
   first; it read a touch loose without the rule, so it came down one step —
   emilkowal.ski keeps 16px between two-line blocks). Neighbor-dim still
   targets `.row > *`, not the row, for the `.row.extra` opacity/animation
   reason noted below. */
.row {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: var(--space-16);
  padding: var(--space-12) 0;
  cursor: pointer;
}
/* Hover on the page lists (29/08/2026): a flat --row-hover fill, instant,
   no shadow, no transition — the same hover the home's Writing rows got
   from the Figma `Article` the same day (see `.doc-item`). Corners at 16px,
   fill bleeds 12px past the column on each side (padding-inline + negative
   margin, so the text itself never moves). History: a flat --gray-955 fill
   (jakub.kr's row, 16/08) read as inert once the page moved off pure white
   (see --bg), so on 22/08 the row became a small card — --white +
   --shadow-lift, the shadow transitioning over 200ms. The real fix was the
   hue, not the shadow: --row-hover is the same lightness but carries --bg's
   hue (docs/design-system.md), so the tint reads warm instead of bluish and
   the card lift could go. Emil's rule for anything hovered dozens of times
   a session: zero frames. Scoped to `main > section` so the link rows
   inside the panel/⌘K detail (which are `.row` too, cursor default) keep
   their plain layout. This replaces the neighborhood dim for these lists;
   the dim stays on phase items, panel items and palette results, where
   nothing else marks the hovered one. */
main > section .row {
  /* 12px, not jakub.kr's 8: his row leads with a 48px icon that carries its
     own air; ours leads with a 13px favicon that sat against the fill's
     edge at 8px. One step up on the scale gives it the same optical breath. */
  padding-inline: var(--space-12);
  margin-inline: calc(-1 * var(--space-12));
  border-radius: var(--radius-16);
}
@media (hover: hover) and (pointer: fine) {
  main > section .row:hover {
    background: var(--row-hover);
  }
}
/* `flex-shrink: 0` keeps the name whole. Letting it shrink even a hair is worse
   than it sounds: a sub-pixel deficit is enough to trigger `text-overflow`, and
   the ellipsis then has to eat a whole cluster of real characters to make room
   for itself — "Dominik Kandravý" renders as "Dominik Kandra…" over 0.07px. The
   document root's `overflow-x: clip` (moved there 22/08/2026, see `html`)
   is what catches a name too long to fit at all. */
.row .who {
  font-weight: var(--fw-medium);
  white-space: nowrap;
  flex-shrink: 0;
  transition-property: opacity;
  transition-duration: var(--duration-250);
  transition-timing-function: var(--ease);
}
/* The summary is the side that gives: `min-width: 0` (a flex item won't shrink
   past its content otherwise) plus `overflow: hidden` lets it absorb the entire
   deficit down to zero width and ellipsise, so narrow screens cost summary text
   rather than names. Keeping each summary near ~35 characters is what stops it
   from ellipsising in practice. */
.row .what {
  color: var(--muted);
  text-align: end;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  transition-property: color, opacity;
  transition-duration: var(--duration-250);
  transition-timing-function: var(--ease);
}
.row:hover .what {
  color: var(--ink);
}
/* ---------------------------------------------------------------------------
   Foco por vizinhança — com o mouse dentro de uma lista, os itens que não estão
   sob o cursor caem pra 30% e só o de baixo dele fica em cheio. A lista inteira
   recua, o item lido avança; nenhum destaque novo é pintado, é só o resto que
   sai da frente.

   Desde 16/08/2026 isto NÃO vale mais pros `.row` das seções da página: lá o
   hover virou o preenchimento instantâneo do jakub.kr (ver `main > section
   .row:hover` acima), e apagar o resto por cima disso era efeito demais. Vale
   pros itens das fases, do painel e do palette, onde nada mais marca o item
   sob o cursor.

   `:has()` em vez de `:hover` no container de propósito — a lista só apaga
   quando o cursor está de fato sobre um item, não quando passa pelo h2 ou pela
   folga entre as linhas.
--------------------------------------------------------------------------- */
.phase ul:has(li:hover) li:not(:hover),
.panel:has(.item:hover) .item:not(:hover),
.cmd-modal:has(.item:hover) .item:not(:hover),
.cmd__list:has(.cmd__item:hover) .cmd__item:not(:hover) {
  opacity: 0.3;
}
.extras {
  display: grid;
  grid-template-rows: 0fr;
  visibility: hidden;
  transition:
    grid-template-rows var(--duration-320) var(--ease),
    visibility 0s linear var(--duration-320);
}
.expanded .extras {
  grid-template-rows: 1fr;
  visibility: visible;
  transition:
    grid-template-rows var(--duration-320) var(--ease),
    visibility 0s;
}
.extras-inner {
  min-height: 0;
  /* min-width: 0 (22/08/2026 mobile fix) is load-bearing on its own, not
     just belt-and-suspenders with min-height. `.extras-inner` is the sole
     item in `.extras`'s single-column grid, and CSS Grid's automatic
     minimum size only floors a grid item's min-width at 0 while its
     `overflow` is `visible` (this box is `hidden` at rest) — the moment
     script.js's transitionend listener below flips it to `overflow:
     visible` (to stop clipping a `.row.extra`'s hover shadow, see below),
     the browser's automatic minimum reverts to the item's min-content size
     instead. Every row inside carries `white-space: nowrap` text (.who,
     and .what before it's forced to shrink), so min-content is the full
     unwrapped width of the longest role line — on mobile that's wider than
     the viewport, so the grid item (and every row-btn sized off its 100%)
     ballooned out and forced horizontal scroll on the whole page, undoing
     `.row .what`'s ellipsis in the process (nothing was left to shrink it
     against). `min-width: 0` overrides that automatic floor outright,
     independent of overflow, so the item stays pinned to the grid track's
     actual width in both states. */
  min-width: 0;
  overflow: hidden;
}
/* 6px of slack top and bottom, expanded state only (22/08/2026) —
   `.extras-inner`'s `overflow: hidden` above is load-bearing for the
   collapse animation (it's what makes `0fr` actually hide the rows instead
   of just squeezing them), but a `.row.extra`'s own hover box-shadow bleeds
   a few px past its border box, and with zero slack that bleed lands
   exactly on this box's own clip edge — cut flush, top and bottom, on the
   first/last row and (since the rows stack with no gap of their own) every
   row in between. Horizontal bleed doesn't need the same fix: this box is
   already wide enough to hold the row's 12px side bleed without touching
   its own edge, so only the block axis needed room.

   Scoped to `.expanded` rather than added to the bare rule above: padding
   isn't swallowed by the `0fr` collapse the way content height is —
   `min-height: 0` only removes the *content* floor, not the box's own
   padding — so an unconditional `padding-block` here left the collapsed
   state 12px tall instead of 0 (caught by
   tests/ui/see-more-sections.test.mjs, "collapsed extras take no space").
   Gating it on `.expanded` keeps the collapsed height genuinely 0 and only
   adds the slack once the rows (and their shadow) are actually visible. */
.expanded .extras-inner {
  padding-block: var(--space-6);
}
.row.extra {
  opacity: 0;
  transition-property: opacity;
  transition-duration: var(--duration-240);
  transition-timing-function: var(--ease);
}
.expanded .row.extra {
  transform: translateY(var(--space-12));
  filter: blur(var(--space-4));
  animation: enter var(--duration-400) var(--ease-out) forwards;
}
.expanded .row.extra:first-of-type {
  animation-delay: 0s;
}
.expanded .row.extra:nth-of-type(2) {
  animation-delay: var(--duration-40);
}
.expanded .row.extra:nth-of-type(3) {
  animation-delay: var(--duration-80);
}
.expanded .row.extra:nth-of-type(4) {
  animation-delay: var(--duration-120);
}
.expanded .row.extra:nth-of-type(5) {
  animation-delay: var(--duration-160);
}
.expanded .row.extra:nth-of-type(6) {
  animation-delay: var(--duration-200);
}
.see-more {
  display: block;
  background: 0 0;
  border: none;
  padding: 0;
  margin-top: var(--space-16);
  font-family: inherit;
  font-size: var(--fs-13);
  color: var(--faint);
  cursor: pointer;
  user-select: none;
  transition-property: color, scale;
  transition-duration: var(--duration-200);
  transition-timing-function: var(--ease);
}
.see-more:hover {
  color: var(--muted);
}
.see-more:active {
  scale: 0.96;
}
.phases {
  display: grid;
  grid-template-columns: 1fr 1fr;
  column-gap: var(--space-48);
  row-gap: var(--space-48);
}
@media (max-width: 560px) {
  .phases {
    grid-template-columns: 1fr;
  }
}
.phase {
  margin-bottom: 0;
}
.phase-head {
  display: flex;
  align-items: baseline;
  gap: var(--space-12);
  margin-bottom: var(--space-8);
  cursor: pointer;
  user-select: none;
}
.phase-head h3 {
  transition-property: color;
  transition-duration: var(--duration-250);
  transition-timing-function: var(--ease);
}
.phase-num {
  font-variant-numeric: tabular-nums;
  color: var(--faint);
  font-weight: var(--fw-regular);
  user-select: none;
  transition-property: color;
  transition-duration: var(--duration-250);
  transition-timing-function: var(--ease);
}
.phase-head:hover .phase-num,
.phase.active .phase-num {
  color: var(--ink);
}
.phase ul {
  list-style: none;
  margin-top: var(--space-8);
}
.phase li {
  color: var(--muted);
  margin-bottom: var(--space-10);
  text-wrap: pretty;
  cursor: pointer;
  transition-property: color, opacity;
  transition-duration: var(--duration-250);
  transition-timing-function: var(--ease);
}
.phase li:hover {
  color: var(--ink);
}
.note {
  color: var(--faint);
  margin-top: var(--space-40);
  padding-top: var(--space-24);
  border-top: var(--border-w) solid var(--line);
  text-wrap: pretty;
  cursor: pointer;
}
.note:hover {
  color: var(--muted);
}
footer {
  margin-top: var(--space-128);
  padding-top: var(--space-24);
  color: var(--faint);
  display: flex;
  justify-content: space-between;
  font-variant-numeric: tabular-nums;
}
/* Groups page links and the version button so the footer keeps its two-item
   `space-between` (name on the left, navigation/actions on the right). */
.footer__right {
  display: flex;
  align-items: center;
  gap: var(--space-16);
}
body {
  transition-property: padding-right, background-color, color;
  transition-duration: var(--duration-250);
  transition-timing-function: var(--ease);
}
/* The mark's two shapes are baked as `fill="black"` in the SVG markup itself
   (it's shared between the intro overlay and the topbar logo), so a plain
   `var(--ink)` on the wrapping element wouldn't touch them — `fill` doesn't
   inherit through an explicit attribute the way `color` does. Overriding the
   attribute-selector match keeps the mark dark-on-light and light-on-dark
   without touching the SVG file itself. */
.intro__mark path[fill="black"],
.topbar__logo path[fill="black"] {
  fill: var(--ink);
}
body.panel-open {
  padding-right: var(--panel-w);
}
body.cpanel-open {
  padding-right: var(--cpanel-w);
}
body.panel-open.cpanel-open {
  padding-right: calc(var(--panel-w) + var(--cpanel-w));
}
.panel {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  width: var(--panel-w);
  background: var(--glass-bg);
  backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-sat));
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-sat));
  border-left: var(--border-w) solid var(--white-a60);
  box-shadow:
    -1px 0 0 var(--white-a50),
    var(--shadow-glass-core),
    -24px 0 48px var(--shadow-a02);
  padding: var(--space-96) var(--space-32) var(--space-48);
  overflow-y: auto;
  transform: translateX(100%);
  transition-property: transform, right;
  transition-duration: var(--duration-250);
  transition-timing-function: var(--ease);
  z-index: 10;
  scrollbar-width: thin;
  scrollbar-color: var(--line) transparent;
}
.panel::-webkit-scrollbar {
  width: var(--space-6);
}
.panel::-webkit-scrollbar-track {
  background: 0 0;
}
.panel::-webkit-scrollbar-thumb {
  background: var(--line);
  border-radius: var(--radius-3);
}
.panel::-webkit-scrollbar-thumb:hover {
  background: var(--faint);
}
body.resizing .panel {
  transition: none;
}
body.resizing {
  user-select: none;
  cursor: col-resize;
}
.panel-resize {
  position: absolute;
  top: 0;
  left: var(--space-n3);
  width: var(--space-7);
  height: 100%;
  cursor: col-resize;
  z-index: 12;
  touch-action: none;
}
.panel-resize::after {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: var(--space-3);
  width: var(--border-w);
  background-image: repeating-linear-gradient(to bottom, var(--faint) 0 var(--space-8), transparent var(--space-8) var(--space-16));
  opacity: 0;
  transition-property: opacity;
  transition-duration: var(--duration-200);
  transition-timing-function: var(--ease);
}
#panel.resize-hint .panel-resize::after,
#panel.resizing .panel-resize::after {
  opacity: 0.3;
}
body.panel-open #panel {
  transform: translateX(0);
}
body.panel-open.cpanel-open #panel {
  right: var(--cpanel-w);
}
/* Modal mode. Only "The plan" (phases) opens #panel as the sidebar; people,
   refs, courses and readings open the same element as a centered modal —
   script.js toggles body.panel-modal per kind. Geometry and motion mirror
   .cmd-modal so both modals feel like one surface. The wash sits above the
   comment panel's normal layer, so the cpanel is lifted over it in this mode. */
.panel-wash {
  position: fixed;
  inset: 0;
  z-index: 50;
  background: var(--wash-bg);
  backdrop-filter: blur(var(--glass-blur-lg)) saturate(var(--glass-sat));
  -webkit-backdrop-filter: blur(var(--glass-blur-lg)) saturate(var(--glass-sat));
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--duration-250) var(--ease);
}
body.panel-open.panel-modal .panel-wash {
  opacity: 1;
  pointer-events: auto;
}
body.panel-modal.panel-open {
  padding-right: 0;
}
body.panel-modal.panel-open.cpanel-open {
  padding-right: var(--cpanel-w);
}
body.panel-modal #panel {
  top: 50%;
  left: 50%;
  right: auto;
  bottom: auto;
  z-index: 51;
  width: min(var(--space-480), calc(100vw - var(--space-48)));
  max-height: calc(100vh - var(--space-96));
  padding: var(--space-64) var(--space-32) var(--space-40);
  border-left: none;
  border-radius: var(--radius-28);
  box-shadow: var(--shadow-modal);
  transform: translate(-50%, -50%) scale(0.96);
  opacity: 0;
  pointer-events: none;
  transition:
    transform var(--duration-250) var(--ease-pop),
    opacity var(--duration-200) var(--ease);
}
body.panel-modal.panel-open #panel {
  right: auto;
  transform: translate(-50%, -50%) scale(1);
  opacity: 1;
  pointer-events: auto;
}
/* The sidebar's resize handle and its sticky top fade have no place in the
   modal: no edge to drag, and .cmd-modal (the reference surface) has no fade. */
body.panel-modal #panel .panel-resize,
body.panel-modal #panel .panel-fade {
  display: none;
}
body.panel-modal #panel .panel-close {
  top: var(--space-16);
  right: var(--space-16);
}
body.panel-modal .cpanel {
  z-index: 52;
}
#panel.no-transition {
  transition: none;
}
.panel-close {
  position: absolute;
  top: var(--space-24);
  right: var(--space-24);
  width: var(--space-40);
  height: var(--space-40);
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--wash-bg);
  border: none;
  border-radius: var(--radius-full);
  font-family: inherit;
  font-size: var(--fs-15);
  color: var(--faint);
  cursor: pointer;
  z-index: 8;
  box-shadow:
    inset 0 1px 0 0 var(--white-a40),
    0 0 2px var(--shadow-a0086),
    0 0 6px var(--shadow-a0128),
    0 4px 12px var(--shadow-a0214);
  transition-property: color, background, box-shadow, scale;
  transition-duration: var(--duration-150), var(--duration-150), var(--duration-400), var(--duration-160);
  transition-timing-function: var(--ease), var(--ease), var(--ease), var(--ease-pop);
  user-select: none;
}
.panel-close:hover {
  color: var(--ink);
}
.panel-close:active {
  scale: 0.96;
}
/* All panel-close consumers (bare panel/comment close, about/avatar modals,
   ⌘K's close+back) render a Phosphor Light SVG now instead of a literal
   "×" character — sized to roughly the glyph's old visual weight. */
.panel-close svg {
  width: var(--space-15);
  height: var(--space-15);
}
.panel h3 {
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-tight);
}
.panel .role {
  color: var(--muted);
  margin-top: var(--space-2);
}
.panel .bio {
  margin-top: var(--space-20);
}
.panel .label {
  margin-top: var(--space-32);
  margin-bottom: var(--space-4);
  display: block;
}
.panel .item {
  margin-bottom: var(--space-10);
  padding-top: var(--space-6);
}
.panel .item:first-child {
  padding-top: var(--space-4);
}
.panel .row {
  padding: var(--space-8) 0;
  cursor: default;
}
.panel .row .what {
  font-variant-numeric: tabular-nums;
}
/* Deeper reference entries (27/08/2026, Simile first): an entry may carry a
   multi-paragraph `bio` and `sections` after Links (see content.js). Both
   surfaces get the rules together, like the h3/.role/.bio pairs the panel
   and the ⌘K detail already keep in step. Paragraphs inside .bio sit 12px
   apart, one step under the 16px of page prose: the column is narrower and
   the paragraphs shorter. An .entry is a person or a lineage node: name and
   role on a .row (the same .who/.what as the page rows, so it reads as a
   list, not a card), descriptor and inline links underneath because .what
   is a single nowrap line. The row may wrap: the modal is 263px wide on a
   375px phone, and a role truncated to "Founding Desi…" is worse than the
   role dropping under the name. Links are text, .inline-link, joined by
   " · ", so one person stays one quiet block and not three favicon rows. */
.panel .bio p + p,
.cmd-modal .bio p + p {
  margin-top: var(--space-12);
}
.panel .entry,
.cmd-modal .entry {
  padding: var(--space-8) 0;
}
.panel .entry .row,
.cmd-modal .entry .row {
  padding: 0;
  flex-wrap: wrap;
  row-gap: var(--space-2);
}
.panel .section-text,
.panel .entry-desc,
.cmd-modal .section-text,
.cmd-modal .entry-desc {
  margin-top: var(--space-2);
}
.panel .entry-links,
.cmd-modal .entry-links {
  margin-top: var(--space-4);
}
/* Anchor rows (<a class="row">) are the only panel rows with a pointer
   cursor, since they are the only ones that do something: people rows
   inside an entry (data-sub, swap the modal's content) and, since
   28/08/2026, the Links list. Links used to be a div.row with the <a> only
   around the label — the row was full width and drawn like the page rows,
   which open on a click anywhere, so people clicked the row's empty right
   half and nothing happened (the "ghost click" users reported). Now the
   whole row is the link, same shape the people rows already had. Colour
   stays the row's own (.row .what beats `a`); the hover feedback is the
   .row:hover .what lift where there is a .what, and the pointer itself
   (cursor.mjs sticks to the row) where there is not. */
.panel a.row,
.cmd-modal a.row {
  cursor: pointer;
}
/* Sub-entry back (27/08/2026): a person opened from inside an entry swaps
   the modal's content, and this button returns to the entry. Same spot and
   chrome as .cmd-modal__back so the two modals keep reading as one surface.
   Only ever shown in modal mode; `hidden` has to beat .panel-close's flex. */
body.panel-modal #panel .panel-back {
  top: var(--space-16);
  left: var(--space-16);
  right: auto;
}
.panel-back[hidden] {
  display: none;
}
.p-stagger {
  opacity: 0;
  transform: translateY(var(--space-12));
  filter: blur(var(--space-4));
  animation: enter var(--duration-400) var(--ease-out) forwards;
}
.p-stagger:nth-child(2) {
  animation-delay: var(--duration-60);
}
.p-stagger:nth-child(3) {
  animation-delay: var(--duration-120);
}
.p-stagger:nth-child(4) {
  animation-delay: var(--duration-180);
}
.p-stagger:nth-child(5) {
  animation-delay: var(--duration-240);
}
/* 6/7 (22/08/2026): a project entry with both a preview image and items now
   runs 7 deep (h3+role, preview, bio, "In practice" label, items, "Links"
   label, links) — two steps past what the palette/panel used to need. Past
   7 (escola-da-bel's Landing pages block only) the tail lands together at
   nth-child(1)'s delay instead of extending further — one entry doesn't
   earn its own duration tokens. */
.p-stagger:nth-child(6) {
  animation-delay: var(--duration-300);
}
.p-stagger:nth-child(7) {
  animation-delay: var(--duration-360);
}
.people .row.active .what,
.people .row.active .who a,
.row.active .what,
.row.active .who a {
  color: var(--ink);
}
/* Estado persistente da lista People: enquanto o sidebar de uma pessoa está
   aberto (body.panel-open), a mesma lógica de foco por vizinhança acima passa
   a valer sem depender de hover. `open()`/`close()` em script.js já são a
   única fonte de verdade — body.panel-open e .row.active (setados ali) — não
   existe estado novo aqui, só CSS mais específico que o de hover pra manter
   a leitura enquanto o ponteiro sai da lista.
   A row ativa fica em cheio mesmo se outra row for hovered (por isso a regra
   dela não leva :not(:hover)); as demais dimam exceto a que está sob o
   cursor no momento (:not(:hover) nela preserva o hover atual sem
   regressão). Fechar o painel remove body.panel-open e o .people volta a
   depender só de :hover, como antes.

   Dim é cor sólida (--row-dim), não opacidade — segunda volta nisso depois
   que a primeira (opacity: 0.6 só em .who, .what sem tocar) saiu sutil
   demais pedindo pra ler tão apagado quanto o 0.3 do hover comum. Opacidade
   fatalmente empurra a cor pra perto de --bg (é matemática de alpha
   compositing, não capricho): a 0.3, .who mede 1.99:1 e .what 1.5:1 reais
   contra --bg — os dois abaixo de AA (4.5:1), foi o que a rodada anterior
   corrigiu subindo pra 0.6 só em .who. Mas 0.6 não é opaco o bastante pra
   "parecer" 0.3 — e não tem opacidade nenhuma que sirva os dois lados: ler
   apagado precisa de baixo contraste, ser legível precisa do oposto, e os
   dois quiseram o mesmo canal.
   --row-dim resolve isso saindo do canal de opacidade: é a cor mais clara
   (contraste mais baixo) que essa paleta permite contra --bg mantendo AA
   com folga, calibrada direto contra --bg em cada tema (não é --muted, que
   por acaso já mede perto desse mesmo piso, ~5.37:1 em light — --row-dim é
   ~0.55L ali, uma migalha mais claro que --muted, porque essa migalha é
   literalmente todo o espaço que sobra antes de furar contraste de novo).
   Sem opacidade nenhuma envolvida, mesma cor serve .who *e* .what — os dois
   agora dimam, o que a rodada anterior não fazia (só .who mudava; .what
   ficava sempre em cheio, e foi metade do porquê de ter saído sutil). Medido
   com axe-core real contra a página/story, não só calculado — ver
   tests/ui/people-persistent-selection.test.mjs: 4.75:1 em light, 4.78:1 em
   dark, sem hover e com hover cruzado em outra row.
   Isso é o teto físico de "apagado" que dá pra ter em texto cinza AA nessa
   paleta — não chega no visual quase-invisível do 0.3 (que só existe porque
   ele fura contraste), mas é o mais apagado que da pra ficar sem reabrir a
   violação.

   Opacidade em si só aparece aqui pra neutralizar a regra de hover mais
   antiga (foco por vizinhança, `section:has(.row:hover) .row:not(:hover) >
   *`, 0.3, documentada em AGENTS.md e usada em toda lista do site — não é
   desta feature e não é pra "consertar" aqui). Com o painel aberto, passar o
   mouse por uma *outra* row ainda ativa aquela regra antiga pro resto —
   inclusive pra uma row que já faz parte deste estado persistente — e ela
   reintroduziria opacity: 0.3 por cima da nossa cor sólida, furando
   contraste de novo por um caminho diferente. A primeira regra abaixo
   trava opacity: 1 em todo filho de row dentro deste escopo (ativa, hovered
   ou dimmed) antes da regra antiga ter chance de aplicar; a segunda pinta o
   dim de fato só nas rows que não são a ativa nem a hovered. */
body.panel-open .people:has(.row.active) .row > * {
  opacity: 1;
}
body.panel-open .people:has(.row.active) .row:not(.active):not(:hover) .who a,
body.panel-open .people:has(.row.active) .row:not(.active):not(:hover) .what {
  color: var(--row-dim);
}
.cpanel {
  width: var(--cpanel-w);
  z-index: 11;
}
body.cpanel-open .cpanel {
  transform: translateX(0);
}
.c-add {
  color: var(--faint);
  margin-left: var(--space-8);
  opacity: 0;
  cursor: pointer;
  user-select: none;
  text-decoration: underline;
  text-decoration-color: var(--line);
  text-underline-offset: var(--space-3);
  transition-property: opacity, color, text-decoration-color;
  transition-duration: var(--duration-200);
  transition-timing-function: var(--ease);
}
.panel .item:hover .c-add {
  opacity: 1;
}
.c-add:hover {
  color: var(--ink);
  text-decoration-color: var(--ink);
}
.c-link {
  display: block;
  margin-top: var(--space-2);
}
.c-link a {
  color: var(--muted);
  display: inline-block;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  vertical-align: bottom;
}
.cpanel .ctext {
  margin-top: var(--space-16);
}
.cpanel textarea {
  width: 100%;
  min-height: var(--space-140);
  margin-top: var(--space-16);
  padding: var(--space-14);
  font-family: inherit;
  font-size: var(--fs-15);
  line-height: var(--lh-base);
  color: var(--ink);
  background: var(--white-a50);
  border: var(--border-w) solid var(--gray-955);
  border-radius: var(--radius-16);
  resize: vertical;
  outline: 0;
  transition-property: border-color;
  transition-duration: var(--duration-200);
  transition-timing-function: var(--ease);
}
.cpanel textarea:focus {
  border-color: var(--gray-870);
}
.cpanel .ctx {
  color: var(--faint);
  margin-top: var(--space-12);
}
.cpanel .actions {
  display: flex;
  gap: var(--space-20);
  margin-top: var(--space-16);
}
.txtbtn {
  background: 0 0;
  border: none;
  padding: 0;
  font-family: inherit;
  font-size: var(--fs-15);
  color: var(--ink);
  cursor: pointer;
  text-decoration: underline;
  text-decoration-color: var(--line);
  text-underline-offset: var(--space-3);
  transition-property: text-decoration-color, color, scale;
  transition-duration: var(--duration-200);
  transition-timing-function: var(--ease);
  user-select: none;
}
.txtbtn:hover {
  text-decoration-color: var(--ink);
}
.txtbtn:active {
  scale: 0.96;
}
.txtbtn.quiet {
  color: var(--faint);
}
.txtbtn.quiet:hover {
  color: var(--muted);
}
@media (max-width: 900px) {
  body.cpanel-open,
  body.panel-open.cpanel-open {
    padding-right: 0;
  }
  .cpanel {
    width: min(var(--cpanel-w), 100%);
  }
  body.panel-open.cpanel-open #panel {
    right: 0;
  }
}
@media (max-width: 900px) {
  body.panel-open {
    padding-right: 0;
  }
  .panel {
    width: min(var(--panel-w), 100%);
  }
}
.topbar {
  position: fixed;
  top: var(--space-24);
  left: 50%;
  display: flex;
  align-items: center;
  gap: var(--space-8);
  transform: translate(-50%, var(--space-n6)) scale(0.98);
  z-index: 5;
  opacity: 0;
  pointer-events: none;
  transition-property: opacity, transform;
  transition-duration: var(--duration-120);
  transition-timing-function: var(--ease);
}
.topbar.visible {
  opacity: 1;
  transform: translate(-50%, 0) scale(1);
  pointer-events: auto;
  transition-duration: var(--duration-200);
  transition-timing-function: var(--ease-out);
}
.topbar__logo,
.topbar__nav,
.topbar__social {
  height: var(--topbar-h);
  background: var(--glass-bg);
  backdrop-filter: blur(var(--glass-blur-sm)) saturate(var(--glass-sat));
  -webkit-backdrop-filter: blur(var(--glass-blur-sm)) saturate(var(--glass-sat));
  border-radius: var(--radius-full);
  box-shadow:
    var(--shadow-glass-core),
    0 4px 16px var(--shadow-a02);
}
.topbar__logo {
  width: var(--topbar-h);
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  color: var(--faint);
  transition-property: color, background;
  transition-duration: var(--duration-200);
  transition-timing-function: var(--ease);
}
.topbar__logo:hover {
  color: var(--muted);
  background: var(--ink-a5);
}
.topbar__logo svg {
  width: var(--space-13-77);
  height: auto;
}
.topbar__social {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: var(--space-2);
  padding: 0 var(--space-6);
  flex-shrink: 0;
}
.topbar__icon {
  width: var(--space-37);
  height: var(--space-37);
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  padding: 0;
  border: none;
  background: 0 0;
  border-radius: var(--radius-full);
  color: var(--faint);
  cursor: pointer;
  transition-property: color, background, scale;
  transition-duration: var(--duration-200);
  transition-timing-function: var(--ease);
}
.topbar__icon:hover {
  color: var(--muted);
  background: var(--ink-a5);
}
.topbar__icon:active {
  scale: 0.96;
}
.topbar__icon svg {
  width: var(--space-17);
  height: var(--space-17);
}
/* Same stacked-icon crossfade as `.composer__send` (icon-send/icon-close):
   both svgs sit on top of each other via `position: absolute` on a
   `position: relative` button, so swapping which one is visible never
   reflows the row of icons. `data-mode` mirrors the *current* theme, set by
   script.js — showing the moon in light mode (an invitation to go dark) and
   the sun in dark mode (an invitation to go back), same convention every OS
   dark-mode switch uses. */
.theme-toggle {
  position: relative;
}
.theme-toggle svg {
  position: absolute;
  transition-property: opacity, scale, filter;
  transition-duration: var(--duration-200);
  transition-timing-function: var(--ease);
}
.theme-toggle .icon-moon {
  opacity: 1;
  scale: 1;
  filter: blur(0);
}
.theme-toggle .icon-sun {
  opacity: 0;
  scale: 0.25;
  filter: blur(var(--space-4));
}
.theme-toggle[data-mode="dark"] .icon-moon {
  opacity: 0;
  scale: 0.25;
  filter: blur(var(--space-4));
}
.theme-toggle[data-mode="dark"] .icon-sun {
  opacity: 1;
  scale: 1;
  filter: blur(0);
}
.topbar__nav {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: var(--space-2);
  padding: 0 var(--space-8);
}
.topbar__nav a {
  font-size: var(--fs-13);
  color: var(--faint);
  text-decoration: none;
  white-space: nowrap;
  user-select: none;
  padding: var(--space-8) var(--space-16);
  border-radius: var(--radius-full);
  transition-property: color, background;
  transition-duration: var(--duration-200);
  transition-timing-function: var(--ease);
}
.topbar__nav a:hover {
  color: var(--muted);
  background: var(--ink-a5);
}
.topbar__nav a.on {
  color: var(--ink);
  background: var(--ink-a7);
}
/* "portfólio" is announced before the page exists: a non-interactive item in
   the same pill metrics as the links — a span on purpose, so there is no
   href, no Tab stop and no hover invitation — carrying a small status tag.
   Tag recipe matches `.cmd__esc` (size, weight, tracking, ink-a5 fill), just
   pill-rounded like everything else in the topbar. */
.topbar__soon {
  display: flex;
  align-items: center;
  gap: var(--space-6);
  font-size: var(--fs-13);
  color: var(--faint);
  white-space: nowrap;
  user-select: none;
  cursor: default;
  padding: var(--space-8) var(--space-16);
}
.topbar__soon-tag {
  font-size: var(--fs-11);
  font-weight: var(--fw-medium);
  letter-spacing: var(--ls-wide);
  color: var(--faint);
  background: var(--ink-a5);
  border-radius: var(--radius-full);
  padding: var(--space-2) var(--space-8);
}
@media (max-width: 900px) {
  .topbar {
    top: max(var(--space-16), env(safe-area-inset-top));
  }
  .topbar__nav {
    display: none;
  }
  .topbar__icon {
    width: var(--space-44);
    height: var(--space-44);
  }
}
.gloss {
  position: relative;
  cursor: help;
  border-bottom: var(--border-w) dotted var(--line);
  transition-property: border-bottom-color;
  transition-duration: var(--duration-200);
  transition-timing-function: var(--ease);
}
.gloss:focus-visible,
.gloss:hover {
  border-bottom-color: var(--faint);
  outline: 0;
}
.gloss-tip {
  position: absolute;
  left: 0;
  bottom: calc(100% + var(--space-8));
  width: max-content;
  max-width: var(--space-220);
  background: var(--white);
  border: var(--border-w) solid var(--line);
  border-radius: var(--radius-6);
  box-shadow:
    var(--shadow-glass-core),
    0 10px 24px var(--shadow-a02);
  padding: var(--space-10) var(--space-12);
  font-size: var(--fs-13);
  line-height: var(--lh-snug);
  color: var(--muted);
  cursor: default;
  opacity: 0;
  transform: translateY(var(--space-4));
  pointer-events: none;
  transition-property: opacity, transform;
  transition-duration: var(--duration-180);
  transition-timing-function: var(--ease);
  z-index: 20;
}
.gloss:focus-visible .gloss-tip,
.gloss:hover .gloss-tip {
  opacity: 1;
  transform: translateY(0);
  pointer-events: auto;
}
.note-row {
  margin-top: var(--space-20);
}
a.note-open.note-empty {
  color: var(--faint);
}
a.note-open.note-empty:hover {
  color: var(--muted);
}
.page-fade {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: var(--space-64);
  background: linear-gradient(to bottom, var(--bg) 0, transparent 100%);
  pointer-events: none;
  z-index: 2;
}
.panel-fade {
  position: sticky;
  top: 0;
  height: var(--space-32);
  background: linear-gradient(to bottom, var(--bg-a90) 0, transparent 100%);
  pointer-events: none;
  z-index: 6;
}
.stagger {
  opacity: 0;
  transform: translateY(var(--space-12));
  filter: blur(var(--space-4));
  animation: enter var(--duration-500) var(--ease-out) forwards;
}
.stagger:first-child {
  animation-delay: 0s;
}
.stagger:nth-child(2) {
  animation-delay: var(--duration-80);
}
.stagger:nth-child(3) {
  animation-delay: var(--duration-160);
}
.stagger:nth-child(4) {
  animation-delay: var(--duration-240);
}
.stagger:nth-child(5) {
  animation-delay: var(--duration-320);
}
.stagger:nth-child(6) {
  animation-delay: var(--duration-400);
}
.stagger:nth-child(7) {
  animation-delay: var(--duration-480);
}
.stagger:nth-child(8) {
  animation-delay: var(--duration-560);
}
.stagger:nth-child(9) {
  animation-delay: var(--duration-640);
}
@keyframes enter {
  to {
    opacity: 1;
    transform: translateY(0);
    /* Written as `blur(0)`, not `none` — has to be, so it animates smoothly
       from `blur(var(--space-4))` — but a CSS Animation resolves that end
       value to a concrete `blur(0px)` no matter which of the two the
       keyframe names (tried `none` here first, 22/08/2026: identical
       computed style, confirmed in a minimal repro before touching this
       comment). Any non-`none` filter, even this visually-inert one, gives
       the element its own stacking context, which clips box-shadow bleeding
       past its own border box — invisible while every `.stagger`/
       `.row.extra` consumer only had a flat fill, but the instant `.row`/
       `.doc-item` grew a hover box-shadow (22/08/2026, see "Hover das listas
       da página" above) any row revealed by show more rendered that shadow
       cut flush at its own edges. The real fix can't live here: it's the
       `animationend` listener in script.js/chrome.js that clears `filter`
       from *outside* the animation once it ends, which is the only way to
       land on a genuine `none`. */
    filter: blur(0);
  }
}
/* ---------------------------------------------------------------------------
   Intro — the "hello screensaver": a full-bleed greeting in six languages
   that plays over the page before the content is revealed.

   The languages cut, they don't cross-fade: one word per --intro-step, swapped
   on a metronome. The single dissolve in the whole sequence is the last
   greeting handing off to the mark, which then hands off to the page.

   The overlay is inert unless <html> carries `.intro-playing`. That class ships
   in the markup (so there is no frame of content before the intro paints) and
   script.js drops it when the sequence ends. Anything that keeps the sequence
   from running — reduced motion, JS disabled — takes the class out of play
   instead of adding one, so a failure can never strand the page behind a
   permanent white sheet.

   Word and mark share one grid cell: the word fades out, the mark fades in on
   the same optical center. Timing comes from tokens/motion.css.
--------------------------------------------------------------------------- */
.intro {
  display: none;
}
.intro-playing .intro {
  display: grid;
  place-items: center;
  position: fixed;
  inset: 0;
  z-index: 100;
  padding-inline: var(--space-24);
  background: var(--bg);
  transition: opacity var(--intro-out) var(--ease-out);
}
.intro-playing .intro > * {
  grid-area: 1 / 1;
}
/* The content stagger is withheld behind the overlay and dealt the moment the
   overlay starts dissolving, so the two cross-fade instead of leaving a blank
   page between them. Dropping `animation-name` rather than pausing is what
   makes this reliable: the elements simply sit in the static opacity-0 state
   they already declare, and `enter` starts clean from zero when the name comes
   back — no resuming a timeline that began before the page was visible. */
.intro-playing:not(.intro-done) .stagger {
  animation-name: none;
}
.intro-playing.intro-done .intro {
  opacity: 0;
  pointer-events: none;
}
/* Skipping is a cut, not a dissolve — match the word transition, not the outro. */
.intro-playing.intro-skipped .intro {
  transition-duration: var(--intro-fade);
}
.intro__word {
  display: flex;
  align-items: center;
  white-space: nowrap;
  font-size: 1.2rem;
  font-weight: var(--fw-medium);
  line-height: var(--lh-snug);
  letter-spacing: var(--ls-tight);
  color: var(--ink);
  opacity: 0;
  transform: scale(0.95);
  /* No transition here on purpose. The row cuts in with the first greeting and
     every language after it swaps inside `.intro__langs` with no fade at all;
     the row animates exactly once, on its way out, and `.dissolve` is what
     grants it that transition for that one moment. */
}
/* Every greeting sits stacked in a single grid cell, all six in the DOM from the
   start: the stack settles on the width of the widest word (こんにちは) and never
   reflows again. Swapping is a class toggle between two elements that are
   already laid out — no measurement, no reflow.
   Centered, not start-aligned (there was a bullet marker here until
   22/08/2026 — removed for craft, see git history). With the bullet gone
   there's nothing pinning the stack to a left edge, so a plain center has no
   trade-off left to make: it keeps the visible word's optical center
   identical across all six languages, instead of drifting with each word's
   width the way start-alignment did. */
.intro__langs {
  display: grid;
  align-items: center;
  justify-items: center;
}
.intro__lang {
  grid-area: 1 / 1;
  opacity: 0;
}
.intro__lang.on {
  opacity: 1;
}
.intro__word.dissolve {
  transition:
    opacity var(--intro-fade) var(--ease-out),
    transform var(--intro-fade) var(--ease-out);
}
.intro__mark {
  display: block;
  height: var(--space-32);
  width: auto;
  opacity: 0;
  transform: scale(0.95);
  transition:
    opacity var(--intro-reveal) var(--ease-out),
    transform var(--intro-reveal) var(--ease-out);
}
.intro__word.visible,
.intro__mark.visible {
  opacity: 1;
  transform: scale(1);
}
/* A palavra é 1.2rem nos dois breakpoints de propósito — sem gap pra encolher
   desde que a bolinha saiu (22/08/2026), este breakpoint não tem mais nada
   pra fazer com .intro__word. */
@media (max-width: 560px) {
  /* The tip is anchored to the glossed word (`left: 0` on a `position:
     relative` inline span) and is up to 220px wide, so a word in the right half
     of a 375px screen throws it past the viewport edge — laid out even at
     `opacity: 0`, which is enough to make the page scroll sideways. Below 560px
     it anchors to the list item instead and spans its full width, which is both
     inside the viewport by construction and easier to read on a phone. */
  .phase li {
    position: relative;
  }
  .phase li .gloss {
    position: static;
  }
  .phase li .gloss-tip {
    right: 0;
    width: auto;
    max-width: none;
  }
}
@supports (corner-shape: superellipse(1)) {
  .fav {
    corner-shape: var(--corner-sm);
  }
  .gloss-tip {
    corner-shape: var(--corner-lg);
  }
  .cpanel textarea {
    corner-shape: var(--corner-sm);
  }
  .about-modal,
  body.panel-modal #panel {
    corner-shape: var(--corner-lg);
  }
  .composer {
    corner-shape: var(--corner-lg);
  }
}
.about-wash {
  position: fixed;
  inset: 0;
  z-index: 50;
  background: var(--wash-bg);
  backdrop-filter: blur(var(--glass-blur-lg)) saturate(var(--glass-sat));
  -webkit-backdrop-filter: blur(var(--glass-blur-lg)) saturate(var(--glass-sat));
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--duration-250) var(--ease);
}
body.about-open .about-wash {
  opacity: 1;
  pointer-events: auto;
}
.about-modal {
  position: fixed;
  top: 50%;
  left: 50%;
  z-index: 51;
  width: min(var(--space-420), calc(100vw - var(--space-48)));
  max-height: calc(100vh - var(--space-96));
  overflow-y: auto;
  padding: var(--space-40) var(--space-32);
  background: var(--glass-bg);
  backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-sat));
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-sat));
  border-radius: var(--radius-28);
  box-shadow: var(--shadow-modal);
  transform: translate(-50%, -50%) scale(0.96);
  opacity: 0;
  pointer-events: none;
  transition:
    transform var(--duration-250) var(--ease-pop),
    opacity var(--duration-200) var(--ease);
}
body.about-open .about-modal {
  transform: translate(-50%, -50%) scale(1);
  opacity: 1;
  pointer-events: auto;
}
.about-modal__close {
  position: absolute;
  top: var(--space-16);
  right: var(--space-16);
}
.about-modal__lede {
  color: var(--muted);
  margin-top: var(--space-16);
  text-wrap: pretty;
}
.about-modal__version {
  margin-top: var(--space-8);
}
.about-modal__version .row {
  padding: var(--space-8) 0;
  cursor: default;
}
.about-modal__version .what {
  font-variant-numeric: tabular-nums;
}
.about-modal__note {
  color: var(--muted);
  margin-top: var(--space-8);
  font-size: var(--fs-14);
  text-wrap: pretty;
}
.about-trigger {
  background: 0 0;
  border: none;
  padding: 0;
  font: inherit;
  font-variant-numeric: tabular-nums;
  color: var(--faint);
  cursor: pointer;
  transition-property: color;
  transition-duration: var(--duration-200);
  transition-timing-function: var(--ease);
}
.about-trigger:hover {
  color: var(--muted);
}
.profile {
  display: flex;
  align-items: center;
  gap: var(--space-12);
  margin-bottom: var(--space-32);
}
.profile__avatar {
  width: var(--space-57);
  height: var(--space-57);
  border-radius: var(--radius-full);
  overflow: hidden;
  padding: 0;
  border: none;
  background: 0 0;
  cursor: pointer;
  flex-shrink: 0;
  transition-property: opacity;
  transition-duration: var(--duration-200);
  transition-timing-function: var(--ease);
}
.profile__avatar:hover {
  opacity: 0.82;
}
.profile__avatar:active {
  scale: 0.96;
}
.profile__avatar img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.profile__id {
  display: flex;
  flex-direction: column;
}
.profile__name {
  font-size: var(--fs-15);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-tight);
  color: var(--ink);
}
.profile__role {
  font-size: var(--fs-13);
  color: var(--muted);
  margin-top: var(--space-2);
}
.avatar-wash {
  position: fixed;
  inset: 0;
  z-index: 50;
  background: var(--wash-bg);
  backdrop-filter: blur(var(--glass-blur-lg)) saturate(var(--glass-sat));
  -webkit-backdrop-filter: blur(var(--glass-blur-lg)) saturate(var(--glass-sat));
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--duration-250) var(--ease);
}
body.avatar-open .avatar-wash {
  opacity: 1;
  pointer-events: auto;
}
.avatar-viewer {
  position: fixed;
  top: 50%;
  left: 50%;
  z-index: 51;
  width: min(var(--space-150), calc(100vw - var(--space-64)));
  transform: translate(-50%, -50%) scale(0.94);
  opacity: 0;
  pointer-events: none;
  transition:
    transform var(--duration-250) var(--ease-pop),
    opacity var(--duration-200) var(--ease);
}
body.avatar-open .avatar-viewer {
  transform: translate(-50%, -50%) scale(1);
  opacity: 1;
  pointer-events: auto;
}
.avatar-viewer__img {
  width: 100%;
  aspect-ratio: 1/1;
  border-radius: var(--radius-full);
  display: block;
  object-fit: cover;
  box-shadow:
    var(--shadow-glass-core),
    0 24px 64px var(--shadow-a08);
}
.avatar-viewer__close {
  position: absolute;
  top: var(--space-n8);
  right: var(--space-n8);
}
.mail-wash {
  position: fixed;
  inset: 0;
  z-index: 50;
  background: var(--wash-bg);
  backdrop-filter: blur(var(--glass-blur-lg)) saturate(var(--glass-sat));
  -webkit-backdrop-filter: blur(var(--glass-blur-lg)) saturate(var(--glass-sat));
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--duration-250) var(--ease);
}
body.mail-open .mail-wash {
  opacity: 1;
  pointer-events: auto;
}
.mail-modal {
  position: fixed;
  top: 50%;
  left: 50%;
  z-index: 51;
  width: min(var(--space-620), calc(100vw - var(--space-48)));
  transform: translate(-50%, -50%) scale(0.96);
  opacity: 0;
  pointer-events: none;
  transition:
    transform var(--duration-250) var(--ease-pop),
    opacity var(--duration-200) var(--ease);
}
body.mail-open .mail-modal {
  transform: translate(-50%, -50%) scale(1);
  opacity: 1;
  pointer-events: auto;
}
.composer {
  background: var(--white);
  border: var(--border-w) solid var(--line);
  border-radius: var(--radius-32);
  padding: var(--space-12);
  box-shadow: var(--shadow-card);
}
/* Two-step composer (added 23/08/2026): `#stepEmail` and `#stepMessage`
   swap via mail.js's `showStep()`, which toggles `hidden` + a class rather
   than driving two permanently-mounted, absolutely-stacked panels — so the
   crossfade is a real exit-then-enter, not two elements overlapping. Both
   steps sit at opacity 0 for the instant the swap happens, which is what
   hides the height change between the two steps; there's no explicit
   height transition anywhere here.

   The email step deliberately reuses `.composer__input` (same textarea
   look, same rows="4" footprint) and `.composer__send` (same 40px round
   button, just a different icon and no data-mode) verbatim instead of
   introducing its own field/button styling — asked for explicitly
   23/08/2026 after a first pass built a distinct single-line component
   that didn't match. Until 29/08/2026 the step had its own
   `.composer__actions--end` (`justify-content: flex-end`) because it had
   only the one button and no `.composer__to` label to balance against —
   plain `.composer__actions`'s `space-between` would have pinned
   the arrow left. Since then the step shows the transparency line
   ("Used only to reply.") in the same `.composer__to` slot, so the two
   steps share the exact same actions row. */
.composer__step {
  transition:
    opacity var(--duration-200) var(--ease),
    transform var(--duration-200) var(--ease),
    filter var(--duration-200) var(--ease);
}
/* --step-dir (1 forward, -1 back) is set on .composer__stage right before
   each transition by showStep() — the exiting step always moves opposite
   the entering one, and back reverses both, so "voltar" actually reads as
   reversing "avançar" instead of replaying the same forward slide. */
.composer__step.is-leaving {
  transition-duration: var(--duration-150);
  opacity: 0;
  transform: translateX(calc(var(--space-12) * -1 * var(--step-dir, 1)));
  filter: blur(var(--space-4));
}
.composer__step.is-entering {
  opacity: 0;
  transform: translateX(calc(var(--space-12) * var(--step-dir, 1)));
  filter: blur(var(--space-4));
}
.composer__input {
  width: 100%;
  min-height: var(--space-84);
  max-height: var(--space-240);
  padding: var(--space-12) var(--space-12) var(--space-8);
  font-family: inherit;
  font-size: var(--fs-16);
  line-height: var(--lh-base);
  color: var(--ink);
  background: 0 0;
  border: none;
  outline: 0;
  resize: none;
  display: block;
  overflow-y: auto;
}
.composer__input::placeholder {
  color: var(--faint);
}
/* Inline validation line under either textarea (29/08/2026): "Add your
   email so I can reply." / "That email doesn't look right." on the email
   step, "Couldn't send — try again." on the message step. Muted ink, not
   red — the palette has no danger token and the site is monochrome on
   purpose; the words carry the meaning (and `aria-invalid` + role="alert"
   carry it to AT), the shake below carries the moment. Same 12px inset as
   the textarea's own padding so it reads as part of the field. */
.composer__hint {
  margin: 0;
  padding: 0 var(--space-12) var(--space-8);
  font-size: var(--fs-13);
  line-height: var(--lh-base);
  color: var(--muted);
}
.composer__hint[hidden] {
  display: none;
}
/* Honeypot (`#mailTrap`): off-canvas, not `display: none` — form bots skip
   fields that aren't rendered and fill the ones that are. `aria-hidden` +
   `tabindex="-1"` keep it out of AT and the Tab order, so no person meets it. */
.composer__trap {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
}
/* Short horizontal shake on the step whose validation failed — the
   "wrong password" gesture. `translate`, not `transform`, so it never
   fights the enter/leave slide above; ±4px, four beats, one duration. */
.composer__step.is-invalid {
  animation: composer-shake var(--duration-320) var(--ease);
}
@keyframes composer-shake {
  20%,
  60% {
    translate: calc(var(--space-4) * -1) 0;
  }
  40%,
  80% {
    translate: var(--space-4) 0;
  }
}
@media (prefers-reduced-motion: reduce) {
  .composer__step.is-invalid {
    animation: none;
  }
}
.composer__actions {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-16);
  padding-left: var(--space-12);
}
/* Wraps `#mailBack` + `.composer__to` as one flex item so `.composer__actions`'s
   `space-between` still only ever sees two groups (this one, `.composer__send`)
   — without it, three direct children would spread the back button, the
   email, and the send button apart instead of keeping the first two together. */
.composer__from {
  display: flex;
  align-items: center;
  min-width: 0;
}
.composer__to {
  font-size: var(--fs-13);
  color: var(--faint);
  user-select: none;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* The email step's "Used only to reply." shares `.composer__to`'s slot and
   size but not its `--faint`: the address on the message step is a
   decoration the visitor already knows, this line is information they are
   entitled to read — `--muted` (4.75:1 measured, see tokens/colors.css) is
   the readable one. Storybook's axe run caught it (29/08/2026). */
.composer__note {
  color: var(--muted);
  user-select: text;
}
/* Small, quiet — same chevron path as ⌘K's detail-view back button
   (`cmd-modal__back`), but without its circular wash-colored chrome: this
   one sits inline next to the "to" address, not floating over a panel.
   Hidden until `.composer__from` is hovered (or the button itself gets
   keyboard focus) — it's a rarely-needed correction, not a control that
   should compete with the "to" address at rest. Collapsed to `width: 0`
   rather than just `opacity: 0` — the spacing to "to" lives on this
   element's own `margin-right` (not `.composer__from`'s `gap`) so both
   width and margin animate to 0 together and leave no reserved gap. */
.composer__back {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  overflow: hidden;
  width: 0;
  height: var(--space-16);
  margin-right: 0;
  padding: 0;
  border: none;
  background: none;
  color: var(--faint);
  cursor: pointer;
  opacity: 0;
  pointer-events: none;
  transition:
    color var(--duration-150) var(--ease),
    opacity var(--duration-150) var(--ease),
    width var(--duration-150) var(--ease),
    margin-right var(--duration-150) var(--ease);
}
.composer__from:hover .composer__back,
.composer__back:focus-visible {
  width: var(--space-16);
  margin-right: var(--space-16);
  opacity: 1;
  pointer-events: auto;
}
.composer__back:hover {
  color: var(--ink);
}
.composer__back svg {
  width: var(--space-14);
  height: var(--space-14);
}
.composer__send {
  position: relative;
  width: var(--space-40);
  height: var(--space-40);
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: var(--radius-full);
  background: var(--ink);
  color: var(--white);
  cursor: pointer;
  font: inherit;
  padding: 0;
  transition-property: scale, background;
  transition-duration: var(--duration-200);
  transition-timing-function: var(--ease);
}
.composer__send:active {
  scale: 0.96;
}
.composer__send svg {
  position: absolute;
  width: var(--space-18);
  height: var(--space-18);
  transition-property: opacity, scale, filter;
  transition-duration: var(--duration-200);
  transition-timing-function: var(--ease);
}
.composer__send .icon-send {
  opacity: 1;
  scale: 1;
  filter: blur(0);
  translate: 0 var(--space-0-5);
}
.composer__send .icon-close {
  opacity: 0;
  scale: 0.25;
  filter: blur(var(--space-4));
}
.composer__send[data-mode="close"] .icon-send {
  opacity: 0;
  scale: 0.25;
  filter: blur(var(--space-4));
}
.composer__send[data-mode="close"] .icon-close {
  opacity: 1;
  scale: 1;
  filter: blur(0);
}
.composer__send .icon-sent {
  opacity: 0;
  scale: 0.25;
  filter: blur(var(--space-4));
}
.composer__send[data-mode="sent"] .icon-send,
.composer__send[data-mode="sent"] .icon-close {
  opacity: 0;
  scale: 0.25;
  filter: blur(var(--space-4));
}
.composer__send[data-mode="sent"] .icon-sent {
  opacity: 1;
  scale: 1;
  filter: blur(0);
}
/* Checkmark draws itself in rather than just fading with the other two
   icons — gives the success state its own signature motion instead of
   reusing the routine send/close crossfade verbatim. 17 is the path's
   actual length (~16.3, measured from its `d`) rounded up a hair so the
   line finishes exactly as the transition ends, not before. Delayed by
   --duration-80 so the draw reads as a second beat after the icon has
   already faded in, not simultaneous with it. */
.composer__send .icon-sent path {
  stroke-dasharray: 17;
  stroke-dashoffset: 17;
  transition: stroke-dashoffset var(--duration-320) var(--ease) var(--duration-80);
}
.composer__send[data-mode="sent"] .icon-sent path {
  stroke-dashoffset: 0;
}
/* Small scale pop on the whole button the moment it lands on "sent" — the
   send/close crossfade alone read as too flat/"seco" for a confirmation
   (23/08/2026). Only the 35% keyframe is set; start/end inherit whatever
   scale is already computed (1, or 0.96 mid-:active), same shorthand the
   `enter` keyframe above relies on. */
.composer__send[data-mode="sent"] {
  animation: mail-sent-pop var(--duration-320) var(--ease);
}
@keyframes mail-sent-pop {
  35% {
    scale: 1.08;
  }
}
.cmd-wash {
  position: fixed;
  inset: 0;
  z-index: 60;
  background: var(--wash-bg);
  backdrop-filter: blur(var(--glass-blur-lg)) saturate(var(--glass-sat));
  -webkit-backdrop-filter: blur(var(--glass-blur-lg)) saturate(var(--glass-sat));
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--duration-250) var(--ease);
}
/* Visibility keys on the element's own aria-hidden, not on the body switch
   (`body.cmd-open` / `body.cmd-detail-open` still exist as page-level state).
   Reason: /prompts has two `.cmd-modal` + `.cmd-wash` pairs on the same page
   — the ⌘K palette's (cmd.mjs) and the prompt detail's (prompts.mjs) — and
   both toggle `cmd-detail-open`. Keyed on the body class, opening one would
   light up both. Every surface already sets its own aria-hidden in the same
   tick as the class, so nothing about the motion changes. */
.cmd-wash[aria-hidden="false"] {
  opacity: 1;
  pointer-events: auto;
}
.cmd {
  position: fixed;
  top: 50%;
  left: 50%;
  z-index: 61;
  width: min(var(--space-560), calc(100vw - var(--space-48)));
  transform: translate(-50%, -50%) scale(0.96);
  opacity: 0;
  pointer-events: none;
  transition:
    transform var(--duration-250) var(--ease-pop),
    opacity var(--duration-200) var(--ease);
}
.cmd[aria-hidden="false"] {
  transform: translate(-50%, -50%) scale(1);
  opacity: 1;
  pointer-events: auto;
}
.cmd__card {
  background: var(--white);
  border: var(--border-w) solid var(--line);
  border-radius: var(--radius-24);
  padding: var(--space-12);
  box-shadow: var(--shadow-card);
}
.cmd__head {
  display: flex;
  align-items: center;
  gap: var(--space-12);
  padding: var(--space-8) var(--space-12) var(--space-16);
}
.cmd__input {
  flex: 1;
  min-width: 0;
  font-family: inherit;
  font-size: var(--fs-16);
  line-height: var(--lh-base);
  color: var(--ink);
  background: transparent;
  border: none;
  outline: 0;
}
.cmd__input::placeholder {
  color: var(--faint);
}
/* Canonical visually-hidden pattern: stays in the accessibility tree (unlike
   `display: none`/`visibility: hidden`) while taking no visual space. 1px
   boxes, not 0 — some screen readers skip zero-sized elements. Backs
   `#cmdEscHint`, the `aria-describedby` target for `.cmd__input` (see
   `.cmd__esc` below and the comment in `script.js`), and is generic enough
   to reuse anywhere else a control needs a description with no visible text. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.cmd__esc {
  flex-shrink: 0;
  font-size: var(--fs-11);
  font-weight: var(--fw-medium);
  letter-spacing: var(--ls-wide);
  color: var(--faint);
  background: var(--ink-a5);
  border-radius: var(--radius-6);
  padding: var(--space-4) var(--space-8);
  white-space: nowrap;
  user-select: none;
}
.cmd__rule {
  height: var(--border-w);
  background: var(--line);
  margin: 0 var(--space-n12);
}
.cmd__list {
  max-height: min(var(--space-420), 60vh);
  overflow-y: auto;
  padding-top: var(--space-8);
  scrollbar-width: thin;
  scrollbar-color: var(--line) transparent;
}
.cmd__list::-webkit-scrollbar {
  width: var(--space-6);
}
.cmd__list::-webkit-scrollbar-thumb {
  background: var(--line);
  border-radius: var(--radius-3);
}
.cmd__group {
  font-size: var(--fs-12);
  font-weight: var(--fw-medium);
  color: var(--faint);
  padding: var(--space-12) var(--space-12) var(--space-6);
  user-select: none;
}
.cmd__item {
  display: flex;
  align-items: baseline;
  gap: var(--space-12);
  width: 100%;
  text-align: left;
  padding: var(--space-11) var(--space-12);
  border: none;
  background: 0 0;
  border-radius: var(--radius-12);
  font-family: inherit;
  font-size: var(--fs-15);
  color: var(--ink);
  cursor: pointer;
  transition:
    background var(--duration-120) var(--ease),
    opacity var(--duration-250) var(--ease);
}
.cmd__item[aria-selected="true"] {
  background: var(--ink-a5);
}
.cmd__item .what {
  color: var(--muted);
  font-size: var(--fs-13);
  margin-left: auto;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  flex-shrink: 1;
}
.cmd__empty {
  color: var(--faint);
  padding: var(--space-20) var(--space-12);
  text-align: center;
}
.cmd__hint {
  display: flex;
  gap: var(--space-16);
  padding: var(--space-12) var(--space-12) var(--space-4);
  font-size: var(--fs-12);
  color: var(--faint);
  user-select: none;
}
.cmd-modal {
  position: fixed;
  top: 50%;
  left: 50%;
  z-index: 61;
  width: min(var(--space-480), calc(100vw - var(--space-48)));
  max-height: calc(100vh - var(--space-96));
  overflow-y: auto;
  /* Extra top padding: back and close now bracket the header, so the title
     needs a band of its own instead of running under them. */
  padding: var(--space-64) var(--space-32) var(--space-40);
  background: var(--glass-bg);
  backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-sat));
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-sat));
  border-radius: var(--radius-28);
  box-shadow: var(--shadow-modal);
  transform: translate(-50%, -50%) scale(0.96);
  opacity: 0;
  pointer-events: none;
  transition:
    transform var(--duration-250) var(--ease-pop),
    opacity var(--duration-200) var(--ease);
}
.cmd-modal[aria-hidden="false"] {
  transform: translate(-50%, -50%) scale(1);
  opacity: 1;
  pointer-events: auto;
}
.cmd-modal__close {
  position: absolute;
  top: var(--space-16);
  right: var(--space-16);
}
.cmd-modal__back {
  position: absolute;
  top: var(--space-16);
  left: var(--space-16);
}
/* Opened straight from a page row (portfolio lists): no results to go back
   to, so the back button goes and the title takes the left edge. */
.cmd-modal--direct .cmd-modal__back {
  display: none;
}
.cmd-modal h3 {
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-tight);
}
.cmd-modal .role {
  color: var(--muted);
  margin-top: var(--space-2);
}
/* Project preview (22/08/2026): the entry's own og:image, hotlinked live —
   1.91:1 is the standard og:image ratio (1200×630); `object-fit: cover`
   absorbs whatever the source actually ships. Sits between .role and .bio,
   so .bio's own margin-top now measures off the image instead of the role
   line — reads the same either way, since both are "gap before the next
   block of text/media" at the same token. */
.cmd-modal .cmd-modal__preview {
  display: block;
  width: 100%;
  aspect-ratio: 1200 / 630;
  object-fit: cover;
  margin-top: var(--space-16);
  border-radius: var(--radius-16);
  border: var(--border-w) solid var(--line);
}
.cmd-modal .bio {
  margin-top: var(--space-20);
}
/* Sub-project cards (22/08/2026, escola-da-bel's Landing pages): same
   preview-image contract as .cmd-modal__preview above, one per linked page,
   stacked rather than gridded (image on top, name + description under it).
   The whole card is the tap target (28/08/2026). It was name-only from
   22/08 — a card-wide `<a>` made hover read too heavy — but a card that
   looks like one thing and only answers on one word is the ghost click
   users reported on the Links rows, so the anchor is the card again and
   the *hover* is what stays light: only the preview border and the name's
   underline react (no fill, no lift), keyed on `.subproject:hover` now
   that the name is no longer its own anchor. `.subproject__name` keeps
   `.inline-link`'s underline treatment via the class on the markup side,
   so the "link inside a sentence" look stays in one place; the description
   sets its own colour because `a` would paint it --ink otherwise. */
.cmd-modal__subprojects {
  display: flex;
  flex-direction: column;
  gap: var(--space-24);
}
.subproject {
  display: block;
  color: inherit;
}
.subproject__preview {
  display: block;
  width: 100%;
  aspect-ratio: 1200 / 630;
  object-fit: cover;
  border-radius: var(--radius-16);
  border: var(--border-w) solid var(--line);
}
.subproject__name {
  display: block;
  width: fit-content;
  margin-top: var(--space-10);
  font-weight: var(--fw-semibold);
}
.subproject__desc {
  color: var(--muted);
  margin-top: var(--space-4);
}
@media (hover: hover) and (pointer: fine) {
  .subproject:hover .subproject__preview {
    border-color: var(--faint);
  }
  .subproject:hover .subproject__name {
    text-decoration-color: var(--ink);
  }
}
.cmd-modal .label {
  margin-top: var(--space-32);
  margin-bottom: var(--space-4);
  display: block;
}
.cmd-modal .item {
  margin-bottom: var(--space-10);
  padding-top: var(--space-6);
}
.cmd-modal .row {
  padding: var(--space-8) 0;
  cursor: default;
}
.cmd-trigger {
  cursor: pointer;
}
@supports (corner-shape: superellipse(1)) {
  .cmd__card,
  .cmd-modal {
    corner-shape: var(--corner-lg);
  }
  .cmd__item {
    corner-shape: var(--corner-sm);
  }
}
@media (prefers-reduced-motion: reduce) {
  .cmd,
  .cmd-modal {
    transition: none;
  }
}
@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }
  /* Never paint the intro at all — script.js also bails, but this beats it to
     the first frame. */
  .intro-playing .intro {
    display: none;
  }
  .p-stagger,
  .stagger {
    animation: none;
    opacity: 1;
    transform: none;
    filter: none;
  }
  .topbar,
  .topbar.visible {
    opacity: 1;
    transform: translateX(-50%);
    pointer-events: auto;
  }
  /* `.expanded .row.extra` enters via `animation` (enter, 400ms, translateY +
     blur), not `transition` — the `* { transition: none !important }` below
     never touches it. Collapsed state (`.row.extra` outside `.expanded`) is
     already invisible through `.extras`'s `visibility: hidden` + 0fr grid
     row, so it needs no explicit override here; only the expanded/entering
     state has an effect to cancel. */
  .expanded .row.extra {
    animation: none;
    opacity: 1;
    transform: none;
    filter: none;
  }
  * {
    transition: none !important;
  }
}
/* ---------------------------------------------------------------------------
   Changelog page (`/changelog`, `changelog.html`) — a GitHub-style vertical
   timeline: one rail on the left connecting a dot per entry, content running
   beside it. Only these selectors are new; everything else on the page (the
   topbar, `.stagger`/`enter`, `h1`/`.subtitle`, `.about-trigger`, `.label`,
   `p`) is reused as-is from the rest of the site. No new tokens were needed —
   every value below already existed in `tokens/spacing.css`.

   Each `.timeline__entry` is a flex row of two columns: a fixed-width rail
   and the content. The connecting line is the rail's own `::before`, sized
   `top: 0; bottom: 0`, so it stretches to match whatever height the sibling
   content column forces on the row (flex's default `align-items: stretch`) —
   consecutive entries chain into one continuous line down the list without
   any manual height math. The marker sits after the line in source order
   inside the same rail, so it paints on top; its `box-shadow` punches a
   `--bg`-colored ring around it, breaking the line at the dot the way
   GitHub's does.
--------------------------------------------------------------------------- */
.timeline {
  list-style: none;
  margin-top: var(--space-48);
}
.timeline__entry {
  display: flex;
  gap: var(--space-16);
}
.timeline__rail {
  position: relative;
  width: var(--space-20);
  flex-shrink: 0;
  display: flex;
  justify-content: center;
}
.timeline__rail::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  width: var(--border-w);
  background: var(--line);
}
/* The last entry's rail has nothing below it to connect to — stop the line
   just past the dot instead of running it out past the last row. */
.timeline__entry:last-child .timeline__rail::before {
  bottom: auto;
  height: var(--space-14);
}
.timeline__marker {
  position: relative;
  width: var(--space-8);
  height: var(--space-8);
  margin-top: var(--space-6);
  border-radius: var(--radius-full);
  background: var(--faint);
  box-shadow: 0 0 0 var(--space-4) var(--bg);
  transition-property: background, box-shadow;
  transition-duration: var(--duration-250);
  transition-timing-function: var(--ease);
}
.timeline__entry:hover .timeline__marker {
  background: var(--ink);
}
.timeline__content {
  flex: 1;
  min-width: 0;
  padding-bottom: var(--space-40);
}
.timeline__entry:last-child .timeline__content {
  padding-bottom: 0;
}
.timeline__date {
  display: block;
  font-variant-numeric: tabular-nums;
  margin-bottom: var(--space-4);
}
.timeline__title {
  color: var(--ink);
  margin-bottom: var(--space-4);
}
/* One entry per day, not per change: a day with several updates shares one
   date and one dot, and its updates stack as `.timeline__item`s inside the
   same content column — the gap between them is smaller than the gap between
   days, so the grouping reads without any extra rule or marker. */
.timeline__item + .timeline__item {
  margin-top: var(--space-24);
}

/* ---------------------------------------------------------------------------
   Prompts page (`/prompts`, `prompts.html`). The page keeps changelog's shell
   and type hierarchy; these selectors cover the pill search field, the prompt
   rows, and the detail modal. The modal reuses `.cmd-wash`/`.cmd-modal` (and
   the `body.cmd-detail-open` switch) wholesale so the prompt detail and the
   ⌘K detail share one surface and one motion contract — only measurements
   are overridden here.
--------------------------------------------------------------------------- */
.prompts-page {
  color: var(--ink);
}
.prompt-search {
  margin-top: var(--space-48);
}
/* Same recipe as the topbar pills — glass fill, full radius, the shared
   glass-core shadow, `--topbar-h` tall. The input inside is transparent;
   the pill itself is the field. */
.prompt-search__pill {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  height: var(--topbar-h);
  padding: 0 var(--space-8) 0 var(--space-20);
  background: var(--glass-bg);
  backdrop-filter: blur(var(--glass-blur-sm)) saturate(var(--glass-sat));
  -webkit-backdrop-filter: blur(var(--glass-blur-sm)) saturate(var(--glass-sat));
  border-radius: var(--radius-full);
  box-shadow:
    var(--shadow-glass-core),
    0 4px 16px var(--shadow-a02);
}
/* The ring belongs to the pill, not the transparent input — outlining the
   input would paint a rectangle inside the capsule. */
.prompt-search__pill:has(.prompt-search__input:focus-visible) {
  outline: var(--space-2) solid var(--ink);
  outline-offset: var(--space-2);
}
.prompt-search__input {
  flex: 1;
  min-width: 0;
  min-height: var(--space-44);
  border: none;
  background: transparent;
  color: var(--ink);
  font: inherit;
  outline: 0;
}
.prompt-search__input::placeholder {
  color: var(--muted);
  opacity: 1;
}
/* The pill brings its own clear control; the UA's (blue on WebKit) would
   double it. */
.prompt-search__input::-webkit-search-cancel-button,
.prompt-search__input::-webkit-search-decoration {
  -webkit-appearance: none;
  appearance: none;
}
/* Enters and exits with the stacked-icon recipe (opacity/scale/blur).
   `visibility` keeps the hidden button out of the tab order; its transition
   delay holds it visible until the fade-out finishes, and `.visible` zeroes
   the delay so the fade-in starts immediately. */
.prompt-search__clear {
  flex-shrink: 0;
  width: var(--space-40);
  height: var(--space-40);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: none;
  background: 0 0;
  border-radius: var(--radius-full);
  color: var(--faint);
  cursor: pointer;
  opacity: 0;
  scale: 0.25;
  filter: blur(var(--space-4));
  visibility: hidden;
  transition-property: opacity, scale, filter, color, background, visibility;
  transition-duration: var(--duration-200);
  transition-timing-function: var(--ease);
  transition-delay: 0s, 0s, 0s, 0s, 0s, var(--duration-200);
}
.prompt-search__clear.visible {
  opacity: 1;
  scale: 1;
  filter: blur(0);
  visibility: visible;
  transition-delay: 0s;
}
.prompt-search__clear:hover {
  color: var(--muted);
  background: var(--ink-a5);
}
.prompt-search__clear.visible:active {
  scale: 0.96;
}
.prompt-search__clear svg {
  width: var(--space-16);
  height: var(--space-16);
}
.prompt-search__status {
  min-height: var(--space-24);
  margin-top: var(--space-8);
  color: var(--muted);
  font-size: var(--fs-12);
  cursor: default;
}
.prompt-results {
  margin-top: var(--space-32);
}
/* Prompt rows are buttons wearing the shared `.row` costume: reset the UA
   button chrome, let `.row` do the layout. Unlike the site lists, here the
   title is the side that gives — a nowrap title would clip at 320px, while
   the short category can stay whole. */
.prompt-row,
.row-btn {
  /* Buttons don't stretch like block divs: the width has to include the
     12px the hover fill bleeds past the column on each side. `.row-btn` is
     the same reset for any list where a row is a <button> (the portfolio's
     clients/personal/life rows open a modal, so they're buttons too). */
  width: calc(100% + 2 * var(--space-12));
  border: none;
  background: 0 0;
  color: var(--ink);
  font: inherit;
  text-align: left;
}
/* `.row`'s `display: flex` outranks the UA's `[hidden] { display: none }`,
   so the search filter's `hidden` needs restating. */
.prompt-row[hidden] {
  display: none;
}
.prompt-row .who {
  flex-shrink: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}
.prompt-row .what {
  flex-shrink: 0;
}
/* Long-form prompt text needs a wider sheet than the ⌘K person card. */
.prompt-modal {
  width: min(var(--space-620), calc(100vw - var(--space-48)));
  max-height: min(var(--space-600), calc(100vh - var(--space-96)));
  /* Delicate, near-invisible scrollbar (same recipe as .panel above): thin
     track, transparent background, --line thumb that only firms up to
     --faint on hover — closer to the Apple overlay scrollbar than a browser
     default. */
  scrollbar-width: thin;
  scrollbar-color: var(--line) transparent;
}
.prompt-modal::-webkit-scrollbar {
  width: var(--space-6);
}
.prompt-modal::-webkit-scrollbar-track {
  background: 0 0;
}
.prompt-modal::-webkit-scrollbar-thumb {
  background: var(--line);
  border-radius: var(--radius-3);
}
.prompt-modal::-webkit-scrollbar-thumb:hover {
  background: var(--faint);
}
.prompt-modal h2 {
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-tight);
  margin-top: var(--space-4);
}
.prompt-modal__eyebrow {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-16);
}
.prompt-modal__description {
  color: var(--muted);
  margin-top: var(--space-8);
  cursor: default;
}
.prompt-tags {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-4) var(--space-12);
  margin-top: var(--space-16);
  list-style: none;
}
.prompt-tag {
  color: var(--muted);
  font-size: var(--fs-12);
  white-space: nowrap;
}
.prompt-tag:not(:last-child)::after {
  content: "·";
  margin-inline-start: var(--space-12);
  color: var(--line);
}
/* Prompt body as a code block (30/08/2026), after ChatGPT's: a rounded card
   on --code-bg (= --row-hover, the fill that carries the page hue) with a
   header bar — "prompt" label left, copy button right — and the text set in
   Geist Mono at the body size (--fs-16, --lh-code 1.7), with a light syntax
   color in the site's cyan ramp (--code-* in colors.css; contrast ratios in
   their comments). What gets colored is what the prompts actually contain —
   highlightPrompt() in prompts.mjs: **section headers**, list markers,
   "Key:" lines, URLs, "quoted strings", `code`, numbers. Was a --muted
   <pre> under a hairline, with a "Copy prompt" text button up in the eyebrow.
   Decided in a /prototype run against "Terminal" (inverted block) and "Bare"
   (no card, sticky copy) — the decision block is in docs/patterns.md.
   The copy button is icon-only, always: on copy the glyph crossfades to a
   check (blur bridges the swap, Emil's trick) for two seconds and back. No
   color change, no "Copied" text — asked for explicitly; the sr-only label
   still flips to "Copied" so the accessible name and the live region tell
   the story the eye doesn't need. */
.prompt-code {
  margin-top: var(--space-32);
  border: var(--border-w) solid var(--line);
  border-radius: var(--radius-16);
  background: var(--code-bg);
  overflow: hidden;
}
.prompt-code__bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-12);
  padding: var(--space-4) var(--space-4) var(--space-4) var(--space-16);
}
.prompt-code__lang {
  display: inline-flex;
  align-items: center;
  gap: var(--space-8);
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  color: var(--muted);
  cursor: default;
  user-select: none;
}
.prompt-code__lang svg,
.prompt-copy svg {
  width: var(--space-16);
  height: var(--space-16);
  flex-shrink: 0;
}
/* 40px square, same footprint as .panel-close — the bar stays 48 tall. */
.prompt-copy {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: var(--space-40);
  min-height: var(--space-40);
  padding: 0 var(--space-12);
  background: 0 0;
  border: none;
  border-radius: var(--radius-12);
  color: var(--faint);
  cursor: pointer;
  touch-action: manipulation;
  transition-property: color, background-color, scale;
  transition-duration: var(--duration-150), var(--duration-150), var(--duration-160);
  transition-timing-function: var(--ease), var(--ease), var(--ease-pop);
  user-select: none;
}
@media (hover: hover) and (pointer: fine) {
  .prompt-copy:hover {
    color: var(--ink);
    background-color: var(--ink-a5);
  }
}
.prompt-copy:active {
  scale: 0.96;
}
/* Both glyphs share one grid cell; the swap is a crossfade with a 2px blur
   and a small scale so the eye reads one shape changing, not two icons
   trading places. Interruptible (transitions, not keyframes): a second
   click mid-swap retargets instead of restarting. */
.prompt-copy__glyph {
  display: grid;
  width: var(--space-16);
  height: var(--space-16);
}
.prompt-copy__glyph svg {
  grid-area: 1 / 1;
  transition-property: opacity, filter, scale;
  transition-duration: var(--duration-160);
  transition-timing-function: var(--ease-out);
}
.prompt-copy__check,
.prompt-copy.is-copied .prompt-copy__icon {
  opacity: 0;
  filter: blur(2px);
  scale: 0.8;
}
.prompt-copy.is-copied .prompt-copy__check {
  opacity: 1;
  filter: blur(0);
  scale: 1;
}
@media (prefers-reduced-motion: reduce) {
  .prompt-copy,
  .prompt-copy__glyph svg {
    transition: none;
  }
}
.prompt-modal__content {
  max-width: 100%;
  margin: 0;
  padding: var(--space-8) var(--space-16) var(--space-20);
  color: var(--code-fg);
  font-family: var(--font-mono);
  font-size: var(--fs-16);
  font-weight: var(--fw-regular);
  line-height: var(--lh-code);
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  tab-size: 2;
}
.prompt-modal__content code {
  font: inherit;
}
.tok-head,
.tok-key,
.tok-strong {
  color: var(--code-key);
  font-weight: var(--fw-medium);
}
.tok-str,
.tok-code {
  color: var(--code-str);
}
.tok-num {
  color: var(--code-num);
}
.tok-url {
  color: var(--code-url);
  text-decoration: underline;
  text-decoration-color: color-mix(in srgb, var(--code-url) 40%, transparent);
  text-underline-offset: var(--space-3);
}
.tok-punct {
  color: var(--code-punct);
}
.prompt-clear {
  min-height: var(--space-44);
}
.prompt-modal__eyebrow > .label,
.prompts-page .about-trigger,
.prompts-page footer > span {
  color: var(--muted);
}
.prompts-page .subtitle {
  color: var(--muted);
}
.prompts-page .txtbtn {
  color: var(--ink);
}
.prompt-empty {
  margin-top: var(--space-40);
}
.prompt-empty p {
  color: var(--muted);
  cursor: default;
}
.prompt-clear {
  margin-top: var(--space-8);
}
@media (max-width: 560px) {
  footer {
    flex-direction: column;
    gap: var(--space-12);
  }
  .footer__right {
    flex-wrap: wrap;
    gap: var(--space-12) var(--space-16);
  }
  .prompt-modal__eyebrow {
    align-items: flex-start;
  }
  .prompt-search__clear {
    width: var(--space-44);
    height: var(--space-44);
  }
  /* 16px floor keeps iOS from zooming the focused field; with Flat type on,
     inherit already lands above it, so this is the no-experiment guard. */
  .prompt-search__input {
    font-size: max(var(--fs-16), 1rem);
  }
}

/* ---------------------------------------------------------------------------
   Portfolio components (16/08/2026) — built ahead of the portfolio page they
   are for (see PORTFOLIO.md), so they ship here as design-system pieces with
   a Storybook story and no consumer yet. Two references, both measured in a
   real Chrome rather than eyeballed:

   - `.doc-item` is jakub.kr's Writing row: a document icon beside a title
     and one short summary; the whole row is the link. The icon is the Figma
     `Doc icon` (29/08/2026, Otávio's own take, not Jakub's 48×56 with a
     shadow): a 50×60 outlined shell (`--line` border, padding 2, radius 8,
     no shadow) around a white 44×54 page (`--line` border, padding 6,
     radius 6) with five 4px "lines of text" — 16 / 30 26 / 20 12, the title
     line then two paragraphs 6px apart, lines 4px apart inside them. While
     the row is hovered the page gets `--shadow-doc-hover` (Figma `Doc icon`
     Status=Hover) — instant, like the row fill, for the same reason. Since 29/08/2026 the row is the
     `Article` component of the Figma file (docs/figma-workflow.md): padding
     6, icon→text gap 10, radius 12, 652 wide = the 640 column + 6 each side,
     so the icon sits on the column edge and the text starts at +60; rows
     4px apart. Hover is a fill only — `--row-hover`, the gray that carries
     the page hue — with NO shadow and NO transition, on purpose: it's the
     instant fill Jakub and Emil use (checked in their served HTML: no
     `transition-*` on the row), and it's a row you'll pass over dozens of
     times, which is exactly where motion should get out of the way. Every
     page list (`main > section .row`) took the same hover the same day.
   - `.contrib` is the GitHub contribution graph as noechague.vercel.app draws
     it — same two-layer shell (radius 16 + padding 4 + `--shadow-lift`
     around a `--white` card with a `--line` border, radius 12, padding 16),
     a 7 × 53 grid of 9px cells with 2px gaps flowing by column, and a
     footer with the total and the Less→More legend. Colors come from the
     `--contrib-*` ramp in tokens/colors.css (the site's blue, not GitHub's
     green). The grid is `aria-hidden`; the total in the footer is the
     accessible summary. It scrolls sideways below ~600px rather than
     shrinking the cells, and opens scrolled to the latest weeks there
     (`scrollToLatest` in contrib.mjs — a scroll container starts at the
     left, i.e. the oldest, emptiest end). `contrib.mjs` fills the grid
     from a 371-day array.
   - `.contrib__tip` is the hover readout GitHub gives each cell ("3
     contributions on Aug 12"), on the site's own tooltip surface — the same
     recipe as `.gloss-tip` (`--bg`, `--line`, radius 6, `--fs-13`), so the
     two tooltips on the site read as one. It's ONE element for the whole
     grid, appended to `<body>` and `position: fixed`, because the card is
     `overflow: hidden` and the grid scrolls sideways — anchored inside,
     the top row's tooltip would be clipped. contrib.mjs positions it over
     the hovered cell (below it when there's no room above) and owns the
     timing: the first tooltip waits `--tip-delay` and scales in from the
     cell (0.97 → 1, origin bottom, `--ease-out`, `--tip-in`); while the
     pointer keeps moving across cells the tooltip just jumps — no delay,
     no transition (`.contrib__tip--instant`) — and it stays warm for
     `--tip-warm` after leaving, so coming back is instant too. Hover on a
     cell that's used dozens of times in one pass is exactly where motion
     gets out of the way (Emil's rule). The hovered cell gets a 1px ring,
     also instant. Reduced motion keeps the fade and drops the scale.
--------------------------------------------------------------------------- */
.doc-list {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}
.doc-item {
  display: flex;
  align-items: center;
  gap: var(--space-10);
  padding: var(--space-6);
  margin-inline: calc(-1 * var(--space-6));
  border-radius: var(--radius-12);
  color: inherit;
  text-decoration: none;
  /* No `transition` here, and none on hover below — the fill lands in zero
     frames (Figma `Article`, Status=Hover; see the block comment above). */
}
@media (hover: hover) and (pointer: fine) {
  .doc-item:hover {
    background: var(--row-hover);
  }
  .doc-item:hover .doc-icon__page {
    box-shadow: var(--shadow-doc-hover);
  }
}
/* The portfolio's Writing rows are buttons (they open the shared modal);
   same look as the anchor form. */
.doc-item--btn {
  width: calc(100% + 2 * var(--space-6));
  border: none;
  background: 0 0;
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.doc-item:focus-visible {
  outline: var(--space-2) solid var(--faint);
  outline-offset: 0;
}
.doc-icon {
  flex: none;
  padding: var(--space-2);
  border: var(--border-w) solid var(--line);
  border-radius: var(--radius-8);
}
/* 44 wide; the height (54) falls out of the lines below — no token for it. */
.doc-icon__page {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  width: var(--space-44);
  padding: var(--space-6);
  border: var(--border-w) solid var(--line);
  border-radius: var(--radius-6);
  background: var(--white);
  overflow: hidden;
  user-select: none;
}
.doc-icon__line {
  display: block;
  height: var(--space-4);
  border-radius: var(--radius-full);
  background: var(--line);
}
/* Five lines: a short first line (a title), then two paragraphs that tail
   off — read as text, not stripes. Lines sit 4px apart (the flex gap); the
   paragraph breaks are 6px, so the first line of each adds 2px on top. */
.doc-icon__line:nth-child(1) { width: var(--space-16); }
.doc-icon__line:nth-child(2) { width: var(--space-30); margin-top: var(--space-2); }
.doc-icon__line:nth-child(3) { width: var(--space-26); }
.doc-icon__line:nth-child(4) { width: var(--space-20); margin-top: var(--space-2); }
.doc-icon__line:nth-child(5) { width: var(--space-12); }
.doc-item__text {
  display: flex;
  flex-direction: column;
  min-width: 0;
}
.doc-item__title {
  color: var(--ink);
  font-weight: var(--fw-medium);
  text-wrap: pretty;
}
.doc-item__desc {
  color: var(--muted);
  text-wrap: pretty;
}

.contrib {
  padding: var(--space-4);
  border-radius: var(--radius-16);
  box-shadow: var(--shadow-lift);
  overflow: hidden;
}
.contrib__card {
  display: flex;
  flex-direction: column;
  gap: var(--space-12);
  padding: var(--space-16);
  border: var(--border-w) solid var(--line);
  border-radius: var(--radius-12);
  background: var(--white);
}
.contrib__grid {
  display: grid;
  grid-template-rows: repeat(7, var(--space-9));
  grid-auto-flow: column;
  gap: var(--space-2);
  padding-bottom: var(--space-4);
  overflow-x: auto;
  scrollbar-width: thin;
  scrollbar-color: var(--line) transparent;
}
.contrib__cell {
  width: var(--space-9);
  height: var(--space-9);
  border-radius: var(--radius-2);
  background: var(--contrib-0);
}
.contrib__cell[data-level="1"] { background: var(--contrib-1); }
.contrib__cell[data-level="2"] { background: var(--contrib-2); }
.contrib__cell[data-level="3"] { background: var(--contrib-3); }
.contrib__cell[data-level="4"] { background: var(--contrib-4); }
/* The hovered cell (JS-driven, so touch taps count too) — a hairline ring in
   the card's own border color (`--line`, not the text gray: at 100% it read
   as a black outline), drawn outside the cell so the fill stays readable. No
   transition on purpose: you sweep across dozens of these per pass. */
.contrib__grid .contrib__cell[data-date] {
  cursor: pointer;
}
.contrib__cell.is-hover {
  box-shadow: 0 0 0 var(--border-w) var(--line);
}
.contrib__tip {
  position: fixed;
  top: 0;
  left: 0;
  width: max-content;
  max-width: var(--space-220);
  background: var(--white);
  border: var(--border-w) solid var(--line);
  border-radius: var(--radius-6);
  box-shadow:
    var(--shadow-glass-core),
    0 10px 24px var(--shadow-a02);
  padding: var(--space-6) var(--space-10);
  font-size: var(--fs-13);
  line-height: var(--lh-snug);
  color: var(--muted);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  transform: translateY(var(--space-4)) scale(0.97);
  transform-origin: 50% 100%;
  transition-property: opacity, transform;
  transition-duration: var(--tip-out);
  transition-timing-function: var(--ease-out);
  z-index: 20;
}
.contrib__tip strong {
  color: var(--ink);
  font-weight: var(--fw-medium);
}
.contrib__tip[data-open="true"] {
  opacity: 1;
  transform: translateY(0) scale(1);
  transition-duration: var(--tip-in);
}
/* Flipped below the cell: it grows down from the cell instead. */
.contrib__tip[data-side="bottom"] {
  transform: translateY(calc(-1 * var(--space-4))) scale(0.97);
  transform-origin: 50% 0;
}
.contrib__tip[data-side="bottom"][data-open="true"] {
  transform: translateY(0) scale(1);
}
/* Warm: the pointer moved to a neighbouring cell (or came back within
   --tip-warm). Jump, don't animate. */
.contrib__tip.contrib__tip--instant {
  transition-duration: 0ms;
}
@media (prefers-reduced-motion: reduce) {
  .contrib__tip,
  .contrib__tip[data-side="bottom"] {
    transform: none;
    transition-property: opacity;
  }
}
.contrib__meta {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: var(--space-16);
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}
.contrib__legend {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  color: var(--muted);
}
.contrib__legend .contrib__cell:first-of-type { margin-left: var(--space-4); }
.contrib__legend .contrib__cell:last-of-type { margin-right: var(--space-4); }
@supports (corner-shape: superellipse(1)) {
  main > section .row,
  .doc-item,
  .doc-icon,
  .doc-icon__page,
  .contrib,
  .contrib__card,
  .contrib__tip {
    corner-shape: var(--corner-sm);
  }
}

/* ---------------------------------------------------------------------------
   Portfolio page (`/portfolio`, portfolio.html + portfolio.mjs). Everything
   above is reused as-is (rows, doc list, contribution graph); what's new is
   the photo gallery and its lightbox — PORTFOLIO.md §4: photos are content,
   not decoration, so they get a surface of their own where the image is the
   subject, and the typographic index stays pure. Same wash/dialog motion
   contract as the ⌘K detail (`--ease-pop`, 250/200ms, aria-hidden + inert
   while closed).
--------------------------------------------------------------------------- */
.gallery {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--space-8);
}
@media (max-width: 560px) {
  .gallery {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}
/* Fixed aspect per cell so the grid never jumps as photos load; the shell is
   the same two-layer lift the graph uses, so the gallery
   reads as part of the same family. */
.gallery__item {
  display: block;
  padding: var(--space-4);
  border: none;
  border-radius: var(--radius-16);
  background: var(--white);
  box-shadow: var(--shadow-lift);
  cursor: zoom-in;
  transition: box-shadow var(--duration-200) var(--ease-out);
}
@media (hover: hover) and (pointer: fine) {
  .gallery__item:hover {
    box-shadow: var(--shadow-lift-hover);
  }
}
.gallery__item:focus-visible {
  outline: var(--space-2) solid var(--faint);
  outline-offset: var(--space-2);
}
.gallery__item:active {
  scale: 0.98;
}
.gallery__frame {
  display: block;
  aspect-ratio: 1 / 1;
  overflow: hidden;
  border-radius: var(--radius-12);
  border: var(--border-w) solid var(--line);
  background: var(--white);
}
.gallery__img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.gallery__img--contain {
  object-fit: contain;
  padding: var(--space-16);
  box-sizing: border-box;
}
.lightbox {
  position: fixed;
  top: 50%;
  left: 50%;
  z-index: 61;
  width: min(var(--space-960), calc(100vw - var(--space-48)));
  max-height: calc(100vh - var(--space-96));
  padding: var(--space-4);
  border-radius: var(--radius-16);
  background: var(--white);
  box-shadow: var(--shadow-modal);
  transform: translate(-50%, -50%) scale(0.96);
  opacity: 0;
  pointer-events: none;
  transition:
    transform var(--duration-250) var(--ease-pop),
    opacity var(--duration-200) var(--ease);
}
.lightbox[aria-hidden="false"] {
  transform: translate(-50%, -50%) scale(1);
  opacity: 1;
  pointer-events: auto;
}
.lightbox__figure {
  margin: 0;
  border-radius: var(--radius-12);
  border: var(--border-w) solid var(--line);
  background: var(--white);
  overflow: hidden;
}
.lightbox__img {
  display: block;
  width: 100%;
  max-height: calc(100vh - var(--space-96) - var(--space-64));
  object-fit: contain;
}
.lightbox__caption {
  padding: var(--space-12) var(--space-16);
  color: var(--muted);
}
.lightbox__caption:empty {
  display: none;
}
.lightbox__close {
  position: absolute;
  top: var(--space-16);
  right: var(--space-16);
  z-index: 1;
}
@supports (corner-shape: superellipse(1)) {
  .gallery__item,
  .gallery__frame,
  .lightbox,
  .lightbox__figure {
    corner-shape: var(--corner-sm);
  }
}
.row--draft .who::after {
  content: " · draft";
  color: var(--faint);
}

/* ---------------------------------------------------------------------------
   iPadOS pointer (cursor.mjs, all pages) — the translucent dot that turns
   into an I-beam over text and into a rounded highlight around whatever it
   can press. Geometry (transform/width/height/border-radius/opacity) is
   written inline by the module every frame; this block only owns the look
   and the two page-level switches. See the header of cursor.mjs for the
   modes and the gates.

   `html.cursor-on` is set on the first mouse move and hides the OS cursor
   everywhere. The rule needs enough weight to beat every `cursor:` in this
   file without `!important` — `!important` would also beat the *native*
   escape below, and there is no way to un-important a declaration. Three
   `:not(#\#)` steps borrow id-level specificity without an id existing.
   `html.cursor-native` (the resize handle and any `[data-cursor="native"]`)
   simply un-matches the rule, so the element's own `cursor:` shows again. */
html.cursor-on:not(.cursor-native):not(#\#):not(#\#):not(#\#),
html.cursor-on:not(.cursor-native):not(#\#):not(#\#):not(#\#) * {
  cursor: none;
}
.ipad-cursor {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 100;
  width: var(--cursor-size);
  height: var(--cursor-size);
  border-radius: var(--radius-full);
  background: var(--cursor-fill);
  opacity: 0;
  pointer-events: none;
  contain: strict;
  will-change: transform, width, height, opacity;
  transition: background-color var(--duration-120) var(--ease);
}
.ipad-cursor.is-pressed {
  background: var(--cursor-fill-pressed);
}
.ipad-cursor[data-mode="text"] {
  background: var(--cursor-stroke);
}
/* SVG preserves the exact 12px dash / 12px gap around rounded images. */
.ipad-cursor[data-mode="ring"] {
  background: transparent;
}
.ipad-cursor__ring {
  display: none;
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  fill: none;
  stroke: var(--cursor-ring-stroke);
  stroke-width: var(--border-w);
  stroke-dasharray: var(--space-12) var(--space-12);
  stroke-linecap: butt;
}
.ipad-cursor[data-mode="ring"] .ipad-cursor__ring { display: block; }
/* Belt and braces with the module's own matchMedia gates (it never mounts
   under a coarse pointer or reduced motion): the overlay can't paint there. */
@media (hover: none), (pointer: coarse), (prefers-reduced-motion: reduce) {
  .ipad-cursor {
    display: none;
  }
}

/* Shared spring-following label for draggable rails and the drawer backdrop. */
.carousel-drag-hint {
  position: fixed;
  inset: 0 auto auto 0;
  z-index: 101;
  display: flex;
  align-items: center;
  gap: var(--space-8);
  box-sizing: border-box;
  height: var(--space-40);
  padding: var(--space-12) var(--space-16);
  border-radius: var(--radius-full);
  background: oklch(0 0 0);
  color: oklch(1 0 0);
  font-size: var(--fs-16);
  line-height: 1;
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  transition: opacity var(--duration-120) var(--ease);
}
.carousel-drag-hint__arrow {
  display: block;
  flex: none;
  width: var(--space-16);
  height: var(--space-16);
  opacity: 0.4;
}
.carousel-drag-hint.is-visible { opacity: 1; }
[data-drag-carousel] { cursor: grab; }
[data-drag-carousel][data-dragging] {
  scroll-snap-type: none;
  scroll-behavior: auto;
  user-select: none;
}
[data-drag-carousel][data-dragging],
[data-drag-carousel][data-dragging] * { cursor: grabbing; }
@media (hover: none), (pointer: coarse), (prefers-reduced-motion: reduce) {
  .carousel-drag-hint { display: none; }
}
