/* L&R HUB — redesign layer (sandbox v1).
   ADDITIVE. This file is ALWAYS loaded AFTER /hub-static/hub.css and never instead
   of it: hub.css is frozen (sha256 105e44b7…) and stays the base system. What
   lives here is the 1a "grid editorial" voice, the liquid-glass utilities and
   the crane.

   TWO RULES THIS FILE IS BUILT ON
   1. Page-specific rules hang off the page's root class (`.login .bar`, never
      `.bar`). `.sub` is the proof: hub.css:239 already owns it as an interior
      page's 14px muted caption, and the login's `.sub` is a flex row with a red
      dot. Unscoped, one of the two loses on file order alone.
   2. A page rule must NOT declare what a `.rx-glass` utility declares.
      `.login .bar` is (0,2,0) and `.rx-glass--strong` is (0,1,0), so a
      `background:#fff` written here silently kills the glass on the element
      that carries the class. Layout here, surface there.

   Sections: TOKENS-1A · THEME · SURFACES · GLASS · INTERACTION · CRANE ·
   LOGIN-1A · AUTH-CARDS · LANDING · DASHBOARD · DELIVERIES · TEAM · MESSAGE ·
   ADMIN · COMPONENTS · SANDBOX CHROME · BREAKPOINTS (one block per side of
   768px, at the end, same discipline as hub.css).

   THE CRANE IS NOT THIS FILE'S ANYMORE. Section CRANE, static/crane.js, the
   `.crane` DOM in pages/login.html and tools/calibrate.html belong to the
   `crane-builder` role (SPEC-DECISIONS #10). They are token-driven, so they
   follow the theme without being edited. */

/* ================= TOKENS-1A ================= */
/* Only what the 1a handoff adds. Colour, ink, muted, hair and --ease are
   hub.css's and are reused by name: two token systems for one palette is how a
   third red gets born. `--hairline` in the handoff == `--hair` here. */
:root{
  --rx-display:'Anton',sans-serif;
  --rx-underline:rgba(0,0,0,.22);   /* input rule at rest; focus goes --ink */
  --rx-r-tab:12px;                  /* tabs / small cards */
  --rx-r-card:18px;                 /* cards, same as hub.css */
  --rx-tap:44px;                    /* the system's touch minimum */
  --rx-h1:clamp(2.6rem,13vw,4rem);  /* desktop value in the breakpoint block */
}

/* 1A applied to the hub's own primitives, system-wide (not page-specific).
   This is the whole of the layer's reach into hub.css: the display voice, the
   tab corner and the input rule. Everything else a screen needs is scoped to
   that screen. Kept deliberately short — pass 2 is where the design review
   decides how much further the voice travels. */
.display,
.dash-h1,
.card-title,
.section-h{
  font-family:var(--rx-display); font-weight:400;
  letter-spacing:.01em; text-transform:uppercase; line-height:1;
}
/* clamp(28px,5vw,44px) — the interior cap of UX-BRIEF R1. The login's
   clamp(64px,7.5vw,100px) is the login's and stays there: measured at 390x844,
   a 100px Anton headline on /p/{code} pushes the pill row under the fold. */
.display{ font-size:clamp(1.75rem,5vw,2.75rem); }
.dash-h1{ font-size:clamp(1.7rem,4.5vw,2.4rem); line-height:1.05; }
.card-title{ font-size:22px; }
.section-h{ font-size:18px; }
.btn{ font-weight:600; letter-spacing:.01em; }
/* The underline input, on the hub's own field. Three things this rule has to
   get right:
   - `:not(.chk)`. A checkbox is an `input` inside a `.field` too, and a
     border-bottom under an 18px box is a rule under a tick.
   - box-shadow. The red 3px glow comes from hub.css's global `input:focus`
     (0,1,1) and survives ANY rule that does not name box-shadow — under a 1px
     rule it reads as an error on a field nobody has typed in yet.
   - `:not(:focus-visible)`, so the keyboard ring `:focus-visible` draws is
     still drawn. */
.field input:not(.chk),
.field select{
  border:none; border-bottom:1px solid var(--rx-underline); border-radius:0;
  background:transparent; padding-left:0; padding-right:0;
}
/* delta pasada 2: E (ux) — `box-shadow:none` A SECAS DEJABA EL FOCO SIN MARCA.
   hub.css:35 apaga el outline de TODO `input:focus` con (0,1,1), que es más
   específico que el `:focus-visible` global de hub.css:37 — o sea que en los
   formularios de app el único indicador de foco que quedaba en pie era ese
   box-shadow rojo, y esta regla lo apagaba sin poner nada en su lugar: el
   teclado navegaba a ciegas. La marca vuelve por donde este sistema la sabe
   decir, la propia regla del campo: 1px de borde + 2px de sombra = un subrayado
   de 3px en `--ink` (contra los 1px a .22 del reposo), en la tinta del tema y
   sin rojo, que es de errores. */
.field input:not(.chk):focus,
.field select:focus{ box-shadow:0 2px 0 0 var(--ink); border-bottom-color:var(--ink); }
.field input:not(.chk):focus:not(:focus-visible),
.field select:focus:not(:focus-visible){ outline:none; }
/* A refused field keeps its mark. hub.css says this at (0,2,1) and the rule
   above outranks it, so the layer has to re-state it or the admin console
   loses the border colour on every rejected box. */
.field input[aria-invalid="true"]:not(.chk),
.field select[aria-invalid="true"]{
  border-bottom-color:rgba(224,54,43,.55); background:rgba(224,54,43,.04);
}

/* ================= THEME ================= */
/* Three states, one attribute (SPEC-DECISIONS #9). `data-theme` is written on
   <html> by the inline script in every page's <head> — BEFORE the first paint,
   because a flash of the wrong theme is a bug that cannot be fixed further down
   the page.

     no attribute      → follow the OS. This is the default, and it is "System".
     data-theme=light  → the reader overrode the OS.
     data-theme=dark   → same, the other way.

   Which is why the media query carries `:not([data-theme="light"])`: a bare
   @media would hand a dark OS to a reader who explicitly asked for Light.

   The dark VALUES are written twice (media + attribute) because a CSS media
   query cannot be aliased. The dark RULES are written ONCE: everything below
   this block reads a token, so there is a single place to change a colour and
   no way for the two copies to drift. If you find yourself repeating a rule in
   both dark blocks, the value belongs in a token instead. */
:root{
  color-scheme:light;
  --rx-card:#ffffff;         /* every surface hub.css paints #fff */
  --rx-raised:#ffffff;       /* a control lifted OFF a --pill-gray track */
  --rx-hover:rgba(0,0,0,.06);
  --rx-hover-strong:rgba(0,0,0,.08);
  --rx-hover-soft:rgba(0,0,0,.03);
  --rx-shadow-s:0 1px 3px rgba(0,0,0,.10);
  --rx-shadow-m:0 10px 30px rgba(0,0,0,.07);
  --rx-shadow-l:0 12px 40px rgba(0,0,0,.08);
  --rx-ok-ink:#15803d;
  --rx-warn-ink:#b45309;
  --rx-locked-ink:#b91c1c;
  --rx-link:#e0362b;         /* == --accent in light: the accent is untouched */
  --rx-ink-on:#ffffff;       /* text ON an --ink surface */
  --rx-ink-on-2:rgba(255,255,255,.72);   /* its secondary line */
  /* THE TERTIARY (SPEC-DECISIONS #23d). The crane's own paint, sampled from the
     photo the 3D livery was built from — static/crane3d.js:103 keeps the whole
     ramp (#9a6a24 dirty · #c9932f mid · #f0c96f sunlit) and these two are taken
     off it, so the page and the machine on it are the same yellow.

     IT HAS TO STAY OUT OF THE WAY OF `--warn`, AND THAT IS THE WHOLE DESIGN OF
     THESE TWO VALUES. Amber is already spoken for twice in this system: it is
     `--warn` (#f59e0b, and #b45309 when it is text) and it is the `rebar`
     delivery type. hub.css:932 records the arbitration of 2026-07-29, where an
     amber dot next to a `pill--warn` was thrown out for reading as a third
     severity tier. So the tertiary is a MUSTARD, not an orange, it never lands
     on a status mark, a number or a cell, and it never appears on a surface that
     can also carry a warning. Where it is allowed, see LOGIN-1A and blob 4.

     Two of them for the same reason `--accent` has `--accent-ink`: one pigment
     for decoration and one for the rare string that has to be READ. The tertiary
     made that text more legible than the `--muted` it replaced, and if a future
     value does not, it is the wrong value.
     THE INK WENT ONE STEP DARKER IN LIGHT ON PASS 3 (#7d5f18 -> #6b5214). At
     #7d5f18 the worst of the four tabs — 03, the pane over the crane's lit
     tower — measured 4.11:1 sampling the rendered pixels behind it, i.e. under
     the floor on the one string the tertiary was allowed to colour. #6b5214 is
     the same mustard family and measures 5.09:1 there (both numbers are the
     5th-percentile pixel of the ink's box, the metric the chip block in LOGIN-1A
     explains). Dark does not move: #dda955 on a near-black page, 7.56:1 on the
     same tab, before and after. */
  --rx-amber:#c9932f;        /* decoration only: never text, never a status mark */
  --rx-amber-ink:#6b5214;    /* the same family when it has to be read */
  /* Ambience of `.rx-bgfx` (SPEC-DECISIONS #13). The veil runs from `b` at the
     top to `a` at the foot; the mark is the watermark's ink; the chip is what
     goes under text stranded on glass over something very dark (#4). */
  --rx-veil-a:rgba(10,10,11,.075);
  --rx-veil-b:rgba(10,10,11,0);
  --rx-mark-o:.055;               /* the logo watermark; an alpha now, not an ink */
  --rx-chip:rgba(255,255,255,.85);
  /* The same chip, near-opaque, for text with NO pane of its own between it and
     the crane. The alpha is a measurement, not a taste: see LOGIN-1A. */
  --rx-chip-hi:rgba(255,255,255,.94);
  /* delta pasada 2: F (design) — la tinta secundaria que va SOBRE una pista
     `--pill-gray`, un peldaño por encima de `--muted`. En light es la de
     hub.css:849 a la cifra (rgba(0,0,0,.62), 6.58:1 medidos), aquí solo para
     que el tema oscuro tenga dónde poner la suya: un negro al 62% sobre
     #222226 mide 1.23:1 y desaparece. */
  --rx-muted-hi:rgba(0,0,0,.62);
  /* hub.css's alert washes, flattened onto --rx-card. Same .06/.09 of the
     same three pigments; the .alert rules in SURFACES say why they are flat. */
  --rx-alert-err:#fdf3f2;
  --rx-alert-ok:#edfbf6;
  --rx-alert-warn:#fef6e9;
  /* The five delivery types. In LIGHT these are hub.css's own values, to the
     digit (SPEC-DECISIONS #5 freezes them). They are re-declared through tokens
     only so the dark theme has somewhere to put its own: #0a0a0b concrete on a
     #17171a cell is a dot nobody can see, and #5 was written before dark
     existed — it protects the palette from being reinvented, not from being
     rendered. Same five hues either way, and the name still rides beside the
     dot. */
  --rx-tc-concrete:#0a0a0b;  --rx-tb-concrete:rgba(10,10,11,.09);
  --rx-tc-rebar:#b45309;     --rx-tb-rebar:rgba(180,83,9,.10);
  --rx-tc-pt:#7c3aed;        --rx-tb-pt:rgba(124,58,237,.10);
  --rx-tc-materials:#0369a1; --rx-tb-materials:rgba(3,105,161,.10);
  --rx-tc-equipment:#15803d; --rx-tb-equipment:rgba(21,128,5,.10);
}

@media (prefers-color-scheme:dark){
  :root:not([data-theme="light"]){
    color-scheme:dark;
    --surface:#0e0e10;
    --ink:#f5f5f6;
    --muted:rgba(255,255,255,.55);
    --hair:rgba(255,255,255,.14);
    --hair-soft:rgba(255,255,255,.09);
    --ghost:rgba(255,255,255,.38);
    --pill-gray:#222226;
    --accent-hover:#ef4a3f;
    --accent-ink:#ff7a70;
    --bad-ink:#ff7a70;
    --rx-underline:rgba(255,255,255,.26);
    --rx-card:#17171a;
    --rx-raised:#2c2c31;
    --rx-hover:rgba(255,255,255,.07);
    --rx-hover-strong:rgba(255,255,255,.12);
    --rx-hover-soft:rgba(255,255,255,.04);
    --rx-shadow-s:0 1px 3px rgba(0,0,0,.5);
    --rx-shadow-m:0 10px 30px rgba(0,0,0,.45);
    --rx-shadow-l:0 12px 40px rgba(0,0,0,.55);
    --rx-ok-ink:#4ade80;
    --rx-warn-ink:#fbbf24;
    --rx-locked-ink:#ff8f85;
    --rx-link:#ff7a70;
    --rx-ink-on:#0e0e10;
    --rx-ink-on-2:rgba(14,14,16,.72);
    /* The tertiary, dark side: one step UP the crane's ramp (#dda955, its q90 —
       the paint with sun on it). The mustard of light is a dark mark on a dark
       page. Decoration and ink land on the same value here, exactly as
       `--rx-link` and `--accent-ink` do: at 3px and at 11px the same pigment is
       both visible and legible on #0e0e10, and inventing a second one would be a
       third yellow in a system that already has two. */
    --rx-amber:#dda955;
    --rx-amber-ink:#dda955;
    --rx-veil-a:rgba(0,0,0,.38);
    --rx-veil-b:rgba(0,0,0,0);
    --rx-mark-o:.055;
    --rx-chip:rgba(14,14,16,.85);
    --rx-chip-hi:rgba(14,14,16,.94);
    --rx-muted-hi:rgba(255,255,255,.72);   /* delta pasada 2: F (design) */
    /* Interaction, dark side. The edge is the dark accent and it carries a
       glow instead of a shadow: on #0e0e10 a drop shadow is invisible and the
       only way an edge can say "hot" is by emitting light. */
    --rx-edge-hot:rgba(255,122,112,.66);
    --rx-hot-glow:0 0 0 4px rgba(255,122,112,.20);
    --rx-shadow-hover:0 22px 52px rgba(0,0,0,.72), 0 4px 14px rgba(0,0,0,.55);
    --rx-shadow-press:0 4px 12px rgba(0,0,0,.5);
    --rx-alert-err:#23191b;
    --rx-alert-ok:#1a2825;
    --rx-alert-warn:#2b2319;
    --glass-bg:rgba(24,24,27,.40);
    --glass-bg-strong:rgba(24,24,27,.65);
    --glass-bg-ink:rgba(245,245,246,.9);
    --glass-hairline:rgba(255,255,255,.12);
    --glass-inner:inset 0 1px 0 rgba(255,255,255,.06);
    --glass-shadow:0 8px 30px rgba(0,0,0,.5);
    --rx-tc-concrete:#e8e8ea;  --rx-tb-concrete:rgba(245,245,246,.12);
    --rx-tc-rebar:#f0a04a;     --rx-tb-rebar:rgba(240,160,74,.14);
    --rx-tc-pt:#a78bfa;        --rx-tb-pt:rgba(167,139,250,.14);
    --rx-tc-materials:#56b6f0; --rx-tb-materials:rgba(86,182,240,.14);
    --rx-tc-equipment:#4ade80; --rx-tb-equipment:rgba(74,222,128,.14);
  }
}
:root[data-theme="dark"]{
  color-scheme:dark;
  --surface:#0e0e10;
  --ink:#f5f5f6;
  --muted:rgba(255,255,255,.55);
  --hair:rgba(255,255,255,.14);
  --hair-soft:rgba(255,255,255,.09);
  --ghost:rgba(255,255,255,.38);
  --pill-gray:#222226;
  --accent-hover:#ef4a3f;
  --accent-ink:#ff7a70;
  --bad-ink:#ff7a70;
  --rx-underline:rgba(255,255,255,.26);
  --rx-card:#17171a;
  --rx-raised:#2c2c31;
  --rx-hover:rgba(255,255,255,.07);
  --rx-hover-strong:rgba(255,255,255,.12);
  --rx-hover-soft:rgba(255,255,255,.04);
  --rx-shadow-s:0 1px 3px rgba(0,0,0,.5);
  --rx-shadow-m:0 10px 30px rgba(0,0,0,.45);
  --rx-shadow-l:0 12px 40px rgba(0,0,0,.55);
  --rx-ok-ink:#4ade80;
  --rx-warn-ink:#fbbf24;
  --rx-locked-ink:#ff8f85;
  --rx-link:#ff7a70;
  --rx-ink-on:#0e0e10;
  --rx-ink-on-2:rgba(14,14,16,.72);
  /* The tertiary, dark side: one step UP the crane's ramp (#dda955, its q90 —
     the paint with sun on it). The mustard of light is a dark mark on a dark
     page. Decoration and ink land on the same value here, exactly as `--rx-link`
     and `--accent-ink` do: at 3px and at 11px the same pigment is both visible
     and legible on #0e0e10, and inventing a second one would be a third yellow
     in a system that already has two. */
  --rx-amber:#dda955;
  --rx-amber-ink:#dda955;
  --rx-veil-a:rgba(0,0,0,.38);
  --rx-veil-b:rgba(0,0,0,0);
  --rx-mark-o:.055;
  --rx-chip:rgba(14,14,16,.85);
  --rx-chip-hi:rgba(14,14,16,.94);
  --rx-muted-hi:rgba(255,255,255,.72);   /* delta pasada 2: F (design) */
  /* Interaction, dark side. The edge is the dark accent and it carries a
     glow instead of a shadow: on #0e0e10 a drop shadow is invisible and the
     only way an edge can say "hot" is by emitting light. */
  --rx-edge-hot:rgba(255,122,112,.66);
  --rx-hot-glow:0 0 0 4px rgba(255,122,112,.20);
  --rx-shadow-hover:0 22px 52px rgba(0,0,0,.72), 0 4px 14px rgba(0,0,0,.55);
  --rx-shadow-press:0 4px 12px rgba(0,0,0,.5);
  --rx-alert-err:#23191b;
  --rx-alert-ok:#1a2825;
  --rx-alert-warn:#2b2319;
  --glass-bg:rgba(24,24,27,.40);
  --glass-bg-strong:rgba(24,24,27,.65);
  --glass-bg-ink:rgba(245,245,246,.9);
  --glass-hairline:rgba(255,255,255,.12);
  --glass-inner:inset 0 1px 0 rgba(255,255,255,.06);
  --glass-shadow:0 8px 30px rgba(0,0,0,.5);
  --rx-tc-concrete:#e8e8ea;  --rx-tb-concrete:rgba(245,245,246,.12);
  --rx-tc-rebar:#f0a04a;     --rx-tb-rebar:rgba(240,160,74,.14);
  --rx-tc-pt:#a78bfa;        --rx-tb-pt:rgba(167,139,250,.14);
  --rx-tc-materials:#56b6f0; --rx-tb-materials:rgba(86,182,240,.14);
  --rx-tc-equipment:#4ade80; --rx-tb-equipment:rgba(74,222,128,.14);
}

/* ================= SURFACES ================= */
/* hub.css is frozen and it writes `#fff`, `#15803d` and `#b45309` as literals
   in ~40 rules. Every one of them is re-read from a token here — ONCE, for both
   themes. Nothing in this block is a design change in light: each declaration
   resolves to exactly the pigment hub.css already had.
   The selectors are copied at hub.css's own specificity and land later in the
   cascade, which is the whole reason the layer is loaded second. */
.authcard,.tile,.kpi,.dcard,.pill,.seg,.empty,.alert,.cal-cell,.cal-detail,
.startedit__in,.btn--danger,.card__b{ background:var(--rx-card); }
/* The three tinted alerts, made OPAQUE. hub.css writes them as a translucent
   wash (`rgba(52,211,153,.09)`) over whatever is behind, which was harmless
   while "behind" was flat white — and stopped being harmless with the veil of
   SPEC-DECISIONS #13, because the same alert then measured 5.02:1 at the top of
   a page and 3.95:1 at the foot of it. A message that gets harder to read the
   further down the page it appears is a message the console cannot promise.
   The token holds hub.css's own wash already flattened onto `--rx-card`, so the
   pigment is unchanged and the number stops moving with the scroll position.
   Flattened rather than layered as a gradient over the card ON PURPOSE: the
   contrast meter reads `backgroundColor`, and a tint hidden in a
   `background-image` would be a tint the badge does not count. */
.alert.err{ background:var(--rx-alert-err); }
.alert.ok{ background:var(--rx-alert-ok); }
.alert.warn{ background:var(--rx-alert-warn); }
.roleform select{ background:var(--rx-card); }
.dcard__refresh::before{ background:var(--rx-card); }
.vtoggle__btn.is-active{ background:var(--rx-raised); box-shadow:var(--rx-shadow-s); }
/* 40px is the one control in the system that stopped four pixels short of its
   own minimum. Raised from here, because hub.css is frozen. */
.vtoggle__btn{ min-height:var(--rx-tap); }
/* The checkbox in the role matrix is 18x18 of hit area on a laptop — the phone
   already gets a 44px cell from hub.css:1167. `.cellchk` is the LABEL that wraps
   the box, so a hit strip drawn on it toggles the box; absolutely positioned, so
   the column keeps the width the matrix was measured at (scrollWidth ==
   clientWidth from 1180 to 1728) and no cell can steal a click from the one
   beside it, because the strip stops at its own cell's edges. */
.matrix .cellchk{ position:relative; }
.matrix .cellchk::after{
  content:''; position:absolute; left:0; right:0; top:50%;
  height:var(--rx-tap); transform:translateY(-50%);
}
/* The card is a FRAME now: its header is glass (SPEC-DECISIONS #11) and its
   body is the solid data surface. An opaque card with a glass header inside it
   would be blurring its own background — a paint nobody can see, which is the
   one thing R2 of the UX brief forbids. */
.card{ background:transparent; }

.authcard{ box-shadow:var(--rx-shadow-l); }
.tile:hover{ box-shadow:var(--rx-shadow-l); }
.dcard:hover{ box-shadow:var(--rx-shadow-m); }

.btn--ghost:hover,.nav-logout:hover,.mini:hover,.seg:hover,.dweek__d:hover,
.tabchk:hover,.cal-cell:hover{ background:var(--rx-hover); }
.pill-nav:hover{ background:var(--rx-hover-strong); }
.dcard__refresh:hover::before{ background:var(--rx-hover-strong); }
table.tbl tbody tr:hover{ background:var(--rx-hover-soft); }

.pill--ok,.alert.ok,.kd--up,.dhead__d--up,.vline--ok,.checkline--ok{ color:var(--rx-ok-ink); }
.pill--warn,.alert.warn,.kv--warn,.dhead__v--warn,.dmeta__l--warn{ color:var(--rx-warn-ink); }
.dstat--warn .dstat__v{ color:var(--rx-warn-ink); }
.pill--locked{ color:var(--rx-locked-ink); }
/* --accent itself never moves (it is the brand red and it is the fill under
   white button text in both themes). What moves is the accent when it is TEXT
   on the page background: #c22c22 on #0e0e10 measures 2.94:1. */
a,.linkbox code{ color:var(--rx-link); }
/* `.btn--danger` is the same case and was missed: hub.css fills it with the card
   colour and writes the label in raw `--accent`, which on the dark card (#17171a)
   measures 4.03:1 — the button that takes something away, under the floor, in
   the console. `--rx-link` IS `--accent` in light (#e0362b, so the light button
   does not move a digit) and #ff7a70 in dark, where the same label measures
   7.04:1. The border keeps its own alpha: a 1px edge answers to 3:1 and clears
   it. `.btn--primary` is NOT touched here — it is a solid accent fill under white
   text at 4.44:1 and Jordi has that decision open. */
.btn--danger{ color:var(--rx-link); }
/* A circle filled with --ink carries text that has to be the page, not white:
   in dark, --ink IS near-white. */
.user-avatar,.presence-av{ color:var(--rx-ink-on); }

.t-concrete{ --tc:var(--rx-tc-concrete); --tb:var(--rx-tb-concrete); }
.t-rebar{ --tc:var(--rx-tc-rebar); --tb:var(--rx-tb-rebar); }
.t-pt{ --tc:var(--rx-tc-pt); --tb:var(--rx-tb-pt); }
.t-materials{ --tc:var(--rx-tc-materials); --tb:var(--rx-tb-materials); }
.t-equipment{ --tc:var(--rx-tc-equipment); --tb:var(--rx-tb-equipment); }

/* ================= GLASS ================= */
/* Liquid glass. Three surfaces, one recipe. NEVER animate the blur: a
   transition on backdrop-filter repaints the whole stacking context every
   frame and turns a hover into a stutter on any laptop without a discrete GPU.
   Opacity and background are the only things that may move.
   Presence raised 2026-08-24 (SPEC-DECISIONS #11): .5 instead of .55 and 20px
   of blur, and the chrome that used to be an opaque card — card headers, the
   KPI band, the calendar frame, the interior page nav — is glass now. The line
   that did not move: a CELL or ROW of dense data stays solid. The glass
   dresses the chrome, never the number.

   LIGHT CAUGHT UP WITH DARK, 2026-08-24 (SPEC-DECISIONS #13). Dark read as
   glass and light read as a stack of white cards, because the three things
   that make a pane look like glass — a backdrop with structure, an edge you
   can see, and a shadow that says "this is off the page" — were all tuned for
   a dark surface. The hairline went to .14 and the shadow gained a second,
   tighter term.

   .40, 2026-08-24 (SPEC-DECISIONS #16). `--glass-bg` goes to .40 and
   `--glass-bg-strong` to .65 — the top of the band #16 allows, taken because the
   meter in pages/components.html was RUN at .72/.68/.65/.62/.60 in both themes
   before the number was written, and every glass pair held the floor. Measured
   at .40/.65, light: 4.53:1 on the worst case of #4 (the `.rx-chip`'d label over
   solid `--ink`, i.e. the crane's tower behind the pane), 4.62:1 on `.rx-glass`
   over the plain backdrop, 4.69:1 on `.rx-glass--strong`. Dark: 5.70 / 6.18 /
   5.99. Going to .60 buys nothing visible and costs 0.02–0.05 of headroom, so
   .65 is the floor and not a target to keep pushing.
   What this comment does NOT claim: that the whole PAGE clears 4.5:1. It does
   not — `.btn--primary` (4.44 light) and `.btn--danger` (4.03 dark, 3.95 before
   the amber of #23d warmed blob 4 under it) sit under the floor and have since
   before this layer existed. They are solid `--accent`
   fills, no glass anywhere near them, and #16 does not reach them. Reported to
   the orchestrator on delivery of pass 4 rather than fixed here. */
:root{
  --glass-bg:rgba(255,255,255,.40);
  --glass-bg-strong:rgba(255,255,255,.65);
  --glass-bg-ink:rgba(10,10,11,.82);
  --glass-hairline:rgba(0,0,0,.14);
  --glass-inner:inset 0 1px 0 rgba(255,255,255,.7);
  /* Two terms, not one: the wide one puts the pane above the page, the tight
     one draws the contact edge. A single 30px blur reads as a smudge under a
     card and as nothing at all under a 44px pill. */
  --glass-shadow:0 12px 34px rgba(10,10,11,.13), 0 2px 6px rgba(10,10,11,.07);
  --glass-blur:20px;                /* 12px on the phone, breakpoint block */
  --glass-sat:160%;
}

/* The chip of SPEC-DECISIONS #4: what goes UNDER a block of text when that
   text has to sit on glass over something as dark as the crane's tower. It is
   the prescribed remedy for a failed contrast measurement and the ONLY one —
   the blur never comes down, because the blur is the whole point of the pane.
   Token, not the literal of #4, because #4 was written before the dark theme:
   in dark the darkest thing behind a pane is a near-white `--ink` swatch and a
   white chip there would make the reading worse, not better. Same job, mirrored
   pigment. */
.rx-chip{
  background:var(--rx-chip);
  border-radius:10px; padding:6px 10px; margin:0 -10px;
}

.rx-glass,
.rx-glass--strong,
.rx-glass--ink{
  background:var(--glass-bg);
  -webkit-backdrop-filter:blur(var(--glass-blur)) saturate(var(--glass-sat));
          backdrop-filter:blur(var(--glass-blur)) saturate(var(--glass-sat));
  border:1px solid var(--glass-hairline);
  box-shadow:var(--glass-inner), var(--glass-shadow);
}
.rx-glass--strong{ background:var(--glass-bg-strong); }
/* The inner highlight is dropped to .14: at .7 a white hairline across the top
   of an 82%-black pill reads as a seam, not as a lit edge. */
.rx-glass--ink{
  background:var(--glass-bg-ink); color:var(--rx-ink-on);
  border-color:var(--glass-hairline);
  box-shadow:inset 0 1px 0 rgba(255,255,255,.14), var(--glass-shadow);
}
.rx-glass--ink a{ color:var(--rx-ink-on); }

/* The chrome that went to glass with SPEC-DECISIONS #11. One recipe, applied
   by name rather than by adding the utility class to a hub element that the
   port would then have to carry: `.nav--app` and `.card__h` are hub.css's own
   names and they get their surface here, in the layer, the same way `.tile` and
   `.kpi` get theirs above. */
.nav--app,
.card__h{
  background:var(--glass-bg-strong);
  -webkit-backdrop-filter:blur(var(--glass-blur)) saturate(var(--glass-sat));
          backdrop-filter:blur(var(--glass-blur)) saturate(var(--glass-sat));
}
/* hub.css paints .nav--app as a white-to-transparent gradient, which in dark is
   a white smear across the top of the page. Replaced, not tinted.
   The shadow is #13: this bar is the one surface in the hub whose glass does
   real work (content scrolls under it), and without a drop shadow it read in
   light as the page's own white with a line under it. */
.nav--app{
  border-bottom:1px solid var(--glass-hairline); padding-bottom:16px;
  box-shadow:var(--glass-shadow);
}
.card__h{ border-bottom:1px solid var(--glass-hairline); }

/* What the glass is for: something has to be moving behind it. Fixed, behind
   everything, and out of the hit-testing. */
.rx-bgfx{
  position:fixed; inset:0; z-index:-1; overflow:hidden;
  pointer-events:none; background:var(--surface);
}
/* The veil (SPEC-DECISIONS #13). A single vertical wash that deepens the foot
   of the viewport, so the page reads as lit from above and a pane sitting on it
   has a gradient to sit ON. Blobs alone give patches; the veil gives the field
   a direction, which is what was missing in light.
   Kept strictly 180deg and two-stop for one reason: the contrast meter in
   pages/components.html has to rebuild it exactly, and this one covers the whole
   viewport — unlike a blob, it moves EVERY sample on the page. Same custom
   property contract as the blobs: retint here, measured there. */
.rx-bgfx::before{
  content:''; position:absolute; inset:0;
  background:linear-gradient(180deg,
    var(--rx-veil-b) 0%, var(--rx-veil-b) 38%, var(--rx-veil-a) 100%);
}
/* The watermark. The 37th-anniversary lockup, huge, bleeding off the
   bottom-right corner and clipped by the frame's own overflow. In light it is
   the third thing giving the field depth; in dark it keeps the same weight
   against a near-black surface.
   It is DECORATION inside an `aria-hidden` frame, so it never reaches the
   accessibility tree — a screen reader would otherwise read the mark once per
   page with no context.

   IT WAS THE WORD "L&R" SET IN ANTON, AND THAT IS WHY IT MOVED (#16).
   The crane-builder measured the page's whole CLS — 0.008 — arriving at ~223ms
   in this one pseudo-element: the mark is anchored `right`/`bottom`, so when
   Anton finished loading and the glyphs took their real width the box grew
   leftwards and upwards and the browser logged a shift. #16 asked for the logo
   here anyway, and an image is the fix as well as the brief: `width` is a
   clamp of the viewport and `aspect-ratio` derives the height from it, so the
   box is final at the FIRST layout — before the WebP has been fetched, and with
   no font in the dependency chain at all. Nothing here may go back to text.
   No `will-change`: this element never animates, and promoting a
   half-a-megapixel layer for a static decoration costs memory to buy nothing.

   DARK GETS A FILTER, AND IT IS NOT A BRAND DECISION. The lockup is mid-grey
   and crimson on transparency; at these alphas over #0e0e10 that is a dark mark
   on a dark page, which is not a faint watermark but an absent one. `grayscale`
   + `brightness(2.2)` turns it into the light silhouette the Anton mark already
   was in dark, and 2.2 is not a taste: the lockup's grey is ~#808285, ×2.2
   clips to white, so at `--rx-mark-o:.055` the mark lands on exactly the
   `rgba(255,255,255,.055)` the Anton watermark used to be. Same weight against
   the same surface, new shape. At that alpha the hue is far below the threshold
   where anyone could read it as the wrong red — the SHAPE is the brand here and
   the shape is untouched, and the header mark on every page carries the real
   colours at a size where they are actually seen. */
.rx-bgfx::after{
  content:''; position:absolute; right:-60px; bottom:-64px;
  width:clamp(300px,40vw,620px); aspect-ratio:1400/967;
  background:url('/hub-static/img/logo-37.webp') center/contain no-repeat;
  opacity:var(--rx-mark-o);
}
/* Dense-data pages (UX-BRIEF R2 and its risk 2): the ambience is for the header
   band, and a blob under a 9-column table is noise. At the alphas #13 asks for
   this stopped being a nuance. The frame is FIXED, so this mask is in viewport
   space and the bottom of the screen — where the grid always is — stays flat
   whether the page is scrolled or not. */
.rx-bgfx--top{
  -webkit-mask-image:linear-gradient(180deg,#000 0,#000 180px,transparent 380px);
          mask-image:linear-gradient(180deg,#000 0,#000 180px,transparent 380px);
}
/* The two stops of each blob are custom properties, not literals inside the
   gradient: the contrast meter in pages/components.html rebuilds this backdrop
   in a canvas to sample it, and it reads the colours back off these elements.
   One source of truth — a blob retinted here cannot go unmeasured there. */
.rx-bgfx__b{
  position:absolute; filter:blur(80px);
  background:radial-gradient(circle at 50% 50%,var(--rx-blob-a),var(--rx-blob-b) 70%);
}
/* Alpha raised to .08 and a fourth blob added (SPEC-DECISIONS #11), then to
   .10 in light with #13: the dark values were already carrying the field and
   the light ones were not, because the same alpha over #0e0e10 moves far less
   than it does over white — which is exactly backwards from what the eye needs.
   .10 is the ceiling #13 sets, and blob 2 stays grey: R3 says the red does not
   grow, and two more red washes is how a fifth red gets born.
   BLOB 4 IS THE TERTIARY'S ONE PLACE ON THE FIELD (SPEC-DECISIONS #23d). It was
   the second grey and it is now the crane's mustard, retinted rather than added
   to: a fifth blob would have meant a fifth `<div>` in the `.rx-bgfx` of nine
   pages, and the ambience does not need more elements, it needs a warmer corner.
   Alphas below the grey it replaces in light (.055 against .075, "apenas") and
   above the white it replaces in dark (.07 against .04, where #23d asks for more
   presence). At .055 behind an 80px blur this is a temperature, not a mark —
   which is the only way amber is allowed on a page that can also show a
   `pill--warn`. The meter in pages/components.html reads the two stops off this
   element, so the retint is measured the moment it is made. */
.rx-bgfx__b--1{
  left:-8vw; top:-10vh; width:46vw; height:46vw;
  --rx-blob-a:rgba(224,54,43,.10); --rx-blob-b:rgba(224,54,43,0);
}
.rx-bgfx__b--2{
  left:62vw; top:38vh; width:52vw; height:52vw;
  --rx-blob-a:rgba(10,10,11,.09); --rx-blob-b:rgba(10,10,11,0);
}
.rx-bgfx__b--3{
  left:24vw; top:74vh; width:38vw; height:38vw;
  --rx-blob-a:rgba(224,54,43,.08); --rx-blob-b:rgba(224,54,43,0);
}
.rx-bgfx__b--4{
  left:-6vw; top:52vh; width:42vw; height:42vw;
  --rx-blob-a:rgba(201,147,47,.055); --rx-blob-b:rgba(201,147,47,0);
}
@media (prefers-color-scheme:dark){
  :root:not([data-theme="light"]) .rx-bgfx__b--1{ --rx-blob-a:rgba(224,54,43,.16); }
  :root:not([data-theme="light"]) .rx-bgfx__b--2{ --rx-blob-a:rgba(255,255,255,.05); --rx-blob-b:rgba(255,255,255,0); }
  :root:not([data-theme="light"]) .rx-bgfx__b--3{ --rx-blob-a:rgba(224,54,43,.11); }
  :root:not([data-theme="light"]) .rx-bgfx__b--4{ --rx-blob-a:rgba(221,169,85,.07); --rx-blob-b:rgba(221,169,85,0); }
}
:root[data-theme="dark"] .rx-bgfx__b--1{ --rx-blob-a:rgba(224,54,43,.16); }
:root[data-theme="dark"] .rx-bgfx__b--2{ --rx-blob-a:rgba(255,255,255,.05); --rx-blob-b:rgba(255,255,255,0); }
:root[data-theme="dark"] .rx-bgfx__b--3{ --rx-blob-a:rgba(224,54,43,.11); }
:root[data-theme="dark"] .rx-bgfx__b--4{ --rx-blob-a:rgba(221,169,85,.07); --rx-blob-b:rgba(221,169,85,0); }
/* The watermark's dark silhouette. Written twice for the same reason every dark
   value in this file is: a media query cannot be aliased. The `why` is in the
   `.rx-bgfx::after` comment above. */
@media (prefers-color-scheme:dark){
  :root:not([data-theme="light"]) .rx-bgfx::after{ filter:grayscale(1) brightness(2.2); }
}
:root[data-theme="dark"] .rx-bgfx::after{ filter:grayscale(1) brightness(2.2); }
/* No backdrop-filter: the 2A card, opaque. The ink pill degrades to solid ink
   and not to the card colour — it carries the inverse text, and a pill the same
   colour as its own label reads as empty. */
@supports not (backdrop-filter:blur(1px)){
  .rx-glass,
  .rx-glass--strong,
  .nav--app,
  .card__h,
  .cal,
  .kpis{ background:var(--rx-card); }
  .rx-glass--ink{ background:var(--ink); }
  .rx-bgfx{ background:var(--surface); }
  /* The whole ambience goes, not just the blobs: veil and watermark are there
     to give a pane something to sit on, and there are no panes in this branch.
     The meter reads this same `display:none` off the first blob before it
     bothers rebuilding any of it. */
  .rx-bgfx__b,
  .rx-bgfx::before,
  .rx-bgfx::after{ display:none; }
}

/* ================= BRAND MARK ================= */
/* The 37th-anniversary lockup in the header of every page (SPEC-DECISIONS #16).
   It REPLACES the two-bar SVG rather than standing beside it: the SVG was an
   abstract mark and this is a full lockup with its own wordmark inside, so side
   by side they read as two companies. One mark, and the Inter wordmark next to
   it keeps carrying the name at the size where the lockup's own engraved line
   is texture rather than type.
   `height` fixed and `width:auto`: the `width`/`height` attributes on the
   `<img>` give the browser the ratio before the WebP lands, so the header's
   height is settled at the first layout and this mark contributes nothing to
   CLS — which is the whole point of #16's fourth bullet and would be undone by
   an `<img>` with no intrinsic size.
   No theme filter, unlike the watermark: at 32px the lockup is READ, the grey
   and the crimson are both legible on #ffffff and on #0e0e10, and recolouring a
   brand mark that works is how a brand mark stops working. */
.rx-brand-37{ display:block; height:32px; width:auto; }

/* ================= INTERACTION ================= */
/* SPEC-DECISIONS #14: every interactive thing answers the cursor, and it does
   it the same way everywhere, so the answer itself teaches what is clickable.

   ONE recipe, three sizes:
     surface (tile, gallery card)  lift -4px, no scale — a card that grows
                                   nudges its neighbours in a grid;
     control (button, pill, tab)   lift -3px + scale 1.02;
     row     (day, table row)      no lift at all — a row that leaves the page
                                   drags the rows under it with the eye, and
                                   these live inside dense data.
   All three move the edge from hairline to accent, and all three sink on
   `:active` so a thumb gets an answer with no cursor to give one.

   THREE THINGS THIS BLOCK MUST NOT DO
   1. Animate `backdrop-filter`. A transition on the blur repaints the whole
      stacking context every frame; on a laptop without a discrete GPU the
      hover becomes a stutter. `transition` here NAMES its properties for that
      reason — never `all`, which would sweep the blur in the moment any glass
      surface gets one.
   2. Grow the red. The hover edge is `--accent` itself at low alpha, the same
      pigment already in the system: UX-BRIEF R3 forbids a NEW red, and it is
      the `.pdot`/`.pill-dot` "needs attention" mark that has to keep meaning
      something. A hover edge is under the cursor and gone when it leaves — it
      is feedback, never state.
   3. Give the login's four `.tab` cards a POINTER or an href. Jordi opened them
      to the tilt in SPEC-DECISIONS #16, so the ban of UX-BRIEF 2.1 is no longer
      absolute — but what #16 grants is the lean, not the promise of a
      destination. They still get no `cursor:pointer`, no lift and no focus ring.
      See the note in LOGIN-1A. */
/* THE HOVER SHADOW GOT STRONGER (SPEC-DECISIONS #23c). Jordi asked for it "un
   poco mas fuerte" and it is one token, so every lift in the file — tiles,
   cards, buttons, pills — gained it at once and none of them had to be edited.
   Light took reach AND alpha: 16→22px of offset, 40→52px of blur, .16→.22 on the
   wide term and .08→.12 on the tight one, because at .16 a white pane lifting
   off a near-white page was moving without casting anything.
   Dark could not take the same medicine — a black shadow on #0e0e10 is still
   invisible at any alpha — so dark's gain is where dark can be seen: the glow
   ring goes 3→4px and .13→.20 and the edge goes .55→.66. The shadow is raised
   too, for the tighter term against the card's own surface rather than the page.
   What did NOT move: the press shadow, so the sink still reads as a DROP from a
   deeper hover; and `transition`, which still names its properties one by one so
   no backdrop-filter blur is ever swept into an animation. */
:root{
  --rx-t:170ms;            /* surfaces: the lift and its shadow */
  --rx-t-fast:140ms;       /* edges and fills */
  --rx-t-press:70ms;       /* the sink; a slow press feels broken */
  --rx-tilt-p:800px;       /* #16's perspective, one value for every tilt */
  --rx-edge-hot:rgba(224,54,43,.42);
  --rx-hot-glow:0 0 0 4px rgba(224,54,43,.11);
  --rx-shadow-hover:0 22px 52px rgba(10,10,11,.22), 0 4px 14px rgba(10,10,11,.12);
  --rx-shadow-press:0 4px 12px rgba(10,10,11,.10);
}

/* ---- surface ---- */
/* `.tile` is the landing portal AND the sandbox gallery on index.html: same
   class, same promise (the whole card navigates), so one rule covers both. */
.tile{
  transition:transform var(--rx-t) var(--ease),
             box-shadow var(--rx-t) var(--ease),
             border-color var(--rx-t-fast) var(--ease);
}
.tile:hover,
.tile:focus-visible{
  transform:perspective(var(--rx-tilt-p))
            rotateX(var(--rx-tx,0deg)) rotateY(var(--rx-ty,0deg))
            translateY(-4px);
  box-shadow:var(--rx-shadow-hover), var(--rx-hot-glow);
  border-color:var(--rx-edge-hot);
}
.tile:active{
  transform:perspective(var(--rx-tilt-p))
            rotateX(var(--rx-tx,0deg)) rotateY(var(--rx-ty,0deg))
            translateY(-1px);
  box-shadow:var(--rx-shadow-press);
  transition-duration:var(--rx-t-press);
}

/* ---- control ---- */
/* `.pill-nav--soon` is excluded by name: it is a `<span aria-disabled="true">`
   for a section that does not exist yet, and lifting it would promise a
   destination the hub cannot deliver. */
.btn,
.mini,
.seg,
.nav-logout,
.vtoggle__btn,
.dcard__refresh,
.rx-theme-c,
.rx-devnav__l,
.login .bar__btn,
.pill-nav:not(.pill-nav--soon){
  transition:transform var(--rx-t-fast) var(--ease),
             box-shadow var(--rx-t-fast) var(--ease),
             border-color var(--rx-t-fast) var(--ease),
             background-color var(--rx-t-fast) var(--ease),
             color var(--rx-t-fast) var(--ease);
}
.btn:hover,
.mini:hover,
.seg:hover,
.nav-logout:hover,
.vtoggle__btn:hover,
.rx-theme-c:hover,
.rx-devnav__l:hover,
.login .bar__btn:hover,
.pill-nav:not(.pill-nav--soon):hover{
  transform:translateY(-3px) scale(1.02);
  box-shadow:var(--rx-shadow-hover);
}
/* The edge only where there IS an edge to move. A pill with no border would
   gain a 1px ring out of nowhere on hover and jump by a pixel. */
.mini:hover,
.seg:hover,
.nav-logout:hover,
.rx-theme-c:hover,
.btn--ghost:hover,
.btn--danger:hover{ border-color:var(--rx-edge-hot); }
.btn:active,
.mini:active,
.seg:active,
.nav-logout:active,
.vtoggle__btn:active,
.rx-theme-c:active,
.rx-devnav__l:active,
.login .bar__btn:active,
.pill-nav:not(.pill-nav--soon):active{
  transform:translateY(0) scale(.99);
  box-shadow:var(--rx-shadow-press);
  transition-duration:var(--rx-t-press);
}
/* The refresh button draws its own hit area in a `::before` pseudo-element, so
   a transform on the button would slide the circle out from under the glyph. */
.dcard__refresh:hover{ transform:none; box-shadow:none; }
.dcard__refresh::before{ transition:background var(--rx-t-fast) var(--ease); }

/* ---- row ---- */
/* Dense data. Colour and edge only: the lift belongs to things that sit ON the
   page, and a row is part of one. The left rule is the cheapest "you are on
   this one" that does not move a single character of the row. */
.dweek__d,
.tabchk,
.cal-cell,
table.tbl tbody tr{
  transition:background-color var(--rx-t-fast) var(--ease),
             box-shadow var(--rx-t-fast) var(--ease);
}
.dweek__d:hover,
.tabchk:hover{ box-shadow:inset 2px 0 0 var(--rx-edge-hot); }
.dweek__d:active,
.tabchk:active{ background:var(--rx-hover-strong); transition-duration:var(--rx-t-press); }

/* ---- tilt (SPEC-DECISIONS #16) ---- */
/* The lean towards the cursor. `--rx-tx` / `--rx-ty` are written by
   static/tilt.js, one card at a time, and they are the ONLY thing it writes —
   the transform itself is composed here so the lift of #14 above and the tilt
   arrive as one declaration instead of two owners fighting over one property.
   Read `.tile` above: its `:hover` and `:active` transforms already carry the
   same two variables, which is why a tile leans AND lifts.

   The fallback in every `var()` is `0deg`, and that is the whole degradation
   story: no script, no touch device, no rotation — just #14, unchanged.

   Two cards that have no lift of their own get the tilt and nothing else. A
   `.dcard` sits in a grid of four and a KPI card that jumped would drag the
   reader's eye off the number; the login's four `.tab`s must not start reading
   as navigation (UX-BRIEF 2.1), and a lean is not a promise of a destination
   the way a lift and a pointer cursor are. */
.dcard,
.login .tab{
  transition:transform var(--rx-t) var(--ease),
             box-shadow var(--rx-t) var(--ease),
             border-color var(--rx-t-fast) var(--ease);
}
.dcard:hover,
.login .tab:hover{
  transform:perspective(var(--rx-tilt-p))
            rotateX(var(--rx-tx,0deg)) rotateY(var(--rx-ty,0deg));
}

/* prefers-reduced-motion. hub.css already kills every transition with a
   `*{transition:none!important}`, which is enough for the timing — but NOT for
   the movement: `transition:none` still lets a `:hover` transform apply, it
   just applies it instantly. The lift, the scale and the tilt of #16 have to be
   revoked by name. What stays is exactly what #14 says stays: colour and border.

   THIS BLOCK, NOT tilt.js, IS THE GUARANTEE. The script also reads the query and
   declines to attach, but it reads it once at load: a reader who turns the OS
   setting on with the tab already open would keep the tilt. `transform:none`
   here outranks the variables whatever the script is doing. */
@media (prefers-reduced-motion:reduce){
  .dcard:hover,
  .login .tab:hover,
  .tile:hover, .tile:focus-visible, .tile:active,
  .btn:hover, .btn:active,
  .mini:hover, .mini:active,
  .seg:hover, .seg:active,
  .nav-logout:hover, .nav-logout:active,
  .vtoggle__btn:hover, .vtoggle__btn:active,
  .rx-theme-c:hover, .rx-theme-c:active,
  .rx-devnav__l:hover, .rx-devnav__l:active,
  .login .bar__btn:hover, .login .bar__btn:active,
  .pill-nav:not(.pill-nav--soon):hover,
  .pill-nav:not(.pill-nav--soon):active{ transform:none!important; }
}

/* ================= CRANE ================= */
/* Rig 3D fotográfico — sección propiedad EXCLUSIVA del rol crane-builder
   (SPEC-DECISIONS #10). Aquí vive solo la ESTRUCTURA (planos, capas,
   superficies); toda la geometría numérica (offsets, pivotes, cámara) la
   escribe static/crane.js desde su CFG — una sola fuente de verdad,
   calibrada en tools/crane3d-lab.html. La zona `.crane` la coloca cada
   página (flex, height, entrance en LOGIN-1A); `.crane3d` la llena.
   Escena: perspective en __world; __pivot hace rotateY CONTINUO 360° (#12)
   sobre el eje vertical de la torre; __hang contra-rota (billboard) y el
   gancho cuelga libre (#8). El cable usa var(--ink): se voltea solo con el
   tema. En el rAF solo se escriben transform/opacity. */
.crane{ position:relative; min-width:0; overflow:hidden; cursor:crosshair; }
.crane3d{
  position:absolute; inset:0;
  --c3-line:var(--ink);
  --c3-hair:var(--hair);
}
.crane3d *{ pointer-events:none; }
.crane3d__ground{ position:absolute; left:0; right:0; bottom:20px; height:1px; background:var(--c3-hair); }
/* Ancla 0x0 en el centro-abajo de la zona; fit() escribe translateX+scale. */
.crane3d__scale{ position:absolute; left:50%; bottom:20px; width:0; height:0; }
/* Contexto 3D: crane.js escribe perspective/perspective-origin aquí. */
.crane3d__world{ position:absolute; left:0; bottom:0; width:0; height:0; }
.crane3d__world img{ display:block; position:absolute; max-width:none; }
.crane3d__pivot,
.crane3d__troll,
.crane3d__hang{
  position:absolute; width:0; height:0;
  transform-style:preserve-3d; will-change:transform;
  backface-visibility:visible;   /* la espalda espejada ES la vista trasera */
}
.crane3d__tower{ will-change:transform; }
.crane3d__cable{
  position:absolute; left:-1.5px; top:0; width:3px; height:600px;
  background:var(--c3-line); transform-origin:0 0; will-change:transform;
  border-radius:2px;
}
.crane3d__hook{ will-change:transform; }

/* ---- VOLUMEN (#15): el grupo giratorio es una CAJA, no un plano ---------
   Jordi rechazó el slew plano: a ángulos intermedios la pluma y la cabina se
   veían de papel. `__slew3d` es la caja y crane.js la llena de caras `__f`,
   cada una un recorte del MISMO slew.webp por background-position (0 KB de
   assets nuevos, un solo bitmap decodificado):
     · caras verticales delante/detrás a ±dz  → mandan de 0° a ~40°
     · intradós horizontales (rotateX 90°)    → mandan de ~40° a ~140°
     · testeros perpendiculares (rotateY 90°) → el grosor del extremo
     · planos CRUZADOS en el eje (rotateY 90°)→ la maquinaria a 90°/270°
   Las caras NO se animan: su transform y su brightness se escriben una sola
   vez al construir y solo el pivote rota. Dentro del rAF únicamente cambia
   la opacidad de las cruzadas — de ahí que sean las únicas con will-change.
   El `filter: brightness()` de las caras traseras/intradós es ESTÁTICO: en
   el rAF no se toca ningún filtro. */
.crane3d__slew3d{
  position:absolute; left:0; top:0; width:0; height:0;
  transform-style:preserve-3d;
}
.crane3d__f{
  position:absolute; background-repeat:no-repeat; pointer-events:none;
  backface-visibility:visible;   /* la espalda espejada ES la vista trasera */
}
/* `--r` = cara PERPENDICULAR (intradós, testero, cruzada). No cuelga del
   pivote sino del elemento con `perspective`, y crane.js le escribe la
   rotación completa en un único transform. Es por Safari, y está medido:
   WebKit no pinta un hijo con rotateX/rotateY(90°) bajo un padre ya rotado
   —0 px a 45° y a 90° frente a 460 y 3143 en Chrome—, así que Safari se
   quedaba con la grúa plana. Con la rotación compuesta los dos motores
   pintan lo mismo (paridad 0.93–0.98 en el barrido completo).
   NO se les pone will-change: el transform 3D ya las promociona y son 8. */
.crane3d__f--r{ position:absolute; }
.crane3d__f--x{ opacity:0; will-change:opacity; }
/* crane.js congela la física con prefers-reduced-motion (pose estática). */
@media (prefers-reduced-motion:reduce){
  .crane{ cursor:default; }
}

/* ================= LOGIN-1A ================= */
/* Everything below hangs off `.login`. Stagger delays are the handoff's table
   and are load-bearing for the entrance: header 0 · sub .12 · h1 .2 · bar .3 ·
   stats .35 · crane .4 · tabs .5/.58/.66/.74. */
@keyframes rise{ from{ opacity:0; transform:translateY(16px); } to{ opacity:1; transform:none; } }

.login{ min-height:100vh; min-height:100dvh; display:flex; flex-direction:column; overflow:hidden; }

.login .hd{
  display:flex; align-items:center; justify-content:space-between;
  padding:16px 20px; position:relative;
  animation:rise .8s var(--ease) both;
}
.login .hd__brand{ display:flex; align-items:center; gap:11px; text-decoration:none; color:var(--ink); }
.login .hd__brand-text{ font-size:17px; font-weight:600; letter-spacing:-.02em; }
.login .hd__meta{
  display:none; position:absolute; left:50%; transform:translateX(-50%);
  align-items:center; gap:16px; font-size:11px; font-weight:500;
  letter-spacing:.14em; text-transform:uppercase; color:var(--muted);
  background:var(--rx-chip-hi); border-radius:999px; padding:6px 16px;
}
/* The tertiary's second dose (SPEC-DECISIONS #23d), and the reason it is allowed
   HERE and nowhere else a dot appears: this is a 3px separator between two
   strings of a typographic strip, on the one page in the hub that has no pill,
   no severity and no delivery type on it. Every other dot in the system is a
   MARK — `.pdot`, `.pill-dot`, `.dot--warn`, `.cdot` — and hub.css:932 records
   what happens when an amber one joins them: it was read as a third severity
   tier and thrown out. Nothing is lost if a reader does not see it; the gap
   between the two labels already separates them. */
.login .hd__dot{ width:3px; height:3px; border-radius:50%; background:var(--rx-amber); }
.login .hd__right{ display:flex; align-items:center; gap:10px; }
/* No background, no colour: `.rx-glass--ink` owns the surface of this pill. */
.login .hd__hub{
  border-radius:999px; padding:9px 20px;
  font-size:12px; font-weight:600; letter-spacing:.12em;
}

.login .main{ flex:1; display:flex; flex-direction:column; padding:12px 20px 22px; min-height:0; }

/* ---- THE CHIP UNDER THE SECONDARY TEXT (SPEC-DECISIONS #4) ----------------
   Four blocks on this page carry text with NOTHING between it and the crane:
   the eyebrow, the two stats lines and the header strip. That was survivable
   while the crane lived inside `.crane`; the stage of #19 puts the canvas fixed
   over the WHOLE area, so a moving machine now passes behind every one of them.
   Measured on the real login at 1440x900, driving the yaw 0->360 in steps of 15
   with `__crane3d.poseAt`, text set to `color:transparent` and the ratio
   computed pixel by pixel over the ink's own bounding box (the 5th-percentile
   pixel is the number, so one stray highlight does not decide it):
       light   sub 1.03 · stats__mid 2.53 · stats__sub 1.14 · hd__meta 1.60
       dark    sub 1.05 · stats__mid 3.29 · stats__sub 1.82 · hd__meta 2.19
   The remedy #4 prescribes is a local background under the block — never a
   lower blur, and never a heavier ink, because the ink is the theme's `--muted`
   and this page does not get its own.

   WHY `--rx-chip-hi` (.94) AND NOT `--rx-chip` (.85). `--muted` is
   rgba(0,0,0,.55): composited on PURE WHITE it measures 4.76:1, which is the
   ceiling for every muted string in the hub. A chip at .85 over the crane's
   darkest pixel lands at 218/255 and the same ink measures 4.35:1 there — under
   the floor, with no ink change able to fix it. .94 puts the worst backdrop at
   ~240/255 and the floor back at ~4.6:1. .85 stays the token for text that sits
   on a GLASS PANE (components.html, where the pane has already lifted the
   backdrop); .94 is for text standing on the page itself.
   The chip is sized to the text, not to the row: `width:max-content` plus a
   negative margin equal to the padding, so the box grows around the string and
   not one character of it moves. */
/* The collision. hub.css:239 has `.sub{font-size:14px;color:var(--muted);
   margin:0}` for interior pages; here `.sub` is the red dot + one line. */
.login .sub{
  display:flex; align-items:center; gap:9px;
  width:max-content; max-width:100%;
  background:var(--rx-chip-hi); border-radius:999px;
  padding:6px 14px; margin-left:-14px;
  animation:rise .8s var(--ease) .12s both;
}
.login .sub__dot{ width:8px; height:8px; border-radius:50%; background:var(--accent); flex:0 0 auto; }
.login .sub span:last-child{ font-size:13px; color:var(--muted); }

.login .row{ flex:1; display:flex; flex-direction:column; gap:16px; min-height:0; }
.login .h1{
  font-family:var(--rx-display); font-weight:400;
  font-size:var(--rx-h1); line-height:.86; letter-spacing:.005em;
  text-transform:uppercase; color:var(--ink); margin:16px 0 0; flex:0 0 auto;
  animation:rise .8s var(--ease) .2s both;
}

.login .crane{ flex:1; min-height:260px; animation:rise 1s var(--ease) .4s both; }

.login .stats{
  display:none; flex:0 0 auto; flex-direction:column; justify-content:space-between;
  align-items:flex-end; text-align:right; padding:16px 0 12px;
  animation:rise .8s var(--ease) .35s both;
}
/* One chip for the two lines and not one each: they are a single block of copy
   and two boxes with an 8px gap between them read as two labels. */
.login .stats__blk{
  background:var(--rx-chip-hi); border-radius:var(--rx-r-tab);
  padding:10px 14px; margin-right:-14px;
}
.login .stats__mid{
  font-family:var(--rx-display); font-weight:400; font-size:26px;
  letter-spacing:.02em; text-transform:uppercase; line-height:1;
}
.login .stats__sub{ font-size:13px; color:var(--muted); margin-top:8px; }
.login .stats__sub--sm{ font-size:12px; margin-top:6px; max-width:230px; }

/* The sign-in bar rides UP into the crane zone: the jib's load swings down
   behind it instead of stopping politely above it, which is the whole reason
   the bar is glass and not a white card. `.crane` clips at its own box, so the
   overlap has to eat the zone's last pixels — not the gap under it.
   z-index:2 over a `.crane` that is positioned and has none.
   No background/border here: `.rx-glass--strong` owns them and `.login .bar`
   would outrank it. */
.login .bar{
  position:relative; z-index:2;
  display:flex; flex-direction:column; gap:18px;
  border-radius:var(--rx-r-card); padding:22px; margin-top:-22px;
  animation:rise .8s var(--ease) .3s both;
}
.login .bar__title{
  font-family:var(--rx-display); font-weight:400; font-size:24px;
  letter-spacing:.01em; text-transform:uppercase;
}
.login .bar__portal{ font-size:12px; color:var(--muted); margin-top:3px; }
/* The refusal (SPEC-DECISIONS #1). Under the title block, above the fields:
   after a POST there is no focus and no script to move one, so the message has
   to be read on the way IN to the first field — the same rule `startedit__e`
   follows on the dashboard.
   `min-height` on desktop and not on the phone: on one row the bar is a fixed
   height and a message that appears would push email, password and the button
   down by a line; in the column layout the bar is already free to grow and a
   reserved empty line is just a gap under the title of every clean load. */
.login .bar__error{
  margin-top:6px; font-size:12px; font-weight:500;
  color:var(--bad-ink); line-height:1.4;
}
.login .bar__error:empty{ display:none; }
/* THE FLOOR, AND WHY IT IS NOT `min-width:0`. `flex:1` on a box that may shrink
   to nothing is a field that DOES shrink to nothing the moment the row is
   tighter than its content: measured on this login at 768/800/900, email and
   password came out 53.4 / 69.4 / 119.4px wide — a sign-in box narrower than the
   address it holds. 120px is the same floor the auth sheet has carried since
   pass 1 for the same reason (four fields on one row at 38px), and it is a
   FLOOR, not a basis: the field still grows to fill its line, and below this
   width the bar wraps instead (see the `flex-wrap` in Breakpoints). Harmless in
   the column layout under 768, where the field is already full width. */
.login .bar__field{ flex:1; display:flex; flex-direction:column; gap:2px; min-width:120px; }
.login .bar__field label{
  font-size:10px; font-weight:500; letter-spacing:.14em;
  text-transform:uppercase; color:var(--muted);
}
/* min-height, not more padding: the input centres its own text, so the box
   grows to the touch minimum without the caret moving off the rule under it. */
.login .bar__field input{
  border:none; border-bottom:1px solid var(--rx-underline); border-radius:0;
  background:transparent; padding:8px 0; font-size:14px;
  min-height:var(--rx-tap);
  color:var(--ink); width:100%;
}
/* Same three lines as the system rule above, and for the same reason: the red
   glow from hub.css's `input:focus` survives everything that does not name
   box-shadow, and the keyboard ring has to be re-drawn by name because
   hub.css:34 turns the outline off on every `input:focus` at a specificity the
   global `:focus-visible` cannot reach. */
.login .bar__field input:focus{ box-shadow:none; border-bottom-color:var(--ink); }
.login .bar__field input:focus:not(:focus-visible){ outline:none; }
/* delta pasada 2: D (ux) — EL ANILLO DE FOCO NO ES ROJO. `/reset` autoenfoca
   su campo de email, y `:focus-visible` acierta con el autofocus: la página
   abría con un anillo `--accent` de 2px alrededor de una caja intacta, que en
   este sistema es el color de "esto está mal" (`.hint--err`, `--bad-ink`, el
   borde de `aria-invalid`). En tinta del tema el anillo dice lo mismo — dónde
   está el cursor — sin decir lo que no es. El rojo se queda para los estados de
   error de verdad. */
.login .bar__field input:focus-visible{ outline:2px solid var(--ink); outline-offset:2px; }
.login .bar__btn{
  background:var(--accent); color:#fff; border:none; border-radius:999px;
  padding:13px 38px; min-height:var(--rx-tap); font-size:14px; font-weight:600;
  cursor:pointer; transition:background .2s var(--ease);
}
.login .bar__btn:hover{ background:var(--accent-hover); }
.login .bar__aside{ display:flex; align-items:center; justify-content:space-between; gap:4px; }
/* 12px of type, 44px of target. The hit area is an absolutely positioned
   pseudo-element and not padding, so the link keeps its place in the aside and
   the row does not grow: the only thing that changes is what a thumb can miss.
   The strip spans the link's own width and is centred on it, so it never
   reaches the sign-in button in the row above. */
.login .bar__forgot{
  position:relative;
  font-size:12px; font-weight:500; color:var(--accent-ink); text-decoration:none;
}
.login .bar__forgot::after{
  content:''; position:absolute; left:0; right:0; top:50%;
  height:var(--rx-tap); transform:translateY(-50%);
}
.login .bar__invite{ font-size:11px; color:var(--muted); }

.login .tabs{ display:grid; grid-template-columns:repeat(2,1fr); gap:12px; margin-top:14px; }
/* Surface from `.rx-glass`; corner, padding and rhythm from here.
   NO POINTER, NO FOCUS, NO LIFT — and one hover, added on purpose.
   These four are `<div>`s with no href: a rótulo of what L&R builds, not
   navigation. UX-BRIEF 2.1 lists them as a named risk for exactly that reason
   ("parecen tarjetas pulsables y no lo son"), and pass 2 left them inert and
   flagged it. SPEC-DECISIONS #16 answers the flag: Jordi wants them tilting.
   So they tilt (see the tilt block in INTERACTION) and they gain NOTHING ELSE —
   no `cursor:pointer`, no lift, no accent edge, no focus ring. The distinction
   the ban was protecting survives: a card that leans is a card that is alive
   under the cursor, a card that lifts and turns the cursor into a hand is a
   card that has somewhere to go. These have nowhere to go, and they still say
   so. Re-flagged to the design-reviewer on pass 4. */
.login .tab{
  display:flex; align-items:center; gap:12px;
  border-radius:var(--rx-r-tab); padding:13px 18px;
}
/* 01–04 in the crane's paint (SPEC-DECISIONS #23d). The numbering is the one
   string on this page that carries no information — it counts what L&R builds,
   it does not name it — which is exactly why the tertiary gets it and the name
   beside it keeps `--ink`. `--rx-amber-ink`, never `--rx-amber`: this is text,
   and at 11px the light mustard would be decoration pretending to be readable.
   Measured on the four tabs at 1440x900, worst case the card over the crane's
   lit tower: 4.46:1 light and 7.49:1 dark, where the `--muted` it replaces was
   scoring 3.55:1 and 4.74:1. */
.login .tab__idx{ font-size:11px; font-weight:500; color:var(--rx-amber-ink); font-variant-numeric:tabular-nums; }
.login .tab__name{
  font-family:var(--rx-display); font-weight:400; font-size:16px;
  letter-spacing:.02em; text-transform:uppercase;
}
.login .tab__sub{ font-size:11px; color:var(--muted); margin-left:8px; }
/* `backwards`, NOT `both`, and these four are the only entrance in this file
   that had to change. An animation's value outranks every normal declaration in
   the cascade, so `both` — which holds the LAST keyframe forever — was pinning
   `transform:none` from `rise`'s `to` on these cards for the life of the page.
   The tilt of #16 was measured arriving as `matrix(1,0,0,1,0,0)`: tilt.js was
   writing the angles, redesign.css was composing them, and the finished
   entrance was quietly overruling the result.
   `backwards` still holds the FIRST keyframe through the stagger delay, which is
   the only thing the entrance actually needs the fill for, and after the run the
   card goes back to the cascade — where `to{opacity:1;transform:none}` already
   put it, so nothing moves at the handover.
   The other `rise` users above keep `both`: none of them takes a transform after
   the entrance, and a fill mode is not worth changing on a rule that has no bug.
   If one of them ever gains a hover transform, this is the trap it will fall
   into. */
.login .tabs .tab:nth-child(1){ animation:rise .7s var(--ease) .5s backwards; }
.login .tabs .tab:nth-child(2){ animation:rise .7s var(--ease) .58s backwards; }
.login .tabs .tab:nth-child(3){ animation:rise .7s var(--ease) .66s backwards; }
.login .tabs .tab:nth-child(4){ animation:rise .7s var(--ease) .74s backwards; }

/* hub.css already kills every animation under `prefers-reduced-motion` with a
   `*{animation:none!important}`. Repeated here because this layer must degrade
   on its own if it is ever loaded without it: rise stops, and the crane's idle
   swing stops with the slew where it stands. */
@media (prefers-reduced-motion:reduce){
  .login .hd, .login .sub, .login .h1, .login .crane,
  .login .stats, .login .bar, .login .tabs .tab{ animation:none!important; }
}

/* ================= AUTH-CARDS ================= */
/* invite / reset / reset_request, and the login's own error states. They
   inherit the session canvas whole: the page root is `.login`, so `.hd`, `.sub`,
   `.h1`, `.bar` and `.tab` above are already theirs and nothing is re-declared
   here. `--auth` only undoes the two things the login needs and a sheet of
   fixtures cannot have: one locked viewport and a clipped overflow. */
.login--auth{ min-height:0; overflow:visible; }
.login--auth .main{ flex:0 1 auto; }
/* Side by side, because the point of the page is comparing states — a state
   that only exists on the happy path is a state nobody looked at. */
/* delta pasada 2: C (ux) — DOS CLASES, NO UNA, Y ESA ES TODA LA AVERÍA. El
   solape del login es del login: la barra sube -22px (fono) / -50px (escritorio)
   para meterse en la zona de la grúa. Aquí no hay grúa, hay un titular de tres
   líneas, y esta hoja ya declaraba `margin-top:0` para deshacerlo — pero lo
   declaraba a (0,2,0), igual que `.login .bar`, y las dos reglas de Breakpoints
   están escritas DESPUÉS en el fichero, así que ganaban por orden y el solape
   volvía. Medido con la tinta en píxeles (card oculta, se busca la fila más
   baja con tinta en la banda del H1), light, las tres páginas: a 375 la tinta
   baja hasta y=280.0 y la card empezaba en 258.8 — 21.2px de letra por debajo
   del borde; a 1280, tinta 400.0 contra card 351.1, 48.9px. Con las dos clases
   la regla gana por especificidad y no por orden, que es lo que hace que
   sobreviva al siguiente bloque de anchura que alguien escriba.
   El margen inferior es el aire, no el arreglo: sin él la card arranca a 0.8px
   de la tinta (375) y a 1.1px (1280), dentro de lo que una métrica de fuente
   distinta —Anton en WebKit— puede mover sola. .16em lo escala con el titular:
   7.8px a 375 y 15.4px a 1280. */
.login.login--auth .bar{ margin-top:0; }
.login.login--auth .h1{ margin-bottom:.16em; }
.login--auth .fx{ margin-top:26px; }
.login--auth .fx__l{
  display:flex; align-items:center; gap:8px; margin-bottom:8px;
  font-size:10px; font-weight:500; letter-spacing:.14em;
  text-transform:uppercase; color:var(--muted);
}
.login--auth .fx__l::after{ content:''; flex:1; height:1px; background:var(--hair-soft); }
/* The refusal is a row of its OWN, full width, above the fields — the bar wraps
   for it. Reading it on the way in to the field is the whole point; a message
   squeezed into a flex cell beside the email box is a message read after the
   retype. */
.login--auth .bar .alert{ flex:1 0 100%; margin-bottom:0; }
/* The 120px floor that this sheet used to declare for itself now lives on
   `.login .bar__field` in LOGIN-1A: the invite card's four fields at 38px and
   the login's own two at 53.4px were the same bug measured on two pages, and one
   of the two was answered a pass later than the other. */
.login--auth .bar__ro{
  font-size:14px; color:var(--ink); padding:8px 0;
  border-bottom:1px solid var(--hair-soft);
}
.login--auth .bar__foot{ flex:1 0 100%; font-size:11px; color:var(--muted); }

/* ================= LANDING ================= */
/* landing.html — the portal. Question: which job is mine and where do I tap. */
/* The greeting is the one string on this page that carries no information, and
   Anton is the loudest voice in the system: in it, the word that cannot help
   you choose a project is the one shouting. Inter 300, as in 2A. Anton goes to
   the project NAME instead, one line down (UX-BRIEF 2.3). */
.pg-landing .display{
  font-family:'Inter',sans-serif; font-weight:300;
  letter-spacing:-.03em; text-transform:none;
}
/* THE PROJECT NAME WEARS THE THEME'S INK (SPEC-DECISIONS #24a). It was `--accent`
   for one round (#23b, measured at 4.02:1 worst-case); Jordi saw it rendered and
   reverted it: "deshace este cambio y dejalo blanco y en tema light dejalo en
   negro". No color declaration on purpose — the name inherits the page ink
   (near-black in light, #f5f5f6 in dark), which is what it was before #23b. */
.pg-landing .tile .tn{
  font-family:var(--rx-display); font-weight:400; font-size:21px;
  letter-spacing:.015em; text-transform:uppercase; line-height:1.05;
}
/* No background here: `.rx-glass--strong` owns the surface of the tile and this
   rule is (0,2,0) — it would silently win. Only the lift, which is hub.css's
   and is kept because it promises the whole card navigates, and it does. */
.pg-landing .tile{ border-color:var(--glass-hairline); }
/* And the hot edge of #14 has to survive that line. `.tile:hover` in INTERACTION
   and `.pg-landing .tile` above are both (0,2,0), the LANDING one is written
   later, so on the portal — the one page whose whole job is "tap your project" —
   the card lifted and glowed but its border never moved. Re-stated here, right
   after the rule that was winning, and by specificity rather than by
   `!important`: an override that needs a bang is an override that will lose
   again the next time somebody adds a page class. */
.pg-landing .tile:hover,
.pg-landing .tile:focus-visible{ border-color:var(--rx-edge-hot); }
.pg-landing .empty{ margin-top:16px; }

/* ================= DASHBOARD ================= */
/* project_dashboard.html — pills, KPI band, cards. */
/* The band is one glass surface and the cells inside it stop being cards
   (SPEC-DECISIONS #11): four panes of glass in a row is four blurs paid for one
   piece of chrome, and the hairlines between them read as a table. */
#dash .kpis{ border-radius:var(--rx-r-card); padding:6px; }
#dash .kpis .kpi{ background:transparent; border-color:transparent; }
#dash .kpis .kpi + .kpi{ border-left:1px solid var(--glass-hairline); border-radius:0; }
#dash .kv{
  font-family:var(--rx-display); font-weight:400; font-size:34px;
  letter-spacing:.01em; text-transform:uppercase; line-height:1;
}
/* The exception that the whole band is judged on. A KPI with no number prints
   in Inter muted, never in Anton: a 34px condensed em-dash is a headline that
   says nothing, and the crew trusts the numbers that ARE there precisely
   because the hub can say "no data" without dressing it up as one. */
#dash .kv--none{
  font-family:'Inter',sans-serif; font-weight:400; font-size:20px;
  letter-spacing:0; text-transform:none; color:var(--muted);
}
/* hub.css:611 makes every `.ks span` `white-space:nowrap` so a wrap lands on the
   separator instead of inside a date ("next pour 2026-08-/03"). The no-data sub
   is the one `.ks` that holds no token: it is a sentence, and an unbreakable
   sentence sets the min-content width of its grid track — measured at 375 it
   ran to right=379.8 and gave the whole page 5px of horizontal scroll
   (scrollWidth 380 / clientWidth 375). Only `--off`, so every real KPI keeps
   the nowrap that protects its dates. */
#dash .kpis .ks--off span{ white-space:normal; }
#dash .pills{ border-radius:999px; padding:5px; }
#dash .pills .pill-nav{ background:transparent; }
#dash .pills .pill-nav:hover{ background:var(--rx-hover-strong); }

/* ================= DELIVERIES ================= */
/* project_deliveries.html — the calendar frame is glass, every cell is not. */
.pg-deliveries .cal{ border-radius:var(--rx-r-card); padding:18px; }
.pg-deliveries .cal-title{
  font-family:var(--rx-display); font-weight:400; font-size:20px;
  letter-spacing:.02em; text-transform:uppercase;
}
/* A day cell is data: a date, coloured dots and a count, read at arm's length
   in the sun. Solid, always. This is the line SPEC-DECISIONS #11 draws. */
.pg-deliveries .cal-cell{ border-color:var(--hair); }
/* Today in the AGENDA (ux gate, item 13). hub.css only marks `.cal-cell.is-today`,
   and on the phone the agenda replaces the grid — so the mark vanished exactly
   where the crew reads it. Same language as the cell: accent edge, accent date. */
.pg-deliveries .agenda-day.is-today{ border-left:2px solid var(--accent); padding-left:10px; }
.pg-deliveries .agenda-day.is-today .agenda-date{ color:var(--accent); }

/* ================= TEAM ================= */
/* project_team.html — names and emails are people, not headlines (R1). */
.pg-team .uname{ font-family:'Inter',sans-serif; }

/* ================= MESSAGE ================= */
/* message.html — a dead end with one way out. The card is glass because there
   is nothing else on the page for the eye to rest on. */
.msgwrap .authcard{ border-radius:var(--rx-r-card); }
.msgwrap .card-title{ font-size:26px; margin-bottom:8px; }

/* ================= ADMIN ================= */
/* admin_users.html / admin_project.html — the console. */
/* Anton on the H1 and NOWHERE else on this page. `.card__h .t` stays Inter 600
   15px: ten condensed titles down a 9,000px page stop marking anything, and
   this screen is full of strings that get compared character by character —
   appXXXXXXXXXXXXXX, pm0004, Dropbox paths — where a condensed capital confuses
   0/O and 1/l on exactly the value that costs a deploy (UX-BRIEF R1). */
.pg-admin .card__h .t{ font-family:'Inter',sans-serif; }
.pg-admin .baseitem__id{ font-variant-numeric:tabular-nums; letter-spacing:.01em; }
.pg-admin .psum{ max-width:68ch; }
/* delta pasada 2: F (design) — EL VALOR QUE UN CLEAR VA A BORRAR, LEGIBLE EN
   DARK. hub.css:849 escribe `rgba(0,0,0,.62)` como literal y su razonamiento es
   correcto para el único tema que existía cuando se escribió: sobre la pista
   `--pill-gray` de `.tabchk--clear`, un `--muted` daba 4.65:1 y ese negro al 62%
   da 6.58:1. En dark la pista es #222226 y el mismo negro mide 1.23:1 — el
   "currently <valor>" que dice QUÉ se va a borrar deja de leerse justo en el
   control destructivo de la consola. Mismo papel, tinta del tema: el token
   guarda el valor de hub.css tal cual en light (el número no se mueve) y le da
   al oscuro el suyo. Sólo la tinta: ni tamaño, ni display, ni el `font-size:12px`
   que hub.css declara al lado — esta capa no toca la caja de `.tabchk`. */
.pg-admin .tabchk .clearval{ color:var(--rx-muted-hi); }

/* ================= COMPONENTS ================= */
/* The sandbox sheets — index.html (gallery), pages/components.html (design
   system) and the header of tools/calibrate.html — and only their own chrome:
   every primitive shown on them is drawn by hub.css plus the TOKENS-1A block
   at the top of this file, which is the entire point of the page. */
.cx{ max-width:1080px; margin:0 auto; padding:40px 20px 120px; }
/* The sheet's header row: the eyebrow on the left, the theme cycle button in
   the same top-right corner the interior nav puts it in. `.eyebrow` carries its
   own bottom margin, so the button is centred against the text, not the gap. */
.cx__hd{ display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:14px; }
.cx__hd .eyebrow{ margin-bottom:0; }
.cx__h{ margin:0 0 6px; }
.cx__lead{ font-size:14px; color:var(--muted); margin:0 0 8px; max-width:68ch; }
.cx__sec{ margin-top:56px; }
.cx__t{
  font-family:var(--rx-display); font-weight:400; font-size:15px;
  letter-spacing:.12em; text-transform:uppercase; color:var(--muted);
  margin:0 0 16px; padding-bottom:8px; border-bottom:1px solid var(--hair-soft);
}
.cx__row{ display:flex; flex-wrap:wrap; align-items:center; gap:12px; margin-bottom:14px; }
.cx__grid{ display:grid; grid-template-columns:repeat(auto-fit,minmax(210px,1fr)); gap:14px; }
.cx__note{ font-size:12px; color:var(--muted); margin:8px 0 0; max-width:68ch; }
/* Muted, but on an `--ink` surface, where `--muted` is the wrong side of the
   theme: on the ink pill the text is the PAGE colour, not the ink colour.
   The alpha is IN the colour and not an `opacity`, because the contrast meter
   reads `getComputedStyle(el).color` — an opacity on the box would leave it
   measuring a text that is lighter than the one it reports. */
.cx__note--on{ color:var(--rx-ink-on-2); }
.cx__demo{ position:relative; border-radius:var(--rx-r-card); padding:22px; }
.cx__swatch{ border:1px solid var(--hair); border-radius:var(--rx-r-tab); overflow:hidden; }
.cx__chipc{ height:64px; }
.cx__swl{ display:flex; justify-content:space-between; gap:8px; padding:8px 10px; font-size:11px; }
.cx__swn{ font-weight:500; }
.cx__swv{ color:var(--muted); font-variant-numeric:tabular-nums; }
.cx__type{ border-bottom:1px solid var(--hair-soft); padding:14px 0; }
.cx__typel{ font-size:11px; color:var(--muted); letter-spacing:.06em; text-transform:uppercase; }
/* The measured ratio, printed beside the text it was measured on. Never
   `display:none` on a failing badge: a contrast meter that hides its bad news
   is a counter that lies. */
.cx-ratio{
  display:inline-flex; align-items:center; gap:5px; vertical-align:middle;
  margin-left:8px; padding:2px 8px; border-radius:999px;
  font-size:11px; font-weight:600; font-variant-numeric:tabular-nums;
  border:1px solid var(--hair); background:#fff; color:var(--muted);
}
.cx-ratio--ok{ color:var(--rx-ok-ink); border-color:rgba(52,211,153,.5); background:rgba(52,211,153,.10); }
.cx-ratio--bad{ color:var(--bad-ink); border-color:rgba(224,54,43,.4); background:rgba(224,54,43,.08); }
/* The worst case of C2, made of pixels instead of assumed (SPEC-DECISIONS #4):
   solid --ink behind a glass pane, which is the darkest thing that can ever pass
   under one — the crane's tower. The meter samples the text sitting on it. */
.cx__ink{ background:var(--ink); border-radius:var(--rx-r-card); padding:14px; }
.cx__ink .cx__demo{ margin:0; }
/* The fallback, drawn beside the glass instead of described next to it. It does
   NOT carry `.rx-glass`: it is what that class becomes when the browser has no
   backdrop-filter, and the only honest way to show that is to build it. */
.cx__demo--nofx{
  background:var(--rx-card);
  border:1px solid var(--glass-hairline);
  box-shadow:var(--glass-inner), var(--glass-shadow);
}
/* 44px of dashed box behind a control, so "meets the touch minimum" is a thing
   you can see rather than a thing the note claims. */
.cx__tap{
  display:inline-flex; align-items:center;
  min-height:var(--rx-tap); padding:0 2px;
  border:1px dashed var(--ghost); border-radius:var(--rx-r-tab);
}

/* ================= THEME TOGGLE ================= */
/* Three states and one of them is "let the OS decide", so it cannot be a
   two-position switch: System is not a shade, it is the absence of a choice,
   and a sun/moon flip has nowhere to put it.
   Two shapes of the same control, both driven by static/theme.js:
     `.rx-theme`   — the explicit three-button group. Sandbox chrome.
     `.rx-theme-c` — one 44px button that cycles, for the page's own header,
                     where the interior nav already carries a user pill and a
                     Sign out and has no room for three.
   aria-pressed / aria-label are written by the script, never by the markup: the
   state lives in localStorage and the markup is served identical to every
   reader. */
.rx-theme{
  display:inline-flex; align-items:center; gap:2px;
  border-radius:999px; padding:3px;
  background:var(--pill-gray);
}
.rx-theme__b{
  display:inline-flex; align-items:center; justify-content:center;
  min-width:36px; min-height:34px; padding:0 9px;
  border:none; border-radius:999px; background:transparent;
  color:var(--muted); font-size:13px; line-height:1; cursor:pointer;
}
.rx-theme__b:hover{ color:var(--ink); }
.rx-theme__b[aria-pressed="true"]{
  background:var(--rx-raised); color:var(--ink); box-shadow:var(--rx-shadow-s);
}
.rx-theme-c{
  display:inline-flex; align-items:center; justify-content:center;
  width:44px; height:44px; flex:0 0 auto;
  border:1px solid var(--ghost); border-radius:999px;
  background:transparent; color:var(--ink);
  font-size:15px; line-height:1; cursor:pointer;
  transition:background .2s var(--ease);
}
.rx-theme-c:hover{ background:var(--rx-hover); }
.rx-theme-c__i{ display:none; }
/* Before the script runs there is no data-state, and the honest default is the
   one the page is actually in: System. */
.rx-theme-c:not([data-state]) .rx-theme-c__i--system,
.rx-theme-c[data-state="system"] .rx-theme-c__i--system,
.rx-theme-c[data-state="light"] .rx-theme-c__i--light,
.rx-theme-c[data-state="dark"] .rx-theme-c__i--dark{ display:block; }

/* ================= SANDBOX CHROME ================= */
/* The dev nav between the sandbox-only markers. NOT PORTED — it is deleted
   with its markers when a page moves into the hub.
   COLLAPSIBLE since pass 2: open and pinned bottom-left it sat on top of the
   login's tab row, which is the bottom band of a page whose whole promise is
   "one viewport, nothing cut". Closed it is one 40px pill in the corner; the
   pages it navigates are one click away and none of them is covered. */
.rx-devnav{
  position:fixed; right:16px; bottom:16px; z-index:90;
  border-radius:999px; padding:4px;
  max-width:calc(100vw - 32px);
}
.rx-devnav > summary{
  display:inline-flex; align-items:center; gap:8px;
  min-height:36px; padding:0 14px; border-radius:999px;
  font-size:11px; font-weight:600; letter-spacing:.12em; text-transform:uppercase;
  color:var(--muted); cursor:pointer; list-style:none;
}
.rx-devnav > summary::-webkit-details-marker{ display:none; }
.rx-devnav > summary::after{ content:'+'; font-size:14px; letter-spacing:0; }
.rx-devnav[open] > summary::after{ content:'–'; }
.rx-devnav[open]{ padding:4px 6px 6px; }
.rx-devnav__p{
  display:flex; flex-wrap:wrap; align-items:center; gap:4px;
  padding:6px 2px 0; margin-top:4px; border-top:1px solid var(--glass-hairline);
  max-width:340px;
}
.rx-devnav__l{
  display:inline-flex; align-items:center; min-height:34px; padding:0 12px;
  border-radius:999px; font-size:12px; font-weight:500;
  color:var(--ink); text-decoration:none;
}
.rx-devnav__l:hover{ background:var(--rx-hover); }
.rx-devnav__t{
  flex:1 0 100%; padding:2px 6px 0; font-size:10px; font-weight:600;
  letter-spacing:.12em; text-transform:uppercase; color:var(--muted);
}
/* The rule between a screen and the extra STATES of that screen rendered under
   it. Sandbox-only, inside the markers, and it is the reason those states exist
   on the page at all: the glass and the Anton scale break precisely on the em
   dash, on `unavailable` and on a refused login, not on the happy path. */
.fxlabel{
  display:flex; align-items:center; gap:10px;
  margin:44px 0 0; font-size:10px; font-weight:500; letter-spacing:.14em;
  text-transform:uppercase; color:var(--muted);
}
.fxlabel::after{ content:''; flex:1; height:1px; background:var(--hair-soft); }

/* ================= HERO-TYPE ================= */
/* Dueño: hero-typographer (SPEC #18c — Steel Spring; corregido en el #22).
   Solo ese rol edita esta sección, static/herotype.js y el bloque hero-type de
   pages/login.html.
   Sin media queries de anchura a propósito: todos los tamaños derivan de
   --rx-h1, que ya cambia en el bloque Breakpoints — así este bloque no
   duplica el corte de 768px que ese bloque declara único.

   HISTORIA CORTA DE LO QUE YA NO ESTÁ. El #22b hizo el lockup WIDTH-MATCHED
   (L&R escalada hasta rasar con STRUCTURAL: 3.30× su cuerpo, y de ahí el "hace
   mucho ruido visual" del #23e); la ronda 3 mató el flush y lo sustituyó por una
   jerarquía de FRACCIONES de la medida (L&R = 1/4, es decir --ht-m/6.1168, o sea
   0.757× el cuerpo de STRUCTURAL). El #24c mata también esa jerarquía. De todo
   aquello sobrevive UNA cosa, la que sostiene la geometría entera: la MEDIDA.

   #24c — EL PATRÓN BRANDLY DEL TEMPLATE GUÍA (Jordi, con la captura del hero de
   motionsides delante: "remember the guide lines from the template, this is what
   i'm expecting for the hero"). El patrón, leído de la captura "BUILDING /
   BRANDS THAT / RESONATE": titular apilado, TODAS las líneas al MISMO cuerpo,
   condensada pesada, interlínea muy apretada, alineado a la izquierda y con
   ANCHOS NATURALES — la línea corta queda corta y no rasa nada. Un bloque macizo
   de tipografía; la jerarquía la hace el conteo de glifos, no la escala.

   Aplicado aquí: L&R y STRUCTURAL comparten cuerpo, tracking e interlínea. L&R
   queda discreta porque tiene 3 glifos (30.9% de la medida), que es exactamente
   lo que pedía el #23e ("el L&R más pequeño") sin inventar una escala falsa.

     --ht-fs  sigue siendo el ÚNICO mando: el cuerpo contra --rx-h1 (.94).
     --ht-m   sigue siendo LA MEDIDA = la tinta de STRUCTURAL = 4.6327 × cuerpo.
              (4.6327 = 4.5127 de avances Anton + 10 × .012em de tracking;
               verificado célula a célula: 435.30px medidos contra 435.47
               previstos a cuerpo 94.) La medida NO se mueve en el #24c, y por
               eso siguen valiendo sin re-abrir el estudio del acantilado de
               44vw, el pin de la columna (CLS) y el reparto de la fila.
     Las dos líneas → --ht-m / 4.6327 · interlínea .88 · tracking .012em.
     PERFORMANCE. rasa 3/5 de la medida → --ht-m / 16 (15.9925 = 9.5955 / .6).

   Números medidos en el login real a --rx-h1 = 100px (1440×900), antes→después:
     L&R          71.19 → 94.00px (+32.0%)  tinta 108.88 → 134.45 (30.9% de m)
     STRUCTURAL   94.00 → 94.00px (=)       tinta 435.30 = la medida (sin tocar)
     interlínea   .95/.86 → .88/.88         caja de línea 67.63/80.83 → 82.72 ×2
     slogan       30.26 → 27.22px (−10.0%)  3 líneas: 213.00 · 163.92 · 261.09
                  (261.09 / 435.30 = 0.5998 ≈ 3/5, la fracción)
     separación   26 → 32px    alto del bloque 290.61 → 303.56px (+4.5%)
   La barra de sign-in NO se mueve (top 705px antes y después): la franja que
   crece se la come `.crane`, que es flex:1 — el alto del documento sigue en 900.

   POR QUÉ .88 Y NO .95. Con los dos cuerpos iguales, la interlínea es lo único
   que decide si el bloque es un muro o dos frases: a .95 queda un canal de aire
   entre las mayúsculas y el bloque se lee en dos tiempos; a .88 las cajas miden
   82.72px sobre una altura de mayúscula de ~68.6px (0.73em de Anton), o sea 14px
   de canal — las líneas casi se tocan, como en la captura, y todavía no hay
   colisión posible porque Anton no tiene descendentes en caja alta. Probadas
   .92, .90 y .88 en el login real (it-A/D/E): .88.

   POR QUÉ EL TRACKING ES ÚNICO. L&R traía .045em de la época del width-match
   (era el tercer sumando de aquel 6.1168) y a igualdad de cuerpo eso rompía el
   bloque: dos líneas con dos ritmos. Probada la variante con .03em en L&R
   (it-F): la marca se despega del muro. .012em en las dos, una sola voz.

   EL CUERPO SE QUEDA EN .94 (94px). Se probó ×1.00 (it-C: cuerpo 100px, medida
   463.27) y, aunque el muro gana masa, mueve la medida y con ella todo el
   estudio del acantilado móvil y el reparto de la fila; la r3 ya había medido
   que ×1.04 (medida 482) mete STRUCTURAL bajo la contraflecha. Con las DOS
   líneas al cuerpo de STRUCTURAL el bloque ya pesa el doble que en la r3 sin
   tocar un solo número de la geometría heredada.

   LA COLUMNA VA CLAVADA A LA MEDIDA, y eso es lo que pone el CLS en 0. htcol era
   width:max-content, o sea su ancho lo fijaban las métricas de la fuente: al
   entrar Anton la columna cambiaba de ancho, empujaba la caja de .crane y el
   layout-shift lo cobraba el hero. Medido en el login real, mismo CSS salvo el
   pin: 0.0459 sin pin → 0.0000 con pin (y la línea base de antes del cambio ya
   traía 0.0237, todo atribuido a `crane`). El pin es exacto a 0.17px sobre 435.
   Si Anton no cargara, STRUCTURAL desbordaría la columna en vez de re-maquetarla
   — con nowrap ya no reflota nada y el FOUT deja de mover la grúa. Medido con
   las webfonts bloqueadas: la columna se queda clavada en 435.47 y la palabra en
   fallback sale a 634px, o sea desborda 199px y NO re-maqueta nada.

   EL TOPE DE 44vw ES GEOMÉTRICO, NO ESTÉTICO — y sin él el titular se rompe para
   siempre en teléfono. El escenario del #19 pone la grúa fixed a TODA el área,
   así que su eje de giro cae en el centro del viewport y la raíz de la pluma es
   un punto que NO se mueve al girar. Si la tinta del titular llega hasta ese
   punto, la raíz queda DENTRO de una letra en todos los ángulos: el director
   sostiene acquire() y las letras no vuelven a casa jamás. Medido en el login
   real conduciendo el yaw 0→360° con poseAt, a 375px, en % de la revolución con
   el titular en casa ("íntegro"):
       medida 130px → 36%   165 → 33%   185 → 32%   200 → 30%
       medida 205px →  0%   210 →  0%   212 (sin tope) → 0%  ← el acantilado
   El acantilado está entre 200 y 205; 44vw deja la medida en 165px a 375, un 18%
   por debajo. Referencias: el titular ANTERIOR daba 28% de íntegro a 375 (o sea
   el teléfono ya era malo antes: la grúa ocupa la pantalla entera), y a 1440 el
   nuevo da 76% contra el 73% del anterior — arco roto 88° contra 96°, empuje
   máximo 69px contra 98.7px. En desktop, pues, MEJORA; en teléfono pasa de 28% a
   33% Y ADEMÁS STRUCTURAL crece de 26.5 a 35.6px (+34%).
   El tope solo muerde por debajo de ~640px de viewport (a 768 pide 338px y la
   medida natural son 278.7): no hay media query porque no hace falta, la
   comparación la resuelve min() sola. */
.login .htcol{
  --ht-fs:.94;
  --ht-m:min(calc(var(--rx-h1) * var(--ht-fs) * 4.6327), 44vw);
  width:min(var(--ht-m), 100%);
  flex:0 0 auto; display:flex; flex-direction:column; min-width:0;
}
.ht-lock{ width:max-content; cursor:default; }
/* LAS DOS LÍNEAS SON LA MISMA VOZ (#24c): un cuerpo, un tracking, una
   interlínea, y la regla que los declara es UNA. Los modificadores --1/--2
   siguen en el markup como asideros (el stage y el motor parten por [data-split],
   no por ellos), pero ya no llevan tipografía propia: cualquier declaración por
   línea sería, por definición, una jerarquía de escala, que es justo lo que el
   patrón del template prohíbe.
   El cuerpo cuelga de la MEDIDA y no de --rx-h1 para que el tope de 44vw baje
   las dos líneas y el slogan a la vez y las fracciones sigan valiendo dentro del
   tope. A --rx-h1=100 da los mismos 94px que --rx-h1 × --ht-fs, porque ahí el
   tope no muerde. */
.ht-lock .ht-ln{
  display:block; white-space:nowrap;
  font-size:calc(var(--ht-m) / 4.6327); line-height:.88; letter-spacing:.012em;
}
/* El & en el rojo de MARCA — el mismo pigmento que rellena el pill de sign-in,
   el mismo en los dos temas (#22a). --accent-ink se descarta: en dark vale
   #ff7a70, un salmón que es la respuesta correcta para un link de 12px y una
   respuesta falsa para un glifo de 200px, y era justo lo que desentonaba.
   A este tamaño el listón que aplica es el de texto grande de WCAG 1.4.3 (3:1),
   no el de texto corrido: medido contra el fondo real, 4.44:1 en light y
   4.35:1 en dark — 1.45× por encima del listón en ambos.
   Re-medido con la geometría del #24c (el & ahora es un glifo de 94px, no de
   200: el cuerpo de L&R y el de STRUCTURAL son el mismo) muestreando los píxeles
   rojos de su caja contra el fondo pintado: 4.13:1 en light y 4.12:1 en dark,
   1.37× el listón. */
.ht-lock .ht-amp{ color:var(--accent); }
/* Célula por letra (la escribe herotype.js): transform-only, compositor puro.
   will-change SOLO mientras algo anima — .ht-live la ponen y la quitan el motor
   y el stage. Permanente, las 13 células tenían capa propia hasta en reposo y el
   texto no se rasterizaba igual. Dos medidas, las dos por diferencia de captura
   a 2×: en banco aislado sobre blanco plano, célula promocionada contra run
   continuo da la misma masa de tinta (0.001% de diferencia) con −21% de píxeles
   de antialias; en el login, promocionada contra no promocionada, 8671 píxeles
   del titular difieren en más de 8 niveles (máximo 135, media 2.40/255) y la
   masa de tinta se mueve un 1.1%. El signo depende del fondo — el del login no
   es plano —, pero que el reposo se dibuja distinto no depende de nada. Era el
   otro medio grado del #22b. */
.ht-lock .ht-c{ display:inline-block; }
.ht-lock.ht-live .ht-c{ will-change:transform; }
/* El complementario. Baja del H1 a rótulo editorial: el eco tipográfico de
   hd__meta (uppercase, tracking ancho, muted), en la columna del titular.
   Entra en el stagger entre el h1 (.2s) y la barra (.3s).
   #22c lo sube de 12px a 15px (+25%) para que ocupe el hueco bajo el lockup: el
   tracking se queda en .14em porque es el ADN que hereda de hd__meta, y sigue
   sin disputarle nada al titular (1/13 de la línea L&R, 1/4 de STRUCTURAL).

   #23e ronda 2 lo subió de 15 a 17.8px y lo partió en DOS líneas contra la
   medida del titular. Ronda 3, Jordi: "el slogan aun mas grande apilado".

   #23e RONDA 3 — SEGUNDA VOZ, TRES LÍNEAS APILADAS. Ya no es una entradilla que
   rasa: es un bloque. 17.8 → 30.26px (+70%) y de 2 líneas a 3. El cuerpo no se
   elige a ojo, cuelga de la medida igual que el titular: PERFORMANCE. (la línea
   larga) rasaba 2/3 de --ht-m, o sea --ht-m/14.393.

   #24c — SE ARMONIZA CON EL MURO. El apilado en 3 se conserva (a Jordi le gustó)
   pero el titular ha doblado su masa, así que la segunda voz baja un peldaño
   para que el conjunto lea como el template: bloque macizo arriba, bloque menor
   debajo. La fracción pasa de 2/3 a 3/5 de la medida (--ht-m/16, siendo
   15.9925 = 9.5955/.6): 30.26 → 27.22px (−10%), medido 261.09px de PERFORMANCE.
   sobre una medida de 435.30, o sea 0.5998 contra el 0.6000 de la fracción. La
   relación con el titular cae de 30.26/94 = 0.322 a 0.290, y la interlínea sube
   de 1.28 a 1.30 (la franja se queda en 106.13px de alto). La separación entre
   los dos bloques sube de .26 a .32 de --rx-h1 (26 → 32px): sin ese aire extra,
   con el titular más alto, el slogan se leía pegado al muro en vez de debajo.

   LAS TRES LÍNEAS ESTÁN GARANTIZADAS POR CONTEO DE PALABRAS, no por métricas.
   La frase son tres tokens indivisibles (EXCELLENCE · THROUGH · PERFORMANCE.),
   así que el navegador solo puede romperla en los DOS espacios: los únicos
   resultados posibles son 1, 2 o 3 líneas, nunca 4. Basta con que max-width sea
   menor que la más estrecha de las dos parejas para que SIEMPRE salgan 3.
   Medido en Inter a .14em: EXCELLENCE 7.826em, THROUGH 6.026em, PERFORMANCE.
   9.5955em; la pareja más estrecha es "EXCELLENCE THROUGH" ≈ 14.25em. Con
   max-width:10em el margen es del 30%, así que aguantaría una fuente de
   fallback un 40% más ancha que Inter sin perder el apilado. Si el fallback
   fuera más ancho que 10em la única consecuencia es que PERFORMANCE. desborda la
   caja — sigue habiendo 3 líneas y el alto del bloque no cambia, que es lo que
   protege el CLS. Nada de <br> en el markup: fijaría el corte en un ancho y el
   H1 dejaría de ser una frase limpia y seleccionable.

   EL SUELO SUBE DE 14 A 16px. Con el tope de 44vw la medida en teléfono es
   pequeña (165px a 375), así que el suelo manda por debajo de ~600px de
   viewport: de 320 a 414px el rótulo se queda clavado en 16px apilado en 3,
   contra los 14px en 2 líneas de la ronda 2. Por debajo de 349px PERFORMANCE.
   (153.5px a cuerpo 16) es más ancha que la medida y sobresale de la columna: a
   320px sobra 12.7px sobre una columna de 140.8px, y aun así se queda 126px
   dentro del ancho útil de la página. Medido de 320 a 2560: nunca desborda.

   CONTRASTE — LA MEDICIÓN DE UNA POSE ERA UNA MENTIRA (re-entrada gates p3).
   Aquí decía "4.71:1 en light y 6.30:1 en dark" y los dos números eran ciertos
   Y ENGAÑOSOS: estaban tomados con la grúa en UNA pose. Conduciendo el yaw
   0→360 de 15 en 15 con `__crane3d.poseAt` el rótulo se desploma a 1.64:1
   (light, yaw 165°) y 2.03:1 (dark, yaw 150°) — los 4.66/6.12 que yo había
   medido son, exactamente, el yaw 0. El design-reviewer lo cazó en render.

   ERAN DOS AVERÍAS, NO UNA, Y CADA PARCHE SOLO ARREGLA LA SUYA. Medidas por
   separado a 1440×900, barrido completo, con la MASA DE TINTA (nº de píxeles
   en que el render difiere del fondo) como detector de oclusión:
     tal cual         ratio 1.64 / 2.03   masa 11577→17711  (35% del texto BORRADO)
     solo el chip     ratio 1.65 / 2.04   masa 23154→35422  (sigue borrándose)
     solo el z2       ratio 1.64 / 2.03   masa 17209→17711  (masa PLANA: ya no
                                          se borra, pero sigue ilegible)
     z2 + chip        ratio 4.65 / 6.14   masa PLANA a 17711 / 35422
   1) OCLUSIÓN. El canvas del #19 es `position:fixed; z-index:1` y el escalafón
      del stage sube a z2 `.hd .sub .h1 .stats .tabs` — pero el rótulo es
      HERMANO de `.h1` dentro de `.htcol`, no descendiente, así que ningún
      selector lo alcanzaba: era el único trozo de contenido del login que se
      quedó DEBAJO de la grúa, y a ciertos yaws la contrapluma lo borra. Un
      chip no puede arreglar esto: un fondo puesto debajo del canvas lo tapa la
      grúa igual que a la tinta (la fila "solo el chip" lo mide: 1.65).
   2) CONTRASTE. Ya delante, la máquina sigue pasando por DETRÁS y el amarillo
      del jib bajo tinta `--muted` da 1.64. Eso sí es el chip del SPEC #4, el
      mismo `--rx-chip-hi` (.94, no .85) y por la misma razón aritmética que
      escribió LOGIN-1A para `.sub`/`.stats`/`.hd__meta`.
   Por eso van las DOS cosas. Lo que se pierde es el sándwich (la grúa ya no
   pasa por delante del rótulo) y no había manera de conservarlo: "que la grúa
   lo tape" y "que se lea" son la misma frase con dos signos. Queda el consuelo
   medido de que a .94 la máquina se transparenta a través del chip, así que
   el rótulo lee como un rótulo sobre un cristal y no como un recorte pegado.

   POR QUÉ EL CHIP ES POR LÍNEA Y NO UN BLOQUE. Un fondo en el `<p>` es un
   rectángulo del ancho de la línea más larga: mata el RAG — los anchos
   naturales 213.00 / 163.92 / 261.09 — que es justo el ADN del patrón Brandly
   del #24c. Probadas las tres a yaw peor: bloque, pastilla por línea y caja
   por línea dan el MISMO ratio (4.65 / 6.14), así que la elección es
   tipográfica y no de contraste. El bloque parece un post-it; la pastilla
   entera escalona el borde izquierdo (tres extremos redondos apilados a 2.38px
   dejan muescas). Gana la tercera: IZQUIERDA A ESCUADRA, derecha redonda.
   Así el borde izquierdo de los tres chips cae en x=44 — la MISMA vertical que
   L&R y STRUCTURAL—, o sea el muro de tipografía y el rótulo comparten espina,
   y el rag se lo queda el lado derecho, que es donde el patrón lo quiere.
   No es "un chip por etiqueta": son tres fragmentos de UNA frase separados
   2.38px, que a esa distancia leen como un bloque escalonado, no como tres
   rótulos (que es lo que LOGIN-1A evitaba al unir las dos líneas de `.stats`).

   EL PADDING VA SOLO A LA DERECHA, Y ESO ES SAFARI, NO GUSTO. Con
   `box-decoration-break:clone` los motores NO reparten igual las decoraciones
   del borde de inicio. Medido en el login real, mismo CSS, chips x/ancho:
     padding 0 14px + margin-left −14px   Chromium  30/241.0  30/191.9  30/289.1
                                          WebKit    30/241.0  44/178.0  44/275.1
   es decir: en Safari el margen y el padding IZQUIERDOS solo se aplican al
   PRIMER fragmento y las otras dos líneas se desalinean 14px. Con el padding
   únicamente a la derecha los dos motores coinciden a menos de 0.1px:
     padding 0 16px 0 0                   Chromium  44/229.0  44/179.9  44/277.1
                                          WebKit    44/229.0  44/180.0  44/277.1
   El borde izquierdo a escuadra no es solo estética: es lo que hace que la
   ausencia de padding-left no se note y que el diseño sea portable.
   `-webkit-box-decoration-break` va delante porque WebKit NO reconoce la
   propiedad sin prefijo (`CSS.supports('box-decoration-break','clone')` es
   false ahí y true en Chromium: medido, no supuesto).

   CLS 0 POR CONSTRUCCIÓN. El chip no toca una sola métrica de layout: el fondo
   y el padding HORIZONTAL de una caja inline no alteran la altura de la línea,
   así que la franja del rótulo sigue midiendo 106.13px (106.08 en WebKit, que
   es redondeo de fuente, no un movimiento) y el bloque del hero no se mueve.

   El listón. A 27.22px el rótulo es "texto grande" de WCAG 1.4.3 (el listón
   son 18.66px), donde bastaría 3:1 — se le sigue exigiendo el de texto
   corrido, 4.5:1, y ahora lo pasa EN EL PEOR YAW de todo el barrido y en los
   dos temas. El techo de `--muted` sobre blanco puro es 4.76:1, así que el
   4.65 medido es el 97.7% de todo lo que esta tinta puede dar. */
.login .ht-tag{
  /* El peldaño z2 del escalafón del stage (#19/#20), que el rótulo nunca
     recibió por ser hermano de `.h1` y no descendiente. Sin él la grúa le
     pasa POR ENCIMA y ningún fondo lo salva. */
  position:relative; z-index:2;
  margin:max(18px, calc(var(--rx-h1) * .32)) 0 0;
  /* sin techo: --rx-h1 ya es un clamp que topa en 100px, así que --ht-m/16 topa
     solo en 27.22px. El único límite propio es el suelo. */
  font-size:max(16px, calc(var(--ht-m) / 16));
  line-height:1.30; max-width:10em;
  font-weight:500; letter-spacing:.14em;
  text-transform:uppercase; color:var(--muted);
  animation:rise .8s var(--ease) .26s both;
}
/* El chip, uno por línea. Inline a propósito: es la única caja que se fragmenta
   con el texto y puede seguir el rag. */
.login .ht-tag__i{
  -webkit-box-decoration-break:clone; box-decoration-break:clone;
  background:var(--rx-chip-hi);
  padding:0 16px 0 0;
  border-radius:0 999px 999px 0;
}
@media (prefers-reduced-motion:reduce){
  .login .ht-tag{ animation:none!important; }
  .ht-lock .ht-c{ transform:none!important; }
}

/* ================= Breakpoints =================
   ONE block per side of 768px, at the end, so an override can only lose to
   something written after it in the same block. Same rule as hub.css.
   Two more blocks join them on pass 3 and neither one is a second width system:
   `max-width:480px` is the PHONE inside the phone (the two things that only
   break when the screen is one column wide), and `max-height:700px` is the
   dimension this layer had never asked about — see each one. */

/* ---------- Phone ---------- */
@media (max-width:767px){
  /* 18px of blur over a full-width bar is the one paint that drops frames on a
     mid-range phone while the crane is running rAF next to it. */
  :root{ --glass-blur:12px; }
  /* The bar still cuts into the crane, by less: at 375px the zone is ~200px
     tall and 50px of it is the ground line, the base and the foot of the
     tower — the whole bottom of the drawing behind a bar that is 4 rows tall
     here instead of one. */
  .login .bar{ margin-top:-22px; }
  /* 260 was the desktop's floor spent on a screen that cannot afford it: on the
     phone the bar is four rows and the tabs are two, and at 375x812 the zone's
     last 60px were pushing the `Sign in` button past the fold (measured: button
     bottom 852 against a 812 viewport, `elementFromPoint` on its centre
     returning null). At 200 the button lands at 792 and the zone still holds the
     ground line, the base and the foot of the tower for the photo fallback.
     A floor, not a height: `flex:1` still gives the zone every pixel the page
     does not need, which is why nothing moves on a tall phone. */
  .login .crane{ min-height:200px; }
  /* delta pasada 2: G (design) — LA LÍNEA DEL PORTAL, CON SU RESPALDO. Misma
     familia que el chip de SPEC-DECISIONS #4 y la misma aritmética que escribió
     LOGIN-1A para `.sub`/`.stats`/`.hd__meta`: `--muted` es rgba(0,0,0,.55) y su
     techo sobre blanco puro es 4.76:1, así que basta con que el fondo baje un
     poco para que el listón de 4.5 quede fuera de alcance sin tocar la tinta.
     Aquí el fondo lo baja la TORRE de la grúa, que pasa por detrás de la barra
     de sign-in: medido a 375x812 con emulación de teléfono, barriendo el yaw
     0->345 de 15 en 15 con `__crane3d.poseAt` y el percentil 5 de los ratios
     píxel a píxel sobre la caja de tinta, 4.22:1 en light. No se mueve con el
     yaw porque la torre no gira — es el mástil, no la pluma. A 1440 la misma
     medida da 4.70 y 6.12, o sea el escritorio no lo necesita y por eso la
     regla vive aquí y no en LOGIN-1A: es la geometría del teléfono la que mete
     el mástil detrás de esta línea.
     Ni menos blur ni tinta más pesada, que son las dos cosas que #4 prohíbe:
     un respaldo local, del ancho del texto y con el margen negativo que le
     devuelve su sitio, así que no se mueve un carácter. */
  .login:not(.login--auth) .bar__portal{
    width:max-content; max-width:100%;
    background:var(--rx-chip-hi); border-radius:999px;
    padding:3px 12px; margin-left:-12px;
  }
  .rx-devnav{ right:10px; bottom:10px; }
  .rx-devnav__p{ max-width:min(300px,calc(100vw - 44px)); }
  /* The console is the admin's laptop screen and it says so (UX-BRIEF 2.8), but
     the H1 still has to leave the first card above the fold on a phone. */
  .pg-admin .display{ font-size:1.75rem; }
  /* delta pasada 2: A (tester) — SIGN OUT VUELVE DENTRO DE LA PANTALLA.
     El toggle de tema (44px + 10 de gap) es ancho que esta capa AÑADE a
     `.nav-right`, y la fila no envuelve ni encoge: con la presencia visible
     (2 avatares = 76px) empujaba `.nav-logout` fuera del viewport — astilla
     visible medida 0px a 320, 19.1 a 360, 34.1 a 375, contra 53.4/77.4/81.2 en
     main. `.user-role` NO es el ancho a recortar: hub.css:107 ya lo tiene en
     `display:none` y solo lo enciende a partir de 768 (hub.css:1211), así que
     bajo 768 la única cadena que crece con el nombre de la persona es
     `.user-name` — 128.6px de píldora medidos con "Pat Engineer". Fuera el
     nombre, la inicial del avatar sigue diciendo quién ha iniciado sesión y la
     píldora vuelve a ser un círculo (de ahí el padding parejo). El nombre
     completo vuelve intacto en cuanto hay ancho: esta regla muere en 768. */
  .user-pill .user-name{ display:none; }
  .user-pill{ padding:5px; }
}

/* ---------- Desktop ---------- */
@media (min-width:768px){
  :root{ --rx-h1:clamp(64px,7.5vw,100px); }
  .login .hd{ padding:22px 44px; }
  .login .hd__meta{ display:flex; }
  .login .main{ padding:18px 44px 30px; }
  .login .row{ flex-direction:row; gap:24px; }
  .login .stats{ display:flex; }
  /* THE WRAP IS THE LOGIN'S TOO, SINCE PASS 3. It arrived for the auth sheet,
     where an `.alert` or a `.bar__foot` with a `flex:1 0 100%` basis holds the
     single line open at full width and every field on it collapses (measured at
     1280 before it: `invite · refused` overflowing by 1533px, four fields at
     0px, page scrollWidth 2768). The sign-in row has no 100% item, so it was
     left out — and that was the wrong reading: with the 120px floor now on
     `.bar__field` the row simply cannot fit its five items between 768 and
     ~900px, and without a wrap it would overflow the card instead of breaking.
     Above ~950px nothing wraps and the row is byte-for-byte what it was:
     measured at 1280 and 1440, fields 309.4 / 389.4px, identical to pass 2. */
  .login .bar{
    flex-direction:row; flex-wrap:wrap; align-items:center;
    gap:28px; padding:20px 28px; margin-top:-50px;
  }
  /* The overlap eats the bottom of the ROW, and the crane is not the only
     thing down there: `.stats` is space-between over the full row height, so
     the 50px the bar climbs took "Turnkey Shell" behind it. The crane is meant
     to go behind the glass; the copy is not. */
  .login .stats{ padding-bottom:62px; }
  .login .bar__aside{ flex-direction:column; align-items:flex-end; }
  .login .tabs{ grid-template-columns:repeat(4,1fr); }
  .cx{ padding:56px 32px 140px; }
}

/* ---------- One column ---------- */
/* 480px, and only two things live here: both are cases where the page has
   exactly one column and something that behaves on a tablet stops behaving. */
@media (max-width:480px){
  /* The section row scrolls sideways under a fade (hub.css:545), and hub.css
     itself already wraps it from 768 up. Between the two, at 375, three of the
     six sections were off-screen behind the fade — including Material Order,
     which is the one carrying an attention dot: a mark nobody can see is not a
     mark. Measured at 375: scrollWidth 743 against clientWidth 333. The mask
     goes with the wrap, exactly as it does in hub.css's own 768 block —
     a fade over the last row of a wrapped set is a fade over nothing. */
  #dash .pills{
    flex-wrap:wrap;
    -webkit-mask-image:none; mask-image:none;
    /* delta pasada 2: B (ux + design) — EL RADIO SIGUE A LA FORMA DE LA CAJA.
       `.pills` hereda `overflow-x:auto` de hub.css:547, así que recorta a su
       propio borde redondeado. Con una sola fila eso es un óvalo de 56px de
       alto y el recorte cae fuera de las pastillas; envuelta, la caja mide 160
       y hasta 212px de alto y 999px se resuelve como un radio de 80-106px que
       muerde las esquinas: medido a 375, "Daily Report" 80 de 107.8px y
       "Dispatcher" 111 de 139.4px visibles, con `elementFromPoint` sobre el
       borde izquierdo de la primera y la última devolviendo el fondo de la
       página en vez de la pastilla — el enlace no solo se lee a medias, no se
       puede tocar por ahí. 14px es el radio de card del sistema y no mueve una
       sola pastilla: el wrap, el padding de 5px y la geometría se quedan como
       están. Solo aquí: por encima de 480 la fila vuelve a ser un scroller de
       una línea, donde el óvalo es la forma correcta, y en el wrap de 768 la
       caja es de 108px (radio 54, mordida de 2.2px sobre el eje de la primera
       pastilla: medido, por debajo del ruido del propio muestreo). */
    border-radius:14px;
  }
  /* THE DEV NAV OFF THE PRIMARY BUTTON. Sandbox chrome, never ported, and at
     375 it was sitting on 86px of the 289px-wide `Sign in` — 30% of the one
     control this page exists for, with `elementFromPoint` returning the nav on
     two of the button's corners. Both bottom corners are the same trap: under
     480 the bar is full width, so bottom-left covers the other end of the same
     button. It goes up instead, beside the lockup — measured free at 375x553,
     375x812 and 390x844, and it opens downward into the crane zone, where the
     only thing under it is the machine.
     `.login ~` and not `.rx-devnav`: on the interior pages the bottom-right
     corner is empty and the top-right one is the app nav. */
  .login ~ .rx-devnav{ top:118px; bottom:auto; }
  /* The written mark stops disappearing (it was `display:none` under 768) and it
     has to share 335px of header with a 46px lockup, a 44px theme button and the
     HUB pill. 15px and four pixels off each side of the pill are what it costs
     to keep all four on one line down to 320px, where at 17px the name wrapped
     and the header grew a row. */
  .login .hd__brand-text{ font-size:15px; }
  .login .hd__hub{ padding:9px 16px; }
}

/* ---------- Short viewport ---------- */
/* THE HEIGHT SAFARI ACTUALLY GIVES YOU. `100dvh` is honest about the browser
   chrome, but the login's content did not fit inside it and `.login` clips its
   own overflow: at 390x664 the document ran to 1038px and the EMAIL field ended
   at y=672 — eight pixels below a fold that cannot be scrolled to. The air is
   not in the copy, it is in `.crane`: 260px of reserved zone in a page where
   the stage has moved the canvas to a fixed, full-area layer. So the zone gives
   the height back and the drawing does not lose a pixel — the canvas is not
   inside that box any more.
   Not `.rx-stage .crane`: without WebGL the photo rig IS drawn inside the zone,
   so the floor drops to 96px instead of to zero and the fallback still has
   somewhere to stand. And `flex:1` means this only ever bites when the viewport
   is short — on a tall phone or a desktop the zone still takes what is left. */
@media (max-height:700px){
  /* Proportional and with a floor: 10vh is 66px on a 664 viewport and 56 on a
     553, which is the difference between the password field clearing the fold
     and not clearing it. Under the floor the zone stops being a zone. */
  .login .crane{ min-height:max(56px,10vh); }
  .login .hd{ padding-top:10px; padding-bottom:10px; }
  .login .h1{ margin-top:6px; }
  .login .main{ padding-top:6px; padding-bottom:12px; }
  .login .row{ gap:10px; }
  .login .tabs{ margin-top:10px; }
}
@media (max-width:767px) and (max-height:700px){
  /* Five gaps and 44px of padding in a bar that is a column here: the last 24px
     the field needs to clear the fold. Only under 768 — in the row layout the
     28px gap is what keeps the five items apart. */
  .login .bar{ gap:14px; padding:18px; }
}

/* ============================ STAGE (stage-director) ==========================
   SPEC #19/#20 — escenario full-área del login. body.rx-stage la pone stage.js
   SOLO con el 3D vivo (window.__craneStage); el fallback fotográfico jamás
   entra aquí y el login queda como estaba. El canvas de la grúa va fixed a
   toda el área (CSS inline de stage.js, dueño por contrato) en z1: encima del
   bgfx/watermark (z-1) y debajo de TODO el contenido. Escalera completa y
   dueños: design/STAGE-CONTRACTS.md.
   ========================================================================== */
/* La entrada `rise` anima transform: un ancestro animando transform se vuelve
   containing block del canvas fixed y lo desencuadra 1.4s. Con la grúa de
   telón full-área esa entrada de zona es además escénicamente falsa. */
.rx-stage .crane{ animation:none; }
/* z2 — el contenido vive EN la escena, delante de la grúa; el titular se
   rompe por transform de sus letras (hero), nunca por capas. */
.rx-stage .hd,
.rx-stage .sub,
.rx-stage .h1,
.rx-stage .stats,
.rx-stage .tabs{ position:relative; z-index:2; }
/* z3 — la barra sign-in y su form JAMÁS se rompen ni se tapan. */
.rx-stage .bar{ z-index:3; }

/* --- .alert as prose ------------------------------------------------------
   The add-project form's answer to an Airtable outage is three or four
   paragraphs now, not an 80-word block: measured at 1440 the old one ran to
   147 characters a line, twice a comfortable measure, and at 390 it was 11
   lines in one paragraph. It fitted on both screens and was read on neither.
   Additive, and hub.css stays frozen: the cap and the paragraph rhythm only.

   SCOPED, and that is the whole of this rule. `.alert` is in ten templates, and
   in the sign-in bar it is given `flex:1 0 100%` twenty lines below so that a
   refusal is a ROW OF ITS OWN above the fields — a max-width smaller than that
   row beats the flex-basis, the box stops filling the line, and the fields ride
   up beside it: on the invite card "Passwords do not match" went from a 1294px
   band over the boxes to a 590px cell next to Name and Email, and the two
   password fields fell to another row. Measured at 1440; at 390 nothing moved.
   The two containers named here are the ones this prose is rendered in, and the
   child combinator is what keeps it out of the bar. Same shape as
   `.cloneplan .alert` and `#airtable > .card__b > .alert.err` in hub.css, which
   cap their own two blocks and are left alone by this. */
.card__b > .alert,
.wrap > .alert{ max-width:72ch; }
.alert__p{ margin:0; }
.alert__p + .alert__p{ margin-top:.55em; }
/* The verdict, and only the verdict: weight instead of a colour, because every
   serious message on this console is already read through --bad-ink. */
.alert__lede{ font-weight:500; }
