/* styles/base.css — palette, reset, typography (GDD §7, sprite style guide) */

:root {
  --bg: #0d0b08;
  --bg-raise: #171310;
  --panel: #241d15;
  --line: #3a3226;
  --line-soft: #4a4034;
  /* Map STRUCTURE — the roads and plain rings a player reads to plan a run
     (#45, Sunna's ruling: 3:1 on each glyph's identifying boundary against each
     adjacent rendered colour). --line-soft's atmospheric value measures 1.93
     (edge vs field) / 1.72 (ring vs its own fill) from rendered pixels — below
     every defensible floor — but its 27 other consumers are text-adjacent soft
     lines that keep the atmospheric look ("atmospheric elsewhere"). So the map
     graph rides its own token: #7a6b54 clears both adjacencies with margin
     (rendered 3.78 vs field, 3.36 worst adjacency) while staying the visibly
     dimmer sibling of high contrast's 4.19 — two settings, two looks.
     Evidence: tools/contrast-audit.mjs --profile hi-contrast-off;
     docs/archive/proposal-45-atmospheric-map-structure.md (R2). */
  --map-structure: #7a6b54;
  --parchment: #e8dcc0;
  --muted: #8e826a;
  --gold: #c9a227;
  --accent-rgb: 201, 162, 39; /* --gold as rgb, for glow/halo alpha (swapped by accent theme) */
  --blood: #8a1a1a;
  /* The HP resource fill — the approved hybrid's owner pixel (2026-08-13,
     claude-family falk-family hybrid-confirmation, F1-resource-health.png),
     minted as its OWN token so the accent themes keep owning the palette
     (cb-safe swaps it below). Before this token the hp row wore var(--blood)
     and the conflict was recorded, not resolved: token vs owner hex. Now both
     hold — the value is the owner's, the swap is the theme's. --blood keeps
     its 16 other consumers (map rings, danger buttons, enemy hpbars) and is
     NOT this token. */
  --res-hp: #a43c35;
  --rot: #b5541c;
  --frost: #7fa8c9;
  --grace: #9fc3e8;
  --ember: #e46a44;
  --green: #7fd97f;
  /* THE KEYWORD TINT CARD TEXT USES, and it is a token because it is a fact
     about a WORD, not about a container. It had no token, so the one palette
     that wanted a different answer had nowhere to put it and put it in a
     SELECTOR instead (`body.cb-safe .card .ctext .st-bleed`, deleted below).
     When the five mark rules re-keyed `.card .ctext` → `.ctext` so the Smith
     preview could wear them too, every token-based rule came through for free
     and that one hardcoded hex stayed behind on the old container — so under
     Colorblind-friendly the SAME WORD read reddish-purple on a card face and
     vermillion in the preview. The hex was the reason it could not follow.
     Derived from --ember, not restated: in the default palette Bleed wears the
     ember hue TODAY, and that is a derivation, not a copy — one home for the
     value. Colorblind-friendly overrides it once, below, which is the only
     place the two diverge.
     OPEN, AND FREJA'S, NOT MINE: whether the DEFAULT palette should diverge
     too. It does not today — a Bleed keyword and a number the upgrade made
     WORSE both render rgb(201,80,46) on every screen, one hue doing two jobs
     on one line. Colorblind-friendly already answers that question `yes`. */
  --bleed: var(--ember);
  /* THE TAP-TARGET FLOOR, and it has one home because it is one fact.
     Sunna's ruling under Law 4 (#37): "a floor of N device px measured after
     zoom; a floor is not a ceiling." The whole app is scaled by `zoom:
     var(--ui-zoom)` on <body>, so a length L below body renders at L x zoom
     device px — dividing by the zoom here is what makes N mean N ON GLASS
     rather than N in a coordinate space the finger has never heard of. It is
     the same conversion the rest of the app does through fx.js, expressed as a
     unit so a rule can just use it.
     px, DELIBERATELY, and this is exactly the case Law 2 admits: a finger does
     not get bigger when a player raises Text size. Law 4 is the other half of
     the same sentence — text scaling must stop scaling non-text UI — so a
     control that held text at 4.4rem was wrong in two directions at once.

     `--tap-target` IS THE CONSTANT, AND IT IS NOT WRITTEN HERE. It is the
     player's Minimum tap size (Settings → Accessibility, default 44), written
     onto <html> by applyTapSize() in src/main.js exactly where applyUiScale()
     already writes `--ui-zoom`. This calc keeps its shape; only the number
     moved out of it, to `balance.ui.tapSize` — the one place a number a player
     can tune is allowed to live (Law 1 clause 2).

     AND THERE IS NO `, 44px` FALLBACK IN THIS LINE, deliberately. A literal
     here would agree with balance.ui.tapSize.def today and be synchronised by
     nothing — the second copy this house exists to catch, planted in the very
     line whose job is to hold one home. If `--tap-target` is ever unwritten
     the floor vanishes and every floored control drops to 21–31 device px,
     which is loud on the screen and is what `node tools/tapsize.mjs` measures.
     A quietly-correct-looking 44 is the failure Law 0 clause 5 names; a
     missing floor is not quiet. */
  --tap-floor: calc(var(--tap-target) / var(--ui-zoom, 1));
  /* THE ICON-BUTTON HIT BOX. Close and ☰ reserve the same tap-safe square on
     every component. The close control paints a smaller face inside that box,
     but its pointer, touch, and focus target remains this shared length:
     a floor lets a tall row or a big title stretch the control, and then the
     shape a player learned on one door is a different shape on the next.
     Width AND height read this; nothing else sizes them. It is the tap floor
     by value, so it still clears the floor at every UI size and zoom. */
  --iconbtn-size: var(--tap-floor);
  /* THE FLOOR, FROZEN AT THE DEFAULT — for the one control that is exempt from
     the floor it sets. Marina's ruling: Minimum tap size is the only control in
     the game whose resizing happens WHILE IT IS BEING PRESSED, so its own four
     chips hold still at the default and everything else on the screen still
     resizes under the player's eye. Measured cause: at the 44 step the pressed
     chip landed 61.44 device px from the finger, because the whole group
     re-lays-out — wider than a fingertip, and no `scrollTop` answers a
     horizontal reflow.
     Same single datum: applyTapSize() writes `--tap-floor-default-target` from
     balance.ui.tapSize.def, the same `def` the row's own default reads — not a
     second copy of 44, the same 44 asked a second question. Divided by the zoom
     for the reason --tap-floor is: an exempt control a small phone shrinks
     anyway is an exemption in name only. */
  --tap-floor-default: calc(var(--tap-floor-default-target) / var(--ui-zoom, 1));
  /* THE CORNER SCALE, AND WHY IT IS A CLOSED SET.
     Measured 2026-09-02 by driving 78 surfaces (every ?shot= state, every
     settings category, the quick menu, all three Armoury views) at 1440x900 and
     390x844 and reading computed style off every rendered button: TWENTY
     distinct border-radius values on one control layer — 0, 1.8, 2, 3, 4, 5.83,
     6, 6.5, 7.78, 8, 9, 10, 11, 12, 13, 14, 14.44, 50%, 999px and a
     two-corner pair. A player cannot read twenty roundnesses as meaning
     anything, so they mean nothing, and every new rule had twenty precedents to
     copy. This is the set instead. Snapping is BOUNDED: no declaration moved
     more than 2px, which is why the sweep could be mechanical and reviewed as a
     diff rather than re-judged surface by surface.

     FOUR STEPS, NAMED BY WHAT WEARS THEM, not by size — a rule should be able to
     ask "what am I?" rather than "how round?":
       --r-1  tags, keycaps, inline pills — the small stuff inside a row
       --r-2  small controls — segmented chips, inputs, icon buttons
       --r-3  buttons, cards, list rows, tiles — the things a finger presses
       --r-4  panels and modal shells — the things that hold the above

     THE ROLES ARE READ OFF THE TREE, NOT IMPOSED ON IT. Every step is a value
     the app already used most often for that job, which is what let the sweep
     stay inside 2px; a scale that argued with the design would have cost a
     re-judgement of all 168 declarations instead of a diff.

     REM RADII WERE A BUG THIS SET RETIRES, and it is the same defect Law 4
     already names in the Text size block below: a radius written in rem grows
     when the player raises Text size, so the title modal's corners were a
     function of the font. Non-text geometry is px here for that reason.

     THE `-glass` VARIANTS ARE NOT A FIFTH STEP. `zoom: var(--ui-zoom)` on
     <body> scales every length below it, so a control that must hold the SAME
     roundness on the screen at every UI size divides by the zoom — the same
     conversion --tap-floor makes above, for the same reason. Combat's HUD
     already did this by hand in five places; these are those five, named.

     NOT IN THE SET, deliberately: `45% 45% 55% 55%` on the refusal blob, which
     is a drawn shape rather than a corner, and `border-radius: 0`, which is a
     rule saying "square on purpose" and stays legible as a literal. */
  --r-1: 3px;
  --r-2: 6px;
  --r-3: 8px;
  --r-4: 12px;
  --r-pill: 999px;
  --r-round: 50%;
  --r-1-glass: calc(var(--r-1) / var(--ui-zoom, 1));
  --r-2-glass: calc(var(--r-2) / var(--ui-zoom, 1));
  --r-3-glass: calc(var(--r-3) / var(--ui-zoom, 1));
  --r-4-glass: calc(var(--r-4) / var(--ui-zoom, 1));
  /* CONTROL TYPE — FOUR STEPS, AND AN HONEST BOUNDARY ON HOW FAR THEY REACH.
     The same census that found twenty corner values found TWENTY-ONE font
     size/family pairs on buttons: Cinzel at 5.8, 7, 9, 10, 10.5, 11.5, 12, 13,
     13.5, 14, 15, 16, 17, 18 and 20px, and Inter at 9, 11, 12, 14 and 15 —
     across ONE control layer, with two typefaces sharing it. Some of those are
     one rule read at two UI zooms (16.67 and 20 are the title menu; 13.33 and
     13.5 are BACK/CONTINUE), which is honest scaling. The rest is drift.

     THESE FOUR ARE THE SCALE. `button` and `button.subtle` — the two rules every
     other control inherits from — now name a step instead of a number, and so
     do the modal action buttons, whose 1.35rem was an arbitrary point between
     two steps that already existed.

     WHAT IS NOT DONE, SAID HERE RATHER THAN DISCOVERED LATER: the ~40 component
     rules that override button type for their own geometry are UNSWEPT. A
     corner can be snapped mechanically because it changes nothing else; a font
     size reflows the box it is in, and those rules are entangled with grid
     templates and min-heights that were tuned around the current values. Each
     is a judgement against its own surface, not a find-and-replace, and doing
     them blind is how a type scale ships a regression. The scale is here so
     that work has somewhere to land. */
  --t-1: 1rem;
  --t-2: 1.2rem;
  --t-3: 1.5rem;
  --t-4: 1.8rem;
  --font-display: 'Cinzel', Georgia, 'Times New Roman', serif;
  --font-body: Inter, 'Segoe UI', system-ui, sans-serif;
}

/* Accessibility: high contrast raises text/line contrast by swapping tokens. */
body.hi-contrast {
  --parchment: #fdf6e6;
  --muted: #a89571;
  --line: #6a5b43;
  --line-soft: #85714f;
  --map-structure: #85714f; /* unchanged shipped value — hi-contrast map keeps its exact look */
}
body.hi-contrast .set-note, body.hi-contrast .card .ctext, body.hi-contrast .cz-keepsake p { color: #cdbf9e; }

/* Accessibility: Text size is set on the root element (main.js
   applyDisplaySettings → html font-size %) and owns readable text metrics only.
   Non-text geometry uses px or a UI-size-owned token; a box that contains text
   gets a fixed floor and grows intrinsically. Text size and --ui-zoom are two
   independent controls. */

/* Accessibility: colorblind-friendly semantic remap (Okabe-Ito hues chosen to
   stay distinguishable under deuteranopia/protanopia — danger→vermillion,
   heal→bluish-green, frost→sky, blight→orange, bleed→reddish-purple). */
body.cb-safe {
  --blood: #d55e00;
  --res-hp: #d55e00; /* the hp fill keeps tracking the danger hue (Okabe-Ito vermillion) */
  --ember: #d55e00;
  --green: #009e73;
  --frost: #56b4e9;
  --grace: #56b4e9;
  --rot: #e69f00;
  --danger: #d55e00; /* danger buttons track the same vermillion as --blood */
  /* Okabe-Ito reddish-purple — the SAME VALUE this palette already shipped,
     moved out of a selector and into the token block where a palette decision
     belongs. It now reaches card text wherever card text is drawn, which is
     the whole change: the card face is byte-identical and the Smith/coop
     upgrade preview stops calling a keyword by the danger hue. */
  --bleed: #cc79a7;
}

/* Accessibility: readable headings swap the decorative serif for the UI sans. */
body.readable-ui { --font-display: var(--font-body); }

/* Ambient effects: 'off' also stills the title glow pulse; 'high' brightens it. */
:root[data-ambient='off'] .title-glow { animation: none; text-shadow: 0 0 20px rgba(var(--accent-rgb), 0.2); }
:root[data-ambient='high'] .title-glow { animation-duration: 2.2s; }

* { margin: 0; padding: 0; box-sizing: border-box; }

/* Root text anchor: 62.5% makes 1rem = 10px at Text M. The Text-size setting
   overrides this value for type and line metrics; component geometry must not
   derive from this calibration. */
html { height: var(--visible-height, 100dvh); overflow: hidden; font-size: 62.5%; }
/* AIM AT THE FAR ENEMY, LEAVE THE GAME — Sunna's gate finding on #22, measured
   both edges x3. The straight line from the leftmost card to the far-right
   enemy runs at 38 deg, under the ~45-47 deg boundary where Chromium claims a
   drag as horizontal scroll. The hand rests at scrollLeft 0, so the claimed
   scroll has nothing to move and CHAINS TO THE VIEWPORT — and Chrome's
   overscroll navigation goes BACK, out of combat, mid-fight. `pan-x` on the
   cards is what routes the shallow aim into the horizontal machinery: the
   known-bad survived the same swipe 3/3, the first cut of the fix survived 0/3.

   THE RULE MUST SIT AT THE ROOT AND SHE MEASURED WHY: `overscroll-behavior-x:
   contain` on .hand does NOT close it — at scrollLeft 0 the hand cannot scroll
   rightward, so it is never the scroll target; the viewport is. Here, it closes
   3/3 with hand scrolling intact (150->200).

   BOTH AXES, DELIBERATELY, and the halves have different evidence: -x is
   measured (her backnav.mjs, both edges). -y is the SAME mechanism pointed the
   other way — pull-to-refresh reloading a live run off any vertical overscroll —
   declared here because this app is a fullscreen game with overflow:hidden at
   the root and NO surface where a browser navigation or refresh gesture is ever
   the player's intent; there is nothing for `none` to take away. Headless
   Chromium cannot raise pull-to-refresh, so the -y half ships as a declaration
   with this named boundary, not as a measured green. */
html, body { overscroll-behavior: none; }
/* PROTOTYPE (EldenSpire#23, track B) — 100dvh, and ONLY here.
   `dvh` was pitched as a sweep. It is wrong everywhere except this one line,
   and the reason is the one that has bitten this file all week. Measured under
   `body { zoom: 0.62 }` at 390x844: 100dvh, 100svh, 100lvh AND 100vh all
   compute to 843.98px and then render at 523 visual — vh-family units resolve
   against the UNZOOMED viewport and are then scaled, so inside the app `dvh` is
   exactly as wrong as `vh` (Marina's ruling, EldenSpire#15). `height: 100%`
   computes 1361.29px = 844/0.62, which is correct, which is why base.css was
   already right to use percent below <body> and still is.
   <html> is the one element the zoom does not touch, so this is the one place a
   viewport unit is evaluated in the room it means. `100%` here resolves against
   the initial containing block, which on a phone tracks the LARGE viewport —
   the tall one you get when the address bar is hidden — so the bottom of the
   board sits under the bar whenever the bar is up. `100dvh` tracks the current
   one.
   UNVERIFIED, and I want that loud: headless Chromium has no moving address
   bar, dvh/svh/lvh all measure identically here, and this harness therefore
   cannot show this line doing anything at all. It is the one change on this
   branch with no measurement behind it. On a desktop it is a no-op (measured:
   1200x730 identical either way), which is why it is safe to leave in and
   dishonest to call it verified. */

/* UI size: the whole app is zoomed by --ui-zoom (Settings → UI size / Auto, set
   in main.js). Sizing uses PERCENT heights (not vh) everywhere below `body` so
   they resolve against the zoomed box and stay exactly viewport-sized — scaling
   up never overflows, scaling down still fills. (vh ignores zoom, which is what
   used to overflow.) */
body {
  background: radial-gradient(ellipse 60% 40% at 70% 10%, rgba(201, 162, 39, 0.06), transparent),
    linear-gradient(#16110c, var(--bg));
  color: var(--parchment);
  font-family: var(--font-body);
  font-size: 1.4rem;
  overflow: hidden;
  user-select: none;
  zoom: var(--ui-zoom, 1);
  width: 100%;
  height: 100%;
}

/* EldenSpire#23/#24 — the narrow layout is selected by `data-layout` on <html>,
   written by main.js's resolveLayout() in the same call that chooses --ui-zoom.
   The stylesheets measure nothing.

   The threshold is evaluated in the unzoomed viewport room. It intentionally
   remains independent of the height-aware --ui-zoom so browser chrome and the
   on-screen keyboard cannot flip the composition while a player is reading it.

   The answer turned out not to be a container query either. Asking the question
   in CSS at all made two deciders out of one decision — the zoom judging
   innerWidth/innerHeight against 430x780, the stylesheet judging the container's
   local width against 520 — and on a tablet they disagreed and the fight became
   unadvanceable (#24, 834x1194: zoom picks narrow at 1.53, layout stays wide at
   545 local px, END TURN 0/45 where dev is 45/45). An attribute has no room
   problem because it carries no length, and it cannot disagree with the zoom
   because the same function writes both.

   Removing it also retires `container-type: inline-size` and with it the WebKit
   unknown Sunna bounded on this branch — whether a query container becomes a
   containing block for fixed descendants. Nothing here declares one now. */
#app { height: 100%; display: flex; flex-direction: column; }

h1, h2, h3, .display { font-family: var(--font-display); letter-spacing: 0.08em; }

button {
  font-family: var(--font-display);
  letter-spacing: 0.1em;
  background: var(--panel);
  color: var(--gold);
  border: 2px solid var(--gold);
  border-radius: var(--r-3);
  padding: 1rem 2.6rem;
  font-size: var(--t-3);
  cursor: pointer;
  transition: background 120ms, transform 120ms;
}
button:hover { background: #2e2517; }
button:disabled { border-color: var(--line); color: var(--muted); cursor: default; }
button.subtle { border-color: var(--line-soft); color: var(--parchment); font-size: var(--t-2); padding: 0.6rem 1.4rem; }

/* THE THREE EMPHASES, AND THE ONE THAT WAS MISSING.
   Measured with the corner census above: the tree had `button` (gold outline),
   `.subtle` (quiet outline) and `.danger` (red outline) — three ways to say
   "not the main thing" and NO way to say "this one". So a modal footer said it
   by accident instead: Load Game leaves BACK and CONTINUE the same size and
   weight and asks the border colour to carry the difference, and the in-run
   Settings footer paints Save and Quit in DANGER RED for an act that saves.
   `.primary` is the missing word. Red is now spent only on loss.

   ONE PER SURFACE is the rule this enables and does not enforce: nothing here
   can count the buttons in a footer. What it can do is make the emphasis a
   NAME a reviewer can see in the markup rather than a set of overrides they
   have to compare pixel by pixel. */
button.primary {
  background: color-mix(in srgb, var(--gold) 22%, var(--panel));
  border-color: var(--gold);
  color: var(--parchment);
}
button.primary:hover { background: color-mix(in srgb, var(--gold) 32%, var(--panel)); }
button.primary:disabled { background: var(--panel); border-color: var(--line); color: var(--muted); }

/* THE FOCUS RING, ONCE, IN THE HOUSE COLOUR.
   There was no app-wide rule, so anything without its own `:focus-visible`
   wore CHROME'S DEFAULT — a thick white double outline on a gold-on-brown
   panel. It is not hypothetical: settings.js:1591 focuses `.set-tab.on` on
   open, so opening Settings on a phone PAINTS it every time, and the census
   caught it on the 390x844 capture.

   `:where()` on purpose — specificity ZERO, so a component that must move the
   ring INWARD (a control inside a card, a tab) can set its own offset without
   out-ranking anything. The ink is never restated: `--focus-ring` (kit.css)
   is the one token, and the pad's cursor (`.gp-focus`, ui.css) wears the same
   ring. Since 2026-09-11 this is not a floor under fourteen rings — it IS the
   ring; the per-component box-shadow halos, 3px outlines and pale inks went. */
:where(a, button, input, select, summary, textarea, [role='button'], [tabindex]:not([tabindex='-1'])):focus-visible {
  outline: var(--focus-ring);
  outline-offset: var(--focus-ring-offset);
}

.bar {
  position: relative;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--r-2);
  overflow: hidden;
}
.bar > .fill { position: absolute; inset: 0; right: auto; transition: width 200ms; }
.bar > .label {
  position: relative; z-index: 1; text-align: center; font-size: 1rem;
  line-height: 1; padding: 0.2rem 0; color: var(--parchment); text-shadow: 0 1px 2px #000;
}

/* ---------- screen transitions (every mount fades in) ---------- */
#app > * { animation: screenIn 240ms ease-out; }
@keyframes screenIn {
  from { opacity: 0; transform: translateY(8px); }
  to { opacity: 1; transform: none; }
}

/* Accessibility: honor reduced-motion for ALL animation (GDD §6).
   TRANSITIONS ARE OFF, NOT SHORT. The usual snippet sets
   `transition-duration: 0.01ms` on `*`, and that is a transition on EVERY
   property — `transition-property` defaults to `all`, so a style write is
   followed by a one-frame transition from the old value, and a synchronous
   read straight after the write still sees the old value. The map camera
   does exactly that (mapboard.js `sizeSvg` sets the SVG's height, then reads
   the scroller's scrollHeight to clamp its landing): under reduced motion it
   read 1136 px against a 2515 px canvas and clamped the entrance frame 1120
   px short of the door, at every width, only for players with the setting
   on (measured 2026-09-11; without the setting the same code lands). Nothing
   in the app waits on `transitionend`, so `none` is the honest value. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition: none !important;
  }
}

/* Manual override from Settings — applies regardless of the OS preference. */
.reduced-motion, .reduced-motion *, .reduced-motion *::before, .reduced-motion *::after {
  animation-duration: 0.01ms !important;
  animation-iteration-count: 1 !important;
  transition: none !important;
}
