/* ============================================================
   parts.css — the printed part's material, and the lit layer
   over the builder's own controls.

   THE SITING NOTE THIS SUPERSEDES. Both halves of this file were
   written in local-dev/ (parts-tokens.css, parts-fx.css) while
   nothing consumed them, and both carried a note saying they
   would move into css/v3/tokens.css and css/v3/builder.css
   "verbatim" once the kit was placed. The kit is placed now —
   the Blitz Builder's roster cards draw playerSprue() — and the
   verbatim move is the wrong shape:

     - tokens.css is ONE `html.ctd-v3, .ctd-scope { ... }` block
       from line 27 down. The token runs would splice into it,
       but this file also carries RULES ([data-cat] on the tag
       shapes, .ctd-part-scale, .ctd-base-paint, and every
       animation in the second half). A rule cannot live inside
       that block, so the "verbatim" move would have split one
       coherent material across two files at an arbitrary seam.
     - The reason for the original note was the orphan-token
       loose end (see night-pitch-effects): a --part-* run in
       tokens.css with no consumer. A file enqueued ONLY on the
       pages that consume it answers that better than one merged
       into a stylesheet that loads everywhere.

   So: one file, one material, enqueued by inc/enqueue.php on
   builder pages beside css/v3/builder.css. Everything below is
   the two local-dev files in order, with their :root blocks
   moved onto the scope selector — see the note below on why.

   THE SCOPE SELECTOR, NOT :root. Every token in css/v3 is
   declared on `html.ctd-v3, .ctd-scope` during the migration
   (v3-token-scoping) so it cannot reach a legacy page. These
   namespaces are new and could not collide with anything, but
   they follow the same rule anyway — at cutover the whole tree
   flips to :root in one pass, and a file that opted out is a
   file that gets missed.
   ============================================================ */

/* ==== the material: the resin, the tag, the paint zones ==== */

/* ============================================================
   THE PRINTED PART's material, as tokens.

   Every value below is derived from the Night Pitch palette in
   css/v3/tokens.css — this file adds no colour of its own except
   --resin, and there is a note on why that one is a raw hex.

   THE MATERIAL IS THE ARGUMENT. The room is stone, timber, iron
   and torn grass — four things that have been out in the weather
   for a century. What the builder sells is the one NEW object in
   it: a part that came off a printer this week, still with the
   gate scar where it was cut off the runner. So the resin is not
   another worn surface. It is lighter in value than every ground
   it sits on, its edges are FILLETED rather than chipped, and its
   only wear is a rim scuff and dust in the recesses.

   THE VALUE LADDER, lit from the top-left like everything else:
     --part-lit    the fillet the light catches       (lightest)
     --part-face   the top land
     --part-face-2 the rim land, one step down
     --part-lo     the fillet turned away
     --part-shade  a recess floor
     --part-void   the bottom of a slot               (darkest)
   Nothing between them is a gradient. Six flat tones and the
   shapes between them do the modelling.
   ============================================================ */

html.ctd-v3, .ctd-scope {
  /* ---- the resin ----------------------------------------
     IT HAS ITS OWN BASE COLOUR NOW, and that is the fix for
     "muted and old" (owner, 2026-09-06). The first cut built the
     plastic out of the theme's own two neutrals — --ink-700
     (dark plum) mixed into --bone-500 (warm khaki) — and those
     two are the palette of a hundred-year-old stadium on
     purpose. Mixed together they make mud: a brownish grey that
     reads as weathered whatever you do to the value ladder.

     --resin is a COOL, CLEAN light grey, the colour of fresh
     printed plastic, and it is a raw hex rather than a mix for
     exactly the same reason --slab is one in tokens.css: the
     theme's neutrals cannot get here. This is also the header's
     own argument finally paid off — the parts are meant to be
     the one NEW thing standing in an old room, and a new thing
     is not the old room's colours at a different brightness.

     It still has to sit ABOVE --surface at every tone or the
     part reads as a hole in the card rather than an object on
     it. It is now allowed PAST --bone-300 at the top of the
     ladder, which the first cut forbade to stop a control
     outshouting body copy — the dome's crown is a 4-unit band on
     a 24-unit tag, not a field, and holding the whole material
     down to protect one hairline is what made it dull. */
  /* DECLARED IN css/v3/tokens.css. It is a raw hex, and every raw colour in
     v3 lives in that one file — the note above is why this particular one
     cannot be a mix of the theme's neutrals. */

  /* ============================================================
     MATT BLACK, AND THIS IS THE THIRD AND LAST WORD ON IT
     (owner, 2026-09-07: "the base looks shiney, it's very grey — it would be
     matt black like the rest of the sprue's resin. do the same for the skills,
     it's all made out of the same material, in black. but do give it some
     definition.")

     THE ARGUMENT THIS RETIRES was in the file header from the first cut: the
     product is near-black plastic photographed on a light bench, this is a
     near-black page, so rendering it true would make it a silhouette — pitch
     it above --surface instead. It sounded right and it was wrong, and the
     reason is worth keeping because it is a general one.

     A BLACK OBJECT IS NOT READ BY ITS FIELD, IT IS READ BY ITS EDGES. Look at
     assets/photos/devious_rainbow_sprue: frame, runner, tags and lettering are
     one near-black plastic on a white bench, and every part of it is legible —
     entirely from the highlight along each lit edge and the shade in each
     recess. Lifting the field to make the part visible is solving the problem
     the wrong way round: it gets you a grey object, and a grey object with a
     gradient on it looks POLISHED, which is what "shiney" was naming. Matt
     plastic has almost no field contrast and very crisp edge contrast.

     SO THE VALUE WENT DOWN AND THE EDGES WENT UP, and that trade is the whole
     change. Every lit tone in this file now mixes toward --bone-100 rather
     than white (warm, and matt), the print lines went from a dark wash to a
     LIGHT one (a print ridge on black catches light — see --part-ridge), and
     the dome's ramp is weighted toward its highlight because on a black face
     there is nowhere left to go downward.

     #2E3138 rather than #000: a cool near-black that still sits a step above
     --surface at the top of its ladder, so a part on a card has somewhere to
     be. True black would make the recesses unreachable — there has to be
     somewhere darker than the face for a channel to go.
     ============================================================ */

  /* IT WENT DOWN A LONG WAY, 2026-09-07, and the owner's word for what was
     wrong is "more refined / medieval / dark / gothy, like the rest of the
     site". #B4B7C0 was the fix for "muted and old" and it over-corrected: a
     cool light grey on a near-black plum page is a LABORATORY colour, and it
     put every piece of furniture built out of it — the quiet button, the
     gauge body, the bench tray, the print bed — a full step brighter than the
     card it was lying on. The room reads dark; the kit read like a UI toolkit
     dropped into it.

     The product photographs are the authority and they always were: the real
     parts are NEAR-BLACK matte plastic (assets/photos/devious_rainbow_sprue,
     skill_example). The old header note argued that rendering them true would
     make them a silhouette on this ground, and that is still true at the
     bottom of the ladder — but it was being used to justify a value the
     object never had. #6F7484 is the compromise stated honestly: a cool dark
     pewter, deep enough to belong to the room, two clear steps above
     --surface so nothing reads as a hole in a card, and still carrying its
     coolness so a part is not just the old room at another brightness.

     WHAT THIS DOES NOT CHANGE is the direction of the ladder or the
     lettering. The code stays dry-brushed bone, moulded proud; because the
     face went down, its contrast went UP, which is the one thing that gets
     better rather than harder as the material darkens. */

  /* THE MIX MOVED UP AS THE BASE MOVED DOWN, and it has to. These are
     fractions of --resin against --ink-800, so darkening the resin darkens
     the face twice over — once because the base is darker and once because
     the same 74% of a darker colour lands nearer the surface it is mixed
     into. At the old 74% the new resin put --part-face within a hair of
     --surface, which is the one thing the ladder must never do: a part the
     value of the card it lies on has no edge, and no edge is no object. So
     the face takes the resin nearly neat and the modelling range is bought
     back at the BOTTOM of the ladder instead, where a dark material has
     room. */
  --part-face:   color-mix(in srgb, var(--resin) 92%, var(--ink-800));
  --part-face-2: color-mix(in srgb, var(--resin) 74%, var(--ink-800));
  /* BONE, NOT WHITE. White into a near-black face is a grey highlight and
     grey highlights are what "shiney" looks like; --bone-100 is the warm
     off-white every other surface in this room is lit by. The amounts also
     went UP, because on a black material the edge is doing all the work the
     field used to do. */
  --part-lit:    color-mix(in srgb, var(--bone-100) 34%, var(--part-face));
  --part-lit-soft: color-mix(in srgb, var(--bone-100) 15%, var(--part-face));
  --part-lo:     color-mix(in srgb, var(--ink-950) 52%, var(--part-face));
  --part-shade:  color-mix(in srgb, var(--ink-950) 70%, var(--part-face));
  --part-void:   color-mix(in srgb, var(--ink-950) 90%, var(--part-face));

  /* The shadow the part casts on the card it lies on. Hard, no
     blur — this room does not own a blur. */
  --part-cast: color-mix(in srgb, var(--ink-950) 62%, transparent);

  /* ---- what makes it PRINTED rather than moulded ---------
     Layer contours. The single strongest cue and the cheapest:
     concentric rings on a disc, straight passes on a tag. They
     must sit at the very bottom of what the eye can resolve —
     at 6% they stop being a finish and become corduroy. */
  /* --part-layer IS THE IRON'S, AND --part-ridge IS THE RESIN'S. They were
     one token and they cannot be: roll() darkens a rolled steel plate with it,
     which wants ink, while a print line on matt black resin is a RIDGE and a
     ridge catches light. A single dark token served both while the resin was
     grey; on black it makes the layer lines vanish and taking it lighter would
     have inverted the iron. */
  --part-layer:     color-mix(in srgb, var(--ink-950) 13%, transparent);
  --part-ridge:     color-mix(in srgb, var(--bone-100) 9%, transparent);

  /* The seam down a moulded face, and the stub where the part
     was nipped off its runner. Freshly cut plastic is PALER than
     the moulded skin, which is why --part-gate goes up toward
     bone and not down toward the shade. */
  --part-seam: color-mix(in srgb, var(--ink-950) 26%, transparent);
  --part-gate: color-mix(in srgb, var(--bone-100) 26%, var(--part-face));

  /* ---- the runner ----------------------------------------
     THE SPRUE IS DARK and the parts on it are not, which is the
     whole read of the print render: the frame is the ground, the
     tags are the figures. Near-black rather than black — a true
     black on this page would punch a hole in it, and moulded
     plastic in shadow is never actually black anyway.

     --tag-blank is where an UNPRINTED tag sits: enough resin
     mixed back in that it is visibly an object with an edge and
     a dome, dark enough that a printed tag laid over it reads as
     the event. On the render the blanks are barely lighter than
     the frame; this is a shade more generous than that, because
     on screen a blank is also the thing you click. */
  --sprue-face: color-mix(in srgb, var(--ink-950) 46%, var(--resin));
  /* ============================================================
     THE FRAME OUTSHINES THE PARTS, and getting that the wrong way round is
     what made the tags look like they were sticking OUT (owner, 2026-09-07:
     "the skills look like they are sticking out of the sprue because of the
     shading — they are BACK in the sprue").

     The frame's front face is the highest surface on the moulding and a tag's
     face sits below it, so the frame must own the brightest edge on the part.
     It did not: --sprue-lit was bone 22% and --tag-lit was bone 36%, so every
     tag had a lit edge HALF AGAIN as bright as the frame holding it. On matt
     black the lit edge is the whole depth cue — there is no field contrast to
     argue with it — so a brighter edge simply reads as nearer, whatever the
     geometry says. The two swapped: the frame goes up, the tag comes down.
     ============================================================ */
  --sprue-lit:  color-mix(in srgb, var(--bone-100) 34%, var(--sprue-face));
  --sprue-lo:   color-mix(in srgb, black 46%, var(--sprue-face));
  /* A BLANK IS THE FRAME'S OWN VALUE, near enough — it differs from the
     runner by its EDGE, not its colour, which is the whole matt-black
     principle and what the print render shows. At 78% of the raw resin it was
     a visibly paler slab than the frame, which put it in front. */
  --tag-blank:  color-mix(in srgb, var(--resin) 30%, var(--sprue-face));

  /* The WELL each tag sits in — a shallow recess in the runner,
     darker than the runner itself, so the ring of ground showing
     round every tag reads as BELOW the surface rather than as
     part of it. Without it a tag looks stuck on however much
     margin it is given. */
  --sprue-well: color-mix(in srgb, black 50%, var(--sprue-face));

  /* ---- the tooth of the plastic ---------------------------
     A moulded part is MATTE, and matte is not smooth — it is a
     fine even tooth that catches the light. The first cut had
     one surface pass on it (print layer lines at 13% ink,
     stepped wide) and came out looking like flat vector, which
     is what "everything looks very flat" meant. These two are
     the speckle: half catching the light, half in shade. Both
     stay under 10% or the tooth becomes dirt, and dirt is the
     one thing this material must not have. */
  --part-grain:     color-mix(in srgb, var(--ink-950) 9%, transparent);
  --part-grain-lit: color-mix(in srgb, white 9%, transparent);

  /* ---- wear, and there is very little of it -------------- */
  --part-scuff: color-mix(in srgb, var(--bone-100) 22%, transparent);
  --part-dust:  color-mix(in srgb, var(--ink-950) 34%, transparent);

  /* ---- what is printed ON it -----------------------------
     RAISED AND PICKED OUT LIGHT, which is the correction the
     product photographs forced. The lettering on these parts is
     moulded proud and dry-brushed: pale type on a dark face with
     its own shadow down and to the right. The first pass struck
     it in instead — dark letters with a lit hairline under them —
     which is an engraved trophy plaque, not the thing that
     arrives in the post.

     The number is the exception that stays gold, because across
     a table it is the one thing anybody is looking for. */
  --part-ink:      color-mix(in srgb, var(--bone-100) 97%, var(--part-face));
  --part-ink-lo:   color-mix(in srgb, var(--ink-950) 76%, var(--part-face));
  --part-number:   var(--gold-300);
  --part-number-lo: color-mix(in srgb, var(--ink-950) 74%, var(--gold-600));

  /* --part-print / --part-print-lit WERE HERE AND ARE DELETED.
     They were "kept for the two things that genuinely are cut
     into a surface: a struck metal plate and a printed docket",
     and by the time they were written neither of those two used
     them — the step plate struck its numeral with the girder
     tones and the docket printed with the paper's. So they were
     already an orphan pair at the last commit, and they are now
     superseded outright: everything cut into iron takes
     --part-cut / --part-cut-lit below, which are mixed off the
     right base for it. A token kept "in case" is a token the
     next reader has to disprove. */

  /* ---- lettering CUT INTO IRON --------------------------
     The two above are struck into RESIN, so they are mixed off
     --part-face. The furniture is iron, and iron is a different
     colour and a much wider range: --part-print on a girder
     comes out as a grey smudge because it is a fraction of the
     wrong base.

     A chisel cut in metal is nearly the void — the groove takes
     no light at all — and it is read almost entirely by the
     BRIGHT LIP the chisel raised along its lower edge. That is
     the whole difference between engraved and printed, and it is
     why the lip is a fraction of --bone-100 rather than of the
     plate: a burnished edge is the brightest thing on a piece of
     old iron. */
  --part-cut:     color-mix(in srgb, var(--ink-950) 82%, var(--girder-face));
  --part-cut-lit: color-mix(in srgb, var(--bone-100) 40%, var(--girder-lit));

  /* ---- the render niche ---------------------------------
     Where a 3D render stands. Every tone is a step DARKER than
     the card it is set into, because the one job of a backdrop
     is to be quieter than the thing in front of it — and because
     a render arrives with its own lighting, which a bright
     backdrop would argue with. --mount-lip is the single lit
     hairline along the front of the shelf, and it is doing all
     the work: it is what turns a flat panel into a place with a
     standing surface. */
  --mount-back:   color-mix(in srgb, var(--ink-950) 68%, var(--ink-800));
  --mount-floor:  color-mix(in srgb, var(--ink-950) 46%, var(--ink-800));
  --mount-lip:    color-mix(in srgb, var(--bone-100) 22%, var(--ink-700));
  --mount-pool:   color-mix(in srgb, var(--ink-950) 82%, transparent);
  --mount-pool-2: color-mix(in srgb, black 62%, transparent);
  --mount-mark:   color-mix(in srgb, var(--bone-500) 62%, var(--mount-back));

  /* ---- geometry -----------------------------------------
     The tag is the only part left with a fixed display size:
     the rings and the boxed set are renders now, so the two
     base diameters that used to live here went with them. */
  --part-tag-w: 60px;
  --part-tag-h: 24px;
}

/* ---- the seven skill categories, as PLASTIC ---------------
   The category colours in tokens.css are TEXT colours, tuned to
   read on --ink-950. Straight onto a part they are poster paint:
   a tag is a moulded chip with a tint through it, not a swatch.

   THE FACE GOES DOWN, NOT UP. A tag is dark plastic with the code
   dry-brushed pale on it — so the tint is mixed into a tone well
   below the ring's own band, not into the band. Three things fall
   out of that and all three are wanted: the pale code has a real
   contrast ratio to sit on, a seated tag reads as sitting DOWN in
   its slot rather than stuck on top of it, and no tag ever ends
   up brighter than the body copy beside it.

   The ladder above each face is rebuilt FROM that face rather
   than shared, which is why a Passing tag's lit edge is warm and
   a General tag's is cold — as it would be in the hand. It is
   also what keeps one category from drifting out of step with
   the other six: there is one ladder, written once.

   A tag has to survive COLOUR-BLIND MODE, where colour is the
   first thing to go. The raised CODE is the real answer, because
   it is on the actual part; the hatch is the second signal for
   the sizes where the code cannot be read, never the only one. */
html.ctd-v3, .ctd-scope {
  /* ---- the seven categories, as PIGMENT rather than as text ----
     THE --cat-* TOKENS ARE TEXT COLOURS. They are pale, low-
     chroma and tuned to read as type on near-black, and mixed a
     third of the way into a dark base — which is what the first
     cut did — they produce seven greys with a rumour of hue in
     them. That is the other half of "muted and old".

     A moulded chip is not type. It is PIGMENT THROUGH THE
     PLASTIC, so these are proper saturated colours, stated
     rather than derived, and then taken down into the resin far
     enough that the pale raised code still has a contrast ratio
     to sit on. Every one of them keeps its category's identity
     from --cat-* — blue general, green agility, red strength,
     gold passing, violet mutation, slate devious, bone trait —
     it is the same hue family with the chroma the screen version
     could not carry.

     They are declared here and NOT in css/v3/tokens.css on
     purpose: --cat-* stays the text ladder, and nothing that
     prints a skill NAME should reach for these. */
  /* THEY ARE JEWELS NOW, not poster paint (owner, 2026-09-07: refined,
     medieval, dark). The first saturated cut was the right correction from
     the greys it replaced and it went one stop too bright: seven colours at
     that value on a near-black page are a row of highlighter pens, and
     brightness is what made a moulded chip look like a sticker.

     A jewel tone is the SAME HUE at a lower value and equal-or-higher
     chroma, and it is the right reference for two reasons beyond taste.
     Stained glass is the one place a medieval building keeps saturated
     colour, and it keeps it exactly this way — deep, dense, lit from behind
     rather than pale. And it is what the material actually is: pigment
     THROUGH a dark plastic, not paint on top of a light one.

     Every category keeps its identity from --cat-*: sapphire general,
     emerald agility, oxblood strength, amber passing, amethyst mutation,
     slate devious, ivory trait, garnet characteristic. */
  /* ALL OF THEM ARE DECLARED IN css/v3/tokens.css, for the same reason --resin
     is: they are stated colours rather than derived ones. The note above
     travels with them there. */

  /* the ground a pigment is taken down into: dark enough for the
     pale code, light enough that the colour survives it */
  /* barely below the plain resin now — the tag is the same black as
     everything else, and what separates a tag from the runner is its lit edge
     and the well it sits in, not its tone */
  /* level with the frame, not above it — see the note on --sprue-lit. A tag's
     face is BELOW the frame's front face, so it cannot be the lighter of the
     two and be believed. */
  --part-tag-base: color-mix(in srgb, var(--ink-900) 58%, var(--resin));
}

/* THE TINT IS A WHISPER NOW, AND THAT IS THE ONE COST OF GOING BLACK.
   The pigment share was 58-76%, which is a coloured chip; on one black
   material it drops to a fifth, so what you get is black plastic with the
   category's hue THROUGH it — visible side by side, and not a colour cue at
   a glance. That is what "it's all the same material, in black" means, and it
   is true of the product: the photographed sets are one black.

   IF THE CATEGORY EVER HAS TO BE LEGIBLE AT A GLANCE AGAIN, the tint is the
   wrong place to buy it back — going up from here just undoes this pass. The
   two real options are the CODE (dry-brush the lettering in --cat-*, which is
   a text ladder already tuned for exactly that job) or the hatch that
   colour-blind mode already ships. Both put the signal on the part's ink
   rather than its body, which is where a signal belongs on a black moulding. */
[data-cat] { --tag-face: var(--part-tag-base); }
[data-blank] { --tag-face: var(--tag-blank); }
/* THE SHARE MORE THAN DOUBLED, ~20% → ~46% (owner, 2026-09-07: "the colours
   need to be more dominant, they are very muted"). At a fifth the hue was a
   rumour: I had called that out as the one cost of going matt black and said
   buying it back through the tint would undo the pass. It does not, and the
   reason is worth having.

   ON A DARK GROUND, COLOUR IS BOUGHT WITH CHROMA, NOT WITH VALUE. What made
   the first saturated set look like highlighter pens was its LIGHTNESS, not
   its saturation — seven pale-bright chips on a near-black page. Mixed
   near-half into a #221F2C base, these same jewel hexes land as deep navy,
   oxblood, bronze, amethyst: the hue is unmistakable and the VALUE barely
   moves, because a dark saturated colour is still dark. General comes out
   about #2D416C, which is a fifteenth of the page's lightness away from the
   plain resin and yet obviously blue.

   So this is not a retreat from matt black — the material's value ladder is
   untouched. It is the correction of a wrong assumption: that tint share and
   brightness are the same dial.

   THE TWO NEAR-NEUTRALS TAKE LESS. Ivory and slate have little chroma to give,
   so the same share would only lighten them — trait sits at 40% and reads as
   the warm grey that ivory-through-black actually is. */
[data-cat="g"] { --tag-face: color-mix(in srgb, var(--pig-g) 46%, var(--part-tag-base)); }
[data-cat="a"] { --tag-face: color-mix(in srgb, var(--pig-a) 45%, var(--part-tag-base)); }
[data-cat="s"] { --tag-face: color-mix(in srgb, var(--pig-s) 46%, var(--part-tag-base)); }
[data-cat="p"] { --tag-face: color-mix(in srgb, var(--pig-p) 44%, var(--part-tag-base)); }
[data-cat="m"] { --tag-face: color-mix(in srgb, var(--pig-m) 47%, var(--part-tag-base)); }
[data-cat="d"] { --tag-face: color-mix(in srgb, var(--pig-d) 48%, var(--part-tag-base)); }
[data-cat="t"] { --tag-face: color-mix(in srgb, var(--pig-t) 40%, var(--part-tag-base)); }
/* Characteristic (2026-09-16). Garnet is fully chromatic, so it takes the
   chromatic share rather than ivory's reduced one — see the note above on why
   the two near-neutrals are the exception and not the rule. */
[data-cat="c"] { --tag-face: color-mix(in srgb, var(--pig-c) 46%, var(--part-tag-base)); }

/* One ladder, rebuilt off whatever face landed above — and it is declared for
   :root as well as [data-cat], with --tag-face defaulting to the plain resin.
   That default is what lets ONE drawing routine serve a Strength tag and a
   player-number tag: the number sits outside any [data-cat] and simply gets
   the untinted ladder. Nothing in the generator has to branch.

   --tag-crown through --tag-foot are the DOME: flat steps down the
   face, lightest just under the top edge, darkest just above the
   bottom, which is what makes a flat plate read as a rounded one
   (owner, 2026-09-06: "use shadows etc to make the skill itself
   look rounded").

   NOTHING RUNS ACROSS THE ENDS. A --tag-end shade was tried down
   the left and right, on the argument that the face turns away
   there, and it read as a hard stripe with two visible edges
   rather than as a curve. A cylinder lit from above does not
   darken at its ends; a sphere does, and a tag is not one. */
html.ctd-v3, .ctd-scope {
  /* THE DEFAULT IS THE TAG'S OWN BASE, NOT THE RESIN'S TOP LAND. --part-face
     is the brightest tone the material has and it belongs to a part standing
     on a card; an untinted TAG — a number, a blank — is still a tag sitting in
     a frame, so it starts where every other tag starts. Pointing the default
     at --part-face is what made the number stubs the palest thing on a sheet. */
  --tag-face: var(--part-tag-base);
}

html.ctd-v3, .ctd-scope, [data-cat], [data-blank] {
  /* THE HIGHLIGHTS ARE BONE, NOT WHITE (2026-09-07). Pure white mixed into a
     saturated face gives a desaturated pale tint, and a pale tint along the
     top of a coloured plate is GLOSS — the read is injection-moulded toy, not
     matte printed part, and it is the other half of what made the set look
     like a UI kit rather than the product. --bone-100 is the warm off-white
     everything else in this room is lit by, so a tag lit by it belongs to the
     same scene as the iron beside it. The amounts came down at the same time
     and the shade went UP: on a matte surface the turn away from the light is
     the bigger event, and moving the modelling downward is what darkens the
     material without touching its hue. */
  --tag-lit:    color-mix(in srgb, var(--bone-100) 22%, var(--tag-face));
  --tag-lo:     color-mix(in srgb, var(--ink-950) 56%, var(--tag-face));
  /* the raised code, dry-brushed: near enough to pure bone that it
     is the brightest thing on the part, which is true of the object */
  --tag-ink:    color-mix(in srgb, var(--bone-100) 97%, var(--tag-face));
  --tag-ink-lo: color-mix(in srgb, var(--ink-950) 78%, var(--tag-face));

  /* THE RAMP IS WEIGHTED TOWARD ITS HIGHLIGHT, because a near-black face has
     nowhere left to go downward — the old 21% and 41% of ink were most of the
     modelling when the resin was grey and are almost nothing now. Light is the
     only axis with room on this material. */
  --tag-crown-2: color-mix(in srgb, var(--bone-100) 24%, var(--tag-face));
  --tag-crown:   color-mix(in srgb, var(--bone-100) 12%, var(--tag-face));
  --tag-belly:   color-mix(in srgb, var(--ink-950) 26%, var(--tag-face));
  --tag-foot:    color-mix(in srgb, var(--ink-950) 48%, var(--tag-face));
}

/* ---- the two things that are not plastic and not iron -----
   ONE "CANVAS" SET WAS SERVING BOTH OF THEM, and that is what
   forced the split (2026-09-07). The docket is a punched works
   order — PAPER, pale, printed dark, and deliberately the only
   paper in the builder. The banner is the team's colours hung
   up — CLOTH. They are different materials with opposite value
   structures, and while they shared a ladder the banner had to
   be pale card with the team's name printed on it in near-black:
   a sign, and the brightest object in the review modal.

   A heraldic banner is a DARK FIELD WITH A PALE DEVICE, which is
   both what the owner's "dark and gothy" asks for and what the
   object has always been. Splitting the tokens is what lets the
   docket stay paper while the banner goes to cloth. */
html.ctd-v3, .ctd-scope {
  /* PAPER — the docket, and nothing else. Pale, because it is
     the one thing in the builder that is printed rather than
     made, and that contrast is its whole job. */
  --canvas-face: color-mix(in srgb, var(--bone-500) 58%, var(--ink-800));
  --canvas-lit:  color-mix(in srgb, var(--bone-100) 26%, var(--canvas-face));
  --canvas-lo:   color-mix(in srgb, var(--ink-950) 34%, var(--canvas-face));

  /* CLOTH — the banner. A dyed field, dark enough to belong in
     the room, warm enough not to be another piece of iron. The
     name is cast on it in --bone-100 and the eyelets bite
     through it, so the field only has to be dark and even. */
  --cloth-face:   color-mix(in srgb, var(--ink-800) 74%, var(--bone-500));
  --cloth-lit:    color-mix(in srgb, var(--bone-100) 13%, var(--cloth-face));
  --cloth-lo:     color-mix(in srgb, var(--ink-950) 52%, var(--cloth-face));
  --cloth-sub:    color-mix(in srgb, var(--bone-300) 82%, var(--cloth-face));
  --cloth-fray:   color-mix(in srgb, var(--ink-950) 66%, var(--cloth-face));
  --cloth-stitch: color-mix(in srgb, var(--bone-100) 22%, transparent);
  --cloth-eyelet: color-mix(in srgb, var(--girder-lit) 90%, var(--bone-500));
}

/* ============================================================
   ONE SCALE FOR THE WHOLE KIT — 2026-09-07.

   Owner: "so that the 32mm skills all look the same on the site
   when together." Making the tag one SIZE in ctd-parts.js is
   only half of that; the other half is that two sprues shown
   side by side have to be rendered at the same pixels-per-unit,
   and nothing was enforcing that. Every width in the labs was
   picked by eye, so a 500-unit skill sheet at 700px and a
   1270-unit team frame at 1500px drew the same 214-unit tag at
   300px and 253px.

   Every part now publishes --pw / --ph (its natural size in its
   own units — see svg() in ctd-parts.js), so a container can
   set ONE number and every part inside it comes out at the same
   scale, whatever shape it is:

     <div class="ctd-part-scale" style="--part-px:0.6px"> ... </div>

   --part-px is the size of one drawing unit. 1px is the parts
   at their natural size; anything else scales the whole group
   together and keeps the tags identical across frames.

   This is a UTILITY, NOT THE CONTRACT. An asset still carries
   no width of its own, and a placement that wants one part at
   one fixed width should just set a width — two parts of
   different shapes rarely want to share a scale. It is for the
   case the owner is describing: the same part, in different
   frames, on one page.
   ============================================================ */
.ctd-part-scale > svg,
.ctd-part-scale > * > svg {
  width: calc(var(--pw, 100) * var(--part-px, 1px));
  height: auto;
  display: block;
  max-width: 100%;   /* never break the page — a narrow viewport wins */
}

/* ============================================================
   THE BASE'S PAINT ZONES — 2026-09-07.

   Owner: "the base sprue needs to stand out more, it's a lot of
   detail going missing in the dark. The plan is to be able to
   change the colours of the different parts as a fun feature to
   plan how to paint your bases. can we design it with that in
   mind?"

   FOUR TOKENS, ONE PER THING YOU WOULD ACTUALLY PAINT. They are
   the whole interface: set any of them on any ancestor of the
   sprue — a stylesheet rule, an inline style, a colour input —
   and that region repaints. No re-render, no JS in the asset.

     --base-face   the flat top land, the big paintable area
     --base-rim    the raised outer ring, the two bars and the
                   stat dividers, which are ONE PIECE on the
                   moulding (owner's earlier call) and therefore
                   one token — paint the rim and they follow
     --base-chan   the recessed stats strip's floor
     --base-ink    the moulded lettering: PRONE / STUNNED, the
                   stat line, the chevrons

   THE SHADING IS NOT IN HERE, AND THAT IS THE DESIGN. Every
   highlight and shadow on the base is an --ink-950 or
   --bone-100 alpha laid OVER these flats (see the gRim note in
   ctd-parts.js). Light is light whatever the object is painted,
   so one drawing serves every palette, and none of the modelling
   has to be re-picked per colour. It is also why the detail
   survives now: an alpha wash's range is set by its alpha, not
   by how many dark tones happen to exist below the field.

   THE DEFAULTS LIFT THE BASE OFF THE RUNNER, which is the other
   half of the ask. It was sitting at --sprue-well — DARKER than
   the --sprue-face frame around it — so the biggest part on the
   sprue was the one hardest to see. A part is lighter than its
   frame; that is the rule the tags already follow.
   ============================================================ */
html.ctd-v3, .ctd-scope {
  /* EVERY ZONE'S DEFAULT DERIVES FROM --part-face, NOT FROM
     --base-face, AND THAT IS NOT A STYLE CHOICE — IT IS THE ONLY
     THING THAT WORKS HERE.

     The first cut had --base-rim / --base-chan / --base-ink all
     mixed from var(--base-face), so that setting the face alone
     would repaint the whole base coherently. It does not work,
     and it fails SILENTLY: **a custom property resolves the
     var()s inside it at the element the DECLARATION sits on, not
     at the element that uses it.** These declarations live on
     :root, so their var(--base-face) is resolved against :root's
     value once and for all. Setting --base-face further down the
     tree repaints the face and nothing else.

     THIS IS THE SAME TRAP AS THE GRADIENT ONE in ctd-parts.js —
     a paint server resolves its properties at its own position
     in the DOM, not at the referencing element. Same rule, two
     places it bites. It was caught here by reading the computed
     value back off a live node: --base-ink-lo was still showing
     a mix of the DEFAULT resin while every other zone had
     repainted. Nothing errors; the colour is simply stale.

     So the defaults are independent, and the "set one colour and
     the rest follow" behaviour is opt-in below, where the
     declarations can sit on the same element as the override. */
  --base-face: var(--part-face);
  /* THE TWO HALVES OF THE FACE, split by the stat channel: the ground under
     PRONE and the ground under STUNNED (owner, 2026-09-07). On a real base
     they are the two areas anyone paints differently — one is the side you
     see when the model is standing, the other when it is down.
     They alias --base-face, so a scheme that only names the face still paints
     the whole top. NOTE THE SAME RESOLUTION TRAP AS EVERYTHING ELSE HERE:
     these aliases are resolved against :root's --base-face, so an override
     set further down the tree does NOT reach them from here. That is what
     .ctd-base-paint below is for, and a plain alias is caught by it exactly
     like a color-mix() is. */
  --base-prone: var(--base-face);
  --base-stun:  var(--base-face);
  --base-rim:  color-mix(in srgb, var(--bone-100) 9%, var(--part-face));
  --base-chan: color-mix(in srgb, var(--ink-950) 42%, var(--part-face));
  /* the lettering is moulded IN the base, so it is the part's own
     material catching light rather than a printed ink — near-bone
     by default, and a picker can make it anything */
  --base-ink:  color-mix(in srgb, var(--bone-100) 92%, var(--part-face));
  /* ONE INK PER HALF, and it is a consequence of the split rather than an
     extra: paint PRONE's ground white and STUNNED's black and a single
     lettering colour cannot be legible on both. Each covers its word AND the
     chevron beside it, which is that word's own "this way up" mark. */
  --base-ink-prone: var(--base-ink);
  --base-ink-stun:  var(--base-ink);
  /* THE LETTERING'S SHADOW IS A SHADOW, so by this file's own
     rule it is LIGHT and not material: a translucent ink that
     darkens whatever it lands on. It used to be mixed with
     --part-face, which is what made it the one zone that stayed
     stale when a scheme repainted everything else — and it was
     wrong in principle as well as in practice, because the
     shadow of a raised letter belongs to the surface it falls
     on, not to the letter. */
  --base-ink-lo: color-mix(in srgb, var(--ink-950) 62%, transparent);
}

/* ---- opt-in: one colour, coherent base --------------------
   Put this class on the element you set --base-face on and the
   other zones follow it. It works from here and not from :root
   for exactly the reason in the note above: these declarations
   are on the SAME element as the override, so their
   var(--base-face) sees it. Any zone set explicitly still wins,
   because an inline custom property beats a class rule. */
.ctd-base-paint {
  --base-prone: var(--base-face);
  --base-stun:  var(--base-face);
  --base-rim:  color-mix(in srgb, var(--bone-100) 9%, var(--base-face));
  --base-chan: color-mix(in srgb, var(--ink-950) 42%, var(--base-face));
  --base-ink:  color-mix(in srgb, var(--bone-100) 92%, var(--base-face));
  --base-ink-prone: var(--base-ink);
  --base-ink-stun:  var(--base-ink);
}

/* ============================================================
   THE TOOL'S OWN MARK — 2026-09-07.

   The size stamp in the player sprue's corner (40MM / 32MM).
   It belongs to the RUNNER, not to the part, which is why it
   has its own pair rather than borrowing --base-ink: repainting
   a base must not repaint the mould's stamp.

   22% bone against the tag lettering's 92% is the whole of
   "lightly" (owner's word). It is a mark cast into the frame
   that you notice when you look for it, not a caption — the
   same distinction as a moulding number on a real sprue versus
   the printed code on a tag.
   ============================================================ */
html.ctd-v3, .ctd-scope {
  --stamp-ink: color-mix(in srgb, var(--bone-100) 22%, var(--sprue-face));
  --stamp-lo:  color-mix(in srgb, var(--ink-950) 40%, var(--sprue-face));
}


/* ==== the lit layer: what the controls do when you aim at them ==== */

/* ============================================================
   THE LIT LAYER for the builder's controls.

   Kept apart from the token run above on purpose — that half
   declares values, this half declares BEHAVIOUR, and mixing the
   two is how a stylesheet stops being readable.

   IT ADDS NO GEOMETRY. Every shape it animates is already in the
   SVG, emitted by js/ctd-parts.js as a hook: [data-sheen] on a
   button, [data-fill] / [data-pip] / [data-scan] on a gauge,
   [data-drip] on a weeping fitting. That split is deliberate —
   a generated asset cannot re-render itself on :hover, so
   anything that changes with state has to be a hook the
   stylesheet drives.

   THE BRIEF WAS "go ham on glows and animation, but don't make
   it garish", and those pull opposite ways. The rule they
   resolve to:

     LIGHT THE THING YOU ARE AIMING AT. Everything else stays
     dark. On any one screen that is the primary action and the
     gauge cell you just filled — two lit objects, not twenty.

   This does NOT reopen the builder's half effect budget (see
   night-pitch-effects). The budget's words are "rim on panels,
   one glow on the chosen thing, price bloom on the grand total.
   Nothing lifts, zooms or rakes." A button you are about to
   press IS the chosen thing. Nothing below lifts, zooms or
   rakes; it lights, sweeps and counts — and a sweep that only
   runs on hover is feedback, not ambient motion.

   FOUR RULES THAT KEEP IT OFF THE GARISH SIDE:
     1. No hue ever changes. A control is brass or it is iron;
        nothing cycles, nothing rainbows.
     2. Rest motion is slow and small — 7s and up, under 10%
        amplitude. If you can watch it, it is too much.
     3. Response motion is fast and once — 600ms and out.
     4. Every glow is a colour the control already is. Nothing
        borrows a colour to be interesting.
   ============================================================ */

/* THE WHOLE --fx-* RUN FOR THESE CONTROLS IS DECLARED IN css/v3/tokens.css,
   after the parts' own material and before the bronze T joint. That is where
   every effect primitive in v3 lives (night-pitch-effects: "effects are --fx-*
   tokens"), and it is enforced — tools/contract/css-tokens.mjs lets a
   drop-shadow or a gradient be SPELLED OUT only inside an --fx-* declaration
   in tokens.css, and only spent as a var() anywhere else. What is below is the
   spending: which control lights, when, and for how long. */


/* ============================================================
   1 · THE BUTTON
   ============================================================ */
.ctd-fx-btn { position: relative; display: block; border-radius: 4px; }
.ctd-fx-btn .ctd-part--btn { display: block; width: 100%; height: auto;
  transition: transform 140ms var(--ease, cubic-bezier(.4, 0, .2, 1)); }

/* the plate's glow lives on the WRAPPER, so it can sit outside the svg's box */
.ctd-fx-btn:has(.is-primary) {
  box-shadow: var(--fx-btn-glow);
  animation: fx-btn-breathe var(--t-btn-breathe) ease-in-out infinite;
  transition: box-shadow 160ms var(--ease, ease);
}
/* THE REST BREATHE — slow enough that you notice it only if you look for it,
   which is the point: the button is alive, not flashing. */
@keyframes fx-btn-breathe {
  0%, 100% { box-shadow: var(--fx-btn-glow); }
  50%      { box-shadow: var(--fx-btn-hot); }
}
.ctd-fx-btn:has(.is-primary):hover,
.ctd-fx-btn:has(.is-primary):focus-within { animation: none; box-shadow: var(--fx-btn-hot); }
.ctd-fx-btn:has(.is-primary):active { animation: none; box-shadow: var(--fx-btn-press); }

.ctd-fx-btn:has(.is-secondary):hover,
.ctd-fx-btn:has(.is-quiet):hover { box-shadow: var(--fx-btn-quiet); }
.ctd-fx-btn:has(.is-disabled) { box-shadow: none; animation: none; }

/* THE SHEEN — parked off the left of the plate, slid across ONCE per hover. */
.ctd-part--btn [data-sheen] { transform: translateX(-10%); opacity: 0; }
.ctd-fx-btn:hover .ctd-part--btn [data-sheen],
.ctd-fx-btn:focus-within .ctd-part--btn [data-sheen] {
  animation: fx-sheen var(--t-sheen) cubic-bezier(.3, 0, .2, 1) 1;
}
@keyframes fx-sheen {
  0%   { transform: translateX(-10%); opacity: 0; }
  20%  { opacity: 1; }
  100% { transform: translateX(115%); opacity: 0; }
}

/* the press: the plate goes DOWN a pixel. A button that only changes its
   shadow reads as a sticker being nudged. */
.ctd-fx-btn:active .ctd-part--btn { transform: translateY(1px); }

/* ============================================================
   2 · THE GAUGE
   ============================================================ */
.ctd-fx-gauge { position: relative; display: block; }
.ctd-fx-gauge .ctd-part--gauge { display: block; width: 100%; height: auto; }
.ctd-part--gauge.is-ok     { --tint: var(--ok); }
.ctd-part--gauge.is-gold   { --tint: var(--accent); }
.ctd-part--gauge.is-violet { --tint: var(--accent-2); }
.ctd-part--gauge.is-warn   { --tint: var(--warn); }
.ctd-part--gauge.is-err    { --tint: var(--err); }

/* the filled run blooms in its own colour, and only the filled run */
.ctd-part--gauge [data-fill] { filter: var(--fx-gauge-glow); }

/* COUNTING UP LOOKS LIKE COUNTING UP. The whole filled group is wiped open
   left to right, so a jump from 8 to 12 runs along the track instead of
   snapping — one animated element for any number of cells. */
.ctd-part--gauge [data-fill] {
  animation: fx-wipe var(--t-wipe) cubic-bezier(.2, .7, .3, 1) 1;
}
@keyframes fx-wipe {
  from { clip-path: inset(0 100% 0 0); }
  to   { clip-path: inset(0 0 0 0); }
}

/* the newest cell is the only thing that breathes */
.ctd-part--gauge [data-pip] { animation: fx-pip var(--t-pip) ease-in-out 420ms infinite; }
@keyframes fx-pip { 0%, 100% { opacity: 0; } 50% { opacity: 1; } }
/* a full gauge stops pointing — there is nothing left to point at */
.ctd-part--gauge[data-full="1"] [data-pip] { animation: none; opacity: 0; }

/* THE SCAN — one soft column walking the track, the kit's only ambient motion.
   9.5s is slower than anyone watches, which is exactly the intent: it keeps a
   finished gauge from reading as a picture without ever asking to be looked
   at. An empty gauge has nothing to scan. */
.ctd-part--gauge [data-scan] { animation: fx-scan var(--t-scan) linear infinite; }
/* USER UNITS, from the asset. A percentage translate on an SVG element
   resolves against the element's OWN bounding box, so it would make the sweep
   a function of the column's width rather than of how much of the gauge is
   filled — which is the bug this fixes. --s0/--s1 come off the svg. */
@keyframes fx-scan {
  0%        { transform: translateX(var(--s0, -30px)); }
  60%, 100% { transform: translateX(var(--s1, 100px)); }
}
.ctd-part--gauge[data-value="0"] [data-scan] { display: none; }

/* over the cap, the track insists — the one place a colour may, because it is
   an error and errors may */
.ctd-part--gauge.is-err [data-fill] { animation: fx-alarm 1.6s ease-in-out infinite; }
@keyframes fx-alarm {
  0%, 100% { filter: var(--fx-gauge-glow); }
  50%      { filter: var(--fx-gauge-hot); }
}

/* ============================================================
   3 · THE WEEP — rust finding the same channel it always finds.
   The dry stain under it never moves; only this runs.
   ============================================================ */
.ctd-part [data-drip] {
  transform-origin: top center;
  animation: fx-drip var(--t-drip) ease-in-out infinite;
  animation-delay: var(--d, 0s);
}
@keyframes fx-drip {
  0%, 6%   { transform: scaleY(0);    opacity: 0; }
  18%      { opacity: 0.9; }
  62%      { transform: scaleY(1);    opacity: 0.8; }
  100%     { transform: scaleY(1);    opacity: 0; }
}

/* ============================================================
   4 · THE SWITCH — one glow, because it is a state you set and
   then stop looking at.
   ============================================================ */
.ctd-part--toggle { transition: filter 160ms var(--ease, ease); }
.ctd-part--toggle.is-on {
  /* --fx-toggle-glow, not a drop-shadow written out here: see the note on the
     --fx-* run above. It was the one effect in this file that had never been
     given a name, which is exactly how an effect escapes the budget. */
  filter: var(--fx-toggle-glow);
}

/* ============================================================
   5 · REDUCED MOTION. The glows STAY — a glow is not motion —
   and every sweep, breathe, pulse, wipe and drip goes. This is
   the second of the two places v3 handles motion: tokens.css
   zeroes the transition tokens, and a stylesheet with its own
   keyframes cancels them itself.
   ============================================================ */
@media (prefers-reduced-motion: reduce) {
  .ctd-fx-btn:has(.is-primary),
  .ctd-part--btn [data-sheen],
  .ctd-part--gauge [data-fill],
  .ctd-part--gauge [data-pip],
  .ctd-part--gauge [data-scan],
  .ctd-part--gauge.is-err [data-fill],
  .ctd-part [data-drip] { animation: none !important; } /* lint-allow: important — cancels a keyed animation on the same element */
  .ctd-part--btn [data-sheen] { opacity: 0; }
  .ctd-part--gauge [data-scan] { display: none; }
  .ctd-part--gauge [data-pip] { opacity: 0; }
  .ctd-part [data-drip] { transform: none; opacity: 0.8; }
  .ctd-fx-btn:active .ctd-part--btn { transform: none; }
}

/* ============================================================
   6 · THE HIT LAYER — the one place a drawn part is a CONTROL.

   js/ctd-parts.js's sprueSheet() can lay a transparent rect over
   every tag it draws (see the `hit` option there and the note on
   why the geometry has to come from the generator). The rects
   are the last thing in the SVG, so they are on top of the
   grain and the dust as well as the tags — nothing the pointer
   can land on instead.

   THEY PAINT NOTHING UNTIL YOU AIM AT ONE, and what they paint
   then is LIGHT rather than colour: a pale hairline along the
   moulding's edge on hover, the theme's focus ring on focus.
   A hover FILL over a moulded tag is a sheet of coloured glass
   laid on it at any alpha; a part you are about to pick up
   catches a little more light along its fillet. See the note on
   the hover rule for why it cannot be drawn on the tag itself.

   WHY NOT A <button>. Nothing inside an SVG can be one. The
   rect carries role="button" and tabindex, and ui/sprue/index.js
   answers Enter and Space by hand — see the keydown beside the
   click there; a role that only answers the mouse is a lie.
   ============================================================ */
.ctd-part-hits > rect {
  fill: transparent;
  /* a transparent FILL is still painted, but say it outright: this rect
     exists only to be hit and one browser quirk away from being unhittable
     is not a place to leave a customer's only way to remove a tag */
  pointer-events: all;
  cursor: pointer;
  /* the browser's own ring is drawn round the SVG's bounding box, not round
     this rect — :focus-visible below draws it on the moulding instead */
  outline: none;
}

/* HOVER IS LIGHT ALONG THE EDGE, and it has to be drawn on the RECT because
   nothing else can be. The tag under the pointer is a sibling of its hit
   target, not a descendant, so no custom property set here can reach it — an
   earlier cut of this block set a --hit-lift the tag was supposed to read and
   the tag could never have seen it.

   What is left is a stroke, and a stroke is the right answer anyway: the rect
   is the tag's exact box with the tag's own corner radius, so a pale hairline
   straddling that edge is the fillet catching more light, which is what a part
   about to be picked up actually does. A FILL would be a sheet of coloured
   glass laid over the moulding, and it looks like one at any alpha.
   --bone-100 is the light everything else in this room is lit by, so it reads
   as light over every pigment without knowing which one it is over. */
.ctd-part-hits > rect:hover {
  /* 3 UNITS AT 58%, and both halves were measured against the alternatives on
     a live sheet. A sheet draws at about half a pixel per unit, so 2 units is
     a ONE-PIXEL line at 58% — present in the DOM and invisible on the screen.
     Going the other way, 80% at the same width stops reading as light and
     starts reading as a selection outline drawn round the tag. */
  stroke: color-mix(in srgb, var(--bone-100) 58%, transparent);
  stroke-width: 3;
}

/* The ring. Same box, same corner, so focus lands ON the moulding rather than
   round a bounding box — and it is the theme's own --focus, at a width that
   survives the sheet being drawn at about two thirds of a pixel per unit. */
.ctd-part-hits > rect:focus-visible {
  stroke: var(--focus);
  stroke-width: 4;
}
