/* GENERATED FILE - DO NOT EDIT BY HAND.
   The source of truth is the <style> block of docs/hud-ui-design.html. Edit it there and
   re-extract from the repo root (adjust the line numbers if the block has moved; find them
   with: grep -n '<style>\|</style>' docs/hud-ui-design.html):

     sed -n '10,1945p' docs/hud-ui-design.html | sed -e 's#url(\.\./arts/ui-icons/\([a-z0-9-]*\)\.png)#url({{art:ui/\1}})#g' -e 's#url(\.\./arts/avatar-front/front\.0\.png)#var(--front-atlas)#g' > public/hud-ui/design.css

   Then put back the one hand edit the extraction wipes, and this header with it: the design
   page's own page chrome (the paper background, the padding, h1/h2/h3, p.note, .row, code,
   pre) is replaced below by a single body rule and the comment that explains it. Verify with:

     diff <(sed -n '10,1945p' docs/hud-ui-design.html) public/hud-ui/design.css

   which must show only this header, that block, the --front-atlas line and the {{art:ui/<name>}} lines
   (the token is filled by the server's page() with the content-hashed file, see src/shared/art.ts). */
/* =====================================================================
   Tamerland HUD design source of truth.
   Everything on this page is built from the tokens below. A component is
   one class plus modifiers; every dimension is in em so a component scales
   with its font-size and never needs a second drawing.
   ===================================================================== */

:root {
  /* paper */
  --paper: #f9f7f4;
  --paper-2: #f1ede6;
  --ink: #4a2b22;
  --ink-strong: #3d2018;
  --ink-mute: #8a6a5e;
  --ink-faint: #a9998f;
  --white: #ffffff;
  --shadow-ink: 60, 25, 12;            /* rgba(var(--shadow-ink), a): every drop shadow */
  --ink-wash: 70, 30, 15;              /* rgba(var(--ink-wash), a): hover and empty-slot washes */
  --error: #c0574a;

  /* THE ONE WINDOW SIZE. Every dimension in the window system - and every width
     and offset in public/place.css, which places windows and reads nothing else
     from here - is an em against this number, so this is the single knob that
     scales the whole window system. Lower it and all 34 windows get smaller
     together, in proportion, with no second drawing anywhere.
     Two things deliberately do NOT follow it: the always-on HUD (public/hud.css
     owns its own sizes - it is not a window and it is not this system), and the
     world layer's nameplates and damage numbers, which are px because they are
     drawn over the canvas at camera pixels. */
  --win-size: 12px;

  /* type: sans for everything; the display face is for dramatic moments only */
  --face-ui: "Rajdhani", "Nunito", "Segoe UI", system-ui, sans-serif;
  --face-weight: 600;
  --face-display: "Grenze", "Rajdhani", serif;
  --face-display-weight: 600;

  /* The cross, as a mask rather than as markup. A pseudo element cannot hold an
     <svg><use>, and the search field's clear control IS a pseudo element the
     browser owns, so the one glyph it needs lives here as a shape. */
  --x-glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3' stroke-linecap='round'%3E%3Cpath d='M5 5 19 19M19 5 5 19'/%3E%3C/svg%3E");

  /* engraving overlays: tone-neutral so they work on every face colour */
  --engrave-dark: rgba(70, 30, 15, .14);
  --engrave-light: rgba(255, 248, 240, .62);

  /* button: tan (default) */
  --btn-face-top: #c4977c;
  --btn-face: #b98a6e;
  --btn-face-bottom: #b07f64;
  --btn-rim: #9c6f57;
  --btn-text: #fbe6d0;
  --btn-text-shadow: rgba(60, 25, 12, .30);

  /* button geometry, all relative to font-size */
  --btn-point: .65em;                 /* depth of the pointed end */
  --btn-rim-w: max(1px, .06em);      /* hairline rim */
  --btn-ring: .2em;                  /* inset of the inner line from the edge */
  --btn-line-fade: 1.7em;            /* the inner line is gone by here, where the ornament begins */
  --btn-pad-x: 1.94em;
  --btn-pad-y: .6em;

  /* window chrome: face, rim, parchment header, tan selection */
  --win-face: #f6f3ee;
  --win-rim: rgba(74, 43, 34, .22);
  --rim: #fbf8f2;                      /* the window's pale outer band */
  --rim-in: #c2b7a1;                   /* the darker hairline just inside it */
  --line: rgba(120, 92, 62, .30);      /* the inner content line in the body */
  --win-shadow: 0 .5em 1.4em rgba(60, 25, 12, .28);
  --parch: #e6ddcc;
  --parch-edge: #d6c9b3;
  --tan: #c99a76;
  --tan-rgb: 201, 154, 118;            /* rgba(var(--tan-rgb), a): every soft tan wash and ring */
  --tan-rim: #a97b5a;
  /* A name you can press: the tan rim's tone taken down until it reads as ink rather than as a
     border, so a link is the family's warm accent and not a blue borrowed from a browser. Darker
     than --tan-rim because this one is prose on cream and has to be read, not seen. */
  --link-ink: #8c5a33;
  --tan-text: #fbe6d0;
  --hairline: rgba(74, 43, 34, .14);
  --field-border: rgba(74, 43, 34, .28);
  --field-face: #ffffff;

  /* grey pills and strips, the dropdown's grey variant, the unlearned skill */
  --grey-pill: #ebe8e3;
  --grey-pill-2: #e1ddd6;
  --grey-face: #918c86;
  --grey-off: #5b5551;
  --off-text: #f3ede4;
  --scroll-thumb: #d6cdc0;
  --scroll-thumb-hover: #c6bbab;

  /* stepper and round buttons: cream face, a faint ring, a pale tan glyph */
  --cream: #ece4d4;
  --cream-rim: rgba(140, 105, 75, .18);
  --cream-ink: #b8a27f;

  /* numbers */
  --green: #5f9d48;
  --orange: #d68a4c;
  /* The stat blue, and it is the family's own: --bar-mp is the mana bar and this is one step
     brighter so it carries at 1em on the card's cream paper, where the bar's depth is meant to be
     read as a filled trough. A third hue beside green and orange rather than a fourth invention. */
  --blue: #3f6ea8;
  --bar-hp: #9d3b34;
  --bar-mp: #3b5a86;
  --bar-exp: #c9a227;                /* gold: experience is the one bar that is a reward */
  --bar-sage: #3d6f30;
  --bar-track: #1d1613;              /* the empty part of every bar is black ink */
  --bar-text: #fff8f0;               /* the engraving light at full alpha */

  /* over the world: the one place this system paints on grass, water and snow
     instead of on paper. The face is the engraving light, the outline is the
     darkest ink in the family rather than pure black, so a nameplate still
     reads as this game and not as a browser default. --world-rim is a hairline
     at small sizes and grows with the type, so a big cast line keeps its edge. */
  --world-text: #fdf6ec;
  --world-dark: 27, 15, 8;             /* one dark, used at four alphas: rim, halo, glow, plate */
  --world-outline: rgb(var(--world-dark));
  --world-rim: max(1px, .06em);
  --world-glow: drop-shadow(0 0 1px rgba(var(--world-dark), .95)) drop-shadow(0 0 3px rgba(var(--world-dark), .7)) drop-shadow(0 1px 1px rgba(var(--world-dark), .9));
  --world-plate: rgba(var(--world-dark), .55);
  --world-veil-loss: rgba(143, 42, 30, .6);   /* the dim behind a defeat, and only a defeat */
  /* The fight's four figures. Every one of them is a panel colour lifted: the
     error red, the tan gold, the sage green and the paper grey are all too dark
     to read at speed over grass, so each gets its own token here rather than a
     filter or an opacity at the point of use. */
  --world-hit: #e0705a;                /* an ordinary blow */
  --world-crit: #f2c66a;               /* a critical, and the burst it fires */
  --world-crit-rgb: 242, 198, 106;     /* rgba(var(--world-crit-rgb), a): the burst and the orb */
  --world-heal: #8ecb63;               /* a heal, and an ally's health */
  --world-nil: #ded3c7;                /* a miss, an immunity, a flight: nothing happened */
  --world-gold: #e0c68f;               /* the quiet gold of the facts beside a name: the level, the guild line */
  /* THE WORLD'S OWN QUALITY INKS. The paper palette's bands (--rarity-*) are inks
     for a parchment card and go dark on purpose; over the canvas, behind a dark
     halo, the deepest of them - the purple at 5.2 on paper - is close to
     invisible. Same bands, same classes, the palette this surface asks for; the
     rule the .chip--bare block below already states. */
  --world-q-green: #86d45f;
  --world-q-blue: #63b8ff;
  --world-q-gold: #ffc247;
  --world-q-purple: #c98bff;
  /* Whoever DID the thing being announced: a player, a guild, a team, or the boss
     that was felled. A lime of its own rather than the quality green above, so an
     actor and a green-band item are not the same word twice. */
  --world-name: #5cf04a;

  /* item rarity: a thin rim on the tile and the tooltip name, never a fill */
  --tile-face: #e9e4dc;
  --rarity-purple: #9c7bb9;
  --rarity-blue: #6d8db2;
  --rarity-gold: #d9a441;
  --rarity-green: #7ba76b;
  --rarity-grey: #b9b3ac;
  --rarity-gold-text: #b8862e;
  /* Purple has a second tone for the same reason gold does: the rim is drawn on a pale tile face
     and wants to be light, while the NAME is read as prose on the card and wants to be dark. One
     token for both left every purple item titled in lavender - legible, but the lightest name on
     a card that is already cream. 5.2:1 against --win-face, where the rim tone was 3.2. */
  --rarity-purple-text: #7a55a0;

  /* one size each, read by the item and by the grid that holds it.
     --tile is thirds of an em rather than a round figure because the target is
     40px at the 12px window size, and 3.33em would land at 39.96 and round a
     ten-column bag half a pixel short. It stays in em so it still follows
     --win-size like everything else. */
  --tile: calc(10em / 3);
  --node-w: 6.4em;
}

* { box-sizing: border-box; }
html, body { margin: 0; }

/* The one flag every window and every foldable block in the game is opened and
   closed by is el.hidden, and the browser's own [hidden] rule is display: block,
   which loses to any component that declares a display of its own: .win is flex,
   .rows and .tiles and .table are grid, .chip and .field are inline-flex. Without
   this line every panel is on screen from boot and open() does nothing visible.
   It carries !important on purpose: a component's display must never win over
   "this is not on screen". */
[hidden] { display: none !important; }

/* Pixel art is drawn at exact halves of a 2x master, so it is sampled, never
   smoothed. Anything painted (the plates, the class rings, the coins) must NOT
   take this: nearest-neighbour at an arbitrary scale is what makes art look
   cheap. This utility is for the few bitmaps that are magnified on purpose,
   such as the world map's walk-grid fallback. */
.px { image-rendering: pixelated; }
/* The design page's own page chrome (paper page background, h1/h2/h3, code blocks, the demo
   .row) is not part of the system and does not ship. What ships is the type stack: every
   component reads --face-ui / --face-weight, so the game body sets them once and no component
   needs a font of its own. No background here: the world canvas is the game's background. */
body { color: var(--ink); font: var(--face-weight) 15px/1.45 var(--face-ui); }

/* A form control does not inherit type. The browser hands a <button>, <input>,
   <select> and <textarea> its own face - 13.333px Arial on this platform - and no
   component here declares one, so every button, field and pill in the game was
   drawn in a font that belongs to the browser and not to the game. The system
   says it once, here, and `font-size: 1em` on .btn finally means the em of the
   thing the button sits in rather than the em of Arial. */
button, input, select, textarea { font: inherit; letter-spacing: inherit; }

/* ---------------------------------------------------------------------
   Button: a pointed hexagon.
   .btn      the rim: dark, clipped to the hexagon.
   ::before  the face: same hexagon inset by the rim width, flat face,
             and the end ornaments as two background layers sized to 2.4em
             (one line of label plus padding), so they scale with the size
             class but stay put when the label wraps to two lines. The ornament
             is the left half (plus the whole gem) of hud-ui/button-end-
             ornament.png, a klein 4B line drawing traced with potrace;
             the right end is the same vector mirrored. These two keep the
             engraving colours baked in (see hud-ui/ornaments.css): the button
             has no spare pseudo element for a mask layer.
   ::after   the inner line: a one-pixel hollow hexagon inset by --btn-ring,
             so it turns at the chamfer angle, masked with a gradient so it
             fades out as it reaches the ornament at each end.
   Shape, rim, groove and ornament are all in em; width is free.
   --------------------------------------------------------------------- */
.btn {
  --hex: polygon(0 50%, var(--btn-point) 0, calc(100% - var(--btn-point)) 0, 100% 50%, calc(100% - var(--btn-point)) 100%, var(--btn-point) 100%);
  position: relative;
  display: inline-grid;
  place-items: center;
  padding: var(--btn-pad-y) var(--btn-pad-x);
  border: 0;
  font-size: 1em; line-height: 1.2;
  letter-spacing: .03em;
  color: var(--btn-text);
  text-shadow: 0 .06em 0 var(--btn-text-shadow);
  background: var(--btn-rim);
  clip-path: var(--hex);
  filter: drop-shadow(0 .1em .1em rgba(var(--shadow-ink), .22));
  cursor: var(--cur-hand, pointer);
  user-select: none;
  -webkit-tap-highlight-color: transparent;
  transition: filter .12s, transform .06s;
}
.btn::before, .btn::after {
  content: "";
  position: absolute;
  z-index: -1;               /* under the label, over the rim */
  pointer-events: none;
}
.btn::before {
  inset: var(--btn-rim-w);
  clip-path: var(--hex);
  background: var(--orn-btn) left center / auto 2.4em no-repeat, var(--orn-btn-r) right center / auto 2.4em no-repeat, linear-gradient(var(--btn-face), var(--btn-face));
}

.btn::after {
  inset: var(--btn-ring);
  clip-path: polygon(evenodd,
    0 50%, var(--btn-point) 0, calc(100% - var(--btn-point)) 0, 100% 50%, calc(100% - var(--btn-point)) 100%, var(--btn-point) 100%, 0 50%,
    1px 50%, calc(var(--btn-point) + 1px) 1px, calc(100% - var(--btn-point) - 1px) 1px, calc(100% - 1px) 50%, calc(100% - var(--btn-point) - 1px) calc(100% - 1px), calc(var(--btn-point) + 1px) calc(100% - 1px), 1px 50%);
  background: var(--engrave-light);
  -webkit-mask: linear-gradient(to right, transparent calc(var(--btn-line-fade) - 1.2em), #000 var(--btn-line-fade), #000 calc(100% - var(--btn-line-fade)), transparent calc(100% - var(--btn-line-fade) + 1.2em));
  mask: linear-gradient(to right, transparent calc(var(--btn-line-fade) - 1.2em), #000 var(--btn-line-fade), #000 calc(100% - var(--btn-line-fade)), transparent calc(100% - var(--btn-line-fade) + 1.2em));
}

.btn:hover { filter: drop-shadow(0 .1em .1em rgba(var(--shadow-ink), .22)) brightness(1.07); }
.btn:active { filter: brightness(.94); transform: translateY(.06em); }
.btn:focus-visible { outline: .12em solid var(--ink); outline-offset: .2em; }
.btn:disabled, .btn.is-disabled {
  cursor: var(--cur-arrow, default);
  filter: saturate(.35) brightness(1.05);
  opacity: .6;
  transform: none;
}

/* sizes: font-size only, relative to the panel the button sits in - .8 / 1 / 1.33 / 1.87 of
   whatever --win-size is, so a button never carries a pixel figure of its own */
.btn--sm { font-size: .8em; }
.btn--md { font-size: 1em; }
.btn--lg { font-size: 1.33em; }
.btn--xl { font-size: 1.87em; }
.btn--block { display: grid; width: 100%; }

/* colour variants: only the face tokens change */
.btn--cream {
  --btn-face-top: #f7f0e5;
  --btn-face: #eee3d3;
  --btn-face-bottom: #e3d4c0;
  --btn-rim: #c9b59e;
  --btn-text: #7a4a3c;
  --btn-text-shadow: rgba(255, 255, 255, .6);
}
.btn--rose {
  --btn-face-top: #d6928b;
  --btn-face: #c97f78;
  --btn-face-bottom: #bb6e66;
  --btn-rim: #a25a53;
  --btn-text: #fff0e8;
  --btn-text-shadow: rgba(90, 30, 25, .35);
}
.btn--sage {
  --btn-face-top: #a0b692;
  --btn-face: #8ea57f;
  --btn-face-bottom: #7d946e;
  --btn-rim: #667a58;
  --btn-text: #f4f8ec;
  --btn-text-shadow: rgba(30, 50, 20, .35);
}




/* ---------------------------------------------------------------------
   Chrome glyphs: monochrome, from hud-ui/icons.svg, fill currentColor.
   --------------------------------------------------------------------- */
.ic { width: 1em; height: 1em; fill: currentColor; flex: none; display: inline-block; vertical-align: -.15em; }
.ic--flip { transform: scaleX(-1); }

/* A star that is a PICTURE and not a sprite. arts/ui-icons/star.svg and its
   three ranked cousins (star-empty, star-luxury, star-radiant) are whole files
   rather than symbols in icons.svg, so they arrive as an <img> and cannot take
   .ic's currentColor fill. It sizes off the LINE it sits in, which is the whole
   of why a rating tier differs by font-size alone and never by a second
   drawing. A rating that is a count rather than a row of stars uses the .ic
   sprite (#i-star) in a .chip instead: this is for the drawn ones. */
.star { width: 1em; height: 1em; flex: none; vertical-align: -.12em; }
/* Currency pictures are painted PNGs, never glyphs: hud-ui/crown.png and hud-ui/lumi.png.
   THE ORDER IS FIXED: the number first, then the coin. A price is a quantity and
   the coin is the unit it is quoted in, and a price column that opened every row
   with the same picture could not be scanned down. `money()` in hud-ui.ts writes
   the pair. The exception is a coin used as a LABEL rather than a unit - the disc
   purse, a band's leading mark, the lead inside a Crowns input - where the
   picture names the thing and no figure follows it. */
img.coin { width: 1.25em; height: 1.25em; object-fit: contain; flex: none; vertical-align: -.3em; }

/* ---------------------------------------------------------------------
   Item picture: one cell of the icon atlas the art pipeline compiles from
   arts/icons/. The sheet is px - a 32 by 32 cell on a 33px pitch - but every
   length here is that px count over 32, in em, and the client writes
   background-position the same way (hud-ui.ts AT). The sheet URL is
   content-hashed, so the client sets --gs-icons and --gs-icons-em on <html>
   at boot; nothing here may assume a filename.
   A cell never resizes: THE CELL IS THE EM. The host says how big the picture
   should be with --ico-box, that becomes this element's font-size, and the
   cell, the sheet and the position the client writes are all lengths in em of
   it. So one cell fits a 3.33em bag tile and a .7em party row with no second
   drawing and no per-host magic number, and it keeps working when a size
   modifier changes the host's font-size.

   It was `zoom: calc(var(--ico-box, 32px) / 32px)` until now, and the sheet and
   the positions were in px. Dividing a length by a length is CSS Values 4
   arithmetic only Blink implements: Gecko drops the whole declaration, so every
   icon in the game stood at the atlas's raw 32px in Firefox - a 15px status
   mark over a body at twice its size, a bag tile's picture overflowing its
   frame. The em carries the ratio instead, and it is plain inheritance both
   engines have always had. --gs-icons-em is the sheet in the same em; panel.ts
   writes it beside --gs-icons-size, which stays px for ngadmin's .gs-ico.
   --------------------------------------------------------------------- */
.ico {
  display: block; flex: none;
  font-size: var(--ico-box, 32px);
  width: 1em; height: 1em;
  background-image: var(--gs-icons); background-size: var(--gs-icons-em); background-repeat: no-repeat;
  image-rendering: pixelated;         /* the sheet is 2x and is drawn at an exact half */
}
/* the hosts, each saying how much room the picture gets. Inside a tile it is
   the same .3em inset the <img> takes, centred the way the lock glyph is. */
.tile > .ico { position: absolute; inset: 0; margin: auto; --ico-box: calc(var(--tile) - .6em); }
.tile.is-dim > .ico { filter: grayscale(1); opacity: .45; }
.portrait > .ico { position: absolute; inset: 0; margin: auto; border-radius: 50%; --ico-box: 4.6em; }
.item-row > .ico { --ico-box: 2.4em; }
/* A quest row's picture: the painted tablet the NPC's head marker wears (npc.ts MARKS), an <img>
   rather than a sprite glyph, so it keeps its own colours. */
.item-row > .quest-mark { width: 1.6em; height: 1.6em; object-fit: contain; flex: none; }
/* The talent node's picture, and it is the HOT BAR's icon size - 32px, the atlas cell drawn at
   1:1. It was 4.3em, which is 51.6px at the window size: a node in the tree stood half again as
   big as the same skill sitting in the bar the player drags it to. The two are the same thing seen
   in two places and they read as one size now. *keputusan user, 2026-09-12.* */
.skill__pic.ico { width: 32px; height: 32px; --ico-box: 32px; }

/* ---------------------------------------------------------------------
   Engraving. Every ornament in hud-ui/ornaments.css is a black shape used
   as a mask: two pseudo layers share the mask, the light one (::before)
   sits --engrave-lift below the dark one (::after), so the tokens paint
   every engraving and retuning them retunes all of it.
   --------------------------------------------------------------------- */
.engrave::before, .engrave::after {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  -webkit-mask: var(--engrave-mask); mask: var(--engrave-mask);
}
.engrave::before { background: var(--engrave-light); transform: translateY(var(--engrave-lift)); }
.engrave::after { background: var(--engrave-dark); }

/* ---------------------------------------------------------------------
   Window. The frame is two bands: .win itself is the pale outer rim
   (a 3px pad of --rim) and its ::after paints the darker warm hairline
   just inside it, so the edge reads light outside, dark inside, exactly
   like the reference. The head is parchment with a highlight hairline on
   its top edge, and .win::before is the soft shadow strip the head casts
   down onto the body.
   Head engraving: two different ornaments in harmony. --orn-head-knot-l runs
   in from the LEFT (a knot of ribbons and curls behind the title, about a
   quarter of the head), --orn-head-min2 from the RIGHT (one small curl and a
   single sparkle tucked in the corner, quiet so the close star owns it). Both are
   drawn full at their outer end, bleed past it, and fade only inward. */
.win {
  position: relative;
  display: flex;
  flex-direction: column;
  min-width: 14em;
  padding: 3px;                        /* the pale outer rim band */
  background: var(--rim);
  color: var(--ink);
  border-radius: .55em;
  box-shadow: var(--win-shadow);
  line-height: 1.3;
  font-size: var(--win-size);          /* the one window size; everything inside is in em */
}
.win::after {                          /* the darker hairline inside the pale rim */
  content: ""; position: absolute; inset: 3px; border-radius: .4em;
  box-shadow: inset 0 0 0 1px var(--rim-in);
  pointer-events: none; z-index: 4;
}
.win::before {                         /* the head's shadow cast down on the body */
  content: ""; position: absolute; left: 3px; right: 3px; top: calc(3px + 3em);
  height: .45em; z-index: 3; pointer-events: none;
  background: linear-gradient(rgba(var(--shadow-ink), .26), rgba(var(--shadow-ink), .06) 55%, rgba(var(--shadow-ink), 0));
}
.win__head {
  position: relative;
  display: flex;
  align-items: center;
  gap: .45em;
  height: 3em;
  padding: 0 1.2em;                    /* the same 1.2em rhythm as the inner line */
  border-radius: .4em .4em 0 0;
  background:
    var(--orn-head-knot-l) left -.4em center / auto 130% no-repeat,
    var(--orn-head-min2) right -.4em center / auto 130% no-repeat,
    linear-gradient(var(--parch), var(--parch) 78%, var(--parch-edge));
  background-origin: border-box;       /* the head padding must not inset the engravings */
  overflow: hidden;
  flex: none;
}
.win__head::before {                   /* highlight hairline along the head's top edge */
  content: ""; position: absolute; left: 0; right: 0; top: 0; height: 1px;
  background: rgba(255, 253, 248, .9);
}
.win__head > * { position: relative; z-index: 1; }
.win__head > .ic { width: 1.5em; height: 1.5em; }
/* the title sits over the sun band: a hair darker than the body ink and a pale
   halo behind the letters keep it readable without turning into a shadowed title */
/* A window's name is in the cursive display face, the same Grenze the rail spells your name in. */
.win__title { font-family: var(--face-display); font-weight: var(--face-display-weight); font-size: 1.3em; letter-spacing: .02em; white-space: nowrap; color: var(--ink-strong, #3d2018); text-shadow: 0 0 .25em rgba(246, 240, 230, .85), 0 0 .5em rgba(246, 240, 230, .6); }
.win__close {
  display: grid; place-items: center; margin-left: auto;
  width: 2.4em; height: 2.4em; border: 0;
  background: transparent; color: var(--ink); cursor: var(--cur-hand, pointer); padding: 0;
  opacity: .9;
}
.win__close .ic { width: 1.75em; height: 1.75em; }
.win__close:hover { opacity: 1; }
/* The chat window's one button is not a close: it puts the chat back in the corner. A plain arrow
   back, drawn a size up, because the little corner-window glyph read as a smudge at head size. */
.win__close--back .ic { width: 2.1em; height: 2.1em; }
/* Put away, beside shut. It is the same button shape and the same ink - both are "this window
   stops being in front of me" - and only the close star takes the auto margin, so a head with
   both reads as one pair at the right end rather than as two controls at two indents. */
.win__min { margin-left: auto; opacity: .75; }
.win__min + .win__close { margin-left: 0; }
.win__min .ic { width: 1.5em; height: 1.5em; }
.win__body { position: relative; flex: 1; padding: .9em 1.2em 1.1em; background: var(--win-face); }
.win__body:not(:last-child) { padding-bottom: .5em; }
.win__body > * { position: relative; z-index: 1; }
.win__foot { display: flex; align-items: center; gap: .6em; padding: 0 1.2em 1.1em; flex: none; background: var(--win-face); }
.win > .win__body:last-child, .win > .win__foot:last-child { border-radius: 0 0 .4em .4em; }
/* A HEADLESS WINDOW: the box with no title bar and no close, for a window
   that is a whole view and has nowhere to be closed to (the sign-in screen).
   Two consequences, both handled here so no caller has to: the body takes the
   top radius the head used to carry, and the head's cast shadow - .win::before,
   a bar drawn 3em down - is switched off, because there is no head above it to
   cast one. That frees ::before on a headless window for whatever the view
   wants to paint there. */
.win--headless > .win__body:first-child { border-radius: .4em .4em 0 0; }
.win--headless::before { content: none; }
.win__grip { display: none; }
.win--resizable > .win__grip {          /* an invisible corner hit area; no triangle is drawn */
  display: block; position: absolute; right: 0; bottom: 0; z-index: 5;
  width: 1.2em; height: 1.2em; cursor: nwse-resize; touch-action: none;
}

/* The cover a modal sits on. A question the player must answer takes the whole
   screen away from the world until it is answered. It is a VIGNETTE, not a
   wash: clear through the middle third, dark at the corners, so the window
   still stands out of the world without the world being painted over. The ink
   is neutral and near-black - the old warm --shadow-ink read as red over a
   green map, and a modal is not an emotion. .scrim--loss is the one that is.
   It centres the one window it holds. Its layer is the top of the stack:
   windows 20, dialogs 80, floating cards 90, this 100, the menus above it 120.
   A stranded hover card must never float over a modal. */
.scrim {
  position: fixed; inset: 0; z-index: 100;
  display: flex; align-items: center; justify-content: center;
  background:
    radial-gradient(circle at 50% 50%, rgba(8, 8, 10, 0) 33%, rgba(8, 8, 10, .62) 100%),
    rgba(8, 8, 10, .18);
}

/* A defeat is the one result that colours its own dim: a soft red falling over
   the field, dark enough at the bottom that the banner still reads over it. */
.scrim--loss { background: var(--world-veil-loss); }

/* ---------------------------------------------------------------------
   Tabs: a white pill; the selected tab is a tan rounded fill with the
   pill's own radius and full height, and the engraved flourish at each
   side of the label. Hairlines between unselected tabs.
   --------------------------------------------------------------------- */
.tabs {
  --tabs-radius: .5em;
  display: inline-flex; flex: none;
  padding: 0;
  background: var(--field-face);
  border: 1px solid var(--field-border);
  border-radius: var(--tabs-radius);
}
.tabs__tab {
  position: relative;
  display: inline-flex; align-items: center; justify-content: center; gap: .4em;
  min-width: 5.6em; height: 2.3em; padding: 0 1.4em;
  border: 0; border-radius: var(--tabs-radius); background-color: transparent;
  font: inherit; color: var(--ink); cursor: var(--cur-hand, pointer); white-space: nowrap;
}
.tabs__tab .ic { width: 1.35em; height: 1.35em; position: relative; z-index: 1; }
.tabs__tab + .tabs__tab:not(.is-on)::before { content: ""; position: absolute; left: 0; top: 28%; height: 44%; width: 1px; background: var(--hairline); }
.tabs__tab.is-on + .tabs__tab::before { display: none; }
.tabs__tab:hover:not(.is-on) { background-color: rgba(var(--ink-wash), .05); }
.tabs__tab.is-on {
  background: var(--orn-tab-new) left -.25em center / auto .8em no-repeat,
              var(--orn-tab-new-r) right -.25em center / auto .8em no-repeat,
              var(--tan);
  color: var(--tan-text);
  text-shadow: 0 .06em 0 rgba(var(--shadow-ink), .25);
  box-shadow: inset 0 0 0 1px var(--tan-rim);
  padding: 0 1.7em;
  overflow: hidden;
}
/* A page tab lit as a drag's drop target. The bag and the warehouse are three
   pages deep and only one page of cells is on screen, so a stack let go on a
   tab is how it crosses - and the tab has to say so while the item is still in
   the air. Read as .drop.is-over reads, the dashed tan edge over the same tan
   wash, so one drawing means "let go here" wherever it turns up. The dash is
   an outline pulled inside the tab, because the strip owns the border and a
   tab has none of its own to colour. */
.tabs__tab.is-drop {
  background-color: rgba(var(--tan-rgb), .14);
  outline: 1px dashed var(--tan);
  outline-offset: -.15em;
}
.tabs--text .tabs__tab { min-width: 7em; padding: 0 2.2em; }
.tabs--block { display: flex; }
.tabs--block .tabs__tab { flex: 1; }
/* A window's own tab strip stands centred over the pane it switches. A full-width block strip is
   already centred by filling the row. */
.win__body > .tabs:not(.tabs--block) { display: flex; width: fit-content; margin-inline: auto; }
.tabs--sm { font-size: .8em; }
.tabs--lg { font-size: 1.33em; }
/* A tab that is off is still worth reading: the quality ladder greyed while
   auto-sell is switched off says what it is set to, and a barred door in the
   instance list says the door exists. Faded, and the pointer does not promise
   anything. Both spellings, because one of these is a real <button> and the
   other is a div that says so with aria. */
.tabs__tab:disabled, .tabs__tab[aria-disabled="true"] { opacity: .45; cursor: var(--cur-arrow, default); }
.tabs__tab:disabled:hover, .tabs__tab[aria-disabled="true"]:hover { background-color: transparent; }

/* ---------------------------------------------------------------------
   Select: a custom dropdown, never a native <select>. The trigger is a
   real <button> (glyph, centred label, chevron-down); the popover list
   (.select__list, role listbox) opens under it, styled like the kv rows,
   with a check on the current option. .is-open on the root shows the
   list; the small script at the end of the page does click, arrow keys,
   Enter and Escape. --grey is the info variant.
   --------------------------------------------------------------------- */
.select { position: relative; display: inline-block; flex: none; min-width: 12em; color: var(--ink); }
.select__trigger {
  display: flex; align-items: center; gap: .3em; width: 100%;
  height: 2.2em; padding: 0 .5em;
  background: var(--field-face); color: inherit;
  border: 1px solid var(--hairline); border-radius: .35em;
  font: inherit; cursor: var(--cur-hand, pointer); text-align: center;
}
.select__trigger > .ic { width: 1.25em; height: 1.25em; }
.select__label { flex: 1; min-width: 0; padding: 0 .3em; color: var(--ink-mute); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.select__trigger > .select__chev { width: .9em; height: .9em; }
.select__trigger:focus-visible, .select.is-open .select__trigger { border-color: var(--ink); outline: 0; }
.select__list {
  position: absolute; left: 0; right: 0; top: calc(100% + .25em); z-index: 20;
  display: none; margin: 0; padding: .2em .35em; list-style: none;
  background: var(--win-face); color: var(--ink);
  border-radius: .4em; box-shadow: 0 0 0 1px var(--win-rim), var(--win-shadow);
}
.select.is-open .select__list { display: block; }
.select__opt {
  display: flex; align-items: center; justify-content: space-between; gap: 1em;
  padding: .5em .35em; border-bottom: 1px solid var(--hairline); cursor: var(--cur-hand, pointer); white-space: nowrap;
}
.select__opt:last-child { border-bottom: 0; }
.select__opt .ic { width: 1em; height: 1em; visibility: hidden; }
.select__opt.is-on .ic { visibility: visible; }
.select__opt:hover, .select__opt.is-active { background: rgba(var(--ink-wash), .06); border-radius: .3em; }
.select--grey .select__trigger { background: var(--grey-face); border-color: transparent; color: var(--white); }
.select--grey .select__label { color: inherit; }
.select--block { display: block; }
.select--sm { font-size: .8em; }
.select--lg { font-size: 1.33em; }

/* ---------------------------------------------------------------------
   Band: a light grey information strip. Sparkle, label, green number,
   an optional centre item, an action at the right.
   --------------------------------------------------------------------- */
.band {
  display: flex; align-items: center; gap: .45em;
  min-height: 2.3em; padding: .3em .6em .3em .8em;
  background: var(--grey-pill); color: var(--ink-mute); border-radius: .3em;
}
.band > .ic { width: 1.15em; height: 1.15em; color: var(--ink); }
.band__label { color: var(--ink); }
.band__num { color: var(--green); }
.band__num--orange { color: var(--orange); }
.band__mid { display: inline-flex; align-items: center; gap: .4em; margin: 0 auto; color: var(--ink); }
.band__mid img { width: 1.6em; height: 1.6em; }
.band__action { display: inline-flex; align-items: center; gap: .5em; margin-left: auto; }

/* ---------------------------------------------------------------------
   Key/value list: label muted at the left, value dark at the right,
   hairline between rows.
   --------------------------------------------------------------------- */
.kv { margin: 0; padding: 0; }
.kv > div { display: flex; justify-content: space-between; align-items: baseline; gap: 1em; padding: .5em .25em; border-bottom: 1px solid var(--hairline); }
.kv > div:last-child { border-bottom: 0; }
.kv dt { margin: 0; color: var(--ink-mute); }
.kv__val { margin: 0; color: var(--ink); font-variant-numeric: tabular-nums; }
.kv__val--hot { color: var(--orange); }
.kv__val--good { color: var(--green); }
/* THE GEAR STAT BLOCK reads in blue: the figures on an equipment card are what the piece grants,
   and giving them a hue of their own separates them from the label beside them and from the plain
   ink every other .kv in the game prints. items.ts statBlock marks the list.

   The orange is restated at this specificity ON PURPOSE: this selector outranks the bare
   `.kv__val--hot` above, so without the second line the blue would swallow the one signal the
   block carries about WHERE a number came from - a line drawn from an option band, which is what
   a recast redraws. Blue is the base colour here, not the only one.

   The green socket addition needs no such line. It is a nested span of a different class, so the
   blue never matches it, and its own rule beats the colour it would otherwise inherit. */
.kv[data-gear-stats] .kv__val { color: var(--blue); }
.kv[data-gear-stats] .kv__val--hot { color: var(--orange); }
/* The two halves read as ONE line - "Attack: 191" - instead of a label and a figure pushed to
   opposite edges. For a stat block that is the difference between a table and a sentence: a column
   of eight facts a hand's width apart is read twice, once down the labels and once down the
   numbers, and the eye has to carry each pairing across the gap itself.
   The colon is drawn, never written into the label: a stat name is content and goes through the
   translation contract, and a punctuation mark baked into it would be translated with it. */
.kv--inline > div { justify-content: flex-start; gap: .4em; }
.kv--inline dt::after { content: ":"; }
/* A kv value that DOES something: the "go and stand here" line in the quest panel. Underlined
   rather than coloured, because colour in this family already means pass or refuse and a third
   meaning on the same channel would be read as one of those two. Disabled when the place is on
   another map - it still names the map, it just cannot walk you there. */
.kv__go { appearance: none; border: 0; background: none; padding: 0; font: inherit;
  color: var(--ink); text-decoration: underline; text-underline-offset: 2px; cursor: var(--cur-hand, pointer); }
.kv__go:hover:not(:disabled) { color: var(--gold, var(--orange)); }
.kv__go:disabled { color: var(--ink-mute); text-decoration: none; cursor: var(--cur-arrow, default); }
/* A kv value that names an NPC: the name, then a map pin with the errand in its ring. The whole
   value is one target - name, pin and the gap between - so the pointer never changes mid-row. */
.kv__npc { display: inline-flex; align-items: center; gap: .4em; }
.kv__npc { --pin-size: 1.75em; }
.kv__npc.is-dim .pin { opacity: .45; }

/* ---------------------------------------------------------------------
   Pin: <tl-pin>, the forged map pin with a hole in its ring. The picture is
   arts/ui-icons/pin.png; the hole is measured on it in arts/ui-icons/pin.json
   and the element writes the hole box from that as inline layout, so the
   sheet sizes the pin and nothing else. 1.25em tall by default, a text-line
   ornament that scales with the words beside it; a row that puts a picture in
   the ring asks for more, because the hole is half the pin's width and a glyph
   inside it needs 8 px or so to read.
   --------------------------------------------------------------------- */
.pin { position: relative; display: inline-block; flex: none; height: var(--pin-size, 1.25em); vertical-align: -.25em; line-height: 0; }
.pin__img { display: block; width: 100%; height: 100%; object-fit: contain; }
.pin__hole { position: absolute; display: grid; place-items: center; }
.pin__hole > .ic, .pin__hole > img { width: 100%; height: 100%; display: block; fill: currentColor; color: var(--ink); object-fit: contain; }

/* ---------------------------------------------------------------------
   Stat row: a grey pill. Glyph, short key, value columns, and the
   stepper: minus, plus, and the double-chevron disc that spends every
   remaining point on this stat. .stats is the stack; .stats__head its
   column caption row (its last span is as wide as the stepper).
   --------------------------------------------------------------------- */
.stat, .stats__head { display: grid; grid-template-columns: 1.4em 3.4em repeat(var(--stat-cols, 3), 1fr) auto; align-items: center; gap: .5em; }
.stat {
  min-height: 2.9em; padding: .3em .35em .3em .8em;
  background: var(--grey-pill); border-radius: .7em; color: var(--ink-mute);
  font-variant-numeric: tabular-nums;
}
.stat > .ic { width: 1.25em; height: 1.25em; color: var(--ink); }
.stat__key { color: var(--ink); letter-spacing: .04em; }
.stat__val { text-align: center; }
.stat__val--hot { color: var(--orange); }
.stat__val--dark { color: var(--ink); }
/* What a stepper has added but not yet committed, in the gain colour and beside the
   figure rather than in place of it: the base number stays where the eye left it and
   the green "+4" is the part that is still yours to take back. A single recoloured
   total said "this is different" without saying which way or by how much. */
.stat__val .stat__add { margin-left: .3em; color: var(--green); font-style: normal; }
.stat__stepper { display: inline-flex; gap: .45em; }
.stats { display: grid; gap: .5em; }
.stats__head { padding: 0 .35em 0 .8em; color: var(--ink-mute); font-size: .8em; line-height: 1.1; text-align: center; }
.stats__head span { grid-column: span 1; }
.stats__head span:first-child { grid-column: 1 / 3; }

/* ---------------------------------------------------------------------
   Round icon button: a nearly flat cream disc, one faint ring, the glyph
   in pale tan; the stepper's minus, plus and double chevron, and the
   refresh action. --white is the plain disc with the dark glyph, --sq is
   the rounded square used at a skill node's side.
   --------------------------------------------------------------------- */
.ibtn {
  position: relative; flex: none;
  display: inline-grid; place-items: center;
  width: 2.1em; height: 2.1em; padding: 0;
  border: 1px solid var(--cream-rim); border-radius: 50%;
  background: var(--cream); color: var(--cream-ink);
  cursor: var(--cur-hand, pointer); font: inherit;
}
.ibtn::after { content: ""; position: absolute; inset: .16em; border: 1px solid var(--cream-rim); border-radius: inherit; opacity: .3; pointer-events: none; }
.ibtn .ic { width: 1em; height: 1em; }
.ibtn:hover { color: var(--ink-mute); filter: brightness(1.02); }
.ibtn:active { transform: translateY(.06em); }
.ibtn:disabled, .ibtn.is-disabled { opacity: .45; cursor: var(--cur-arrow, default); }
.ibtn--white { background: var(--field-face); border-color: var(--field-border); color: var(--ink); }
.ibtn--white::after { content: none; }
.ibtn--sq { border-radius: .3em; width: 1.8em; height: 1.8em; }
.ibtn--sm { font-size: .8em; }
.ibtn--lg { font-size: 1.3em; }

/* ---------------------------------------------------------------------
   Tile: an item slot. One neutral face for every item; the picture sits
   inside, a stack count at the bottom-right. Rarity is optional and
   only a thin coloured rim (--purple, --blue, --gold, --green), nothing
   louder. --empty is the bare slot with a faint inner line, --locked
   adds the lock glyph, .is-dim greys the picture (an unequipped preview).
   The server speaks six quality bands and this system draws four rims: a white
   item wears no rim at all (it is the common case and a rim on everything is a
   rim on nothing) and yellow rides with gold. That is the ruling for the whole
   client, made once here; a fifth colour would have to earn its place against
   the four, not be added because a table has six rows.
   --------------------------------------------------------------------- */
.tile {
  --tile-rim: rgba(255, 255, 255, .7);
  --tile-ring: transparent;            /* the OUTER ring is the state channel, see below */
  position: relative;
  width: var(--tile); height: var(--tile); flex: none;
  border-radius: .55em;
  background: radial-gradient(120% 90% at 25% 15%, rgba(255, 255, 255, .5), rgba(255, 255, 255, 0) 60%), var(--tile-face);
  box-shadow: inset 0 0 0 max(1px, .1em) var(--tile-rim), 0 0 0 max(1px, .14em) var(--tile-ring), 0 0 0 1px var(--hairline), 0 .07em .14em rgba(var(--shadow-ink), .14);
  color: var(--ink);
  overflow: hidden;
}
.tile--purple { --tile-rim: var(--rarity-purple); }
.tile--blue { --tile-rim: var(--rarity-blue); }
.tile--gold { --tile-rim: var(--rarity-gold); }
.tile--green { --tile-rim: var(--rarity-green); }
.tile > img { position: absolute; inset: .3em; width: calc(100% - .6em); height: calc(100% - .6em); object-fit: contain; }
.tile.is-dim > img { filter: grayscale(1); opacity: .45; }
.tile__count { position: absolute; right: .2em; bottom: .2em; font-size: .8em; line-height: 1.3; padding: 0 .35em; border-radius: .3em; background: rgba(255, 255, 255, .82); color: var(--ink); }
.tile--empty, .tile--locked {
  background: var(--grey-pill);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .7);
}
.tile--empty::before, .tile--locked::before { content: ""; position: absolute; inset: .3em; border: 1px solid rgba(var(--ink-wash), .07); border-radius: .35em; }
.tile--locked > .ic { position: absolute; inset: 0; margin: auto; width: 1.5em; height: 1.5em; color: rgba(var(--ink-wash), .22); }
/* A tile that is locked but NOT empty: a pet standing in a place the account is not entitled
   to, an item on a shut pack. The art stays - the thing is still owned, and hiding it would
   read as having lost it - and the padlock becomes a corner badge over it rather than the
   whole face. Pair with .is-dim for the grey. */
.tile--barred { --tile-rim: rgba(var(--ink-wash), .25); cursor: var(--cur-attack-far, not-allowed); }
.tile--barred > .ic { position: absolute; inset: auto; right: .1em; top: .1em; width: .95em; height: .95em;
  color: var(--paper); background: rgba(var(--ink-wash), .72); border-radius: .3em; padding: .08em; }
/* Grey the picture, never the count or the badge: the reading has to stay legible. */
.tile--barred > :not(.ic):not(.tile__count) { filter: grayscale(1); opacity: .5; }
.tile--sm { font-size: .75em; }
.tile--lg { font-size: 1.4em; }
/* A skill or talent icon: the picture alone, no face, no rim, no ring. The art is the whole tile. */
.tile--bare { background: none; box-shadow: none; border-radius: 0; }
.tile--bare > .ico { --ico-box: var(--tile); }
.tiles { display: grid; grid-template-columns: repeat(var(--cols, 5), max-content); gap: .4em; }

/* ---------------------------------------------------------------------
   THE SPREAD. The Backpack's second shape: two pages of cells in ONE grid
   of twenty columns, read as the two halves of an open book. It is inert
   until the panel wears `is-on`, so the ordinary one-page bag carries the
   wrapper and pays nothing for it.

   The centre figure is arithmetic, not taste, and it is not the window's
   chrome - it is whatever makes HALF the spread equal one folded panel,
   because the device's cover is half its case. Both panels are sized by
   their FOOT, not by their cells: a one-page bag needs 443px of grid and
   514px of buttons, so it stands 521 wide, and the spread therefore has to
   stand 1042. The eleventh cell of every row carries the difference -
   116px, plus the ordinary 4.8 grid gap. `bun scripts/bake-magicbag.ts`
   measures both and says so when they drift apart, which is the only way
   this figure should ever be changed.
   --------------------------------------------------------------------- */
.spread { position: relative; }
.spread.is-on .tiles { justify-content: center; }
.bag--magic:not(.bag--folded) .spread.is-on .tile:nth-child(20n + 11) { margin-left: 9.667em; }
/* The fold is a painted band of light down the middle, never a border: the
   two pages are one surface, and a rule between them would make them two
   panels standing next to each other. */
.spread__fold { display: none; }
.bag--magic:not(.bag--folded) .spread.is-on .spread__fold {
  display: block;
  position: absolute; top: -.4em; bottom: -.4em; left: 50%; width: 6.4em; margin-left: -3.2em;
  pointer-events: none; z-index: 2;
  background: linear-gradient(90deg,
    rgba(var(--shadow-ink), 0) 0,
    rgba(var(--shadow-ink), .07) 38%,
    rgba(var(--shadow-ink), .11) 47%,
    rgba(255, 255, 255, .22) 50%,
    rgba(var(--shadow-ink), .11) 53%,
    rgba(var(--shadow-ink), .07) 62%,
    rgba(var(--shadow-ink), 0) 100%);
}

/* One head per page: which pack it is and what is in it. Split on the same
   seam as the grid under it - once the centre gutter grew, the body's
   midpoint stopped being the page seam, and a head a few pixels off its
   page reads as belonging to the other one. */
.pageheads { display: grid; grid-template-columns: 1fr; }
.bag--magic:not(.bag--folded) .pageheads { grid-template-columns: 1fr 1fr; gap: 10.067em; }
.pagehead { display: flex; align-items: center; gap: .6em; padding: 0 .2em; }
.pagehead > b { font-size: 1.05em; }

/* The pack strip, and the list its right entry drops. z-index on the STRIP
   and not only on the list: the heads are later siblings in the body grid,
   so a list raised only within its own parent still paints under them. */
/* The device's canvas. It is a CHILD of the bag window - it inherits the window's place in the
   stack and its position, so there is no z-index to keep in step - and it is simply not there
   while the switch is off. */
.magicbag__glass { display: none; }
/* Only while it is FOLDING. At rest the panel in front of it is the whole picture, and a device
   left standing behind draws the same chrome again a pixel or two out - which reads as the window
   having a shadow of itself. It is also nothing to render, which is the other half of the point. */
.bag--magic.is-folding .magicbag__glass { display: block; }
/* Mid-fold the panel is not on screen: the case is, and the picture on its glass is what the eye
   reads. The window itself has to get out of the way too, or its paper rim stands as a rectangle
   behind a device that has folded away from it. */
.bag--magic.is-folding { background: transparent; box-shadow: none; }
.bag--magic.is-folding::after { display: none; }
.bag--magic.is-folding > .win__head,
.bag--magic.is-folding > .win__body,
.bag--magic.is-folding > .win__foot,
.bag--magic.is-folding > .win__grip { visibility: hidden; }

.packstrip { position: relative; z-index: 7; }
/* Scoped to the spread on purpose. The native bag wears the same wrapper - one strip, one list
   that is never filled - and a two-column rule applied there would fold its four tabs into two. */
.bag--magic:not(.bag--folded) .packstrip .tabs { display: grid; grid-template-columns: 1fr 1fr; gap: 10.067em; }
.packstrip [data-packmenu]::after {
  content: ""; width: .5em; height: .5em; margin-left: .5em; flex: none;
  border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
  transform: translateY(-.15em) rotate(45deg);
}
.packstrip [data-packmenu][aria-expanded="true"]::after { transform: translateY(.1em) rotate(-135deg); }
/* A dropped list has to read as a sheet ABOVE the page, and on this palette
   that does not come from the fill: --paper is a hair off --win-face, so a
   panel-coloured box has no visible edge at all. The rim and the cast
   shadow are what separate it. */
.packmenu {
  position: absolute; z-index: 6; top: calc(100% + .3em); right: 0; width: calc(50% - 5.033em);
  margin: 0; padding: .3em; list-style: none;
  background: var(--white); border-radius: .5em;
  box-shadow: 0 0 0 1px var(--rim-in), 0 .1em .2em rgba(var(--shadow-ink), .18),
    0 .8em 1.6em rgba(var(--shadow-ink), .3);
}
.packmenu__opt {
  display: flex; align-items: center; gap: .6em; padding: .45em .7em; border-radius: .35em;
  cursor: var(--cur-hand, pointer);
}
.packmenu__opt > .note-line { margin-right: auto; }
.packmenu__opt > .ic { width: 1.1em; height: 1.1em; opacity: 0; }
.packmenu__opt.is-on { background: rgba(var(--tan-rgb), .22); }
.packmenu__opt.is-on > .ic { opacity: .55; }
.packmenu__opt:hover { background: rgba(var(--ink-wash), .06); }
/* Folded, the strip is an ordinary two-button row, so the list hangs under the half it belongs to
   rather than under a column that is not there. */
.bag--folded .packmenu { width: calc(50% - .15em); }

/* An item with no cell in the atlas still has to be recognisable, and a shelf
   of costumes or an archetype list is mostly that. The fallback is the first
   letters of its name, centred on the bare face. It is a type size, not a
   layout, so a panel cannot supply it inline. */
.tile__text { position: absolute; inset: .3em; display: grid; place-items: center; font-size: .8em; line-height: 1.15; text-align: center; color: var(--ink-mute); font-variant-numeric: tabular-nums; overflow: hidden; }

/* Cooldown. The sweep is a dark wedge that unwinds clockwise as the skill comes
   back, --cd being how much of the circle is still owed, in degrees; the digits
   sit on top of it. Both live on the tile so a hotbar cell needs no wrapper. */
.tile--cool::after { content: ""; position: absolute; inset: 0; z-index: 1; pointer-events: none; border-radius: inherit; background: conic-gradient(transparent var(--cd, 0deg), rgba(var(--ink-wash), .55) 0); }
.tile__cd { position: absolute; inset: 0; z-index: 2; display: grid; place-items: center; font-size: 1.1em; color: var(--bar-text); text-shadow: 0 .06em .1em rgba(var(--shadow-ink), .6); font-variant-numeric: tabular-nums; }

/* The key that fires a hotbar slot, centred on the tile's BOTTOM EDGE and
   hanging half outside it. Outside on purpose: the count owns the bottom-right
   corner and the cooldown owns the whole face, and a twelve-slot bar has to
   carry all three at once. It is a small parchment cap rather than bare text
   because the bar stands over the world, and bare text out there needs the
   world rim - a hard four-sided rim on a single letter reads as damage, not as
   a label. The tile clips its own overflow, so a cell that carries a key hangs
   it off .tile__key's own position rather than out of the box. */
.tile__key {
  position: absolute; left: 50%; bottom: -.5em; z-index: 3;
  transform: translateX(-50%);
  min-width: 1.5em; padding: 0 .3em;
  border-radius: .3em;
  background: var(--parch);
  box-shadow: 0 0 0 1px var(--parch-edge), 0 .1em .2em rgba(var(--shadow-ink), .35);
  font-size: .7em; line-height: 1.5; text-align: center; white-space: nowrap;
  color: var(--ink); font-variant-numeric: tabular-nums;
  pointer-events: none;
}
/* A tile that wears one is not clipped at its own edge: the cap is meant to
   overhang, and a grid of them needs the room. Both live on the tile, so a
   hotbar cell still needs no wrapper. */
.tile:has(> .tile__key) { overflow: visible; }

/* The ring OUTSIDE a tile is its state; the rim inside it is the item's rarity,
   and the two never fight for the same pixel. .is-on is armed or picked (a
   socket the smith is working, the card you chose); --opener is the move the
   fight opens with and --opener-soft the one that has drifted out of first
   place; --good and --bad are a helpful and a harmful effect, which is a
   direction rather than a quality and must not read as a green or purple item. */
.tile.is-on { --tile-ring: var(--tan); }
.tile--opener { --tile-ring: var(--tan); }
.tile--opener-soft { --tile-ring: rgba(var(--tan-rgb), .45); }
.tile--good { --tile-ring: var(--green); }
.tile--bad { --tile-ring: var(--error); }

/* What the pointer says a tile is: a locked one cannot be taken, a draggable
   one can be picked up. Neither is a layout property, so neither can be inline. */
.tile--locked { cursor: var(--cur-attack-far, not-allowed); }
.tile[draggable="true"] { cursor: var(--cur-grab, grab); }
.tile[draggable="true"]:active { cursor: var(--cur-grabbing, grabbing); }

/* ---------------------------------------------------------------------
   Skill node: the picture, the level pill, the name. Nothing else: no
   frame, no connectors, no stepper (skills level with skill books).
   A learned node's pill is tan; .is-off (not learned yet) greys the
   picture and keeps the grey pill. .skills is the grid the skill panel
   lists them in, --cols wide.
   --------------------------------------------------------------------- */
.skill { position: relative; display: grid; align-content: start; justify-items: center; gap: .3em; width: var(--node-w); color: var(--ink); text-align: center; }
.skill__pic { display: block; width: 4.3em; height: 4.3em; object-fit: cover; }
.skill__name { font-size: .85em; line-height: 1.15; color: var(--ink-mute); }
.skill:not(.is-off) .pill { background: var(--tan); color: var(--tan-text); }
.skill--sm { font-size: .8em; }
.skill--lg { font-size: 1.33em; }
.skill.is-off .skill__name { color: var(--ink-faint); }
.skill.is-off .skill__pic { filter: grayscale(1); opacity: .45; }
.skills { display: grid; grid-template-columns: repeat(var(--cols, 4), var(--node-w)); gap: .8em .4em; align-content: start; }

/* ---------------------------------------------------------------------
   Chips, tags, pills.
   .chip        plain grey counter, a number and nothing else ("8/300")
   .chip--disc  the chip with a disc hanging off its left end: the disc
                (.chip__disc, or a round .ibtn) is larger than the chip
                and overlaps it, the glyph inside the disc, the number
                in the pill. A purse is this one, and it is the only place
                a coin stands in front of its figure.
   .tag         small tan tag ("NEW")
   .pill        grey level pill ("5/5")
   --------------------------------------------------------------------- */
.chip {
  position: relative;
  display: inline-flex; flex: none; align-items: center; gap: .4em;
  min-width: 5em; height: 1.9em; padding: 0 .8em;
  background: var(--grey-pill); color: var(--ink-mute); border-radius: .3em;
  font-variant-numeric: tabular-nums;
}
.chip > .ic { width: 1.15em; height: 1.15em; color: var(--ink); }
.chip > img { width: 1.5em; height: 1.5em; object-fit: contain; }
.chip__num { flex: 1; text-align: center; }
.chip--bare { background: transparent; min-width: 0; padding: 0 .3em; }
.chip--disc { margin-left: 1.25em; padding-left: 1.5em; min-width: 6.4em; overflow: visible; }
.chip--disc > .ibtn, .chip__disc { position: absolute; left: -1.25em; top: 50%; transform: translateY(-50%); width: 2.5em; height: 2.5em; }
.chip__disc {
  display: inline-grid; place-items: center; flex: none;
  border: 1px solid var(--cream-rim); border-radius: 50%;
  background: var(--cream); color: var(--ink);
  box-shadow: 0 .07em .14em rgba(var(--shadow-ink), .14);
}
.chip__disc::after { content: ""; position: absolute; inset: .16em; border: 1px solid var(--cream-rim); border-radius: inherit; opacity: .3; pointer-events: none; }
.chip__disc > .ic { width: 1.3em; height: 1.3em; }
.chip__disc > img { width: 1.9em; height: 1.9em; object-fit: contain; }
.chip--disc > .ibtn .ic { width: 1.2em; height: 1.2em; }
.tag { display: inline-block; flex: none; font-size: .72em; line-height: 1.55; padding: 0 .45em; border-radius: .3em; background: var(--tan); color: var(--tan-text); text-shadow: 0 .06em 0 rgba(var(--shadow-ink), .25); letter-spacing: .02em; }
.pill { display: inline-block; flex: none; min-width: 3.2em; text-align: center; font-size: .8em; line-height: 1.5; padding: 0 .6em; border-radius: .35em; background: var(--grey-pill-2); color: var(--ink-mute); font-variant-numeric: tabular-nums; }
.pill--tan { background: var(--tan); color: var(--tan-text); }
.pill--ink { background: var(--ink); color: var(--paper); }
/* A state a player is waiting on, said loudly: the two answers to "has this side
   agreed yet". Brighter and larger than an ordinary pill on purpose - the trade
   window's whole question is whether the other person has pressed the button, and
   it is read across the window rather than looked for. The two fills take
   different inks because the ratio decides it, not the pairing: white clears 5.0
   on this red and only 2.3 on a green bright enough to read as bright, where a
   deep green ink clears 5.6. */
.pill--go, .pill--stop { font-size: 1em; font-weight: 600; padding: .1em .7em; letter-spacing: .01em; }
.pill--stop { background: #d62828; color: #fff; }
.pill--go { background: #22c55e; color: #0b3b1d; }

/* A READING on a chip: the HUD status line's latency is good, slow or bad, and
   which of the three it is changes while the player is looking at it. That is a
   state, not a variant, so it is .is-* and not --good / --warn / --bad: a
   modifier is what a thing IS and this is how it is DOING. Only the figure takes
   the colour - the grey face stays the same face whatever it says, because three
   coloured pills in the corner of the screen would be three alarms. */
.chip.is-good { color: var(--green); }
.chip.is-slow { color: var(--orange); }
.chip.is-bad { color: var(--error); }

/* ---------------------------------------------------------------------
   Field: text input in the same white box as the select.
   The leading glyph is markup, not a rule: whatever goes in front of the
   input (the search glyph, the painted coin on the trade window's Crowns
   box) is written into the label and dressed by `.field > .ic`. What
   --search is FOR is the box's own behaviour, below: an <input
   type=search> is the one input the browser decorates from the outside,
   and none of that decoration is ours.
   --------------------------------------------------------------------- */
.field {
  display: inline-flex; flex: none; align-items: center; gap: .4em;
  height: 2.4em; min-width: 12em; padding: 0 .6em;
  background: var(--field-face); color: var(--ink);
  border: 1px solid var(--field-border); border-radius: .35em;
}
.field > .ic { width: 1.2em; height: 1.2em; color: var(--ink-mute); }
.field input { flex: 1; min-width: 0; border: 0; background: transparent; outline: 0; font: inherit; color: inherit; padding: 0; }
.field input::placeholder { color: var(--ink-mute); opacity: .7; }
.field:focus-within { border-color: var(--ink); }
.field.is-disabled, .field:has(input:disabled) { opacity: .5; }
.field.is-error { border-color: var(--error); }
.field--block { display: flex; }
.field--sm { font-size: .8em; }
.field--lg { font-size: 1.33em; }

/* THE BROWSER'S OWN FURNITURE, ONE PART REDRAWN AND ONE TAKEN OFF.

   --search, once the player has typed, grows the OS cancel glyph. Clearing a
   search you have typed is worth a control, and the browser already knows when
   to show one and how to wire it - it appears with the first character, goes
   with the last, and clears the field and fires `input` on click, all without a
   line of script. So the glyph stays and only its DRAWING is ours: appearance
   off, then the design's own cross painted back as a mask in the field's own
   ink, at the field's own em. Nothing here is a second element in the markup.

   A NUMBER FIELD grows the spinner: two stacked arrows, drawn by the OS,
   painted over the box's right padding. There are sixteen number fields in
   the game and the worst of them is the count dialog, where a 4.5em field
   carries the OS arrows AND the design's own .stat__stepper right beside
   it - two ways to add one, a hair apart, one of them not ours. The
   stepper, the slider or an All button is always beside the figure, so the
   arrows are never the only way up. Off, in one rule, for all sixteen.
   Figures are tabular here for the same reason they are on a bar: a count
   the player is stepping must not jitter under its own digits. */
.field--search input[type=search]::-webkit-search-decoration { -webkit-appearance: none; appearance: none; }
.field--search input[type=search]::-webkit-search-cancel-button {
  -webkit-appearance: none; appearance: none;
  width: 1.1em; height: 1.1em; margin: 0 0 0 .4em; cursor: var(--cur-hand, pointer);
  background: var(--ink-faint);
  -webkit-mask: var(--x-glyph) center / .8em .8em no-repeat;
  mask: var(--x-glyph) center / .8em .8em no-repeat;
}
.field--search input[type=search]:hover::-webkit-search-cancel-button,
.field--search:hover input[type=search]::-webkit-search-cancel-button { background: var(--ink); }
.field input[type=number] { appearance: textfield; -moz-appearance: textfield; font-variant-numeric: tabular-nums; }
.field input[type=number]::-webkit-outer-spin-button,
.field input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; appearance: none; margin: 0; }

/* ---------------------------------------------------------------------
   Fusion: the bench as a DIAGRAM. Four minor pets at the corners, the
   major between them, and the energy visibly running from the four into
   the one - which is the whole of what fusion does, said once, instead of
   five identical rows that only a caption tells apart.

   Ported from the v0.10 panel (public/panels.css, retired when the design
   system moved here). The GEOMETRY is unchanged - two above, one across
   the middle, two below - and the COLOUR is not: the old teal was picked
   against a dark Stonebevel panel and a pale cyan flow is invisible on
   parchment. Here the ink is the dark end of the same hue and the glow is
   the light end, which is the way round this surface needs.
   --------------------------------------------------------------------- */
.fuse {
  --fuse-ink: #2f7d8a;                  /* the line itself, dark enough to read on paper */
  --fuse-glow: #6fd0dd;                 /* the halo and the travelling orb */
  position: relative;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  grid-template-rows: repeat(3, auto);
  place-items: center;
  gap: .6em 3em;
  padding: 1.2em 1.4em;
  border: 1px solid var(--hairline);
  border-radius: .5em;
  background: var(--paper-2);
}
.fuse__node { position: relative; display: grid; justify-items: center; gap: .3em; min-width: 0; }
/* The major spans both columns on the middle row, which is what puts it BETWEEN the four rather
   than beside them. The four corners are named for where they sit, not for their index: the
   energy paths are drawn from wherever a slot actually lands, so the names only have to be
   readable. */
.fuse__node--major { grid-column: 1 / -1; grid-row: 2; }
.fuse__node--nw { grid-column: 1; grid-row: 1; }
.fuse__node--ne { grid-column: 2; grid-row: 1; }
.fuse__node--sw { grid-column: 1; grid-row: 3; }
.fuse__node--se { grid-column: 2; grid-row: 3; }
.fuse__role { font-size: .78em; color: var(--ink-mute); }
.fuse__node--major .fuse__role { color: var(--tan-rim); font-weight: 600; }
/* The chooser under each slot. Narrow on purpose, and for two reasons: the diagram is the picture
   and the dropdown is only the control, and the energy lines run diagonally past these boxes on
   their way to the middle - a control as wide as the names it holds would be a white wall across
   every one of them. */
.fuse__node .select { width: 100%; max-width: 9.5em; }
.fuse__node .select__label { overflow: hidden; text-overflow: ellipsis; }

/* THE ENERGY. One <svg> over the whole diagram, sized to it, with a pair of paths per minor: a
   dim base that says where the line runs and a dashed flow that moves along it. Drawn from the
   measured positions of the slots rather than from fixed coordinates, so the picture survives a
   resize, a longer pet name, or a different window width. */
.fuse__energy {
  position: absolute; inset: 0; width: 100%; height: 100%;
  pointer-events: none; overflow: visible; fill: none; stroke-linecap: round;
}
.fuse__energy-base { stroke: var(--fuse-ink); stroke-width: 2; opacity: .3; }
.fuse__energy-flow {
  stroke: var(--fuse-ink); stroke-width: 2; stroke-dasharray: 7 19;
  filter: drop-shadow(0 0 3px var(--fuse-glow));
  animation: fuse-flow 1.2s linear infinite;
}
@keyframes fuse-flow { to { stroke-dashoffset: -26; } }
/* The orb that runs the path once, when the bench commits. Hidden until then: a burst that is
   always on screen is decoration, and this one is the answer to a button. */
.fuse__burst {
  visibility: hidden; fill: var(--fuse-glow); stroke: var(--fuse-ink); stroke-width: 2;
  filter: drop-shadow(0 0 5px var(--fuse-glow));
}
.is-charging .fuse__burst { visibility: visible; }
.is-charging .fuse__node--major .fuse__aura { filter: drop-shadow(0 0 6px var(--fuse-glow)); }

/* The ring around one slot: the same two-layer trick at tile scale, so a filled slot reads as
   holding something live rather than as a picture in a box. */
.fuse__aura { position: absolute; inset: -.18em; width: auto; height: auto; overflow: visible; pointer-events: none; fill: none; }
.fuse__aura-base { stroke: var(--fuse-ink); stroke-width: 1; opacity: .4; }
.fuse__aura-flow {
  stroke: var(--fuse-ink); stroke-width: 1.6; stroke-dasharray: 16 34;
  filter: drop-shadow(0 0 3px var(--fuse-glow));
  animation: fuse-orbit 2.4s linear infinite;
}
.fuse__node--major .fuse__aura-base { opacity: .7; stroke-width: 1.5; }
.fuse__node--major .fuse__aura-flow { stroke-width: 2; }
@keyframes fuse-orbit { to { stroke-dashoffset: -100; } }
/* An empty slot has no energy in it. The rings are left in the markup and turned off, so filling
   a slot does not have to rebuild it. */
.fuse__node .tile--drop .fuse__aura { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
  .fuse__energy-flow, .fuse__aura-flow { animation: none; }
  .is-charging .fuse__burst { visibility: hidden; }
}

/* ---------------------------------------------------------------------
   Scroll: a thin, light, round thumb.
   --------------------------------------------------------------------- */
.scroll { overflow: auto; scrollbar-width: thin; scrollbar-color: var(--scroll-thumb) transparent; }
.scroll::-webkit-scrollbar { width: .45em; height: .45em; }
.scroll::-webkit-scrollbar-track { background: transparent; }
.scroll::-webkit-scrollbar-thumb { background: var(--scroll-thumb); border-radius: 1em; }
.scroll::-webkit-scrollbar-thumb:hover { background: var(--scroll-thumb-hover); }

/* ---------------------------------------------------------------------
   Bar: HP, MP, EXP, durability, progress. A black track, a fill of one
   solid dark colour set by --v (percent) with no gradient and no sheen
   over it, and the figure centred on top in whitish engraving light with
   no text shadow. Every fill is dark enough to carry that text over it.
   --sm is the thin bar with no text (party frames, pet rows).
   --------------------------------------------------------------------- */
.bar { --bar: var(--bar-sage); position: relative; height: 1.35em; border-radius: 3px; background: var(--bar-track); overflow: hidden; color: var(--bar-text); font-size: .85em; box-shadow: inset 0 0 0 1px rgba(var(--ink-wash), .22); }
/* The fill is squared off, and it is not flat: one horizontal wash over the
   colour, dark at the left and light at the right, so a full bar reads as a
   thing filling from the near end. The wash is alpha over var(--bar), so a
   new colour needs no second gradient written for it. */
.bar__fill { position: absolute; inset: 0 auto 0 0; width: var(--v, 50%); background: linear-gradient(90deg, rgba(0, 0, 0, .42), rgba(255, 255, 255, .26)), var(--bar); border-radius: inherit; }
.bar__text { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-variant-numeric: tabular-nums; white-space: nowrap; }
.bar--hp { --bar: var(--bar-hp); }
.bar--mp { --bar: var(--bar-mp); }
.bar--exp { --bar: var(--bar-exp); }
.bar--sage { --bar: var(--bar-sage); }
.bar--sm { height: .5em; font-size: 1em; }
.bar--lg { height: 1.7em; font-size: 1em; }
/* A vital that jumps is a vital nobody can read. Every fill slides to its new
   width over a fifth of a second, which is short enough that a hit still feels
   like a hit and long enough to see which way the number went. */
.bar__fill { transition: width .2s linear; }
/* --timed is the other kind of bar: not a value the server keeps sending, but a
   span of time running out in front of the player - a swing, a cast, one cycle
   of gathering. The server hands over how long it lasts and the bar fills once,
   so the picture and the blow cannot drift apart. The transition is off here on
   purpose: it would fight the animation's reset to zero. */
.bar--timed > .bar__fill { width: 0; animation: bar-fill var(--dur, 1s) linear forwards; transition: none; }
@keyframes bar-fill { from { width: 0; } to { width: 100%; } }

/* ---------------------------------------------------------------------
   Table: plain rows for rosters and boards. No cell borders and no cell
   boxes: the cells sit edge to edge so their shared bottom hairline reads
   as one line across the whole row, and the columns are separated by
   padding only. --cols is the grid template; a row is display:contents so
   .is-on washes the full width in one soft tone. The caption row is small
   capitals. --r1 .. --r6 right-align that column, for numbers.
   --------------------------------------------------------------------- */
.table { display: grid; grid-template-columns: var(--cols, 1fr 1fr); font-variant-numeric: tabular-nums; }
.table__row { display: contents; }
.table__row > * { display: flex; align-items: center; gap: .3em; min-width: 0; padding: .5em .7em; border-bottom: 1px solid var(--hairline); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.table__row:last-child > * { border-bottom: 0; }
.table__head > * { font-size: .8em; font-variant: small-caps; letter-spacing: .04em; color: var(--ink-mute); padding-top: .1em; padding-bottom: .25em; }
.table__row.is-on > * { background: rgba(201, 154, 118, .16); }
.table__row.is-on > :first-child { border-radius: .35em 0 0 .35em; }
.table__row.is-on > :last-child { border-radius: 0 .35em .35em 0; }
.table--r1 .table__row > :nth-child(1),
.table--r2 .table__row > :nth-child(2),
.table--r3 .table__row > :nth-child(3),
.table--r4 .table__row > :nth-child(4),
.table--r5 .table__row > :nth-child(5),
.table--r6 .table__row > :nth-child(6) { justify-content: flex-end; text-align: right; }

/* ---------------------------------------------------------------------
   Chart: one price line, and nothing else. The marketplace's book history
   is the only drawing in the game that is DATA rather than a thing, and it
   is kept deliberately quiet: a hairline, a soft fill under it, a dashed
   guide every third of the height, and the two ends of the scale as plain
   text rows above and below. No ticks, no legend, no crosshair, no
   floating value - this is a game window, not a trading terminal.
   .is-down is the only state, and it turns the single ink the line and its
   fill both read, so the two can never disagree about direction.
   The plot is an <svg preserveAspectRatio="none">: its viewBox is the
   panel's own arithmetic, which is why the stroke widths below are the
   only numbers here that are not em - they are viewBox units, and the
   browser stretches them with the box.
   --------------------------------------------------------------------- */
.chart {
  --chart-ink: var(--green);
  display: grid; gap: .3em; grid-template-rows: auto minmax(0, 1fr) auto; min-width: 0;
}
.chart.is-down { --chart-ink: var(--error); }
.chart__plot { display: block; width: 100%; height: 100%; min-height: 0; }
.chart__grid { fill: none; stroke: var(--hairline); stroke-width: 1; stroke-dasharray: 3 5; }
.chart__area { fill: var(--chart-ink); opacity: .13; stroke: none; }
.chart__line { fill: none; stroke: var(--chart-ink); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
/* The scale and the dates are two ROWS of the component, not two boxes floated
   over the drawing: this system has no floating label and does not want one. */
.chart__scale, .chart__axis { display: flex; justify-content: space-between; gap: .6em; font-size: .85em; line-height: 1.3; color: var(--ink-mute); font-variant-numeric: tabular-nums; }
/* A book that has traded once or not at all. A line needs two points, and an
   empty grid would be a chart of zero, which is a different claim. */
.chart__empty { display: grid; place-items: center; min-height: 4em; color: var(--ink-faint); font-size: .85em; text-align: center; }

/* Well: the multi-line band. A grey box for prose, a section of a body, a guide. */
.well { padding: .7em .8em; background: var(--grey-pill); border-radius: .5em; color: var(--ink); line-height: 1.4; }
.well__title { font-size: .78em; letter-spacing: .06em; text-transform: uppercase; color: var(--ink-mute); margin-bottom: .4em; }
.cap { font-size: .78em; letter-spacing: .06em; text-transform: uppercase; color: var(--ink-mute); }
.note-line { font-size: .85em; color: var(--ink-mute); line-height: 1.3; }
.note-line--error { color: var(--error); }
.note-line--good { color: var(--green); }
.note-line--mute { color: var(--ink-mute); }

/* Check and range: the settings controls. The box is a small white tile
   that turns tan with the check glyph; the range is the grey track with
   a cream round thumb, the same disc as the stepper. The part of the track
   already travelled is tan, so a slider reads at a glance from across the
   screen (how much gold to stake, how loud the music is): --v is the same
   percentage the bar's fill uses, written by whoever moves the thumb, and a
   range that nobody drives just shows the grey track. */
.check { position: relative; display: inline-flex; align-items: center; gap: .5em; cursor: var(--cur-hand, pointer); color: var(--ink); }
.check input { position: absolute; opacity: 0; width: 0; height: 0; }
.check__box { display: grid; place-items: center; width: 1.3em; height: 1.3em; border: 1px solid var(--field-border); border-radius: .3em; background: var(--field-face); color: var(--tan-text); flex: none; }
.check__box .ic { width: .85em; height: .85em; visibility: hidden; }
.check input:checked + .check__box { background: var(--tan); border-color: var(--tan-rim); }
.check input:checked + .check__box .ic { visibility: visible; }
.check input:focus-visible + .check__box { outline: .12em solid var(--ink); outline-offset: .15em; }
.range { -webkit-appearance: none; appearance: none; width: 12em; height: .5em; margin: .5em 0; border-radius: .25em; background: linear-gradient(to right, var(--tan) 0 var(--v, 0%), var(--grey-pill-2) var(--v, 0%) 100%); outline: 0; cursor: var(--cur-hand, pointer); }
.range::-webkit-slider-thumb { -webkit-appearance: none; width: 1.3em; height: 1.3em; border-radius: 50%; background: var(--cream); border: 1px solid var(--cream-rim); box-shadow: 0 .07em .14em rgba(var(--shadow-ink), .18); }
.range::-moz-range-thumb { width: 1.3em; height: 1.3em; border-radius: 50%; background: var(--cream); border: 1px solid var(--cream-rim); }
.field--area { height: auto; align-items: flex-start; padding: .5em .6em; }
.field--area textarea { flex: 1; min-width: 0; min-height: 4em; border: 0; background: transparent; outline: 0; font: inherit; color: inherit; padding: 0; resize: vertical; }
.field__label { color: var(--ink-mute); white-space: nowrap; }

/* Paperdoll for the Character and Player screens: the front-view stand
   layers of arts/avatar-front/front.0.png, each clipped to its own frame
   from the sidecar (never one background over the whole box). The atlas
   is 2x, so the 240x480 canvas is scaled by --s. Page-only helper. */
/* A caller sets --doll-w, the width it wants the figure to occupy IN EM, and
   the box is that wide and twice that tall - the 1:2 of the 240x480 canvas.
   The canvas is atlas pixels, so left alone the figure is the one thing in a
   window that ignores --win-size: shrink the system and it stays the size it
   was while the panel closes in around it. --doll-w is what stops that.

   The box used to be `--s: calc(var(--doll-w) / 240px)` with `.doll` a fixed
   240x480 scaled by it. Dividing a length by a length is CSS Values 4
   arithmetic only Blink implements: Gecko drops every declaration carrying it,
   so in Firefox the transform and the box's own width were both thrown away
   and the doll stood at the raw 240x480, unclipped, stretching the panel round
   it. Nothing here divides two lengths any more. `.doll` simply fills the box,
   and character.ts paintFront fits the figure to whatever it measures - the
   same arithmetic it always did, one step earlier. */
.doll-box { width: var(--doll-w, 8em); height: calc(var(--doll-w, 8em) * 2); overflow: hidden; }
/* The stage is only a positioning context now: the class emblem that used to
   hang behind the figure is gone, so the doll stands on the window's own
   paper. .doll-box still clips the canvas it sizes. */
.doll-stage { position: relative; }
.doll { position: relative; width: 100%; height: 100%; }
/* One black line round the figure, and only round the figure: #doll-ink (the
   filter in the sprite at the top of the body) dilates the alpha of the whole
   stack and floods it black under the art, so hair against face and sleeve
   against hand stay clean. It is the DOM half of the world's silhouette pass
   (src/client/outline.ts); the Outline switch in Settings drops both, and the
   game marks that with body.no-ink. */
.doll { filter: url(#doll-ink); }
body.no-ink .doll { filter: none; }
/* the layers are sampled, not smoothed: the atlas is a 2x master and the doll is
   drawn at an exact half of it, so nearest neighbour is the sharper picture.
   The px background-size is the DESIGN PAGE's own hand-written stack, drawn on
   a literal 240x480 box; every layer the game paints writes the whole
   `background` shorthand in per cent of its own box (character.ts frontLayer)
   and never reads it. A layer hand-written into a panel now owes per cent too:
   `.doll` is the size of its box, not 240x480. */
.doll i { position: absolute; display: block; background: var(--front-atlas) no-repeat; background-size: 4088px 2682px; image-rendering: pixelated; }

/* ---------------------------------------------------------------------
   Card: a small popover, the item tooltip. Same face as a window, a
   head with the tile and name in the rarity colour, a body, a foot.
   --------------------------------------------------------------------- */
.card {
  width: 17em; padding: .7em .8em;
  background: var(--win-face); color: var(--ink);
  border-radius: .5em; box-shadow: 0 0 0 1px var(--win-rim), var(--win-shadow);
}
.card__head { display: flex; align-items: center; gap: .6em; }
.card__name { display: grid; gap: .15em; }
/* The item's own name line. .card__title is the class the client writes the
   name into (hud-ui.ts cardEl, items.ts itemCard, the skill panel's card) and
   the four --tone modifiers below colour it by rarity; a card whose name is
   written as plain markup on this page says the same thing with a bare <b>
   inside .card__name. One rule for both, so the two spellings cannot drift.
   Size only, never colour: the tone modifiers own the colour, and a colour
   here would out-specify them on the descendant spelling. */
.card__title, .card__name b { font-size: 1.05em; }
.card__name small { color: var(--ink-mute); font-size: .8em; }
.card__title--purple { color: var(--rarity-purple-text); }
.card__title--blue { color: var(--rarity-blue); }
.card__title--gold { color: var(--rarity-gold-text); }
.card__title--green { color: var(--rarity-green); }

/* THE RARITY ACCENT: a breath of the band's colour bleeding down from the top edge and gone by a
   third of the way in. items.ts puts the band on the card root.

   The token block above says rarity is "a thin rim on the tile and the tooltip name, never a
   fill", and this is written to stay on the right side of that line: the accent is transparent at
   both ends, reaches 16% at the very top edge and is gone before any figure, so the paper under
   every word is still --win-face. A purple sword feels purple as it opens without the card
   becoming a purple object.

   A SECOND BACKGROUND LAYER, not a ::before, and that is the load-bearing choice. A pseudo-element
   needs a positioned parent, and `position: relative` on .card would sit one rule away from
   `.card--float { position: fixed }` below - same specificity, so only source order keeps a
   floating tooltip floating, and the next person to move either line breaks every hover card in
   the game with no way to see why. Background layers need nothing: the gradient paints over the
   paper, both restated here in one declaration because the shorthand replaces the lot. */
.card--purple { background: linear-gradient(to bottom, rgba(156, 123, 185, .16) 0%, transparent 34%), var(--win-face); }
.card--blue   { background: linear-gradient(to bottom, rgba(109, 141, 178, .16) 0%, transparent 34%), var(--win-face); }
.card--gold   { background: linear-gradient(to bottom, rgba(217, 164, 65, .16) 0%, transparent 34%), var(--win-face); }
.card--green  { background: linear-gradient(to bottom, rgba(123, 167, 107, .16) 0%, transparent 34%), var(--win-face); }
.card__body { font-size: .9em; color: var(--ink-mute); line-height: 1.4; }
.card__body .kv { font-size: .95em; }
.card__foot { display: flex; justify-content: flex-end; gap: .5em; padding-top: .5em; }
/* --float is the same card lifted off the page and put wherever the pointer is:
   every item tooltip and every row menu in the game. The client writes left and
   top on it, so it must be fixed and it must be above the windows; and it must
   never eat a click, or the row under the tooltip stops answering. */
.card--float { position: fixed; z-index: 90; pointer-events: none; }
/* --name is the card with nothing in it but a name: what a HUD glyph answers with.
   One short line has no use for the reading column an item card holds, so it
   shrinks to its own label instead of standing 17em wide around three words. */
.card--name { width: max-content; max-width: calc(100vw - 1em); }
/* the one floating card the player is meant to hit: the little menu a row opens.
   It is the same box in the same layer, it just answers the pointer. */
.card--float.card--pick { pointer-events: auto; }
.card--arrow { position: relative; }
.card--arrow::before { content: ""; position: absolute; left: 1.5em; top: -.45em; width: .9em; height: .9em; background: var(--win-face); transform: rotate(45deg); box-shadow: -1px -1px 0 var(--win-rim); }

/* ---------------------------------------------------------------------
   Portrait: the round medallion. The HUD status frames, the party rail,
   a pet row, a roster line. One neutral round face, a parchment rim and
   the picture filling it. --sigil hangs the class magic circle AROUND
   the medallion, and the avatar OVERLAYS it: the emblem is inked right
   through - the class weapon fills its middle - so the well has to cover
   that middle and leave only the outer band showing. The class is set by
   --sigil (warrior, mage, priest, ranger).
   --------------------------------------------------------------------- */
.portrait {
  --sigil: url(/data/art/ui/sigil-warrior.bdf39f22.webp);
  /* How far the ring hangs outside the well. One figure for all four, because
     the masters are cut tight to their own ink and land on the same 512
     canvas; the old art needed a different inset per class to cancel the dead
     margin each file shipped with.
     -36% is bounded on both sides. Below it the well swallows the emblem: the
     band sits at 0.7-0.9 of the art's half-width, so at the old -31% only a
     4px sliver cleared the rim. Above it the ring reaches into the row - this
     portrait is mostly used as a list-row picture (the friend list, the party
     invite), the ring is not in the row's reserved width, and at -42% it was
     printing over the name. The HUD's own sigil is a separate rule at -31%
     and does NOT want this number: its well is inset by the block's padding,
     so it already has the clearance this one has to buy. One number for every
     class, here and in the HUD: all four files are drawn edge to edge. */
  --sigil-in: -36%;
  position: relative; width: 4.6em; height: 4.6em; flex: none;
  border-radius: 50%;
  background: radial-gradient(120% 90% at 30% 20%, rgba(255, 255, 255, .45), rgba(255, 255, 255, 0) 60%), var(--tile-face);
  box-shadow: inset 0 0 0 max(1px, .11em) var(--parch), 0 0 0 1px var(--win-rim), 0 .1em .25em rgba(var(--shadow-ink), .25);
}
.portrait > img { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: 50%; object-fit: cover; object-position: center 22%; }
.portrait .pill { position: absolute; left: 50%; bottom: -.35em; transform: translateX(-50%); font-size: .7em; white-space: nowrap; z-index: 2; }
/* ::before, not ::after, and that is the whole of it. A pseudo-element with
   z-index auto paints in tree order among its positioned siblings, and ::before
   is the first child while .head and > img are the rest - so this lands UNDER
   the face and over the medallion's own background. The old art was a hollow
   ring and could sit on top; this one has the class weapon through its middle,
   and on top it drew a sword across the player's face. */
.portrait--sigil::before {
  content: ""; position: absolute; inset: var(--sigil-in);
  background: var(--sigil) center / contain no-repeat;
  /* the same halo the HUD gives it, so the priest's holy white still reads */
  filter: drop-shadow(0 0 .06em rgba(var(--shadow-ink), .45));
  pointer-events: none;
}
/* The well has to be OPAQUE for the overlay to work. A head crop is a cut-out
   with gaps around the shoulders, and the medallion's own fill sits below the
   sigil, so without this the emblem's middle showed through beside the head.
   Same fill .portrait carries, so a portrait with no sigil is unchanged. */
.portrait--sigil > .head {
  background: radial-gradient(120% 90% at 30% 20%, rgba(255, 255, 255, .45), rgba(255, 255, 255, 0) 60%), var(--tile-face);
}
.portrait--mage { --sigil: url(/data/art/ui/sigil-mage.e69b7ab3.webp); }
.portrait--priest { --sigil: url(/data/art/ui/sigil-priest.99fb5271.webp); }
.portrait--ranger { --sigil: url(/data/art/ui/sigil-ranger.04e5640f.webp); }

/* The face itself, when the face is a PLAYER: the head crop of the front
   paperdoll, which is what the game puts in the well (character.ts headHtml,
   HEAD = 8, 0, 104, 104 on the 1x front canvas, so 16, 0, 208, 208 at 2x).
   An NPC has a painted bust and uses <img>; a player never does. The layers
   are percentages of the crop, so one head fits a well of any size, and the
   blow-up is the HUD's own: the round well would lose the square crop's
   corners, so the crop is enlarged about the face and the shoulders fall off
   the bottom edge. */
.head { position: absolute; inset: 0; overflow: hidden; border-radius: 50%; }
/* no pixelated here, unlike the full doll: the head is blown up about the face
   by a figure that is not a whole ratio, and nearest neighbour at 1.38 makes a
   staircase of every eyelash. The doll is halved exactly and is sampled. */
.head i { position: absolute; display: block; }
.portrait > .head { transform: translateY(-11%) scale(1.38); transform-origin: 50% 36%; }
/* A sigil portrait standing as a LIST ROW's picture. The ring is deliberately
   hung OUTSIDE the medallion, and a row measures its picture column from the
   medallion - so the overhang lands on the name beside it, and on the rows above
   and below, neither of which reserved a pixel for it. The friend list is where
   this showed: a class emblem printed straight across "Pettest / Lv100 Warrior".
   In a row the emblem comes inside the well instead. It is smaller and the class
   still reads, which in a row is the whole of what it was ever saying - the row
   writes the class out in words underneath as well. */
.item-row .portrait--sigil { --sigil-in: -6%; }
/* And it greys with the rest of the row. `.is-dim` only ever reached the <img>,
   and a roster line has none - the server sends a name, a level and a class, no
   Appearance at all - so an offline friend wore a full-colour class badge beside
   their own greyed-out name. The halo goes with the colour, which is right: it
   was there to keep the priest's white readable, and a grey emblem is not being
   read for its class any more. */
.portrait.is-dim::before { filter: grayscale(1); opacity: .45; }
/* A presence dot, for the word it stands in front of. Two states and no third -
   a name is either reachable or it is not - so it is `.is-on` and not a family
   of variants, and the WORD beside it says which either way: the dot is the
   glance, never the only telling. */
.dot {
  display: inline-block; width: .5em; height: .5em; margin-right: .4em;
  border-radius: 50%; background: var(--ink-mute); vertical-align: baseline;
}
.dot.is-on { background: var(--green); }
.portrait--sm { font-size: .7em; }
.portrait--lg { font-size: 1.5em; }
.portrait.is-dim > img { filter: grayscale(1); opacity: .45; }

/* ---------------------------------------------------------------------
   Item row: the one list line the whole game is made of. A picture at
   the left, a name with a smaller second line under it, and an end
   column for the price, the count or the action. Rows sit in .rows and
   share one hairline; .is-on is the selected row's soft tan wash;
   --empty is the placeholder line ("not learned", "drop a pet here").
   Rarity on the name reuses the card title colours.
   --------------------------------------------------------------------- */
.rows { display: grid; align-content: start; min-width: 0; }
.item-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: .6em; align-items: center; min-width: 0; padding: .45em .5em; border-bottom: 1px solid var(--hairline); color: var(--ink); text-align: left; }
.rows > .item-row:last-child { border-bottom: 0; }
.item-row__name { display: grid; gap: .1em; min-width: 0; }
.item-row__name b { font-weight: inherit; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.item-row__sub { font-size: .82em; color: var(--ink-mute); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.item-row__end { display: flex; align-items: center; justify-content: flex-end; gap: .5em; font-variant-numeric: tabular-nums; white-space: nowrap; }
/* A row spelled as a real <button> - the market's shelf list, the row menu a
   player or a bag cell opens. The browser paints its own button on those: a grey
   face and a 13px system label, which is neither the row's face nor the game's
   type. Chrome only, and no background of its own: :hover and .is-on are two
   classes and still out-rank this, so the row keeps its own states. */
button.item-row { appearance: none; -webkit-appearance: none; margin: 0; font: inherit; color: var(--ink); background: none; cursor: var(--cur-hand, pointer); border-top: 0; border-left: 0; border-right: 0; }
.item-row:hover { background: rgba(var(--ink-wash), .04); }
.item-row.is-on { background: rgba(201, 154, 118, .16); border-radius: .35em; }
.item-row--empty { color: var(--ink-faint); }
.item-row--empty:hover { background: none; }
/* A row that is still listed but cannot be used right now: a coach ride the
   player is barred from, a recipe short of a material. Dimmed as a whole, the
   same reading .is-dim gives a tile and a portrait, so one state word means one
   thing everywhere. */
.item-row.is-dim { opacity: .5; }

/* ---------------------------------------------------------------------
   Drop target: where a drag lands. A dashed tan outline and a line of
   guidance, nothing else. .drop is the area (mail attachments, a stall
   shelf, the blacksmith's equipment well); .tile--drop is the single
   dashed slot in a grid of tiles. .is-over is the lit state.
   --------------------------------------------------------------------- */
.drop { display: grid; place-items: center; align-content: center; gap: .35em; padding: 1em .8em; min-height: 4em; border: 1px dashed var(--tan-rim); border-radius: .5em; background: rgba(var(--ink-wash), .03); color: var(--ink-mute); font-size: .9em; text-align: center; }
.drop.is-over { border-color: var(--tan); background: rgba(201, 154, 118, .14); }
.tile--drop { background: rgba(var(--ink-wash), .03); box-shadow: none; border: 1px dashed var(--tan-rim); }
.tile--drop::before { content: none; }
/* The lit slot. A tile is a drop target twice over: the dashed empty one and an
   ordinary filled one that will take a swap, so both light, and both light the
   same way the .drop area does. */
.tile--drop.is-over, .tile.is-over { border-color: var(--tan); background: rgba(var(--tan-rgb), .14); --tile-ring: var(--tan); }
.drop, .tile--drop { cursor: var(--cur-grab, copy); }
/* A whole GRID of tiles as one drop area: the trade offer, where a stack and a pet land in the
   same twenty places and no single cell is the target. An outline rather than a border, because
   an outline is drawn outside the box and so cannot move forty tiles by a pixel. */
.tiles.is-over { outline: 1px dashed var(--tan); outline-offset: .25em; }

/* ---------------------------------------------------------------------
   Steps: the numbered run of a job, for every panel that walks the
   player through one (the blacksmith's seven tabs, a listing, a fusion).
   The number in a box, the label beside it, a hairline between steps.
   .is-on is the step being worked, .is-done one already behind you.
   --------------------------------------------------------------------- */
.steps { display: flex; align-items: center; gap: .6em; flex-wrap: wrap; color: var(--ink-faint); font-size: .92em; }
.step { display: flex; align-items: center; gap: .45em; }
.step__n { display: grid; place-items: center; width: 1.6em; height: 1.6em; flex: none; border: 1px solid var(--field-border); border-radius: .3em; font-size: .9em; font-variant-numeric: tabular-nums; }
.step.is-on { color: var(--ink); }
.step.is-on .step__n { background: var(--tan); border-color: var(--tan-rim); color: var(--tan-text); }
.step.is-done { color: var(--ink-mute); }
.step.is-done .step__n { border-color: var(--tan-rim); color: var(--tan-rim); }
.steps__sep { width: 1.6em; height: 1px; flex: none; background: var(--hairline); }

/* ---------------------------------------------------------------------
   Bubble: one line of chat. The speaker's portrait, a grey bubble with
   the name above the words and a tail pointing back at the portrait,
   and the clock outside it. --own is the parchment bubble for your own
   line, its tail on the other side.
   --------------------------------------------------------------------- */
.msg { display: grid; grid-template-columns: auto auto minmax(0, 1fr); gap: .5em; align-items: flex-start; }
.msg__time { justify-self: start; font-size: .78em; color: var(--ink-mute); padding-top: .5em; font-variant-numeric: tabular-nums; }
.bubble { position: relative; justify-self: start; max-width: 26em; padding: .4em .7em; background: var(--grey-pill); border-radius: .5em; color: var(--ink); line-height: 1.35; }
.bubble::before { content: ""; position: absolute; left: -.28em; top: .75em; width: .6em; height: .6em; background: inherit; transform: rotate(45deg); }
.bubble__name { display: block; font-size: .82em; color: var(--ink-mute); }
.bubble--own { background: var(--parch); }
.bubble--own::before { left: auto; right: -.28em; }
.bubble--system { background: none; padding-left: 0; color: var(--ink-mute); font-size: .9em; }
.bubble--system::before { content: none; }
/* A TRANSCRIPT: a conversation read inside a panel column rather than across the
   foot of the screen, and read the way every messaging app has taught people to
   read one - the other person down the left behind their face, your own words
   down the right with no face at all, because you know who you are.

   Flex rather than the grid .msg uses: the sides differ only by reading order,
   and row-reverse says that in one declaration where a second column table would
   have to restate every track. The bubble's own 26em ceiling was measured for
   the HUD log, which is as wide as the screen; in a panel column it is wider
   than the column, so the row's last track - the clock - was squeezed out of a
   long line entirely. Here it is a share of the row and wraps inside it. */
.talk { display: grid; align-content: start; gap: .5em; }
.talk .msg { display: flex; align-items: flex-end; gap: .45em; }
.talk .bubble { max-width: 78%; overflow-wrap: anywhere; }
.talk .msg__time { padding-top: 0; padding-bottom: .15em; flex: none; }
.talk .msg--own { flex-direction: row-reverse; }
/* Your own line needs no portrait: it is the only one on its side of the column,
   and a face repeated down the right edge is the picture a reader already has.
   The element stays in the row rather than being left out of the markup, so both
   sides are built by the same builder. */
.talk .msg--own .portrait { display: none; }
/* msg() always emits the picture column, even when there is no picture, because
   its own grid needs the track. A flex row does not, and an empty box here is a
   gap's worth of indent under every line that has no face. */
.talk .msg > span:first-child:empty { display: none; }
/* Not a speaker: what the server says about the conversation. Down the middle,
   because it belongs to neither side of it. */
.talk .msg--system { justify-content: center; }
/* The dock of put-away conversations: chat heads stacked along the bottom-right
   corner, over the menu row. Each is a face you can click back open, with the
   count of what came in while it was away. */
.talk-dock { display: flex; flex-direction: row-reverse; align-items: center; gap: .5em; }
/* No overflow clip on the BUTTON: the count sits proud of the circle, and a clip
   here takes the half of it that is outside. The round crop belongs to the face
   inside, which is the only thing that needs one. */
.talk-head {
  position: relative; display: grid; place-items: center;
  width: 3.4em; height: 3.4em; padding: 0; border: 0; cursor: var(--cur-hand, pointer);
  border-radius: 50%; background: var(--win-face);
  box-shadow: 0 0 0 2px var(--rim), var(--win-shadow);
  color: var(--ink); font-size: 1.1em;
}
.talk-head:hover { box-shadow: 0 0 0 2px var(--tan), var(--win-shadow); }
.talk-head__face { position: absolute; inset: 0; display: grid; place-items: center; overflow: hidden; border-radius: 50%; }
.talk-head__face img, .talk-head__face > span { width: 100%; height: 100%; object-fit: cover; }
/* The initial, for a face this client has never seen a body for. */
.talk-head__letter { position: relative; z-index: 1; font-size: 1.2em; color: var(--ink-mute); }
.talk-head__n {
  position: absolute; right: -.15em; top: -.15em; z-index: 2; min-width: 1.5em;
  padding: 0 .3em; border-radius: 1em; background: var(--tan); color: var(--tan-text);
  font-size: .7em; line-height: 1.5; text-align: center;
}
/* A thing somebody dropped into what they said: an item, a pet, a place. It is
   coloured by the rarity title colours, which is the whole of its look, so all
   this owes is the promise that it can be clicked. */
.chat-link { cursor: var(--cur-hand, pointer); }
.chat-link:hover { text-decoration: underline; }

/* Equipment slot silhouettes: one frame of arts/ui-icons/equipment-empty.png
   drawn faintly inside an empty tile, so a bare slot says what belongs in
   it. The sheet is a 5 by 2 grid; the modifier picks the cell. */
.tile__slot { position: absolute; inset: .28em; background-image: url(/data/art/ui/equipment-empty.f9c85ae2.webp); background-repeat: no-repeat; background-size: 500% 200%; opacity: .3; }
.tile__slot--weapon { background-position: 0% 0%; }
.tile__slot--necklace { background-position: 25% 0%; }
.tile__slot--ring { background-position: 50% 0%; }
.tile__slot--coat { background-position: 75% 0%; }
.tile__slot--trousers { background-position: 100% 0%; }
.tile__slot--hat { background-position: 0% 100%; }
.tile__slot--shoes { background-position: 25% 100%; }
.tile__slot--bracelet { background-position: 50% 100%; }
.tile__slot--fashion { background-position: 75% 100%; }
.tile__slot--aircraft { background-position: 100% 100%; }

/* The icon button's set state: a toggle that is on (a pinned skill, a
   filter that is applied) fills tan the way the checkbox does. */
.ibtn.is-on { background: var(--tan); border-color: var(--tan-rim); color: var(--tan-text); }
.ibtn.is-on::after { border-color: rgba(255, 255, 255, .45); opacity: .5; }

/* A window set: the tab variants of one panel stacked in the same place, one
   page shown at a time. The strip that pages them carries data-pages; every
   other strip on the page just moves its own mark. A page may hold more than
   one window when the panel has a second state worth showing (a fusion
   result, a roster mid-registration). */
.winset { display: contents; }
.winset > .win { display: none; }
.winset > .win.is-on { display: flex; }

/* ---------------------------------------------------------------------
   Screens: page-only helpers. .scene is the dimmed game backdrop the
   windows sit on (soft green gradients, no picture). Windows inside keep
   their own size.
   There is deliberately no HUD scene here. The always-on layer is not
   built from this system and never was - public/hud.css owns it, in its
   own language: no box, no plate, no well anywhere on screen while you
   play. A mock of it composed out of .win and .frame drew a HUD this game
   does not have, and drifted further from the real one with every pass.
   To look at the HUD, look at the game.
   --------------------------------------------------------------------- */
.scene {
  position: relative; display: flex; flex-wrap: wrap; gap: 28px; align-items: flex-start;
  padding: 28px; border-radius: 8px; overflow: hidden; isolation: isolate;
  background:
    radial-gradient(55% 70% at 18% 12%, #a9c47e, rgba(169, 196, 126, 0) 70%),
    radial-gradient(45% 60% at 82% 88%, #3f6a3a, rgba(63, 106, 58, 0) 70%),
    radial-gradient(60% 45% at 62% 42%, #8fb06c, rgba(143, 176, 108, 0) 70%),
    radial-gradient(35% 40% at 40% 80%, #5d8a4e, rgba(93, 138, 78, 0) 70%),
    linear-gradient(#6f9459, #4b6d3f);
}
.scene::before { content: ""; position: absolute; inset: 0; background: rgba(18, 28, 14, .38); z-index: -1; }

/* page-only helpers for the demos */
.demo-col { display: grid; gap: .8em; }
/* A one-shot animation - a damage figure, a crit burst, a soul orb - has run
   and gone by the time the page has finished loading, and cannot be judged.
   Inside this box it repeats. Nothing in the game ever wears it. */
.demo-replay .dmg--float, .demo-replay .burst__core, .demo-replay .burst i,
.demo-replay .orb__core, .demo-replay .orb__ring { animation-iteration-count: infinite; }
.demo-grey { background: #7f8f81; padding: 24px; border-radius: 8px; display: flex; flex-wrap: wrap; gap: 24px; align-items: flex-start; }

/* live size probe */
.probe { display: grid; gap: 14px; }
.probe input[type=range] { width: 320px; }

/* ---------------------------------------------------------------------
   Minimap: the round plate at the top right, with the painted map of where
   you are standing inside it. It is a real <button> in the client - the
   whole plate opens the world map - so the button's own padding, border and
   background are cleared here rather than in a panel, and the pointer is
   set here too.
   It CLIPS. The game puts a <canvas> in it and a canvas is a square: without
   the clip its four corners are painted straight back over the round plate,
   which is what the page's empty div could never show.
   What the client paints inside it is minimap.ts and not this sheet, and it is
   short on purpose: the ground it is standing on - the map's own rendered plate,
   the tiles the window overlaps, so it is sharp on every screen - one red dot per enemy, and
   the gold arrow at the centre that is the player, big enough to read a facing
   off at a glance. No NPC dots and no player dots - a town square full of them
   said nothing about danger, and who to talk to is the tracker's job.
   --------------------------------------------------------------------- */
.minimap {
  position: relative;
  width: 6.4em; height: 6.4em; flex: none; padding: 0; border: 0;
  border-radius: 50%; overflow: hidden; cursor: var(--cur-hand, pointer);
  background: radial-gradient(60% 60% at 40% 35%, #a9c47e, #5d8a4e 70%, #3f6a3a);
  box-shadow: 0 0 0 .22em var(--parch), 0 0 0 calc(.22em + 1px) var(--win-rim), 0 .2em .6em rgba(var(--shadow-ink), .35);
}
.minimap > canvas, .minimap > img { display: block; width: 100%; height: 100%; object-fit: cover; }

/* ---------------------------------------------------------------------
   Map pin: one mark on the world map for every thing a player travels TO.
   A teardrop standing on the point it names - the disc carries the same
   painted glyph the head marker over that NPC wears, so the mark on the
   map and the mark over the body are one vocabulary.
   Three families and no more: portals (the ways out and the coach stops)
   in the world layer's blue, instance doors in red, counters in gold. A
   place name is not a pin - it is text on the ground, which is what the
   window's "Place names" switch turns off.
   Nothing here is a label: a pin is 22px of picture and its name is on
   the hover card, or a city's worth of sentences covers the coastline.
   --------------------------------------------------------------------- */
.mappin {
  position: absolute; width: 1.47em; height: 1.87em;
  padding: 0; border: 0; background: none; cursor: var(--cur-hand, pointer);
  filter: drop-shadow(0 .12em .16em rgba(var(--shadow-ink), .45));
}
/* The disc and the nib under it, so the pin points at its own tile rather
   than hovering over it: the button's origin is the point of the nib. */
.mappin::before {
  content: ""; position: absolute; inset: 0 0 .47em;
  border-radius: 50%; background: var(--pin-face);
  box-shadow: inset 0 0 0 max(1px, .06em) rgba(var(--world-dark), .45);
}
.mappin::after {
  content: ""; position: absolute; left: 50%; bottom: 0; transform: translateX(-50%);
  border: .34em solid transparent; border-top: .54em solid var(--pin-face); border-bottom: 0;
}
.mappin > img {
  position: absolute; left: 50%; top: .72em; transform: translate(-50%, -50%);
  width: .94em; height: .94em; object-fit: contain;
}
.mappin--portal { --pin-face: #6d8db2; }
.mappin--instance { --pin-face: #b8574a; }
.mappin--service { --pin-face: #d9a441; }

/* A named place: text on the ground, in the world layer's voice rather
   than the window's, because it sits ON the picture and not beside it. */
.mapname {
  color: var(--world-text); font-size: .8em; letter-spacing: .04em;
  text-shadow: 0 0 .2em rgba(var(--world-dark), .95), 0 .08em .12em rgba(var(--world-dark), .9);
  pointer-events: none;
}

/* ---------------------------------------------------------------------
   HUD strip: the menu row at the bottom-right of the screen, the painted
   arts/ui-icons/hud-*.png plates as they are, one .hud__btn each.
   --------------------------------------------------------------------- */
.hud { display: flex; gap: .3em; align-items: flex-end; }
.hud__btn { position: relative; width: 3.4em; height: 3.4em; padding: 0; border: 0; background: transparent; cursor: var(--cur-hand, pointer); filter: drop-shadow(0 .1em .15em rgba(var(--shadow-ink), .35)); transition: transform .08s; }
.hud__btn img { display: block; width: 100%; height: 100%; object-fit: contain; }
.hud__btn:hover { transform: translateY(-.15em); }
.hud__btn:active { transform: none; filter: drop-shadow(0 .05em .08em rgba(var(--shadow-ink), .35)) brightness(.92); }
/* a painted plate that is switched off, such as a pet toggle the player has
   turned down: the colour goes out of it and it stays where it is. */
.hud__btn.is-off { filter: grayscale(1); opacity: .55; }

/* Something in there wants you. One mark, always the same one, in the top right
   corner of whatever carries it: a menu plate with unread mail, a tab with a
   finished job behind it. Orange because nothing else in this system is, so it
   is the only thing on a quiet HUD that moves the eye. */
.dot {
  position: absolute; right: .2em; top: .2em;
  width: .55em; height: .55em; border-radius: 50%;
  background: var(--orange); box-shadow: 0 0 0 max(1px, .06em) rgba(255, 248, 240, .75);
  pointer-events: none;
}

/* ---------------------------------------------------------------------
   On the world. The one text in the game that is not on paper: nameplates,
   damage figures, a guild name under a player, a stall sign, the broadcast
   line. It has to hold over grass, over snow, over water and over a white
   robe walking underneath, and no panel may be put behind it - a plate
   behind every name would wall the world off.
   So it is the reverse of the panel voice: the engraving light for the face
   and a hard dark rim on all four sides, one hairline thick and growing with
   the type, plus a soft halo to seat it. The rim is the darkest ink of this
   family, not black, so the world text still belongs to the same drawing.
   --glow is for a PICTURE over the world (a glyph, a plate art) where a text
   shadow has nothing to bite on; --plate is the last resort for a long line
   the halo alone cannot carry, and it drops the rim because the plate is the
   contrast then.
   --------------------------------------------------------------------- */
.on-world {
  color: var(--world-text);
  text-shadow:
    var(--world-rim) var(--world-rim) 0 var(--world-outline),
    calc(var(--world-rim) * -1) var(--world-rim) 0 var(--world-outline),
    var(--world-rim) calc(var(--world-rim) * -1) 0 var(--world-outline),
    calc(var(--world-rim) * -1) calc(var(--world-rim) * -1) 0 var(--world-outline),
    0 0 .4em rgba(var(--world-dark), .8);
}
/* a chip that rides inside a world line - a stall sign's price, a tracker count -
   is a piece of paper chrome and keeps its own crisp edges: the rim is for the
   loose words. A nameplate's level is NOT one of them any more: see below. */
.on-world .pill, .on-world .tag, .on-world .chip:not(.chip--bare) { text-shadow: none; }

/* A BARE CHIP IS NOT PAPER. Every other chip brings its own grey face and can
   sit anywhere; --bare threw the face away, so over the canvas it is loose words
   in panel ink, and over pale stonework it all but disappears.
   (The client's status line - clock, latency, FPS, build - used to be exactly
   that, printed straight on the world under the minimap. It is not any more: it
   rides its own slim ink strip above the map, in hud.css. A reading in a panel
   still comes through here.)
   The fix is the one this section already exists to be: the row carries
   .on-world, and a bare chip inside it stops exempting itself from the rim and
   takes the engraving light like any other loose word. It is the only chip that
   does, because it is the only chip with nothing behind it.
   The three READINGS keep their meaning and change their colour: the panel
   green, gold and red are the same figures the fight found too dark to read at
   speed over grass, so a reading over the world takes the lifted world token
   instead. Same state, same class, the palette the surface asks for. */
.on-world .chip--bare { color: var(--world-text); }
.on-world .chip--bare.is-good { color: var(--world-heal); }
.on-world .chip--bare.is-slow { color: var(--world-crit); }
.on-world .chip--bare.is-bad { color: var(--world-hit); }
/* An announcement over the whole world. Its own three-colour reading, because a
   broadcast is a sentence about strangers and the eye has to find the two things
   in it that matter before reading any of it: WHO, and WHAT. So the actor is
   always the world's name lime, the thing is always its own quality band, and
   every joining word between them is plain white - `.on-world`'s warm off-white
   is the right ink for a nameplate sitting in the scene and slightly muddy for a
   line the reader is meant to skim.

   The band classes are the SAME ones a parchment card uses. Only the ink changes,
   for the reason the token block says: a card's purple is drawn to be read on
   paper, and over the canvas it disappears. */
.cast { color: #fff; }
.cast .cast__name { color: var(--world-name); }
.cast .card__title--green { color: var(--world-q-green); }
.cast .card__title--blue { color: var(--world-q-blue); }
.cast .card__title--gold { color: var(--world-q-gold); }
.cast .card__title--purple { color: var(--world-q-purple); }
/* A top-of-the-ladder result (ten stars, a Perfect pet, a wedding, a war won):
   the same coloured line as any other broadcast, set larger so it stands out
   from ordinary news without a band across the world (owner, 2026-09-16). */
.cast__line--big { font-size: 1.4em; }

.on-world--glow { filter: var(--world-glow); }
.on-world--plate { display: inline-block; padding: 0 .35em .05em; background: var(--world-plate); border-radius: .2em; text-shadow: none; }

/* ---------------------------------------------------------------------
   Quest tracker. The always-on list of what you took on, in the right rail
   under the minimap. public/hud.css draws the shipped one (.hud-track) and
   this is its spec: the chat's dark wash and its chipped rim, so the two
   blocks of world text read as one family; a parchment band across the top
   with the word in the display face, the carried count and the fold; the
   head engraving running in from the band's own left edge and clipped by
   it. Under the band, one block per quest - the category tag, the name in
   the display face, then a line per objective with the step kind's glyph,
   n/m (gold the moment that step is full) and the walk mark where there is
   somebody to go and see. It is NOT a window: no frame, no close, no head
   chip. A quest name and a walk mark are the only things in it you click.
   --------------------------------------------------------------------- */
.track {
  display: flex; flex-direction: column; width: 14.4em;
  background: rgba(6, 8, 12, .68); color: #fff;
  border: 2px solid transparent;
  border-image: url(/data/art/ui/box-border.2a3e0b71.webp) 15 / 2px / 0 stretch;
  font-size: 13px;
}
.track__head {
  display: flex; align-items: center; gap: .45em; padding: .15em .6em .25em;
  background:
    var(--orn-head-knot-l) left -.4em center / auto 150% no-repeat,
    linear-gradient(var(--parch), var(--parch) 78%, var(--parch-edge));
  background-origin: border-box; overflow: hidden;
  color: var(--ink-strong); cursor: var(--cur-hand, pointer);
}
.track__head > * { position: relative; }
.track__head-title { font: var(--face-display-weight) 1.15em/1.2 var(--face-display); letter-spacing: .03em; }
.track__count { font-size: .9em; color: var(--ink-mute); }
.track__fold { margin-left: auto; letter-spacing: -1px; }
.track__q { padding: .3em .6em .4em; }
.track__q + .track__q { border-top: 1px solid rgba(255, 255, 255, .08); }
.track__q.is-band { opacity: .55; }
.track__title { display: flex; gap: .45em; align-items: baseline; cursor: var(--cur-hand, pointer); }
.track__name { font-family: var(--face-display); font-weight: var(--face-display-weight); }
.track__q.is-pinned .track__name { color: var(--world-crit); }
.track__obj { display: flex; gap: .45em; align-items: center; padding-left: .3em; font-size: .92em; color: rgba(255, 255, 255, .9); }
.track__obj .ic { width: .92em; height: .92em; fill: rgba(255, 255, 255, .62); }
.track__obj > span:nth-child(2) { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.track__n { white-space: nowrap; font-variant-numeric: tabular-nums; }
.track__n.is-done { color: var(--world-crit); }
.track__walk { appearance: none; padding: 0; border: 0; background: none; line-height: 0; color: rgba(255, 255, 255, .62); cursor: var(--cur-hand, pointer); }
.track__walk .ic { width: .92em; height: .92em; fill: currentColor; }
.track__walk:hover { color: var(--world-crit); }

/* ---------------------------------------------------------------------
   THE FIGHT AND THE WORLD LAYER.
   Everything above this line is paper. This is the family that is painted
   over the 3D canvas, and all of it is built on .on-world just above: the
   engraving light for the face and a hard dark rim on all four sides.

   THIS SECTION'S GEOMETRY IS PX, AND ONLY THIS SECTION'S. Every other
   dimension in the system is em, because a panel scales with its own type.
   These do not, and must not: a nameplate is placed by main.ts and
   combat.ts at raw toScreen() pixels over a body whose size is a fact about
   the CAMERA, the layer they live in is never HUD-scaled, and a health bar
   that grew with the HUD's font would stop lining up with the body under
   it. So px here, em everywhere else. The colours are still tokens.

   .nameplate  a name over a body: the emblem well and the two lines beside it,
               the health bar, the row of statuses. --ally / --hostile / --pet /
               --npc / --player say who this body is to the reader, --boss
               widens the bar, .is-targeted marks the one you are aiming at,
               .is-dead takes the plate away with the corpse. The bar is the
               shipped .bar, one drawing wherever in the game it appears.
   .dmg        a figure peeling off a struck body: --crit, --heal, --zero
               (a miss, an immunity, a creature that broke free), and
               --float, which is the rise and the fade.
   .burst      the flat star of light a critical fires out of a body.
   .orb        the soul orb a caught creature leaves where it stood.
   .ibtn--world  the round control that stands ON the world instead of in a
               panel: the phone bar's page and pet-switch buttons.
   The fight RESULT is not here: it is .toast, this system's one dramatic
   moment, in three tones (--win, --loss, --flee) with .scrim behind it. A
   second banner would be two components saying one thing.
   --------------------------------------------------------------------- */
.nameplate {
  --plate-w: 92px;
  display: grid; justify-items: center; gap: 2px;
  min-width: var(--plate-w);
  text-align: center; white-space: nowrap;
  pointer-events: none;
}
/* The identity block: the guild's emblem at the left, the two lines beside it -
   "Name level", then "Guild [Position]". A flex row, so the lines keep one left
   edge whether or not a crest is hanging there, and the plate goes back to being
   centred on the body when there is none. */
.nameplate__id { display: flex; align-items: center; justify-content: center; gap: 5px; }
/* The crest, at 20px off the 24px re-encode the server writes for a plate - never
   a picture scaled up. It takes --glow rather than a text rim: a rim has nothing
   to bite on around a picture. Nothing is drawn when a guild has no emblem, and
   nothing at all when the body is in no guild. */
.nameplate__emblem { flex: none; width: 20px; height: 20px; object-fit: contain; filter: var(--world-glow); }
.nameplate__lines { min-width: 0; }
.nameplate__name { display: flex; align-items: baseline; justify-content: center; gap: 4px; font-size: 12px; line-height: 1.2; }
/* The level, AFTER the name and in the quiet gold: a fact about the body, not
   part of what it is called. It was the shipped .pill and it led the row, which
   put a piece of paper chrome on every head in town - the one place in this
   system where a panel component was the wrong answer, because out here there is
   no paper for it to be a piece of. One colour for every kind of body, so the
   number is never read as the name's own hue saying whose side this is. */
.nameplate__lvl { color: var(--world-gold); font-size: .85em; }
/* The guild under the name, and only when there is one: a guildless body gets no
   line at all rather than "No Guild" printed over most of the map. Quieter than
   the name and tracked out, so it reads as a caption belonging to the name above
   it rather than as a second name. The position rides in it in literal brackets,
   for every guild and not only your own. */
.nameplate__guild { font-size: 10px; font-weight: 700; line-height: 1.2; letter-spacing: .04em; color: var(--world-gold); margin-top: 1px; }
/* A player's nickname and an NPC's sign are read across a crowded map and
   carry a little more weight than a creature's label. */
.nameplate--player .nameplate__name, .nameplate--npc .nameplate__name { font-size: 14px; }
/* Who this body is TO YOU, and nothing else. Read from the reader's own seat:
   before this the defender in a duel was drawn in the enemy colour over their
   own head. An outlaw and a guild-war foe take the same red as a hostile,
   because one colour has to mean "this body is a target". */
.nameplate--hostile .nameplate__name,
.nameplate--foe .nameplate__name,
.nameplate--outlaw .nameplate__name { color: var(--world-hit); }
.nameplate--pet .nameplate__name { color: var(--world-heal); }
/* The one you are aiming at. The ring goes on the BAR rather than giving the
   name a fourth colour: the name is already saying whose side this body is on,
   and a target mark that overwrote that would cost more than it bought. */
.nameplate.is-targeted .nameplate__name { color: var(--world-crit); }
.nameplate.is-targeted .bar { box-shadow: 0 0 0 1px var(--world-crit); }
/* ONE bar width for every body in the fight, centred under whatever width the
   name happened to need. Letting the bar inherit the plate's width made it a
   readout of name length: a full bar on a Noisy Cactus was a different number
   of pixels from a full bar on a Rogue Bear. A boss is the exception and it is
   a flag on the wire, never a guess from the name or the scale: wider, and
   still fixed. */
.nameplate .bar { width: var(--plate-w); height: 6px; border-radius: 3px; }
.nameplate--boss { --plate-w: 138px; }
/* The world's own two colours, not the panel's. The panel bar reds and greens
   are mixed for parchment and go dead against grass at six pixels tall: the
   hostile bar takes the same red the hostile NAME takes, so one colour means
   "this body is a target" on both rows of the plate, and an ally's takes the
   heal green. */
.nameplate--hostile .bar, .nameplate--foe .bar { --bar: var(--world-hit); }
.nameplate--ally .bar, .nameplate--pet .bar { --bar: var(--world-heal); }
/* Health moves when a blow lands and not otherwise, so this transition IS the
   drain: the number itself arrives from the server in one step. */
.nameplate .bar__fill { transition: width .18s ease-out; }
/* A clock counting UP to something - a siege swing, a cast, one cycle of
   gathering - is the shipped .bar--timed, filled by the server's own duration
   so the picture and the blow cannot drift apart. Brass, and never the health
   colour: this is a clock counting UP to a blow, not a pool draining, and one
   colour for the two would read as the character taking damage from the wall it
   is hitting. */
.nameplate .nameplate__cast { --bar: var(--world-crit); }
/* The statuses a body is wearing, under the bar and over the body itself. Not
   the HUD's buff strip and not a .tile: the chrome that frames an icon inside
   a panel - a face, a rim, a boxed count - buries one at this size over moving
   art, and the black square with a number on it was never a missing icon, it
   was the badge sitting on top of one. Out here the icon IS the cell, and the
   only thing drawn around it is the direction ring.
   Wrapping is deliberate: a body holds at most one status per category, so the
   longest row is the number of categories, and a second line beats a clipped
   one. No height until an icon is in it, so a body wearing nothing costs the
   plate nothing. */
.nameplate__status { display: flex; flex-wrap: wrap; justify-content: center; gap: 5px 6px; }
.nameplate__buff {
  position: relative; display: block; width: 20px; height: 20px; flex: none;
  /* The ring is drawn OUTSIDE the box, never inset: the icon fills the cell
     edge to edge, so an inset ring would be painted under the picture and
     never seen. Outside it also costs no layout, so the icon stays centred.
     The dark ring under it is what keeps a green rim legible on grass.
     Green is helpful and red is harmful - a direction, the same reading
     .tile--good and .tile--bad carry in a panel. */
  box-shadow: 0 0 0 1px var(--world-heal), 0 0 0 2px rgba(var(--world-dark), .8);
}
.nameplate__buff--bad { box-shadow: 0 0 0 1px var(--world-hit), 0 0 0 2px rgba(var(--world-dark), .8); }
.nameplate__buff > img { display: block; width: 100%; height: 100%; object-fit: contain; }
/* The seconds left, sized to its DIGITS and hung outside the cell so it eats no
   icon. It keeps the plate's world rim rather than a ground: bare text with a
   soft halo was tried and lost against a bright picture. */
.nameplate__buff-n { position: absolute; right: -3px; bottom: -3px; font-size: 10px; line-height: 1; font-variant-numeric: tabular-nums; }
/* The death clip ends on an empty frame, so a corpse has no body left to
   label, and the plate goes with it. */
.nameplate.is-dead { display: none; }

/* A figure peeling off a struck body. The one text in the game set in the UI
   face's heaviest cut: it is read at speed over moving art, so the rim does the
   cutting and there is no blur at all - a blur is what makes a number look
   painted onto the backdrop instead of standing off it. */
.dmg { font: 700 26px/1 var(--face-ui); letter-spacing: -.01em; color: var(--world-hit); }
.dmg--crit { font-size: 34px; color: var(--world-crit); }
.dmg--heal { color: var(--world-heal); }
/* A miss, an immunity, a creature that broke free: nothing happened, and it is
   said smaller and in the paper grey rather than in a colour that means a hit. */
.dmg--zero { font-size: 21px; color: var(--world-nil); }
/* The rise, and the only fade in the game: everything else the player reads
   stays put. The element removes itself on animationend, so no frame of the
   fight loop has to know it exists. */
.dmg--float { animation: dmg-rise 1.1s ease-out forwards; }
@keyframes dmg-rise {
  from { transform: translate(-50%, 0); opacity: 1; }
  to { transform: translate(-50%, -52px); opacity: 0; }
}

/* The crit burst: a flat star of light fired out of the body a critical landed
   on, drawn BEHIND every number and plate in the layer. It is CSS and not the
   effects scene because the thing it is a backdrop for - the damage figure - is
   itself DOM, and a burst in the 3D scene would sit behind the bodies while its
   own number floated over the whole fight. One element per critical rather than
   a dozen additive quads, so a party fight pays nothing for it.
   The element is a 0x0 POINT at the impact and every spike radiates from it, so
   nothing here has to centre anything: --r is the longest spike (the body's own
   height, so a burst on a boss is a boss-sized burst and one on a piglet does
   not swallow it), --spin turns the whole star one step per burst so a run of
   criticals never reads as the same stamp twice, and each <i> carries its own
   --a angle and --l length as a fraction of --r. */
.burst { position: absolute; width: 0; height: 0; pointer-events: none; transform: rotate(var(--spin, 0deg)); }
.burst__core {
  position: absolute; left: 0; top: 0;
  width: calc(var(--r, 40px) * .5); height: calc(var(--r, 40px) * .5);
  margin: calc(var(--r, 40px) * -.25) 0 0 calc(var(--r, 40px) * -.25);
  border-radius: 50%;
  background: radial-gradient(circle, var(--world-crit) 0%, rgba(var(--world-crit-rgb), .55) 40%, rgba(var(--world-crit-rgb), 0) 70%);
  animation: burst-core 320ms ease-out forwards;
}
.burst i {
  position: absolute; left: 0; top: 0;
  width: calc(var(--r, 40px) * var(--l, 1)); height: 2px; margin-top: -1px;
  transform-origin: 0 50%;
  background: linear-gradient(to right, var(--world-crit), rgba(var(--world-crit-rgb), 0));
  animation: burst-spike 260ms cubic-bezier(.12, .8, .3, 1) forwards;
}
@keyframes burst-core { from { transform: scale(.2); opacity: 1; } to { transform: scale(1.15); opacity: 0; } }
@keyframes burst-spike {
  from { transform: rotate(var(--a, 0deg)) scaleX(0); opacity: 1; }
  to { transform: rotate(var(--a, 0deg)) scaleX(1); opacity: 0; }
}

/* The soul orb a caught creature leaves where it stood: a core that rises off
   the ground and a ring that opens out of it once. Same 0x0 anchor as the
   burst, and --r is the body's own half-height. */
.orb { position: absolute; width: 0; height: 0; pointer-events: none; }
.orb__core, .orb__ring {
  position: absolute; left: 0; top: 0;
  width: var(--r, 24px); height: var(--r, 24px);
  margin: calc(var(--r, 24px) * -.5) 0 0 calc(var(--r, 24px) * -.5);
  border-radius: 50%;
}
.orb__core {
  background: radial-gradient(circle, var(--world-text) 0%, rgba(var(--world-crit-rgb), .8) 45%, rgba(var(--world-crit-rgb), 0) 72%);
  animation: orb-rise 620ms cubic-bezier(.16, .72, .3, 1) forwards;
}
.orb__ring { border: 2px solid rgba(var(--world-crit-rgb), .75); animation: orb-ring 420ms cubic-bezier(.1, .7, .3, 1) forwards; }
@keyframes orb-rise {
  from { transform: translateY(0) scale(.4); opacity: 0; }
  35% { opacity: 1; }
  to { transform: translateY(-46px) scale(1); opacity: 0; }
}
@keyframes orb-ring { from { transform: scale(.3); opacity: .9; } to { transform: scale(2.4); opacity: 0; } }

/* Motion turned down. The burst and the orb's ring are decoration and simply do
   not happen; the figure still has to be READ, so it stops rising and holds
   where it landed before it goes. */
@media (prefers-reduced-motion: reduce) {
  .burst { display: none; }
  .orb__ring { display: none; }
  .orb__core { animation: orb-hold 500ms linear forwards; }
  .dmg--float { animation: dmg-hold 1.1s linear forwards; }
  @keyframes orb-hold { from { opacity: 1; } to { opacity: 0; } }
  @keyframes dmg-hold { from { transform: translate(-50%, 0); opacity: 1; } to { transform: translate(-50%, 0); opacity: 0; } }
}

/* The round control that stands on the WORLD instead of in a panel. A phone
   pages the twelve-slot hotbar into threes, and the page button and the
   pet-switch button that do it sit beside the bar, over the canvas. It is the
   same disc as .ibtn - same shape, same press, same disabled - with the cream
   paper face swapped for the world's own dark plate, because a cream disc
   floating over grass reads as a hole in a panel that is not there. That is the
   whole answer to the phone bar: the two buttons are .ibtn ibtn--world, and the
   page counter beside them is the .chip it already is.
   .ibtn__cap is the tiny word under the glyph ("PET", "1 / 3"); the disc turns
   into a column to hold it. */
.ibtn--world {
  display: inline-flex; flex-direction: column; align-items: center; justify-content: center; gap: .1em;
  width: 2.5em; height: 2.5em;
  background: var(--world-plate); border-color: transparent;
  color: var(--world-text);
  box-shadow: 0 .1em .2em rgba(var(--world-dark), .5);
}
.ibtn--world::after { content: none; }
.ibtn--world:hover { color: var(--world-text); filter: brightness(1.2); }
.ibtn__cap { font-size: .55em; line-height: 1; letter-spacing: .06em; }

/* ---------------------------------------------------------------------
   Party frame: the left-rail strip, one per teammate. The portrait tile
   with the level badge, the name row (the crown glyph on the leader),
   thin HP and MP bars. .is-on is the targeted member; .rail stacks them.
   --------------------------------------------------------------------- */
.frame { display: grid; grid-template-columns: auto 1fr; gap: .6em; align-items: center; width: 13em; padding: .35em .5em .35em .35em; background: rgba(246, 243, 238, .92); border-radius: .5em; box-shadow: 0 0 0 1px var(--win-rim), 0 .15em .4em rgba(var(--shadow-ink), .25); color: var(--ink); cursor: var(--cur-hand, pointer); }
.frame__pic { position: relative; display: block; }
.frame__pic .pill { position: absolute; right: -.45em; bottom: -.35em; min-width: 0; font-size: .7em; line-height: 1.4; padding: 0 .4em; background: var(--tan); color: var(--tan-text); }
.frame__name { display: flex; align-items: center; gap: .3em; font-size: .9em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.frame__name .ic { width: .9em; height: .9em; color: var(--orange); }
.frame__bars { display: grid; gap: .25em; min-width: 0; }
.frame.is-on { box-shadow: 0 0 0 2px var(--tan), 0 .15em .4em rgba(var(--shadow-ink), .25); }
.rail { display: grid; gap: .45em; align-content: start; }

/* ---------------------------------------------------------------------
   Toast: the one dramatic moment, set in the display face. A dark
   translucent band across the scene, the headline in tan with an
   engraved sparkle at each side, the line under it in the UI sans.
   --------------------------------------------------------------------- */
.toast {
  display: grid; justify-items: center; gap: .2em;
  padding: .9em 3em 1em;
  background: linear-gradient(to right, rgba(20, 12, 8, 0), rgba(20, 12, 8, .72) 18%, rgba(20, 12, 8, .72) 82%, rgba(20, 12, 8, 0));
  color: var(--tan-text); text-align: center;
}
.toast__title {
  display: inline-flex; align-items: center; gap: .5em;
  font: var(--face-display-weight) 2.4em/1.1 var(--face-display); letter-spacing: .04em;
  color: var(--tan); text-shadow: 0 .04em 0 rgba(255, 245, 230, .25), 0 .1em .3em rgba(0, 0, 0, .6);
}
.toast__title .ic { width: .7em; height: .7em; opacity: .8; }
.toast__line { font-size: 1em; color: var(--tan-text); opacity: .9; }
/* The result of a fight is this same band. It IS the dramatic moment - Victory,
   Defeat, You fled - so it takes the toast rather than a second banner drawn to
   say the one thing this component exists for. Three tones, and only the
   headline turns: --win is the tan the toast already wears, --loss the world's
   own red (with .scrim--loss behind it), --flee the quiet grey, because fleeing
   is neither a win nor a loss and should not be dressed as one. */
.toast--win .toast__title { color: var(--tan); }
.toast--loss .toast__title { color: var(--world-hit); }
.toast--flee .toast__title { color: var(--world-nil); }
/* A DROP on the result banner: the item's own tile, its name in its own rarity
   (the card title colours, so a thing reads here with the face it will have in
   the bag) and the count. Experience and Crowns stay plain .toast__line - they
   are numbers, not things - so only a row that names an item grows a picture,
   and the block still reads as one list under the headline. */
.toast__drop { display: inline-flex; align-items: center; justify-content: center; gap: .4em; }
/* The tile on a banner line is smaller than the smallest tile in a panel and
   its face is nearly gone: the band behind it is the ground here, and a
   parchment square under every drop would be a row of paper on a dark screen. */
.toast__drop .tile { font-size: .45em; --tile-face: rgba(255, 255, 255, .12); }

/* ---------------------------------------------------------------------
   Proclamation. The band the world says something in: you arrived
   somewhere, you reached a level, somebody on this server did a thing
   worth everyone's attention, the operator is talking. One band across
   the upper third, edge to edge, in gold on an ink wash that has no edge
   of its own - it fades out top and bottom into the world rather than
   being a box laid on it - with the engraved flourish either side of the
   words, dense at the outer edge and gone before the text.

   It is NOT the toast. The toast is the end of a fight: a block of lines
   parked over the field with a scrim behind it, and it waits to be read.
   This one is a sentence that crosses the screen and leaves on its own,
   and it never takes a click. Two components because they are two
   moments; a fight result drawn as this band would lose the drop list it
   exists to carry.

   Tone lives on the WORDS and never on the band, the same rule
   .toast--loss follows: --good for something that went right, --bad for
   something that did not. The band itself is always the same ink.

   Enter with a short fade and a 6px rise, hold, leave with a fade:
   .is-on is on for the hold and off for the leave, so nothing here needs
   a keyframe and the caller owns how long the hold is. Two
   seconds is the hold unless the caller has a number of its own (a
   server announcement carries how long it stands).

   WHERE it sits is not here: public/place.css .tl-procl parks it over
   the upper third, the same way it parks the announcement ladder.
   --------------------------------------------------------------------- */
.procl {
  display: flex; align-items: center; justify-content: center; gap: .4em;
  width: 100%; padding: .55em 0 .65em;
  /* No edge, top or bottom: the wash is strongest behind the words and
     dissolves into the world before it reaches anything with a corner. */
  background: linear-gradient(rgba(var(--world-dark), 0), rgba(var(--world-dark), .74) 42%, rgba(var(--world-dark), .74) 58%, rgba(var(--world-dark), 0));
  text-align: center;
  opacity: 0; transform: translateY(6px);
  transition: opacity .22s linear, transform .22s ease-out;
}
.procl.is-on { opacity: 1; transform: none; }
/* The flourish, one asset both sides (--orn-procl is mirrored by the
   scaleX below). A mask and not a picture, so the engraving takes the
   band's own gold and changes with it. It shrinks to nothing on a narrow
   screen rather than pushing the words out of the middle. */
.procl::before, .procl::after {
  content: ""; flex: 0 1 8.1em; min-width: 0; height: 3.2em;
  background: var(--world-gold); opacity: .85;
  -webkit-mask: var(--orn-procl) left center / auto 100% no-repeat;
  mask: var(--orn-procl) left center / auto 100% no-repeat;
}
.procl::after { transform: scaleX(-1); }
/* The one line, in the display face, large and tracked out: this is the
   only place in the game where a sentence is set as an inscription. The
   rim is the world layer's, because the band's wash is thin at the words'
   own height and a name over snow still has to hold. */
.procl__text {
  flex: 0 1 auto; max-width: 60%;
  font: var(--face-display-weight) 2.1em/1.2 var(--face-display);
  letter-spacing: .14em; text-transform: uppercase;
  color: var(--world-gold);
  text-shadow: 0 .04em 0 rgba(255, 245, 230, .18), 0 .08em .3em rgba(var(--world-dark), .8);
}
.procl__text--good { color: var(--world-heal); }
.procl__text--bad { color: var(--world-hit); }
/* A player's own notice: the same words, the same ink, nothing behind them.
   The wash and the flourish are the operator's announcement alone (owner,
   2026-09-16); without them the text's own rim is what holds it over the
   world, so the shadow is the world layer's full rim rather than the band's. */
.procl--plain { background: none; }
.procl--plain::before, .procl--plain::after { display: none; }
.procl--plain .procl__text {
  max-width: 90%;
  text-shadow:
    var(--world-rim) var(--world-rim) 0 var(--world-outline),
    calc(var(--world-rim) * -1) var(--world-rim) 0 var(--world-outline),
    var(--world-rim) calc(var(--world-rim) * -1) 0 var(--world-outline),
    calc(var(--world-rim) * -1) calc(var(--world-rim) * -1) 0 var(--world-outline),
    0 0 .4em rgba(var(--world-dark), .8);
}

/* ---------------------------------------------------------------------
   Quest sheet. What a quest IS, drawn in two places off one component:
   inside the NPC conversation, in place of its choice rows, and in the
   quest log's detail column. One component and not two, because the same
   quest read in a window and read in a conversation that differ in any
   particular are two quests to the player.
   The name is the one dramatic line here so it takes the display face;
   the category and the level band are the muted line beside it; the
   objectives are ordinary rows carrying the step kind's glyph and n/m,
   which turns gold the moment that step is full; the payout is one line
   of exp, money and the reward tiles. The quest's own prose is NOT here -
   it is behind the ( i ) - because this sheet says what to DO.
   --------------------------------------------------------------------- */
.qs { display: grid; gap: .5em; align-content: start; min-width: 0; }
.qs__head { display: flex; align-items: baseline; gap: .6em; }
.qs__name { font: var(--face-display-weight) 1.4em/1.15 var(--face-display); letter-spacing: .02em; color: var(--ink-strong); }
.qs__meta { display: flex; align-items: center; gap: .4em; margin-left: auto; color: var(--ink-mute); font-size: .9em; text-align: right; }
/* The story: muted, a few lines tall, scrolls past that. A long quest does not push the list
   and the pay out of the window. */
.qs__desc { max-height: 5.6em; padding-right: .4em; color: var(--ink-mute); font-size: .92em; line-height: 1.4; white-space: pre-line; }
/* The pay, one line: each figure with its coin, each item as a small tile with its name. The
   coins are one family at one size - crown, Lumi and the experience mark. */
.qs > .cap { font-weight: 700; color: var(--ink); }
.qs__pay { display: flex; align-items: center; flex-wrap: wrap; gap: .5em 1.2em; }
.qs__sums { display: inline-flex; align-items: center; gap: 1.2em; margin-left: auto; }
.qs__sum { display: inline-flex; align-items: center; gap: .3em; font-weight: 600; font-variant-numeric: tabular-nums; }
.qs__item { display: inline-flex; align-items: center; gap: .45em; }
.qs__item-name { color: var(--ink); }
.qs .item-row > .ic { width: 1.15em; height: 1.15em; color: var(--ink-mute); }
.qs .item-row__end.is-done { color: var(--rarity-gold-text); }

/* WHAT CAN BE PRESSED SAYS SO WITHOUT BEING HOVERED (user, 2026-09-16). A .chat-link carried a
   hover underline and nothing at rest, so the giver, the turn-in and the names inside the
   objectives read as plain prose until the pointer happened to cross one - and on an objective
   line the name sits mid-sentence ("Talk to Mayor Primar"), where there is no other clue at all.

   A dotted rule at rest, solid on hover: dotted is the quieter of the two and this is a list of
   several links, not one call to action, and the step up on hover keeps the hover meaningful.
   `text-underline-offset` because the display face has descenders that a flush underline cuts.

   Scoped to the quest sheet and the Who rows, NOT to .chat-link everywhere: the same class draws
   names in the chat box, where a line of underlined words would read as a page of links. */
.qs .chat-link,
.kv__npc .chat-link {
  color: var(--link-ink);
  text-decoration: underline dotted;
  text-underline-offset: .18em;
  text-decoration-thickness: from-font;
}
.qs .chat-link:hover,
.kv__npc .chat-link:hover { text-decoration: underline solid; }
/* A name somewhere else in the world is not a walk, so it must not wear a walk's underline. It
   keeps the dim treatment below and says where it is on its own card. */
.qs .chat-link.is-dim,
.kv__npc .chat-link.is-dim { color: var(--ink-mute); text-decoration: none; }

/* The objective line, coloured to be read in one pass: the thing to do in ink, the count in the
   figures' own tone, and the whole row in gold the moment it is full - the count alone turning
   gold was a two-character signal at the far right of a row the eye scans from the left. */
.qs .item-row__end { color: var(--ink-mute); font-variant-numeric: tabular-nums; }
.qs .item-row:has(.item-row__end.is-done) .item-row__name b { color: var(--rarity-gold-text); }
.qs .item-row:has(.item-row__end.is-done) > .ic { color: var(--rarity-gold-text); }
/* The pay reads as a reward: the figures in gold, the item name in its own rarity tone (the tile
   beside it already carries the band, and a name in plain ink under a purple rim said nothing). */
.qs__sum { color: var(--rarity-gold-text); }
/* A name that is somewhere else in the world: still a name, no longer a
   walk, and its map is on the card. */
.chat-link.is-dim { opacity: .6; cursor: var(--cur-arrow, help); }
.chat-link.is-dim:hover { text-decoration: none; }

/* ---------------------------------------------------------------------
   Loading screen: the curtain. Not a window - a window is a thing you
   open and close, and this is the whole screen going away and coming
   back. An ink scrim over everything, one parchment band straight across
   the middle with the title in the display face, the note under it and
   the shipped .bar--sage in it. No frame, no engraving, no close: there
   is nothing here to act on.

   One picture may sit behind the band (--art, a map's own art), and the
   scrim is painted over it so the band still reads.
   --------------------------------------------------------------------- */
.ldg {
  position: fixed; inset: 0; z-index: 200;
  display: grid; align-content: center;
  background: #0b0907 var(--art, none) center / cover no-repeat;
}
/* The scrim is its own layer so a picture behind it keeps its own size and
   the ink over it is one flat value whatever the picture is. */
.ldg::before { content: ""; position: absolute; inset: 0; background: rgba(12, 9, 7, .72); }
.ldg__band {
  position: relative;
  display: grid; justify-items: center; gap: .55em;
  padding: 2.2em 2em 2.4em;
  background: linear-gradient(var(--parch), var(--parch) 78%, var(--parch-edge));
  box-shadow: 0 0 2em rgba(0, 0, 0, .55);
}
/* The official logo, at the top of the band on every wait: as wide as the
   bar under it, never wider, and a hidden title under it leaves no gap. */
.ldg__logo { width: min(18em, 56vw); height: auto; }
.ldg__title[hidden] { display: none; }
/* The one dramatic line on the screen, so it takes the display face - the
   same reason .toast__title does. */
.ldg__title {
  font: var(--face-display-weight) 2.4em/1.1 var(--face-display);
  letter-spacing: .06em; color: var(--ink-strong);
}
.ldg__note { color: var(--ink-mute); }
/* As wide as a reading line and no wider: a track across a whole monitor
   reads as a wall rather than as progress. */
.ldg .bar { width: min(26em, 62vw); }

/* typeface: both faces are fixed on :root (Rajdhani for the UI, Grenze for display);
   the only control left is the preview toggle for the display face. */
.faces__preview { display: flex; gap: 8px; align-items: flex-start; margin: 14px 0 0; max-width: 62ch; color: var(--ink-mute); font-size: 13px; cursor: var(--cur-hand, pointer); }
.faces__preview input { margin-top: 3px; }
:root.preview-display .win__title, :root.preview-display .btn { font-family: var(--face-display); font-weight: var(--face-display-weight); letter-spacing: .02em; }

/* ---------------------------------------------------------------- CURSORS
   The game's twelve cursors. The pictures are arts/ui-icons/cursor-*.png and
   arts/ui-icons/cursors.json holds the hotspot, the frame count and the native
   keyword for each; src/client/cursors.ts reads that table and writes one
   --cur-<state> custom property per state onto <html> at boot.

   THE RULE: no sheet the game loads ever spells a bare cursor keyword. A component
   that presses writes `cursor: var(--cur-hand, pointer)`, one that rests writes
   `var(--cur-arrow, default)`, and so on - the drawn picture IS the meaning of the
   word, and the keyword after the comma is only what a page without the tokens
   (the editor, a sheet the script has not reached) falls back to. A bare keyword
   is the OS picture and nothing here can remap it, because `cursor` inherits but
   an element's own declaration always wins over what it inherits - which is why
   `cursor: default` is the worst offender: it reads as "back to normal" and shows
   the OS arrow. scripts/check-cursors.ts fails the build on any bare keyword.

   So this block is small: the page default, the elements that have no class and
   so no rule of their own, and the errand cursors that no component declares.
   Scoped to body.play so the editor keeps its native set. */
body.play { cursor: var(--cur-arrow, default); }
/* Bare elements a click presses: the UA sheet gives these `pointer` or nothing. */
body.play :is(a[href], button, summary, label, [role="button"]) { cursor: var(--cur-hand, pointer); }
/* A dropdown's open list wears the hand as one piece, padding between the rows included. */
body.play :is(.select__list, .packmenu) { cursor: var(--cur-hand, pointer); }
/* Typing, and the two gestures that move a thing rather than press it. */
body.play :is(textarea, [contenteditable="true"],
  input:not([type="checkbox"], [type="radio"], [type="range"], [type="submit"])) { cursor: var(--cur-text, text); }
body.play :is(.win__head, .gs-slot) { cursor: var(--cur-grab, grab); }
body.play :is(.win__head, .gs-slot, .tile[draggable="true"]):active { cursor: var(--cur-grabbing, grabbing); }
/* The corner grip keeps the browser's own resize arrow: it is the one gesture the
   system already says well, and a drawn one would have to be drawn eight times. */
/* The states with the errand drawn into them. A link to an NPC walks you over and
   talks, a pin that moves you is the portal, the button that starts a gathering run
   is the pickaxe, and what a fight pays out is the chest. */
body.play :is(.chat-link, .mappin--service) { cursor: var(--cur-talk, pointer); }
body.play :is(.mappin--portal, .mappin--instance) { cursor: var(--cur-portal, pointer); }
/* A name that walks you to someone presses like anything else - the hand - and the dimmed one
   on another map says nothing, because a click there does nothing. */
body.play .kv__npc { cursor: var(--cur-hand, pointer); }
body.play .kv__npc.is-dim { cursor: var(--cur-arrow, default); }
body.play .kv__npc .chat-link { cursor: inherit; }
body.play .gth [data-go] { cursor: var(--cur-gather, crosshair); }
body.play .gs-banner__gain--drop { cursor: var(--cur-loot, default); }
/* The curtain: the wait is the game's, not a click that did nothing. */
body.play :is(.ldg, [aria-busy="true"]) { cursor: var(--cur-busy, progress); }
/* Nothing to press, nothing to promise. */
body.play :is(button, .btn, .ibtn, .tabs__tab):disabled,
body.play :is([aria-disabled="true"], .is-disabled, .tile--locked, .gs-slot--empty) { cursor: var(--cur-arrow, default); }

/* the cursor catalogue on this page: pictures, not live cursors, because the rules
   above are scoped to the game page */
.curs { display: flex; flex-wrap: wrap; gap: .9em; }
.curs figure { display: grid; justify-items: center; gap: .2em; width: 7em; margin: 0; padding: .7em .3em; background: var(--paper-2); border-radius: .4em; }
.curs img { width: 32px; height: 32px; image-rendering: auto; }
.curs b { font-size: 12px; color: var(--ink); }
.curs i { font-size: 11px; font-style: normal; color: var(--ink-faint); }
