/* =====================================================================
   QANDIL AWAYAN · PORTFOLIO DESIGN SYSTEM
   Theme concept: "sunlight through leaves on a plaster wall"

   STANDARDS INDEX (documented in DESIGN.md at project root)
   1. Color tokens        --wall / --ink / --accent ... (one accent only)
   2. Type scale          --text-* (1.25 modular-ish) + display clamps
   3. Spacing scale       --s-2..128 (value-named; --sp-1..10 deprecated alias)
   4. Radius scale        pill for interactive, 20px tiles, 6px focus
   5. Motion tokens       --ease / --dur-*  (transform+opacity only)
   6. Z-index scale       bg 0 · page 1 · fall 2 · nav 10 · skip 20
   7. Layout              1080px container, 24px gutter
   8. Breakpoints         640 phone · 768 tablet · 1024 desktop
   ===================================================================== */

/* ------- 1. COLOR TOKENS ------- */
:root{
  --wall:#ece7de;
  --wall-deep:#e4ddd0;
  --sun:#faf4e6;
  --ink:#272319;
  --muted:#655d4f;
  --accent:#44603a;
  --accent-ink:#f0ede4;
  --tile:#f4efe5;
  --line:rgba(39,35,25,.14);
  --shadow-leaf:#4a453c;
  --shadow-opacity:.4;
  /* sakura: the one pink exception, hero tree only */
  --sk0:#fbe4eb; --sk1:#f2bfcd; --sk2:#e6a2b6; --sk3:#d487a0; --sk4:#b96f8e;
  --bark:#4b3335; --bark-l:#7d5a50; --bark-d:#2e1d1c;
  /* heat: AI/07-UI-STANDARDS.md SS5 breach 1 - the love/likes control's warm
     glow, previously 6 distinct hex/rgba literals hard-coded straight into
     UI chrome (a second accent, not illustration - the love button is a
     control, not hero art). Tokenised here WITHOUT changing how it renders:
     every literal below is the exact value it replaces, so this is a
     rename, not a redesign. --heat-0..5 (six stops, not five - there were
     six genuinely distinct source colours and merging any of them, even
     ones a shade apart, would be a rendering change the brief said not to
     make) is a documented art-only exception the same way --sk*, --bark*
     above are: never reach for --heat-* outside .love-*, .lr-*, .dock-*.
     Deliberately identical in light and dark - the literals it replaces
     were never theme-conditional, so giving it per-theme values now would
     itself be a rendering change. Whether this stays orange or becomes an
     --accent ramp instead is Qandil's call, not made here - see
     plans/NOTES-FOR-QANDIL.md. */
  --heat-0:#e0642f; --heat-1:#e0692f; --heat-2:#e07a2f;
  --heat-3:#e98a3a; --heat-4:#f0aa3c; --heat-5:#eda45c;

  /* 2. TYPE SCALE (rem) */
  --text-xs:.8125rem;   /* 13   meta            */
  --text-sm:.90625rem;  /* 14.5 captions, chips */
  --text-base:.96875rem;/* 15.5 body            */
  --text-md:1.0625rem;  /* 17   lead            */
  --text-lg:1.15rem;    /* 18.4 h3              */
  --text-xl:1.3rem;     /* 20.8 tile titles     */
  /* 23.2 - prose and comment h2. Added 2026-07-26 (07 section 2): slice 4
     escalated these rather than inventing a token, which was right. They sit
     above --text-xl and below the display clamps, and --display-3 was the
     wrong home: its clamp grows to 38.4px on a wide screen, so reusing it
     would have visibly enlarged every blog heading during what is meant to be
     an invisible migration. This rung maps them exactly, zero visual change. */
  --text-2xl:1.45rem;
  --display-3:clamp(1.4rem,3.4vw,2.4rem);
  --display-2:clamp(1.8rem,3.4vw,2.5rem);
  --display-1:clamp(2.3rem,5.6vw,4.1rem);

  /* 3. SPACING SCALE - value-named, AI/07-UI-STANDARDS.md §1. The token
     carries its own pixel value, so --s-16 is 16px and nobody has to count
     indices to find the number they wanted. Replaces the index-named
     --sp-1..10 ladder below, whose 16->24->32 jump was too coarse for
     dense UI - builders hand-built these exact midpoints with
     calc((var(--sp-N) + var(--sp-M)) / 2) 29 times before this scale
     existed. Purely additive: nothing is removed, --sp-* still resolves
     (as aliases, just below) so no existing rule changes appearance. */
  --s-2:2px;   --s-4:4px;   --s-6:6px;   --s-8:8px;
  --s-10:10px; --s-12:12px; --s-14:14px; --s-16:16px;
  --s-20:20px; --s-24:24px; --s-28:28px; --s-32:32px;
  --s-40:40px; --s-48:48px; --s-56:56px; --s-64:64px; --s-96:96px;
  --s-128:128px;
  /* --s-72 added: board row 301, .rb-pane-preview's sticky offset was
     calc(var(--s-56) + var(--s-16)) - banned outright by 07 §1 rule 2
     (calc() mixing two spacing tokens). 56 + 16 = 72 exactly, so this rung
     is a pure rename, byte-identical computed value (verified: no
     calc(), same 72px). It also continues the ladder's own 8px cadence
     through the existing 40/48/56/64, same precedent as --s-56 and --s-160
     below - 72 is the missing rung between 64 and 96, not an invented one. */
  --s-72:72px;
  /* --s-160 added: [data-po-grid]/[data-pr-grid] (pdf-organize/pdf-rotate's
     thumbnail grid track minimum) used calc(var(--s-128) + var(--s-32)),
     banned outright by 07 §1 rule 2 (calc() mixing two spacing tokens).
     128 + 32 = 160 exactly, so this rung is a pure rename: the computed
     minmax() value is unchanged (160px before, 160px after - verified live,
     see the handoff), same precedent as --s-56 above. */
  --s-160:160px;

  /* --sp-1..10 (4px base): DEPRECATED aliases onto --s-* above, kept only
     so nothing already written against them breaks mid-migration. New
     code must use --s-* directly; any rule touched for another reason
     migrates in the same edit (07 §1). Deleted once the last use is gone. */
  --sp-1:var(--s-4);   --sp-2:var(--s-8);   --sp-3:var(--s-12);  --sp-4:var(--s-16);
  --sp-5:var(--s-24);  --sp-6:var(--s-32);  --sp-7:var(--s-48);  --sp-8:var(--s-64);
  --sp-9:var(--s-96);  --sp-10:var(--s-128);

  /* page rhythm - a LAYOUT quantity, not a spacing step, so it gets its
     own tokens rather than being bent onto the --s-* ladder (07 §1).

     CORRECTED 2026-07-26. These were first defined as 110px/72px, taken
     from DESIGN.md's prose. The shipped CSS has always been 88px desktop
     and 56px at the 640 breakpoint, so the tokens described a rhythm the
     site never had and, being unreferenced, silently did nothing. Wiring
     them up as written would have moved every section on the site by
     22px. The code was the fact and the doc was not; both DESIGN.md and
     AI/07 have been corrected to 88/56. */
  --rhythm-section:88px;
  --rhythm-section-sm:56px;

  /* control-height ladder (07 §4). 44px is the floor for anything a
     thumb touches; -sm is dense desktop-only chrome, never the primary
     touch path. */
  --h-control-sm:36px;
  --h-control:44px;
  --h-control-lg:52px;

  /* icon glyph sizes (07 §4) - nothing outside this set; anything larger
     is illustration, sized by its own rule with a comment saying so. */
  --ic-14:14px; --ic-16:16px; --ic-18:18px; --ic-20:20px; --ic-24:24px;

  /* type floor + prose measure (07 §2/§4) */
  --text-2xs:.6875rem; /* 11px - micro labels only, never body/control text */
  --measure:65ch;

  /* 4. RADIUS */
  --r-pill:999px;   /* every interactive control   */
  --r-tile:20px;    /* every surface / card / row  */
  --r-focus:6px;    /* focus outline corners       */

  /* 5. MOTION */
  --ease:cubic-bezier(.16,1,.3,1);
  --dur-1:.2s;   /* taps, color shifts   */
  --dur-2:.35s;  /* lifts, slides        */
  --dur-3:.7s;   /* reveals              */

  /* 6. Z-INDEX */
  --z-bg:0; --z-page:1; --z-fall:2; --z-float:5; --z-nav:10; --z-skip:20;
  /* --z-float (plan 10b, resume-writer ROUND 8): a rung for chrome that is
     scoped to ONE piece of page content (a floating control bound to a
     document, not to the whole viewport) rather than to the site itself.
     It sits strictly between --z-fall (ambient leaves must never cover it)
     and --z-nav (it must never win a stacking tie against the sitewide nav,
     its own dropdowns, or the consent notice - all three share --z-nav, and
     a same-value tie is decided by DOM order, which is an accident, not a
     decision). Coordinator-approved rather than invented: the résumé
     toolbar shared --z-nav outright before this and was caught, live,
     painting OVER an open Tools mega-box dropdown (same z-index, later in
     the DOM, so it won the tie) - see .rw-doc-toolbar's own comment for the
     reproduction. Nothing else on the site currently sits in the 3-9 gap,
     so no existing consumer is renumbered by adding this rung. */
  /* --z-modal: a full-viewport takeover surface (the résumé builder's
     expanded "real size" preview) needs to sit above the sticky nav and
     the skip link. Reused pattern: the existing mobile tools-sheet already
     sits at a raw 30 and the confirm dialog at a raw 60 (both pre-existing,
     untokenised - out of this change's scope to fix); this is the first
     time that layer gets a real name instead of another bare number. */
  --z-modal:40;

  /* 7. LAYOUT */
  --container:1080px;
  --gutter:24px;
}
/* dark palette: follows system unless the toggle sets data-theme */
@media (prefers-color-scheme: dark){
  :root:not([data-theme="light"]){
    --wall:#232019;
    --wall-deep:#1d1a15;
    --sun:#332e22;
    --ink:#ece5d6;
    --muted:#a89e8b;
    --accent:#a3bd8b;
    --accent-ink:#1d2416;
    --tile:#2b2720;
    --line:rgba(236,229,214,.14);
    --shadow-leaf:#0a0906;
    --shadow-opacity:.6;
    --sk0:#e3c2cc; --sk1:#c793a3; --sk2:#a97286; --sk3:#8a5a6e; --sk4:#5e3b4b;
    --bark:#c8b4a8; --bark-l:#e8d9cc; --bark-d:#8a746a;
  }
}
:root[data-theme="dark"]{
  --wall:#232019;
  --wall-deep:#1d1a15;
  --sun:#332e22;
  --ink:#ece5d6;
  --muted:#a89e8b;
  --accent:#a3bd8b;
  --accent-ink:#1d2416;
  --tile:#2b2720;
  --line:rgba(236,229,214,.14);
  --shadow-leaf:#0a0906;
  --shadow-opacity:.6;
  --sk0:#e3c2cc; --sk1:#c793a3; --sk2:#a97286; --sk3:#8a5a6e; --sk4:#5e3b4b;
  --bark:#c8b4a8; --bark-l:#e8d9cc; --bark-d:#8a746a;
}

/* ------- FONT: Cairo variable, self-hosted ------- */
@font-face{
  font-family:'Cairo';
  src:url('/fonts/Cairo-latin.woff2') format('woff2');
  font-weight:200 1000;font-style:normal;font-display:swap;
  unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face{
  font-family:'Cairo';
  src:url('/fonts/Cairo-arabic.woff2') format('woff2');
  font-weight:200 1000;font-style:normal;font-display:swap;
  unicode-range:U+0600-06FF,U+0750-077F,U+0870-088E,U+0890-0891,U+0897-08E1,U+08E3-08FF,U+200C-200E,U+2010-2011,U+204F,U+2E41,U+FB50-FDFF,U+FE70-FE74,U+FE76-FEFC;
}

/* ------- BASE ------- */
*{margin:0;padding:0;box-sizing:border-box}
html{scroll-behavior:smooth}
body{
  font-family:'Cairo',system-ui,-apple-system,sans-serif;
  font-weight:500;
  font-size:var(--text-base);
  background:var(--wall);
  color:var(--ink);
  line-height:1.6;
  overflow-x:hidden;
  -webkit-font-smoothing:antialiased;
}
::selection{background:var(--accent);color:var(--accent-ink)}
a:focus-visible,button:focus-visible{
  outline:2px solid var(--accent);
  outline-offset:3px;
  border-radius:var(--r-focus);
}
/* design-qa r1 M5: the accent ring is unreadable on --ink surfaces (footer
   card, locale modal) because --ink IS the accent's neighbour token there,
   not --wall. Swap the ring to --wall on those two inverted cards only -
   still tokens only, still one accent, just the correct token for the
   surface underneath the ring. */
.foot-card a:focus-visible,.foot-card button:focus-visible,
.locale-modal-card a:focus-visible,.locale-modal-card button:focus-visible{
  outline-color:var(--wall);
}
.skip{
  position:absolute;inset-inline-start:var(--s-16);top:calc(-1 * var(--s-48));z-index:var(--z-skip);
  background:var(--ink);color:var(--wall);
  /* AI/07-UI-STANDARDS.md §1: 18px sat exactly between --s-16/--s-20;
     rounded to the larger rung (+2px, imperceptible on a floating pill). */
  padding:var(--s-10) var(--s-20);border-radius:var(--r-pill);
  font-weight:700;font-size:var(--text-sm);text-decoration:none;
  transition:top var(--dur-1);
}
.skip:focus{top:var(--s-12)}

/* ------- the wall: warm light patches drifting slowly ------- */
.wall-light{
  position:fixed;inset:-6%;z-index:var(--z-bg);pointer-events:none;
  background:
    radial-gradient(52vw 44vh at 68% 18%, var(--sun) 0%, transparent 70%),
    radial-gradient(40vw 36vh at 12% 62%, var(--sun) 0%, transparent 72%),
    linear-gradient(168deg, var(--wall) 30%, var(--wall-deep) 100%);
  animation:sunDrift 26s ease-in-out infinite alternate;
}
@keyframes sunDrift{
  0%  {transform:translate3d(0,0,0) scale(1);opacity:1}
  50% {opacity:.85}
  100%{transform:translate3d(-2%,1.4%,0) scale(1.05);opacity:.95}
}

/* ------- leaf shadows ------- */
.wind{position:fixed;inset:0;z-index:var(--z-bg);pointer-events:none;overflow:hidden}
.gust{position:absolute;will-change:transform}
.cluster{
  color:var(--shadow-leaf);
  opacity:var(--shadow-opacity);
  mix-blend-mode:multiply;
  will-change:transform;
  animation:sway 9s ease-in-out infinite alternate;
}
@media (prefers-color-scheme: dark){
  :root:not([data-theme="light"]) .cluster,
  :root:not([data-theme="light"]) .fall-leaf{mix-blend-mode:normal}
}
:root[data-theme="dark"] .cluster,
:root[data-theme="dark"] .fall-leaf{mix-blend-mode:normal}
.g-a{top:-16vh;left:-18vw}
.g-a .cluster{filter:blur(18px);animation-duration:10.5s;transform-origin:10% 0}
.g-a svg{width:min(98vw,1200px);height:auto}
.g-b{top:2vh;right:-16vw}
.g-b .cluster{filter:blur(11px);animation-duration:7.8s;animation-delay:-3.2s;transform-origin:90% 5%}
.g-b svg{width:min(72vw,880px);height:auto;transform:scaleX(-1)}
.g-c{bottom:-12vh;left:-10vw}
.g-c .cluster{filter:blur(9px);animation-duration:6.6s;animation-delay:-1.6s;transform-origin:5% 90%}
.g-c svg{width:min(56vw,660px);height:auto;transform:rotate(14deg)}
.g-d{bottom:-14vh;right:-12vw}
.g-d .cluster{filter:blur(15px);animation-duration:9.2s;animation-delay:-4.4s;transform-origin:90% 90%}
.g-d svg{width:min(62vw,760px);height:auto;transform:scaleX(-1) rotate(-18deg)}
/* leaves close to the wall: nearly sharp, hanging into the frame */
.g-e{top:-3vh;left:26vw}
.g-e .cluster{filter:blur(4px);opacity:calc(var(--shadow-opacity) + .12);animation-duration:5.8s;animation-delay:-2.4s;transform-origin:50% 0}
.g-e svg{width:min(34vw,400px);height:auto}

/* shadows of leaves detaching from the branches above and drifting down */
.fall{position:fixed;inset:0;z-index:var(--z-fall);pointer-events:none;overflow:hidden}
.fall-leaf{
  position:absolute;color:var(--shadow-leaf);
  --o:calc(var(--shadow-opacity) + .06);
  opacity:0;
  mix-blend-mode:multiply;
  will-change:transform;
  animation:fallY var(--d,20s) linear infinite;animation-delay:var(--dl,0s);
}
.fall-leaf .flutter{animation:flutter 3.4s ease-in-out infinite alternate;animation-delay:inherit}
.fall-leaf svg{display:block;height:auto}
/* each leaf starts inside a branch cluster and matches its blur depth */
.f1{left:14vw;top:16vh;--d:23s;--dl:2s;filter:blur(9px)}
.f1 svg{width:52px}
.f2{left:31vw;top:13vh;--d:18s;--dl:12s;filter:blur(3px)}
.f2 svg{width:24px}
.f3{left:78vw;top:20vh;--d:20s;--dl:7s;filter:blur(6px)}
.f3 svg{width:38px}
@keyframes fallY{
  0%  {transform:translate3d(0,0,0);opacity:0}
  6%  {opacity:var(--o)}
  88% {opacity:var(--o)}
  100%{transform:translate3d(8vw,100vh,0);opacity:0}
}
@keyframes flutter{
  0%  {transform:rotate(-52deg) translateX(-26px)}
  48% {transform:rotate(24deg) translateX(8px)}
  100%{transform:rotate(64deg) translateX(28px)}
}
@keyframes sway{
  0%  {transform:rotate(-1.5deg) translateX(-7px)}
  36% {transform:rotate(.5deg)  translateX(2px)}
  58% {transform:rotate(-.4deg) translateX(-2px)}
  100%{transform:rotate(1.7deg) translateX(9px)}
}
.limb{
  transform-box:fill-box;
  transform-origin:left center;
  animation:swayLimb 5.4s ease-in-out infinite alternate;
}
.limb.l2{animation-duration:6.7s;animation-delay:-2.1s}
.limb.l3{animation-duration:4.6s;animation-delay:-.9s}
.limb.l4{animation-duration:7.3s;animation-delay:-3.4s}
@keyframes swayLimb{
  0%  {transform:rotate(-2.4deg)}
  42% {transform:rotate(.8deg)}
  70% {transform:rotate(-.6deg)}
  100%{transform:rotate(2.6deg)}
}

/* ------- LAYOUT PRIMITIVES ------- */
.page{position:relative;z-index:var(--z-page)}
.wrap{max-width:var(--container);margin:0 auto;padding-inline:var(--gutter)}

/* ------- NAVBAR ------- */
.top{
  position:sticky;top:0;left:0;right:0;z-index:var(--z-nav);
  background:transparent;
}
/* slice 6 (AI/07-UI-STANDARDS.md §1): .top/.top-in/.nav-links/.top-actions/
   .logo migrated per the brief's named scope. .btn-hire's own padding is
   deliberately NOT touched here - see the slice-6 handoff §"nav/header" for
   why: it sits inside the 960px load-bearing collapse this file's own
   comment below records as already broken twice in German at 760/900px,
   and it is not one of the five classes the brief named. */
.top-in{display:flex;align-items:center;justify-content:space-between;gap:var(--s-20);height:68px}
.top-actions{display:flex;gap:var(--s-10);align-items:center}
.brand{color:var(--ink);text-decoration:none;display:inline-flex;flex:none}
/* slice 6: 26px sits exactly between --s-24/--s-28, and every other tie in
   this campaign rounds to the larger rung - but NOT here. Measured at
   960-1128px in German (the "either side of the load-bearing 960px"
   check this slice's brief asked for): with .nav-links margin-left:auto
   already fully consumed (no slack left in the row), moving this gap to
   28px shifts .btn-hire ~10px further right and the row is *already*
   marginally past the viewport edge in German at ~960-965px before this
   slice touched anything (html{overflow-x:hidden} clips it silently, so
   it never shows up as a scrollWidth mismatch - only as content
   disappearing under the edge). Confirmed pre-existing by toggling the
   gap live on the rendered page with both values and diffing
   getBoundingClientRect - the overflow exists at the OLD 26px value too,
   my change just adds to it. Left as a literal 26px rather than widen an
   already-broken width budget the file's own 960px comment documents as
   fragile; see the slice-6 handoff for the full measurement. */
.nav-links{display:flex;gap:26px;align-items:center;margin-left:auto}
/* ===== nav-german-overflow: German-only nav gap taper, 961-1010px =====
   Measured live (nav-german-overflow-frontend-ui handoff has the full
   940-1010 sweep): at the literal 26px gap above, .btn-hire's right edge
   sits at a FIXED x (the row is left-anchored once it no longer fits, not
   right-aligned - margin-left:auto on .nav-links resolves to 0 once there
   is no free space left), while the wrap's own right edge moves with the
   viewport. So the overflow shrinks linearly as the viewport widens and
   is genuinely gone site-wide by ~1004-1005px in German; nothing below
   961px is reachable in the unwrapped layout at all (the 960px rule below
   still wins there - see the specificity note).
   This ONLY changes German ([lang="de"], set on <html> by
   layouts/site.blade.php from the current locale) - every other locale's
   own nav-links content is shorter here and does not overflow this band
   the same way (en: none; ar-EG/fr/es: a separate, larger, PRE-EXISTING
   overflow of their own in this same band, left untouched on purpose -
   out of this fix's scope, reported in the handoff, not fixed here).
   :where() is load-bearing: it keeps this selector's specificity equal to
   the plain `.nav-links` above (0,1,0), not higher, so the later
   @max-width:960px / @max-width:640px wrapped-state gap rules further
   down this file still win by source order once the nav collapses - this
   rule must never fight those for the collapsed row/column gap. Without
   :where(), the attribute selector would outrank them by specificity and
   break the collapsed German nav's row spacing, which must stay untouched.

   Superseded for JS users by nav-content-driven-collapse (see the
   `:where(.top.is-tight)` block further down this file, right after the
   @max-width:960px block): site.js now measures the real overflow for
   EVERY locale (this one included) and collapses the whole nav to its
   wrapped layout whenever it doesn't fit, which happens well before this
   clamp's own floor of 26px would still leave anything overflowing. Left
   in place, not deleted, because it is the only protection a no-JS German
   visitor has in this exact band - `.is-tight` never gets added without
   JavaScript, and the 960px floor alone would leave German's own
   pre-clamp overflow (+42.84px at 961px) fully exposed to that visitor.
   For a JS-enabled visitor this rule is inert: `.is-tight`'s wrapped
   `.nav-links` gap rule sits later in this file at equal specificity, so
   it always wins by source order the moment the row is genuinely
   collapsed, and outside that band this clamp already resolves back to
   the plain 26px. */
:where(html[lang="de"]) .nav-links{
  /* the two 10px ends of the clamp are ordinary --s-10; the 26px ceiling
     must stay a literal in lockstep with .nav-links's own 26px above -
     see that rule's comment for why it is not on the ladder. */
  gap:clamp(var(--s-10), calc(var(--s-10) + (100vw - 961px) * .32), 26px);
}
.nav-links a{
  position:relative;
  color:var(--muted);text-decoration:none;font-size:var(--text-sm);
  transition:color var(--dur-1);
  display:inline-flex;align-items:center;min-height:44px;
}
.nav-links a::after{
  /* height:2px stays literal - a decorative underline stroke thickness,
     the same category as the 1px/1.5px border-widths the ladder exempts,
     not a spacing value. */
  content:"";position:absolute;left:0;right:0;bottom:calc(-1 * var(--s-4));height:2px;
  background:var(--accent);border-radius:2px;
  transform:scaleX(0);transform-origin:left;
  transition:transform var(--dur-2) var(--ease);
}
.nav-links a:hover{color:var(--ink)}
.nav-links a:hover::after{transform:scaleX(1)}
.nav-links a.active{color:var(--ink);font-weight:700}
.nav-links a.active::after{transform:scaleX(1)}
.btn-hire{
  display:inline-flex;align-items:center;justify-content:center;
  /* nav-content-driven-collapse: measured 41.2px tall at every unwrapped
     desktop width, in every locale including English - under the 44px
     floor (AI/07-UI-STANDARDS.md §4) by omission, since only the
     @max-width:640px rule ever set an explicit min-height. Growing the
     hit box via min-height, not the type: .btn-hire's own align-items:
     center (and .top-in's, one level up) centres the label inside the
     taller box for free, so this is a padding-equivalent change, not a
     type-size change. */
  min-height:var(--h-control);
  /* px-and-breakpoints: 9px sat exactly between --s-8/--s-10; rounded to the
     larger rung (+1px). Unlike .nav-links's own 26px gap and the German
     taper above, this is VERTICAL padding on a control whose height is
     already floored by min-height:var(--h-control) - it cannot add a single
     px to the row's total width, so it carries none of the documented
     960px overflow risk (that risk is horizontal-only). Screenshot-verified
     unchanged at 960-1128px in German. */
  padding:var(--s-10) var(--s-20);border-radius:var(--r-pill);
  background:var(--accent);color:var(--accent-ink);
  font-weight:700;font-size:var(--text-sm);text-decoration:none;
  transition:transform var(--dur-1) var(--ease), opacity var(--dur-1);
  white-space:nowrap;
}
.btn-hire:hover{transform:translateY(-2px)}
.btn-hire:active{transform:scale(.98)}

/* ------- LOGO: layered leaf-Q pinwheel + cycling wordmark ------- */
.logo{height:34px;width:auto;display:block;overflow:visible;color:var(--ink);direction:ltr}
.logo text{fill:currentColor;font-family:'Cairo',sans-serif;letter-spacing:-.02em}
/* leaf-slab gradient stops + extrusion shade */
:root{--lg-a:#8aab72;--lg-b:#3c5433;--lg-d:#2c3f25}
@media (prefers-color-scheme: dark){
  :root:not([data-theme="light"]){--lg-a:#c3d8ab;--lg-b:#7d9866;--lg-d:#141b10}
}
:root[data-theme="dark"]{--lg-a:#c3d8ab;--lg-b:#7d9866;--lg-d:#141b10}
.lgq-d{fill:var(--lg-d);opacity:.55}
/* the wind clicks the pinwheel a quarter turn as each word arrives */
.lgq-spin{
  transform-box:fill-box;transform-origin:50% 50%;
  animation:lgqSpin 9s var(--ease) infinite;
}
@keyframes lgqSpin{
  0%,26%   {transform:rotate(0deg)}
  33.3%,59%{transform:rotate(90deg)}
  66.6%,92%{transform:rotate(180deg)}
  100%     {transform:rotate(270deg)}
}
/* wordmark cycle: QANDIL -> DEVELOPING -> IDEAS, 3s per word,
   sliding through a streak of motion blur */
.lg-w{
  opacity:0;
  animation:lgWord 9s linear infinite;
}
/* AI/07-UI-STANDARDS.md §8 (slice 4): the 2nd/3rd word's stagger used to be
   a static inline style="animation-delay:...s" in site.blade.php - the
   9s cycle is 3 words wide, so this is exactly "n * 3s" per word. */
.lg-words text.lg-w:nth-child(2){animation-delay:3s}
.lg-words text.lg-w:nth-child(3){animation-delay:6s}
@keyframes lgWord{
  0%       {opacity:0;transform:translateX(14px);filter:blur(6px)}
  4.5%     {opacity:1;transform:translateX(0);filter:blur(0)}
  29%      {opacity:1;transform:translateX(0);filter:blur(0)}
  33.3%    {opacity:0;transform:translateX(-16px);filter:blur(6px)}
  33.4%,100%{opacity:0;transform:translateX(14px);filter:blur(6px)}
}

.theme-btn,.sound-btn{
  position:relative;
  display:inline-flex;align-items:center;justify-content:center;
  width:38px;height:38px;border-radius:var(--r-pill);
  background:transparent;border:none;cursor:pointer;
  color:var(--muted);box-shadow:inset 0 0 0 1.5px var(--line);
  transition:color var(--dur-1), box-shadow var(--dur-1);
}
/* design-qa r1 M4: keep the visual pill small (it matches the lang-trigger
   and sits in a tight header), but guarantee a >=44px tap/click area with an
   invisible hit-slop layer instead of growing the button and re-breaking M1 */
.theme-btn::before,.sound-btn::before{
  content:"";position:absolute;top:50%;left:50%;
  width:max(44px,100%);height:max(44px,100%);
  transform:translate(-50%,-50%);
}
.theme-btn:hover,.sound-btn:hover{color:var(--ink);box-shadow:inset 0 0 0 1.5px var(--ink)}
.theme-btn svg,.sound-btn svg{width:17px;height:17px}
.theme-btn .i-sun{display:none}
@media (prefers-color-scheme: dark){
  :root:not([data-theme="light"]) .theme-btn .i-sun{display:block}
  :root:not([data-theme="light"]) .theme-btn .i-moon{display:none}
}
:root[data-theme="dark"] .theme-btn .i-sun{display:block}
:root[data-theme="dark"] .theme-btn .i-moon{display:none}
.sound-btn .i-on{display:none}
.sound-btn.on .i-on{display:block}
.sound-btn.on .i-off{display:none}
.sound-btn.on{color:var(--accent);box-shadow:inset 0 0 0 1.5px var(--accent)}

/* ------- HERO: text left, taped note card right ------- */
/* AI/07-UI-STANDARDS.md §1 (slice 2, landing sections): padding-bottom was
   56px, off the --s-* ladder with 48/64 as EQUIDISTANT neighbours (8px either
   way - a materially bigger tie than anything else in this pass). Left as a
   literal px on purpose - flagged in the slice-2 handoff as a real design
   decision, not a mechanical pick. */
.hero{
  display:grid;grid-template-columns:1.05fr .95fr;
  gap:var(--s-48);align-items:center;
  padding-top:var(--s-48);padding-bottom:var(--s-56);
}
.hero-intro{font-size:var(--text-md);color:var(--muted);margin-bottom:var(--s-12)}
.hero-intro strong{color:var(--ink);font-weight:700}
h1{
  font-weight:900;
  font-size:clamp(2.2rem,4.2vw,3.3rem);
  line-height:1.18;
  letter-spacing:-.02em;
  text-wrap:balance;
}
/* hand-drawn underline on the key words */
.hl{position:relative;white-space:nowrap}
/* AI/07-UI-STANDARDS.md §1 (slice 2): -7px sat exactly between -6/-8; rounded
   to the larger rung (-8px, +/-1px, invisible on a stroke this size). */
.hl svg{
  position:absolute;left:-2%;bottom:calc(-1 * var(--s-8));width:104%;height:12px;
  color:var(--accent);overflow:visible;
}
/* AI/07-UI-STANDARDS.md §1 (slice 2): 18px sat exactly between 16/20; rounded
   to the larger rung (+2px, disclosed in the slice-2 handoff).
   §2 (slice 4): the private clamp(1.05rem,1.6vw,1.2rem) was the one-off rule 3
   names - a hero subtitle is a lead paragraph, exactly --text-md's own
   definition ("Lead paragraph, card heading"), not a display heading, so it
   earns the existing token rather than a new --display-4. The clamp only
   differed from flat 17px above ~750px viewport width (maxing at 19.2px) -
   disclosed as a real, screenshotted pixel change at wide desktop widths. */
.hero-sub{
  margin-top:var(--s-20);font-size:var(--text-md);
  color:var(--muted);max-width:46ch;
}
.hero-cta{display:flex;gap:var(--s-14);margin-top:var(--s-28);flex-wrap:wrap}
/* ===== ui-fixes-batch item 1: hero tools-discovery note =====
   ux-survey-r1 finding 3 (MAJOR) - see welcome.blade.php's own comment for
   the full decision. Small/muted like the megabox footer link it echoes
   (.tools-megabox-foot), not a second accent or a new colour - the link
   reuses --accent, the same single accent every other in-prose link on
   this page uses (.contact .mail, .socials a). No bespoke focus ring
   needed: the global a:focus-visible rule (site.css ~205) already covers
   it correctly on --wall/--tile backgrounds. */
.hero-tools-note{margin-top:var(--s-20);font-size:var(--text-sm);color:var(--muted);max-width:46ch}
.hero-tools-note a{color:var(--accent);font-weight:700;text-decoration:none;margin-inline-start:var(--s-4)}
.hero-tools-note a:hover{text-decoration:underline}
.btn{
  display:inline-flex;align-items:center;
  padding:var(--s-14) var(--s-28);border-radius:var(--r-pill);
  font-family:inherit;font-size:var(--text-base);font-weight:700;
  text-decoration:none;cursor:pointer;border:none;
  transition:transform var(--dur-1) var(--ease), box-shadow var(--dur-1);
}
/* [hidden] cascade guard - same bug and same fix shape as .tool-field/
   .tool-video (see those rules' own comments): an author rule (this one)
   always beats the UA's [hidden]{display:none}, even at equal
   specificity, because origin order (author > user-agent) outranks it.
   Caught live: image-to-base64.js hides the shell's shared
   [data-tool-download] button (`downloadBtn.hidden = true`) because that
   tool's real download actions are its own per-card Copy/txt buttons, not
   a single primary download - and without this rule the "hidden" button
   stayed fully visible and clickable, `display:flex` computed, [hidden]
   attribute ignored. */
.btn[hidden]{display:none}
.btn:active{transform:scale(.98)}
/* design-phase-4-r1 F-B: no rule anywhere gave a disabled .btn/.btn-primary
   a distinct look (contrast: .tool-gradient-stop-remove:disabled and
   .tool-palette-move:disabled already dim to opacity:.35 - the same
   treatment, just never applied to the shared .btn base every tool's Run
   button and every reorder button in merge-pdf/jpg-to-pdf actually use).
   A genuinely disabled Run button read as fully enabled, so a user with
   nothing chosen yet, or a caveat blocking the run, saw no signal the
   click would do nothing. transform:none defends the hover-lift even
   though disabled buttons don't receive :hover in evergreen browsers. */
.btn:disabled{opacity:.35;cursor:default;transform:none}
.btn-primary{background:var(--ink);color:var(--wall)}
.btn-primary:hover{transform:translateY(-2px)}
.btn-ghost{
  background:transparent;color:var(--ink);
  box-shadow:inset 0 0 0 1.5px var(--line);
}
.btn-ghost:hover{box-shadow:inset 0 0 0 1.5px var(--ink);transform:translateY(-2px)}

/* the traced cherry tree beside the headline, bark-to-blossom gradient fill */
.tree-scene{
  position:relative;justify-self:end;
  width:min(440px,100%);
}
.etree{width:100%;height:auto;display:block;overflow:visible;position:relative;z-index:1;transform-origin:50% 58%;animation:etreeBreathe 5.5s ease-in-out 2s infinite alternate}
/* the brain breathes: a slow neural pulse in scale, echoed by a soft glow behind it */
@keyframes etreeBreathe{from{transform:scale(1)}to{transform:scale(1.016)}}
.tree-scene::before{
  content:"";position:absolute;z-index:0;left:50%;top:47%;width:80%;aspect-ratio:1;
  transform:translate(-50%,-50%);border-radius:50%;pointer-events:none;
  background:radial-gradient(circle at 50% 50%,
    color-mix(in srgb,var(--accent) 38%,transparent) 0%,
    color-mix(in srgb,var(--accent) 12%,transparent) 44%,transparent 68%);
  filter:blur(14px);opacity:.5;
  animation:brainGlow 5.5s ease-in-out 2s infinite alternate;
}
@keyframes brainGlow{from{opacity:.3;transform:translate(-50%,-50%) scale(.9)}to{opacity:.72;transform:translate(-50%,-50%) scale(1.06)}}
/* soft ground shadow gently breathes */
.etree-shadow{transform-box:fill-box;transform-origin:center;animation:etreeShade 7s ease-in-out infinite alternate}
@keyframes etreeShade{from{transform:scaleX(1);opacity:1}to{transform:scaleX(1.08);opacity:.78}}
/* the whole tree wipes up out of the ground on load (trunk first, crown last) */
.etree-grow{clip-path:inset(100% 0 0 0);animation:etreeGrow 1.7s var(--ease) .25s forwards}
@keyframes etreeGrow{to{clip-path:inset(0 0 0 0)}}
/* then it leans in the breeze from the trunk base */
.etree-sway{transform-box:view-box;transform-origin:44% 97%;animation:etreeSway 8s ease-in-out 1.9s infinite alternate}
@keyframes etreeSway{from{transform:rotate(-.7deg)}to{transform:rotate(.8deg)}}
/* backend concepts float over the crown as soft UI cards (rounded, shadowed, iconed) */
.tree-chips{position:absolute;inset:0;pointer-events:none;z-index:2}
.chip{position:absolute;transform:translate(-50%,-50%)}
/* AI/07-UI-STANDARDS.md §8 (slice 4): each chip's left/top/--d entrance-delay
   used to be a static inline style in welcome.blade.php - moved here, one
   rule per chip, same nth-child selector this file already uses two blocks
   down for --fd's desync stagger. Values unchanged (design-qa r1 M2's
   respaced coordinates), so this is a zero-visual-change move, not a
   redesign. */
.chip:nth-child(1){left:18%;top:9%;--d:1.9s}
.chip:nth-child(2){left:70%;top:6%;--d:2.0s}
.chip:nth-child(3){left:46%;top:22%;--d:2.1s}
.chip:nth-child(4){left:12%;top:38%;--d:2.2s}
.chip:nth-child(5){left:80%;top:36%;--d:2.3s}
.chip:nth-child(6){left:28%;top:58%;--d:2.4s}
.chip:nth-child(7){left:70%;top:60%;--d:2.5s}
.chip-in{
  display:inline-flex;align-items:center;gap:.5em;white-space:nowrap;
  padding:.44em .82em .44em .5em;
  background:var(--tile);color:var(--ink);
  border:1px solid var(--line);border-radius:var(--r-pill);
  /* design-qa r1 M2: max clamped down from .9rem - the longest translated
     labels (German "Sauberer Code"/"Warteschlangen") were wide enough at the
     old max to collide with a respaced neighbour anyway */
  font:800 clamp(.7rem,.52rem + .85vw,.82rem)/1 'Cairo',sans-serif;letter-spacing:-.01em;
  box-shadow:0 18px 30px -14px color-mix(in srgb,var(--shadow-leaf) 55%,transparent),
             0 3px 8px -4px color-mix(in srgb,var(--shadow-leaf) 32%,transparent);
  opacity:0;--fd:6.9s;
  animation:chipIn .6s var(--ease) var(--d) both,
            chipFloat var(--fd) ease-in-out calc(var(--d) + .6s) infinite;
}
/* desync the drift so the cards never bob in unison */
.chip:nth-child(1) .chip-in{--fd:6.4s}
.chip:nth-child(2) .chip-in{--fd:7.6s}
.chip:nth-child(3) .chip-in{--fd:6.9s}
.chip:nth-child(4) .chip-in{--fd:8s}
.chip:nth-child(5) .chip-in{--fd:7.1s}
.chip:nth-child(6) .chip-in{--fd:6.2s}
.chip:nth-child(7) .chip-in{--fd:7.9s}
.chip .chk{
  display:grid;place-items:center;flex:none;width:1.55em;height:1.55em;
  border-radius:50%;background:var(--accent);color:var(--accent-ink);
  transform:scale(0);animation:chkPop .5s var(--ease) calc(var(--d) + .34s) both;
}
.chip .chk svg{width:.8em;height:.8em}
/* the one solid accent card, echoing the green highlight tile */
.chip-in.chip-accent{background:var(--accent);color:var(--accent-ink);border-color:transparent;padding:.5em .9em}
/* card lands with a little overshoot, then drifts on a gentle organic path */
@keyframes chipIn{0%{opacity:0;transform:translateY(18px) scale(.9)}70%{opacity:1;transform:translateY(-2px) scale(1.03)}100%{opacity:1;transform:translateY(0) scale(1)}}
@keyframes chipFloat{0%{transform:translate3d(0,0,0) rotate(0deg)}50%{transform:translate3d(2px,-7px,0) rotate(.7deg)}100%{transform:translate3d(0,0,0) rotate(0deg)}}
/* the checkmark badge pops in just after its card settles */
@keyframes chkPop{0%{transform:scale(0)}65%{transform:scale(1.18)}100%{transform:scale(1)}}
.avail{display:inline-flex;align-items:center;gap:var(--s-8);font-weight:700;color:var(--accent)}
.avail i{
  width:8px;height:8px;border-radius:50%;background:var(--accent);
  animation:pulse 2.4s ease-in-out infinite;
}
@keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.45;transform:scale(.8)}}

/* ------- STATS ------- */
.stats{
  border-top:1px solid var(--line);
  /* AI/07-UI-STANDARDS.md §1: 36px sat exactly between --s-32/--s-40;
     rounded to the larger rung (+4px, vertical-only, no wrap risk). */
  padding-top:var(--s-40);
  display:grid;grid-template-columns:repeat(4,1fr);gap:var(--s-24);
}
.stat b{display:block;font-weight:900;font-size:clamp(1.7rem,3vw,2.4rem);letter-spacing:-.02em}
.stat span{font-size:var(--text-sm);color:var(--muted)}

/* ------- SECTIONS ------- */
/* AI/07-UI-STANDARDS.md §1. Slice 2 correctly refused to migrate this and
   escalated instead: :root defined --rhythm-section:110px/72px while the
   shipped values here were 88/56/32, so wiring the rule to the tokens as
   they stood would have moved every section on the site by +22px.

   Resolved 2026-07-26 - the tokens were wrong, not the CSS. They had been
   defined from DESIGN.md's prose rather than from the render; 88/56 is what
   this site has always shipped. Tokens corrected, DESIGN.md and 07 corrected,
   and the rule is now wired to them, which is a genuine no-op.

   scroll-margin-top stays a literal on purpose: 76 = the sticky nav's 68px
   height + 8px clearance, so it tracks the nav rather than the spacing
   ladder. Forcing it to 64 would hide a scrolled-to heading under the nav
   and 96 would overshoot. */
section{padding-top:var(--rhythm-section);scroll-margin-top:76px}
h2{
  font-weight:900;font-size:var(--display-2);
  letter-spacing:-.02em;margin-bottom:var(--s-32);
}
.sec-sub{margin:calc(-1 * var(--s-20)) 0 var(--s-32);color:var(--muted);max-width:56ch}
.sec-sub a{color:var(--accent);font-weight:700;text-decoration:none}
.sec-sub a:hover{text-decoration:underline;text-underline-offset:4px}

/* ------- WORK TILES: 1 + 2 bento ------- */
.work-grid{
  display:grid;grid-template-columns:1.15fr .85fr;grid-template-rows:auto auto;
  gap:var(--s-16);
}
/* AI/07-UI-STANDARDS.md §1's own worked example: 34/32 collapses onto the
   single token --s-32 (vertical -2px, horizontal unchanged) - "a 2px move
   nobody can see, in exchange for four fewer off-scale values". */
.tile{border-radius:var(--r-tile);padding:var(--s-32);position:relative;overflow:hidden}
.tile h3{font-weight:700;font-size:var(--text-xl);letter-spacing:-.015em;margin-bottom:var(--s-10)}
.tile p{font-size:var(--text-base);line-height:1.65;max-width:52ch}
.tile .big{font-weight:900;font-size:clamp(2.2rem,4vw,3.2rem);letter-spacing:-.02em;display:block;margin-bottom:var(--s-14);line-height:1.1}
.tile .tile-link{
  display:inline-block;margin-top:var(--s-14);font-weight:700;font-size:var(--text-sm);
  color:inherit;text-decoration:underline;text-underline-offset:4px;
}
.t-green{grid-row:1 / 3;background:var(--accent);color:var(--accent-ink)}
.t-green p{color:color-mix(in srgb, var(--accent-ink) 82%, var(--accent))}
/* AI/07-UI-STANDARDS.md §1 (slice 2): -60/-70 weren't on the ladder; nearest
   rungs are both --s-64 (-4px / +6px). A few px on a 300x300px decorative
   watermark bleeding off the tile corner is not perceptible - screenshot-
   verified unchanged in the slice-2 handoff. */
.t-green .leafmark{
  position:absolute;right:calc(-1 * var(--s-64));bottom:calc(-1 * var(--s-64));width:300px;height:300px;opacity:.16;
  pointer-events:none;
}
.t-soft{background:var(--tile)}
.t-soft p{color:var(--muted)}
.t-ink{background:var(--ink);color:var(--wall)}
.t-ink p{color:color-mix(in srgb, var(--wall) 78%, var(--ink))}

/* ------- EXPERIENCE ------- */
.xp{display:grid;grid-template-columns:170px 1fr;gap:var(--s-8) var(--s-40);padding:var(--s-24) 0}
.xp + .xp{border-top:1px solid var(--line)}
.xp time{font-size:var(--text-sm);color:var(--muted);padding-top:var(--s-4)}
.xp h3{font-weight:700;font-size:var(--text-lg);letter-spacing:-.01em}
.xp h3 a{color:inherit;text-decoration:none;border-bottom:1.5px solid transparent;transition:border-color var(--dur-1)}
.xp h3 a:hover{border-color:var(--accent)}
.xp .role{color:var(--accent);font-size:var(--text-sm);font-weight:700;margin-bottom:var(--s-8)}
.xp p{font-size:var(--text-base);color:var(--muted);max-width:62ch}
.xp-earlier{padding:var(--s-24) 0 0;font-size:var(--text-base);color:var(--muted);max-width:70ch}

/* ------- OPEN SOURCE PROJECTS ------- */
.repos{display:grid;grid-template-columns:1fr 1fr;gap:var(--s-14)}
/* AI/07-UI-STANDARDS.md §1 (slice 2): 18/22 both sat exactly between two
   rungs (16/20 and 20/24); rounded each to the larger rung (+2px each,
   disclosed in the slice-2 handoff). */
.repo{
  display:flex;align-items:baseline;justify-content:space-between;gap:var(--s-16);
  padding:var(--s-20) var(--s-24);border-radius:var(--r-tile);
  box-shadow:inset 0 0 0 1px var(--line);
  color:var(--ink);text-decoration:none;
  transition:box-shadow var(--dur-1), transform var(--dur-2) var(--ease), background var(--dur-2);
}
.repo:hover{
  box-shadow:inset 0 0 0 1.5px var(--accent);
  transform:translateY(-2px);
  background:color-mix(in srgb, var(--accent) 6%, transparent);
}
.repo b{font-weight:700;font-size:var(--text-base)}
.repo span{font-size:var(--text-xs);color:var(--muted);text-align:right}
.repo:hover b{color:var(--accent)}

/* ------- REVIEWS ------- */
.plats{display:flex;gap:var(--s-12);flex-wrap:wrap;margin-bottom:var(--s-28)}
.plat{
  display:inline-flex;gap:var(--s-6);align-items:baseline;
  /* AI/07-UI-STANDARDS.md §1: 18px sat exactly between --s-16/--s-20;
     rounded to the larger rung (+2px). The parent .plats row already
     flex-wraps, so a marginally wider pill carries no overflow risk. */
  padding:var(--s-10) var(--s-20);border-radius:var(--r-pill);
  box-shadow:inset 0 0 0 1px var(--line);
  text-decoration:none;color:var(--muted);
  font-size:var(--text-sm);font-weight:700;
  transition:box-shadow var(--dur-1), color var(--dur-1);
}
.plat b{color:var(--ink)}
.plat:hover{box-shadow:inset 0 0 0 1.5px var(--accent);color:var(--ink)}
/* swiper: one active card center stage, neighbours peek from the wings */
.rev-swiper{outline:none}
.rev-swiper:focus-visible{outline:2px solid var(--accent);outline-offset:6px;border-radius:var(--r-focus, 6px)}
.rev-stage{
  display:grid;place-items:stretch;
  max-width:620px;margin:0 auto;
  perspective:1200px;
  touch-action:pan-y;
  user-select:none;-webkit-user-select:none;
  cursor:grab;
}
.rev-stage.dragging{cursor:grabbing}
.rev-card{
  grid-area:1/1;
  position:relative;overflow:hidden;
  background:var(--tile);border-radius:var(--r-tile);
  /* AI/07-UI-STANDARDS.md §1: 34px sat 2px from --s-32, 6px from --s-40 -
     not a tie, rounded to the nearer rung (-2px, imperceptible on a
     620px-wide carousel card). */
  padding:var(--s-32) var(--s-32);
  display:flex;flex-direction:column;gap:var(--s-16);
  min-height:238px;
  opacity:0;pointer-events:none;visibility:hidden;
  transform:translateX(0) scale(.7);
  filter:blur(6px);
  transition:
    transform .75s var(--ease),
    opacity .6s var(--ease),
    filter .6s var(--ease),
    visibility 0s .6s;
  will-change:transform,opacity,filter;
}
.rev-card.is-a{
  opacity:1;pointer-events:auto;visibility:visible;
  transform:translateX(0) scale(1) rotate(0deg);
  filter:blur(0);z-index:2;
  transition:
    transform .75s var(--ease),
    opacity .6s var(--ease),
    filter .6s var(--ease),
    visibility 0s;
  box-shadow:0 18px 44px -22px color-mix(in srgb, var(--shadow-leaf) 55%, transparent);
}
.rev-card.is-p,.rev-card.is-n{
  opacity:.32;visibility:visible;z-index:1;
  filter:blur(2.5px);
  transition:
    transform .75s var(--ease),
    opacity .6s var(--ease),
    filter .6s var(--ease),
    visibility 0s;
}
.rev-card.is-p{transform:translateX(-58%) scale(.84) rotate(-2.5deg)}
.rev-card.is-n{transform:translateX(58%) scale(.84) rotate(2.5deg)}
/* far offstage: keeps direction of travel readable when they enter */
.rev-card.is-pp{transform:translateX(-95%) scale(.72)}
.rev-card.is-nn{transform:translateX(95%) scale(.72)}
/* hand-drawn doodle washed across the whole card, blurred like a wall shadow */
.rev-doodle{
  position:absolute;left:50%;top:50%;
  width:120%;aspect-ratio:1;
  color:var(--accent);opacity:.15;
  filter:blur(3px);
  transform:translate(calc(-50% + var(--dx,0%)), calc(-50% + var(--dy,0%))) rotate(var(--dr,0deg));
  /* fade the drawing off toward the text edge so the quote stays clean */
  -webkit-mask-image:radial-gradient(120% 105% at 78% 68%, #000 30%, transparent 78%);
  mask-image:radial-gradient(120% 105% at 78% 68%, #000 30%, transparent 78%);
  pointer-events:none;
}
.rev-card.big .rev-doodle{color:var(--accent-ink);opacity:.13}
/* the active card's drawing breathes: slow sway + swell, like the leaves */
.rev-card.is-a .rev-doodle{
  animation:doodleBreathe 12s ease-in-out infinite alternate;
}
@keyframes doodleBreathe{
  from{transform:translate(calc(-50% + var(--dx,0%)), calc(-50% + var(--dy,0%))) rotate(calc(var(--dr,0deg) - 3deg)) scale(1)}
  to{transform:translate(calc(-50% + var(--dx,0%)), calc(-50% + var(--dy,0%))) rotate(calc(var(--dr,0deg) + 3deg)) scale(1.08)}
}
.rev-card > *:not(.rev-doodle){position:relative;z-index:1}
.rev-card blockquote{
  font-size:var(--text-md);line-height:1.7;
  color:color-mix(in srgb, var(--ink) 88%, var(--muted));
}
.rev-who{margin-top:auto;font-size:var(--text-xs);color:var(--muted);display:flex;flex-direction:column;gap:var(--s-2)}
.rev-who b{color:var(--ink);font-size:var(--text-sm)}
.stars{display:flex;gap:var(--s-4);color:var(--accent)}
.stars svg{width:15px;height:10px}
.rev-card.big{background:var(--accent);color:var(--accent-ink)}
.rev-card.big blockquote{color:color-mix(in srgb, var(--accent-ink) 88%, var(--accent))}
.rev-card.big .rev-who{color:color-mix(in srgb, var(--accent-ink) 72%, var(--accent))}
.rev-card.big .rev-who b{color:var(--accent-ink)}
.rev-card.big .stars{color:var(--accent-ink)}
/* the active card's insides arrive one after another */
.rev-card.is-a .stars svg{animation:revLeaf .55s var(--ease) both}
.rev-card.is-a .stars svg:nth-child(1){animation-delay:.18s}
.rev-card.is-a .stars svg:nth-child(2){animation-delay:.26s}
.rev-card.is-a .stars svg:nth-child(3){animation-delay:.34s}
.rev-card.is-a .stars svg:nth-child(4){animation-delay:.42s}
.rev-card.is-a .stars svg:nth-child(5){animation-delay:.50s}
.rev-card.is-a blockquote{animation:revRise .65s var(--ease) .22s both}
.rev-card.is-a .rev-who{animation:revRise .65s var(--ease) .38s both}
@keyframes revLeaf{
  from{opacity:0;transform:translateY(8px) rotate(-40deg) scale(.4)}
  60%{transform:translateY(-2px) rotate(6deg) scale(1.15)}
  to{opacity:1;transform:translateY(0) rotate(0deg) scale(1)}
}
@keyframes revRise{
  from{opacity:0;transform:translateY(14px)}
  to{opacity:1;transform:translateY(0)}
}
/* controls under the stage */
.rev-ui{
  display:flex;align-items:center;justify-content:center;
  /* AI/07-UI-STANDARDS.md §1: 26px sat exactly between --s-24/--s-28;
     rounded to the larger rung (+2px, vertical-only). */
  gap:var(--s-20);margin-top:var(--s-28);
}
.rev-nav{
  display:grid;place-items:center;
  width:44px;height:44px;border-radius:var(--r-pill);
  background:none;border:none;cursor:pointer;
  color:var(--ink);box-shadow:inset 0 0 0 1px var(--line);
  transition:box-shadow var(--dur-1), color var(--dur-1), transform var(--dur-1);
}
.rev-nav svg{width:18px;height:18px}
.rev-nav:hover{box-shadow:inset 0 0 0 1.5px var(--accent);color:var(--accent)}
.rev-nav:active{transform:scale(.92)}
.rev-meta{display:flex;flex-direction:column;align-items:center;gap:var(--s-8);min-width:120px}
.rev-count{
  font-size:var(--text-xs);font-weight:700;color:var(--muted);
  font-variant-numeric:tabular-nums;letter-spacing:.08em;
}
.rev-count b{color:var(--ink)}
.rev-count i{font-style:normal;margin:0 var(--s-4);opacity:.5}
.rev-bar{
  display:block;width:120px;height:2px;border-radius:2px;
  background:color-mix(in srgb, var(--ink) 14%, transparent);
  overflow:hidden;
}
.rev-bar i{
  display:block;height:100%;width:100%;
  background:var(--accent);border-radius:2px;
  transform:scaleX(0);transform-origin:left;
}
.rev-bar i.run{animation:revBar var(--rev-delay, 6s) linear forwards}
@keyframes revBar{from{transform:scaleX(0)}to{transform:scaleX(1)}}

/* ------- SKILLS ------- */
.skills{display:grid;grid-template-columns:1fr 1fr;gap:var(--s-32) var(--s-48)}
.skill-group h3{font-weight:700;font-size:var(--text-md);margin-bottom:var(--s-16)}
.chips{display:flex;flex-wrap:wrap;gap:var(--s-10)}
.chips li{
  list-style:none;font-size:var(--text-sm);color:var(--ink);
  padding:var(--s-8) var(--s-16);border-radius:var(--r-pill);
  box-shadow:inset 0 0 0 1px var(--line);
}

/* ------- NOTES (latest writing on home) ------- */
.notes{display:flex;flex-direction:column}
/* AI/07-UI-STANDARDS.md §1 (slice 2): 22px sat exactly between 20/24 (->
   --s-24, +2px); 18px sat exactly between 16/20 (-> --s-20, +2px). Both
   disclosed in the slice-2 handoff. */
.note{
  display:flex;align-items:baseline;gap:var(--s-24);
  padding:var(--s-20) var(--s-4);text-decoration:none;color:var(--ink);
  border-top:1px solid var(--line);
  transition:padding-left var(--dur-2) var(--ease);
}
.note:last-of-type{border-bottom:1px solid var(--line)}
.note:hover{padding-left:var(--s-14)}
.note b{font-weight:700;font-size:var(--text-lg);flex:1}
.note:hover b{color:var(--accent)}
.note span{font-size:var(--text-xs);color:var(--muted);white-space:nowrap}
.note .go{
  width:26px;height:14px;flex:none;align-self:center;color:var(--accent);
  opacity:0;transform:translateX(-8px);
  transition:opacity var(--dur-2), transform var(--dur-2) var(--ease);
}
.note:hover .go{opacity:1;transform:none}
/* AI/07-UI-STANDARDS.md §1 (slice 2): 18px sat exactly between 16/20 rungs;
   rounded to the larger rung (+2px, disclosed in the slice-2 handoff). */
.all-posts{position:relative;display:inline-block;margin-top:var(--s-20);font-weight:700;color:var(--accent);text-decoration:none}
/* slice 6 (AI/07-UI-STANDARDS.md §4): same defect as .article .back, same
   fix - a stand-alone text link measuring 58x25, found while checking
   whether other small controls shared .back's sub-44px hit box. */
.all-posts::before{
  content:"";position:absolute;top:50%;inset-inline-start:0;
  width:max(44px,100%);height:max(44px,100%);
  transform:translateY(-50%);
}
.all-posts:hover{text-decoration:underline;text-underline-offset:4px}

/* ------- CONTACT ------- */
.contact{padding-bottom:var(--s-40)}
/* AI/07-UI-STANDARDS.md §1 (slice 2): 18px sat exactly between 16/20 rungs;
   rounded to the larger rung (+2px, disclosed in the slice-2 handoff). */
.contact h2{margin-bottom:var(--s-20)}
.contact .mail{
  display:inline-block;
  font-weight:900;font-size:var(--display-3);letter-spacing:-.02em;
  color:var(--accent);text-decoration:none;
  border-bottom:3px solid transparent;
  transition:border-color var(--dur-1);
}
.contact .mail:hover{border-color:var(--accent)}
.contact .phone{display:block;margin-top:var(--s-14);color:var(--muted);font-size:var(--text-md);text-decoration:none;width:fit-content}
.contact .phone:hover{color:var(--ink)}
/* AI/07-UI-STANDARDS.md §1 (slice 2): 26px sat exactly between 24/28 (->
   --s-28, +2px); 36px sat exactly between 32/40 (-> --s-40, +4px). Both
   disclosed in the slice-2 handoff. */
.socials{display:flex;gap:var(--s-28);margin-top:var(--s-40);flex-wrap:wrap}
.socials a{position:relative;color:var(--muted);text-decoration:none;font-size:var(--text-base);font-weight:700;transition:color var(--dur-1)}
/* slice 6 (AI/07-UI-STANDARDS.md §4): same .article .back defect - each
   link here measured ~25px tall. ::after is already the arrow glyph, so
   the hit-slop pad uses ::before. The --s-28 row gap is wider than the
   44px pad needs, so neighbouring links' hit boxes never touch. */
.socials a::before{
  content:"";position:absolute;top:50%;inset-inline-start:0;
  width:max(44px,100%);height:max(44px,100%);
  transform:translateY(-50%);
}
.socials a:hover{color:var(--ink)}
.socials a::after{content:" \2197";font-weight:500;color:var(--accent)}

/* ------- FOOTER: an ink card with brand + link columns and a ghost wordmark ------- */
/* slice 6 (AI/07-UI-STANDARDS.md §1): --sp-* aliases migrated to --s-*.
   11px/22px/9px are all ties between two rungs (per the slice-2/3 tie-break
   rule: round to the larger rung, disclosed) - see the slice-6 handoff for
   the full before/after list. */
.site-foot{margin-top:var(--s-96);padding-bottom:var(--s-32)}
.foot-card{
  position:relative;overflow:hidden;
  background:var(--ink);color:var(--wall);
  border-radius:var(--r-tile);
  padding:var(--s-64) var(--s-48) var(--s-32);
}
.foot-grid{
  position:relative;z-index:1;
  display:grid;grid-template-columns:1.7fr 1fr 1.25fr .9fr;
  gap:var(--s-32) var(--s-24);
}
.foot-brand{max-width:40ch}
.foot-logo{
  display:inline-flex;align-items:center;gap:var(--s-8);
  text-decoration:none;color:var(--wall);
  /* 1.4rem stays a literal on purpose. This is the footer WORDMARK, not page
     typography - the same category as the logo mark, which 07 section 4 keeps
     off the scale as illustration rather than an icon. Slice 4 escalated it
     with the two prose h2 sizes; those got a real rung (--text-2xl) because
     they are typography, this did not because it is branding. */
  font-weight:900;font-size:1.4rem;letter-spacing:-.015em;
}
.foot-qmark{width:30px;height:34px;display:block}
.foot-bio{
  margin-top:var(--s-16);font-size:var(--text-sm);line-height:1.7;
  color:color-mix(in srgb, var(--wall) 66%, var(--ink));
}
.foot-cta-label{margin-top:var(--s-28);font-weight:800;color:var(--wall)}
/* design-qa r1 M6: 58% wall measured 3.77:1 in dark (needs 4.5:1) - the
   footer card's bg is --ink, which is a LIGHT token in dark mode (it
   doubles as the inverted-card colour), so the mix needs more --wall
   weight, not less, to read against it in dark too. */
.foot-cta-sub{margin-top:var(--s-4);font-size:var(--text-sm);color:color-mix(in srgb, var(--wall) 74%, var(--ink))}
.foot-cta{
  display:inline-flex;align-items:center;gap:var(--s-8);margin-top:var(--s-16);
  /* slice 6: 11px/22px both ties (10/12, 20/24), rounded to the larger
     rung -> 12px/24px (+1px, +2px, disclosed). */
  padding:var(--s-12) var(--s-24);border-radius:var(--r-pill);
  background:var(--accent);color:var(--accent-ink);
  text-decoration:none;font-weight:800;font-size:var(--text-sm);
  transition:transform var(--dur-1) var(--ease), box-shadow var(--dur-2);
}
.foot-cta svg{width:18px;height:18px}
.foot-cta:hover{transform:translateY(-2px);box-shadow:0 12px 26px -12px color-mix(in srgb, var(--accent) 70%, transparent)}
.foot-col{display:flex;flex-direction:column;gap:var(--s-12)}
.foot-col h3{
  font-size:var(--text-xs);text-transform:uppercase;letter-spacing:.12em;font-weight:700;
  /* design-qa r1 M6/MINOR-6: 52% wall measured 3.19:1 dark, 4.49:1 light
     (both fail/border AA at 4.5:1). Same fix as .foot-cta-sub above. */
  color:color-mix(in srgb, var(--wall) 72%, var(--ink));margin-bottom:var(--s-6);
}
.foot-col a,.foot-line{
  color:color-mix(in srgb, var(--wall) 80%, var(--ink));
  text-decoration:none;font-size:var(--text-sm);line-height:1.4;
  /* slice 6: 9px sat exactly between --s-8/--s-10; rounded to the larger
     rung (+1px, disclosed). */
  display:inline-flex;align-items:center;gap:var(--s-10);
  transition:color var(--dur-1);
}
.foot-line{color:color-mix(in srgb, var(--wall) 68%, var(--ink))}
.foot-line svg,.foot-col a svg{width:16px;height:16px;flex:none;color:var(--accent)}
.foot-col a:hover{color:var(--accent)}
.foot-bar{
  position:relative;z-index:1;
  margin-top:var(--s-48);padding-top:var(--s-20);
  border-top:1px solid color-mix(in srgb, var(--wall) 15%, transparent);
  display:flex;justify-content:space-between;align-items:center;gap:var(--s-14);flex-wrap:wrap;
  font-size:var(--text-xs);color:color-mix(in srgb, var(--wall) 55%, var(--ink));
}
.foot-top{position:relative;color:color-mix(in srgb, var(--wall) 70%, var(--ink));text-decoration:none;font-weight:700;transition:color var(--dur-1)}
/* slice 6 (AI/07-UI-STANDARDS.md §4): same .article .back defect - measured
   77x21. .foot-bar's own row has -s-14 gap and align-items:center, plenty
   of clearance for the padded hit box not to touch .foot-top's siblings. */
.foot-top::before{
  content:"";position:absolute;top:50%;inset-inline-start:0;
  width:max(44px,100%);height:max(44px,100%);
  transform:translateY(-50%);
}
.foot-top:hover{color:var(--accent)}
.foot-mark{
  position:absolute;left:50%;bottom:-.24em;transform:translateX(-50%);z-index:0;
  font-weight:900;letter-spacing:-.03em;white-space:nowrap;line-height:1;
  font-size:clamp(4.5rem,17vw,12rem);
  color:color-mix(in srgb, var(--wall) 5%, transparent);
  pointer-events:none;user-select:none;
}
@media (max-width:900px){
  /* AI/07-UI-STANDARDS.md §3 (slice 5): migrated from the old 820px
     breakpoint to the 900px canon ("lg"). Screenshot-verified at 800/821/
     850/899/900/901 in en+de(longest)+ar-EG(RTL, dark): the 4-column
     .foot-grid already reads cleanly with no crowding through 820-899, so
     this is a disclosed, deliberate density change (2-col a bit earlier),
     not a fix for breakage - see the slice-5 handoff. */
  .foot-grid{grid-template-columns:1fr 1fr}
  .foot-brand{grid-column:1 / -1;max-width:52ch}
}
@media (max-width:520px){
  .foot-grid{grid-template-columns:1fr;gap:var(--s-32)}
  .foot-card{padding:var(--s-48) var(--s-24) var(--s-24)}
  .foot-bar{margin-top:var(--s-32)}
  .foot-mark{font-size:clamp(3.5rem,26vw,7rem)}
}

/* ------- BLOG INDEX ------- */
/* AI/07-UI-STANDARDS.md §1 (slice 3, blog surfaces): 56 was already on the
   ladder (--s-56, added by slice 2). */
.blog-head{padding-top:var(--s-56)}
.blog-head h1{font-size:var(--display-2);max-width:none}
.blog-head p{margin-top:var(--s-14);color:var(--muted);max-width:52ch}
/* slice 3: 36px sat exactly between --s-32/--s-40; rounded to the larger
   rung per the slice-2 tie-break rule (same distance either side, always
   round up), disclosed in the slice-3 handoff. */
.post-grid{display:grid;grid-template-columns:1fr 1fr;gap:var(--s-16);margin-top:var(--s-40)}
/* design-qa r1 MINOR-7: empty state prompt + a next step, not a blank box */
/* slice 3: 44px and 18px each sat exactly between two rungs; both rounded
   to the larger rung (--s-48, --s-20). */
.blog-empty{margin-top:var(--s-48);display:flex;flex-direction:column;align-items:flex-start;gap:var(--s-20)}
.blog-empty p{color:var(--muted)}
/* slice 3: 30px sat exactly between --s-28/--s-32, rounded up (+2px
   vertical); 28px was already exact (--s-28, horizontal unchanged) - the
   same tile-padding pattern 07 §1 already names for .tile. */
.post-card{
  display:flex;flex-direction:column;gap:var(--s-12);
  border-radius:var(--r-tile);padding:var(--s-32) var(--s-28);
  background:var(--tile);color:var(--ink);text-decoration:none;
  transition:transform var(--dur-2) var(--ease), box-shadow var(--dur-1);
  position:relative;overflow:hidden;
}
.post-card:hover{transform:translateY(-4px)}
.post-card.featured{grid-column:1 / -1;background:var(--accent);color:var(--accent-ink)}
/* featured: the banner takes the left half, the words take the right */
/* slice 3: 36px sat exactly between --s-32/--s-40, rounded up (+4px); 40px
   and the gap's 10/48 were already exact. */
.post-card.featured{
  display:grid;grid-template-columns:1.1fr 1fr;
  gap:var(--s-10) var(--s-48);align-items:start;align-content:center;
  padding:var(--s-40) var(--s-40);
}
.post-card.featured .post-banner{grid-column:1;grid-row:1 / 5;align-self:center;margin:0}
.post-card.featured .meta,
.post-card.featured h2,
.post-card.featured p,
.post-card.featured .read{grid-column:2}
.post-card .meta{font-size:var(--text-xs);color:var(--muted)}
.post-card.featured .meta{color:color-mix(in srgb, var(--accent-ink) 75%, var(--accent))}
.post-card h2{font-size:clamp(1.3rem,2.4vw,1.7rem);margin:0;letter-spacing:-.015em;max-width:30ch}
.post-card p{font-size:var(--text-base);color:var(--muted);max-width:60ch}
.post-card.featured p{color:color-mix(in srgb, var(--accent-ink) 82%, var(--accent))}
.post-card .read{
  margin-top:auto;padding-top:var(--s-10);font-weight:700;font-size:var(--text-sm);
  display:inline-flex;align-items:center;gap:var(--s-8);
}
.post-card .read svg{width:30px;height:14px;transition:transform var(--dur-2) var(--ease)}
.post-card:hover .read svg{transform:translateX(6px)}

/* ------- BLOG BANNERS: living sketches, one scene per post ------- */
/* RTL: banners deliberately do not mirror. Two of them (queue/worker in
   why-your-api-feels-slow, rider/dispatch in anatomy-realtime-taxi-booking)
   depict a left-to-right sequence and read backwards to an RTL viewer -
   verified, not a rumour (see personas/QA/reports/banner-rtl-mirroring-
   decision.md). Left unfixed on purpose: they are aria-hidden decoration
   in the site's "napkin sketch" language, the same narrative is stated
   correctly in the surrounding Arabic prose, and a correct fix needs
   per-node text counter-mirroring plus re-deriving two animated paths for
   the mirrored space, not a CSS one-liner - transform:scaleX(-1) on a
   diagram with <text> in it is already banned (06-PIXEL-CHECKLIST.md §5).
   Functional RTL correctness (bidi text, mirrored control icons, layout
   direction) is a hard requirement on this site; narrative direction
   inside decorative art is not, when the same narrative is independently
   correct in real text next to it. Do not re-open without a person who
   was actually confused by one of these two banners specifically. */
.post-banner{display:block;margin:0 0 var(--s-8)}
/* slice 3: 26px sat exactly between --s-24/--s-28, rounded up (+2px);
   30px sat exactly between --s-28/--s-32, rounded up (+2px); 20/8 were
   already exact. */
.article-banner{
  background:var(--tile);border-radius:var(--r-tile);
  padding:var(--s-20) var(--s-28);margin:var(--s-32) 0 var(--s-8);
}
.bn{
  width:100%;height:auto;display:block;
  --bni:color-mix(in srgb, var(--ink) 80%, var(--muted));
  --bna:var(--accent);
  --bnt:var(--muted);
}
.post-card.featured .bn{
  --bni:var(--accent-ink);
  --bna:var(--accent-ink);
  --bnt:color-mix(in srgb, var(--accent-ink) 72%, var(--accent));
}
.bn-i{stroke:var(--bni);fill:none;stroke-linecap:round;stroke-linejoin:round}
.bn-plate{fill:var(--bnp,var(--tile))}
.post-card.featured .bn{--bnp:var(--accent)}
.bn-f{fill:var(--bni)}
.bn-a{stroke:var(--bna);fill:none;stroke-width:2.6;stroke-linecap:round}
.bn-af{fill:var(--bna)}
.bn use{fill:var(--bni)}
.bn-t{font:italic 600 14px/1 Cairo,sans-serif;fill:var(--bnt);letter-spacing:.02em}
.bn-tb{font:700 13.5px/1 Cairo,sans-serif;fill:var(--bni)}
.bn-ta{font:italic 700 14px/1 Cairo,sans-serif;fill:var(--bna)}
/* depth: the backdrop layer sits far away */
.bn-bg{opacity:.3}
/* the sun swells gently */
.bn-sun{transform-box:fill-box;transform-origin:center;animation:bnSun 6s ease-in-out infinite alternate}
@keyframes bnSun{from{transform:scale(1);opacity:.7}to{transform:scale(1.07);opacity:1}}
/* stray petals cross the whole sky */
.bn-petal{opacity:0;animation:bnPetal 11s linear infinite}
.bn-petal.p2{animation-delay:3.6s}
.bn-petal.p3{animation-delay:7.2s}
@keyframes bnPetal{
  0%{transform:translate(-16px,34px) rotate(0deg);opacity:0}
  8%{opacity:.6}
  50%{transform:translate(320px,58px) rotate(120deg)}
  92%{opacity:.6}
  100%{transform:translate(656px,40px) rotate(240deg);opacity:0}
}
/* dashed lines crawl in the flow direction */
.bn-flow{animation:bnFlow 2.6s linear infinite}
@keyframes bnFlow{to{stroke-dashoffset:-56}}
/* request leaves ride the wind line into the queue */
.bn-glide{opacity:0;animation:bnGlide 7.5s linear infinite}
.bn-glide.g2{animation-delay:2.5s}
.bn-glide.g3{animation-delay:5s}
@keyframes bnGlide{
  0%{transform:translate(12px,132px) rotate(0deg);opacity:0}
  10%{opacity:1}
  50%{transform:translate(126px,106px) rotate(38deg)}
  86%{opacity:1}
  100%{transform:translate(240px,112px) rotate(84deg);opacity:0}
}
/* queued leaves shuffle forward impatiently */
.bn-qrow{animation:bnQueue 3.4s var(--ease) infinite}
.bn-qrow.q2{animation-delay:.5s}
.bn-qrow.q3{animation-delay:1s}
@keyframes bnQueue{0%,100%{transform:translateY(0)}50%{transform:translateY(-4px)}}
/* the slow worker's clock hand */
.bn-hand2{transform-origin:490px 142px;animation:bnHand 8s linear infinite}
@keyframes bnHand{to{transform:rotate(360deg)}}
/* the finished leaf floats free */
.bn-done{animation:bnDone 5s ease-in-out infinite alternate}
@keyframes bnDone{from{transform:translate(0,0) rotate(0deg)}to{transform:translate(10px,-12px) rotate(14deg)}}
/* crowns sway like the wall shadows */
.bn-sway{transform-box:fill-box;transform-origin:50% 100%;animation:bnSway 5.5s ease-in-out infinite alternate}
.bn-sway.s2{animation-delay:.9s;animation-duration:6.3s}
.bn-sway.s3{animation-delay:1.7s;animation-duration:5.1s}
.bn-sway.s4{animation-delay:.4s;animation-duration:6.8s}
@keyframes bnSway{from{transform:rotate(-3.5deg)}to{transform:rotate(3.5deg)}}
/* the index lookup hops down the tree */
.bn-hop{opacity:0;animation:bnHop 3.6s ease-in-out infinite}
.bn-hop.h2{animation-delay:1s}
.bn-hop.h3{animation-delay:2s}
@keyframes bnHop{0%,100%{opacity:0}12%,32%{opacity:1}44%{opacity:0}}
/* dispatch signals ripple out */
.bn-ring{transform-box:fill-box;transform-origin:center;opacity:0;animation:bnRing 2.6s ease-out infinite}
.bn-ring.r2{animation-delay:1.3s}
.bn-ring.r3{animation-delay:.6s}
.bn-ring.r4{animation-delay:1.9s}
@keyframes bnRing{0%{transform:scale(.25);opacity:.85}100%{transform:scale(1.7);opacity:0}}
/* the taxi rides the center line */
.bn-car{
  offset-path:path('M-10 186 C150 170 480 208 650 180');
  offset-rotate:auto;offset-distance:0%;
  animation:bnRide 13s linear infinite;
}
@keyframes bnRide{from{offset-distance:0%}to{offset-distance:100%}}

/* ------- LOVE: a floating leaf-heart riding beside the article ------- */
.love-dock{
  position:fixed;
  /* STACKING (love-dock-stacking-frontend-ui): was a raw `z-index:15`,
     predating --z-float by a year. At the time it was written --z-nav was
     the only chrome rung (10), so 15 was chosen to clear the sticky nav
     when this fixed rail's vertical band happened to reach the header (a
     short viewport, or a tall dock). It never accounted for the Tools
     mega-box, because love-dock-on-tools (which puts this same partial on
     every tool page, wrapped in .tool-feedback-bar) didn't exist yet.

     That mega-box wrap is exactly where 15 broke: `.tool-feedback-bar
     .love-dock` (site.css, "love-dock-on-tools" section) sets
     `position:static` for the always-bar tool-page layout, but never
     touched z-index - and z-index applies to flex items even at
     position:static, so the inherited 15 kept outranking `#tools-megabox`
     and `nav.top` (both `var(--z-nav)`, 10) on every one of the 62 tool
     pages, painting the like/comment/share dock over an open dropdown.

     Fixed to `var(--z-float)` (5) - the rung plan 10b's resume-writer
     round 8 added for exactly this shape of element (page-bound floating
     chrome, not site chrome): strictly above `--z-fall` (2, so the dock
     still floats over the ambient falling leaves - the one thing the
     original 15 needed to clear that --z-float also clears) and strictly
     below `--z-nav` (10, so the nav and its mega-box dropdown always win -
     they are chrome, this is page furniture). The old "clear the sticky
     nav in a short viewport" case is no longer cleared on purpose: nav
     over page furniture is the intended outcome everywhere else this rung
     is used (`.rw-doc-toolbar`, the résumé writer's own floating bar,
     already made the same call). See the token's own comment (root
     variable block above) for why 5 was free to reuse. */
  z-index:var(--z-float);
  /* AI/07-UI-STANDARDS.md §1 (spacing migration): 20px and 118px tokenised.
     118px sat 22px from --s-96 and 10px from --s-128 (not a tie) - rounded
     to the nearer rung per the standard's off-scale rule, disclosed in the
     handoff (+10px, i.e. the rail sits 10px further from the article edge
     at every width where this calc governs). 360px stays a literal - it is
     not a spacing quantity, it is exactly half of .article's own (still
     unmigrated, tracked separately by 07 §4) 720px column width, so there
     is no --s-* rung it could mean. */
  left:max(var(--s-20), calc(50% - 360px - var(--s-128)));
  top:52%;transform:translateY(-50%);
  display:flex;flex-direction:column;align-items:center;gap:var(--s-10);
}
/* the wall itself warms with the reader's enthusiasm */
.love-heat{
  position:fixed;inset:0;z-index:-1;pointer-events:none;
  opacity:calc(var(--heat,0));
  transition:opacity .35s var(--ease);
  background:
    radial-gradient(70% 55% at 50% 88%, color-mix(in srgb, var(--heat-2) 34%, transparent), transparent 70%),
    radial-gradient(40% 30% at 82% 10%, color-mix(in srgb, var(--heat-4) 22%, transparent), transparent 75%);
}
.love-dock.hot .love-heat{animation:heatBreathe 1.5s ease-in-out infinite alternate}
@keyframes heatBreathe{from{opacity:calc(var(--heat,0)*.78)}to{opacity:var(--heat,0)}}
.love-btn{
  position:relative;
  width:62px;height:62px;border-radius:50%;
  border:none;cursor:pointer;
  background:color-mix(in srgb, var(--tile) 82%, transparent);
  -webkit-backdrop-filter:blur(10px) saturate(1.15);
  backdrop-filter:blur(10px) saturate(1.15);
  box-shadow:
    inset 0 0 0 1px color-mix(in srgb, var(--line) 90%, transparent),
    0 12px 30px -14px var(--shadow-leaf),
    0 0 calc(var(--heat,0)*44px) calc(var(--heat,0)*4px) color-mix(in srgb, var(--heat-3) calc(var(--heat,0)*50%), transparent);
  display:grid;place-items:center;
  transform:scale(calc(1 + var(--heat,0)*.1));
  transition:transform var(--dur-1) var(--ease), box-shadow .25s;
  -webkit-tap-highlight-color:transparent;
}
.love-btn:hover{transform:scale(calc(1.06 + var(--heat,0)*.1))}
.love-btn:focus-visible{outline:2px solid var(--accent);outline-offset:4px}
/* the combo ring fills as the streak grows */
.love-ring{position:absolute;inset:var(--s-2);transform:rotate(-90deg);pointer-events:none}
.love-ring circle{fill:none;stroke-width:2.6;stroke-linecap:round}
.lr-bg{stroke:var(--line)}
.lr-fill{
  stroke:color-mix(in srgb, var(--heat-0) calc(var(--heat,0)*100%), var(--accent));
  stroke-dasharray:182.2;
  stroke-dashoffset:calc(182.2 - 182.2*var(--ring,0));
  transition:stroke-dashoffset .25s var(--ease), stroke .3s;
}
.love-heart{
  width:26px;height:24px;
  fill:color-mix(in srgb, var(--heat-0) calc(var(--heat,0)*100%), var(--accent));
  transition:fill .3s;
  animation:heartIdle 3.2s ease-in-out infinite;
}
@keyframes heartIdle{0%,100%{transform:scale(1)}12%{transform:scale(1.09)}24%{transform:scale(1)}}
.love-btn.pop .love-heart{animation:lovePop .5s cubic-bezier(.2,1.6,.35,1)}
@keyframes lovePop{
  0%{transform:scale(.7)}
  45%{transform:scale(1.4) rotate(-7deg)}
  70%{transform:scale(.94) rotate(4deg)}
  100%{transform:scale(1)}
}
/* every 25th tap the heart cartwheels */
.love-btn.spin .love-heart{animation:loveSpin .7s var(--ease)}
@keyframes loveSpin{from{transform:scale(.6) rotate(0)}60%{transform:scale(1.5) rotate(320deg)}to{transform:scale(1) rotate(360deg)}}
/* tap ripple */
.love-ripple{
  position:absolute;inset:0;border-radius:50%;pointer-events:none;
  border:2px solid color-mix(in srgb, var(--heat-0) calc(var(--heat,0)*100%), var(--accent));
  animation:loveRipple .65s ease-out forwards;
}
@keyframes loveRipple{
  from{transform:scale(.6);opacity:.7}
  to{transform:scale(2.1);opacity:0}
}
/* the streak chip springs above the button */
.love-combo{
  position:absolute;top:calc(-1 * var(--s-40));left:50%;
  /* slice (spacing): 5px sat exactly between --s-4/--s-6, rounded up to
     --s-6 per the standard's tie rule (+1px vertical padding). */
  padding:var(--s-6) var(--s-12);border-radius:var(--r-pill);
  background:color-mix(in srgb, var(--heat-0) calc(var(--heat,0)*100%), var(--accent));
  color:var(--accent-ink);
  font-weight:900;font-size:var(--text-sm);font-variant-numeric:tabular-nums;
  opacity:0;pointer-events:none;
  transform:translateX(-50%) translateY(8px) scale(.5);
  transition:transform .25s cubic-bezier(.2,1.6,.35,1), opacity .2s, background .3s;
}
.love-dock.streak .love-combo{opacity:1;transform:translateX(-50%) translateY(0) scale(1)}
/* burst particles: leaves and sparks thrown off the heart */
.love-p{
  position:absolute;left:50%;top:50%;z-index:2;
  width:11px;height:8px;pointer-events:none;
  background:color-mix(in srgb, var(--heat-1) calc(var(--heat,0)*100%), var(--accent));
  border-radius:0 100% 0 100%;
  animation:loveFly .9s cubic-bezier(.15,.65,.35,1) forwards;
}
.love-p.spark{width:5px;height:5px;border-radius:50%;background:var(--heat-5)}
@keyframes loveFly{
  from{transform:translate(-50%,-50%) rotate(0deg) scale(1);opacity:1}
  to{transform:translate(calc(-50% + var(--tx,40px)), calc(-50% + var(--ty,-60px))) rotate(var(--tr,180deg)) scale(.3);opacity:0}
}
.love-count{
  font-size:var(--text-xs);font-weight:800;color:var(--muted);
  font-variant-numeric:tabular-nums;letter-spacing:.04em;
  /* slice (spacing): 11px sat exactly between --s-10/--s-12, rounded up to
     --s-12 per the standard's tie rule (+1px horizontal padding each side). */
  padding:var(--s-4) var(--s-12);border-radius:var(--r-pill);
  background:color-mix(in srgb, var(--tile) 82%, transparent);
  -webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);
  box-shadow:inset 0 0 0 1px color-mix(in srgb, var(--line) 80%, transparent);
}
.love-count.bump{animation:loveBump .3s var(--ease)}
@keyframes loveBump{50%{transform:scale(1.3)}}
/* the rest of the dock: comments, share, contact */
.dock-sep{width:20px;height:1px;background:var(--line);margin:var(--s-2) 0}
.dock-btn{
  position:relative;
  width:46px;height:46px;border-radius:50%;
  border:none;cursor:pointer;text-decoration:none;
  color:var(--muted);
  background:color-mix(in srgb, var(--tile) 82%, transparent);
  -webkit-backdrop-filter:blur(10px) saturate(1.15);
  backdrop-filter:blur(10px) saturate(1.15);
  box-shadow:
    inset 0 0 0 1px color-mix(in srgb, var(--line) 90%, transparent),
    0 10px 24px -14px var(--shadow-leaf);
  display:grid;place-items:center;
  transition:transform var(--dur-1) var(--ease), color var(--dur-1), box-shadow var(--dur-1);
  -webkit-tap-highlight-color:transparent;
}
.dock-btn svg{width:20px;height:20px}
.dock-btn:hover{
  transform:scale(1.09);color:var(--accent);
  box-shadow:
    inset 0 0 0 1.5px color-mix(in srgb, var(--accent) 55%, transparent),
    0 10px 24px -12px var(--shadow-leaf);
}
.dock-btn:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
/* comment count badge */
.dock-n{
  position:absolute;top:calc(-1 * var(--s-4));right:calc(-1 * var(--s-4));
  /* slice (spacing): 5px sat exactly between --s-4/--s-6, rounded up to
     --s-6 per the standard's tie rule (+1px each side, badge ~2px wider). */
  min-width:18px;height:18px;padding:0 var(--s-6);border-radius:var(--r-pill);
  background:var(--accent);color:var(--accent-ink);
  font-size:var(--text-2xs);font-weight:900;font-variant-numeric:tabular-nums;
  display:grid;place-items:center;
}
/* share button success state: the icon swaps to a check */
.dock-share.ok{color:var(--accent)}
.dock-share.ok svg{animation:dockOk .4s cubic-bezier(.2,1.6,.35,1)}
@keyframes dockOk{from{transform:scale(.5)}to{transform:scale(1)}}
/* hover tooltips slide out beside the rail.
   love-dock-on-tools (ad hoc - see personas/QA/handoffs/love-dock-on-tools-
   frontend-ui.md): `opacity:0` alone still let the pill's real, unclipped
   `white-space:nowrap` box count toward the page's scrollable width even
   while invisible - a design gate previously called this "latent, not
   currently manifesting" on the blog, and putting the dock on 20 more tool
   pages was exactly the kind of change that could manifest it. It did,
   confirmed live with the real test suite (a temporary de/375 case
   mirroring HoneypotRtlOverflowTest): ~380-387px scrollWidth on a 375px
   viewport. A first attempt added `visibility:hidden` alone - measurably
   better, but not reliably 0: 4 repeated runs of the exact same page load
   read 375/375/375/380, i.e. still an occasional few px, because
   `visibility:hidden` keeps the box's full layout footprint (just unpainted)
   and some rendering paths still let that footprint reach scrollWidth. The
   deterministic fix constrains the resting box itself to zero width -
   `max-width:0;overflow:hidden` - so there is no footprint to leak,
   verified stable at exactly 375 across 10 repeated runs (see the handoff).
   `max-width`'s own transition is a delayed, zero-duration STEP (not a
   smooth tween - never eased, never a duration, so this stays "transform/
   opacity only" motion in spirit, exactly like the `visibility` delay
   pattern it replaces), timed to snap open the instant a hover starts (so
   the fading-in text has room to sit in) and snap shut only once the fade-
   out transition has fully finished (so the visible fade never gets clipped
   mid-transition). */
.dock-btn::after{
  content:attr(data-tip);
  position:absolute;left:calc(100% + var(--s-12));top:50%;
  transform:translateY(-50%) translateX(-6px);
  max-width:0;overflow:hidden;
  /* slice (spacing): 5px sat exactly between --s-4/--s-6, rounded up to
     --s-6 per the standard's tie rule (+1px vertical padding). */
  padding:var(--s-6) var(--s-12);border-radius:var(--r-pill);
  background:var(--ink);color:var(--wall);
  font-size:var(--text-xs);font-weight:700;white-space:nowrap;
  opacity:0;pointer-events:none;
  transition:opacity var(--dur-1), transform var(--dur-1) var(--ease), max-width 0s var(--dur-1);
}
.dock-btn:hover::after{
  opacity:1;max-width:240px;transform:translateY(-50%) translateX(0);
  transition:opacity var(--dur-1), transform var(--dur-1) var(--ease), max-width 0s;
}
/* AI/07-UI-STANDARDS.md §8 (slice 4): the two 0x0 <svg><defs> sprite holders
   (the wind/leaf glyphs here, the tools + chrome icon sprite in
   partials/tool-icons.blade.php) used a static style="position:absolute" to
   stay out of layout flow. Same rule, one class, both callers. */
.icon-defs{position:absolute}
/* px-and-breakpoints: margin:-1px here is the standard visually-hidden
   clip-rect technique, not a spacing choice - the scale has no --s-1 rung
   and none is wanted; -1px is exactly what zeroes the box while keeping it
   focusable. Same reasoning already written at .c-hp and repeated at
   .sr-only-live further down. Left as a literal on purpose. */
.sr-only{
  position:absolute;width:1px;height:1px;padding:0;margin:-1px;
  overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0;
}
/* narrow screens: a fixed floating bubble here used to sit on top of the
   article's own text for the whole scroll (design-qa r2, N1). There is no
   offset that fixes a `position:fixed` control on a narrow column - the text
   reflows underneath it forever. Instead the dock joins the document flow as
   a plain action bar under the article body, same place it already sits in
   the DOM (between .prose and #comments), so nothing is ever painted over
   reading text at any scroll position.
   px-and-breakpoints (AI/07-UI-STANDARDS.md §3): migrated from 1020px to the
   1128px canon ("xl"). Not load-bearing at 1020 the way the nav's 960px is -
   measured live at the tightest point right above the old boundary (1021px,
   `.love-dock` floating): 61px of clear space between the rail and
   `.article`, growing to 66px by 1050px. Raising the threshold to 1128 only
   means the static in-flow bar (already proven safe - it is what ships below
   1020 today) now also covers 1021-1128px instead of the floating rail
   there; nothing overlaps in that range either before or after. Screenshot-
   verified at 1020/1050/1128/1129, en/fr(longest label set)/ar-EG(RTL, dark). */
@media (max-width:1128px){
  .love-dock{
    position:static;left:auto;top:auto;right:auto;bottom:auto;transform:none;
    flex-direction:row;flex-wrap:wrap;justify-content:center;
    /* slice (spacing): 18px is an exact tie between --s-16/--s-20. This file's
       usual convention rounds ties up, but measured live here that breaks
       the row: at 375px this flex row (5 controls + a separator) is tight
       enough that +2px/side (--s-20) pushes the contact icon onto its own
       wrapped line - confirmed by toggling both candidates against the real
       DOM (dock-contact's rect top moves from 118 to 176, i.e. off the row)
       before picking one. Rounding down to --s-16 instead reproduces the
       pre-migration single-row layout exactly, so that is the deliberate
       exception to the round-up convention, not an oversight. */
    gap:var(--s-10);margin:var(--s-40) auto 0;padding:var(--s-14) var(--s-16);max-width:420px;
    background:color-mix(in srgb, var(--tile) 82%, transparent);
    border-radius:var(--r-tile);
    box-shadow:
      inset 0 0 0 1px color-mix(in srgb, var(--line) 90%, transparent),
      0 12px 30px -14px var(--shadow-leaf);
  }
  .love-btn{width:52px;height:52px}
  .love-heart{width:22px;height:20px}
  /* slice (spacing): -38px sat 2px from --s-40 and 6px from --s-32 - not a
     tie, rounded to the nearer rung (-2px, chip sits 2px higher). */
  .love-combo{top:calc(-1 * var(--s-40));bottom:auto}
  .love-dock.streak .love-combo{transform:translateX(-50%) translateY(0) scale(1)}
  .dock-sep{width:1px;height:26px;margin:0 var(--s-4)}
  .dock-btn{width:44px;height:44px;background:transparent;box-shadow:none}
  .dock-btn:hover{box-shadow:none}
  .dock-btn svg{width:20px;height:20px}
  /* tooltips flip above the bar - there is no rail edge to hang off of */
  .dock-btn::after{left:50%;right:auto;top:auto;bottom:calc(100% + var(--s-10));transform:translateX(-50%) translateY(6px)}
  .dock-btn:hover::after{transform:translateX(-50%) translateY(0)}
}

/* ------- COMMENTS: a name and a thought, no gate ------- */
/* slice 3: 56px was already exact (--s-56); 44px sat exactly between
   --s-40/--s-48, rounded up (+4px). */
.comments{margin-top:var(--s-56);padding-top:var(--s-48);border-top:1px solid var(--line)}
.comments h2{font-size:var(--text-2xl);letter-spacing:-.015em}
/* slice 3: 18px sat exactly between --s-16/--s-20, rounded up (+2px). */
.c-flash{
  margin-top:var(--s-20);padding:var(--s-12) var(--s-20);border-radius:12px;
  background:color-mix(in srgb, var(--accent) 14%, transparent);
  color:var(--ink);font-size:var(--text-sm);font-weight:600;
}
/* slice 3: 26px sat exactly between --s-24/--s-28, rounded up (+2px); the
   gap's 22px did too (+2px). */
.c-list{list-style:none;margin:var(--s-28) 0 0;padding:0;display:flex;flex-direction:column;gap:var(--s-24)}
.c-item{display:grid;grid-template-columns:40px 1fr;gap:var(--s-14);align-items:start}
.c-avatar{
  width:40px;height:40px;border-radius:50%;
  background:color-mix(in srgb, var(--accent) 22%, var(--tile));
  color:var(--accent);font-weight:900;font-size:var(--text-md);
  display:grid;place-items:center;
}
.c-head{font-size:var(--text-sm)}
.c-head b{color:var(--ink)}
.c-head time{color:var(--muted);font-size:var(--text-xs);margin-left:var(--s-8)}
.c-text{margin-top:var(--s-4);line-height:1.7;color:color-mix(in srgb, var(--ink) 88%, var(--muted));overflow-wrap:anywhere}
.c-empty{color:var(--muted);font-size:var(--text-sm);font-style:italic}
/* slice 3: 34px is nearer --s-32 than --s-40 (2px vs 6px away, not a tie -
   rounds to nearest, not up). */
.c-form{margin-top:var(--s-32);display:flex;flex-direction:column;gap:var(--s-12)}
/* slice 3: 13px sat exactly between --s-12/--s-14, rounded up (+1px); 18px
   sat exactly between --s-16/--s-20, rounded up (+2px). */
.c-form input[type="text"],.c-form textarea{
  font:inherit;color:var(--ink);
  background:var(--tile);border:none;border-radius:14px;
  padding:var(--s-14) var(--s-20);
  box-shadow:inset 0 0 0 1px var(--line);
  resize:vertical;
}
.c-form input[type="text"]{max-width:320px}
.c-form input:focus-visible,.c-form textarea:focus-visible{
  outline:none;box-shadow:inset 0 0 0 1.5px var(--accent);
}
.c-form ::placeholder{color:var(--muted);opacity:.8}
.c-form .btn{align-self:flex-start}
/* visually-hidden honeypot: a real, focusable-by-automation input that
   must never be announced to assistive tech (the markup carries
   aria-hidden + tabindex="-1") and must never be visible to a sighted
   human - but it must NOT use a large physical offset to get there.
   `left:-6000px` is a *physical* offset: in RTL, `.c-hp`'s containing
   block direction is rtl and that negative left offset extends the
   scrollable area leftward instead of being clipped away, so
   document.body.scrollWidth becomes 375 + 6000 = 6375 at 375px (see
   AI/10-AGENT-OPERATING-MANUAL.md). Same clip-rect technique as
   .sr-only above: a 1x1px box at its normal static position, clipped
   to nothing, so it never grows the scrollable area in either
   direction, in either writing direction.

   Selector is `.c-form input[type="text"].c-hp`, not plain `.c-hp`:
   `.c-form input[type="text"],.c-form textarea{padding:var(--s-14) var(--s-20);...}`
   two rules above has specificity (0,2,1) and would otherwise win over
   a bare `.c-hp` (0,1,0), inflating the "1px" box to roughly 41x29 via its
   padding - harmless for overflow (clip hides paint regardless of box
   size) but not the small, inert box a honeypot should be.

   This rule's own `padding:0;margin:-1px` is the standard visually-hidden
   clip-rect technique (same as `.sr-only` above) and stays a literal on
   purpose: the scale has no --s-1 rung, and the -1px is not a spacing
   choice, it is exactly what the technique requires to zero the box out
   while staying focusable (AI/07-UI-STANDARDS.md §1, slice-3 handoff). */
.c-form input[type="text"].c-hp{
  position:absolute;width:1px;height:1px;padding:0;margin:-1px;
  overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0;
  background:none;box-shadow:none;
}
/* AI/07-UI-STANDARDS.md SS5 breach 2: was a raw red (#b4472e), the one hue
   this system forbids (rule 1, "one accent means no red" - AI/03-DESIGN-
   SYSTEM.md "severity by shape"). Every other error surface on the site
   (.alert-error, .field-error) already carries --ink + the #ic-error
   circle-X glyph instead of a hue; .c-err was the one place that got
   missed. Matched to .field-error's shape here so severity still reads
   without a second accent. */
.c-err{display:flex;gap:var(--s-6);align-items:center;color:var(--ink);font-size:var(--text-sm);font-weight:600}
.c-err svg{width:14px;height:14px;flex:none}

/* ------- BLOG ARTICLE ------- */
/* slice 3: 64px was already exact (--s-64). */
.article{max-width:720px;margin:0 auto;padding:var(--s-64) var(--gutter) 0}
/* slice 3: 34px is nearer --s-32 than --s-40 (2px vs 6px - not a tie,
   rounds to nearest). */
/* ui-fixes-batch item 4 (replaces the slice-6 fix below): the slice-6
   ::before hit-slop DID work - point hit-tested with elementFromPoint at
   1px steps on the live /en/blog/why-your-api-feels-slow page, the real
   clickable region already spanned a full 44px (dy -11..+33 around the
   visible box). But an absolutely positioned pseudo-element never
   contributes to its host's own getBoundingClientRect(), no matter how it
   resolves internally - so .back kept reading 23px tall under the exact
   check AI/07-UI-STANDARDS.md §9 / QA/06-PIXEL-CHECKLIST.md §2 runs on
   every interactive element, a permanent false flag under this project's
   own measurement method. Replaced with the same technique .btn-hire
   already uses for the identical problem (site.css ~438): min-height on
   the flex container itself. align-items:center (already set below)
   centres the label inside the taller box for free - a padding-equivalent
   change, not a type-size change - and getBoundingClientRect() now
   reports 44px because the box genuinely is 44px, not just hit-testable
   inside an invisible overflow region. position:relative dropped - it
   only ever existed to anchor the now-removed pseudo. Left untouched,
   flagged for a future slice: .all-posts and .socials a (site.css, both
   above this rule) use the identical ::before pattern and almost
   certainly share the same false-23px-reading characteristic - not
   re-verified here, out of this item's scope. */
.article .back{
  display:inline-flex;align-items:center;gap:var(--s-8);
  min-height:var(--h-control);
  color:var(--muted);text-decoration:none;font-weight:700;font-size:var(--text-sm);
  margin-bottom:var(--s-32);transition:color var(--dur-1);
}
.article .back:hover{color:var(--ink)}
.article .back svg{width:26px;height:13px;transform:scaleX(-1)}
.article h1{font-size:clamp(1.9rem,4vw,2.9rem);line-height:1.22;max-width:none}
.article .meta{margin-top:var(--s-14);color:var(--muted);font-size:var(--text-sm)}
.prose{margin-top:var(--s-40)}
.prose p{margin:0 0 var(--s-20);line-height:1.75;color:color-mix(in srgb, var(--ink) 88%, var(--muted))}
/* slice 3: 44px sat exactly between --s-40/--s-48, rounded up (+4px). */
.prose h2{font-size:var(--text-2xl);margin:var(--s-48) 0 var(--s-16);letter-spacing:-.015em}
.prose h2::before{
  content:"";display:inline-block;width:26px;height:3px;border-radius:2px;
  background:var(--accent);margin-right:var(--s-12);vertical-align:middle;
  transform:rotate(-2deg);
}
/* slice 3: the list-indent 22px sat exactly between --s-20/--s-24, rounded
   up (+2px). */
.prose ul,.prose ol{margin:0 0 var(--s-20) var(--s-24);line-height:1.75}
.prose li{margin-bottom:var(--s-6)}
.prose strong{font-weight:700}
/* slice 3: 7px sat exactly between --s-6/--s-8, rounded up (+1px). */
.prose code{
  font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
  font-size:.85em;background:var(--tile);padding:var(--s-2) var(--s-8);border-radius:6px;
}
/* slice 3: 18px sat exactly between --s-16/--s-20, rounded up (+2px); 22px
   sat exactly between --s-20/--s-24, rounded up (+2px). */
.prose .callout{
  border-radius:var(--r-tile);background:var(--tile);
  padding:var(--s-20) var(--s-24);margin:0 0 var(--s-20);
  font-size:var(--text-base);color:var(--muted);
  border-left:3px solid var(--accent);
}
/* hand-drawn explainer figures */
/* slice 3: 26px (padding-top) sat exactly between --s-24/--s-28, rounded
   up (+2px); the margin's 28/32 and the padding's 20/14 were already
   exact. */
.sketch{
  margin:var(--s-28) 0 var(--s-32);padding:var(--s-28) var(--s-20) var(--s-14);
  border-radius:var(--r-tile);
  background:var(--tile);
  overflow-x:auto;
}
/* board row 299 (personas/QA/reports/inline-diagram-rtl-scroll-start.md):
   .sketch is a horizontal scroll container and INHERITS direction:rtl from
   the page, while .sketch svg below is pinned direction:ltr. A scroll
   container rests at its content's LAST panel under rtl and its FIRST under
   ltr (Chrome's spec-compliant rtl scrolling puts 0 at the right end), so
   the same figure opened on its first panel for English readers and its
   last panel - hiding up to 193 of 520px, on every phone - for Arabic ones.
   Fix: give the CONTAINER the same direction:ltr the svg already has, so it
   rests at the drawing's start for both audiences. Verified live before
   writing this: svg offset at rest went -173 -> +20 on a real Arabic post
   at 375x812, matching the English control exactly.
   The second rule is not optional. .sketch figcaption is real Arabic prose
   (see the comment on .sketch svg below) and direction is inherited, so
   without re-flipping it explicitly this rule would silently turn every
   Arabic caption LTR - a worse bug than the one being fixed. */
[dir="rtl"] .sketch{direction:ltr}
[dir="rtl"] .sketch figcaption{direction:rtl}
/* design-qa r1 B1-class (flagged as CNV-1 until plan 02 seeded a real post):
   the diagram is illustrated with fixed pixel coordinates - boxes, arrows
   and the SVG <text x=..> labels are laid out together as one drawing. RTL's
   inherited direction flips text-anchor:start onto the wrong edge (same bug
   as the header logo) while the boxes/arrows around it stay put, so the
   label detaches from what it's labelling. Scoped to the <svg>, not the
   whole <figure>: the figcaption below it is real prose and must still flip
   to RTL when it is eventually translated. */
.sketch svg{width:100%;height:auto;display:block;min-width:480px;direction:ltr}
/* ===== ui-fixes-batch item 3: mixed Arabic + FSI-isolated Latin garbled
   in a .sketch svg <text> node =====
   The direction:ltr above is required (see the comment right above it) and
   stays - removing it re-breaks text-anchor:start box/arrow alignment,
   confirmed by re-testing: with no direction override at all, a mixed
   Arabic+isolate string rendered as pure glyph-soup, worse than today.
   But that same forced-ltr paragraph direction is what garbles a run that
   MIXES Arabic with an FSI-isolated Latin term - confirmed by screenshot:
   "الـ⁨API⁩" as a box label (text-anchor=middle) rendered unreadable,
   while the byte-identical string in a normal <p> (which follows the
   page's real direction) was correct. content-seo hit this authoring the
   first Arabic post body and worked around it with pure-Arabic labels
   instead of touching this file (personas/QA/handoffs/published-posts-
   translation-content-seo.md §8) - this rule is that fix, so the
   workaround is no longer necessary for new diagrams.
   unicode-bidi:plaintext re-enables per-content direction detection
   (UAX#9 P2/P3), which is exactly what fixes the mixed run - confirmed by
   screenshot, matches the plain-<p> control exactly.
   Scoped to [text-anchor="middle"] only, NOT every .sketch svg text -
   this was proven necessary, not stylistic. A/B screenshot on real
   shipped content ("3 ثوانٍ كاملة", post 1, text-anchor:start/default):
   plaintext visibly reordered which end the leading digit lands on - a
   real regression on already-correct, already-shipped copy. The same
   test on a real MIDDLE-anchored digit-leading string (post 3's
   "4 · مكالمة صوتية...") showed no shift at all: centering makes the
   internal reorder invisible. Box-name labels (.sk-t) - exactly where
   the reported bug lives - are typically middle-anchored, so this covers
   the reported case without touching the start/end-anchored caption
   classes (.sk-ta/.sk-ts) where the regression risk was proven live.
   A future label that mixes Arabic + an isolated Latin term in a
   start-anchored caption is not covered by this rule - flagged in the
   handoff, not solved here; CSS cannot select by "the text contains an
   isolate character". */
.sketch svg text[text-anchor="middle"]{unicode-bidi:plaintext}
.sketch figcaption{
  margin-top:var(--s-10);text-align:center;
  font-size:var(--text-xs);color:var(--muted);font-style:italic;
}
.sk-ink{stroke:var(--ink);fill:none;stroke-width:2.4;stroke-linecap:round;stroke-linejoin:round}
.sk-ink-f{fill:var(--ink)}
.sk-acc{stroke:var(--accent);fill:none;stroke-width:2.4;stroke-linecap:round;stroke-linejoin:round}
.sk-acc-f{fill:var(--accent)}
.sk-dash{stroke-dasharray:7 6}
.sk-t{fill:var(--ink);font-family:'Cairo',sans-serif;font-size:15px;font-weight:700}
.sk-ts{fill:var(--muted);font-family:'Cairo',sans-serif;font-size:12.5px;font-style:italic;font-weight:500}
.sk-ta{fill:var(--accent);font-family:'Cairo',sans-serif;font-size:13px;font-weight:700;font-style:italic}
/* slice 3: 48px was already exact (--s-48). */
.article-end{
  margin:var(--s-48) auto 0;max-width:720px;padding:0 var(--gutter);
}
/* slice 3: 30px sat exactly between --s-28/--s-32, rounded up (+2px); 32/20
   were already exact. */
.article-end .cta{
  border-radius:var(--r-tile);background:var(--ink);color:var(--wall);
  padding:var(--s-32) var(--s-32);display:flex;align-items:center;justify-content:space-between;
  gap:var(--s-20);flex-wrap:wrap;
}
.article-end .cta b{font-size:var(--text-lg)}
.article-end .cta .btn{background:var(--wall);color:var(--ink)}

/* ------- SCROLL REVEAL ------- */
.js .rv{opacity:0;transform:translateY(18px);transition:opacity var(--dur-3) var(--ease), transform var(--dur-3) var(--ease)}
.js .rv.in{opacity:1;transform:none}

/* ------- HERO ENTRANCE ------- */
.js .hero-intro,.js .hero h1,.js .hero-sub,.js .hero-cta,.js .hero-tools-note{
  opacity:0;transform:translateY(16px);
  animation:enter .8s var(--ease) forwards;
}
.js .hero h1{animation-delay:.08s}
.js .hero-sub{animation-delay:.16s}
.js .hero-cta{animation-delay:.24s}
/* ui-fixes-batch item 1: joins the same cascade one step after .hero-cta,
   same .08s stagger the three rows above it already use. */
.js .hero-tools-note{animation-delay:.32s}
@keyframes enter{to{opacity:1;transform:none}}
.js .tree-scene{
  opacity:0;transform:translateY(24px);
  animation:enterNote .9s var(--ease) .3s forwards;
}
@keyframes enterNote{to{opacity:1;transform:translateY(0)}}

/* =====================================================================
   plan 01: i18n - language switcher, first-visit modal, RTL
   ===================================================================== */

/* ---- language switcher: pill trigger + ONE flat menu (r4 spec - the
   two-level Arabic submenu is gone, not hidden) ---- */
.lang-switch{position:relative}
.lang-trigger{
  display:inline-flex;align-items:center;gap:var(--s-6);
  /* design-qa r1 M4: 38px was 6px short of the 44px tap-target bar; MINOR-3:
     padding was physical (right/left), so RTL put the roomy side on the flag
     instead of the caret - padding-inline flips correctly with dir */
  height:44px;padding-inline:var(--s-10) var(--s-14);border-radius:var(--r-pill);
  background:transparent;border:none;cursor:pointer;
  color:var(--muted);box-shadow:inset 0 0 0 1.5px var(--line);
  font-family:inherit;font-size:var(--text-sm);font-weight:700;
  transition:color var(--dur-1), box-shadow var(--dur-1);
}
.lang-trigger:hover,.lang-trigger[aria-expanded="true"]{color:var(--ink);box-shadow:inset 0 0 0 1.5px var(--ink)}
.lang-flag{font-size:var(--text-sm);line-height:1}
.lang-name{white-space:nowrap}
.lang-caret{width:10px;height:7px;flex:none;transition:transform var(--dur-2) var(--ease)}
.lang-trigger[aria-expanded="true"] .lang-caret{transform:rotate(180deg)}

.lang-menu{
  position:absolute;top:calc(100% + var(--s-10));inset-inline-end:0;z-index:var(--z-nav);
  min-width:200px;margin:0;padding:var(--s-8);list-style:none;
  background:var(--tile);border-radius:var(--r-tile);
  box-shadow:0 18px 40px -18px color-mix(in srgb, var(--shadow-leaf) 55%, transparent),
             0 4px 14px -6px color-mix(in srgb, var(--shadow-leaf) 30%, transparent);
  opacity:0;transform:translateY(-6px) scale(.98);
  visibility:hidden;pointer-events:none;
  transition:opacity var(--dur-2) var(--ease), transform var(--dur-2) var(--ease), visibility 0s linear var(--dur-2);
}
/* visibility flips instantly on open so focus/hit-testing matches what's on
   screen; only opacity/transform actually animate (rule: transform+opacity only) */
.lang-switch.is-open .lang-menu{
  opacity:1;transform:none;visibility:visible;pointer-events:auto;
  transition:opacity var(--dur-2) var(--ease), transform var(--dur-2) var(--ease), visibility 0s;
}
.lang-item{position:relative}
.lang-link{
  display:flex;align-items:center;gap:var(--s-10);width:100%;
  min-height:44px;/* design-qa r1 M4: was 40.4px, under the 44px tap bar */
  /* px-and-breakpoints: 9px sat exactly between --s-8/--s-10; rounded to
     the larger rung (+1px, a dropdown row with plenty of clearance). */
  padding:var(--s-10) var(--s-12);border-radius:calc(var(--r-tile) - 10px);
  background:none;border:none;cursor:pointer;
  color:var(--ink);text-decoration:none;font-family:inherit;
  font-size:var(--text-sm);font-weight:500;text-align:start;
  transition:background var(--dur-1);
}
.lang-link:hover,.lang-link:focus-visible{background:color-mix(in srgb, var(--accent) 14%, transparent)}
/* current locale stays visible in the list (never hidden), marked via
   aria-current so it's announced, plus a quiet on-brand tint + a check
   glyph reusing the leaf accent - no new colour, tokens only */
.lang-link[aria-current="true"]{
  background:color-mix(in srgb, var(--accent) 10%, transparent);
  font-weight:700;
}
.lang-link[aria-current="true"]::after{
  content:"";width:6px;height:6px;border-radius:var(--r-pill);flex:none;
  margin-inline-start:auto;background:var(--accent);
}

/* ---- first-visit locale suggestion modal: overlay, never blocks content ---- */
.locale-modal{
  position:fixed;inset:0;z-index:var(--z-skip);
  display:flex;align-items:flex-end;justify-content:center;
  padding:var(--s-24);pointer-events:none;
}
.locale-modal-card{
  pointer-events:auto;position:relative;max-width:440px;width:100%;
  margin-bottom:var(--s-24);
  /* design-qa r1 MINOR-2: physical padding never flipped, so the roomy side
     (reserved for the close button) stayed on the right in both directions.
     padding-inline flips with dir, so the extra room tracks the close
     button's own inset-inline-end. */
  padding-block:var(--s-24);padding-inline:var(--s-24) var(--s-32);
  border-radius:var(--r-tile);background:var(--ink);color:var(--wall);
  box-shadow:0 24px 60px -20px color-mix(in srgb, var(--shadow-leaf) 65%, transparent);
  opacity:0;transform:translateY(18px);
  animation:localeModalIn var(--dur-3) var(--ease) forwards;
}
@keyframes localeModalIn{to{opacity:1;transform:none}}
@keyframes localeModalFadeOnly{from{opacity:0}to{opacity:1}}
.locale-modal-card.is-leaving{
  animation:none;opacity:0;transform:translateY(18px);
  transition:opacity var(--dur-2) var(--ease), transform var(--dur-2) var(--ease);
}
.locale-modal-card p{font-size:var(--text-base);font-weight:500}
.locale-modal-actions{display:flex;flex-wrap:wrap;gap:var(--s-10);margin-top:var(--s-16)}
/* design-qa r1 B2: .btn-primary is ink-on-wall by default, but this card's
   background IS --ink - identical colour, 1.00:1 contrast, the primary CTA
   read as plain text in both themes. Invert it exactly like .btn-ghost
   already is below: swap to --wall/--ink so it still reads as "the button"
   against the card, in both themes (the tokens swap with data-theme). */
.locale-modal-card .btn-primary{background:var(--wall);color:var(--ink)}
.locale-modal-card .btn-ghost{color:var(--wall);box-shadow:inset 0 0 0 1.5px color-mix(in srgb, var(--wall) 40%, transparent)}
.locale-modal-card .btn-ghost:hover{box-shadow:inset 0 0 0 1.5px var(--wall)}
.locale-modal-close{
  position:absolute;top:var(--s-10);inset-inline-end:var(--s-10);
  display:inline-flex;align-items:center;justify-content:center;
  width:30px;height:30px;border-radius:var(--r-pill);
  background:none;border:none;cursor:pointer;color:color-mix(in srgb, var(--wall) 70%, transparent);
  transition:color var(--dur-1);
}
/* design-qa r1 M4: 30px close button, same hit-slop technique as the
   theme/sound buttons above - visual size stays small and tucked in the
   corner, tap area grows to 44px */
.locale-modal-close::before{
  content:"";position:absolute;top:50%;left:50%;
  width:max(44px,100%);height:max(44px,100%);
  transform:translate(-50%,-50%);
}
.locale-modal-close:hover{color:var(--wall)}
.locale-modal-close svg{width:16px;height:16px}

@media (max-width:640px){
  .lang-name{display:none}
  .lang-trigger{padding-inline:var(--s-10);gap:0}
  /* v2 audit D1: below 960px the header wraps to two rows and the switcher
     sits mid-row in .top-actions, so the menu - absolutely anchored to the
     trigger - overflowed the viewport (right by ~59px in RTL, left by ~57px
     in LTR) and body/html's overflow-x:hidden hard-clipped every label with
     no way to scroll. Pin it to the viewport instead: position:fixed with a
     gutter inset keeps the whole box on-screen in BOTH directions
     (min-width 190 fits inside 375 - 2*gutter), and its top tracks the sticky
     trigger through --lang-menu-top (set in site.js on open, so it stays
     aligned at any scroll offset). The 62px fallback (nearer --s-64 than
     --s-56, +2px) only ever paints in the instant before site.js's own
     `setProperty` runs on open - real position tracking always wins once
     the menu is actually visible. */
  .lang-menu{
    position:fixed;top:var(--lang-menu-top,var(--s-64));
    inset-inline-end:var(--gutter);inset-inline-start:auto;
    min-width:190px;max-width:calc(100vw - var(--gutter) * 2);
  }
}

/* =====================================================================
   plan 01: RTL - logical properties handle most of it automatically;
   this section only overrides the few physical-property/mirrored spots.
   ===================================================================== */
/* the decorative ambient layers (.wind/.gust/.cluster) intentionally extend
   past the viewport (see AI/05-WORKFLOW.md traps) - in a LTR document that
   overflow sits harmlessly to the right of x:0, clipped by body's
   overflow-x:hidden. In RTL, the browser anchors the initial scroll position
   at the *opposite* edge, which put that same overflow in view and shifted
   real content off-screen. Clipping on <html> too (not just body) fixes it
   for both directions. */
html{overflow-x:hidden}
[dir="rtl"]{direction:rtl}
[dir="rtl"] .hl svg{transform:scaleX(-1)}
[dir="rtl"] .nav-links{margin-left:0;margin-right:auto}
[dir="rtl"] .nav-links a::after{left:auto;right:0}
[dir="rtl"] .go,[dir="rtl"] .foot-cta svg,[dir="rtl"] .rev-nav svg{transform:scaleX(-1)}
[dir="rtl"] .rev-prev svg,[dir="rtl"] .rev-next svg{transform:scaleX(-1)}
/* v2 audit D3: the blog card "read more" arrow is a directional (forward)
   arrow, so it flips in RTL like the article back-arrow already does - it was
   the only one left pointing right (against RTL reading-forward). The hover
   nudge flips with it: translateX(6px) inside a scaleX(-1) frame moves it left,
   the correct forward direction in RTL. */
[dir="rtl"] .post-card .read svg{transform:scaleX(-1)}
[dir="rtl"] .post-card:hover .read svg{transform:scaleX(-1) translateX(6px)}
[dir="rtl"] .tree-chips .chip{direction:ltr} /* tech labels (MySQL, REST APIs, ...) stay LTR inside RTL pages */
/* design-qa r1 B1: the wordmark is a fixed Latin brand string (QANDIL /
   DEVELOPING / IDEAS) laid out with fixed SVG <text x="40"> anchors. RTL's
   inherited direction flipped text-anchor:start to the right edge, so the
   words rendered backwards and off their own mark. Fixed at the base
   `.logo{direction:ltr}` rule above (not scoped to [dir="rtl"]) because the
   wordmark must never mirror in either direction - it isn't translated. */
[dir="rtl"] .foot-grid{direction:rtl}
[dir="rtl"] .xp{direction:rtl}

/* =====================================================================
   8. RESPONSIVE PATTERNS
   ===================================================================== */
@media (max-width:960px){
  /* design-qa r1 M7/M1: this used to just hide all five nav links with
     nothing to replace them below 860px, and separately the header
     overflowed (hire button measured past the edge at both 760px and
     900px, worst in German - the exact px a 5-link row stops fitting
     shifts per locale). DESIGN.md §7 asks mobile to trim Work + Skills, not
     all five - so below 960px (tablet and phone both) Work + Skills are
     trimmed and the remaining three (Experience / Blog / Contact) wrap onto
     their own line under the brand + actions row, instead of vanishing or
     overflowing the viewport. */
  .lang-name{display:none}
  .lang-trigger{padding-inline:var(--s-10);gap:0}
  /* px-and-breakpoints: .btn-hire's padding is now tokenised (9px was a
     --s-8/--s-10 tie, rounded up) - VERTICAL only, so it cannot add a
     single px to the row's width. .nav-links's own horizontal gap a few
     hundred lines up stays an untouched literal; that one is load-bearing
     at this exact width, this one is not - see that rule's own comment. */
  .top-in{flex-wrap:wrap;gap:var(--s-10);height:auto;padding-block:var(--s-10);row-gap:var(--s-8)}
  .top-actions{gap:var(--s-8)}
  .btn-hire{padding:var(--s-10) var(--s-16);font-size:var(--text-sm)}
  .nav-links{
    order:3;flex-basis:100%;margin:0;
    justify-content:center;flex-wrap:wrap;gap:var(--s-8) var(--s-20);
    padding-top:var(--s-8);border-top:1px solid var(--line);
  }
  .nav-links .nav-trim{display:none}
}
/* ===== nav-content-driven-collapse =====
   The 960px floor above forces the collapsed nav unconditionally - it is
   the no-JS fallback and stays exactly as it was (AI/07-UI-STANDARDS.md
   §3's documented 960px exception is unchanged: this does not replace it,
   it supplements it above 960px). But a fixed breakpoint cannot know how
   long a locale's own nav text is, and nav-german-overflow's own sweep
   found the unwrapped row genuinely overflows `.top-in` well past 960px
   for several locales at once, each by a different amount that shrinks to
   0 at a different width: Spanish +117px, French +77px, German +43px
   (already narrowed by the clamp below, which this supersedes for JS
   users - see its own comment), Arabic -14px (mirrored). No single fixed
   number is right for all of them, which is exactly why four width-only
   attempts at this bug each fixed one locale and left (or worsened)
   the rest - see the handoff for the full history.

   site.js (guarded, `// plan 03: nav-content-driven-collapse`) measures
   whether `.top-in`'s real, unwrapped content actually fits - not a
   viewport width - and toggles `.is-tight` on `.top` when it does not.
   These declarations are a deliberate, disclosed duplicate of the
   `@media (max-width:960px)` block above: same values, same specificity
   (`:where()` contributes zero, so `:where(.top.is-tight) .nav-links` is
   exactly as specific as the plain `.nav-links` inside that media block,
   which matters because `[dir="rtl"] .nav-links{margin-right:auto}` a few
   hundred lines down already outranks a *bare* `.nav-links{margin:0}` at
   (0,2,0) vs (0,1,0) - if this block used `.top.is-tight .nav-links`
   instead, at (0,3,0) it would outrank that RTL rule too and silently
   flip the wrapped RTL nav's own alignment. Matching the existing
   specificity keeps this block's cascade behaviour identical to the
   media query's, proven, not assumed - see the regression test pinning
   both routes to the same computed styles.
   No-JS visitors keep exactly the 960px floor above; nothing here changes
   for them. */
:where(.top.is-tight) .lang-name{display:none}
:where(.top.is-tight) .lang-trigger{padding-inline:var(--s-10);gap:0}
:where(.top.is-tight) .top-in{flex-wrap:wrap;gap:var(--s-10);height:auto;padding-block:var(--s-10);row-gap:var(--s-8)}
:where(.top.is-tight) .top-actions{gap:var(--s-8)}
:where(.top.is-tight) .btn-hire{padding:var(--s-10) var(--s-16);font-size:var(--text-sm)}
:where(.top.is-tight) .nav-links{
  order:3;flex-basis:100%;margin:0;
  justify-content:center;flex-wrap:wrap;gap:var(--s-8) var(--s-20);
  padding-top:var(--s-8);border-top:1px solid var(--line);
}
:where(.top.is-tight) .nav-links .nav-trim{display:none}
@media (max-width:900px){
  /* AI/07-UI-STANDARDS.md §1 (slice 2): 44px sat exactly between 40/48;
     rounded to the larger rung (+4px, disclosed in the slice-2 handoff). */
  .hero{grid-template-columns:1fr;gap:var(--s-48);align-items:start}
  .tree-scene{justify-self:center;width:min(380px,88vw)}
  .repos{grid-template-columns:1fr}
  /* AI/07-UI-STANDARDS.md §3 (slice 5): migrated from the old 760px
     breakpoint to the 900px canon ("lg") and merged into this block, which
     already targets 900. Screenshot-verified at 700/760/800/850/899/900,
     en+de(longest)+ar-EG(RTL, dark): the 2-col .work-grid bento and the
     2-col .post-grid both read cleanly with no crowding or overflow
     throughout 760-899, so this is a disclosed, deliberate density change
     in that range (1-col instead of 2, one screen earlier), not a fix for
     breakage - see the slice-5 handoff for the full evidence. The
     résumé-builder's own separate 760px breakpoint (its mobile Edit/
     Preview tab switch, a few hundred lines down) is NOT this one and was
     deliberately left alone - different concern, different selector,
     higher-risk surface. */
  .work-grid{grid-template-columns:1fr}
  .t-green{grid-row:auto}
  .post-grid{grid-template-columns:1fr}
  /* slice 3: mirrors the desktop rule's 30->32 tie-break rounding. */
  .post-card.featured{display:flex;padding:var(--s-32) var(--s-28)}
}
@media (max-width:640px){
  .g-a{top:-8vh;left:-40vw}
  .g-a svg{width:170vw}
  .g-b svg{width:100vw}
  .g-c,.g-d{display:none}
  .g-e{left:52vw}
  .g-e svg{width:46vw}
  .f2{display:none}

  .logo{height:27px}
  .theme-btn,.sound-btn{width:34px;height:34px}
  /* design-qa r1 M1: tighter than the 860px shrink above - the phone-width
     worst case (German "Beauftragen") still needed a smaller hire button.
     px-and-breakpoints: 11px sat exactly between --s-10/--s-12; rounded up
     (+1px, vertical-only - min-height:44px already floors the real height). */
  .btn-hire{padding:var(--s-12) var(--s-16);font-size:var(--text-xs);min-height:44px}
  .top-actions{gap:var(--s-6)}
  /* slice 6: 18px sat exactly between --s-16/--s-20; rounded to the larger
     rung (+2px, disclosed). Only reachable once .nav-links has already
     wrapped to its own centred row via the 960px rule above, so this is
     not part of the tight desktop width budget. */
  .nav-links{gap:var(--s-6) var(--s-20)}

  .hero{padding-top:var(--s-32);padding-bottom:var(--s-40);gap:var(--s-32)}
  .hl svg{display:none}
  .stats{grid-template-columns:repeat(2,1fr)}
  /* AI/07-UI-STANDARDS.md §1 (slice 2): section's padding-top NOT migrated
     here either - same unresolved --rhythm-section conflict as the desktop
     rule above (see that rule's comment and the slice-2 handoff §5). */
  section{padding-top:var(--rhythm-section-sm)}
  /* AI/07-UI-STANDARDS.md §1 (slice 2): 22px sat exactly between 20/24;
     rounded to the larger rung (+2px, disclosed in the slice-2 handoff). */
  h2{margin-bottom:var(--s-24)}
  /* AI/07-UI-STANDARDS.md §1's own worked example: mobile tile padding
     26/22 collapses onto the single token --s-24 (vertical -2px, horizontal
     +2px) - the exact migration 07 §1 names for this pair. */
  .tile{padding:var(--s-24)}
  .xp{grid-template-columns:1fr;gap:var(--s-4)}
  .skills{grid-template-columns:1fr}
  .contact .mail{overflow-wrap:anywhere}
  .note{flex-wrap:wrap;gap:var(--s-8) var(--s-16)}
  .article{padding-top:var(--s-40)}

  /* px-and-breakpoints: 26px sat exactly between --s-24/--s-28; 22px sat
     exactly between --s-20/--s-24. Both rounded up (+2px each, a fixed
     min-height card with generous room). */
  .rev-card{padding:var(--s-28) var(--s-24);min-height:262px}
  .rev-card.is-p{transform:translateX(-16%) scale(.9)}
  .rev-card.is-n{transform:translateX(16%) scale(.9)}
  .rev-card.is-p,.rev-card.is-n{opacity:0}
  .rev-ui{gap:var(--s-14);margin-top:var(--s-20)}
  /* design-qa r1 M4: was shrunk to 40x40, under the 44px tap bar - kept at
     the desktop 44x44 size instead of shrinking it further */
  .rev-bar{width:90px}
}

/* ------- REDUCED MOTION: everything collapses to static ------- */
@media (prefers-reduced-motion: reduce){
  .wall-light,.cluster,.limb{animation:none}
  .fall{display:none}
  html{scroll-behavior:auto}
  .js .rv{opacity:1;transform:none;transition:none}
  .js .hero-intro,.js .hero h1,.js .hero-sub,.js .hero-cta,.js .hero-tools-note{opacity:1;transform:none;animation:none}
  .js .tree-scene{opacity:1;animation:none}
  .avail i{animation:none}
  .etree-grow{animation:none;clip-path:none}
  .etree-sway,.etree-shadow,.etree{animation:none}
  .tree-scene::before{animation:none;opacity:.42}
  .chip-in{animation:none;opacity:1}
  .chip .chk{animation:none;transform:none}
  /* static lockup: leaf-Q mark with QANDIL only */
  .lgq-spin{animation:none;transform:none}
  .lg-w{animation:none}
  .lg-w:first-child{opacity:1}
  /* love button: counts still work, fireworks stay home */
  .love-btn{transform:none}
  .love-heart,.love-btn.pop .love-heart,.love-btn.spin .love-heart,.love-count.bump{animation:none}
  .love-dock.hot .love-heat{animation:none}
  .love-p,.love-ripple{display:none}
  /* blog banners freeze mid-scene */
  .bn-flow,.bn-glide,.bn-qrow,.bn-hand2,.bn-done,.bn-sway,.bn-hop,.bn-ring,.bn-car,.bn-sun,.bn-petal{animation:none}
  .bn-glide{opacity:1;transform:translate(126px,106px) rotate(38deg)}
  .bn-glide.g2,.bn-glide.g3{display:none}
  .bn-hop{opacity:1}
  .bn-ring,.bn-petal{display:none}
  .bn-car{offset-distance:38%}
  .bn-sun{opacity:1}
  /* swiper: instant cuts, no autoplay bar, no staggered insides */
  .rev-card{transition:none}
  .rev-card.is-a .stars svg,.rev-card.is-a blockquote,.rev-card.is-a .rev-who{animation:none}
  .rev-card.is-a .rev-doodle{animation:none}
  .rev-bar{display:none}
  /* plan 01: language switcher + first-visit modal go static */
  .lang-menu,.lang-caret{transition:none}
  /* fade only, no slide-up, per plan 01 §6 */
  .locale-modal-card{animation:localeModalFadeOnly var(--dur-2) linear forwards;transform:none}
  .locale-modal-card.is-leaving{transform:none;transition:opacity var(--dur-2) linear}
  /* plan 03: mega-box, mobile sheet, cards, toast, confirm dialog, skeleton
     shimmer and the progress bar all go static/instant */
  .tools-nav-trigger svg,.tools-megabox,.tools-sheet,.tool-card,.hub-card,
  .dropzone,.toast,.confirm-backdrop,.confirm-card,.faq-q svg,.faq-a,
  .progressbar > i,.skel-row::after,.tool-skel .ti-skel::after,
  .icon-btn,.tools-cats button{
    transition:none !important;animation:none !important;
  }
  /* tool option controls (range thumb hover/press, checkbox fill+check,
     segmented group, select/color focus) - static swaps, no animation */
  .tool-range::-webkit-slider-thumb,.tool-range::-moz-range-thumb,
  .tool-check-box,.tool-check-box svg,.tool-segmented-btn{
    transition:none !important;animation:none !important;
  }
  /* wave 1: image-watermark's position-grid cell background swap */
  .tool-position-cell,.tool-position-cell::before{
    transition:none !important;animation:none !important;
  }
  /* plan 06: color-palette-generator's reroll pulse, and every lift-on-hover
     in the palette/gradient controls, go static */
  .tool-palette-swatch,.tool-palette-lock,.tool-palette-move,
  .tool-gradient-preset,.tool-gradient-stop-remove{
    transition:none !important;
  }
  .tool-palette-bar.is-rerolled .tool-palette-swatch{
    animation:none !important;
  }
  /* picker-blocker fix: pick-history chip's lift-on-hover/focus, same
     pattern as the palette swatch just above */
  .tool-picker-history-item{transition:none !important}
  /* plan 10b: resume-writer - section-accordion chevron rotate and
     accent-swatch hover lift go static (no resting transform on either -
     07 SS6's "put the offset in the keyframe's from" trap does not apply,
     there is nothing here to fight). r3: `.rw-line-input` (the seamless
     field's own hover/focus swap) and `.rw-outline-cell` (the navigation-
     only outline row) are GONE from this list, not just their rules above
     - the r3 engine emits neither class any more (grepped before
     removing), so keeping them here would be a reduced-motion off-switch
     for a transition that no longer exists anywhere in the stylesheet. */
  .rw-section-header::after,.rw-swatch{
    transition:none !important;
  }
  /* plan 10: consent notice - fade only, no slide-up (same pattern as the
     locale modal just above, reusing its keyframe rather than a near-
     identical duplicate) */
  .consent-notice{animation:localeModalFadeOnly var(--dur-2) linear forwards;transform:none}
  .consent-notice.is-dismissed{transform:none;transition:opacity var(--dur-2) linear}
  /* plan 11: guide cards (and any other .post-card use) go static under
     reduced motion - this is `.post-card` itself, not scoped to
     `.tool-guide-grid`, so it also closes the same gap for blog/index's
     own pre-existing `.post-card` hover lift for free (same class, one
     rule, no separate blog-specific override needed). */
  .post-card,.post-card .read svg{transition:none !important}
  /* tool-feedback: the rating-star hover lift loses only its transition
     (no resting transform exists on it to fight, see AI/07-UI-STANDARDS.md
     S6's "put the offset in the keyframe's from" trap). The like control's
     own pop/ripple/bump rules lived here too until love-dock-on-tools
     replaced .tool-like with the shared .love-dock partial - that
     component's own reduced-motion off-switches (.love-btn, .love-heart,
     .love-p, .love-ripple, earlier in this same block) already cover it,
     sitewide, with nothing tool-specific to add. */
  .tool-rating-star{transition:none !important}
  /* motion audit (AI/07-UI-STANDARDS.md S6): .btn's hover lift (and by
     extension .btn-primary/.btn-ghost, which share .btn's own transition
     rather than declaring their own) had no off-switch at all, nor did
     .btn:active's press-scale, .btn-hire and .foot-cta (separate CTA
     pills, each with their own transition declaration), the nav-links
     underline grow, or .note's "go" arrow slide - all named in AI/03's
     motion vocabulary. Same pattern as .icon-btn/.tool-card/.post-card
     above: kill the transition only, so the hover/active transform still
     applies (real feedback a mouse user still needs) but stops being an
     animated tween. This avoids the resting-transform-vs-reduced-motion
     specificity race in 07 S6 entirely, since nothing here fights a
     transform:none override. */
  .btn,.btn-hire,.foot-cta,.nav-links a::after,.note .go{transition:none !important}
}

/* ------- PRINT ------- */
@media print{
  /* .consent-notice-wrap is added here, not scoped to any one tool: it is
     position:fixed;inset:0 (plan 10), so a browser repeats it on EVERY
     paginated print page, sitewide - a first-time visitor exporting a PDF
     before accepting/declining the cookie notice would otherwise get it
     baked onto every page of the download, forever. Found while producing a
     real PDF of the resume-builder fix below (not specific to that tool -
     any page printed with the notice still open would show it), and fixed
     at the same sitewide rule that already hides other fixed/viewport chrome
     (.top) rather than in the resume-scoped block further down. */
  /* .tool-feedback-bar/.tool-comments: found the same way, while proving the
     .rb-edu break-inside fix below with a real page.pdf() - a "short, fits
     on one page" resume fixture still exported at 2 pages, because every
     tool page (tools/show.blade.php) renders the like-count bar and the
     comments section as normal document flow AFTER .tool-shell, and neither
     was ever added to a print hide-list (only .rb-page-scroll's own DOM
     siblings are hidden by the resume-scoped rule further down, and these
     two sit outside that subtree). Not resume-builder-specific either - any
     tool page printed as-is would carry its like bar and comment thread into
     the PDF/paper output, which is chrome, not content worth printing. */
  /* .love-dock (love-dock-on-tools, ad hoc - see personas/QA/handoffs/
     love-dock-on-tools-frontend-ui.md): on a tool page this is already
     inside .tool-feedback-bar above, so this line is redundant-but-correct
     there. On a blog post it is NOT inside anything already hidden - it
     sits directly in .article, position:fixed at desktop widths, so a
     browser would otherwise print the floating heart/comment/share/contact
     rail on top of every page of a printed article, sitewide, the same
     "position:fixed chrome baked into every printed page" failure shape
     .consent-notice-wrap above exists to prevent. Verified with a real
     page.pdf() on both a blog post and a tool page (see handoff). */
  /* .confirm-backdrop/.toast-region/.tools-sheet/.love-heat: found while
     chasing a spurious blank-trailing-PDF-page report (resume-print-css
     handoff) - .confirm-backdrop is `display:flex` at rest (only opacity/
     visibility toggle when closed, never display:none - needed on screen so
     its opacity transition has something to animate), and `position:fixed;
     inset:0` forces it to size against the page box regardless of paint
     visibility. CORRECTION, same investigation, later and more carefully:
     this rule alone does NOT fix that blank-page defect - re-tested after
     shipping it and the spurious page still reproduced (see the handoff's
     root-cause section; it turned out to be a page.pdf()-without-a-settle
     measurement artifact, not a real export defect). Keeping this rule
     anyway because it is independently correct on its own terms: rule 14
     ("nothing in the print flow may be position:absolute or fixed") and the
     existing sitewide precedent above (.consent-notice-wrap et al, "a
     browser repeats position:fixed chrome on every printed page") apply to
     these four exactly the same way, and they are sitewide shared UX
     primitives (plan 03, frontend-ui's own: confirm-before-destructive-
     action, toasts, the mobile tools nav sheet, the heart "warms the wall"
     glow) that exist in the DOM at rest on every page, not resume-builder-
     specific. Do not re-attribute the blank-page fix to this rule again -
     see the handoff for what the real cause turned out to be (or not to be
     - it was not conclusively pinned to a single fixable rule). */
  .wind,.fall,.wall-light,.top,.theme-btn,.sound-btn,.skip,.rev-ui,.consent-notice-wrap,
  .tool-feedback-bar,.tool-comments,.love-dock,
  .confirm-backdrop,.toast-region,.tools-sheet,.love-heat{display:none!important}
  body{background:#fff;color:#000}
  .js .rv,.js .hero-intro,.js .hero h1,.js .hero-sub,.js .hero-cta,.js .hero-tools-note,.js .tree-scene{opacity:1;transform:none;animation:none;transition:none}
  /* AI/07-UI-STANDARDS.md §1 (slice 2): section's padding-top NOT migrated
     here either - same unresolved --rhythm-section conflict noted at the
     desktop rule (see the slice-2 handoff §5). */
  section{padding-top:var(--s-32)}
  .hero{min-height:0;padding:var(--s-24) 0}
}

/* =====================================================================
   plan 03: tools platform - navbar mega-box, mobile sheet, hub, the
   single-tool page shell, and the shared UX feedback primitives (toast,
   alert, inline validation, progress, skeleton, empty state, confirm).
   Tokens only, both themes, transform/opacity motion with reduced-motion
   off-switches added to the existing reduced-motion block below.
   ===================================================================== */

/* ---- nav: "Tools" trigger (desktop) + icon trigger (mobile) ---- */
.tools-nav-trigger{
  display:inline-flex;align-items:center;gap:var(--s-6);
  background:none;border:none;cursor:pointer;font-family:inherit;
  color:var(--ink);opacity:.75;font-size:var(--text-sm);font-weight:700;
  /* px-and-breakpoints: 9px sat exactly between --s-8/--s-10; rounded up
     (+1px, vertical-only - min-height:44px already floors the real height). */
  padding:var(--s-10) var(--s-2);min-height:44px;
  transition:opacity var(--dur-1) var(--ease),color var(--dur-1) var(--ease);
}
.tools-nav-trigger:hover,.tools-nav-trigger[aria-expanded="true"]{opacity:1;color:var(--ink)}
.tools-nav-trigger svg{width:11px;height:8px;transition:transform var(--dur-2) var(--ease)}
.tools-nav-trigger[aria-expanded="true"] svg{transform:rotate(180deg)}

.icon-btn{
  width:44px;height:44px;border-radius:var(--r-pill);border:none;
  background:var(--tile);color:var(--ink);cursor:pointer;
  display:inline-flex;align-items:center;justify-content:center;
  box-shadow:inset 0 0 0 1.5px var(--line);
  transition:transform var(--dur-1) var(--ease),box-shadow var(--dur-1) var(--ease);
}
.icon-btn svg{width:18px;height:18px}
.icon-btn:hover{transform:translateY(-1px);box-shadow:inset 0 0 0 1.5px var(--ink)}
.tools-mobile-trigger{display:none}

/* ---- shared search field + category chips (mega-box, sheet, hub) ---- */
.tools-search{
  display:flex;align-items:center;gap:var(--s-10);background:var(--tile);
  /* px-and-breakpoints: 11px sat exactly between --s-10/--s-12; rounded up
     (+1px). Shared by the mega-box, sheet and hub - screenshot-verified
     unchanged at all four canonical widths, RTL and LTR. */
  border-radius:var(--r-pill);padding:var(--s-12) var(--s-16);margin-bottom:var(--s-20);
  box-shadow:inset 0 0 0 1.5px var(--line);
  transition:box-shadow var(--dur-1) var(--ease);
}
.tools-search:focus-within{box-shadow:inset 0 0 0 1.5px var(--accent)}
.tools-search svg{width:17px;height:17px;color:var(--muted);flex:none}
.tools-search input{
  border:none;background:none;font-family:inherit;font-size:var(--text-sm);
  color:var(--ink);width:100%;outline:none;
}
.tools-cats{display:flex;gap:var(--s-8);flex-wrap:wrap;margin-bottom:var(--s-20)}
.tools-cats button{
  font-family:inherit;border:none;cursor:pointer;border-radius:var(--r-pill);
  padding:var(--s-6) var(--s-14);min-height:44px;display:inline-flex;align-items:center;
  font-size:var(--text-xs);font-weight:700;background:var(--tile);color:var(--muted);
  box-shadow:inset 0 0 0 1.5px var(--line);
  transition:background var(--dur-1) var(--ease),color var(--dur-1) var(--ease),box-shadow var(--dur-1) var(--ease);
}
.tools-cats button.is-active{background:var(--accent);color:var(--accent-ink);box-shadow:none}
.tools-cats button:focus-visible,.tools-search input:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:var(--r-focus)}

/* ---- tool card: shared by the mega-box, the sheet, the hub and "related" ---- */
.tool-card{
  display:flex;gap:var(--s-12);align-items:flex-start;padding:var(--s-14);border-radius:var(--r-tile);
  text-decoration:none;color:var(--ink);box-shadow:inset 0 0 0 1.5px var(--line);
  transition:transform var(--dur-2) var(--ease),box-shadow var(--dur-2) var(--ease);
}
.tool-card:hover{transform:translateY(-3px);box-shadow:inset 0 0 0 1.5px var(--ink)}
.tool-card:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.tool-card-ic{
  width:40px;height:40px;border-radius:var(--r-tile);background:var(--tile);color:var(--accent);
  display:flex;align-items:center;justify-content:center;flex:none;
}
.tool-card-ic svg{width:22px;height:22px}
.tool-card strong{display:block;font-size:var(--text-sm);font-weight:700;margin-bottom:var(--s-2)}
.tool-card p{font-size:var(--text-xs);color:var(--muted);line-height:1.5;margin:0}

/* ---- desktop mega-box: full-width panel under the nav ---- */
.tools-megabox{
  position:absolute;inset-inline:0;top:100%;z-index:var(--z-nav);background:var(--wall);
  border-bottom:1px solid var(--line);
  box-shadow:0 24px 48px -20px color-mix(in srgb, var(--shadow-leaf) 45%, transparent);
  opacity:0;visibility:hidden;transform:translateY(-8px);
  transition:opacity var(--dur-2) var(--ease),transform var(--dur-2) var(--ease),visibility 0s linear var(--dur-2);
}
.tools-megabox.is-open{
  opacity:1;visibility:visible;transform:none;
  transition:opacity var(--dur-2) var(--ease),transform var(--dur-2) var(--ease),visibility 0s;
}
/* px-and-breakpoints: 26px sat exactly between --s-24/--s-28; 30px sat
   exactly between --s-28/--s-32. Both rounded to the larger rung
   (+2px each, a desktop dropdown panel with no width constraint). */
.tools-megabox-in{max-width:var(--container);margin:0 auto;padding:var(--s-28) var(--gutter) var(--s-32)}
.tools-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:var(--s-12)}
.tools-megabox-empty{grid-column:1/-1;text-align:center;padding:var(--s-32) var(--s-20);color:var(--muted)}
.tools-megabox-empty svg{width:32px;height:32px;color:var(--muted);margin-bottom:var(--s-10)}
.tools-megabox-empty strong{display:block;color:var(--ink);font-weight:700;margin-bottom:var(--s-6);font-size:var(--text-md)}
.tools-megabox-foot{margin-top:var(--s-20);text-align:center}
.tools-megabox-foot a{color:var(--accent);font-weight:700;font-size:var(--text-sm);text-decoration:none}
.tools-megabox-foot a:hover{text-decoration:underline}

/* ---- mobile: full-screen sheet, off-canvas + not keyboard-reachable while closed ---- */
.tools-sheet{
  position:fixed;inset:0;z-index:30;background:var(--wall);
  display:flex;flex-direction:column;
  transform:translateY(100%);visibility:hidden;
  transition:transform var(--dur-3) var(--ease),visibility 0s linear var(--dur-3);
}
.tools-sheet.is-open{
  transform:none;visibility:visible;
  transition:transform var(--dur-3) var(--ease),visibility 0s;
}
.tools-sheet-head{
  display:flex;align-items:center;justify-content:space-between;padding:var(--s-16) var(--gutter);
  border-bottom:1px solid var(--line);flex:none;
}
.tools-sheet-head strong{font-size:var(--text-lg);font-weight:900}
.tools-sheet-body{overflow-y:auto;padding:var(--s-16) var(--gutter) var(--s-40);flex:1}
.tools-sheet .tools-grid{grid-template-columns:1fr}

/* =====================================================================
   the tools hub (/tools)
   ===================================================================== */
.tools-hub-hero{text-align:center;padding-block:var(--s-8) var(--s-32)}
.tools-hub-hero h1{font-size:var(--display-2);font-weight:900;letter-spacing:-.02em;margin-bottom:var(--s-10)}
.tools-hub-hero p{color:var(--muted);max-width:56ch;margin:0 auto var(--s-24);font-size:var(--text-base)}
.tools-hub .tools-search{max-width:480px;margin-inline:auto;margin-bottom:var(--s-20)}
/* px-and-breakpoints: 36px sat exactly between --s-32/--s-40; rounded up
   (+4px, vertical-only). */
.tools-hub .tools-cats{justify-content:center;margin-bottom:var(--s-40)}
.tools-hub-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--s-16)}
.hub-card{
  /* AI/07-UI-STANDARDS.md SS1: 22 isn't a rung on the --s-* ladder; this is
     the same documented tile-padding migration as .tool-shell's mobile
     rule below (26/22 -> --s-24, a 2px move nobody can see). */
  position:relative;padding:var(--s-24);border-radius:var(--r-tile);background:var(--tile);
  text-decoration:none;color:var(--ink);display:block;
  transition:transform var(--dur-2) var(--ease);
}
.hub-card:hover{transform:translateY(-4px)}
.hub-card:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
.hub-card .tool-card-ic{width:44px;height:44px;background:var(--wall);margin-bottom:var(--s-14)}
.hub-card .tool-card-ic svg{width:24px;height:24px}
.hub-card strong{display:block;font-weight:700;font-size:var(--text-md);margin-bottom:var(--s-4)}
.hub-card p{font-size:var(--text-sm);color:var(--muted);line-height:1.55;margin:0 0 var(--s-14)}
.hub-card-meta{display:flex;align-items:center;gap:var(--s-8);font-size:var(--text-xs);color:var(--muted)}
.hub-card-meta .badge-instant{color:var(--accent);font-weight:700}
.tools-hub-empty{
  grid-column:1/-1;text-align:center;padding:var(--s-48) var(--s-20);border-radius:var(--r-tile);
  background:var(--tile);color:var(--muted);
}
.tools-hub-empty svg{width:40px;height:40px;color:var(--muted);margin-bottom:var(--s-14)}
.tools-hub-empty strong{display:block;color:var(--ink);font-weight:700;margin-bottom:var(--s-6);font-size:var(--text-md)}

/* =====================================================================
   single-tool page shell
   ===================================================================== */
.tool-crumb{font-size:var(--text-xs);color:var(--muted);margin-bottom:var(--s-10)}
.tool-crumb a{color:var(--muted);text-decoration:none;position:relative;display:inline-block}
/* design-tools-sizing-responsive-audit.md MAJOR: the old `inset:-12px` only
   extends the hit-slop on 3 of 4 sides in practice - the space above is
   legitimately owned by the sticky header, so the upward -12px never
   paints there. Net effective target measured ~63x33px - width cleared
   the 44px floor, height didn't. Stop claiming the unreachable top slop
   (inset-block-start:0, honest) and make up the full 44px floor on the
   bottom instead, since that side has nothing else to share it with. */
.tool-crumb a::after{
  content:"";position:absolute;
  inset-inline:calc(-1 * var(--s-12));
  inset-block-start:0;
  inset-block-end:calc(-1 * var(--s-24));
}
.tool-head{margin-bottom:var(--s-28)}
.tool-head h1{font-size:var(--display-2);font-weight:900;letter-spacing:-.02em;margin-bottom:var(--s-8)}
.tool-head p{color:var(--muted);font-size:var(--text-base);max-width:60ch}

.tool-shell{background:var(--tile);border-radius:var(--r-tile);padding:var(--s-32);margin-bottom:var(--s-48)}
.dropzone{
  border:2px dashed var(--line);border-radius:var(--r-tile);padding:var(--s-48) var(--s-24);text-align:center;
  background:var(--wall);cursor:pointer;position:relative;
  transition:box-shadow var(--dur-2) var(--ease);
}
.dropzone:hover,.dropzone:focus-within{box-shadow:inset 0 0 0 2px var(--accent)}
/* ===== ui-fixes-batch item 2: .is-dragover had no CSS rule =====
   engine-runtime.js toggles .is-dragover on 4 dragenter/dragover/dragleave/
   drop listeners (public/js/tools/_shared/engine-runtime.js ~878) but
   site.css never had a rule for it, so dragging a file over any dropzone
   gave zero visual feedback. :focus-within's inset ring (above) is the
   obvious model and this reuses its exact box-shadow so a dragging file
   and a keyboard-focused dropzone read as the same "this is the active
   target" state. Deliberately different from focus by one thing: a warm
   accent-tinted fill, not just the ring. A drag is a stronger, more
   imminent signal than a tab-stop (the file is already over the target,
   about to drop) and a fill reads as "ready to receive" the way an empty
   .dropzone's own icon+copy invite a click - a ring alone would say "this
   is focused" without saying "drop it here". Tint via color-mix, not a new
   token - still one accent, both themes. */
.dropzone.is-dragover{
  box-shadow:inset 0 0 0 2px var(--accent);
  background:color-mix(in srgb, var(--accent) 8%, var(--wall));
}
.dropzone svg{width:40px;height:40px;color:var(--accent);margin-bottom:var(--s-14)}
.dropzone strong{display:block;font-weight:700;margin-bottom:var(--s-4);font-size:var(--text-md)}
.dropzone p{color:var(--muted);font-size:var(--text-sm);margin:0}
.dropzone input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;outline:none}
.dropzone input[type=file]:focus-visible{outline:2px solid var(--accent);outline-offset:2px}

.tool-options{display:flex;align-items:center;justify-content:space-between;gap:var(--s-20);flex-wrap:wrap;margin-top:var(--s-20)}
.tool-quality{display:flex;flex-direction:column;gap:var(--s-8);flex:1 1 240px;font-size:var(--text-sm);color:var(--muted)}
.tool-quality input[type=range]{accent-color:var(--accent);width:100%}

.tool-loading{padding-block:var(--s-24)}
.tool-loading-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--s-10)}
.tool-loading-row strong{font-size:var(--text-base);font-weight:700}
.tool-loading-row span{font-size:var(--text-sm);color:var(--muted)}
.tool-loading-cancel{margin-top:var(--s-16);text-align:end}

.tool-success{display:flex;gap:var(--s-24);align-items:center;flex-wrap:wrap}
.tool-success-preview{
  flex:1 1 260px;display:flex;gap:var(--s-12);align-items:center;justify-content:center;
  background:var(--wall);border-radius:var(--r-tile);padding:var(--s-20);min-height:140px;
}
.tool-success-preview .before,.tool-success-preview .after{flex:1;text-align:center;font-size:var(--text-xs);color:var(--muted)}
.tool-success-preview .swatch{height:80px;border-radius:var(--r-tile);margin-bottom:var(--s-8);background:var(--tile)}
/* ---- shared before/after preview image (plan 06 fix: image-convert.js's
   own comment already documents the exact contract it's been waiting on -
   width/height:100%, object-fit:cover, border-radius:inherit, display:block.
   image-compress/background-remover currently duplicate this inline
   instead - not my lane to edit those files, but the class both engines
   could adopt now exists.) ---- */
.tool-preview-img{width:100%;height:100%;object-fit:cover;border-radius:inherit;display:block}
/* [hidden] cascade guard - same bug family as .tool-segmented above.
   image-to-base64.js sets `img.hidden = true` in its onerror handler (a
   format the browser can't preview as an <img>, falling back to a text
   notice) - without this, the broken-image icon would render at full size
   right next to that notice, because `display:block` here beats the UA's
   `[hidden]{display:none}` at equal specificity. Every other consumer of
   this class (image-compress/convert/resize/crop/watermark, the PDF/video
   frame grids) never toggles `.hidden` on it, so this was a live but
   easy-to-miss gap - found by a sweep, not by a report. */
.tool-preview-img[hidden]{display:none}
.tool-success-info{flex:1 1 260px}
.tool-success-info strong{display:block;font-weight:700;font-size:var(--text-md);color:var(--accent);margin-bottom:var(--s-6)}
/* design-phase-4-r1 F-A: engine-runtime.js programmatically focuses this
   <strong tabindex="-1"> so screen readers announce "Done." - with no rule
   targeting its focus state anywhere, the browser painted its own native
   fallback ring (measured: rgb(229,151,0) outline-style:auto - orange, a
   second accent by omission, the exact shape AI/06-LESSONS.md §5 already
   catalogues). Both :focus and :focus-visible are covered because a
   scripted .focus() on a non-natively-focusable element doesn't reliably
   trigger the :focus-visible heuristic the same way across browsers. */
.tool-success-info strong:focus,.tool-success-info strong:focus-visible{
  outline:2px solid var(--accent);outline-offset:3px;border-radius:var(--r-focus);
}
.tool-success-info p{color:var(--muted);font-size:var(--text-sm);margin-bottom:var(--s-16)}
.tool-actions{display:flex;gap:var(--s-10);flex-wrap:wrap}
.tool-actions .btn svg{width:16px;height:16px}

.tool-error{
  display:flex;gap:var(--s-14);align-items:flex-start;
  background:color-mix(in srgb, var(--ink) 6%, var(--wall));
  /* px-and-breakpoints: 22px sat exactly between --s-20/--s-24; rounded up
     (+2px, symmetric padding on a full-width error panel). */
  border-radius:var(--r-tile);padding:var(--s-24);
}
.tool-error svg{width:26px;height:26px;color:var(--ink);flex:none;margin-top:var(--s-2)}
.tool-error strong{display:block;font-weight:700;margin-bottom:var(--s-4);font-size:var(--text-md)}
.tool-error p{color:var(--muted);font-size:var(--text-sm);margin-bottom:var(--s-14)}

.tool-howto{margin-bottom:var(--s-48)}
.tool-howto ol{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--s-16);list-style:none;padding:0;counter-reset:step}
.tool-howto li{background:var(--tile);border-radius:var(--r-tile);padding:var(--s-20);counter-increment:step}
.tool-howto li::before{
  content:counter(step);display:flex;align-items:center;justify-content:center;
  width:28px;height:28px;border-radius:var(--r-pill);background:var(--accent);color:var(--accent-ink);
  font-weight:700;font-size:var(--text-sm);margin-bottom:var(--s-10);
}
.tool-howto li p{margin:0;font-size:var(--text-sm);color:var(--muted)}

.tool-faq{margin-bottom:var(--s-48)}
.faq-item{border-bottom:1px solid var(--line)}
.faq-q{
  width:100%;text-align:start;background:none;border:none;cursor:pointer;font-family:inherit;
  min-height:44px;padding:var(--s-16) var(--s-4);display:flex;justify-content:space-between;align-items:center;gap:var(--s-12);
  font-weight:700;font-size:var(--text-base);color:var(--ink);
}
.faq-q svg{width:14px;height:14px;flex:none;transition:transform var(--dur-2) var(--ease)}
.faq-item.is-open .faq-q svg{transform:rotate(180deg)}
.faq-a{max-height:0;overflow:hidden;transition:max-height var(--dur-2) var(--ease);color:var(--muted);font-size:var(--text-sm);line-height:1.6}
.faq-item.is-open .faq-a{max-height:320px;padding-bottom:var(--s-16)}

.tool-related{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--s-14)}

/* =====================================================================
   shared UX feedback primitives (plan 03 owns these; every tool/page reuses)
   ===================================================================== */

/* toast */
.toast-region{position:fixed;bottom:var(--s-20);inset-inline-end:var(--s-20);z-index:50;display:flex;flex-direction:column;gap:var(--s-10);max-width:320px}
.toast{
  display:flex;gap:var(--s-10);align-items:flex-start;background:var(--ink);color:var(--wall);
  border-radius:var(--r-tile);padding:var(--s-14) var(--s-16);
  box-shadow:0 12px 32px -12px color-mix(in srgb, var(--shadow-leaf) 50%, transparent);
  opacity:0;transform:translateY(8px);
  transition:opacity var(--dur-2) var(--ease),transform var(--dur-2) var(--ease);
}
.toast.is-shown{opacity:1;transform:none}
/* px-and-breakpoints: this and .alert/.rb-print-note svg's own 1px below
   are optical baseline nudges for an inline icon beside text, not spacing -
   there is no --s-1 rung and none is wanted; left as literals. */
.toast svg{width:18px;height:18px;flex:none;margin-top:1px}
.toast p{margin:0;font-size:var(--text-sm);line-height:1.5;flex:1}
.toast-close{background:none;border:none;color:inherit;opacity:.7;cursor:pointer;padding:0;flex:none;width:20px;height:20px}
.toast-close svg{width:14px;height:14px}
.toast-success svg{color:var(--accent)}

/* alert banner: severity rides on icon + copy, never a second accent hue */
.alert{
  /* px-and-breakpoints: 18px sat exactly between --s-16/--s-20; rounded up
     (+2px). */
  display:flex;gap:var(--s-12);align-items:flex-start;border-radius:var(--r-tile);padding:var(--s-16) var(--s-20);
  font-size:var(--text-sm);margin-bottom:var(--s-12);
}
.alert svg{width:20px;height:20px;flex:none;margin-top:1px}
.alert-info{background:color-mix(in srgb, var(--ink) 6%, var(--wall));color:var(--ink)}
.alert-info svg{color:var(--muted)}
.alert-success{background:color-mix(in srgb, var(--accent) 16%, var(--wall));color:var(--ink)}
.alert-success svg{color:var(--accent)}
.alert-warning{background:color-mix(in srgb, var(--sun) 90%, var(--accent) 10%);color:var(--ink)}
.alert-warning svg{color:color-mix(in srgb, var(--ink) 70%, var(--sun) 30%)}
.alert-error{
  background:color-mix(in srgb, var(--ink) 15%, var(--wall));color:var(--ink);
  box-shadow:inset 0 0 0 2px color-mix(in srgb, var(--ink) 45%, transparent);
}
.alert-error svg{color:var(--ink)}

/* inline validation */
.field{margin-bottom:var(--s-16)}
.field label{display:block;font-size:var(--text-sm);font-weight:700;margin-bottom:var(--s-6)}
.field input{
  /* px-and-breakpoints: 11px sat exactly between --s-10/--s-12; rounded up (+1px). */
  width:100%;font-family:inherit;font-size:var(--text-base);padding:var(--s-12) var(--s-14);border-radius:var(--r-pill);
  background:var(--wall);box-shadow:inset 0 0 0 1.5px var(--line);border:none;color:var(--ink);
}
.field input:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
.field.has-error input{box-shadow:inset 0 0 0 1.5px var(--ink)}
.field-error{display:flex;gap:var(--s-6);align-items:center;color:var(--ink);font-weight:700;font-size:var(--text-xs);margin-top:var(--s-6)}
.field-error svg{width:13px;height:13px;flex:none}

/* progress bar */
.progressbar{height:10px;border-radius:var(--r-pill);background:var(--wall);overflow:hidden;box-shadow:inset 0 0 0 1.5px var(--line)}
.progressbar > i{
  display:block;width:0;height:100%;background:var(--accent);border-radius:var(--r-pill);
  transform-origin:left center;transition:width var(--dur-2) var(--ease);
}

/* skeleton */
.skel-row{height:14px;border-radius:var(--r-tile);background:var(--wall);margin-bottom:var(--s-8);position:relative;overflow:hidden}
.skel-row::after{
  content:"";position:absolute;inset:0;
  background:linear-gradient(90deg,transparent,color-mix(in srgb, var(--tile) 60%, white 10%),transparent);
  transform:translateX(-100%);animation:toolsShimmer 1.6s var(--ease) infinite;
}
:root[data-theme="dark"] .skel-row::after{
  background:linear-gradient(90deg,transparent,color-mix(in srgb, var(--wall) 60%, black 10%),transparent);
}
@keyframes toolsShimmer{to{transform:translateX(100%)}}
/* AI/07-UI-STANDARDS.md SS1: same 22 -> --s-24 tile-padding migration as
   .hub-card above. */
.tool-skel{background:var(--wall);border-radius:var(--r-tile);padding:var(--s-24)}
.tool-skel .ti-skel{width:44px;height:44px;border-radius:var(--r-tile);background:var(--tile);margin-bottom:var(--s-14);position:relative;overflow:hidden}
.tool-skel .ti-skel::after{
  content:"";position:absolute;inset:0;
  background:linear-gradient(90deg,transparent,color-mix(in srgb, var(--wall) 60%, transparent),transparent);
  transform:translateX(-100%);animation:toolsShimmer 1.6s var(--ease) infinite;
}

/* empty state */
.empty-panel{text-align:center;padding:var(--s-32) var(--s-20);border-radius:var(--r-tile);background:var(--wall);box-shadow:inset 0 0 0 1.5px var(--line)}
.empty-panel svg{width:36px;height:36px;color:var(--muted);margin-bottom:var(--s-12)}
.empty-panel strong{display:block;font-weight:700;margin-bottom:var(--s-4)}
.empty-panel p{color:var(--muted);font-size:var(--text-sm);margin-bottom:var(--s-14)}

/* confirm dialog: no second "danger" hue exists (one accent, forever) - a
   destructive confirm carries its weight through copy + icon + the modal
   itself, styled exactly like the site's existing .btn-primary/.btn-ghost. */
.confirm-backdrop{
  position:fixed;inset:0;z-index:60;
  background:color-mix(in srgb, var(--shadow-leaf) 65%, transparent);
  display:flex;align-items:center;justify-content:center;padding:var(--s-20);
  opacity:0;visibility:hidden;
  transition:opacity var(--dur-2) var(--ease),visibility 0s linear var(--dur-2);
}
.confirm-backdrop.is-open{opacity:1;visibility:visible;transition:opacity var(--dur-2) var(--ease),visibility 0s}
.confirm-card{
  background:var(--wall);border-radius:var(--r-tile);padding:var(--s-28);max-width:360px;width:100%;
  transform:translateY(12px) scale(.98);transition:transform var(--dur-2) var(--ease);
}
.confirm-backdrop.is-open .confirm-card{transform:none}
.confirm-card h3{font-size:var(--text-md);font-weight:700;margin-bottom:var(--s-8)}
.confirm-card p{color:var(--muted);font-size:var(--text-sm);margin-bottom:var(--s-20)}
.confirm-actions{display:flex;gap:var(--s-10);justify-content:flex-end}

/* =====================================================================
   plan 03 responsive
   ===================================================================== */
@media (max-width:960px){
  .tools-nav-trigger{display:none}
  .tools-mobile-trigger{display:inline-flex}
  .tools-megabox{display:none} /* dropdown never shows once the sheet trigger takes over */
}
@media (max-width:820px){
  .tools-grid{grid-template-columns:repeat(2,1fr)}
  .tools-hub-grid{grid-template-columns:repeat(2,1fr)}
  .tool-howto ol,.tool-related{grid-template-columns:1fr}
  /* design-tools-sizing-responsive-audit.md MINOR: 22 isn't a rung on the
     --s-* ladder. AI/07-UI-STANDARDS.md §1 already names this exact value
     as the documented mobile tile-padding migration (26/22 -> --s-24) - a
     2px move nobody can see, in exchange for one fewer off-scale value. */
  .tool-shell{padding:var(--s-24)}
}
@media (max-width:640px){
  /* design-W1-image-tools-r1 F1/F2 root cause: .tool-options's base rule
     (line ~1806) carries `flex-wrap:wrap` for its row layout at wider
     widths, and this override never cancelled it when switching to
     `flex-direction:column` here. A column-direction flex container with
     `flex-wrap:wrap` and no explicit height cannot tell where one "column
     line" ends and the next begins, so instead of stacking every child
     in one column stretched to the container's width, the browser was
     free to size each wrapped line by its own content (shrink-to-fit) -
     `align-items:stretch`'s 283px never applied. Every direct child of
     `[data-tool-options]` (the batch-file-row list, the primary Run
     button, any `.tool-field`) grew to its own max-content width instead
     - measured live at 375: the run button and the resize batch list both
     rendered ~812px wide, clipped by `body{overflow-x:hidden}` rather
     than reflowed. `flex-wrap:nowrap` here restores the single-column
     stack this layout was always meant to be; row-mode wrapping at wider
     widths (line 1806) is untouched. */
  .tool-options{flex-direction:column;align-items:stretch;flex-wrap:nowrap}
  .tool-actions{width:100%}
  /* gate-fixes-css row 171: .btn (site.css ~576) has no white-space rule, so
     the default `normal` let a short label ("Copy all"/"نسخ الكل") wrap onto
     2-3 lines inside its `flex:1` equal-share width once that share (~half
     the tool-actions row, minus gap) undercut the label's natural width -
     the wrapped multi-line text then blew the button's height past --r-pill
     (999px) with a near-square box, so the radius rounded it into a blob
     (measured live: "Copy all" 113x86, "نسخ الكل" 119x115). German text
     never showed the bug because it was already long enough to exceed
     min-content for its half-share, which flex-wrap correctly resolved by
     wrapping the whole *button* to its own full-width row (247x57) instead
     of wrapping the *text* inside a squeezed one - `nowrap` makes every
     locale take that same already-correct path: a flex item's automatic
     minimum width is its min-content when overflow is visible (no
     min-width:0 here), so a nowrap label that can't fit its equal share is
     never shrunk below its own single-line width - it wraps to its own row
     and grows to fill it, exactly like German already did. One rule, not
     four per-tool patches - verified on uuid-generator, instagram-grid-
     splitter, social-media-image-resizer and photo-collage-maker (see
     handoff for full before/after numbers). */
  .tool-actions .btn{justify-content:center;flex:1;white-space:nowrap}
}
@media (max-width:520px){
  .tools-hub-grid{grid-template-columns:1fr}
  .tools-hub-hero h1{font-size:var(--display-3)}
  .toast-region{inset-inline:var(--s-12);bottom:var(--s-12);max-width:none}
}

/* =====================================================================
   plan 03: RTL - logical properties handle most of this automatically;
   only mirrored icons/arrows need an explicit flip.
   ===================================================================== */
[dir="rtl"] .tools-nav-trigger svg{transform:scaleX(-1)}
[dir="rtl"] .tools-nav-trigger[aria-expanded="true"] svg{transform:scaleX(-1) rotate(180deg)}
[dir="rtl"] .faq-q svg{transform:scaleX(-1)}
[dir="rtl"] .faq-item.is-open .faq-q svg{transform:scaleX(-1) rotate(180deg)}
[dir="rtl"] .dropzone svg,[dir="rtl"] .tool-actions svg{transform:scaleX(-1)}

/* =====================================================================
   plan 03: tool option controls - the shared, styled control set every
   tool engine renders into [data-tool-options] instead of inline styles.
   Range slider, checkbox/toggle, select, segmented group, colour swatch.
   Tokens only, both themes, transform/opacity motion with reduced-motion
   off-switches added above. Markup contract: see the frontend-ui handoff
   for plan "tool option controls".
   ===================================================================== */

/* ---- generic labelled field wrapper: one option = one .tool-field ---- */
/* diff-checker word mode puts a <del> and an <ins> directly next to each
   other with no node between them - the whitespace tokens all live in the
   surrounding `equal` runs, so a replaced word has none of its own. In Latin
   text that reads as two touching words with different decorations. In
   Arabic it does not read as two words at all: the design gate found
   "يقفز" + "يعدو" rendering as "يقفزيعدو", a single plausible-looking word
   that means nothing, and measured the two boxes sharing an exact edge at
   x=653.0. A gap is the whole fix, and it belongs here rather than in the
   engine because injecting a space into the DOM would put it in what the
   visitor selects and copies, and the copy output already has its own
   correct {+…+}/[-…-] form. */
.tool-diff-del + .tool-diff-ins,
.tool-diff-ins + .tool-diff-del{margin-inline-start:.3em}
/* regex-tester highlights a match inside the visitor's own test text. When
   that text is Arabic and the match contains a separator between two
   number groups - a phone number, a date - Unicode bidi splits it: with a
   preceding Arabic letter the digits resolve to Arabic-number runs, the
   hyphen between them resolves to the paragraph direction, and the two
   groups lay out right to left. `555-1234` then reads `1234-555` to anyone
   scanning left to right.

   That is NOT a defect in this engine, and the design gate filing it as a
   blocker was one instrument short: a bare `<div dir="rtl">` containing the
   same sentence, with no site CSS and no <mark>, reproduces it exactly
   (555 at x=84.9, 1234 at x=41.5). It is what the algorithm specifies.

   It is still wrong *for this tool*, which exists to show exactly what
   matched. Isolating the mark makes the match its own directional run, so
   it renders as the string it is. Measured: 555 moves from x=696.4 to
   x=652.8, left of 1234, in the same Arabic sentence. The DOM text was
   always correct, so copying was never affected - only reading was.

   The Matches list below the highlight still has this, because there the
   match is interpolated into a translated sentence rather than wrapped in
   its own element. Fixing it means a <bdi> in the engine and a key that
   stops carrying `:text`. */
mark.tool-regex-match{unicode-bidi:isolate}
.tool-field{display:flex;flex-direction:column;gap:var(--s-8);flex:1 1 240px;min-width:0}
/* [hidden] cascade guard - same bug and same fix shape as .tool-video (F1,
   personas/QA/reports/phase-4-code-r1.md): an author rule always beats the
   UA's [hidden]{display:none}, even at equal specificity, because origin
   order (author > user-agent) outranks specificity. This class sets its own
   display AND is toggled via `element.hidden` by four shipped engines
   (css-gradient-generator's angleWrap/posWrap, color-palette-generator's
   ruleWrap, image-convert's qualityWrap/matteWrap, video-to-frames's
   qualityField) - without this rule, a "hidden" conditional field stayed
   visibly flexed on every one of them. See the frontend-ui handoff's r3 for
   the full per-selector map of what was checked. */
.tool-field[hidden]{display:none}
/* ===== board row 267: .tool-field-panel-flex - width use for classed
   RT.buildFieldPanel() groups =====
   engine-corrections-tools-engineer.md (board rows 281/267): clip-path.js's
   circle/ellipse/inset field groups are each a `RT.buildFieldPanel(runBtn)`
   call - a real, single hideable container, per that helper's own file-header
   contract in engine-runtime.js. Left classless (the helper's deliberate
   default - a classless container never fights [hidden] and needs no cascade
   guard), each group renders as a bare block <div>, so its .tool-field
   children - which already carry flex:1 1 240px, sized for a flex-wrap ROW
   context, the same as cubic-bezier.js's fields get for free by landing
   directly in [data-tool-options] - stack in a single narrow column instead
   of packing side by side. The design gate measured this directly on the
   live inset-mode panel at 1128px: 129px used of 968px available.
   The fix is the same shape as .tool-gradient-presets below (also a bare
   RT.buildFieldPanel()-adjacent container inserted as a direct
   [data-tool-options] child): flex:1 1 100% claims the full row so the panel
   is never squeezed to its own content width by [data-tool-options]'s own
   flex-wrap:wrap, then display:flex;flex-wrap:wrap lets the .tool-field
   children pack multi-up using the width that frees up. No separate mobile
   rule is needed - .tool-field{flex-basis:100%} at max-width:640px (below)
   already applies to every .tool-field regardless of parent, so these panels
   collapse to the same single-column stack at mobile that cubic-bezier's own
   fields already use, unchanged.
   The [hidden] cascade guard is not optional once a className sets its own
   display: an author rule always beats the UA's [hidden]{display:none} at
   equal specificity (origin order outranks specificity) - buildFieldPanel's
   own doc comment names this exact failure shape, and it already shipped
   once on .tool-gradient-presets in this same file before being caught.
   setType() toggles `panel.hidden` on all three groups (only the active
   shape's panel should show); without this guard every group would stay
   visibly flexed regardless of `.hidden`. */
.tool-field-panel-flex{flex:1 1 100%;display:flex;flex-wrap:wrap;gap:var(--s-20)}
.tool-field-panel-flex[hidden]{display:none}
.tool-field-label{
  display:flex;align-items:center;justify-content:space-between;gap:var(--s-8);
  font-size:var(--text-sm);font-weight:700;color:var(--ink);
}
/* row 263 (design-css-tools-gate.md finding #2): a labelled number field
   (`buildLabeledNumberField` - label is a CHILD span, wrap is one
   `label.tool-field` flex item, h~75 with a two-line label+input stack)
   sharing a wrapped `.tool-options` row with a range field
   (`buildLabeledRange` - its `<p class="tool-field-label">` label is a
   PRECEDING SIBLING of its own `<div class="tool-field">`, i.e. two short
   flex items, not one tall one) hits `.tool-options{align-items:center}`
   (line ~2541): the row centers on its tallest item, so the range's own
   label+input pair floats mid-row instead of sitting flush with the
   number field's own top-aligned label - a ~16px baseline mismatch,
   confirmed on cubic-bezier (Y2 vs. duration, 900/1128/1440) and
   grid-playground (900). Both symptoms trace to the one buildLabeledRange
   DOM shape, so one shared fix: `:has()` with a sibling combinator picks
   out exactly a standalone label immediately followed by its own range
   wrapper (never `label.tool-field`'s own inline `span.tool-field-label`,
   which isn't a `.tool-options` sibling of anything to match against) and
   top-aligns both halves of the pair, matching every other `.tool-field`
   row's own top-flush layout. No-op wherever the pair is already the
   tallest/only item in its row (align-self:flex-start == align-items:center
   there) - confirmed unchanged at every width these two fields render
   alone. */
.tool-field-label:has(+ .tool-field > input[type=range]),
.tool-field:has(> input[type=range]){
  align-self:flex-start;
}
/* same guard, same reason: jpg-to-pdf.js/merge-pdf.js's `hintEl` carries only
   this class (not `.tool-field`) and is toggled via `.hidden` directly. */
.tool-field-label[hidden]{display:none}
/* NOTE: no `.tool-field-value` rule here on purpose - see the frontend-ui
   handoff's "tool-field-value decision". Every shipped engine (image-compress,
   background-remover) renders one combined string into `.tool-field-label`
   ("Quality: 75%"), which content-seo ships as one translation key per
   locale (`quality_label` => "Quality: :pct%"). A split label/value span was
   the original contract's shape but has never had a real consumer, so this
   rule described a shape nothing built. The contract below now matches what
   ships. */

/* ---- text/number input: same recipe as .field input, reused under .tool-field ---- */
.tool-input{
  /* px-and-breakpoints: 11px sat exactly between --s-10/--s-12; rounded up
     (+1px, vertical-only - min-height:44px already floors the real height). */
  width:100%;font-family:inherit;font-size:var(--text-sm);padding:var(--s-12) var(--s-14);min-height:44px;
  border-radius:var(--r-pill);background:var(--wall);box-shadow:inset 0 0 0 1.5px var(--line);
  border:none;color:var(--ink);
}
.tool-input:focus-visible{outline:2px solid var(--accent);outline-offset:1px}

/* ---- multi-line free text: .tool-textarea, a peer of .tool-input for the
   wave-4 text tools (word-counter, case-converter, slug-generator, json-
   formatter, base64-encode-decode, url-encode-decode), every one of which
   needs a large `<textarea>` as its primary input. Same visual recipe as
   .tool-input (wall fill, inset hairline, accent focus ring) but
   --r-tile instead of --r-pill - a multi-line box reads wrong as a pill.
   This is the same call the resume builder already made locally
   (`.rb-fixed-fields textarea.tool-input` below, left as-is), promoted
   here to a shared class any tool can reach for directly instead of
   re-deriving the same four properties per tool.
   Deliberately sets no text-align/direction: these tools mix dir="auto"
   (freeform prose - specs 21/22/23 and the Encode side of 25/26) with a
   forced dir="ltr" (a code-like value - the Decode side of 25/26) per
   each spec's own i18n section, and the browser's own direction-aware
   default for text-align:start already does the right thing for both
   without this rule picking a side. ---- */
.tool-textarea{
  display:block;width:100%;font-family:inherit;font-size:var(--text-sm);
  line-height:1.6;color:var(--ink);padding:var(--s-14) var(--s-16);
  min-height:var(--s-128);border-radius:var(--r-tile);background:var(--wall);
  box-shadow:inset 0 0 0 1.5px var(--line);border:none;resize:vertical;
}
.tool-textarea::placeholder{color:var(--muted);opacity:.8}
.tool-textarea:focus-visible{outline:2px solid var(--accent);outline-offset:1px}

/* ---- range slider: 44px-tall hit area around an 8px track + 22px thumb ---- */
.tool-range{
  -webkit-appearance:none;appearance:none;width:100%;height:44px;background:none;
  margin:0;padding:0;cursor:pointer;
}
.tool-range::-webkit-slider-runnable-track{
  height:8px;border-radius:var(--r-pill);background:var(--wall);
  box-shadow:inset 0 0 0 1.5px var(--line);
}
.tool-range::-moz-range-track{
  height:8px;border-radius:var(--r-pill);background:var(--wall);
  box-shadow:inset 0 0 0 1.5px var(--line);
}
.tool-range::-webkit-slider-thumb{
  /* px-and-breakpoints: -7px is not a spacing choice - it is exactly
     -(22px thumb - 8px track)/2, the geometric offset that centers the
     thumb on the track. Rounding it to the nearest --s-* rung (-8px) would
     visibly mis-center every range slider on the site by 1px. Left as a
     literal on purpose, same category as the .sr-only -1px above. */
  -webkit-appearance:none;width:22px;height:22px;margin-top:-7px;border-radius:var(--r-pill);
  background:var(--accent);box-shadow:0 0 0 4px var(--tile);
  transition:transform var(--dur-1) var(--ease);
}
.tool-range::-moz-range-thumb{
  width:22px;height:22px;border:none;border-radius:var(--r-pill);
  background:var(--accent);box-shadow:0 0 0 4px var(--tile);
  transition:transform var(--dur-1) var(--ease);
}
.tool-range:hover::-webkit-slider-thumb,.tool-range:active::-webkit-slider-thumb{transform:scale(1.1)}
.tool-range:hover::-moz-range-thumb,.tool-range:active::-moz-range-thumb{transform:scale(1.1)}
.tool-range:focus-visible{outline:none}
.tool-range:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 4px var(--tile),0 0 0 7px var(--accent)}
.tool-range:focus-visible::-moz-range-thumb{box-shadow:0 0 0 4px var(--tile),0 0 0 7px var(--accent)}
/* legacy wrapper from the first engine (image-compress) - same track/thumb
   recipe applied by element type so it improves without a markup change */
.tool-options input[type=range]{-webkit-appearance:none;appearance:none;width:100%;height:44px;margin:0;padding:0;cursor:pointer}
.tool-options input[type=range]::-webkit-slider-runnable-track{height:8px;border-radius:var(--r-pill);background:var(--wall);box-shadow:inset 0 0 0 1.5px var(--line)}
.tool-options input[type=range]::-moz-range-track{height:8px;border-radius:var(--r-pill);background:var(--wall);box-shadow:inset 0 0 0 1.5px var(--line)}
.tool-options input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:22px;height:22px;margin-top:-7px;border-radius:var(--r-pill);background:var(--accent);box-shadow:0 0 0 4px var(--tile);transition:transform var(--dur-1) var(--ease)}
.tool-options input[type=range]::-moz-range-thumb{width:22px;height:22px;border:none;border-radius:var(--r-pill);background:var(--accent);box-shadow:0 0 0 4px var(--tile);transition:transform var(--dur-1) var(--ease)}
.tool-options input[type=range]:focus-visible{outline:none}
.tool-options input[type=range]:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 4px var(--tile),0 0 0 7px var(--accent)}
.tool-options input[type=range]:focus-visible::-moz-range-thumb{box-shadow:0 0 0 4px var(--tile),0 0 0 7px var(--accent)}

/* ---- checkbox/toggle: full-cover invisible input over a themed circle,
   same "invisible input over the whole hit area" trick as .dropzone ---- */
.tool-check{
  position:relative;display:inline-flex;align-items:center;gap:var(--s-8);
  min-height:44px;cursor:pointer;font-size:var(--text-sm);color:var(--ink);
}
/* wave 2: the same [hidden] cascade guard as .btn/.tool-field/.tool-video
   above (an author class rule always beats the UA's [hidden]{display:none}
   at equal specificity, by source order) - caught live at RTL x dark x 375
   because pdf-metadata.js is the first engine to ever toggle `.hidden` on
   a `.tool-check` (its "edit anyway" signature confirmation): without this
   rule the checkbox stayed fully visible and clickable for every PDF, not
   just a signed one. No prior tool tripped this because none of them ever
   hide a `.tool-check`. */
.tool-check[hidden]{display:none}
.tool-check-input{position:absolute;inset:0;opacity:0;cursor:pointer;margin:0}
.tool-check-box{
  width:22px;height:22px;flex:none;border-radius:var(--r-pill);background:var(--wall);
  box-shadow:inset 0 0 0 1.5px var(--line);color:var(--accent-ink);
  display:inline-flex;align-items:center;justify-content:center;
  transition:background var(--dur-1) var(--ease),box-shadow var(--dur-1) var(--ease);
}
.tool-check-box svg{width:13px;height:13px;opacity:0;transform:scale(.6);transition:opacity var(--dur-1) var(--ease),transform var(--dur-1) var(--ease)}
.tool-check-input:checked ~ .tool-check-box{background:var(--accent);box-shadow:none}
.tool-check-input:checked ~ .tool-check-box svg{opacity:1;transform:none}
.tool-check-input:focus-visible ~ .tool-check-box{outline:2px solid var(--accent);outline-offset:2px}
/* gate-fixes-css row 175: the rule above is correct for the unchecked box
   (accent outline against a --wall fill + --line border - plenty of
   contrast), but a *checked* box is already filled with --accent, so the
   outline drew the same colour on top of itself with nothing but a 2px
   transparent gap between them - real, not a false alarm (confirmed via
   real Tab keypresses + getComputedStyle in both themes: fill and outline
   both resolved to the literal same rgb()). Same halo technique already
   shipping two rules up for the range-slider thumb's own focus ring
   (`box-shadow:0 0 0 4px var(--tile),0 0 0 7px var(--accent)`) - a --tile
   ring is drawn to fill exactly the outline-offset gap, since .tool-shell's
   own background *is* --tile, so it reads as the page showing through
   rather than a new colour, while giving the accent ring a guaranteed
   >=6:1 contrast neighbour on both sides regardless of what actually sits
   behind the control (measured: --tile vs --accent computes to ~7.2:1
   dark theme, ~6.1:1 light theme, both against the required 3:1 for a
   non-text UI indicator - see handoff for the full contrast workings). */
.tool-check-input:checked:focus-visible ~ .tool-check-box{box-shadow:0 0 0 2px var(--tile)}
/* legacy: a bare checkbox dropped straight in .tool-options (pre-contract
   markup) still gets an accent-themed native tick and a focus ring */
.tool-options input[type=checkbox]:not(.tool-check-input){accent-color:var(--accent);width:20px;height:20px;flex:none}
.tool-options input[type=checkbox]:not(.tool-check-input):focus-visible{outline:2px solid var(--accent);outline-offset:2px}

/* ---- select: native control, themed chevron via the shared #ic-chevron glyph ---- */
.tool-select-wrap{position:relative}
.tool-select{
  appearance:none;-webkit-appearance:none;width:100%;font-family:inherit;font-size:var(--text-sm);
  /* px-and-breakpoints: end-side 34px sat 2px from --s-32, 6px from --s-40 -
     not a tie, rounded to the nearer rung (-2px; the chevron glyph it clears
     is only 11px wide at inset-inline-end:14px, so ample room remains).
     11px block padding sat exactly between --s-10/--s-12; rounded up
     (+1px, vertical-only - min-height:44px already floors the real height). */
  padding-inline:var(--s-14) var(--s-32);padding-block:var(--s-12);min-height:44px;border-radius:var(--r-pill);
  background:var(--wall);box-shadow:inset 0 0 0 1.5px var(--line);border:none;color:var(--ink);cursor:pointer;
}
.tool-select:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
.tool-select-ic{
  position:absolute;inset-inline-end:var(--s-14);top:50%;transform:translateY(-50%);
  width:11px;height:8px;color:var(--muted);pointer-events:none;
}

/* ---- segmented group: exclusive choice (gradient type, output format...),
   same pill-chip recipe as .tools-cats so it reads as the same family ---- */
.tool-segmented{
  display:inline-flex;gap:var(--s-4);background:var(--wall);padding:var(--s-4);
  border-radius:var(--r-pill);box-shadow:inset 0 0 0 1.5px var(--line);
  /* QA F1 fix (tools-w4-text-tools r1): max-width (a cap, never a forced
     stretch - width:100% would regress every short 2-3-option consumer
     by stretching its content-hugging pill to fill whatever width its
     field happens to have) + flex-wrap together make this control safe
     against ANY amount of content, at ANY width above 640px, not just
     case-converter's 7-option "Plain" group that first exposed the gap.
     Root cause: .tool-segmented was inline-flex/shrink-to-fit with no
     wrap above 640px (only the max-width:640px block below ever gave it
     one) and no cap on its own width, so once a field's available space
     shrank below the group's content (case-converter's Plain: content
     needs ~635-867px, its field could compress to ~300px in a crowded
     row), the buttons rendered outside the field's own box and on top of
     whatever sibling sat next to it. This is a no-op for every existing
     shorter/shorter-label group (resume-builder's Font/Scale/Density,
     Encode/Decode, image-crop's 8-chip short-label aspect-ratio row,
     etc.) - max-width never binds while content already fits, which is
     true for every one of them today; confirmed live, see handoff. */
  max-width:100%;flex-wrap:wrap;overflow-x:auto;scrollbar-width:none;
}
.tool-segmented::-webkit-scrollbar{display:none}
/* [hidden] cascade guard - the fourth instance of the same bug already
   documented on .btn/.tool-field/.tool-check/.tool-color/.tool-file-list/
   .tool-video above: an author `display` rule always beats the UA's own
   `[hidden]{display:none}` at equal specificity (author beats UA
   regardless of order), so a bare `.hidden = true` on a `.tool-segmented`
   left it fully rendered AND in the accessibility tree. Shipped live in
   instagram-grid-splitter (wave 6 design-qa MAJOR 1, r2 handoff): switching
   Purpose to Carousel left the Feed-grid Layout preset buttons visible with
   a stale `aria-checked="true"`, because `RT.buildSegmentedControl()` was
   promoted into the shared runtime this wave with no consumer able to rely
   on `.hidden` alone. The engine's own r2 fix worked around it locally with
   a bare unclassed wrapper div (no competing `display` rule); this is the
   general fix so the next `.tool-segmented` consumer doesn't have to. */
.tool-segmented[hidden]{display:none}
.tool-segmented-btn{
  font-family:inherit;border:none;cursor:pointer;border-radius:var(--r-pill);
  padding:0 var(--s-16);min-height:44px;display:inline-flex;align-items:center;justify-content:center;
  font-size:var(--text-xs);font-weight:700;background:none;color:var(--muted);
  transition:background var(--dur-1) var(--ease),color var(--dur-1) var(--ease);
  /* same reasoning as .tool-segmented above: only matters once the parent
     has actually been forced narrower than its content (max-width kicked
     in) and wrapped to multiple rows - keeps each button at its own
     legible label width instead of being crushed by flex-shrink, exactly
     the rule the max-width:640px block below already used for mobile. */
  flex:1 1 auto;min-width:max-content;
}
.tool-segmented-btn.is-active{background:var(--accent);color:var(--accent-ink)}
.tool-segmented-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}

/* ---- QA F1 fix, continued: a .tool-field housing a WIDE .tool-segmented
   group (5+ options - :nth-child(5) existing is a reliable proxy: every
   current 2-4-option consumer already fits comfortably, confirmed live
   for every one checked - resume-builder, base64/url-encode-decode,
   json-formatter, css-gradient-generator, slug-generator; see handoff)
   prefers its own full row above 640px, instead of sharing a row and
   being flex-shrunk arbitrarily narrow first. flex-basis:100% is
   resolved against the FLEX CONTAINER (.tool-options), not against this
   field's own content, so - unlike an earlier version of this fix that
   used min-width:max-content and read the field's own content size -
   there is no circular sizing and no risk of the field rendering wider
   than the page: shrink stays allowed (flex:1 1 100%, not flex:0 1 100%),
   so on a narrow-but-not-mobile row (768px tablet: the field's own
   available row is ~657px, less than case-converter's Plain content at
   ~785-867px) the field still ends up exactly as wide as the row gives
   it, .tool-segmented's own max-width:100%+flex-wrap above then wraps
   the buttons to fit THAT, and nothing overflows the page. Where the row
   has room (900px+: case-converter's own field row measures 968px),
   flex-basis:100% simply reserves the whole line for this field and the
   control renders as a normal single-row pill, unchanged look.

   The 5+ threshold matters: an earlier version of this rule matched
   ANY .tool-field with a .tool-segmented inside, regardless of option
   count - correct for case-converter, but it also caught every small
   2-3-option toggle (base64/url-encode-decode's Encode/Decode,
   json-formatter's Format/Minify, css-gradient-generator's Type/Format,
   slug-generator's Separator) and forced each onto its own dedicated
   row even though it already shared one comfortably with a sibling
   field before this fix - a real, if minor, regression (more vertical
   space than before, for controls that were never broken). Gating on
   option count keeps every one of those exactly as they were - confirmed
   live, see handoff - while still catching rotate-flip-image's 6-op
   "Apply to all images" toolbar (public/js/tools/rotate-flip-image.js:
   124-160, IS wrapped in .tool-field, unlike image-crop's own 5-op
   toolbar and 8-chip aspect-ratio row, which are appended to a bare
   `data-ic-crop-ui` div and never matched this selector to begin with).

   Scoped via :has() (same tool already used for resume-builder's print
   rules, site.css:~3146) - resume-builder's own Font/Scale/Density/
   Columns/Zoom segmented groups live in `.rb-toolbar-group`, not
   `.tool-field` (resources/views/tools/partials/resume-builder.blade.php:
   212+), so they were never in scope regardless of option count.
   Reverted at <=640px, in the block below - .tool-options is already a
   single stacked column there (flex-basis:100% is redundant, not wrong,
   but the mobile-only width:100% + wrap-inside-the-control rule a few
   lines below is the one actually designed for and tested at that
   width). ---- */
.tool-field:has(.tool-segmented-btn:nth-child(5)){flex:1 1 100%}

/* ---- QA F2 fix (design-tools-w4-jiru-design-qa-r2.md): the coordinator's
   own prose-only "acceptable" call on this (see the F1 handoff's "Known
   gaps") didn't hold up once actually screenshotted - a wrapped
   .tool-segmented computed a full 999px stadium above 640px and a flat
   20px tile below it, for the identical wrapped-to-2-rows state.
   "Wrapped" is a rendered fact a media query cannot see (a fixed
   breakpoint cutover measured wrong for two different real consumers -
   see the `// plan 03: segmented wrap radius` comment in site.js for the
   live numbers), so site.js measures each group's own button offsets and
   toggles this class - a no-op for any control that never wraps. ---- */
.tool-segmented.is-wrapped{border-radius:var(--r-tile)}

/* ---- colour swatch: native input[type=color], enlarged to a full 44px target ---- */
.tool-color{display:inline-flex;align-items:center;gap:var(--s-8);min-height:44px;cursor:pointer}
/* same [hidden] cascade guard as .tool-field/.tool-video above -
   background-remover.js's `colorWrap` carries only this class and is
   toggled via `.hidden` (shown only when the "custom color" backdrop
   option is picked). */
.tool-color[hidden]{display:none}
.tool-color-input{
  appearance:none;-webkit-appearance:none;width:44px;height:44px;border:none;padding:0;
  border-radius:var(--r-pill);cursor:pointer;background:none;box-shadow:inset 0 0 0 1.5px var(--line);
}
.tool-color-input::-webkit-color-swatch-wrapper{padding:var(--s-2);border-radius:var(--r-pill)}
.tool-color-input::-webkit-color-swatch{border:none;border-radius:var(--r-pill)}
.tool-color-input::-moz-color-swatch{border:none;border-radius:var(--r-pill)}
.tool-color-input:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.tool-color-label{font-size:var(--text-sm);color:var(--ink)}

@media (max-width:640px){
  .tool-field{flex-basis:100%}
  /* QA F1 fix, continued: flex-basis:100% on .tool-field:has(.tool-segmented)
     above is a no-op here - every .tool-field already gets flex-basis:100%
     the line above at this width - so nothing to revert; the mobile-only
     width:100% + wrap-inside-the-control rule a few lines below is still
     the one actually designed for and tested at this width, unchanged. */
  /* plan 06 fix: flex:1 (flex-basis:0%) let 4-option rows (HEX/RGB/HSL/
     OKLCH on css-gradient-generator) overflow their pill track at this
     width, since a flex item's default min-width:auto still refuses to
     shrink below its label's content size - flagged live in the
     tools-engineer handoff (04-color-gradient-engines). flex:1 1 auto
     keeps 2-3 option rows filling evenly exactly as before (their content
     already fits), and lets a 4th option keep its natural width instead of
     squeezing/overflowing - the container scrolls horizontally on the
     rare case even natural widths don't fit, instead of clipping. */
  /* design-W1-image-tools-r1 F1/F2: a lone `overflow-x:auto` scroller
     never actually became one for image-resize's 6 preset chips or
     rotate-flip's 6-op toolbar - `min-width:max-content` on every button
     stopped them shrinking, so the row's `scrollWidth === clientWidth`
     and it just grew past its container instead (fixed one level up at
     `.tool-options`, see that rule's own comment for the root cause).
     Once `.tool-options` correctly stretches this row to the viewport,
     `flex-wrap:wrap` lets the row that still doesn't fit break onto
     multiple lines - reachable without a drag gesture, and every existing
     2-3-option `.tool-segmented` (output format, mode toggles) already
     fits on one line at this width so it wraps to nothing and looks
     exactly as before. `overflow-x:auto` stays as a fallback only for the
     edge case a *single* chip's own label is wider than the row itself
     (still can't shrink below its content, wrap can't help that one).

     Radius: a wrapped `.tool-segmented` is no longer a slim one-line bar -
     it's a multi-row block of chips, so its own `--r-pill` (999px)
     background exceeds half its now-taller height and renders as a big
     rounded blob rather than a rectangle (seen live once wrap kicked in).
     `--r-tile` is the exact precedent already in this file for "a
     background surface housing a cluster of its own round controls" -
     `.tool-position-grid` (a few rules below) does the same thing for the
     watermark position picker. Switching to it here at the same
     breakpoint keeps both fixed radii in the one-accent system's own
     vocabulary; the buttons inside keep their own `--r-pill` chip shape
     either way, matching `.tool-position-cell`'s circles inside their
     tile housing. */
  .tool-segmented{width:100%;flex-wrap:wrap;overflow-x:auto;scrollbar-width:none;border-radius:var(--r-tile)}
  .tool-segmented::-webkit-scrollbar{display:none}
  .tool-segmented-btn{flex:1 1 auto;min-width:max-content}
}

/* RTL: the select chevron already flips via inset-inline-end; the range
   slider is deliberately left un-mirrored - see the frontend-ui handoff's
   "RTL slider decision" for why. */

/* =====================================================================
   plan 04: tool disclosure caveat - a footnote to a promise, not a
   warning. `tools.widget.empty_body` promises "nothing is uploaded"
   above the dropzone; a few engines have one honest, narrow exception to
   that promise to disclose (background-remover's one-time img.ly CDN
   model fetch today; video-to-frames' ffmpeg.wasm/COOP-COEP need is the
   likely next one). This class is the reusable answer for all of them.

   Hierarchy intent: quieter than body copy so it doesn't read as a
   warning (the tool is not dangerous), but never buried - text-sm at
   `--muted` on `--tile` is the same pairing already shipping on
   `.tool-quality`'s label, which measures ~5.7:1 in light and ~5.3:1 in
   dark (both AA-pass for normal text; computed against this exact token
   pair). A hairline `--line` rule above ties it visually to the promise
   it qualifies, and a small info glyph (never the alert/warning triangle)
   marks it as a disclosure being made, not a danger being flagged.

   Engines should add `class="tool-caveat"` to the paragraph they inject.
   `background-remover.js` already shipped before this class existed and
   is out of this agent's ownership (`public/js/tools/**`), so its
   existing `[data-bg-cdn-notice]` attribute is targeted here too as a
   compatibility hook - it renders identically to `.tool-caveat` today
   without needing an edit to that file. Flagged in the handoff for
   tools-engineer to add the class explicitly next time it touches that
   file, and for the four upcoming engines to reach for `.tool-caveat`
   from day one instead of an unclassed <p>.

   No animation here, so no reduced-motion entry is needed - static by
   construction. ===================================================== */
.tool-caveat,[data-bg-cdn-notice]{
  flex:1 1 100%;
  display:flex;align-items:flex-start;gap:var(--s-8);
  margin:0 0 var(--s-4);padding-top:var(--s-12);
  border-top:1px solid var(--line);
  font-size:var(--text-sm);line-height:1.6;color:var(--muted);
}
/* [hidden] cascade guard - the same bug family as .tool-field/.tool-video/
   .tool-segmented elsewhere in this file (an author rule always beats the
   UA's [hidden]{display:none}, even at equal specificity, because origin
   order outranks it), just never caught here before: every prior consumer
   of this class (background-remover's CDN notice) renders it statically
   and never toggles `.hidden`. resume-writer's own autosave line
   (public/js/tools/resume-writer.js, `autosaveEl.hidden = true` until the
   first save) is the first real toggle, and it surfaced the gap live - a
   bordered, icon-only caveat row with no text rendered at the bottom of
   the sticky action bar before any save had happened, confirmed via
   getComputedStyle (hidden:true, display:"flex"). Pure correctness fix:
   this can only ever make an already-hidden caveat MORE hidden, never
   less, so it is safe for every existing consumer of the class too. */
.tool-caveat[hidden],[data-bg-cdn-notice][hidden]{display:none}
.tool-caveat::before,[data-bg-cdn-notice]::before{
  content:"";flex:none;width:16px;height:16px;margin-top:var(--s-2);
  background-color:currentColor;
  -webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Ccircle cx='10' cy='10' r='7.5' fill='none' stroke='black' stroke-width='1.8'/%3E%3Cpath d='M10 9v5' stroke='black' stroke-width='1.8' stroke-linecap='round'/%3E%3Ccircle cx='10' cy='6.5' r='1' fill='black'/%3E%3C/svg%3E") center/contain no-repeat;
  mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Ccircle cx='10' cy='10' r='7.5' fill='none' stroke='black' stroke-width='1.8'/%3E%3Cpath d='M10 9v5' stroke='black' stroke-width='1.8' stroke-linecap='round'/%3E%3Ccircle cx='10' cy='6.5' r='1' fill='black'/%3E%3C/svg%3E") center/contain no-repeat;
}

/* =====================================================================
   plan 04: PDF file reorder list (merge-pdf, jpg-to-pdf) - shared
   `.tool-file-list`/`.tool-file-row*` classes rendered by both engines
   (public/js/tools/merge-pdf.js, jpg-to-pdf.js). The engines built real
   <button>s with real labels; nothing here styled the surface until now -
   filename and page-count ran together illegibly and the remove button
   was under the 44px tap-target minimum on its short axis.
   No animation, so no reduced-motion entry needed - static by
   construction. ===================================================== */
.tool-file-list{list-style:none;margin:0;padding:0;flex:1 1 100%;display:flex;flex-direction:column;gap:var(--s-8)}
/* [hidden] cascade guard - the third instance of this exact bug (see the
   .tool-field and .btn guards). An author rule always beats the UA's
   [hidden]{display:none}, even at equal specificity, because origin order
   outranks specificity - so a class that sets its own `display` silently
   defeats `element.hidden`.

   This one shipped as a real regression rather than a hypothetical: the
   shared selected-file list is created up front and left `hidden` until a
   file is chosen, and without this rule the empty list still occupied a
   flex row on every file tool. On image-compress at 375px that pushed the
   Run button below the fold, where elementFromPoint returns null, and the
   hit-test guard that exists precisely to catch a blocked primary action
   caught it. A class that carries `display` and is toggled by .hidden needs
   this guard, every time. */
.tool-file-list[hidden]{display:none}
.tool-file-row{
  display:flex;align-items:center;gap:var(--s-12);
  padding:var(--s-12);border-radius:var(--r-tile);background:var(--tile);
}
.tool-file-row-name{
  flex:1 1 auto;min-width:0;font-size:var(--text-sm);font-weight:600;color:var(--ink);
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
  /* an arbitrary filename is user text, often Latin, inside a page that may
     be RTL - isolate it so it can't scramble reading order, the same fix
     already applied to .tool-palette-hex */
  unicode-bidi:isolate;
}
.tool-file-row-meta{
  flex:none;font-size:var(--text-xs);color:var(--muted);
  /* "3 page(s)" is a number/unit, not prose - keep it LTR-shaped in RTL too */
  direction:ltr;unicode-bidi:isolate;
}
.tool-file-row-actions{display:flex;align-items:center;gap:var(--s-2);flex:none}
/* override .btn's text-button padding with a fixed 44x44 square - these
   three are icon-only (single glyph or SVG), not label buttons */
.tool-file-row-actions .btn{
  width:44px;height:44px;padding:0;flex:none;
  display:inline-flex;align-items:center;justify-content:center;
}
.tool-file-row-actions .btn svg{width:18px;height:18px}

/* =====================================================================
   plan 04/07: video-to-frames - the preview <video> + the frame-preview
   grid (personas/QA/handoffs/04-video-frames-css-frontend-ui.md). The
   engine currently inline-styles the injected preview <video>
   (maxWidth/borderRadius, flagged in its own handoff §8/§9) because
   neither class existed yet, and its success state reuses the shared
   before/after strip (`.tool-success-preview`) as a 6-thumb sample
   rather than the spec's full contact-sheet grid. Both gaps are closed
   here; tools-engineer adopts these classes (and drops the two inline
   styles) on its next pass. Neither piece animates, so neither needs a
   reduced-motion entry - static by construction. =====================
   ===================================================================== */

/* ---- preview <video>: the same "constrained surface" contract as
   .tool-preview-img (radius, never overflows its column) but for a
   native <video> that must keep its own intrinsic aspect ratio rather
   than being cropped - object-fit:cover would crop the content the
   user is scrubbing through. max-width + max-height together resolve a
   replaced element's used size while preserving aspect ratio, so a
   1080p source never blows out the column and a portrait clip never
   balloons to full column height at any width, 375 included.
   direction:ltr per the spec's own i18n note ("video controls LTR") -
   native control-bar order should stay predictable regardless of page
   direction, same family as never mirroring a wordmark or a hex code. ---- */
.tool-video{
  flex:1 1 100%;
  display:block;
  max-width:100%;
  max-height:360px;
  margin-inline:auto;
  border-radius:var(--r-tile);
  background:var(--wall);
  box-shadow:inset 0 0 0 1.5px var(--line);
  direction:ltr;
}
/* an author rule always beats the UA's [hidden]{display:none}, even at equal
   specificity, because origin order (author > user-agent) outranks
   specificity in the cascade - so this class's own display:block would
   otherwise render the engine's `video.hidden = true` preview as an empty,
   sourceless control bar before any file is chosen. Restore the browser's
   default explicitly whenever the attribute is present. */
.tool-video[hidden]{display:none}
.tool-video:focus-visible{outline:2px solid var(--accent);outline-offset:2px}

/* ---- frame-preview grid: the spec's contact sheet (a 6-thumb strip is
   not it - see the tools-engineer handoff's §8 "known gaps"). `auto-fill`
   + `minmax` wraps cleanly from 375 to 1280 with no manual breakpoint,
   and each cell's height comes from `aspect-ratio`, never from a
   flex-basis hack - the exact "0-height at 375" bug class that has
   already shipped twice on this project. Frame order is a *sequence*
   (reading order), so this deliberately does NOT set direction:ltr on
   the grid itself: `dir="rtl"` on <html> flips CSS grid auto-placement
   for free, the same way it already flips a `direction:rtl` flex row,
   so index 0 lands at the reading-start side in both directions - no
   JS re-sort needed. Only the Latin frame *number* inside each thumb is
   isolated LTR below: a sequence position is a digit, not prose.

   Markup contract for whoever wires this in (tools-engineer):
     <div class="tool-frame-grid" aria-label="...">
       <figure class="tool-frame">
         <img class="tool-preview-img" src="…" alt="Frame :n">
         <span class="tool-frame-num" aria-hidden="true">:n</span>
       </figure>
       ...
     </div>
   `.tool-frame` itself only needs `:focus-visible` wiring if a cell
   becomes focusable (e.g. wrapped in an <a>/<button> for a per-frame
   download per the spec's "individual" download) - the rule below is
   ready either way and costs nothing on a plain, non-interactive
   <figure>. ---- */
.tool-frame-grid{
  display:grid;
  grid-template-columns:repeat(auto-fill,minmax(var(--s-96),1fr));
  gap:var(--s-8);
  flex:1 1 100%;
}
/* [hidden] cascade guard - same bug family as .tool-segmented/.tool-preview-img
   above. instagram-grid-splitter.js toggles `previewGrid.hidden` (no
   source file selected yet, or a re-render mid-flight); of this class's
   five consumers (also pdf-organize/pdf-rotate/pdf-split/video-to-frames'
   own thumbnail grids) it's the only one that does. Today's call sites
   happen to also clear `.innerHTML` first, so an empty `display:grid` box
   renders at zero height and nothing was visibly broken - but that's
   incidental to those call sites, not a property of the CSS, and this is
   exactly the kind of gap a future consumer (or a future edit to this one)
   would fall straight into without warning. */
.tool-frame-grid[hidden]{display:none}
.tool-frame{
  position:relative;
  aspect-ratio:4/3;
  margin:0;
  border-radius:var(--r-tile);
  overflow:hidden;
  background:var(--tile);
}
.tool-frame:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.tool-frame-num{
  position:absolute;inset-block-end:var(--s-4);inset-inline-start:var(--s-4);
  min-width:20px;height:20px;padding:0 var(--s-6);border-radius:var(--r-pill);
  display:inline-flex;align-items:center;justify-content:center;
  background:color-mix(in srgb, var(--ink) 55%, transparent);color:var(--wall);
  font-size:var(--text-xs);font-weight:700;line-height:1;
  /* unicode-bidi:isolate alone keeps the digit run from ever being
     bidi-reordered - deliberately WITHOUT `direction:ltr` here, unlike
     .tool-file-row-meta / .tool-palette-hex. Those are inline text runs
     sitting inside RTL prose, where a forced direction only affects
     content. This badge also carries `inset-inline-start` for its
     corner position, and a logical inset resolves against its OWN
     element's direction, not an ancestor's - forcing ltr here was
     caught live (RTL x dark x 375 check) silently pinning the badge to
     the physical-left corner in every direction instead of following
     the grid's own reading-order flip. Digits render left-to-right on
     their own regardless of direction (they are weak characters with
     no adjacent text inside this isolated, absolutely-positioned span),
     so isolation alone is enough; direction stays inherited (rtl on an
     Arabic page) so the badge's corner tracks the same reading-start
     side the grid itself flips to. */
  unicode-bidi:isolate;
}

/* =====================================================================
   plan 06: color-palette-generator + css-gradient-generator - the first
   two tools whose UI *is* the tool (no dropzone to hide behind - see
   tools-engineer's r1 handoff, "why these two don't call
   ToolEngineRuntime.register()"). Every swatch/gradient colour rendered
   here is the tool's own literal computed output (set via inline
   style/backgroundImage by the engine, same "unavoidable dynamic value"
   exception already used by image-compress/image-convert's canvas
   output) - never a design colour, so it never violates "tokens only".
   Everything else - card surfaces, radii, focus rings, spacing, motion -
   is tokens only, both themes, transform/opacity with the reduced-motion
   off-switches added above. ===================================================== */

/* ---- gradient preview: needs real geometry - it was 0x0 and invisible
   until now, per the handoff's own flag ---- */
.tool-gradient-preview{
  flex:1 1 100%;
  width:100%;height:220px;border-radius:var(--r-tile);
  /* box-shadow-generator (tools-engineer's lane) demonstrates a real
     box-shadow value on this same shared preview box. It composes that
     value in via the --demo-shadow custom property (chip.style.setProperty,
     never style.boxShadow) so this class keeps ownership of the shorthand -
     an inline box-shadow= assignment beats a class rule outright and was
     permanently defeating the inset boundary below at low/zero opacity
     (design gate wave-3 Finding 1: the preview went fully invisible).
     --demo-shadow is unset for every other consumer of this class
     (css-gradient-generator, glassmorphism-generator both only ever set
     backgroundImage here), so the fallback must itself be a real,
     syntactically valid <shadow> - "none" is NOT valid as one item in a
     comma-separated box-shadow list, and would make the whole declaration
     invalid at computed-value time, silently dropping the boundary too.
     "0 0 transparent" is a real, always-invisible shadow, so the list stays
     valid whether or not the engine has run yet. Verified live with
     --demo-shadow unset and set to a heavy multi-layer shadow - see the
     frontend-ui wave-3 blocker-fix handoff. */
  box-shadow:inset 0 0 0 1.5px var(--line), var(--demo-shadow, 0 0 transparent);
}
.tool-gradient-note{font-size:var(--text-xs);color:var(--muted);margin-top:var(--s-4)}

/* ---- color stop editor: color swatch + opacity/position sliders + a
   remove button, laid out with numbered grid lines (not physical offsets)
   so RTL mirrors automatically along with the rest of the page ---- */
.tool-gradient-stops{flex:1 1 100%;display:flex;flex-direction:column;gap:var(--s-8)}
.tool-gradient-stop-row{
  display:grid;grid-template-columns:44px 1fr 1fr 44px;grid-template-rows:auto auto;
  align-items:center;column-gap:var(--s-12);row-gap:var(--s-4);
  padding:var(--s-12);border-radius:var(--r-tile);background:var(--tile);
}
.tool-gradient-stop-row > :nth-child(1){grid-column:1;grid-row:1 / 3;justify-self:center}
.tool-gradient-stop-row > :nth-child(2){grid-column:2;grid-row:1}
.tool-gradient-stop-row > :nth-child(3){grid-column:2;grid-row:2}
.tool-gradient-stop-row > :nth-child(4){grid-column:3;grid-row:1}
.tool-gradient-stop-row > :nth-child(5){grid-column:3;grid-row:2}
.tool-gradient-stop-row > :nth-child(6){grid-column:4;grid-row:1 / 3;justify-self:center}
.tool-gradient-stop-remove{
  width:44px;height:44px;flex:none;border:none;border-radius:var(--r-pill);
  background:var(--wall);color:var(--muted);cursor:pointer;font-size:var(--text-xl);line-height:1;
  display:inline-flex;align-items:center;justify-content:center;
  box-shadow:inset 0 0 0 1.5px var(--line);
  transition:transform var(--dur-1) var(--ease),color var(--dur-1) var(--ease);
}
.tool-gradient-stop-remove:hover{transform:translateY(-1px);color:var(--ink)}
.tool-gradient-stop-remove:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.tool-gradient-stop-remove:disabled{opacity:.35;cursor:default;transform:none}
@media (max-width:640px){
  .tool-gradient-stop-row{grid-template-columns:44px 1fr 44px}
  .tool-gradient-stop-row > :nth-child(1){grid-row:1 / 5}
  .tool-gradient-stop-row > :nth-child(2){grid-column:2;grid-row:1}
  .tool-gradient-stop-row > :nth-child(3){grid-column:2;grid-row:2}
  .tool-gradient-stop-row > :nth-child(4){grid-column:2;grid-row:3}
  .tool-gradient-stop-row > :nth-child(5){grid-column:2;grid-row:4}
  .tool-gradient-stop-row > :nth-child(6){grid-column:3;grid-row:1 / 5}
}

/* ---- CSS output block: a code surface + copy button. dir="ltr" is set
   inline by the engine (a CSS value is always Latin/LTR regardless of
   page locale) - this only supplies the card chrome around it ---- */
.tool-gradient-output{
  flex:1 1 100%;display:flex;flex-direction:column;gap:var(--s-12);
  background:var(--tile);border-radius:var(--r-tile);padding:var(--s-16);
}
.tool-gradient-code{
  margin:0;padding:var(--s-12);border-radius:var(--r-tile);
  background:var(--wall);box-shadow:inset 0 0 0 1.5px var(--line);
  font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
  font-size:var(--text-sm);line-height:1.6;color:var(--ink);
  white-space:pre-wrap;word-break:break-word;overflow-x:auto;
}

/* ===== wave 8 (specs 50-52): markdown-to-html preview typography =====
   .tool-markdown-preview (public/js/tools/markdown-to-html.js) is a plain
   layout wrapper around two alternate views toggled via [hidden] - the
   HTML-source view already reuses .tool-gradient-code unmodified above, so
   it needs nothing here. .tool-markdown-rendered is the one surface that
   has never had to style real body prose inside a tool panel before: it
   renders headings/lists/code/blockquotes/links produced live from visitor
   Markdown (see parseBlocks() in that file - h1-h6, p, ul/ol>li, pre>code,
   inline code, blockquote, a, img, hr, strong, em, hr, br; no table, no
   nested list - the engine's own stated CommonMark subset).
   Adapted DOWN from .article's .prose vocabulary above, not reused
   verbatim, per the wave 8 engine handoff's own ask: a tool panel reads at
   .tool-textarea's own scale, not the blog's - --text-sm body (matching
   the Markdown <textarea> beside it so both panes read at the same size),
   the ladder's compact heading rungs instead of .prose h2's --text-2xl,
   and the same wall/tile/inset-line card .tool-textarea already uses so
   the two panes read as siblings, not the blog's 65ch measure or --s-48
   heading rhythm which would visually outgrow the panel. Scoped entirely
   under .tool-markdown-rendered so none of this can leak into .prose or
   any other tool's output panel. border-inline-start (not .prose
   .callout's older physical border-left) so the blockquote accent mirrors
   correctly under RTL from day one, matching .consent-control's own
   documented reasoning above. pre/inline code force direction:ltr - code
   is a Latin/code artifact regardless of the surrounding Markdown's
   language, the same rule this file already applies to a wordmark, a hex
   value or a résumé field (AI/06-LESSONS.md SS3's glyph/text taxonomy). */
.tool-markdown-rendered{
  min-height:var(--s-128);border-radius:var(--r-tile);background:var(--wall);
  box-shadow:inset 0 0 0 1.5px var(--line);padding:var(--s-14) var(--s-16);
  font-size:var(--text-sm);line-height:1.6;color:var(--ink);
  overflow-wrap:break-word;
}
.tool-markdown-rendered > :first-child{margin-top:0}
.tool-markdown-rendered > :last-child{margin-bottom:0}
.tool-markdown-rendered h1,.tool-markdown-rendered h2,.tool-markdown-rendered h3,
.tool-markdown-rendered h4,.tool-markdown-rendered h5,.tool-markdown-rendered h6{
  font-weight:700;line-height:1.3;margin:var(--s-20) 0 var(--s-8);color:var(--ink);
}
.tool-markdown-rendered h1{font-size:var(--text-xl)}
.tool-markdown-rendered h2{font-size:var(--text-lg)}
.tool-markdown-rendered h3,.tool-markdown-rendered h4,
.tool-markdown-rendered h5,.tool-markdown-rendered h6{font-size:var(--text-md)}
.tool-markdown-rendered p{margin:0 0 var(--s-12)}
.tool-markdown-rendered ul,.tool-markdown-rendered ol{
  margin:0 0 var(--s-12);padding:0;margin-inline-start:var(--s-20);line-height:1.6;
}
.tool-markdown-rendered li{margin-bottom:var(--s-4)}
.tool-markdown-rendered strong{font-weight:700}
.tool-markdown-rendered em{font-style:italic}
.tool-markdown-rendered code{
  font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
  font-size:.9em;background:var(--tile);padding:var(--s-2) var(--s-6);
  border-radius:6px;direction:ltr;unicode-bidi:embed;
}
.tool-markdown-rendered pre{
  margin:0 0 var(--s-12);padding:var(--s-12);border-radius:var(--r-tile);
  background:var(--tile);overflow-x:auto;direction:ltr;
}
.tool-markdown-rendered pre code{
  background:none;padding:0;font-size:var(--text-xs);border-radius:0;
}
.tool-markdown-rendered blockquote{
  margin:0 0 var(--s-12);padding-inline-start:var(--s-16);
  border-inline-start:3px solid var(--accent);color:var(--muted);
}
.tool-markdown-rendered blockquote > :last-child{margin-bottom:0}
.tool-markdown-rendered a{color:var(--accent);text-decoration:underline;text-underline-offset:2px}
.tool-markdown-rendered a:focus-visible{outline:2px solid var(--accent);outline-offset:1px;border-radius:2px}
.tool-markdown-rendered img{max-width:100%;height:auto;border-radius:var(--r-tile);display:block}
.tool-markdown-rendered hr{border:none;border-top:1.5px solid var(--line);margin:var(--s-16) 0}

/* ---- preset chips: each one's own gradient is its swatch ---- */
.tool-gradient-presets{flex:1 1 100%;display:flex;flex-wrap:wrap;gap:var(--s-8)}
/* [hidden] cascade guard - same bug family as .tool-field/.tool-check/
   .tool-segmented/.tool-color/.tool-file-list above. An author rule always
   beats the UA's [hidden]{display:none}, even at equal specificity, because
   origin order (author > user-agent) outranks specificity - so this class's
   own `display:flex` silently defeated `element.hidden` with no error and
   no visible failure until someone actually toggled it and looked.

   Found live, not hypothetically: clip-path.js (board row 237,
   plans/tools/specs/57-clip-path.md) sets `presetsWrap.hidden = true` when
   switching to Circle/Ellipse/Inset, and without this rule the eight
   Polygon preset buttons stayed visible underneath every other shape's own
   controls - screenshot showed all of them stacked under the wrong panel.
   The engine's own workaround (an inline `style.display` toggle,
   documented in that file) still works after this fix and is safe to
   remove - see the frontend-ui handoff (css-tools-icons-frontend-ui.md).
   This is the one class in the shared "labelled option group" family that
   was missed; the other five (.tool-field/.tool-check/.tool-segmented/
   .tool-color/.tool-file-list) were swept and already carry this guard. */
.tool-gradient-presets[hidden]{display:none}
.tool-gradient-preset{
  width:56px;height:56px;flex:none;border:none;border-radius:var(--r-tile);cursor:pointer;
  /* same --demo-shadow composition as .tool-gradient-preview just above -
     box-shadow-generator's preset swatches demo a real shadow value here
     too. See that rule's comment for why the fallback is a real invisible
     <shadow> ("0 0 transparent") and not the string "none". */
  box-shadow:inset 0 0 0 1.5px var(--line), var(--demo-shadow, 0 0 transparent);
  transition:transform var(--dur-2) var(--ease);
}
.tool-gradient-preset:hover{transform:translateY(-3px)}
/* double inset ring (same trick as .tool-range's thumb focus ring) so the
   ring reads against any arbitrary generated gradient colour underneath.
   --demo-shadow composes alongside the ring instead of an inline style
   replacing it outright - that was permanently defeating this rule for
   keyboard users (design gate wave-3 Finding 2), because the engine used to
   set the shadow via style.boxShadow, which beats a class selector on the
   same property regardless of :focus-visible specificity. */
.tool-gradient-preset:focus-visible{outline:none;box-shadow:inset 0 0 0 3px var(--wall),inset 0 0 0 6px var(--accent), var(--demo-shadow, 0 0 transparent)}

/* ---- palette bar: 5 swatch cards, lockable/rerollable/reorderable ---- */
.tool-palette-hint{flex:1 1 100%;font-size:var(--text-sm);color:var(--muted);margin:0}
.tool-palette-bar{
  flex:1 1 100%;display:flex;gap:var(--s-12);flex-wrap:wrap;
}
.tool-palette-swatch{
  display:flex;flex-direction:column;flex:1 1 140px;min-width:140px;
  border-radius:var(--r-tile);overflow:hidden;background:var(--tile);
  box-shadow:0 1px 0 var(--line);
  transform:translateY(0);transition:transform var(--dur-2) var(--ease);
}
.tool-palette-swatch:hover,.tool-palette-swatch:focus-within{transform:translateY(-4px)}
.tool-palette-swatch.is-locked{box-shadow:inset 0 0 0 2px var(--accent)}
.tool-palette-color{
  display:block;width:100%;height:132px;border:none;padding:0;margin:0;cursor:pointer;
}
/* same double inset ring as the gradient presets - visible against any
   generated swatch colour, not just ones that happen to contrast with a
   single-colour accent ring */
.tool-palette-color:focus-visible{outline:none;box-shadow:inset 0 0 0 3px var(--wall),inset 0 0 0 6px var(--accent)}
.tool-palette-meta{display:flex;flex-direction:column;gap:var(--s-2);padding:var(--s-8) var(--s-8) 0}
/* direction:ltr - a hex code is Latin/digits regardless of page locale;
   without this, RTL bidi reorders "#E85726" into "E85726#" (caught live in
   ar-EG at 375px) */
.tool-palette-hex{font-weight:700;font-size:var(--text-sm);color:var(--ink);letter-spacing:.02em;direction:ltr;unicode-bidi:isolate;display:inline-block}
.tool-palette-name{font-size:var(--text-xs);color:var(--muted)}
.tool-palette-contrast{
  align-self:flex-start;margin-top:var(--s-4);padding:var(--s-2) var(--s-10);border-radius:var(--r-pill);
  font-size:var(--text-xs);font-weight:700;
  background:color-mix(in srgb, var(--accent) 18%, var(--tile));color:var(--accent);
}
/* "fail" reads as informational, not alarming - no second accent (e.g. a
   red) for a low-contrast badge; a muted neutral says the same thing.
   2026-07-26 wave-3 blocker-fix (design gate Finding 4, MAJOR): this badge
   is contrast-checker's own pass/fail status pill, so its own text failed
   the exact AA floor it exists to measure - 4.27:1 in dark theme (13px/700
   is "normal" text, needs 4.5:1; --muted on this tinted bg fell short by a
   measured margin). Text moves from --muted to --ink (still a neutral, not
   an accent, not red - same colour "dark tiles" text already uses
   elsewhere) which measures ~9:1 dark / ~11.3:1 light against this same
   background, both reload-into-theme via canvas-resolved colour, not
   live-toggled (AI/06-LESSONS.md §10's live-toggle trap). background is
   unchanged so the badge keeps its own visual weight against the swatch
   card's plain --tile surface. */
.tool-palette-contrast.is-fail{background:color-mix(in srgb, var(--ink) 10%, var(--tile));color:var(--ink)}
.tool-palette-actions{display:flex;align-items:center;justify-content:space-between;gap:var(--s-2);padding:var(--s-6)}
.tool-palette-lock{
  flex:1;min-height:44px;padding:0 var(--s-10);border:none;border-radius:var(--r-pill);
  background:none;color:var(--muted);font-size:var(--text-xs);font-weight:700;cursor:pointer;
  transition:background var(--dur-1) var(--ease),color var(--dur-1) var(--ease);
}
.tool-palette-lock:hover{background:color-mix(in srgb, var(--ink) 6%, transparent);color:var(--ink)}
.tool-palette-swatch.is-locked .tool-palette-lock{color:var(--accent)}
.tool-palette-move{
  width:44px;height:44px;flex:none;border:none;border-radius:var(--r-pill);
  background:none;color:var(--muted);font-size:var(--text-sm);cursor:pointer;
  transition:background var(--dur-1) var(--ease),color var(--dur-1) var(--ease);
}
.tool-palette-move:hover{background:color-mix(in srgb, var(--ink) 6%, transparent);color:var(--ink)}
.tool-palette-move:disabled{opacity:.35;cursor:default}
.tool-palette-lock:focus-visible,.tool-palette-move:focus-visible{outline:2px solid var(--accent);outline-offset:2px}

/* ---- reroll pulse: transform/opacity only, guarded by the engine's own
   prefers-reduced-motion check before the class is ever added, and
   defended again here (off-switch above) ---- */
@keyframes paletteReroll{0%{opacity:.55;transform:scale(.97)}100%{opacity:1;transform:none}}
.tool-palette-bar.is-rerolled .tool-palette-swatch{animation:paletteReroll var(--dur-2) var(--ease)}

@media (max-width:640px){
  /* .tool-options switches to flex-direction:column at this width (see
     plan 03 responsive above). Once the main axis is vertical, this item's
     flex:1 1 100% basis (100%) becomes the *height* hint and overrides the
     explicit height below, collapsing to 0 (no content - the gradient is
     painted via inline background-image, not a child node). flex:0 0 auto
     makes height resolve from the explicit property again - real geometry
     restored at the one breakpoint that matters most. */
  .tool-gradient-preview{height:160px;flex:0 0 auto}
  .tool-palette-swatch{flex-basis:100%}

  /* The flex `order` promotion that used to live here is gone. It fixed the
     stranded-output friction visually and could not fix it for a keyboard
     user: `order` moves painted position, never DOM order, so tab order
     still ran through every slider before the button that had moved above
     them. The engines now insert the output block right after the preview
     (AI/07-UI-STANDARDS.md §10, "Mobile output position"), so painted order
     and tab order agree and these two rules became no-ops.

     Worth keeping in mind rather than repeating: `order` is a presentation
     tool, and reaching for it to solve a *sequence* problem produces exactly
     this - a fix that a sighted mouse user sees and a keyboard user does
     not. When the required order is the reading order, change the DOM. */
}

/* =====================================================================
   picker-blocker fix: color-picker-from-image's HEX/RGB/HSL readout row
   and pick-history chips (personas/QA/reports/design-wave5-design-
   review.md - one BLOCKER, one MAJOR). JS/CSS contract fixed up front so
   neither lane invented class names (AI/10-AGENT-OPERATING-MANUAL.md Part
   3, "two lanes cannot converge on a contract neither of them owns"): JS
   assigns every class below (public/js/tools/color-picker-from-image.js,
   not mine to edit), this file styles them. Read from the shipped engine
   before writing a rule, per this seat's own standing rule - the child
   classes (`-value`, `-magnifier`, `-swatch`, `-hex`) are the engine's own
   naming, disclosed in its handoff.
   ===================================================================== */

/* ---- BLOCKER fix: the readout row (magnifier + hex/rgb/hsl) had zero
   layout CSS before this - plain inline flow, so the docked 88x88
   magnifier canvas physically overlapped wrapped text and the three
   values ran together with no separator ("rgb(153, 128,#998080"). A flex
   line cannot let its items overlap the way unconstrained inline content
   can, and wrapping moves each item onto its own line instead of
   colliding - that is the whole fix. ---- */
.tool-picker-readout{
  display:flex;flex-wrap:wrap;align-items:center;gap:var(--s-8) var(--s-16);
}
.tool-picker-readout-magnifier{
  flex:none;display:block;border-radius:var(--r-tile);
  box-shadow:inset 0 0 0 1.5px var(--line);background:var(--wall);
}
/* white-space:nowrap stops a single "rgb(51, 102, 255)" breaking
   mid-value, which was the illegible-at-375 half of the BLOCKER.
   direction:ltr + unicode-bidi:isolate on every value, not only the hex
   one (the only one that carried .tool-palette-hex before this fix) - a
   picked colour's numbers must never bidi-reorder inside an RTL page,
   same fix .tool-palette-hex already applies to hex codes elsewhere. */
.tool-picker-readout-value{
  white-space:nowrap;direction:ltr;unicode-bidi:isolate;
  font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
  font-size:var(--text-sm);font-weight:700;color:var(--ink);
}

/* ---- MAJOR fix: pick history is a row of small chips, not
   color-palette-generator's fixed five-card grid. Deliberately does not
   touch .tool-palette-swatch/-color/-meta anywhere - those classes are
   shared with that tool's own fixed layout and stay exactly as they are;
   a lone history entry used to inherit flex:1 1 140px/height:132px from
   them and grow into a single oversized ~279x163 card. ---- */
.tool-picker-history{display:flex;flex-wrap:wrap;gap:var(--s-8)}
.tool-picker-history-item{
  display:flex;align-items:center;gap:var(--s-8);
  flex:none;max-width:100%;
  padding-block:var(--s-4);padding-inline-start:var(--s-4);padding-inline-end:var(--s-12);
  border-radius:var(--r-pill);background:var(--tile);
  box-shadow:0 1px 0 var(--line);
  transform:translateY(0);transition:transform var(--dur-2) var(--ease);
}
.tool-picker-history-item:hover,.tool-picker-history-item:focus-within{transform:translateY(-2px)}
/* the swatch is the entry's only clickable part (copies the hex on
   click) - 44x44 is the touch-target floor (AI/07-UI-STANDARDS.md §4),
   not a stylistic choice. Pad the chip around it; never shrink the box. */
.tool-picker-history-swatch{
  width:44px;height:44px;flex:none;border:none;border-radius:var(--r-pill);
  cursor:pointer;box-shadow:inset 0 0 0 1.5px var(--line);
}
/* same double inset ring as .tool-palette-color/.tool-gradient-preset -
   visible against any arbitrary generated swatch colour underneath */
.tool-picker-history-swatch:focus-visible{
  outline:none;box-shadow:inset 0 0 0 2px var(--wall),inset 0 0 0 4px var(--accent);
}
/* also carries .tool-palette-hex (same shared hex-text treatment as the
   readout's own hex value) - this only trims it one size down to fit a
   compact chip instead of a full palette-card label */
.tool-picker-history-hex{font-size:var(--text-xs)}

/* =====================================================================
   resume-builder swap (board row 348 follow-up): tools-engineer's
   registerGenerative() engine (formerly resume-writer.js, now
   public/js/tools/resume-builder.js) took over the `resume-builder`
   slug outright. resources/views/tools/partials/resume-builder*.blade.php
   - the ONLY source of every .rb-*/[data-rb-*] class and attribute that
   used to live in this whole range (toolbar, template gallery, section
   accordion, custom sections, photo, rich text, the zoom/preview pane,
   both its print-only blocks) - is deleted outright, not replaced: the
   new engine builds its whole DOM itself into [data-tool-options], the
   same shape as cover-letter-builder (tools/show.blade.php's
   $toolShape). Every one of those rules is genuinely unreachable now,
   not merely superseded, and is removed rather than left as dead weight
   (AI/10-AGENT-OPERATING-MANUAL.md: "dead rules have survived weeks
   here before"). The live résumé UI now styles via .rw-* (further down
   this file, the "plan 10b: resume-writer ..." section headers - kept
   under that name as real history, the same convention the engine file
   itself follows for its own ROUND 1-8 comments).

   Two things below survive the sweep because they were never rb-scoped
   in the first place: the dropzone hide (this tool still has no file
   input) and the generic print resets (.top/.skip + the html/body
   sizing reset, plus the shared resume-builder + cover-letter-builder +
   resume-writer ancestor-neutralisation block) - the new engine's own
   Export still goes through window.print(), so both are still needed.
   ===================================================================== */
[data-tool-slug="resume-builder"] .dropzone{display:none}


/* =====================================================================
   plan 55: cover-letter-builder page UI (frontend-ui, board row 203).

   SCOPE NOTE: not asked for in the literal dispatch (which named only the
   print CSS), but a real gap found while verifying that print CSS live:
   public/js/tools/cover-letter-builder.js (tools-engineer's file, shipped
   be2bec6) ships zero CSS for its own .cl-* preview classes - every field
   rendered as an identical, unstyled <p> (screenshot-verified before
   writing this). The print rule below has nothing coherent to preserve
   without this, so it is added here as ordinary tool-page UI (this
   persona's own "04-07 tools: every tool's page UI" ownership), disclosed
   in the handoff rather than silently expanded. Kept deliberately small:
   ~30 lines, tokens only, mirrors .rb-page's own proven "page" recipe
   (site.css ~L3918) rather than inventing a second one - same
   --*-page-pad-block/-inline custom-property shape, same modern-template
   accent-band bleed technique (negative margin equal to the page's own
   padding, matching border-start-*-radius to var(--r-tile)) - so a person
   who has seen the résumé builder recognises this tool's "page" at a
   glance. Body copy is var(--text-base)/1.65, not resume-builder's dense
   var(--text-sm)/1.55: a cover letter is prose (AI/07-UI-STANDARDS.md §2
   rule 6, "1.6-1.75 body"), not the résumé's dense-UI table/toolbar
   category.
   ===================================================================== */
.cl-page{
  --cl-page-pad-block: var(--s-40);
  --cl-page-pad-inline: var(--s-48);
  padding:var(--cl-page-pad-block) var(--cl-page-pad-inline);
  font-size:var(--text-base);line-height:1.65;color:var(--ink);
}
.cl-page-header{margin-bottom:var(--s-24)}
.cl-sender-name{font-size:var(--text-xl);font-weight:900;color:var(--cl-accent,var(--accent))}
.cl-sender-meta{font-size:var(--text-xs);color:var(--muted)}
.cl-date{margin-bottom:var(--s-20);font-size:var(--text-sm);color:var(--muted)}
.cl-recipient{margin-bottom:var(--s-20)}
.cl-recipient-line{font-size:var(--text-sm)}
.cl-salutation{margin-bottom:var(--s-16)}
.cl-body p{margin:0 0 var(--s-16)}
.cl-body p:last-child{margin-bottom:0}
.cl-closing{margin-top:var(--s-24)}
/* signature space: the gap a physical signature would sit in above the
   printed name, the same reasoning a real letter leaves blank lines here */
.cl-signoff{margin-top:var(--s-40);font-weight:700}

/* modern template: the exact accent-band-bleed technique .rb-page[data-
   rb-template="modern"] .rb-page-head already uses (site.css ~L4019) -
   negative margin equal to this page's own padding, re-padded to the same
   inline value, rounded top corners matching the page's own radius. Kept
   algebraically equal to --cl-page-pad-block/-inline for the same reason
   the résumé rule's own comment gives: they must stay equal or the bleed
   misaligns. */
.cl-page[data-cl-template="modern"] .cl-page-header{
  background:color-mix(in srgb, var(--cl-accent,var(--accent)) 12%, var(--tile));
  margin:calc(-1 * var(--cl-page-pad-block)) calc(-1 * var(--cl-page-pad-inline)) var(--s-24);
  padding:var(--s-24) var(--cl-page-pad-inline);
  border-start-start-radius:var(--r-tile);border-start-end-radius:var(--r-tile);
}
.cl-page[data-cl-template="modern"] .cl-sender-name{color:var(--ink)}

@page{
  /* @page cannot be scoped by a selector - this is necessarily global,
     for every printed page on the site, not just resume-builder.
     Deliberate: verified against the landing page and another tool's
     print output (personas/QA/handoffs/resume-export-fidelity-frontend-
     ui.md §7) that a fixed A4 margin (replacing the browser's own
     default paper margin) does not visibly break anything else that
     prints here. 6mm keeps a small paper edge while leaving room for
     whichever tool's own internal padding composes with it (kept from
     the original fix, resume-export-fidelity r1 - see AI/06-LESSONS.md
     for the "abspos never paginates" history that produced it). */
  size:A4;margin:6mm;
}
@media print{
  html:has([data-tool-slug="resume-builder"]),
  body:has([data-tool-slug="resume-builder"]){
    height:auto;overflow:visible;
  }
  body:has([data-tool-slug="resume-builder"]) .top,
  body:has([data-tool-slug="resume-builder"]) .skip{
    display:none!important;
  }
  /* .tool-crumb/.tool-head/.tool-howto/.tool-faq/.tool-guides/
     div:has(> .tool-related)/.site-foot, and the ancestor-neutralisation
     rule (.page/#main/.wrap/.tool-shell), live ONCE, in the shared
     "print: resume-builder + cover-letter-builder + resume-writer" block
     immediately below - not repeated here. */
}
@media print{
  /* board row 355 (frontend-ui, resume-writer): extended to a THIRD
     print-exporting generative tool alongside resume-builder/cover-
     letter-builder, the exact situation cover-letter-builder's own block
     comment below already anticipated ("if a THIRD print-exporting
     generative tool ships, a shared attribute-driven primitive... would
     be worth building then - two data points is not enough evidence to
     design it now"). Flagged in this build's handoff as now worth that
     engine-side contract change; not built here (public/js/tools/**
     is not this file's to edit). */
  body:has([data-tool-slug="resume-builder"]) .tool-crumb,
  body:has([data-tool-slug="cover-letter-builder"]) .tool-crumb,
  body:has([data-tool-slug="resume-writer"]) .tool-crumb,
  body:has([data-tool-slug="resume-builder"]) .tool-head,
  body:has([data-tool-slug="cover-letter-builder"]) .tool-head,
  body:has([data-tool-slug="resume-writer"]) .tool-head,
  body:has([data-tool-slug="resume-builder"]) .tool-howto,
  body:has([data-tool-slug="cover-letter-builder"]) .tool-howto,
  body:has([data-tool-slug="resume-writer"]) .tool-howto,
  body:has([data-tool-slug="resume-builder"]) .tool-faq,
  body:has([data-tool-slug="cover-letter-builder"]) .tool-faq,
  body:has([data-tool-slug="resume-writer"]) .tool-faq,
  body:has([data-tool-slug="resume-builder"]) .tool-guides,
  body:has([data-tool-slug="cover-letter-builder"]) .tool-guides,
  body:has([data-tool-slug="resume-writer"]) .tool-guides,
  body:has([data-tool-slug="resume-builder"]) div:has(> .tool-related),
  body:has([data-tool-slug="cover-letter-builder"]) div:has(> .tool-related),
  body:has([data-tool-slug="resume-writer"]) div:has(> .tool-related),
  body:has([data-tool-slug="resume-builder"]) .site-foot,
  body:has([data-tool-slug="cover-letter-builder"]) .site-foot,
  body:has([data-tool-slug="resume-writer"]) .site-foot{
    display:none!important;
  }
  /* the ancestor-neutralisation rule: everything between the printed
     "paper" (.rw-page / .tool-gradient-output>.cl-page) and the print
     root that would otherwise constrain or clip it - .page/.wrap
     establish containing blocks meant for the on-screen tool shell,
     never a printed document; #main/.tool-shell carry padding/background
     meant for the same on-screen chrome. No tool sharing this block has
     ever needed a different reset here - one rule covering all three
     slugs, not copies that would drift the moment someone "fixes" only
     one. */
  body:has([data-tool-slug="resume-builder"]) .page,
  body:has([data-tool-slug="cover-letter-builder"]) .page,
  body:has([data-tool-slug="resume-writer"]) .page,
  body:has([data-tool-slug="resume-builder"]) #main,
  body:has([data-tool-slug="cover-letter-builder"]) #main,
  body:has([data-tool-slug="resume-writer"]) #main,
  body:has([data-tool-slug="resume-builder"]) .wrap,
  body:has([data-tool-slug="cover-letter-builder"]) .wrap,
  body:has([data-tool-slug="resume-writer"]) .wrap,
  body:has([data-tool-slug="resume-builder"]) .tool-shell,
  body:has([data-tool-slug="cover-letter-builder"]) .tool-shell,
  body:has([data-tool-slug="resume-writer"]) .tool-shell{
    position:static;margin:0;padding:0;max-width:none;width:auto;
    overflow:visible;transform:none;box-shadow:none;background:none;
  }
  /* cover-letter-only, restored: the .rb-* dead-code sweep removed these
     two with the rest. Everything on this tool page is a flat sibling of
     .tool-options, so the hide rule is ":not(the node to keep)" rather
     than an enumerated class list; print-color-adjust keeps the modern
     template's accent band, which Chrome would otherwise drop. */
  body:has([data-tool-slug="cover-letter-builder"]) .tool-options > *:not(.tool-gradient-output){
    display:none;
  }
  /* the "paper" is .tool-gradient-output>.cl-page - print-color-adjust
     matches the resume-builder rule's own reasoning above (Chrome's print
     dialog defaults "Background graphics" off, which would otherwise
     silently drop the modern template's accent-tinted header band) and
     the card's on-screen rounded corners are removed since a printed page
     has no card to sit inside. */
  body:has([data-tool-slug="cover-letter-builder"]) .tool-gradient-output{
    print-color-adjust:exact;-webkit-print-color-adjust:exact;
    border-radius:0;
  }
}

/* =====================================================================
   wave 1 (specs 11-15): image crop / resize / rotate-flip / watermark /
   image-to-base64. Every class below is rendered by tools-engineer's
   engines (public/js/tools/{image-crop,image-resize,rotate-flip-image,
   image-watermark,image-to-base64}.js) - see the work order in
   personas/QA/handoffs/W1-image-tools-tools-engineer.md §9. Tokens only,
   both themes; the one new animation (position-grid cell swap) is added
   to the existing reduced-motion block above, not a new one.

   Layout policy for every wrapper below that stacks several `.tool-field`-
   shaped children vertically: use `display:grid`, never
   `flex-direction:column`. `.tool-options`/`.tool-success` are row flex
   containers that go column at 640px, and a child's own `flex:1 1 240px`
   (or any flex-basis) is silently reinterpreted as a HEIGHT hint the
   instant its parent's main axis turns vertical - the documented 0x0
   gradient-preview bug (AI/06-LESSONS.md §4). A grid container ignores
   its children's `flex` shorthand entirely (flex-basis only has meaning
   inside a flex formatting context), so this sidesteps the whole bug
   class instead of re-deriving px-vs-percent safety per case.
   ===================================================================== */

/* ---- batch list: the shared per-file row list image-resize,
   rotate-flip-image and image-watermark render into [data-tool-options],
   and image-to-base64 renders into the success panel as its result
   cards - a real per-file control surface (thumbnail + fields/toolbar/
   actions), same spirit as the existing PDF `.tool-file-list` but each
   row carries more than a name + remove button. ---- */
.tool-batch-list{display:grid;gap:var(--s-12);margin:0;padding:0;flex:1 1 100%;min-width:0}
/* resize's "apply this size to all images" button is the list's one
   non-row child - grid's default `justify-items:stretch` would else
   stretch a plain <button> to the full row width */
.tool-batch-list > .btn{justify-self:start}
.tool-batch-row{
  display:flex;flex-wrap:wrap;align-items:flex-start;gap:var(--s-12);
  padding:var(--s-16);border-radius:var(--r-tile);background:var(--tile);
}
/* fixed-size thumbnail box - the actual <img>/<canvas> the engines drop in
   already carries `.tool-preview-img` (width/height:100%, object-fit:cover,
   border-radius:inherit), so this only needs to give that box real
   geometry to fill (unstyled, `.swatch` alone is 0x0 - the same "flex
   item with no content and no explicit size" trap as the gradient
   preview, just via a missing rule instead of a misread flex-basis). */
.tool-batch-row .swatch{
  flex:none;width:64px;height:64px;border-radius:var(--r-tile);overflow:hidden;
  background:var(--wall);box-shadow:inset 0 0 0 1.5px var(--line);
}
/* image-to-base64's onerror fallback note (a format its own <img> can't
   preview, e.g. HEIC/TIFF) - shown in place of the thumbnail, never left
   as a blank box once the <img> hides itself */
/* design-tools-sizing-responsive-audit.md MINOR (AI/07-UI-STANDARDS.md §2:
   "hard floor, --text-2xs (11px), nothing smaller ships"): 9px was never a
   style choice, it's unreadable. --text-2xs is the smallest legal rung. */
.tool-batch-row .swatch p{margin:0;padding:var(--s-4);font-size:var(--text-2xs);line-height:1.3;color:var(--muted);text-align:center}
/* MINOR: 3 isn't a rung on the --s-* ladder (nearest are 2 and 4). */
.tool-batch-info{flex:1 1 200px;min-width:0;display:flex;flex-direction:column;gap:var(--s-4)}
.tool-batch-info strong{
  font-size:var(--text-sm);font-weight:700;color:var(--ink);
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
  /* a filename is user text, often Latin, inside a page that may be RTL -
     isolate it so it can't scramble reading order, same fix as
     .tool-file-row-name */
  unicode-bidi:isolate;
}
.tool-batch-info p{margin:0;font-size:var(--text-xs);color:var(--muted);line-height:1.5}
/* image-resize's per-row width/height/keep-ratio controls */
.tool-batch-fields{display:flex;flex-wrap:wrap;gap:var(--s-12);flex:1 1 100%;align-items:flex-end}
/* image-to-base64's Copy/Download row - its own full-width line under
   thumbnail+info rather than squeezed beside them */
.tool-batch-row > .tool-actions{flex:1 1 100%;margin-top:var(--s-4)}
/* explicit, plain-language warnings inline in a row's own copy
   (image-resize's upscale notice, image-watermark's 0%-opacity notice) -
   bold ink, never a second accent hue, matching the one-accent severity
   rule (AI/03-DESIGN-SYSTEM.md "severity by shape") */
.tool-row-warning{font-weight:700;color:var(--ink);font-size:var(--text-xs)}

/* ---- position/anchor grid: image-watermark's physical 3x3 picker.
   Deliberately forced LTR regardless of page direction: "bottom-right"
   names a literal corner of the photo's own pixels, not a logical/
   sequence position, so this must NOT mirror in RTL the way a CSS grid's
   auto-placement otherwise does for free (correct for a *sequence* like
   the video-to-frames grid above, wrong for a *physical* picker here -
   the one directional-concept exception this wave's spec calls out by
   name). Cell radius uses --r-tile at a size where it resolves to a full
   circle (radius >= half the box), so this stays inside the fixed radius
   set rather than inventing a new value for a "compass point" look. ---- */
.tool-position-grid{
  direction:ltr;
  display:inline-grid;grid-template-columns:repeat(3,1fr);gap:var(--s-6);
  padding:var(--s-6);border-radius:var(--r-tile);background:var(--wall);
  box-shadow:inset 0 0 0 1.5px var(--line);
}
.tool-position-cell{
  /* design-W1-image-tools-r1 F3: was a hard-coded 40x40 - under this
     wave's own stated 44px minimum tap target (AI/03-DESIGN-SYSTEM.md,
     "each has a 44px minimum tap target"). Raised to 44; the 3x3 grid
     (44*3 + 6*2 gap + 6*2 padding = 156px) still fits its container with
     room to spare at 375px. */
  position:relative;width:44px;height:44px;border:none;cursor:pointer;
  border-radius:var(--r-tile);background:none;padding:0;
  transition:background var(--dur-1) var(--ease);
}
.tool-position-cell::before{
  content:"";position:absolute;inset:0;margin:auto;width:7px;height:7px;
  border-radius:50%;background:var(--muted);
  transition:background var(--dur-1) var(--ease);
}
.tool-position-cell:hover{background:color-mix(in srgb, var(--ink) 8%, transparent)}
.tool-position-cell.is-active{background:var(--accent)}
.tool-position-cell.is-active::before{background:var(--accent-ink)}
.tool-position-cell:focus-visible{outline:2px solid var(--accent);outline-offset:2px}

/* ---- image-watermark's Text/Logo mode panels: plain unclassed <div>s
   (safe by construction as *block* containers - their own `.tool-field`
   children stack full-width in normal flow with no flex-basis to
   misread), but as flex ITEMS of [data-tool-options] they need their own
   explicit full-row width so they don't sit shrink-to-fit narrow beside
   nothing else on the row. ---- */
[data-iw-text-panel],[data-iw-logo-panel]{flex:1 1 100%;min-width:0}
/* the logo drop's own secondary file input carries no class in the
   engine (disclosed, not this seat's file) - a bare, unstyled
   input[type=file] falls back to the browser's own focus ring, the exact
   orange-second-accent-by-omission trap AI/06-LESSONS.md §5 already
   catalogues once on this project. */
[data-iw-logo-input]{font-family:inherit;font-size:var(--text-sm);color:var(--ink)}
[data-iw-logo-input]:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:var(--r-focus)}

/* ---- image-crop: the vendored cropperjs custom-element tree
   (<cropper-canvas>/<cropper-image>/<cropper-selection>/...) renders via
   Shadow DOM with its own inline styles - this file can only reach the
   host element's own box (clipping/background/spacing), which is enough
   since cropperjs paints its own crosshair/handles internally. The
   engine still inline-sizes the live <cropper-canvas> itself
   (width/height/max-height - disclosed in its own handoff as a
   functional stand-in, since no shared class existed until now); this
   rule supplies what an inline style can't reach: rounded corners
   clipped to the site's own tile radius, and a themed background while
   the image decodes. ---- */
[data-ic-crop-host]{
  flex:1 1 100%;margin-top:var(--s-2);
  border-radius:var(--r-tile);overflow:hidden;
  background:var(--wall);box-shadow:inset 0 0 0 1.5px var(--line);
}
/* Live measurement (RTL x dark x 375, then re-checked light x desktop)
   found the widget rendering at a broken 100px tall in both: cropperjs's
   `new Cropper(imgEl)` builds an entirely new <cropper-canvas> tree and
   does not carry over the width/height/max-height inline style the
   engine sets on the *original* <img> element beforehand (confirmed live:
   the rendered <cropper-canvas> has no `style` attribute at all) - so
   that sizing never reaches the element the browser actually paints.
   Flagged to tools-engineer as the real fix (style the element that
   exists after construction, not before); `!important` here is a
   deliberate, narrow override of a Shadow DOM custom element's own
   internal default sizing from outside its shadow root - the values
   match the engine's own already-disclosed intent (60vh / 520px cap)
   rather than inventing a new number. */
[data-ic-crop-host] cropper-canvas{
  display:block!important;width:100%!important;
  height:60vh!important;max-height:520px!important;
}
/* custom properties inherit through Shadow DOM boundaries by default, so
   this re-themes cropperjs's own internal handle/selection paint to the
   site's one accent (the spec's own vetted theming note) - no second
   colour introduced, nothing else to override. */
cropper-handle,cropper-selection{--theme-color:var(--accent)}
/* the crop tool's own ratio row / rotate-flip toolbar / crop host /
   output-format select, stacked - grid per the file-header policy above */
[data-ic-crop-ui]{display:grid;gap:var(--s-12);flex:1 1 100%;min-width:0}

/* ---- shared visually-hidden live region: rotate-flip-image and
   image-watermark both announce per-row state changes
   (`aria-live="polite"`) through this exact class name - identical
   recipe to `.sr-only` above, kept as its own selector because the
   engines (not this file) already shipped using this name. ---- */
.sr-only-live{
  position:absolute;width:1px;height:1px;padding:0;margin:-1px;
  overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0;
}

/* =====================================================================
   plan 10: analytics & privacy - the consent notice, and (r2) the privacy
   page's own "your consent choice" control. Tokens only, both themes
   automatically (every colour below is a token or a color-mix() of one),
   reduced-motion off-switch already added to the block above. The rest of
   the privacy page needs no CSS of its own - it reuses blog/show.blade.php's
   existing .article/.back/.prose classes verbatim.
   ===================================================================== */

/* non-modal overlay: `pointer-events:none` on the wrapper means the page
   underneath stays fully clickable/scrollable while this shows - same
   technique as .locale-modal above, different corner (bottom-start instead
   of bottom-centre, so the two never compete for the same spot on a
   first-ever visit to a non-default locale, where both could theoretically
   show at once) */
.consent-notice-wrap{
  position:fixed;inset:0;z-index:var(--z-nav);
  display:flex;align-items:flex-end;justify-content:flex-start;
  padding:var(--s-24);pointer-events:none;
}
/* r5 (design-tools-sizing-responsive-audit.md BLOCKER - the notice covering
   a tool's own Run button on first visit, 9/20 tools, both 375 and 1440):
   site.js measures a REAL collision between the notice's default bottom-
   start footprint and the page's own primary widget ([data-tool-shell])
   and, only when one is found, tries two escapes in order - the notice is
   the thing that moves; page content is never pushed (r4's history above
   is exactly why not).
   1. `.is-shifted` - stays on the same (bottom) edge, flips to the
      opposite horizontal corner. Zero side effects, but only geometrically
      possible once the notice has stopped being full-width (>640px). */
.consent-notice-wrap.is-shifted{justify-content:flex-end}
/* 2. `.is-elevated` - the fallback when a shift alone can't clear it (a
   narrow, full-width viewport): top-start, below the sticky nav.
   --consent-notice-top-offset is the nav's own measured bottom edge
   (site.js), so this tracks the nav's real height including its mobile
   wrap-to-two-rows state - never a guessed px. */
.consent-notice-wrap.is-elevated{
  align-items:flex-start;
  padding-top:calc(var(--consent-notice-top-offset, 68px) + var(--s-16));
}
.consent-notice{
  pointer-events:auto;max-width:400px;width:100%;
  padding:var(--s-24);border-radius:var(--r-tile);
  background:var(--tile);color:var(--ink);
  box-shadow:0 24px 60px -20px color-mix(in srgb, var(--shadow-leaf) 65%, transparent);
  display:flex;flex-direction:column;gap:var(--s-16);
  opacity:0;
  animation:consentNoticeIn var(--dur-3) var(--ease) forwards;
}
/* [hidden] cascade guard - same bug family as .tool-segmented above, found
   by the same sweep. site.js's hideConsentNotice() adds `.is-dismissed`
   (fades it out, matching the r4 keyframe note just below) and only then,
   after the transition, sets `.hidden = true` - the step meant to pull the
   dismissed notice out of the tab order and the accessibility tree for
   good. Without this rule `display:flex` above beat the UA's own
   `[hidden]{display:none}` at equal specificity, so `.is-dismissed`'s
   `opacity:0;pointer-events:none` made it invisible and unclickable but
   NOT untabbable - a dismissed notice's Accept/Decline buttons stayed
   keyboard-focusable forever afterward, the exact "hidden overlay stays
   Tab-reachable" trap already caught once on an off-canvas sheet
   (AI/06-LESSONS.md). No visual change: by the time `.hidden` flips,
   `.is-dismissed` has already made the card fully transparent. */
.consent-notice[hidden]{display:none}
/* r4: the 18px rise lives in the keyframe's own `from`, never as a resting
   `transform` on the element. It used to sit on `.consent-notice` itself,
   which the reduced-motion block 1100 lines above (`transform:none`, same
   specificity, earlier in the file) could not win against - so a
   reduced-motion visitor, whose swapped fade-only animation never touches
   `transform`, kept the 18px offset forever and the card hung 6px past the
   bottom of the viewport. Keeping the offset inside the keyframe means the
   resting position is correct whether the animation runs, is swapped, or
   never plays at all. */
@keyframes consentNoticeIn{from{opacity:0;transform:translateY(18px)}to{opacity:1;transform:none}}
.consent-notice p{margin:0;font-size:var(--text-sm);line-height:1.6}
.consent-notice a{color:var(--accent);font-weight:700}
.consent-notice-actions{display:flex;flex-wrap:wrap;gap:var(--s-10)}
.consent-notice-actions .btn{min-height:44px}
/* accept/decline post to /consent then hide without a reload (site.js);
   this is the "leaving" transition, matching .locale-modal-card.is-leaving's
   own shape one section up */
.consent-notice.is-dismissed{
  opacity:0;transform:translateY(18px);pointer-events:none;
  animation:none;transition:opacity var(--dur-2) var(--ease),transform var(--dur-2) var(--ease);
}
/* r4 (coordinator revert of r3's design-10-analytics-r1.md F1/F2 "fix"):
   r3 added a `body:has(...) .tool-shell,.post-grid{margin-top:...}` rule
   plus a JS/inline-script loop that kept inflating --consent-notice-h until
   an overlap test read zero. Both are gone. The notice is fixed to the
   viewport BOTTOM - `margin-top` on content pushes it DOWN, i.e. further
   toward the notice, never away from it. The inflating loop "worked" only
   by shoving the run button clean off the bottom of the screen, at which
   point the two rectangles no longer intersected and the loop's own metric
   read 0 - a passing measurement for a button that no longer existed on
   screen. See personas/QA/handoffs/10-analytics-frontend-ui.md's r4
   addendum for the full account.

   r4 believed two pieces were enough: (1) shrink the notice at mobile so
   it never reaches as high as a button sitting at "~y:612-669", and (2)
   reserve --consent-notice-h as `padding-bottom` so a short page's true
   last content can always be scrolled clear once fully scrolled. Both are
   still correct and still kept below/here - but r4 scoped BOTH to
   `max-width:640px` and (1)'s own "~y:612-669" premise was only checked
   against one tool. design-tools-sizing-responsive-audit.md measured the
   real button position across all 20 tools and found it routinely sits
   much closer to the viewport's own bottom edge than that (e.g. y:776-833
   in an 812px-tall viewport) - inside ANY nonzero-height fixed bottom
   bar's footprint, no matter how far it shrinks. And `padding-bottom` on
   `body` cannot fix a first-paint (scrollY:0) overlap at all: normal block
   flow lays out every element from the top down, so trailing padding at
   the document's end never moves anything ABOVE it - it only helps once a
   page is scrolled all the way down. Reproduced blocked at 375 AND 1440,
   scoped media query aside - see the r5 fix on `.consent-notice-wrap`
   above, which is the piece that actually closes the first-paint case. */
@media (max-width:640px){
  .consent-notice-wrap{padding:var(--s-12)}
  .consent-notice{
    max-width:none;
    padding:var(--s-8) var(--s-12);
    gap:var(--s-8);
  }
  .consent-notice p{font-size:var(--text-xs);line-height:1.45}
  .consent-notice-actions{flex-wrap:nowrap;gap:var(--s-8)}
  /* the 44px floor comes from the base rule's min-height and is the reason
     this padding can drop: the buttons stay 44px tall regardless. */
  .consent-notice-actions .btn{flex:1 1 0;padding:var(--s-8) var(--s-12)}
}
/* r5: unscoped from the old `max-width:640px` block above - a short page
   can be short at ANY width, not just mobile (design-tools-sizing-
   responsive-audit.md caught this failing at 1128/1440 too). Still exactly
   the r4-documented behaviour: trailing scroll room only, never a push. */
body:has([data-consent-notice]:not(.is-dismissed)){
  padding-bottom:var(--consent-notice-h,0px);
}
/* board row 269: the top-anchored counterpart to the padding-bottom rule
   above - a real, confirmed cross-cutting defect, not scoped to one tool.
   site.js's own collision-avoidance (the plan 10 (r6) block) already tries
   two escapes for the notice before falling back to `.is-elevated`
   (top-start, below the nav/head); on any tool whose options panel starts
   close under the head - cubic-bezier and clip-path measured live at ~72px,
   `.tool-gradient-preview` sitting immediately after - that fallback is
   shorter than the notice itself (~126-230px) and settles directly on top
   of the panel's own first real controls. Confirmed live: a real
   `elementFromPoint` on cubic-bezier's/clip-path's own drag handles at
   375px resolved to the notice's Decline button, not the handle
   underneath it - this is not cosmetic overlap, it is a real dead click.

   This pushes the WHOLE tool shell down by the notice's own measured
   height, gated to the exact moment `.is-elevated` is the live state -
   never touching the default/`.is-shifted` (bottom-anchored) cases the
   padding-bottom rule above already owns. This is not the r4-reverted r3
   mistake (search "r3 (design gate fix" above): r3 pushed content DOWN
   while the notice sat at the viewport BOTTOM, which moves content TOWARD
   a bottom-anchored notice and only ever "passed" by shoving the real
   button off-screen. Here the notice is TOP-anchored, so pushing content
   down moves it AWAY - opposite geometry. See the matching comment on
   `wrapEl.classList.add('is-elevated')` in site.js for why this fully
   clears by construction rather than by guessing a number. */
body:has(.consent-notice-wrap.is-elevated) .tool-shell{
  margin-top:var(--consent-notice-h,0px);
}
@media print{
  /* board row 357 sixth follow-up (frontend-ui, resume-writer): Qandil -
     "export pdf have padding top and bottom = waste of spacing like
     padding not margin bcoz i try using modern design and color of
     background was not there its like waste of space." Not a print-
     color-adjust bug at all - proved by reading back a REAL page.pdf(),
     not the print stylesheet (AI/10's own rule): the modern header's
     accent band WAS painted (`print-color-adjust:exact` already correct,
     already inherited from `.rw-page`, confirmed by computed style under
     real `emulateMedia({media:'print'})`), it was just pushed ~191px down
     the page by blank white space above it, which is why a low-contrast
     band reading like "no background" was really "the background is
     there, just scrolled past the fold onto what looks like padding."

     Root cause: the rule immediately above this block, unscoped to any
     one tool and NOT scoped away from print. `.consent-notice-wrap`
     already goes `display:none` in print (sitewide print hide-list,
     above) - but that only removes the banner itself, never this
     SEPARATE rule that compensates for it in normal document flow by
     pushing `.tool-shell` down. Its specificity - `body:has(2 classes)
     .tool-shell` = (0,3,1) - beats even resume-builder/cover-letter-
     builder/resume-writer's own print-only ancestor-neutralisation
     `.tool-shell{margin:0}` two blocks up ((0,2,1): `body:has(1
     attribute) .tool-shell`), so it wins regardless of source order.
     Confirmed live in a fresh (no consent decision yet) Playwright
     context, the exact moment `.is-elevated` is live: under print media,
     `.consent-notice-wrap` computed `display:none` exactly as intended,
     while `.tool-shell`'s own `margin-top` still read the full `191px`
     (`--consent-notice-h`, board row 269's own measured figure) - the
     banner is gone, the space it reserved for itself on screen is not.

     Sitewide, not resume-writer-only, on purpose: `body:has(...)
     .tool-shell` reaches every tool page, not just the three that export
     via `window.print()` - any tool printed before a visitor accepts or
     declines consent inherits the same unwanted gap, just less visible
     on a page with no accent-tinted header to reveal it. Fixing it here,
     next to the rule that causes it, covers resume-builder and cover-
     letter-builder for free rather than needing a third per-tool copy of
     the same one-property override. Print never legitimately wants an
     on-screen banner's elevation state affecting document flow, so this
     is unconditional (no `[data-tool-slug]` gate), not a workaround for
     one tool's PDF. */
  body:has(.consent-notice-wrap.is-elevated) .tool-shell{margin-top:0}
}

/* r2: the privacy page's own "your consent choice" control - an in-flow
   card inside .prose (resources/views/pages/privacy.blade.php), not an
   overlay, so no fixed positioning/pointer-events dance needed here.
   `border-inline-start` (not `.prose .callout`'s pre-existing `border-left`)
   so this one new component mirrors correctly under RTL from day one. */
.consent-control{
  /* px-and-breakpoints: 22px sat exactly between --s-20/--s-24; rounded up
     (+2px, symmetric with the horizontal value once tokenised). */
  margin:var(--s-8) 0 var(--s-20);padding:var(--s-24);border-radius:var(--r-tile);
  background:var(--tile);border-inline-start:3px solid var(--accent);
}
.consent-control p{margin:0 0 var(--s-16);color:var(--muted);font-size:var(--text-sm);line-height:1.6}
.consent-control-actions{display:flex;flex-wrap:wrap;gap:var(--s-10)}
.consent-control-actions .btn{min-height:44px}

/* =====================================================================
   plan 11: tool blog clusters - the "Guides" section on a tool page
   (below how-to/FAQ), the guides index (/{locale}/tools/{tool}/guides)
   and a single guide's page. A guide is post-shaped, so its card reuses
   blog/index.blade.php's .post-card/.meta/.read classes verbatim - the
   same idea as pages/privacy.blade.php reusing .article/.back/.prose for
   a non-blog page. Only the grid wrapper below is new, sized like
   .tool-related's own 3-col/1-col responsive shape (see the 820px
   breakpoint further up, extended for this class too). The hub CTA
   reuses .article-end/.cta verbatim; the guide body reuses .article/
   .prose verbatim; the empty state reuses .blog-empty verbatim; a guide
   page's "related tools" reuses .tool-related/.tool-card verbatim,
   identical to tools/show.blade.php's own block. Tokens only, both
   themes automatic (every value below is a token), one accent - nothing
   here introduces a new colour.
   ===================================================================== */
.tool-guides{margin-bottom:var(--s-48)}
.tool-guide-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--s-16)}
/* .post-card was sized for .post-grid's 2-up blog layout; slightly
   tighter padding and a smaller, un-clamped title read better at this
   grid's narrower 3-up card width - everything else (.meta/p/.read) is
   reused completely unchanged.

   The inline 22px is deliberate and is the one raw pixel here: the scale
   jumps --sp-5:24px -> --sp-6:32px, and 32px is visibly too wide for a
   3-up card at this width while 24px on both axes loses the slight
   horizontal tightening the narrower column wants. Recorded as signal
   about the scale, per the checklist's rule, not as licence. */
.tool-guide-grid .post-card{padding:var(--s-24) 22px}
.tool-guide-grid .post-card-title{font-size:var(--text-lg);margin:0;letter-spacing:-.015em;max-width:none}
@media (max-width:820px){
  .tool-guide-grid{grid-template-columns:1fr}
}

/* =====================================================================
   board row 185 (frontend-ui): guide-card thumbnails. Every guide-card
   surface (this tool's own "Guides" section above, the guides index, a
   guide's sibling grid) only ever shows one tool's own three angles
   (why/how-to/use-case, Post::cluster_role) - never two different tools
   side by side - so a thumbnail derived from the tool's own icon alone
   would repeat the identical picture three times on one screen. The
   design review at personas/QA/reports/post-thumbnail-shape.md flagged
   exactly that and recommended no thumbnail at all; Qandil overruled it
   and asked for thumbnails, so this is built to survive that same
   objection: resources/views/blog/banners/guide-{why,howto,usecase}.blade.php
   are three role-specific scenes (rays / waypoints-to-a-mark / a
   viewfinder), reused by every tool and every locale, each composing the
   requesting tool's own #ti-{slug} icon (via $tool->iconSymbol(), passed
   in by the @include at each of the three render sites) so the thumbnail
   still says "this tool" while the surrounding scene says "this angle".
   Resolved with blog/index.blade.php's own view()->exists() + @include
   mechanism, keyed by cluster_role instead of post slug - no new column,
   no per-post art (that was the 126/165-bespoke-SVGs option the same
   report rejected on cost). Every element reuses an existing .bn-*
   class/keyframe (.bn-sun/.bn-ring/.bn-done/.bn-sway/.bn-flow's siblings),
   all already covered by the sitewide reduced-motion block below - only
   one new rule is needed, to make the tool icon's own currentColor
   resolve to the banner's ink token instead of inheriting page text
   colour.
   ===================================================================== */
.bn-tool-ic{color:var(--bni)}

/* =====================================================================
   wave 2 (specs 16-20): pdf-split / pdf-rotate / pdf-organize /
   pdf-page-numbers / pdf-metadata. Every class/attribute below is
   rendered by tools-engineer's engines (public/js/tools/{pdf-split,
   pdf-rotate,pdf-organize,pdf-page-numbers,pdf-metadata}.js) - see the
   work order in personas/QA/handoffs/W2-pdf-tools-tools-engineer.md §9.
   Four of the five tools reuse already-shipped classes wholesale with NO
   new CSS at all for most of their controls (.tool-frame-grid/.tool-frame/
   .tool-frame-num from video-to-frames, .tool-position-grid/
   .tool-position-cell from image-watermark, .tool-field/.tool-select/
   .tool-check/.tool-segmented/.tool-row-warning from the shared option
   controls) - pdf-metadata needs NO new CSS whatsoever (a plain field
   form, same recipe as every other tool's [data-tool-options]). What
   follows is only the handful of genuinely new, previously-unstyled hooks
   the handoff names: the per-thumbnail select toggle (split), the
   per-thumbnail action rows (rotate/organize), and the single-page
   preview + its overlay badge (page-numbers). No transition/animation is
   added anywhere below (every new state swap is instant, static by
   construction, matching the precedent already set for .tool-frame-num's
   own badge) - so nothing here is owed a reduced-motion off-switch.
   Tokens only, both themes automatic. ===================================================================== */

/* ---- pdf-split: the per-thumbnail "select this page for Extract mode"
   toggle. A real <button aria-pressed>, absolutely positioned in the
   thumbnail's opposite corner from `.tool-frame-num` so the two badges
   never collide. Sized to the site's 44px tap-target floor even though
   the thumbnail cell itself is much smaller - there is room (the base
   `.tool-frame-grid` track floor is 96px, comfortably more than double
   the badge) so there is no tension with that floor here, unlike
   organize/rotate's multi-button rows below. `outline-offset:-2px` on
   focus keeps the accent ring inside the button's own box: `.tool-frame`
   clips overflow (by design, for the thumbnail's rounded corners), and a
   ring drawn *outward* at the default offset would be partially clipped
   at the cell edge - same fix already used by `.rb-section-head`
   elsewhere in this file for the same "focusable control inside a
   clipped container" shape. ---- */
[data-ps-select]{
  position:absolute;inset-block-start:var(--s-4);inset-inline-end:var(--s-4);
  width:44px;height:44px;min-width:0;padding:0;
  border-radius:var(--r-pill);
  display:inline-flex;align-items:center;justify-content:center;
  font-size:var(--text-md);font-weight:700;line-height:1;
  background:color-mix(in srgb, var(--ink) 45%, transparent);
  color:color-mix(in srgb, var(--wall) 55%, transparent);
  box-shadow:none;
}
/* selected: solid accent fill on the badge (matching `.tool-segmented-btn
   .is-active`'s own accent-fill recipe for an active/chosen state) plus a
   ring around the whole thumbnail, so a selected page reads clearly even
   at a glance across the grid, not just on the small corner badge. */
[data-ps-select][aria-pressed="true"]{background:var(--accent);color:var(--accent-ink)}
[data-ps-select]:focus-visible{outline-offset:-2px}
[data-ps-grid] .tool-frame.is-selected{box-shadow:inset 0 0 0 3px var(--accent)}

/* ---- pdf-rotate / pdf-organize: per-thumbnail action rows. These carry
   real interactive controls (rotate left/right; move earlier/later plus
   delete-or-undo) that the plain video-to-frames contact sheet never
   needed, so their grid gets a wider minimum track than the base
   `.tool-frame-grid` (96px) - 160px, which comfortably fits three 44px
   buttons with token-spaced gaps, and still wraps cleanly to fewer columns
   (often one) at 375px with no manual breakpoint, same auto-fill/minmax
   technique the base grid already uses.

   RESOLVED (board row 80): this was previously
   `calc(var(--s-128) + var(--s-32))`, which computes to exactly 160px but
   mixes two spacing tokens - banned outright by AI/07-UI-STANDARDS.md §1
   rule 2, flagged as an open question in
   personas/QA/handoffs/spacing-migration-close-frontend-ui.md §7 rather
   than resolved unilaterally, because the other route (visibly resizing
   the track) changes rendered layout and isn't this slice's call alone.
   Route taken: added `--s-160` to the ladder (same precedent as `--s-56`)
   and pointed this rule at it directly - a pure rename, not a resize:
   128px + 32px was already 160px, so the computed minmax() value is
   unchanged (verified live against getComputedStyle, before and after).
   AI/07-UI-STANDARDS.md §1 and DESIGN.md §3 still need the same ledger
   entry `--s-56` got; that edit is outside this persona's file ownership
   (AI/** and DESIGN.md belong to docs-steward) and is named in the
   handoff instead of made here. ---- */
[data-po-grid],[data-pr-grid]{
  grid-template-columns:repeat(auto-fill,minmax(var(--s-160),1fr));
}
/* the action row is an always-visible (not hover-only) overlay strip
   pinned to the thumbnail's own bottom edge - "always visible" because
   these are the tool's primary controls and must be keyboard-discoverable
   without a hover state, per each spec's own accessibility section. Since
   the strip now owns the bottom corner, `.tool-frame-num`'s badge moves to
   the top corner here (its `inset-block-end` from the shared rule is
   cancelled) so the two never overlap. */
[data-po-grid] .tool-frame-num,[data-pr-grid] .tool-frame-num{
  inset-block-end:auto;inset-block-start:var(--s-4);
}
[data-po-page-actions],[data-pr-page-actions]{
  position:absolute;inset-inline:0;inset-block-end:0;
  display:flex;flex-wrap:wrap;align-items:center;justify-content:center;
  gap:var(--s-4);padding:var(--s-4);
  background:color-mix(in srgb, var(--ink) 55%, transparent);
  border-radius:0 0 var(--r-tile) var(--r-tile);
}
/* light content on the dark overlay strip (same "inverted surface" need
   `.locale-modal-card`'s own buttons already solved - reused here, not
   reinvented): wall-toned text/icon, and a wall-toned hover ring instead
   of `.btn-ghost`'s default ink one, which would be invisible against an
   ink-tinted background. min-width/min-height (not a fixed square) so the
   glyph buttons (←/→/↺/↻/×) sit at the 44px floor while organize's wider
   "Undo" text label can still grow past it instead of clipping. */
[data-po-page-actions] .btn,[data-pr-page-actions] .btn{
  background:transparent;color:var(--wall);box-shadow:none;
  min-width:44px;min-height:44px;padding:0 var(--s-8);
}
[data-po-page-actions] .btn-ghost:hover,[data-pr-page-actions] .btn-ghost:hover{
  box-shadow:inset 0 0 0 1.5px var(--wall);
}
[data-po-page-actions] .btn svg,[data-pr-page-actions] .btn svg{width:16px;height:16px}
/* same clipped-container focus-ring fix as `[data-ps-select]` above -
   `.tool-frame`'s own `overflow:hidden` would otherwise clip an
   outward-drawn ring at the cell edge. */
[data-po-page-actions] .btn:focus-visible,[data-pr-page-actions] .btn:focus-visible{
  outline-offset:-2px;
}
/* pdf-rotate's live preview, the one real bug this wave's manual check
   caught: the engine rotates the thumbnail canvas in place via a plain
   `canvas.style.transform='rotate(90deg|270deg)'` (verified live - it
   always sets a transform, `rotate(0deg)` included, never leaves it
   unset). At `.tool-frame`'s own 4:3 box, a 90/270 rotation swaps the
   canvas's rendered footprint to 3:4, taller than the frame - and
   measured live in this exact environment, `.tool-frame`'s
   `overflow:hidden` does NOT reliably clip that oversized rotated canvas
   at real thumbnail sizes (confirmed with a minimal, isolated repro
   outside any of this wave's own markup: a plain rotated <canvas> inside
   a same-sized `overflow:hidden` box clips correctly at ~100x75px but
   visibly bleeds past the box at ~200x150px and up - `contain:paint` and
   `clip-path:inset(0)` were both tried on the same repro and neither
   fixed it either, so this is a genuine clip-of-a-transformed-canvas
   limitation in this environment, not a fixable one-line CSS toggle).
   The robust fix sidesteps clipping entirely instead of depending on it:
   shrink the canvas to 75% (the exact `min(w/h,h/w)` factor for a 4:3 box
   - the largest size whose rotated 3:4 footprint still fits inside the
   original 4:3 frame with zero overflow, proven by measurement) and
   centre it with the standard absolute+inset:0+margin:auto recipe, so
   the rotated preview structurally fits the frame regardless of how any
   given browser's transform-clipping behaves. Scoped narrowly to the two
   rotation values that actually swap the aspect ratio via an attribute
   selector on the engine's own inline style string - `rotate(0deg)`/
   `rotate(180deg)` (the common, not-rotated-this-way case) are
   deliberately untouched and keep the full-bleed 100%/100% thumbnail. */
[data-pr-grid] canvas.tool-preview-img[style*="rotate(90deg)"],
[data-pr-grid] canvas.tool-preview-img[style*="rotate(270deg)"]{
  position:absolute;inset:0;margin:auto;
  width:75%;height:75%;
}
/* pdf-organize: a soft-deleted page (reversible via its own Undo button,
   per the spec's "never vanish instantly" requirement) dims rather than
   disappearing - the engine already swaps its number badge to "x", so
   dimming plus that glyph change together read clearly as "removed,
   undoable" without needing a literal strikethrough line. */
[data-po-grid] .tool-frame.is-deleted{opacity:.4}

/* ---- pdf-page-numbers: the single-page-1 live preview and its overlay
   number badge. The engine inline-styles only the two things that are
   genuinely per-option dynamic values (the wrapper's `position:relative`
   host role, and the badge's top/bottom/left/right/transform, which move
   with the chosen corner) - everything else, including the one real
   layout fix this preview needs, lives here. `.tool-preview-img`'s shared
   default (width/height:100%, object-fit:cover) is built for a
   fixed-aspect grid CELL like `.tool-frame`; this is a single, much
   larger, full-page preview with no such fixed box, so width/height:100%
   would ask the canvas to fill an ancestor with no definite size and
   object-fit:cover would crop the page's own edges - overridden to the
   canvas's own intrinsic size (capped by max-width so a full-resolution
   page render can never overflow a 375px column, the exact bug class
   AI/06-LESSONS.md §4 already catalogues twice). */
[data-pn-preview]{flex:1 1 100%;max-width:100%}
[data-pn-preview] .tool-preview-img{
  width:auto;height:auto;max-width:100%;display:block;
  border-radius:var(--r-tile);background:var(--wall);
  box-shadow:inset 0 0 0 1.5px var(--line);
}
[data-pn-preview-badge]{
  display:inline-flex;align-items:center;justify-content:center;
  min-width:26px;height:26px;padding:0 var(--s-8);
  border-radius:var(--r-pill);
  background:color-mix(in srgb, var(--ink) 62%, transparent);
  color:var(--wall);
  font-size:var(--text-xs);font-weight:700;line-height:1;
  white-space:nowrap;pointer-events:none;
  /* the stamped text is always Western-numeral ASCII, in every locale
     including all five Arabic-tagged ones (spec 19's own scoped decision,
     enforced in the engine, never routed through ctx.t()). Verified live
     in ar-EG: `unicode-bidi:isolate` alone (the `.tool-frame-num`
     treatment) is NOT enough here and reversed "1 / 3" to "3 / 1" -
     `.tool-frame-num` only ever holds a single token (one number), where
     isolate alone is trivially safe; this badge can hold multi-token
     content ("1 / 3", "Page 3"), which needs the stronger, already-
     settled treatment for that class of value (`.tool-file-row-meta`'s
     own "3 pages" pattern): explicit `direction:ltr` alongside isolate.
     Safe to add here specifically because this badge is positioned with
     physical top/bottom/left/right (never inset-inline-*), so it doesn't
     hit the logical-property sharp edge LESSONS §3 warns about for an
     element that carries logical positioning. */
  direction:ltr;unicode-bidi:isolate;
}

/* =====================================================================
   tool-feedback: like, trust indicator, comments, 1-5 star rating.
   Ad hoc slice, no numbered plan (Qandil's own request - see
   personas/QA/handoffs/tool-feedback-laravel-backend.md and the
   "CORRECTED r2" note on the trans_choice() key shapes). Tokens only,
   both themes, transform/opacity motion with reduced-motion off-switches
   added to the existing reduced-motion block above (search "tool-feedback"
   there). Reuses the blog's generic `.c-*`/`.comments`/`.field-error`
   classes for the comment list/form rather than re-declaring them - only
   what is genuinely new to this feature gets a new class.
   ===================================================================== */

/* ---- like + trust indicator, shown right under the H1/description ---- */
.tool-feedback-bar{
  display:flex;align-items:center;flex-wrap:wrap;
  gap:var(--s-16);
  margin-bottom:var(--s-28);
}
/* ===== love-dock-on-tools (ad hoc, Qandil's own request - no numbered
   plan; see personas/QA/handoffs/love-dock-on-tools-frontend-ui.md): the
   old bespoke .tool-like control (a plain --accent icon button, deliberately
   NOT a copy of blog's .love-btn because that component's heat ramp was
   then still hard-coded hex - AI/07-UI-STANDARDS.md S5 breach 1) is gone.
   That breach is closed (--heat-0..5 tokens, both themes, art-only
   exception the same way --sk* and --bark* are - see :root's own comment),
   so the reason not to reuse .love-btn no longer applies. tools/show.blade
   .php now includes the same shared partials/love-dock.blade.php blog
   uses, unchanged, wrapped in .tool-feedback-bar below. =====

   .love-dock's own default rendering (site.css ~1046) is `position:fixed`
   and floats beside .article's 720px column at >1020px - tuned specifically
   for that column's width. .wrap (every tool page's container) is 1080px,
   not 720px: measured live at 1440px viewport, .article's left edge sits
   at 360px and the floating dock's left:242px lands 118px into its own
   margin (correct); .wrap's left edge sits at only 180px, so the same
   left:242px would land 62px INSIDE the tool page's own content - a real
   overlap, not a hypothetical one. Scoping the always-bar layout to
   ".tool-feedback-bar .love-dock" (rather than adding a modifier class the
   partial would need a layout param for) means the partial never has to
   know its own page's layout, and blog's markup and CSS selectors are
   untouched either way. Every number below is copied verbatim from
   .love-dock's own proven <=1020px collapse (site.css, the "narrow
   screens" comment above it) - not reinvented, so the tool dock looks
   identical to blog's own mobile bar at every width. */
.tool-feedback-bar .love-dock{
  position:static;left:auto;top:auto;right:auto;bottom:auto;transform:none;
  flex-direction:row;flex-wrap:wrap;justify-content:center;
  /* slice (spacing): same 18px tie as .love-dock's own <=1020px collapse
     above, resolved the same way (rounded down to --s-16, not the file's
     usual round-up, to avoid wrapping the contact icon onto its own line -
     see that block's comment) - these numbers are a deliberate verbatim
     copy of that block, per the comment above this rule. This is the more
     load-bearing of the two instances: it applies at every viewport width
     on every tool page, not just <=1020px. */
  gap:var(--s-10);margin:0;padding:var(--s-14) var(--s-16);max-width:420px;
  background:color-mix(in srgb, var(--tile) 82%, transparent);
  border-radius:var(--r-tile);
  box-shadow:
    inset 0 0 0 1px color-mix(in srgb, var(--line) 90%, transparent),
    0 12px 30px -14px var(--shadow-leaf);
}
.tool-feedback-bar .love-btn{width:52px;height:52px}
.tool-feedback-bar .love-heart{width:22px;height:20px}
/* slice (spacing): same -38px rounding as the <=1020px block above (-2px). */
.tool-feedback-bar .love-combo{top:calc(-1 * var(--s-40));bottom:auto}
.tool-feedback-bar .love-dock.streak .love-combo{transform:translateX(-50%) translateY(0) scale(1)}
.tool-feedback-bar .dock-sep{width:1px;height:26px;margin:0 var(--s-4)}
.tool-feedback-bar .dock-btn{width:44px;height:44px;background:transparent;box-shadow:none}
.tool-feedback-bar .dock-btn:hover{box-shadow:none}
.tool-feedback-bar .dock-btn svg{width:20px;height:20px}
.tool-feedback-bar .dock-btn::after{left:50%;right:auto;top:auto;bottom:calc(100% + var(--s-10));transform:translateX(-50%) translateY(6px)}
.tool-feedback-bar .dock-btn:hover::after{transform:translateX(-50%) translateY(0)}

.tool-trust{
  display:flex;align-items:center;flex-wrap:wrap;gap:var(--s-6);
  font-size:var(--text-sm);color:var(--muted);
}
.tool-trust-rating{display:inline-flex;align-items:center;gap:var(--s-4)}
.tool-trust-star{width:var(--ic-16);height:var(--ic-16);color:var(--accent);fill:currentColor;flex:none}

/* ---- per-comment optional rating badge (comment list reuses .c-*) ---- */
.c-rating{
  display:inline-flex;align-items:center;gap:var(--s-4);
  margin-inline-start:var(--s-8);
  padding:var(--s-2) var(--s-8);border-radius:var(--r-pill);
  background:color-mix(in srgb, var(--accent) 14%, var(--wall));
  color:var(--accent);font-weight:700;font-size:var(--text-2xs);
  vertical-align:middle;
}
.c-rating svg{width:var(--ic-14);height:var(--ic-14);fill:currentColor}

/* ---- comment form: 1-5 star rating radiogroup ---- */
.tool-rating-input{margin-bottom:var(--s-4)}
.tool-rating-label{display:block;font-size:var(--text-sm);font-weight:700;margin-bottom:var(--s-6)}
.tool-rating-group{display:flex;gap:var(--s-2)}
.tool-rating-star{
  width:var(--h-control);height:var(--h-control);
  display:inline-flex;align-items:center;justify-content:center;
  border:none;background:none;cursor:pointer;border-radius:var(--r-pill);
  color:var(--muted);
  transition:transform var(--dur-1) var(--ease);
  -webkit-tap-highlight-color:transparent;
}
.tool-rating-star svg{width:var(--ic-20);height:var(--ic-20)}
.tool-rating-star:hover{color:var(--accent);transform:translateY(-1px)}
.tool-rating-star:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:var(--r-focus)}
.tool-rating-star.is-active{color:var(--accent)}
.tool-rating-star.is-active svg{fill:currentColor}
.tool-rating-status{margin-top:var(--s-6);font-size:var(--text-xs);color:var(--muted)}

/* ---- comments section wrapper: reuses .comments/.c-* verbatim; this
   class only exists so .rv scroll-reveal + margin-top can key off it
   without touching the blog's own .comments rule. ---- */
.tool-comments{margin-top:0}

/* =====================================================================
   plan tools/w4 (specs 21-26): the two CSS hooks the six text-tool
   engines shipped with no rule, plus the word-counter stat grid - see
   the work order in personas/QA/handoffs/w4-text-engines-tools-engineer.md
   §14. Markup read directly from the shipped engines
   (public/js/tools/{word-counter,case-converter,slug-generator,
   json-formatter,base64-encode-decode,url-encode-decode}.js - not mine to
   edit) before writing a single rule below, per this seat's own standing
   rule. Tokens only, both themes; no new animation anywhere in this
   block, so no reduced-motion entry is needed - static by construction.
   ===================================================================== */

/* ---- 1. .is-bidi-isolate: a bare unicode-bidi:isolate hook. Toggled at
   runtime by case-converter (mode-dependent output), base64-encode-
   decode/url-encode-decode (mode-dependent, on whichever of input/output
   currently holds Latin-shaped or percent-encoded content) and set
   permanently by slug-generator/json-formatter's output. Keeps a
   Latin/Base64/percent-encoded run from ever being bidi-reordered inside
   RTL prose - the identical fix already shipped above on
   .tool-file-row-name/.tool-palette-hex; this just gives the six W4
   engines' own class name a rule instead of six more one-off copies. ---- */
.is-bidi-isolate{unicode-bidi:isolate}

/* ---- 2. json-formatter's validity status line - severity by shape, not
   colour (rule 1: one accent, no red - AI/03-DESIGN-SYSTEM.md "Severity
   by shape"). The engine (json-formatter.js) always renders
   class="tool-gradient-note tool-json-status" on the same <p> and only
   ever toggles .is-invalid; the valid state is deliberately left exactly
   as .tool-gradient-note already renders it (muted text, no icon) - the
   work order asked for a treatment "distinct from the default
   .tool-gradient-note valid-state look", which reads as "leave valid
   alone, give invalid its own look", not "reskin both". Only .is-invalid
   gets new rules here, matching the sitewide .field-error/.alert-error/
   .c-err pattern exactly: --ink + weight 600 + the circle-X #ic-error
   glyph, never a hue.

   The glyph is a CSS mask, not an <svg><use> - the engine's own markup is
   a plain <p> with no child node to point a <use> at. Same technique
   already shipped on .tool-caveat::before above; the path data below is
   duplicated verbatim from #ic-error's own <symbol> in
   resources/views/partials/tool-icons.blade.php (circle r="7.8" + the
   crossing diagonals), so it stays pixel-identical to every other error
   surface on the site rather than being a fifth hand-drawn X. ---- */
.tool-json-status.is-invalid{
  display:flex;align-items:flex-start;gap:var(--s-6);
  color:var(--ink);font-weight:600;
}
.tool-json-status.is-invalid::before{
  content:"";flex:none;width:var(--ic-14);height:var(--ic-14);margin-top:var(--s-2);
  background-color:currentColor;
  -webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Ccircle cx='10' cy='10' r='7.8' fill='none' stroke='black' stroke-width='1.8'/%3E%3Cpath d='M7.3 7.3l5.4 5.4M12.7 7.3l-5.4 5.4' stroke='black' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center/contain no-repeat;
  mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Ccircle cx='10' cy='10' r='7.8' fill='none' stroke='black' stroke-width='1.8'/%3E%3Cpath d='M7.3 7.3l5.4 5.4M12.7 7.3l-5.4 5.4' stroke='black' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center/contain no-repeat;
}

/* ---- 3. word-counter's stat grid - a genuinely new shape, zero
   precedent in this codebase: seven live counts (words / characters /
   characters-no-spaces / sentences / paragraphs / reading time /
   speaking time) that re-render their own text on every 150ms-debounced
   keystroke. Markup read directly from word-counter.js (~lines 123-150):
   <div class="tool-wc-stats" aria-live="polite"> of seven
   <div class="tool-wc-stat"><span class="tool-wc-stat-label">…</span>
   <strong class="tool-wc-stat-value">0</strong></div> - the grid is built
   once at boot and never rebuilt; only the seven <strong> textContents
   change, so the grid's own geometry cannot shift from typing, only from
   a real viewport resize.

   auto-fit + minmax, no manual breakpoint - the identical technique
   already proven on .tool-frame-grid above ("wraps cleanly from 375 to
   1280 with no manual breakpoint"). At 375 (.tool-shell's existing
   --s-24 mobile padding on both sides leaves ~327px) this lands 3 cards
   per row, never five in one row in the failure mode the work order
   named; at 1440 it opens up toward all seven across in one row.
   flex:1 1 100% with no explicit width matches
   .tool-gradient-output/.tool-frame-grid/.tool-file-list's own
   established convention for a full-width child of the column-direction
   mobile .tool-options (max-width:640px above) - AI/06-LESSONS.md's
   flex-basis-becomes-a-height-hint trap doesn't bite here the way it did
   .tool-gradient-preview, because this is a grid with no cosmetic fixed
   height to lose; its height is intrinsic, from its own rows.

   No layout shift as a value grows from 1 to 6 digits:
     - font-variant-numeric:tabular-nums is the sitewide precedent for a
       live-updating number (.love-count/.dock-n above) - every digit
       glyph gets the same advance width, so typing more characters never
       widens the <strong> by more than the ladder of a fixed per-glyph
       step, and the grid's own column tracks are sized by minmax(), not
       by this content, so a growing number can never resize its own
       card, let alone push a sibling.
     - --s-96 (96px) minmax floor + --s-10 card padding + --text-lg
       (18.4px, not --text-xl) for the value: at that size six tabular
       digits measure ~66px against a ~76px inner card width - real
       headroom, not a coin-flip fit, verified live (see the handoff).
     - Measured directly, not just reasoned about: typed a fixture from 1
       digit up to 200,001 characters (word-counter.js's own MAX_CHARS
       cap +1) and re-read getBoundingClientRect() on .tool-wc-stats and
       every .tool-wc-stat before and after - identical x/y/width/height
       throughout (see the handoff for the exact numbers).

   RTL + Arabic digits: direction:ltr + unicode-bidi:isolate on
   .tool-wc-stat-value matches .tool-file-row-meta's "3 page(s)"
   precedent exactly above (a number+unit run, not prose) - covers both
   the plain counts and the "5 min"/"< 1 min" time strings the same way.
   The label stays plain, undirected text - it is real prose in the
   page's own language and mirrors with the rest of the RTL page for
   free, same as every other .tool-field-label. Arabic-Indic digit risk
   (the exact bug bytesToHuman's own -u-nu-latn fix exists for): word-
   counter.js sets every value via plain String(number)/template
   concatenation, never Intl.NumberFormat - the same "always Western-
   digit regardless of locale" category bytesToHuman's own comment names
   explicitly (counts, percentages, dimensions) as already safe without
   the pin, not the one path (Intl.NumberFormat against the page's own
   lang) that needed it. Confirmed live in ar-EG by screenshot, not rect
   maths (Trap 2 - inadmissible for anything about text) - see the
   handoff. ---- */
.tool-wc-stats{
  flex:1 1 100%;
  display:grid;
  grid-template-columns:repeat(auto-fit,minmax(var(--s-96),1fr));
  gap:var(--s-8);
}
.tool-wc-stat{
  display:flex;flex-direction:column;gap:var(--s-4);
  padding:var(--s-10) var(--s-12);
  border-radius:var(--r-tile);
  background:var(--wall);
  box-shadow:inset 0 0 0 1.5px var(--line);
}
.tool-wc-stat-label{
  font-size:var(--text-2xs);font-weight:700;color:var(--muted);line-height:1.3;
}
.tool-wc-stat-value{
  font-size:var(--text-lg);font-weight:900;color:var(--ink);line-height:1.15;
  font-variant-numeric:tabular-nums;
  direction:ltr;unicode-bidi:isolate;
}


/* =====================================================================
   ad hoc: "jiru" private task board (no numbered plan) - see
   personas/QA/handoffs/jiru-board-ui-frontend-ui.md and the backend's
   own jiru-board-backend-laravel-backend.md. `/jiru` is a password-gated,
   noindex'd internal tool (mirrors /admin's own private-surface idea),
   not a public page, so this section is deliberately denser and less
   ambient than the rest of the site - "utility surface: favour clarity
   and density over ambient motion, but stay on-brand" (frontend-ui
   persona's own note on plan 09/dashboard work, extended here). Tokens
   only (--s-* throughout, per AI/07-UI-STANDARDS.md - no --sp-* alias
   anywhere below), both themes automatically (every colour below is a
   token or a color-mix() of one), one accent. Reuses every shared
   component it can rather than inventing new ones: .field, .btn,
   .btn-primary, .btn-ghost, the whole .alert family, .theme-btn,
   .icon-btn, .empty-panel, .skel-row, .tool-select-wrap, .tool-select,
   .tool-select-ic and .sr-only - all verbatim, zero overrides. No new
   animation anywhere in this section -
   see the handoff for why (every hover below is an instant state swap,
   not a transition, so the existing single prefers-reduced-motion block
   needs no new entry here).
   ===================================================================== */

.jiru-page{min-height:100dvh;background:var(--wall);color:var(--ink)}

/* ---- auth screen (mirrors admin's own .admin-auth recipe, its own
   class family since this slice does not load admin.css) ---- */
.jiru-auth{min-height:100dvh;display:flex;align-items:center;justify-content:center;padding:var(--s-32)}
.jiru-auth-card{width:100%;max-width:380px;background:var(--tile);border-radius:var(--r-tile);padding:var(--s-40)}
.jiru-auth-brand{display:flex;align-items:center;justify-content:center;gap:var(--s-8);margin-bottom:var(--s-24)}
.jiru-auth-brand-dot{width:9px;height:9px;border-radius:var(--r-pill);background:var(--accent);flex:none}
.jiru-auth-brand span{font-weight:900;font-size:var(--text-lg)}
.jiru-auth-card h1{font-size:var(--text-xl);font-weight:900;text-align:center;margin-bottom:var(--s-24)}
.jiru-auth-card .btn{width:100%;justify-content:center}
.jiru-auth-theme-btn{position:fixed;inset-inline-end:var(--s-24);top:var(--s-24)}

/* ---- board shell ---- */
/* deliberately uncapped width (unlike .wrap's 1080px --container) - a
   Kanban board is exactly the surface that benefits from all the
   viewport it can get, same reasoning admin.css's own .admin-main
   never caps width either. */
.jiru-shell{padding:var(--s-24) var(--s-24) var(--s-64)}
.jiru-topbar{
  display:flex;align-items:flex-start;justify-content:space-between;gap:var(--s-16);
  flex-wrap:wrap;margin-bottom:var(--s-20);
}
.jiru-heading{display:flex;align-items:flex-start;gap:var(--s-10)}
.jiru-heading-dot{width:9px;height:9px;border-radius:var(--r-pill);background:var(--accent);flex:none;margin-top:var(--s-10)}
.jiru-title{font-size:var(--display-3);font-weight:900;letter-spacing:-.01em}
.jiru-subtitle{color:var(--muted);font-size:var(--text-base);margin-top:var(--s-4);max-width:64ch}
.jiru-topbar-actions{display:flex;align-items:center;gap:var(--s-8);flex:none}
.jiru-topbar-actions form{display:contents}

/* ---- last-updated indicator: quiet by design, not a toast - it sits
   under the subtitle and just states a fact (when the board's data was
   last confirmed current), never an aria-live region (that would ping a
   screen reader every ~45s forever, which is the opposite of quiet).
   Hidden until the first poll actually resolves - showing "Updated" with
   no time yet would be worse than showing nothing (see AI/10 Part 1: an
   honest gap beats a fabricated one). A failed poll never advances the
   time or hides this line - it swaps to the "could not refresh" copy and
   keeps the last real timestamp, so the label is never confidently wrong. */
.jiru-updated{display:flex;align-items:center;gap:var(--s-6);color:var(--muted);font-size:var(--text-xs);margin-top:var(--s-6)}
.jiru-updated[hidden]{display:none}
.jiru-updated-icon{width:var(--ic-14);height:var(--ic-14);flex:none}
.jiru-updated.is-stale{color:var(--ink)}

/* ---- status/announcement region: reuses the .alert family and .btn
   verbatim, just needs a retry action pushed to the row's end ---- */
.jiru-status{margin-bottom:var(--s-16)}
.jiru-status .alert{margin-bottom:0;align-items:center}
.jiru-status-retry{flex:none;margin-inline-start:auto}

/* ---- filters ---- */
.jiru-filters{display:flex;flex-wrap:wrap;gap:var(--s-12);align-items:flex-end;margin-bottom:var(--s-20)}
.jiru-filter{display:flex;flex-direction:column;gap:var(--s-6);min-width:180px}
.jiru-filter label{font-size:var(--text-xs);font-weight:700;color:var(--muted)}
.jiru-filter-clear{
  display:inline-flex;align-items:center;min-height:var(--h-control);padding-inline:var(--s-12);
  border-radius:var(--r-pill);border:none;background:none;cursor:pointer;
  font-family:inherit;font-weight:700;font-size:var(--text-sm);color:var(--muted);
}
.jiru-filter-clear:hover{color:var(--ink)}
.jiru-filter-clear:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:var(--r-pill)}
.jiru-filter-clear[hidden]{display:none}

/* ---- columns ---- */
.jiru-board{display:flex;align-items:flex-start;gap:var(--s-16);overflow-x:auto;padding-block-end:var(--s-8)}
.jiru-column{
  flex:0 0 300px;display:flex;flex-direction:column;
  background:var(--tile);border-radius:var(--r-tile);overflow:hidden;
}
.jiru-column-head{
  display:flex;align-items:center;justify-content:space-between;gap:var(--s-8);
  padding:var(--s-14) var(--s-16);border-bottom:1px solid var(--line);
}
.jiru-column-head-label{display:flex;align-items:center;gap:var(--s-8);min-width:0}
.jiru-column-icon{width:var(--ic-18);height:var(--ic-18);color:var(--muted);flex:none}
.jiru-column-title{
  font-weight:700;font-size:var(--text-sm);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
.jiru-column-count{
  flex:none;display:inline-flex;align-items:center;justify-content:center;
  min-width:var(--s-24);height:var(--s-24);padding-inline:var(--s-6);border-radius:var(--r-pill);
  background:var(--wall);color:var(--muted);font-size:var(--text-2xs);font-weight:700;
  box-shadow:inset 0 0 0 1.5px var(--line);
}
.jiru-column-hint{
  color:var(--muted);font-size:var(--text-xs);line-height:1.5;
  padding:var(--s-10) var(--s-16) 0;
}
.jiru-column-body{display:flex;flex-direction:column;gap:var(--s-12);padding:var(--s-12)}
.jiru-column-body .empty-panel{padding:var(--s-24) var(--s-14)}
.jiru-column-body .empty-panel svg{width:var(--ic-24);height:var(--ic-24);margin-bottom:var(--s-8)}
.jiru-column-body .skel-row{height:var(--s-40)}

/* "blocked on Qandil" carries real visual weight - it is the whole point
   of this board (20 real items only he can move), not just another
   column. Wider, ringed in the one accent, its icon/count inverted to
   accent fill - the same "severity by shape, never a second hue" system
   the rest of the site uses, just turned up for emphasis rather than for
   error (this is a warning-shaped glyph, ic-alert, not ic-error - it
   needs his attention, it is not broken). */
.jiru-column.is-emphasis{
  flex-basis:340px;
  box-shadow:inset 0 0 0 2px var(--accent);
}
.jiru-column.is-emphasis .jiru-column-head{
  background:color-mix(in srgb, var(--accent) 16%, var(--tile));
}
.jiru-column.is-emphasis .jiru-column-icon{color:var(--accent)}
.jiru-column.is-emphasis .jiru-column-count{background:var(--accent);color:var(--accent-ink);box-shadow:none}

/* ---- cards ---- */
.jiru-card{
  background:var(--wall);border-radius:var(--r-tile);padding:var(--s-14);
  box-shadow:inset 0 0 0 1.5px var(--line);display:flex;flex-direction:column;gap:var(--s-8);
}
.jiru-card-title{font-weight:700;font-size:var(--text-sm);line-height:1.4;margin:0}
.jiru-card-meta{
  display:flex;align-items:center;gap:var(--s-6);flex-wrap:wrap;
  color:var(--muted);font-size:var(--text-xs);margin:0;
}
.jiru-card-meta .sep{opacity:.6}
.jiru-badge{
  display:inline-flex;align-items:center;padding:var(--s-2) var(--s-10);border-radius:var(--r-pill);
  font-size:var(--text-2xs);font-weight:700;background:var(--tile);color:var(--muted);
  box-shadow:inset 0 0 0 1.5px var(--line);white-space:nowrap;
}
.jiru-badge.is-high{background:var(--accent);color:var(--accent-ink);box-shadow:none}

/* the whole evidence row is the tappable/clickable target (not a small
   icon inside it) so the real hit area is honestly >=44px tall with no
   hit-slop trick needed - see the handoff for why this shape was chosen
   over a separate small copy-icon button. */
.jiru-card-evidence{
  display:flex;align-items:center;gap:var(--s-8);min-height:var(--h-control);width:100%;
  background:var(--tile);border-radius:var(--r-tile);padding-inline:var(--s-10);
  border:none;cursor:pointer;font-family:inherit;color:inherit;text-align:start;
}
.jiru-card-evidence:hover{box-shadow:inset 0 0 0 1.5px var(--line)}
.jiru-card-evidence:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.jiru-card-evidence svg{width:var(--ic-14);height:var(--ic-14);flex:none;color:var(--muted)}
.jiru-card-evidence code{
  flex:1;min-width:0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
  font-size:var(--text-2xs);color:var(--muted);
  direction:ltr;unicode-bidi:isolate;
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
.jiru-card-evidence-none{color:var(--muted);font-size:var(--text-2xs);margin:0}

.jiru-card-move{margin-top:var(--s-4)}

/* =====================================================================
   jiru responsive - only the four canonical breakpoints, per
   AI/07-UI-STANDARDS.md §3.
   ===================================================================== */
@media (max-width:900px){
  .jiru-column{flex-basis:260px}
  .jiru-column.is-emphasis{flex-basis:290px}
}
@media (max-width:640px){
  .jiru-shell{padding-inline:var(--s-16)}
  .jiru-topbar{flex-direction:column;align-items:flex-start}
  .jiru-filters{flex-direction:column;align-items:stretch}
  .jiru-filter{min-width:0}
  /* one column at a time, swipeable - a 4-5 column strip cannot read at
     375 any other way; scroll-snap makes the "next column" gesture
     discoverable instead of an ambiguous partial sliver of two columns.
     flex-basis is calc(100% - a fixed peek) rather than a vw fraction so
     the next column's peek stays a constant, legible width regardless of
     exactly how narrow the phone is - a vw fraction shrinks the peek right
     along with the viewport, which is the opposite of what design-qa
     asked for. calc(100% - var(--s-*)) is the documented non-spacing
     exception in AI/07-UI-STANDARDS.md §1, not an arithmetic hack. */
  .jiru-board{scroll-snap-type:x proximity}
  /* peek (visible sliver of the next column) = containerWidth - colWidth
     - gap = s-64 - s-16 = 48px normal, s-56 - s-16 = 40px for the wider
     "blocked on Qandil" column - both a real, legible partial-card edge,
     not the ~20-30px hairline design-qa measured before. */
  .jiru-column{flex:0 0 calc(100% - var(--s-64));scroll-snap-align:start}
  .jiru-column.is-emphasis{flex-basis:calc(100% - var(--s-56))}
  /* design-qa r2 MINOR: the only cue the board scrolled at 375 was a
     ~20-30px sliver of the next column - easy to read as "the page just
     ends here" (personas/QA/reports/design-tools-w4-jiru-design-qa-r2.md).
     A mask-image fade on .jiru-board itself, not a separate overlay
     element - a linear-gradient mask is direction-only, so it reads the
     same regardless of how tall the board's tallest column is (17 cards
     deep in "Waiting"), which a position:fixed/absolute overlay tried
     and fought against. .is-scroll-end (JS, on scroll) removes the mask
     once there truly is nothing left to the right - the fade never
     implies more content than actually exists. black/transparent here are
     mask-channel values (alpha), not paint colour - same category as the
     existing -webkit-mask uses elsewhere in this file (the theme-toggle
     icon masks, ~line 2587/4458), not a new colour exception. */
  .jiru-board:not(.is-scroll-end){
    -webkit-mask-image:linear-gradient(to right, #000 calc(100% - var(--s-40)), transparent);
    mask-image:linear-gradient(to right, #000 calc(100% - var(--s-40)), transparent);
  }
}
@media (max-width:480px){
  .jiru-auth-card{padding:var(--s-24)}
}

/* ===== jwt-decoder: full-width content blocks (board row 221, design gate
   personas/QA/reports/jwt-decoder-design-gate.md) =====
   Root cause, read from the CSS the gate already pointed at: .tool-options
   is display:flex;flex-wrap:wrap (line ~2526) and every block this engine
   builds - the input textarea, the persistent teaching panel, the
   malformed-token error line, and the Header/Payload/Timestamps/Signature
   output blocks - lands as a `.tool-field` (or, for the error line, a bare
   `.tool-gradient-note`) DIRECT SIBLING of that container. `.tool-field`'s
   own basis is 240px (line ~2843), so above 640px - where .tool-options
   stops forcing flex-direction:column - any two of these that both fit a
   240px+ share of the row end up sharing one, and WHICH ones share a row
   shifts with content height (empty vs a decoded token vs a wrapped German
   error), which is exactly what the gate measured: Payload/Signature
   swapped sides between the empty and filled state at the same 1440px
   width, in every locale and both themes.

   `json-formatter` and `base64-encode-decode` use the identical
   `.tool-field`/`.tool-gradient-output` classes and never show this,
   because each ships exactly one such content block - alone on its row,
   nothing to unpredictably share it with. jwt-decoder is the first tool in
   this family to place FIVE full-content blocks as `.tool-options`
   siblings, which is the condition that exposes the bug. The honest
   general fix is a shared class this codebase does not have yet - "a
   full-width content block that should never share a row" is a real,
   nameable concept (the `.tool-field:has(.tool-segmented-btn:nth-child(5))`
   rule a few hundred lines up already solves a narrower version of the
   same problem for ONE field with a wide control inside it) - but naming
   it as a class means writing it onto the DOM nodes jwt-decoder.js builds,
   and that file is tools-engineer's, not this seat's, to edit. Handoff
   flags the shared-class idea for the next tool that needs it; this rule
   ships the fix available from here: CSS only, scoped to this tool via the
   `data-tool-slug` attribute `.tool-shell` already carries (same pattern
   as `[data-tool-slug="resume-builder"] .dropzone{display:none}` above),
   zero risk to the other 55 tools sharing `.tool-field`.

   `flex:1 1 100%`, not just flex-basis - shrink stays allowed, same
   reasoning as the segmented-btn rule's own comment: flex-basis:100% is
   resolved against .tool-options itself, not the block's own content, so
   there is no circular sizing and nothing can render wider than the row
   gives it. `*:not([data-tool-run])` rather than enumerating `.tool-field`
   and `.tool-gradient-note` separately, so a block this engine adds later
   inherits the fix for free instead of silently falling back to the old
   240px-basis scramble - the shell's own primary-action button is the one
   child that must keep its normal button sizing, and it is `hidden` on
   this tool regardless (AI/07-UI-STANDARDS.md §10 rule 3: no genuine
   discrete action here), so excluding it changes nothing visible today.

   Below 640px this is a no-op in effect (.tool-options already forces
   flex-direction:column there, and the existing `.tool-field{flex-
   basis:100%}` mobile rule already matches every block on every tool) -
   confirmed unchanged at 375/480/640, see handoff. */
[data-tool-slug="jwt-decoder"] .tool-options > *:not([data-tool-run]){
  flex:1 1 100%;
}

/* =====================================================================
   row 263: shared drag-handle primitive (cubic-bezier, clip-path)
   design-css-tools-gate.md finding #1 (MAJOR) - both engines build their
   draggable curve/shape points as bare SVG `<circle tabindex="0"
   role="slider">` with no class at all, so nothing in this file ever
   targeted them: genuinely focusable and operable (arrow keys move them),
   but invisible once focused (`outline-style` computed `none`), and their
   own painted hit area measured 13x10px / 12x7px - both real symptoms of
   the same root cause, one shared fix. tools-engineer adds
   `class="tool-drag-handle"` at the two call sites (cubic-bezier.js,
   clip-path.js); this rule is written against that class.

   Both engines stretch a non-square viewBox over a fluid-width, fixed-
   height box with `preserveAspectRatio="none"` (cubic-bezier: 300x220 over
   a 100%-wide/220px, 160px<=640px, container; clip-path: 100x100 over the
   same box) - an ANISOTROPIC scale that is never 1:1 in real layout, so
   `vector-effect:non-scaling-stroke` is required to keep the ring below a
   constant SCREEN size (matching the technique clip-path.js's own polygon
   outline already uses) rather than shrinking further exactly where it
   matters most, at the 160px mobile height.

   The hit-area enlargement is a transparent stroke, not a bigger circle -
   `r` stays the engine's own value so the PAINTED dot never grows past
   what the design gate called "obscuring the curve"; `stroke:transparent`
   is still real (non-`none`) paint, so default `pointer-events:auto`
   still hit-tests it.

   `stroke-width` is capped at `--s-14`, not the project's 44px control
   floor, and that cap is the result of a measurement, not a guess -
   corrected twice while writing this rule, both times downward. A first
   pass shipped `--s-40` on the strength of a synthetic harness reproducing
   both tools' viewBox/box RATIOS; re-measured against the two REAL
   rendered pages (`elementFromPoint` swept pixel-by-pixel across the
   handle, both axes, at every width the sweep touched), `--s-40` puts a
   real, reproducible dead pixel INSIDE the ring on both tools' actual
   geometry - e.g. clip-path at 480px: clean through `--s-16`..`--s-19`, a
   1px gap opens at 20, 2px at 24, and by 40 the gap sits a few px out from
   the fill's own edge in both x and y; cubic-bezier shows the same shape,
   clean through ~20, first gap at 24. A ratio-only harness was the wrong
   instrument for a bug that depends on the ABSOLUTE screen-space radius
   after `vector-effect:non-scaling-stroke` neutralises the scale, not just
   the scale's shape - see AI/10-AGENT-OPERATING-MANUAL.md Part 1, "measure
   in the environment the code actually runs in." A second pass shipped
   `--s-16` on that 480px measurement alone; re-swept at 375px (clip-path's
   own narrowest real breakpoint, not just the one width first checked),
   the SAME `--s-16` produced a genuine single dead pixel on one polygon
   point ("1111011111110000..." - a real gap, not measurement noise, since
   it reproduced on a second sweep of the same point). `--s-14` re-swept
   clean at 375px on that exact point and stays clean everywhere `--s-16`
   already was, so it is the number this rule ships - a boundary this close
   to real screen-space radii needs re-checking at more than one width
   before it is trusted, the same lesson AI/10 already states for
   breakpoints generally.

   That leaves the hit diameter at roughly 31-32px (cubic-bezier) and
   21-31px, anisotropic (clip-path) - a real 2-2.5x area gain over the
   13x10 / 12x7px this replaces, but short of the 44px floor
   `AI/07-UI-STANDARDS.md` §4 sets for a touch target. Pushing the stroke
   wider does not close that gap safely: it re-opens the dead-pixel defect
   above before it reaches 44 on either tool's own real geometry. Dropping
   `vector-effect` avoids that specific defect (confirmed by the same
   sweep) but scales the padding WITH the anisotropic transform instead of
   holding it constant, so it would grow unbounded at wide viewports
   instead of shrinking at mobile - trading a proven dead zone for an
   unproven amount of hit-region overlap between adjacent polygon points
   at 1128px, which this pass has no evidence is safe either. Reaching a
   true 44px floor without either risk needs a second, larger transparent
   HIT shape independent of the visible dot's own stroke (the design gate's
   other suggestion, "pointer-events on a larger transparent shape") -
   that is a DOM change in the engines, not a CSS-only fix, and is
   therefore tools-engineer's file, not this rule's; flagged in the
   handoff rather than silently left at an unsafe width to hit a number. */
.tool-drag-handle{
  stroke:transparent;
  stroke-width:var(--s-14);
  vector-effect:non-scaling-stroke;
}
/* Focus ring, r2 (a11y-i18n re-gate, board row 274 / css-tools-a11y-regate.md
   "Ring" + its addendum): the first pass of this rule (fill:tile,
   stroke:accent) shipped believing both tools sit on the same kind of
   surface. They do not - measured live, both themes, reloaded rather than
   toggled:

     clip-path.js:135 sets `preview.style.background = 'var(--accent)'`
       directly, so the handle overlay - a CHILD of that clipped element -
       paints on a literal `--accent` floor with no exception.
     cubic-bezier's chart box (`.tool-gradient-preview`) never sets its own
       background at all; `getComputedStyle` on it returns transparent, and
       walking the ancestor chain lands on `.tool-shell`'s own background,
       which computes to `--tile`. Its curve being drawn `stroke:var(--accent)`
       (the fact the first pass reasoned from) says nothing about what sits
       BEHIND the handle - that was an inference, not a measurement, and it
       was wrong.

   So the old `stroke:var(--accent)` ring was accent-on-accent on clip-path
   (1.00:1 in both themes - the ring is genuinely painted, it is simply the
   same colour as the floor under it, confirmed by swapping in a throwaway
   red stroke and watching the ring appear) while it was already fine on
   cubic-bezier (accent-on-tile, ~6-7:1) - same rule, opposite outcome,
   because the two tools' floors are opposite halves of the one-accent
   palette. And `outline:none` above removed the browser's own ring, which
   is what cubic-bezier's UNSTYLED handles were relying on before this class
   existed - so shipping one fixed ring colour regressed the tool it wasn't
   written for.

   No single ring colour can be right on both floors under a one-accent
   palette (there is no third hue to reach for), so this stops trying to
   pick one and gives the ring its OWN floor to sit on instead: fill AND
   stroke both flip to `--tile` - a neutral disc + ring big enough (the
   fix's own `--s-6` stroke, unchanged) to read clearly against an
   `--accent` surface (tile-on-accent: ~6-7:1, the exact case this row
   exists to fix; confirmed live, both themes, at 375px) - plus an
   `--accent`-coloured `drop-shadow` halo layered outward as the second
   band, so a tile-toned surface (cubic-bezier) still gets a NEW accent-
   coloured signal outside the disc rather than nothing. `drop-shadow`
   because `box-shadow` does not reliably paint against SVG shape geometry
   (the reason the first pass gave for not using it, still true); a real
   CSS property always outranks the engines' own inline `fill`/`stroke`
   presentation attributes regardless of specificity, so this applies over
   both tools' handles without either file changing its own attribute
   values.

   cubic-bezier does not actually need the halo to keep passing: its own
   fill flip (engine's inline `--accent` -> this rule's `--tile`) is
   untouched by this edit and was already the passing mechanism the r1 gate
   measured, so that tool's focus signal stands on its own regardless of
   what the ring does. The halo is there so the ring is never a dead band on
   ANY future tool sharing this class, not because cubic-bezier needs it
   today. Stacked twice because a single `drop-shadow` renders under partial
   opacity across its own blur radius; two compound toward full strength at
   the visible edge without changing the shape. */
.tool-drag-handle:focus-visible{
  outline:none;
  fill:var(--tile);
  stroke:var(--tile);
  stroke-width:var(--s-6);
  filter:drop-shadow(0 0 var(--s-2) var(--accent)) drop-shadow(0 0 var(--s-2) var(--accent));
}

/* ===== board row 295: touch-drag on a handle vs a page pan =====
   Neither tool sets `touch-action` on its pointer hit-shapes
   (`[data-hit-layer] ellipse`, both built the same way - see
   cubic-bezier.js's and clip-path.js's own makeHitShape()), so a
   touch-drag that starts on one can be taken over by the browser as a
   page pan before the `pointermove` handler ever fires. `d5c7847` stopped
   the handle sticking to the finger afterward (a `pointercancel` listener
   releases it), but nothing stops the pan STARTING - a phone user aiming
   at a handle can end up scrolling the page instead.

   The fix is real (`touch-action:none` on the hit shape), but the cost is
   real too and the two tools do not carry the same amount of it, so this
   is scoped to one tool, not both:

   - cubic-bezier has exactly two hit shapes (handle1/handle2), fixed at
     32px screen-space diameter, inside a chart that is 220px tall (160px
     at <=640px) - a small, known, sparse fraction of the page. Losing
     touch-scroll over two small circles costs almost nothing and the tool
     is precision curve-dragging, which benefits the most from a pan that
     never steals the gesture.
   - clip-path's polygon mode can have up to 8 points, and this file's own
     hit-diameter history above (`.tool-drag-handle`'s comment) already
     measured them at 44-50px with ADJACENT POINTS AS CLOSE AS 40.2px (the
     Star preset) - closer than the hit circles themselves, so they
     overlap and cover a large, unpredictable fraction of the whole preview
     box, not a few small spots. `touch-action:none` there would risk
     making the preview un-scroll-past on a tall mobile page where the
     chart is most of the screen - exactly the risk this row raised, and a
     materially different trade than cubic-bezier's two small dots.
     clip-path keeps native page-pan priority; a touch-drag that starts a
     pan there still ends cleanly via the same `pointercancel` release
     `d5c7847` already ships, so nothing regresses, it simply is not made
     stricter here.

   Scoped by `.tool-shell[data-tool-slug]`, the same selector this file
   already uses for a single-tool override (see the jwt-decoder rule
   above) - no change to clip-path.js/cubic-bezier.js needed either way,
   since both already expose `[data-hit-layer] ellipse` with no class of
   its own. */
[data-tool-slug="cubic-bezier"] [data-hit-layer] ellipse{
  touch-action:none;
}

/* =====================================================================
   plan 10b: resume-writer page UI, ROUND 3 (frontend-ui, board row 357 /
   personas/QA/handoffs/resume-writer-r3-ui-frontend-ui.md), styling
   tools-engineer's own ROUND 3 rewrite (public/js/tools/resume-writer.js,
   personas/QA/handoffs/resume-writer-r3-tools-engineer.md SS9 - the DOM
   contract + itemised work order this block answers).

   WHY THIS ROUND EXISTS, VERBATIM (Qandil, after using r2 against his own
   real reference resume builder): "this not correct for my reference - we
   need big preview and sidebar that have tabs and sections for put
   details." r2 made the document (.rw-page) itself the editor and the panel
   navigation-only. That is the model he rejected. r3 reverses it: the
   document is now a big, dominant, READ-ONLY preview built by
   renderDocument() - zero form controls anywhere in it, at any width -
   and every field lives in the panel's "Structure" tab, grouped into real
   `.rw-section` accordions (Header, Contact, Experience, Education, Skills,
   Projects, Links, plus up to six "suggested" sections the panel added). The
   document and the panel are now two DISJOINT DOM TREES - the panel's
   `.rw-section` accordion markup never appears inside `.rw-page` at any
   width, which is the single biggest structural change from r2 and the
   reason most of r2's own >=xl "section -> plain document heading" override
   is simply gone rather than rewritten (SS2 below).

   ENTIRELY DELETED THIS ROUND, confirmed by grep against the r3 engine
   before deleting (AI/10's own evidence standard - a claim is worth what it
   traces to):
   - the seamless-field span-sizer mechanism (`.rw-line`, `.rw-line-sizer`,
     `.rw-line-input`, `.rw-line-sep`, `.rw-line > label.sr-only`) - the
     engine's buildLine()/docField() that built this markup is gone; every
     field is now a normal `.tool-field`/`.tool-input` control (the SAME
     already-generic, already-styled-elsewhere recipe every other tool on
     this site uses - zero bespoke CSS owed for the fields themselves).
   - the >=xl "section accordion -> plain document heading, chevron
     removed" override and its print-media mirror - `.rw-section` now lives
     ONLY inside `.rw-panel`, which is `display:none` in print and was
     never part of `.rw-page` at any screen width either, so there is
     nothing left for either override to protect.
   - the >=xl seamless-field print swap (hide the real input, show its
     aria-hidden sizer) - there is no field in the printed node to swap;
     the document was already, and remains, plain text nodes only.
   - the r2-era "outline grid" (`.rw-outline`, `.rw-outline-row`,
     `.rw-outline-cell`, `.rw-outline-count`) - that was r2's navigation-
     only outline (jump focus, hold no value, R2.2-c's own named defect);
     r3 replaced it with real accordion sections, which already have their
     own CSS below. `.rw-outline-panel` is renamed `.rw-structure-panel`
     (the engine emits the new class name directly - grepped, confirmed);
     `.rw-outline-footer` is UNCHANGED (still the engine's own class name
     for the Backup/Load-a-backup/autosave row, kept as-is).

   NEW THIS ROUND, none of which existed before (grepped): `.rw-page-
   section-title` (a real heading now - the engine's own comment already
   correctly predicted this: "was `.rw-page-section h2` in v1 - dead
   selector today" - it is no longer dead, renderDocument() builds it via
   previewLine() on every section), `.rw-page-section-text` (the Interests
   section's body paragraph in the document), `.rw-entry` (one entry's
   field group INSIDE THE PANEL - reuses `.rb-entry`'s existing tile-card
   recipe, site.css's own precedent for "a group of fields belonging to one
   entry", adapted to this file's own entry-actions-as-a-final-row shape
   rather than `.rb-entry`'s side-by-side one), `.rw-suggested`/
   `.rw-suggested-heading`/`.rw-suggested-list`/`.rw-suggested-btn` (the
   suggested-sections block - Qandil's own explicit ask, "the thing to get
   right": Certificates/Awards/Languages/Volunteer experience/Publications/
   Interests offered with a named "+ Add ..." button, not present by
   default - reuses `.btn.btn-ghost`'s existing look, only list spacing/
   wrapping and an accent-coloured "+" glyph are new, so the offered-but-
   absent state (a plain ghost pill here) reads as visibly different
   furniture from the present-and-filled state (a real `.rw-section` card
   with its own "Remove section" control, in Structure)).

   TWO REAL BUGS in the CSS THIS ROUND REPLACES, both selectors that
   targeted a class name the r2 engine used but the r3 engine does not,
   found by grepping the r3 engine for every selector below before keeping
   it, not assumed clean by inheritance:
   1. `.rw-page[data-rw-template="compact"] .rw-section{margin-bottom:
      var(--s-10)}` - `.rw-section` never lives inside `.rw-page` in this
      round (see above), so this rule matched nothing live. The document's
      real per-section wrapper is `.rw-page-section` (unchanged class,
      still emitted by renderDocument()) - retargeted below.
   2. `.rw-page-entry .rw-entry-actions{margin-top:var(--s-8)}` -
      `.rw-entry-actions` (the real +-1/-1/remove row) never lives inside
      `.rw-page-entry` any more either; the printed/read-only document
      contains no buttons at all (r2.5-c's own "the printed DOM must
      contain no form controls" - now trivially true, there is no field OR
      button left to hide at print time, see the print block's own new,
      much shorter comment). The row now lives inside the PANEL's `.rw-
      entry`, retargeted below.

   "BIG PREVIEW" - THE NUMBERS, PER THE DISPATCH BRIEF'S OWN ASK ("if the
   panel can be narrower... propose it with numbers... say what you chose
   and what it cost"): --container stays 1080px (not on the table per the
   brief) - 2x --gutter 24px still leaves exactly 968px usable at >=1128,
   unchanged. r2 spent that 968px as panel 240px + gap 24px + document
   704px (0.89 of A4's 794px), sized for a panel that only ever held a
   navigation list. r3's panel holds every field on the resume - a `.tool-
   field` inside `.rw-section-body`'s own 16px/side padding at 240px total
   panel width left only ~208px for the input itself, narrower than this
   site's own `.tool-field{flex:1 1 240px}` convention anywhere else it is
   used. CHOSEN: panel 280px (+40px), document 968-280-24 = 664px (-40px,
   -5.7%, still 0.836 of A4 and still a 2.37:1 dominance ratio over the
   panel - "big, dominant" by construction, not by coincidence). COST: the
   document lane is 40px narrower than r2's own figure. In exchange every
   field in Structure gets a genuinely comfortable 280-32=248px of usable
   width, matching (not merely approximating) the site's own 240px field
   convention, instead of running 32px under it. A collapsible panel was
   considered and rejected for this pass: collapsing needs a new persistent
   toggle affordance driven by state the ENGINE currently owns and fully
   re-renders on every change (renderStructurePanel()/applyLaneVisibility())
   - adding one from the CSS/Blade side alone would fight that render cycle
   rather than cooperate with it, so it is flagged for tools-engineer as a
   real follow-up idea, not built here.

   COORDINATOR DECISIONS ALREADY MADE, still not this seat's to relitigate:
   - NO new colour tokens. Paper --tile, ground --wall-deep, plus a --line
     hairline inset on the paper - unchanged from r2.
   - Two live bugs stay OUT OF THIS SLICE, filed as their own board rows,
     and this build must not worsen either: (1) `.fall` (z-index:2)
     painting over the document; (2) the accent living on the segmented
     active state instead of the finish button - `.tool-segmented-btn.is-
     active`/`.btn-primary` are UNTOUCHED here, on purpose, site-wide
     84-tool blast radius.

   SCOPE: <1128px is explicitly OUT OF SCOPE this round too (the dispatch
   brief's own words: "already right") - every field below xl already
   renders through the plain, already-styled-everywhere `.tool-field`/
   `.tool-input`/`.tool-textarea` recipe (field()'s own, unconditional
   output at every width - there was never a second, width-gated
   presentation to maintain here the way r2's seamless mechanism needed
   one), so nothing below xl needed a single new rule to keep working. The
   >=1128 block below is the only breakpoint-scoped work this round does.

   Reused, already-generic, no CSS owed here (grepped before writing this
   comment - none of these classes exist scoped to an .rw- ancestor
   anywhere below, on purpose): .tool-field/.tool-field-label/.tool-input/
   .tool-textarea (every field in the panel, at every width), .tool-
   actions/.btn/.btn-primary/.btn-ghost, .tool-segmented/.tool-segmented-btn
   (mode switch + panel tablist + all four design radiogroups), .tool-
   caveat (empty-state prompt AND autosave line), .sr-only.
   ===================================================================== */

/* ---- shell: mode-switch row (<xl only, see the >=xl override below) +
   document lane + panel. Single grid, base = one column (every width until
   overridden), matching how every other breakpoint block in this file
   layers a >=1128 override on top of a mobile-first base rather than
   duplicating the whole rule.

   `flex:1 1 100%` unchanged from r2, still load-bearing (SS1's own
   original find, re-verified live against the r3 DOM before keeping it):
   `.rw-shell` is a direct child of `[data-tool-options]`, the same shared,
   flex-wrap:wrap row every other multi-field tool panel lands in - without
   an explicit full-row claim a flex item with no flex-basis sizes itself by
   CONTENT, and a bare `1fr` grid TRACK sized during that same intrinsic
   pass behaves like `auto`, not a fraction of anything. */
.rw-shell{
  display:grid;grid-template-columns:1fr;gap:var(--s-24);align-items:start;
  flex:1 1 100%;
}
.rw-mode-switch-host{margin-bottom:var(--s-4)}

/* ---- document lane: .rw-document-content (the toggle target below xl -
   paper + empty-state prompt) and .rw-actionbar (a SIBLING, never hidden -
   Export must never leave the screen). Plain block flow at base. ---- */
.rw-document-lane{display:block}
.rw-document-content{display:block}
.rw-document-content > .tool-caveat{margin-bottom:var(--s-16)}
.rw-page-zoom,.rw-page-zoom-inner{
  /* the zoom mechanism (Fit/100%) is not built yet (disclosed gap, needs a
     real @page number this tool's print stylesheet does not define). Both
     nodes stay plain, inert pass-through boxes so the engine's existing DOM
     (it expects them present) never breaks layout. */
  display:block;width:100%;
}

/* ---- the sticky action bar: Export (primary) + a hidden page-count
   placeholder (deferred feature). Nested one level inside .rw-document-lane
   already, so it claims a full row on its own with no extra rule. ---- */
.rw-actionbar{
  position:sticky;bottom:0;z-index:var(--z-page);
  display:flex;flex-wrap:wrap;align-items:center;gap:var(--s-12);
  margin-top:var(--s-16);padding:var(--s-12) var(--s-16);
  background:var(--wall);border-radius:var(--r-tile);
  box-shadow:inset 0 0 0 1.5px var(--line),0 -12px 24px -12px var(--shadow-leaf);
}
.rw-pagecount{font-size:var(--text-xs);color:var(--muted)}
@media (max-width:640px){
  .rw-actionbar{flex-direction:column;align-items:stretch}
  .rw-actionbar .btn{width:100%;justify-content:center}
}

/* ===== plan 10b: resume-writer action-bar density (>=xl only) =====
   Qandil: "buttons so so big" - the Design tab's own segmented controls/
   swatches were fixed for this already (`--h-control-sm`, two blocks
   down); this bar's Export button was the half not reached. Measured live
   at 1280x900 before this rule existed (re-confirmed here, not carried
   forward from the dispatch): `[data-rw-export]` 130.8x57px, `.rw-
   actionbar` 664x81px - both entirely from the sitewide `.btn` rule
   (padding:var(--s-14) var(--s-28), no min-height) plus this bar's own
   padding:var(--s-12) var(--s-16).

   Scoped to `.rw-actionbar .btn`, never `.btn` itself: `.btn` is the
   shared primary-action base every one of 84 tools' Run/Export button
   uses (site.css ~591), and tools-engineer correctly declined to touch it
   or this file for the same reason (see `personas/QA/handoffs/resume-
   writer-actionbar-tools-engineer.md` SS8) - reshaping it sitewide is a
   separate, Qandil-level decision, not a drive-by inside one tool's
   density pass. `.rw-actionbar` is resume-writer-only chrome in this same
   file, so it is safe to tighten on its own.

   `min-height:var(--h-control)` (44px) is the same floor every other
   button on the site targets by default - this button was, unusually,
   BIGGER than that floor before this rule, not at risk of falling under
   it. `line-height:1` is load-bearing for the same reason the Design tab
   fix two blocks down documents on `[data-rw-clear]`: Cairo's inherited
   `line-height:normal` measures ~29px on its own at --text-base, so
   padding alone can't reach 44px without it.

   Deliberately >=xl only, matching this file's own established convention
   for resume-writer desktop-density passes (the Design-tab block below):
   the 44px floor is a TOUCH requirement (AI/07-UI-STANDARDS.md SS4) and
   this bar renders at every width, so below xl the button and bar padding
   are unchanged - a mouse-and-keyboard surface can be denser, a thumb
   cannot. Measured after, same viewport: button 122.8x44px, bar 664x60px
   (81 -> 60, the bar's own chrome, not just the button, per the dispatch's
   own "that bar is yours regardless" instruction). Re-verified with the
   worst-case label, German `export_cta` ("Als PDF exportieren", the
   longest of the five locales) at 1280px: still 44px tall, 180.4px wide,
   no wrap, no overflow - and in `ar-EG` dark at the same width: correct
   RTL order ("PDF تصدير"), 44px tall, focus ring unaffected (neither
   property touched here participates in the global `button:focus-visible`
   rule, site.css ~220). ===== */
@media (min-width:1128px){
  .rw-actionbar{padding:var(--s-8) var(--s-16)}
  .rw-actionbar .btn{min-height:var(--h-control);padding:var(--s-8) var(--s-24);line-height:1}
}

/* ---- panel: this round briefly had a real ARIA tablist here (Structure/
   Design) + a `.rw-panel-tabs-host` row holding it. Both are gone from the
   engine as of this same session - see the "ROUND 6 tabs redesign - BUILT,
   THEN RETIRED" note further down this file for the full account. `.rw-
   panel` now holds Structure content only, unconditionally, no tabpanel
   wrapper needed. `.rw-structure-panel` is still the engine's own live
   class name (grepped, current). ---- */
.rw-panel{display:block}
.rw-structure-panel{margin-bottom:0}

/* ---- section accordion: the ONLY place `.rw-section` renders now, at
   every width, on the document lane's own reading-start side no longer
   applicable - it lives in the panel exclusively. Real click-to-toggle
   card, engine wires aria-expanded/aria-controls correctly. No more >=xl
   "plain document heading" override to layer on top - see this block's own
   file-header comment for why that override is gone rather than kept. ---- */
.rw-section{background:var(--wall);border-radius:var(--r-tile);box-shadow:inset 0 0 0 1.5px var(--line)}
.rw-section-header{
  width:100%;min-height:var(--h-control);display:flex;align-items:center;gap:var(--s-8);
  padding:var(--s-12) var(--s-16);background:none;border:none;cursor:pointer;
  font:inherit;font-weight:700;font-size:var(--text-md);color:var(--ink);text-align:start;
}
.rw-section-summary{margin-inline-start:auto;font-weight:500;font-size:var(--text-xs);color:var(--muted)}
.rw-section-header::after{
  content:"";width:8px;height:8px;flex:none;
  border-right:2px solid var(--muted);border-bottom:2px solid var(--muted);
  transform:rotate(45deg);
  transition:transform var(--dur-1) var(--ease);
}
.rw-section-header[aria-expanded="true"]::after{transform:rotate(225deg)}
.rw-section-header:focus-visible{outline:2px solid var(--accent);outline-offset:-2px;border-radius:var(--r-focus)}
/* plan 10b: resume-writer panel, ROUND 7 (frontend-ui, board row 357 fifth
   follow-up). Qandil, verbatim: "section items has rounded border and bad
   spacing and bad border not needed at all and also not have margin
   between each others and the section title." Measured live: `.rw-entry`
   `border-radius:20px`/`padding:14px`/`margin-bottom:0`/background equal to
   the page behind it (the old ROUND 3 "tile-card" recipe two blocks down),
   and the gap between a section's own heading and its first entry's first
   field measured 0px (`.rw-section-body`'s own `padding-top:0`, unchanged
   since ROUND 3). Three real defects: entries read as rounded cards nobody
   asked for; the ROUND 3 recipe's only real separation was `.rw-section-
   list`'s own `gap:12px`, invisible against two identically-tiled boxes
   sitting almost flush; and the first entry starts exactly where the
   heading's own text ends.

   Fix mirrors an already-shipped, already-reviewed pattern in this SAME
   file rather than inventing a fourth entry recipe: `.rw-page-entry` below
   (the read-only, printed/previewed version of one entry) already solved
   "plain row, real separation, no card" with `padding-bottom:var(--s-12);
   margin-bottom:var(--s-12);border-bottom:1px solid var(--line)`, `:last-
   child` clearing all three. `.rw-entry` (the editable, panel-side version)
   now uses the identical three-property recipe - the two entry recipes
   agree by construction instead of drifting. `.rw-section-list`'s own
   `gap:12px` is removed in the same edit: keeping it would have stacked a
   THIRD separation source (list gap + entry's own margin-bottom + border)
   on top of the two already carried over from `.rw-page-entry`, doubling
   the rhythm this fix is trying to set once. Matches how `.rw-section`
   itself already separates from its neighbours two blocks up (border-
   bottom divider, no gap on the parent) - "a divider is cheaper than a
   border and does not fight the panel's quiet register," the same call
   already made for section headers this round, reused rather than
   re-argued. */
.rw-section-body{display:flex;flex-direction:column;gap:var(--s-12);padding:var(--s-12) var(--s-16) var(--s-16)}
.rw-section-body[hidden]{display:none}
.rw-section-list{display:flex;flex-direction:column}

/* ---- one entry, INSIDE THE PANEL (`.rw-entry`). A plain, editable row -
   same divider recipe as `.rw-page-entry` below (see this block's own
   header comment for why), not a card. No form controls differ from
   before - only the container's own chrome changed. ---- */
.rw-entry{
  display:flex;flex-direction:column;gap:var(--s-10);
  padding-bottom:var(--s-12);margin-bottom:var(--s-12);
  border-bottom:1px solid var(--line);
}
.rw-section-list > .rw-entry:last-child{border-bottom:none;padding-bottom:0;margin-bottom:0}
.rw-entry .rw-entry-actions{margin-top:var(--s-2)}
.rw-entry-actions .btn{min-width:var(--h-control);padding-inline:var(--s-16);justify-content:center}

/* ---- one entry, IN THE DOCUMENT (`.rw-page-entry`) - a plain, read-only
   preview row, thin --line separator between entries instead of a card
   (entries are not cards; a section is - matches how a résumé itself
   reads). No form controls, no reorder row, at any width - the printed DOM
   contract this file has always held. ---- */
.rw-page-entry{padding-bottom:var(--s-12);margin-bottom:var(--s-12);border-bottom:1px solid var(--line)}
.rw-page-entry:last-child{border-bottom:none;padding-bottom:0;margin-bottom:0}

/* ---- suggested sections - Qandil's own explicit ask, "the thing to get
   right": a fixed catalogue of optional sections, offered rather than
   present, each with a named "+ Add ..." control. Reuses `.btn.btn-ghost`'s
   existing look (no new button recipe) - only list spacing/wrapping and an
   accent-coloured "+" glyph (aria-hidden, decorative only - the accessible
   name comes entirely from the button's own visible "Add :section" text)
   are new, so the offered-but-absent state (a plain ghost pill here) reads
   as visibly different furniture from the present-and-filled state (a real
   `.rw-section` card with its own "Remove section" control, above, once
   added to Structure). ---- */
.rw-suggested{margin-top:var(--s-8);padding-top:var(--s-16);border-top:1px solid var(--line)}
.rw-suggested-heading{margin:0 0 var(--s-10);font-size:var(--text-sm);font-weight:700;color:var(--ink)}
.rw-suggested-list{display:flex;flex-wrap:wrap;gap:var(--s-8)}
.rw-suggested-btn{flex:0 0 auto}
.rw-suggested-btn > span[aria-hidden]{color:var(--accent);font-weight:700}

/* ---- outline footer: Backup + Load a backup + the autosave line -
   unchanged class name from r2 (engine still emits `.rw-outline-footer`
   for this row - grepped, confirmed live). A hairline separates it from
   the suggested-sections block above, the same "own group, not proximity
   alone" language a plain border-top already gives every other divided
   group in this file (ROUND 5 removed the `.rw-design-spacer` rule this
   comment used to point at - Clear data's own divider was that spacer;
   Clear now lives in THIS footer instead, already separated from Backup/
   Load by nothing more than normal button gap, no spacer needed here). ---- */
.rw-outline-footer{
  display:flex;flex-wrap:wrap;gap:var(--s-8);
  margin-top:var(--s-16);padding-top:var(--s-16);border-top:1px solid var(--line);
}
.rw-outline-footer .btn{flex:1 1 auto}
.rw-outline-footer .tool-caveat{flex:1 1 100%;margin:var(--s-4) 0 0}

/* ---- `.rw-swatch-row`/`.rw-swatch`: UNCHANGED content/behaviour from r2/v1,
   only their parent moved (ROUND 5 - see the "plan 10b: resume-writer,
   ROUND 5 floating toolbar" block far below for the new parent,
   `.rw-design-popover`, and why). `.rw-lane-design` (the old Design TAB's
   own layout wrapper: flex column, `[hidden]` guard, `.tool-segmented`
   width cap) and `.rw-design-spacer` (Clear data's own divider inside that
   tab) are DELETED, not left dead - confirmed live against the shipped
   engine (grepped `public/js/tools/resume-writer.js`: neither class is
   emitted anywhere any more, the tablist itself is gone per that file's
   own header) rather than assumed, the same "directly confirmed, so remove
   rather than disclose" bar the ROUND 6 tabs-CSS removal note two blocks
   below already sets. `.rw-design-popover` (below) takes over
   `.rw-lane-design`'s old layout job for its new home. ---- */
.rw-swatch-row{display:flex;flex-wrap:wrap;gap:var(--s-10)}
.rw-swatch{
  width:var(--h-control);height:var(--h-control);flex:none;padding:0;border:none;
  border-radius:var(--r-pill);background:var(--rw-swatch-color,var(--accent));
  box-shadow:inset 0 0 0 1.5px var(--line);cursor:pointer;
  transition:transform var(--dur-1) var(--ease);
}
.rw-swatch:hover{transform:translateY(-1px)}
.rw-swatch.is-active{box-shadow:0 0 0 3px var(--wall),0 0 0 5px var(--ink)}
.rw-swatch:focus-visible{outline:2px solid var(--accent);outline-offset:3px}

/* ---- the document (.rw-page) itself. Same "page" recipe .cl-page/.rb-page
   already use (custom-property padding, --tile background, print-color-
   adjust in the print block below) - reused, not invented a third time.
   Dense-UI font treatment (--text-sm, tight line-height), matching
   .rb-page: a résumé is a table of facts, not paragraphs (AI/07-UI-
   STANDARDS.md SS2 rule 6). --rw-accent is set inline by the engine on
   every render; --accent is the CSS-side fallback for the one frame before
   that first runs. Shown at every width - below xl this doubles as the
   closest analog to a Preview tab (the "Document" lane is one of three
   below xl); only the panel's own field presentation ever changed with
   width in earlier rounds, and that width-gating is gone this round too
   (see file header - the panel fields are unconditional .tool-field). ---- */
.rw-page{
  --rw-page-pad-block: var(--s-40);
  --rw-page-pad-inline: var(--s-48);
  --rw-scale: 1;
  padding:var(--rw-page-pad-block) var(--rw-page-pad-inline);
  background:var(--tile);color:var(--ink);border-radius:var(--r-tile);
  box-shadow:0 10px 30px var(--shadow-leaf);
  font-size:calc(var(--text-sm) * var(--rw-scale));line-height:1.5;
}
.rw-page[data-rw-scale="small"]{--rw-scale:.92}
.rw-page[data-rw-scale="large"]{--rw-scale:1.12}
.rw-page[data-rw-density="compact"]{
  padding:var(--s-24);
  font-size:calc(var(--text-xs) * var(--rw-scale));line-height:1.4;
}
.rw-page[data-rw-font="serif"]{font-family:Georgia,'Times New Roman','Cairo',serif}
.rw-page[data-rw-font="mono"]{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,'Cairo',monospace}
.rw-page-header{margin-bottom:var(--s-20)}
.rw-page-summary{margin-bottom:var(--s-16)}
/* the block-level lines - name/title/summary/an entry's own title/desc -
   need display:grid so each claims its own full-width row, exactly like
   the static <p>/<h1> they are. a.rw-page-entry-title (a Links entry,
   restored this round - see the engine's own file header) is included in
   this selector list on purpose: an anchor needs the identical block/full-
   width/typography treatment its <p> sibling classes get, plus its own
   colour/decoration reset just below since a bare <a> would otherwise pull
   in the browser's default link blue - a second, unauthored accent. */
.rw-page-name,.rw-page-title,.rw-page-summary,
.rw-page-entry-title,.rw-page-entry-desc{display:grid;width:100%}
.rw-page-name{font-size:calc(var(--text-xl) * var(--rw-scale));font-weight:900;color:var(--rw-accent,var(--accent))}
.rw-page-title{font-weight:700;margin-top:var(--s-4)}
.rw-page-contact{font-size:var(--text-xs);color:var(--muted);margin-top:var(--s-8);direction:ltr;unicode-bidi:isolate}
.rw-page-section{margin-bottom:var(--s-16)}
/* the document heading (.rw-page-section-title) and its Interests-only
   text sibling (.rw-page-section-text) - NEW this round, neither existed
   before (grepped). This is r2's own >=xl-only "document heading" look
   (accent, uppercase, hairline underline) made UNCONDITIONAL, because the
   heading it used to belong to (.rw-section-header, forced open on the
   paper at >=xl only) never lives on the paper at any width any more - the
   document's own heading is a real, permanent node in renderDocument()'s
   own output now, not a repurposed accordion button. */
.rw-page-section-title{
  margin:0 0 var(--s-10);text-transform:uppercase;letter-spacing:.06em;
  font-size:calc(var(--text-sm) * var(--rw-scale));font-weight:700;
  color:var(--rw-accent,var(--accent));
  border-bottom:1.5px solid var(--rw-accent,var(--accent));
  padding-bottom:var(--s-4);
}
.rw-page[data-rw-template="modern"] .rw-page-section-title{
  border-bottom:none;color:var(--ink);font-weight:900;
}
.rw-page-section-text{font-size:var(--text-xs);margin-top:var(--s-4)}
.rw-page-entry-title{font-weight:700}
a.rw-page-entry-title{color:inherit;text-decoration:none}
a.rw-page-entry-title:hover{text-decoration:underline;text-underline-offset:2px}
.rw-page-entry-sub{font-size:var(--text-xs);color:var(--muted);margin-top:var(--s-2)}
.rw-page-entry-desc{font-size:var(--text-xs);margin-top:var(--s-4)}
/* modern template: identical accent-band-bleed technique as
   .rb-page[data-rb-template="modern"] .rb-page-head / .cl-page's own -
   same negative-margin-equals-own-padding maths, must stay algebraically
   equal to --rw-page-pad-block/-inline above or the bleed misaligns. */
.rw-page[data-rw-template="modern"] .rw-page-header{
  background:color-mix(in srgb, var(--rw-accent,var(--accent)) 12%, var(--tile));
  margin:calc(-1 * var(--rw-page-pad-block)) calc(-1 * var(--rw-page-pad-inline)) var(--s-20);
  padding:var(--s-24) var(--rw-page-pad-inline);
  border-start-start-radius:var(--r-tile);border-start-end-radius:var(--r-tile);
}
.rw-page[data-rw-template="modern"] .rw-page-name{color:var(--ink)}
.rw-page[data-rw-template="compact"] .rw-page-entry{margin-bottom:var(--s-8)}
/* retargeted this round - bug 1 in this block's own header comment:
   `.rw-section` never lives inside `.rw-page`; the document's real
   per-section wrapper is `.rw-page-section`. */
.rw-page[data-rw-template="compact"] .rw-page-section{margin-bottom:var(--s-10)}

/* =====================================================================
   >=xl (1128px, canonical xl - AI/07-UI-STANDARDS.md line 194): the real
   "paper on a desk" two-column layout. --container:1080px + 2x --gutter:
   24px caps .wrap at exactly 968px usable at every viewport this wide and
   never more (measured, not assumed, unchanged from r2) - panel 280px this
   round (was 240px - see the "big preview" numbers in this block's own
   file-header comment for the full reasoning and cost) + gap 24px leaves
   the document lane at exactly 664px = 0.836 of A4 (794px), by
   construction: 1fr already resolves to 664 given the fixed 280px second
   track, so a future container change keeps this correct for free instead
   of drifting against a hardcoded width.
   ===================================================================== */
@media (min-width:1128px){
  .rw-shell{grid-template-columns:1fr 280px}
  /* the panel's own Structure/Design tabs (.rw-panel-tabs-host) already
     provide this exact switch at this width - the top-level .rw-mode-
     switch-host the engine still renders (same 2-option set,
     unconditionally, at this width per its own modeValues()) would
     otherwise duplicate it verbatim in its own full-width row above the
     grid. Hiding it here is this seat's own CSS-only call, not stated
     either way by the engine - disclosed, not silent (unchanged from r2). */
  .rw-mode-switch-host{display:none}

  /* the ground + the paper (ROUND 6, board row 357 third follow-up):
     Qandil - "this preview have bottom/top mergin that show a dark
     background not needed." The 32px block padding below made
     `.rw-document-content`'s own --wall-deep ground show as a visible
     strip above and below the paper - in dark theme (--wall-deep
     #1d1a15, notably darker than --wall #232019) that strip read as an
     unwanted dark band. Removed.

     This is the same ground/paper pair a `.rw-document-content-only`
     comment in ROUND 3 measured and fixed a real "sitting on an
     identical surface" bug for, so the separation is not dropped, only
     re-homed: `.rw-page` already carries its own inset hairline ring
     PLUS a drop shadow (`inset 0 0 0 1.5px var(--line)`, `0 10px 30px
     color-mix(...)` - ROUND 5, two blocks down), which reads the paper
     as a distinct object against whatever sits behind it with zero
     margin needed to announce it. Proof this still holds with 0 padding:
     `.rw-document-content`'s own height now equals its content's exactly
     (flex column, one child, no padding), so its `--wall-deep` background
     paints a rect pixel-identical to `.rw-page`'s own box and is fully
     covered by the paper - i.e. the ground token is kept (not deleted,
     in case a future empty-state or shorter-than-container child ever
     exposes it again) but is provably inert today, and the paper's own
     ring+shadow is what a person actually sees. Horizontal padding was
     already 0 before this round (unchanged) - the paper is 100% of the
     664px lane at this width regardless. */
  .rw-document-content{
    display:flex;flex-direction:column;align-items:center;
    background:var(--wall-deep);border-radius:var(--r-tile);
    padding:0;
  }
  .rw-document-content[hidden]{display:none}
  .rw-page{
    width:100%;max-width:794px;
    /* a hairline so the paper reads as a real edge even for a viewer whose
       theme puts --tile and --wall-deep close in luminance, on top of the
       existing drop shadow, not instead of it. */
    box-shadow:0 10px 30px var(--shadow-leaf),inset 0 0 0 1.5px var(--line);
  }

  /* the panel: sticky at the same offset .rb-pane-preview already uses,
     scrolling its own overflow rather than the page once its content
     outgrows the viewport - Structure alone can now hold up to 13 section
     accordions (7 core + 6 optional) plus the suggested-sections list,
     comfortably exceeding a short laptop screen.

     plan 10b: resume-writer panel, ROUND 7 (frontend-ui, board row 357
     fifth follow-up). Qandil, verbatim: "side bar not take fully height so
     when click at add section the dropdown list is cutoff." Measured live
     (demo CV loaded, 1280x641): `.rw-panel` max-height 545px against a
     641px viewport - 96px unused, the exact sum of the two tokens this
     calc() subtracts (`--s-72` top offset + `--s-24` bottom slack). The
     top offset clears the sticky nav on purpose (same value `.rb-pane-
     preview` uses, deliberately unchanged here) - the bottom `-var(--s-24)`
     term is not clearing anything, it is unused slack between the panel's
     own bottom edge and the viewport's, and it is the ONLY part of the 96px
     this seat can give back without re-opening the nav-clearance question.
     Dropped. `.rw-panel` now runs flush to the viewport's bottom edge, the
     same "no extra reserve past what the offset requires" shape `.rb-pane-
     preview` already uses (that element carries no max-height cap at all).

     BOUNDED-PANEL GUARD, touched deliberately (AI/10's own instruction:
     "if your height change touches the bounded-panel guard, update it
     deliberately - do not let it lapse"): the panel is still bounded to
     the viewport by construction - `calc(100vh - var(--s-72))` still caps
     it, just without the extra 24px of headroom. tests/Browser/
     ResumeWriterLayoutTest.php's "bounds the panel to the viewport..."
     test hardcodes `expectedMax = window.innerHeight - 96` - updated in
     the same edit to `- 72` (this file's own comment there is updated to
     match, not left describing a formula that no longer exists).

     Re-measured post-fix at the SAME 1280x641 viewport: max-height 569px
     (545 -> 569, the full 24px recovered), still never exceeding the
     viewport (proved, not assumed, by re-running the same "8 entries in
     one open section" overflow test the updated Pest assertion below
     exercises). Does not, on its own, fix the Add-a-section dropdown clip
     tools-engineer is separately relocating out of the panel entirely -
     disclosed in the dispatch brief as "yours either way," so this is the
     CSS-only half: more room, not a guarantee nothing can ever clip inside
     a scrolling container. */
  .rw-panel{
    display:flex;flex-direction:column;gap:var(--s-16);
    position:sticky;top:var(--s-72);align-self:start;
    max-height:calc(100vh - var(--s-72));overflow-y:auto;
    /* one-property fix (Qandil: "when expand any section it take less
       width bcoz of scroll bar, let's find fix for that"). Measured live:
       `scrollbar-gutter` computed `auto` - the gutter track only reserves
       space once content actually overflows, so opening a section (which
       can push `.rw-panel` past its own max-height into its own internal
       scroll) added an 11px native scrollbar that was not there a moment
       before, and every already-laid-out child - every field, every
       button - shifted 11px narrower the instant that happened. `stable`
       reserves the gutter unconditionally, scrolling or not, so opening a
       section never changes the available content width again. RTL: the
       property reserves space on whichever side the UA actually draws its
       own scrollbar, which already follows `direction` on its own (no
       physical left/right in this property to get backwards) - confirmed
       live at `/ar-EG`, gutter reserved on the panel's start (physical
       right) side, content width identical open vs collapsed, same as
       LTR. No new token - this is a keyword, not a colour/space/radius. */
    scrollbar-gutter:stable;
  }
  .rw-panel[hidden]{display:none}
}

@media print{
  /* resume-writer joins the shared "print: resume-builder + cover-letter-
     builder" ancestor-neutralisation block above (search "plan 10b:
     resume-writer" further up this file for the merge point) rather than
     repeating it a third time - see that block's own comment. What is
     resume-writer-only, because its DOM shape is neither .rb-panes nor a
     flat .tool-options sibling list: .rw-shell's own document lane / panel
     split and the sticky action bar. Nothing here is position:absolute/
     fixed (07 rule 14) and every ancestor establishing a containing block
     is already neutralised by the shared block above.

     MUCH SHORTER THAN r2's own print block, on purpose, not by omission:
     `.rw-panel` (which holds EVERY `.rw-section`/`.rw-entry`/button/field
     that used to need its own print-only hide rule) is hidden in full by
     the one rule below - there is no field, no button, no accordion chrome
     left anywhere inside `.rw-page` to individually neutralise any more.
     Verified live via a real generated PDF (tools-engineer's own handoff,
     SS6): 1 page, 0 form-control descendants in the printed node, using
     ONLY the rules below plus the unconditional (non-print-scoped) `.rw-
     page-*` rules above - zero print-only overrides needed for section
     headings, entry rows or the Interests text block, because none of
     them are width- or print-mode-gated in the first place any more. r2's
     own `.rw-section:not(:has(.rw-page-entry))` empty-section print
     suppression is gone too, not just unreachable - it is now redundant
     with, not replaced by, engine behaviour: renderDocument() itself never
     appends an empty section to `.rw-page` (`if (!entries || !entries.
     length) return;` before a list section is even built, checked against
     the r3 engine directly), so "a heading over nothing" cannot occur on
     the paper at any width, print or screen, with no CSS involved.

     ROUND 5 FIX (frontend-ui, board row 357 fourth follow-up / personas/
     QA/handoffs/resume-writer-r5-ui-frontend-ui.md) - BLOCKING, found by
     tools-engineer via a real generated PDF, not a code read (their own
     handoff SS6): this list still named `.rw-actionbar`, which ROUND 5's
     engine rewrite deleted outright - Export/Design now live in a NEW
     element, `.rw-doc-toolbar` (`position:fixed`, always visible,
     viewport-anchored - see that block's own header comment further down
     this file), which this list never named. `.rw-mode-switch-host` and
     `.rw-panel` are unaffected by ROUND 5 (still the same nodes) and stay
     in the list unchanged. Without this fix the "Design"/"Export PDF"
     pills print directly onto every résumé a visitor downloads - proved
     by rendering the broken PDF back as an image before touching this
     rule, and re-proved clean afterward (see the handoff for both).

     SLUG TAKEOVER (resume-builder swap, board row 348 follow-up): this
     engine now boots on `data-tool-slug="resume-builder"`, not
     `resume-writer` (public/js/tools/resume-builder.js's own file-header
     SLUG TAKEOVER note flags this exact rename as the one thing this
     lane had to do). `resume-writer` is kept alongside, not replaced -
     harmless once that registry row is retired, and non-breaking if it
     is still live for a moment while that happens in parallel. Without
     BOTH slugs here this whole block goes unreachable and the print bug
     the comment above describes resurfaces verbatim. */
  body:has([data-tool-slug="resume-writer"]) .rw-mode-switch-host,
  body:has([data-tool-slug="resume-writer"]) .rw-panel,
  body:has([data-tool-slug="resume-writer"]) .rw-doc-toolbar,
  body:has([data-tool-slug="resume-builder"]) .rw-mode-switch-host,
  body:has([data-tool-slug="resume-builder"]) .rw-panel,
  body:has([data-tool-slug="resume-builder"]) .rw-doc-toolbar{
    display:none;
  }
  body:has([data-tool-slug="resume-writer"]) .rw-shell,
  body:has([data-tool-slug="resume-builder"]) .rw-shell{
    display:block;
  }
  /* belt-and-suspenders: onExport() (tools-engineer's file) forces
     narrowLane='document' - and with it .rw-document-content visible -
     before calling window.print(), so this should never actually be
     [hidden] at print time. But print CSS cannot assume a script ran (a
     person can trigger the browser's own print dialog directly, bypassing
     Export, while on Structure/Design below xl). */
  body:has([data-tool-slug="resume-writer"]) .rw-document-content[hidden],
  body:has([data-tool-slug="resume-builder"]) .rw-document-content[hidden]{
    display:block;
  }
  body:has([data-tool-slug="resume-writer"]) .rw-document-content,
  body:has([data-tool-slug="resume-builder"]) .rw-document-content{
    background:none;padding:0;
  }
  body:has([data-tool-slug="resume-writer"]) .rw-page,
  body:has([data-tool-slug="resume-builder"]) .rw-page{
    print-color-adjust:exact;-webkit-print-color-adjust:exact;
    box-shadow:none;border-radius:0;width:auto;max-width:none;
  }
  body:has([data-tool-slug="resume-writer"]) .rw-page-section,
  body:has([data-tool-slug="resume-writer"]) .rw-page-entry,
  body:has([data-tool-slug="resume-builder"]) .rw-page-section,
  body:has([data-tool-slug="resume-builder"]) .rw-page-entry{
    break-inside:avoid;
  }
}

/* =====================================================================
   plan 10b: resume-writer panel density, ROUND 4 (frontend-ui, board row
   357 follow-up / personas/QA/handoffs/resume-writer-panel-ui-frontend-ui.md).
   Qandil, verbatim, after using r3's shipped panel: "side bar layout is so
   so so bad." Measured live with the demo CV loaded: panel 280px wide,
   545px visible, scrollHeight 11420px (21x the visible height), all 6
   sections open, 37 fields rendered at once. ROUND 3's own `.rw-section`
   rule two blocks up gives every section - open or closed - the identical
   filled/bordered card, with zero vertical rhythm between them (no gap,
   no margin anywhere in that block, confirmed by reading it before
   touching it), so a resting panel read as a stack of touching boxes, not
   a list. tools-engineer is fixing the BEHAVIOUR half in parallel
   (collapse-by-default, one section open at a time) on this same tool;
   this is the CSS-only visual-density half and does not depend on how
   many sections happen to be open at once, so it is safe to land on its
   own.

   The target, restated from the dispatch brief: a resting/collapsed
   section reads as one quiet list row - name, a small count, quiet
   dividers, generous row height - not a card; an OPEN section gets real
   containment so it is obviously the one thing being edited; the
   suggested-sections list reads as offered, not present - still, by
   Qandil's own words this round, "the weakest thing on the page."

   Closest existing sitewide precedent for "a list of compact toggle rows
   with quiet dividers," grepped before inventing anything: `.faq-item`/
   `.faq-q` (this file's own "tool-faq" block) - border-bottom divider,
   `background:none`, min-height 44, no per-row card. Reused directly.
   Closest precedent for "offered, not committed yet": `.dropzone`'s own
   dashed border (this file's own "tool-shell" block) - reused directly for
   the suggested-section pills instead of inventing a second "not here yet"
   language.

   Five changes:

   1. Collapsed rows become divider rows, not cards - `.rw-section` loses
      the unconditional background/radius/box-shadow ROUND 3 gave it two
      blocks up, in favour of a plain 1px `--line` divider (removed on the
      last section so it does not double with `.rw-suggested`'s own
      border-top just below it).
   2. The OPEN section only gets the card back (background/radius/inset
      ring), plus a little breathing margin so it visibly separates from
      the calm rows around it - scoped with `:has(> ... [aria-expanded=
      "true"])`, the same tool this file already uses to scope a rule to
      one specific live state rather than a class (07 SS10 "Mobile output
      position": `:has(.tool-gradient-code)`). No JS change needed -
      aria-expanded is already the engine's own real, live state.
   3. The section label ellipses instead of wrapping, so German's
      "Volunteer experience" (already the longest English label, and the
      brief's own warning that it gets longer translated) can never grow a
      collapsed row past one line / 44px. Targeted at `.rw-section-header >
      span:first-child` because the engine's own label <span>
      (buildSectionShell(), resume-writer.js) carries no class of its own
      to hook - confirmed by reading the function rather than assuming one
      exists. It is always appended first, before the optional count
      <span>, which DOES carry `.rw-section-summary`, so the structural
      selector is reliable.
   4. Suggested-section pills swap the plain `.btn.btn-ghost` ring (the
      same look every OTHER secondary action on this page already uses -
      Remove section, the per-entry move/remove row) for the dropzone's own
      dashed "offered" language, pill-shaped instead of a full dropzone.
   5. The sticky Structure/Design tabs (`.rw-panel-tabs-host`) currently
      scroll away with the rest of the panel: `.rw-panel`'s own
      `overflow-y:auto` (two blocks up, >=xl only) applies to its whole
      flex column, tabs included, because the tabs host is just a plain
      in-flow sibling of the two tabpanels inside that same scrolling box.
      Pinned to the top of the panel's own scrollport instead
      (`position:sticky;top:0`), inside the SAME existing >=xl-only block
      two blocks up so it does nothing at any narrower width, where
      `.rw-panel` is not an independent scroll container to begin with.
   6. FOUND WHILE VERIFYING THE ABOVE AT RTL x dark x 375 (AI/10's own
      instruction: verify at the coordinates where a bug would live, not
      the comfortable desktop-English default) - a real, pre-existing,
      unrelated-to-density BLOCKER two blocks up, not introduced by this
      round: `.rw-document-content[hidden]{display:none}` and
      `.rw-panel[hidden]{display:none}` exist ONLY inside the >=xl media
      block. Below 1128px there is no such override, so the UNCONDITIONAL
      base rules `.rw-document-content{display:block}` / `.rw-panel{
      display:block}` two blocks up (author, equal specificity to the UA's
      own `[hidden]{display:none}`) win by ORIGIN regardless of the actual
      `hidden` attribute - the exact "[hidden] cascade guard" bug this same
      file already names and fixes four separate times elsewhere (`.btn`,
      `.tool-field`, `.tool-check`, `.tool-color`, `.tool-file-list`,
      `.tool-video`, `.tool-segmented`). Proved live before writing this
      down: clicking the mobile mode-switch to "Document" left `.rw-panel`
      with `hidden === true` in the DOM while `getComputedStyle(...)
      .display` still read `"block"` - the Structure/Design panel (every
      field on the resume) stayed fully rendered, stacked under the
      document, on every viewport this tool actually ships to below xl.
      Fixed the same way the other six were: the missing guard, added
      unconditionally so it also covers the width this round's own density
      work is verified at. The existing >=xl-scoped copies are left in
      place (now redundant, harmless, not worth a second diff for).

   SECOND PASS, same round, after Qandil looked at the Design tab
   specifically: "some feature broke the design, and buttons are too big,
   and also I need better scrollbar not native scroll bar that take a lot
   of width."

   7. CONTRADICTING THE BRIEF ON ONE NUMBER, per AI/10's own evidence
      standard ("a lane should contradict the brief when the code
      disagrees"): the coordinator's relay measured ".rw-panel 545px tall
      on Structure, 753px tall on Design, the page grows." Reproduced live
      at the SAME viewport (1280x641, the exact number that makes
      `calc(100vh - var(--s-72) - var(--s-24))` = 545px, matching their own
      Structure figure) before touching anything: `.rw-panel`'s
      `getBoundingClientRect().height` reads 545 on BOTH tabs, never 753 -
      the cap two blocks up already holds. 753 is `.rw-panel.scrollHeight`
      (the CONTENT height, i.e. what would render if nothing clipped it),
      not `getBoundingClientRect().height` (what actually paints) - the
      exact two-metrics-conflated shape AI/10's own trap catalogue already
      names elsewhere in this project. `document.documentElement.
      scrollHeight` is IDENTICAL (3936) on both tabs - the page provably
      does not move. So: no fix needed here, and none applied - the
      "layout broke" read is real (Qandil saw *something* wrong), but its
      cause is item 9 below (an ugly, wide, hard-to-notice-as-a-boundary
      native scrollbar reads as "the page changed"), not a broken height
      cap.
   8. The controls genuinely are too big for this column, though - real,
      reproduced live: four stacked `.tool-segmented` groups (Template/
      Font/Scale/Density) at 44px per button, a 123x57 Clear-data button,
      and 6 (not 7 - recounted live, `.rw-swatch` query) accent swatches at
      44x44 wrapping 5-then-1 in a 265px-wide row. `--h-control-sm` (36px)
      is defined in `:root` for exactly this ("dense desktop-only chrome...
      never the primary path on touch", AI/07-UI-STANDARDS.md SS4) and,
      grepped, has NEVER been used anywhere in the shipped stylesheet -
      this is its first real consumer, not a deviation from one. Scoped to
      `.rw-lane-design` inside the SAME >=xl block two blocks up (this
      whole panel is already xl-only, per r3's own file-header "SCOPE"
      note - nothing here touches a touch surface). Swatches shrink too
      (item 9's reclaimed scrollbar width is what makes 6 fit one row
      without also narrowing the gap - see its own note below).
   9. There is NO existing "thin visible" scrollbar pattern in this file to
      reuse, despite the relay's claim - grepped `-webkit-scrollbar`/
      `scrollbar-color` across `site.css` and the repo before writing
      anything new: the only prior art is `.tool-segmented`'s
      `scrollbar-width:none` + `::-webkit-scrollbar{display:none}`, which
      HIDES the bar entirely - the wrong shape for "the track stays visible
      enough to show there is more content," Qandil's own explicit ask.
      Built new, from tokens only (`--ink`/`--r-pill`, no new colour or
      radius): 15px native down to 8px, track transparent (shows the
      panel's own ambient background through it, no extra token needed),
      thumb `color-mix(in srgb, var(--ink) 35%, transparent)` - `--ink`
      guarantees correct contrast against its own background in both
      themes by construction, the same reasoning `--muted`'s own AA-on-
      wall guarantee already rests on. Reclaims 7px of the panel's 280px
      (15 -> 8) - the same 7px that turns the swatch row from "265px
      available, 266px needed at 36px each" (a hair too tight) into 272px
      available - the two fixes are not independent, item 8's one-row
      swatch fit depends on this one landing first.

   No JS/Blade touched (frontend-ui does not own public/js/tools/resume-
   writer.js - see personas/02-frontend-ui.md "you own"/"you never touch").
   No new colour, radius, spacing, breakpoint or z-index token - every
   value below is an existing `--s-*`/`--r-*`/`--line`/`--wall`/`--accent`
   token, or a selector-only change. No transition is added anywhere in
   this block: the hover swaps below are instant background/border-colour
   changes, not animated ones - rule 5 ("Motion is transform + opacity
   only... never animate... color") already forbids animating colour, so
   nothing here needed a reduced-motion off-switch, and the existing block
   was left untouched (confirmed nothing new needed adding to it).

   ONE existing regression test's own assertion changed to match, disclosed
   here rather than silently: tests/Browser/ResumeWriterLayoutTest.php's
   "renders an offered-but-absent suggested section..." test read a section
   card's background WITHOUT opening it, valid only under ROUND 3's
   "every section is always a card" design (item 1 above deliberately ends
   that). Updated to open the section first, since "present-and-filled" is
   now expressed by the OPEN state (item 2), not by mere existence - see
   that file's own updated comment on the test for the full reasoning, and
   this round's handoff for the watched-fail proof (AI/10's own evidence
   standard: every regression guard is proven by breaking it and watching
   it fail before trusting it again).
   ===================================================================== */

/* ---- 1: collapsed rows are quiet divider rows, not cards. Cascade order
   does the override (later declaration of the same properties wins at
   equal specificity), same technique this whole file already uses
   wherever a later block layers on top of an earlier one. A touch more
   vertical room than ROUND 3's own s-12 - comfortable, not cramped -
   `.faq-q`'s own s-16 is the ceiling this stays under, since this row also
   carries a count span the FAQ list never had. ---- */
.rw-section{background:none;border-radius:0;box-shadow:none;border-bottom:1px solid var(--line)}
.rw-structure-panel > .rw-section:last-of-type{border-bottom:none}
.rw-section-header{padding-block:var(--s-14)}
.rw-section-header:hover{background:color-mix(in srgb, var(--ink) 5%, transparent)}

/* ---- 2 (reversed in ROUND 6, board row 357 third follow-up): Qandil,
   after using ROUND 4's shipped panel - "when expand the options like
   header it become rounded borders - we need one style for both expanded
   and collapse." ROUND 4 gave the OPEN section real card containment
   (background/radius/inset ring) while the collapsed row stayed a flat
   divider - a shape swap he is explicitly rejecting: opening a row must
   not change its silhouette. Every `.rw-section` now keeps rule 1's flat,
   radius-less, border-bottom-divider silhouette in BOTH states - no
   `background`/`border-radius`/`box-shadow` override here at all any
   more. "Opened" stays legible three other ways, none of them a shape
   change: the chevron rotates (rule two blocks up, untouched), the body
   appears below it (`.rw-section-body[hidden]`, untouched), and the
   header itself picks up a quiet accent tint - the SAME 8%-accent-tint
   idiom `.rw-suggested-btn:hover` already uses a few rules down, not a
   new colour language. */
.rw-section:has(> .rw-section-header[aria-expanded="true"]) .rw-section-header{
  background:color-mix(in srgb, var(--accent) 6%, transparent);
}
.rw-section:has(> .rw-section-header[aria-expanded="true"]) .rw-section-header:hover{
  background:color-mix(in srgb, var(--accent) 10%, transparent);
}

/* ---- 3: the label ellipses instead of wrapping - see file-header item 3
   for why this targets structural position, not a class. ---- */
.rw-section-header > span:first-child{
  flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
.rw-section-summary{flex:none;white-space:nowrap}

/* ---- 4: suggested-section pills borrow the dropzone's own "offered, not
   committed" dashed language instead of the plain ghost-button ring every
   OTHER secondary action on this page already uses - see file-header item
   4. `box-shadow:none` first, since `.btn-ghost`'s own inset ring would
   otherwise show through alongside the new dashed border. ---- */
.rw-suggested-btn{
  background:none;box-shadow:none;border:1.5px dashed var(--line);
}
.rw-suggested-btn:hover{
  box-shadow:none;border-color:var(--accent);
  background:color-mix(in srgb, var(--accent) 8%, transparent);
}
.rw-suggested-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-color:var(--accent)}

/* ---- 6: the missing [hidden] cascade guard, unconditional (every width) -
   see file-header item 6 for the live proof. Matches this file's own
   established fix for the identical bug on six other selectors. ---- */
.rw-document-content[hidden]{display:none}
.rw-panel[hidden]{display:none}

@media (min-width:1128px){
  /* ---- 5 (retired): this used to pin the Structure/Design tabs to the top
     of the panel's own scrollport. `.rw-panel-tabs-host` no longer exists in
     the DOM (see the "ROUND 6 tabs redesign - BUILT, THEN RETIRED" note
     further down this file) - rule removed rather than left targeting
     nothing, same reasoning as that note. ---- */

  /* ---- 8: dense desktop-only chrome for the Design controls -
     `--h-control-sm` used as documented (see file-header item 8).
     RE-SCOPED, ROUND 5 (personas/QA/handoffs/resume-writer-r5-ui-
     frontend-ui.md): this used to target `.rw-lane-design` (the old
     Design TAB, which only ever rendered >=1128px - "SCOPE" note, r3
     header). ROUND 5 moved the same controls into `.rw-design-popover`,
     which is reachable at EVERY width now (the floating toolbar that
     opens it is never width-gated) - so the dense 36px sizing MUST stay
     inside this >=1128px block, same as before, rather than becoming
     unconditional: below this width the popover can be opened from a
     touch surface, where 44px (`--h-control`, the base `.tool-segmented-
     btn`/`.rw-swatch` size, unchanged two blocks up/down) is a floor, not
     a target (AI/07-UI-STANDARDS.md SS4). Padding trimmed alongside the
     height so the button reads proportionate, not just clipped-taller-
     than-its-frame; `.tool-segmented-btn`'s own `min-width:max-content`
     (two blocks up) means the button still never clips its own label at
     this padding. The Clear-data dense rule (`.rw-lane-design [data-rw-
     clear]`) is DROPPED, not re-scoped: Clear moved out of Design
     entirely, into the Structure footer (`.rw-outline-footer`, ROUND 5,
     see that block's own updated comment above) at the panel's normal,
     non-dense density - it was never really a Design/appearance control,
     confirmed live (no element matches that selector anywhere any more). */
  .rw-design-popover .tool-segmented-btn{min-height:var(--h-control-sm);padding:0 var(--s-12)}
  .rw-swatch{width:var(--h-control-sm);height:var(--h-control-sm)}

  /* ---- 9: a thin, visible custom scrollbar for the panel's own internal
     scroll - see file-header item 9 for why this is new rather than
     reused. Firefox (`scrollbar-width`/`scrollbar-color`) and WebKit/
     Chromium (`::-webkit-scrollbar*`) both covered so it holds across
     engines; `--r-pill` on the thumb, not a new radius. ---- */
  .rw-panel{
    scrollbar-width:thin;
    scrollbar-color:color-mix(in srgb, var(--ink) 35%, transparent) transparent;
  }
  .rw-panel::-webkit-scrollbar{width:8px}
  .rw-panel::-webkit-scrollbar-track{background:transparent}
  .rw-panel::-webkit-scrollbar-thumb{
    background:color-mix(in srgb, var(--ink) 35%, transparent);
    border-radius:var(--r-pill);
  }
  .rw-panel::-webkit-scrollbar-thumb:hover{background:color-mix(in srgb, var(--ink) 55%, transparent)}
}

/* =====================================================================
   plan 10b: resume-writer panel/document rebuild, ROUND 5 (frontend-ui,
   board row 357 second follow-up / personas/QA/handoffs/resume-writer-
   panel-ui-frontend-ui.md). Qandil, after ROUND 4's density pass landed:
   "build this design from scratch please - this design is so so bad. look
   how it take big spacing and button so so big. look how bad shadow. look
   how preview is broke." Four root causes found and fixed below, each
   proved live (measured, then fixed, then re-measured) before being
   written down - AI/10's own evidence standard.

   10. THE FIELD-HEIGHT BUG - the actual cause of "big spacing," not a
       spacing choice. `.tool-field{flex:1 1 240px}` (this file's shared,
       sitewide option-field recipe) sets 240px as a WIDTH hint for the
       HORIZONTAL flex rows it normally lives in ([data-tool-options]
       elsewhere on every tool page). `.rw-section-body` (ROUND 3's own
       rule, unchanged) is `display:flex;flex-direction:column` - inside a
       COLUMN flex container, flex-basis resolves against the vertical
       (main) axis, so the same 240px becomes a HEIGHT hint instead. Proved
       live before touching anything: a Header-section field measured
       240px tall, `getComputedStyle().flexBasis` read `240px`, parent
       `flexDirection` read `column`. This is the SAME mechanism
       AI/06-LESSONS.md SS4 already names for a different element
       (`flex-basis:100%` becoming a height hint once its own parent turned
       `flex-direction:column`) - the general shape of this trap recurring
       on a different selector, not a new discovery. Fixed by removing the
       flex-basis inside this one context only (`.rw-section-body
       .tool-field`), which does not touch `.tool-field`'s own 240px WIDTH
       behaviour anywhere else on the site (every other tool's option
       panel is a horizontal row, never a vertical accordion body).
       Confirmed live post-fix: 240px -> 82px tall, 237px wide (full
       column width via `.rw-section-body`'s own default `align-items:
       stretch`, never touched).
   11. THE SHADOW BUG - `.rw-page`'s box-shadow used `var(--shadow-leaf)`
       RAW, with no alpha. `--shadow-leaf` (`:root`) is itself an OPAQUE
       hex (`#4a453c` light / `#0a0906` dark) - DESIGN.md's own token table
       names it "every shadow shape," meaning every USE SITE is expected to
       tint it, not paint it solid. Grepped every `shadow-leaf` box-shadow
       in this file before touching anything: the dominant, correct,
       already-established idiom (7+ call sites - tile lifts, card floats,
       the toast/alert region) is `color-mix(in srgb, var(--shadow-leaf)
       N%, transparent)` at 30-65%; `.rw-page` was one of a handful of
       older opaque outliers, not the pattern. A 30px-blur fully-opaque
       shadow is exactly the "muddy smear" Qandil described - reads as a
       filled shape, not a soft cast shadow, because it never fades.
       Fixed to `color-mix(in srgb, var(--shadow-leaf) 45%, transparent)`
       - same geometry (`0 10px 30px`), same token, no new colour - the
       midpoint of the established 30-65% range used elsewhere, no
       invented number. Left `.rw-actionbar`'s own separate opaque
       shadow (site.css, ROUND 3) untouched - not named in the brief, a
       small tight `-12px` offset shadow reads very differently from a
       30px-blur page shadow, and changing an un-flagged rule risks a
       silent second diff nobody asked to review.
   12. THE "MODERN + COMPACT" BLEED BUG - found by a systematic sweep of
       every layout-affecting combination (3 templates x 3 fonts x 3 scales
       x 2 densities = 54; the 6-swatch accent axis is colour-only and
       cannot move layout, spot-checked rather than multiplied in, since a
       6x multiplier over 54 already-clean geometry checks would not add a
       new failure mode - it changes a CSS custom property value, nothing
       about box geometry). 9 of 54 failed, EVERY ONE the same root cause
       (all 3 fonts x all 3 scales x template=modern x density=compact,
       nothing else): `.rw-page[data-rw-template="modern"] .rw-page-header`
       bleeds edge-to-edge via `margin:calc(-1 * var(--rw-page-pad-block))
       calc(-1 * var(--rw-page-pad-inline)) ...` (two blocks up) - correct
       ONLY when the page's real `padding` still equals those two custom
       properties. `.rw-page[data-rw-density="compact"]` (two blocks up)
       sets a flat `padding:var(--s-24)` directly, WITHOUT updating
       `--rw-page-pad-block`/`--rw-page-pad-inline` - so modern's bleed
       math keeps subtracting the DEFAULT 40/48px even though the real
       padding shrank to 24px. Proved live before fixing: header measured
       48px wider than the page on each side (padInline stayed `48px`,
       realPadding read `24px`) - the header visibly overhung the paper's
       own rounded edge by 24px per side. Fixed by having the compact-
       density rule update the same two custom properties the bleed math
       actually reads, not just the literal `padding` shorthand - proved
       again post-fix (header/page edges pixel-identical), then the full
       54-combination sweep re-run clean (0 failures) before writing this
       down. This one fix is very likely what Qandil saw as "the preview is
       broke" and "rendered in a monospace font with the header laid out
       differently" - font is provably irrelevant to this bug (all 3 fonts
       failed identically under modern+compact), so a mono-font screenshot
       showing a broken header is this same bug, not a font-specific one.
   13. Swept LTR only above (54/54); RTL re-swept separately at `/ar-EG`
       with the same 54 combinations after all four fixes landed - see the
       handoff for the full pass/fail table, not repeated in a comment.

   No JS/Blade touched (frontend-ui does not own public/js/tools/resume-
   writer.js). No new colour, radius, spacing, breakpoint or z-index token.
   ===================================================================== */

.rw-section-body .tool-field{flex:1 1 auto}
.rw-page{box-shadow:0 10px 30px color-mix(in srgb, var(--shadow-leaf) 45%, transparent)}
.rw-page[data-rw-density="compact"]{--rw-page-pad-block:var(--s-24);--rw-page-pad-inline:var(--s-24)}

@media (min-width:1128px){
  .rw-page{
    box-shadow:0 10px 30px color-mix(in srgb, var(--shadow-leaf) 45%, transparent),inset 0 0 0 1.5px var(--line);
  }
}

/* =====================================================================
   plan 10b: resume-writer, ROUND 6 tabs redesign - BUILT, THEN RETIRED
   IN THE SAME SESSION (frontend-ui, board row 357 third follow-up).
   Qandil asked for a quiet register on the panel's Structure/Design
   tablist ("tabs looks so so bad"); a scoped `.rw-panel-tabs-host .tool-
   segmented*` override (underline-on-accent instead of the shared
   filled-pill recipe, zero risk to any other tool's segmented control)
   was written and verified live in both themes and RTL.

   Mid-session, the coordinator relayed a newer reference from Qandil - a
   floating bottom-centre toolbar - and asked this seat to hold off on
   further tab polish until tools-engineer decided whether the Design tab
   should go. It went: tools-engineer's own engine header (public/js/
   tools/resume-writer.js, current top-of-file comment) confirms the
   Structure/Design tablist is deleted outright (`buildTabs()`/
   `panelTabControl`/`.rw-panel-tabs-host`'s dual-home logic - their own
   words, "are gone"), replaced by a single always-Structure panel plus a
   new floating `.rw-doc-toolbar` holding Export, a Design popover and
   more. Confirmed live by grep against the shipped engine, not inferred:
   `.rw-panel-tabs-host` no longer appears anywhere in resume-writer.js.

   So the block this comment replaces was deleted rather than left as
   inert dead CSS (unlike the suggested-sections rename just below, which
   this file could not confirm was fully retired elsewhere and so kept as
   disclosed dead weight) - here the removal is directly confirmed, and a
   selector that names a host div nothing in the DOM ever creates again is
   worse than no rule at all: it documents a feature that no longer
   exists and invites the next reader to "fix" a control that is not
   there. The floating toolbar itself is real, new, frontend-ui-owned
   surface (per the coordinator's own note, "if it lands, it becomes
   yours to style") but landed mid-edit, still actively changing class
   names between the moment it was found and the moment this note was
   written (`.rw-design-popover` alone was `designPanel` -> `designPopover`
   within the same session) - styling it now would be chasing a moving
   target the same way `.rw-suggested-btn` briefly was. Left for its own
   round, once tools-engineer files a handoff and the shape holds still.
   ===================================================================== */

/* ---- suggested sections: trigger + menu (tools-engineer's already-shipped
   "one control plus a menu" replacing the six standalone pills - board row
   357's own "Also incoming" note, landed in public/js/tools/resume-
   writer.js ahead of its own handoff being filed). `.rw-suggested-btn`'s
   dashed-pill CSS a few blocks up (ROUND 4) now matches nothing live - the
   engine renders `.rw-suggested-trigger`/`.rw-suggested-menu`/`.rw-
   suggested-menuitem` instead - so every menu item was falling back to the
   UA default `<button>` (grey #efefef fill, outset border, confirmed live:
   `getComputedStyle(...).backgroundColor` read `rgb(239, 239, 239)`, the
   browser default, not a token). A real, live, visible defect in this
   file's own responsibility, found while re-running this round's own
   "offered-but-absent suggested section" regression test - watched it fail
   first (AI/10's evidence standard) before writing anything here; the
   failure was `.rw-suggested-btn`'s selector matching zero elements, not a
   flake. `.rw-suggested-btn`/`.rw-suggested-list` (ROUND 4, above) are left
   in place, now dead rather than deleted - a CSS-only lane does not own the
   engine and cannot confirm nothing else still renders the old markup;
   disclosed here rather than silently removed.

   Reuses `.lang-menu`/`.lang-link`'s already-established "trigger + role=
   menu popover" recipe (site.css ~1877) rather than inventing a second one
   - same positioning idiom, same layered shadow, same radius/token
   language. No animation: the engine toggles visibility via the native
   `hidden` attribute only (no open/close class the way `.lang-switch.is-
   open` gets one - confirmed live, `menu.className` stays `"rw-suggested-
   menu"` before and after opening), so there is nothing to key an opacity/
   transform transition off without a JS change this file does not own -
   left as an instant show/hide, the same treatment every other `[hidden]`-
   only toggle in this file already gets. The trigger itself needs no new
   CSS - it already carries `.btn.btn-ghost`, the same reused recipe the
   ROUND 4 dashed pills reused for the same reason.

   ROUND 8, BLOCKING (frontend-ui, per tools-engineer's own r6 handoff,
   `personas/QA/handoffs/resume-writer-r6-tools-engineer.md` SS9 item 1,
   exact fix named there): this menu had exactly ONE consumer when it was
   written - the Structure panel's own "Add a section" trigger, opening
   DOWNWARD into a tall scrollable sidebar with room below it. It now has
   TWO consumers (Add a section, the new Data menu) and both live inside
   `.rw-doc-toolbar`, anchored near the viewport's own bottom edge - opening
   downward from there has no room and renders off-screen at every window
   height, live-measured by tools-engineer before this fix existed. Flipped
   to open UPWARD (`bottom` instead of `top`), the same direction `.rw-
   design-popover` (below) already uses for the identical reason. Both
   current consumers need the same direction, so the shared rule itself is
   flipped rather than adding a per-consumer override - if a future 3rd
   consumer ever disagrees, that is the point to split it, not before.

   Also new this round: `max-height`+`overflow-y:auto`, matching `.rw-
   design-popover`'s own established answer to "what if the menu is taller
   than the space above the toolbar" - flipping direction alone is not
   enough on a short viewport with a long menu (Add a section can list up
   to the tool's own full set of optional sections); this caps it instead
   of letting it grow past the top of the screen, and scrolls the rest,
   reusing the SAME thin-scrollbar treatment `.rw-design-popover` and `.rw-
   panel` already ship (extended below, not duplicated as a third selector
   list).

   STACKING: z-index is now a small, deliberate LOCAL value (`1`), not
   `var(--z-nav)` - `.rw-doc-toolbar` itself moved off `--z-nav` this round
   (see the token's own comment, root variable block, and `.rw-doc-toolbar`
   below) onto the new `--z-float` rung, and both of this menu's real
   consumers are DOM descendants of that toolbar, so this z-index is only
   ever compared against the toolbar's OTHER children (plain buttons, all
   z-index:auto) inside the stacking context the toolbar itself already
   establishes - it never needs to reach the page-level `--z-nav` value to
   win locally, and reusing that token here (its old value) would have
   implied a page-level meaning this number no longer has. */
.rw-suggested-menu-host{position:relative}
.rw-suggested-menu{
  position:absolute;bottom:calc(100% + var(--s-8));inset-inline-start:0;z-index:1;
  min-width:220px;margin:0;padding:var(--s-8);
  max-height:min(60vh,420px);overflow-y:auto;
  background:var(--wall);border-radius:var(--r-tile);
  box-shadow:0 18px 40px -18px color-mix(in srgb, var(--shadow-leaf) 55%, transparent),
             0 4px 14px -6px color-mix(in srgb, var(--shadow-leaf) 30%, transparent);
  scrollbar-width:thin;
  scrollbar-color:color-mix(in srgb, var(--ink) 35%, transparent) transparent;
}
.rw-suggested-menu::-webkit-scrollbar{width:8px}
.rw-suggested-menu::-webkit-scrollbar-track{background:transparent}
.rw-suggested-menu::-webkit-scrollbar-thumb{
  background:color-mix(in srgb, var(--ink) 35%, transparent);
  border-radius:var(--r-pill);
}
.rw-suggested-menu::-webkit-scrollbar-thumb:hover{background:color-mix(in srgb, var(--ink) 55%, transparent)}
/* precautionary [hidden] cascade guard (07's own established pattern, six
   prior instances) - no rule above sets an unconditional `display` on this
   element today, so this is not live-fixing a bug, only foreclosing the
   next person adding one without rediscovering the trap. */
.rw-suggested-menu[hidden]{display:none}
.rw-suggested-menuitem{
  display:flex;align-items:center;width:100%;
  min-height:var(--h-control);padding:var(--s-10) var(--s-12);
  border-radius:calc(var(--r-tile) - 10px);
  background:none;border:none;cursor:pointer;
  color:var(--ink);font-family:inherit;font-size:var(--text-sm);font-weight:500;text-align:start;
}
.rw-suggested-menuitem:hover{background:color-mix(in srgb, var(--accent) 14%, transparent)}
.rw-suggested-menuitem:focus-visible{outline:2px solid var(--accent);outline-offset:-2px;border-radius:var(--r-focus)}

/* =====================================================================
   plan 10b: resume-writer, ROUND 5 - the floating document toolbar and its
   Design popover (frontend-ui, board row 357 fourth follow-up / personas/
   QA/handoffs/resume-writer-r5-ui-frontend-ui.md). Styles tools-engineer's
   own ROUND 5 shell rewrite (public/js/tools/resume-writer.js, current
   top-of-file comment) - the Structure/Design tablist is deleted outright
   (confirmed live by grep: `.rw-panel-tabs-host` no longer appears
   anywhere in the shipped engine) and replaced by a single always-
   Structure `.rw-panel` plus this new element: `.rw-doc-toolbar`, a
   `position:fixed` pill anchored to the viewport, holding a "Design"
   trigger (opens `.rw-design-popover`, a labelled `role="group"`, NOT a
   menu) and Export. Qandil's own reference: "a dark rounded pill hovering
   bottom-centre over the canvas... help us to save a lot of spacing for
   others options and data."

   REFERENCE SCOPE, disclosed rather than silently narrowed (tools-
   engineer's own handoff already named this for the engine side; restated
   here for the CSS side): the reference's other five icons (select/pan/
   sparkle/pen/settings), its "50% v" zoom control and any selection-
   attached formatting bar are NOT built - this product has no canvas
   selection/panning/drawing, the zoom control needs a real `@page` number
   this tool's print stylesheet does not define yet, and `.rw-page` is a
   read-only preview with zero form controls at any width, so there is no
   selected, editable text for a formatting bar to act on. Two controls
   only: Design, Export.

   WHAT IT SITS ON TOP OF - REWRITTEN, ROUND 8. The paragraph this replaced
   argued `position:fixed` (viewport-anchored) was correct because "nothing
   behind the toolbar is ever permanently unreachable... `.site-foot` still
   follows after it." That was true and also incomplete: it checked whether
   scrolling could still REACH content underneath, never what the toolbar
   would still be SITTING ON once you had. Qandil, live: *"wtf is that ???"*,
   a screenshot of the toolbar apparently on the tools hub page. Not a
   confusion, not cross-page leakage (checked for Turbo/Livewire/htmx -
   none) - measured on `/en/tools/resume-writer` itself: this page renders
   the FULL 126-tool catalogue below the tool (`content below .rw-shell`
   2344px, page `scrollHeight` 3063px). `position:fixed` has no concept of
   "the tool's own extent" - it floats over the viewport for the ENTIRE
   3063px, so a visitor who scrolls past the résumé keeps a document-editing
   pill hovering over 2344px of unrelated tool cards. A control that acts on
   a document has no business floating over a list of other tools.

   THE FIX bounds the toolbar to `.rw-shell` (docLane + panel, the tool's
   own content - the catalogue is a separate section below it, untouched by
   this round, see the file-header "Also outstanding" note), but NOT via
   `position:sticky` - a real attempt at "sticky scoped to `.rw-shell` via
   a CSS Grid overlap" was built and live-tested first, and abandoned for a
   concrete, proven reason: this site's own `body{overflow-x:hidden}`
   (root layout rules, exists to clip the decorative ambient leaf layers -
   AI/06-LESSONS.md's own documented reason, unrelated to this tool and not
   this round's to touch) forces `overflow-y` to compute as `auto` too
   (CSS Overflow spec: one non-`visible` axis forces the other to `auto`),
   which makes `<body>` register as a scroll container even though it never
   actually scrolls - and `position:sticky`'s positioning is computed
   against exactly that nearest scroll-container ancestor, not the real
   viewport. Proven, not assumed: a `position:sticky;bottom:...` toolbar
   scrolled in a dead-straight line with zero clamping at every position
   tested, through real Playwright (not just the interactive pane); a
   live A/B (`document.body.style.overflowX='visible'`, nothing else
   changed) made the exact same rule clamp correctly. `body`'s own
   `overflow-x:hidden` is sitewide and deliberate - not something one
   tool's toolbar gets to override.

   So `.rw-doc-toolbar` STAYS `position:fixed` (unconditionally visible,
   viewport-anchored, immediately reachable on load regardless of content
   height - the property that made this element correct in the first
   place, and the one a sticky-based fix would have quietly cost: a short
   or empty résumé makes `.rw-shell` shorter than a typical viewport, and
   a sticky box has no mechanism to pull itself INTO view from below the
   fold the way `fixed` does for free). The actual "gone once scrolled
   past the tool" fix moves to where it belongs given that constraint:
   `site.js`'s own `IntersectionObserver` (plan 10b block, `public/js/
   site.js`) watches `.rw-shell` itself and toggles this element's native
   `hidden` attribute once `.rw-shell` stops intersecting the viewport at
   all - `.rw-doc-toolbar[hidden]{display:none}` (this rule, a few lines
   down) is the same "author `display` beats the UA's own `[hidden]`"
   guard this file already applies everywhere else, needed because this
   rule itself sets an unconditional `display:flex`.
   - Centring is UNCHANGED from ROUND 5: `left:50%;transform:
     translateX(-50%)`, physical `left` rather than the logical
     `inset-inline-start` - see that rule's own preserved comment for why
     (a logical offset resolves to the OPPOSITE physical side under
     `dir="rtl"`, and the same `translateX(-50%)` that correctly recentres
     a `left:50%` box instead DOUBLE-shifts a `right:50%` one further
     off-screen - exactly how this bar went 120px off-screen in Arabic the
     first time, board row 356/AI-10 Part 2). Re-verified live in `/ar-EG`
     after this round's changes anyway (SS-verify below), since the
     dispatch's own instruction is to re-check after ANY positioning
     change, not just ones that touch centring directly.
   - No compensating bottom padding was added anywhere to "reserve" space
     for the pill - unchanged reasoning from ROUND 5: permanent chrome
     space is exactly what a floating toolbar exists to avoid.
   - The pre-existing "the whole 126-card catalogue renders under every
     tool page" fact this round's fix relies on is NOT this round's bug to
     fix - noted in the handoff, left alone, per the coordinator's own
     explicit scope line.

   STACKING - REWRITTEN, ROUND 8. z-index no longer reuses `--z-nav` (10).
   Live-caught, not theorised: with the toolbar AND the sitewide `.tools-
   megabox` dropdown both at `--z-nav`, opening the Tools nav while on this
   page put the résumé toolbar VISUALLY ON TOP of the open mega-box - same
   z-index is a tie, and a same-specificity/same-z-index tie is decided by
   DOM order, which favoured the toolbar purely because it happens to be
   later in the document, not because it should ever outrank a sitewide nav
   surface the visitor just opened on purpose. `.consent-notice-wrap`
   shares `--z-nav` too, for the same reason - a screen this control should
   never be able to cover. Moved to the new `--z-float` rung (root variable
   block, `--z-bg:0; --z-page:1; --z-fall:2; --z-float:5; --z-nav:10;
   --z-skip:20;` - see that token's own comment): strictly above `--z-fall`
   (leaves never cover it, unchanged guarantee) and strictly below `--z-nav`
   (the toolbar can now never win a tie against the nav, its dropdowns, or
   the consent notice - it always loses on the NUMBER, not on DOM-order
   luck). Also, deliberately, below `--z-skip`/`--z-modal`/the confirm
   dialog/toast, none of which this round touches: a control bound to one
   piece of tool content must yield to anything designed to interrupt the
   whole page. `.rw-design-popover`/`.rw-suggested-menu` (both below) are
   DOM descendants of this element and therefore already members of the
   stacking context it establishes - each now carries its own small,
   explicit, LOCAL z-index (documented at each rule) instead of `auto` or a
   borrowed page-level token, so which one paints above the toolbar's plain
   button children is a stated choice, not an accident of the cascade.

   COLOUR: the toolbar itself reuses the EXACT `background:var(--ink);
   color:var(--wall)` "inverted dark surface" idiom already shipped and
   already audited on this file for the identical problem - `.toast`
   (shared UX primitive, ~line 2678) and `.locale-modal-card` (~line 1932)
   are both floating `--ink` surfaces holding `.btn-primary`/`.btn-ghost`
   children, and `.locale-modal-card`'s own comment (design-qa r1 B2)
   already states the exact fix this block reuses verbatim: ".btn-primary
   is ink-on-wall by default, but this card's background IS --ink -
   identical colour, 1.00:1 contrast... invert it exactly like .btn-ghost
   already is." Same swap, same reasoning, third consumer - not a new
   colour language. This is also where board row 357's "the accent sits on
   the wrong object" tension resolves, restated for the record rather than
   left implicit: the OLD complaint was the Structure/Design TABLIST
   (a navigation control) claiming the accent highlight that should have
   read as "the important action," competing visually with Export. That
   tablist is deleted, not merely restyled, so there is no longer any
   control anywhere in this tool claiming accent for a navigation/toggle
   role it does not have. Export itself stays on the sitewide `.btn-
   primary` ink/wall language (inverted to wall/ink here, same as
   `.locale-modal-card`'s own primary button, still never accent) rather
   than being repainted accent-green: `.btn-primary` is deliberately never
   accent anywhere else on the site (grepped every `background:var(
   --accent)` call site in this file before writing this comment - toggle/
   active states and decorative accents only, never a single "primary
   action" button), and accent's own meaning here (a toggle/selected
   state - `.tool-segmented-btn.is-active`, `.rw-swatch.is-active`) would
   be diluted, not reinforced, by also using it for "the important
   button." Left as a disclosed, deliberate choice rather than silently
   deviating from the reference's own bright-accent pill - flagged in the
   handoff as a one-line follow-up if Qandil wants Export itself to read
   accent-green specifically inside this one toolbar.

   `.rw-doc-toolbar-btn` is purely a shared sizing/spacing layer (min-
   height/padding/line-height), reusing the EXACT density fix already
   proven correct on this exact button in its old home (`.rw-actionbar
   .btn`, ROUND 3's own "action-bar density" block above - Export
   measured 130.8x57px there before that fix, 44px after) - Export lost
   that sizing when it moved out of `.rw-actionbar` (confirmed live,
   watched-fail: unfixed, `[data-rw-export]` read back 57px tall in the
   floating toolbar, over the 48px ceiling `tests/Browser/
   ResumeWriterLayoutTest.php`'s own German-label test already pins,
   AI/10's evidence standard - the failure was a real regression from this
   round's DOM move, not a stale assertion, so the test was left
   UNCHANGED and this rule fixes the regression instead). Applied
   unconditionally (every width), matching the toolbar's own always-
   visible, always-reachable-by-touch nature - never gated `>=1128px` the
   way the OLD `.rw-actionbar` density fix was, because that fix lived
   inside a document lane that only ever showed at `>=1128` on its own or
   via `narrowLane==='document'` below it either way meant the SAME
   physical target region; the toolbar has no such lane gate at all.

   `.rw-design-popover` is a plain surface popover (`background:var(
   --wall)`), the SAME family `.lang-menu`/`.rw-suggested-menu` already use
   (not inverted like the toolbar itself) - its own contents (segmented
   controls, the swatch radiogroup, `.tool-field-label` headings) already
   assume a normal `--wall`/`--tile`-family surface by default (that is
   exactly how they rendered inside the old `.rw-panel`, no colour
   overrides needed there either), so keeping the popover on that same
   family avoids re-deriving five separate contrast overrides for content
   that already works correctly on it. Positioned `position:absolute`
   inside `.rw-doc-toolbar` (which is `position:fixed` and therefore
   already the nearest positioned ancestor / containing block - no extra
   wrapper needed), opening UPWARD (`bottom:calc(100% + gap)`) since the
   toolbar itself sits at the viewport's bottom edge - opening downward
   would push it off-screen. `max-height`+`overflow-y:auto`+the SAME thin-
   scrollbar treatment `.rw-panel` already ships (reused, not duplicated
   as a new selector list) covers the case where 5 stacked control groups
   do not fit above the toolbar on a short viewport.

   `[hidden]` cascade guard added pre-emptively on `.rw-design-popover`,
   the same established fix this file already applies 7+ times elsewhere
   (`.tool-segmented[hidden]`, `.rw-suggested-menu[hidden]`, `.rw-panel
   [hidden]`, etc.) - this rule DOES set an unconditional `display:flex`
   on the base selector (unlike `.rw-suggested-menu`, which had nothing to
   guard against yet), so without the guard the popover would stay fully
   rendered (and Tab-reachable/AT-visible) through its own `hidden`
   attribute, exactly the bug AI/06-LESSONS.md's own catalogue already
   names for a different element ("an off-canvas sheet hidden with only a
   transform stayed Tab-reachable" - the general shape recurring here as
   an author-`display`-beats-UA-`[hidden]` case instead of a transform
   case, same underlying trap: an unconditional `display` always wins over
   the UA's own `[hidden]{display:none}` regardless of order, because
   origin (author > user-agent) outranks it).

   No new colour, radius, spacing, breakpoint or z-index token - every
   value below is an existing `--s-*`/`--r-*`/`--h-control*`/`--ink`/
   `--wall`/`--accent`/`--z-nav` token, or a selector-only/positioning
   change. No new animation - the popover toggles via the native `hidden`
   attribute only (confirmed against the shipped engine: no open/close
   class is ever set), matching `.rw-suggested-menu`'s own instant show/
   hide immediately above, so there is nothing here for the reduced-motion
   block to gate; `.rw-doc-toolbar-btn`'s own hover/press motion is
   entirely inherited from the shared `.btn`/`.btn-primary`/`.btn-ghost`
   rules, which the existing reduced-motion block already covers (`.btn,
   .btn-hire,.foot-cta,...{transition:none !important}`, site.css ~2311) -
   nothing new to add there either.
   ===================================================================== */
.rw-doc-toolbar{
  /* ROUND 8: stays `position:fixed`, viewport-centred - a REAL attempt at
     `position:sticky` scoped to `.rw-shell` (via a CSS Grid overlap, so it
     could float over the document without reserving a row) was built,
     live-tested, and abandoned, because the mechanism this whole site
     depends on for a DIFFERENT reason breaks it: `body{overflow-x:hidden}`
     (site.css root layout rules, clips the decorative ambient leaf layers
     - AI/06-LESSONS.md's own documented reason, not this round's to
     touch) forces `overflow-y` to compute as `auto` too, per the CSS
     Overflow spec's "one axis non-visible forces the other to auto" rule.
     That makes `<body>` a scroll container in the browser's eyes even
     though it never actually scrolls (the visual viewport does) - and a
     sticky element's positioning is computed against its nearest such
     container, not the real viewport. Proven, not assumed: an isolated
     sticky test page held correctly; the SAME toolbar on THIS site, same
     CSS, scrolled in a dead-straight line with zero clamping at every
     position, through real Playwright (not just this pane) - and setting
     `document.body.style.overflowX='visible'` as a live A/B, nothing
     else changed, made it clamp correctly again. `body`'s own
     `overflow-x:hidden` is sitewide, deliberate, and outside this round's
     ownership to change for one tool's toolbar. So: fixed stays fixed,
     and the ACTUAL fix for "floats over the 126-card catalogue below" -
     Qandil's real complaint - moves to where it belongs given that
     constraint: `site.js`'s own `IntersectionObserver`, watching
     `.rw-shell` itself, toggling this element's native `hidden` attribute
     once `.rw-shell` is no longer intersecting the viewport at all (plan
     10b block, `public/js/site.js`). `.rw-doc-toolbar[hidden]{display:
     none}` (a few lines down) is the same "author display beats UA
     [hidden]" guard this file already applies everywhere else, needed
     because this rule sets an unconditional `display:flex`.

     `left:50%;transform:translateX(-50%)` (physical `left`, not the
     logical `inset-inline-start`) is UNCHANGED from ROUND 5 - see the
     original reasoning preserved below; still correct, still the fix for
     the 120px-off-screen-in-Arabic bug this exact pairing caused once
     already. */
  position:fixed;bottom:var(--s-24);left:50%;transform:translateX(-50%);
  z-index:var(--z-float);
  display:flex;align-items:center;gap:var(--s-8);padding:var(--s-8);
  background:var(--ink);color:var(--wall);
  border-radius:var(--r-pill);
  box-shadow:0 18px 40px -18px color-mix(in srgb, var(--shadow-leaf) 55%, transparent),
             0 4px 14px -6px color-mix(in srgb, var(--shadow-leaf) 30%, transparent);
}
.rw-doc-toolbar[hidden]{display:none}
.rw-doc-toolbar-btn{min-height:var(--h-control);padding:var(--s-8) var(--s-24);line-height:1}
/* same swap as .locale-modal-card's own .btn-primary/.btn-ghost overrides
   (site.css ~1946) - reused verbatim, see this block's own header comment
   ("COLOUR") for the full reasoning. */
.rw-doc-toolbar .btn-primary{background:var(--wall);color:var(--ink)}
.rw-doc-toolbar .btn-ghost{
  color:var(--wall);box-shadow:inset 0 0 0 1.5px color-mix(in srgb, var(--wall) 40%, transparent);
}
.rw-doc-toolbar .btn-ghost:hover,
.rw-doc-toolbar .btn-ghost[aria-expanded="true"]{box-shadow:inset 0 0 0 1.5px var(--wall)}
/* same fix as design-qa r1 M5 (.foot-card/.locale-modal-card, site.css
   ~225) for the identical problem: the sitewide accent focus ring (site.css
   ~220) is unreadable on an --ink surface because --ink IS the accent's
   neighbour token there. Third consumer of the same fix, not a new one. */
.rw-doc-toolbar a:focus-visible,.rw-doc-toolbar button:focus-visible{outline-color:var(--wall)}

.rw-design-popover{
  /* `left`, not `inset-inline-start` - same reasoning as `.rw-doc-toolbar`
     above (see that rule's own comment): this box is centred on its
     positioned parent (ROUND 8: `.rw-doc-toolbar` is now `position:sticky`,
     not `fixed` - sticky is still a positioned element for containing-
     block purposes, so this rule needed no change of its own beyond this
     comment), a purely symmetric operation that a logical offset actively
     breaks under `dir="rtl"`.

     ROUND 8, z-index: was unset (`auto`) - the dispatch's own words, "the
     popovers must share the toolbar's stacking context deliberately, not
     by accident of `auto`." Functionally this never mispainted anything on
     its own (an `auto`-z-index positioned descendant already paints above
     its parent's plain, non-positioned children), but "happens to work"
     and "declared correct" are different claims, and the toolbar's own
     z-index just changed meaning (STACKING, above) - so this is now a
     small, explicit, LOCAL value instead of an implicit one, matching
     `.rw-suggested-menu`'s own new local value directly above. */
  position:absolute;bottom:calc(100% + var(--s-8));left:50%;transform:translateX(-50%);
  z-index:1;
  display:flex;flex-direction:column;gap:var(--s-20);align-items:flex-start;
  width:300px;max-width:calc(100vw - var(--s-32));
  max-height:min(60vh,420px);overflow-y:auto;
  padding:var(--s-16);margin:0;
  background:var(--wall);border-radius:var(--r-tile);
  box-shadow:0 18px 40px -18px color-mix(in srgb, var(--shadow-leaf) 55%, transparent),
             0 4px 14px -6px color-mix(in srgb, var(--shadow-leaf) 30%, transparent);
  scrollbar-width:thin;
  scrollbar-color:color-mix(in srgb, var(--ink) 35%, transparent) transparent;
}
/* ROUND 8 - "not have enough spacing and height" (Qandil, direct), root-
   caused rather than patched by eye. Measured live: a `.tool-segmented`
   control row rendered 23px tall while its own `.tool-segmented-btn` child
   (min-height 36px at this >=xl density) rendered 44px - the CHILD taller
   than its own PARENT, i.e. the button was visibly poking out of its own
   row. Cause: `.tool-segmented` sets `overflow-x:auto` (site.css ~3135),
   and per the CSS Overflow spec, setting only one of `overflow-x`/`-y`
   forces the OTHER to compute as `auto` too - so this element is a scroll
   container on both axes, and a flex/grid ITEM that is itself a scroll
   container gets its "automatic minimum size" floored at 0 instead of its
   content's natural size. `.rw-design-popover` is `display:flex;flex-
   direction:column` with a real `max-height` cap - once total content
   (10 stacked label/control rows) exceeds that cap, the flex algorithm's
   default `flex-shrink:1` compresses items to fit BEFORE the container's
   own `overflow-y:auto` gets a chance to scroll instead, and the item with
   the 0-floor (this one) absorbs nearly all of that compression, well
   below what its own button actually needs. `overflow-y:auto` was already
   present and correct; it was never actually reachable, since compression
   already made everything "fit" (barely) before scrolling could kick in -
   live-measured `scrollHeight` 422px against `clientHeight` 420px, 2px of
   "overflow" for content that needed ~550px+ at its own natural size.
   Fixed with one property, proven live BEFORE writing it here (a raw style
   override in the browser pane, re-measured, THEN moved into this rule):
   `flex-shrink:0` on every direct child stops the compression outright, so
   the container's own `max-height`+`overflow-y:auto` (already-written,
   correct code) does the job it was always meant to do. Re-measured after:
   the same row now renders 44px (36px button + 4px+4px padding, matching
   exactly), no child taller than its own parent anywhere in the popover.
   `gap` (rule above) raised from `--s-16` to `--s-20` in the same edit -
   "real separation," not just "not clipped"; the DOM here is a flat
   label/control sequence with no per-group wrapper (tools-engineer's own
   engine, not this seat's to restructure), so one shared gap is the only
   lever available and widening it improves spacing between every
   consecutive pair uniformly. Below `xl` this popover's controls stay at
   the 44px touch floor unconditionally (the `--h-control-sm` density two
   blocks below is already `>=1128px`-gated, unchanged by this round -
   re-verified, not re-written). */
.rw-design-popover > *{flex-shrink:0}
.rw-design-popover[hidden]{display:none}
.rw-design-popover .tool-segmented{max-width:100%}
.rw-design-popover::-webkit-scrollbar{width:8px}
.rw-design-popover::-webkit-scrollbar-track{background:transparent}
.rw-design-popover::-webkit-scrollbar-thumb{
  background:color-mix(in srgb, var(--ink) 35%, transparent);
  border-radius:var(--r-pill);
}
.rw-design-popover::-webkit-scrollbar-thumb:hover{background:color-mix(in srgb, var(--ink) 55%, transparent)}
