/* ============================================================
   vecka — design system
   Panel language (white rounded panels,
   pill controls, off-white canvas, hairline borders, light-dark
   pairs) set entirely in one neutral modern font (Inter) — no
   mono, no display family. Each module lives on its own
   subdomain; this file is the one shared visual contract.
   ============================================================ */

/* ---------- Inter, self-hosted ----------
   Was two preconnects plus a stylesheet from fonts.googleapis.com and the font
   files themselves from fonts.gstatic.com — four third-party round-trips (DNS,
   TLS, CSS, font) in front of first paint, on EVERY navigation, measured at
   ~127ms for the CSS alone before a single glyph was fetched. Same font, served
   from our own origin on a connection the browser already has open.
   Only latin and latin-ext are shipped: those cover the UI and the accented
   names in the data. Cyrillic, Greek and Vietnamese were 5 of the 7 subsets
   Google offered and none of them is ever rendered here. */
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 400 800;
  font-display: swap;
  src: url(/assets/fonts/inter-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 400 800;
  font-display: swap;
  src: url(/assets/fonts/inter-latin.woff2) format('woff2');
  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;
}

:root {
  color-scheme: light dark;

  --dd-font-sans: "Inter", -apple-system, "Segoe UI", sans-serif;

  /* The shell's top line (.topbar). A token because three height rules depend
     on it (app main, split main, the chat page) and the mobile shell zeroes
     it — never let a breakpoint fight a used value. */
  --dd-topbar-h: 48px;

  /* type scale — pick a step, never a literal */
  --dd-fs-3xs: .625rem;   /* 10px dense meta */
  --dd-fs-2xs: .6875rem;  /* 11px micro-labels */
  --dd-fs-xs:  .75rem;    /* 12px */
  --dd-fs-sm-: .8125rem;  /* 13px compact body */
  --dd-fs-sm:  .875rem;   /* 14px body */
  --dd-fs-md:  1rem;      /* 16px */
  --dd-fs-lg:  1.125rem;  /* 18px */

  /* ---- Palette: the Attio values, sampled ---------------------------
     Re-toned 2026-08-25 (second pass) onto colours read pixel-by-pixel out
     of Attio's own two screens. The token ROLES below are exactly the ones
     they always were — a gray scale of washes, three ink tiers, five named
     surfaces — only their values moved, so nothing that consumes them had
     to be re-plumbed. The headline of the move is the dark scheme: it is
     far darker than the one it replaces and the RAIL IS DARKER THAN THE
     MAIN AREA, not lighter.

     Two rules govern anything added here later. FILLS ARE THE SAMPLE — the
     surfaces below are the measured colours and are not a matter of taste.
     STEPS ARE OURS — the hover/press/edge tiers have to serve three
     different grounds at once (the rail, the canvas and a raised panel),
     which Attio never asks one value to do, so those are derived to keep
     every consumer legible on all three rather than copied off one screen.

     The quiet ink tier is used at 11px micro-label size, so it stays the
     darkest of the three secondary candidates; it measures 5.7:1 on the
     hover wash in light and 6.1:1 in dark — re-measure before touching it.
     ------------------------------------------------------------------- */

  /* grays — washes, hovers, dividers.
     gray-50 is the PASSIVE wash (a row the pointer is merely over, a
     secondary pill: Attio's #F6F6F6 / its rail's own quiet fill) and
     gray-100 is the LIT one (the selected row, the glide's plate, a menu's
     highlight, every chip's ground: Attio's sampled #F1F1F1).
     The dark halves are the derived pair rather than the sample: Attio's
     one hover value (#171717) is measured on a rail sitting 11 steps below
     its main area, and the same fill has to read on our raised panel too —
     so gray-100 lands a few steps above it, where it clears BOTH grounds,
     and gray-50 sits where Attio's does. */
  --dd-gray-50:  light-dark(#f6f6f6, #1a1c1d);
  --dd-gray-100: light-dark(#f1f1f1, #1f2122);
  --dd-gray-200: light-dark(#e2e2e2, #262829);
  --dd-gray-300: light-dark(#cdcdcd, #34373a);
  --dd-gray-500: light-dark(#949494, #6e7175);

  /* ink tiers. The two ends are sampled (#0A0A0A / #5E5E5E in light,
     #F8FAFB / #A0A0A2 in dark); the middle tier is interpolated between
     them, since Attio has no third body colour to read. */
  --dd-ink:       light-dark(#0a0a0a, #f8fafb);
  --dd-ink-light: light-dark(#3a3a3a, #d2d4d6);
  --dd-ink-quiet: light-dark(#5e5e5e, #a0a0a2); /* AA on panel, canvas + wash */

  /* chrome. --dd-rail is the SIDEBAR's surface and it is the DARKER one in
     both schemes now — near-black beside a lifted main area in dark, a
     breath of off-white beside white cards in light. Anything that wants
     "one step up from the ground" must reach for --dd-panel and never for
     the rail.
     --dd-canvas is the ground the panels float on. In dark it is Attio's
     main-content colour exactly; in light it takes the rail's own off-white
     rather than the sampled pure white, because Attio's main area carries
     no inset cards and ours does — a white card needs a ground to sit on,
     and this is the smallest step that gives it one. The border does the
     rest of the work, which is the whole idea. */
  --dd-canvas:  light-dark(#fcfbfc, #0f1112);
  --dd-panel:   light-dark(#ffffff, #17191a);
  --dd-rail:    light-dark(#fcfbfc, #0c0c0c);
  --dd-border:  light-dark(#e5e5e5, #1e2021);
  --dd-hairline:light-dark(#e8e8e8, #1c1e1f);
  --dd-inverse: light-dark(#0a0a0a, #f8fafb);
  --dd-on-inverse: light-dark(#fcfbfc, #0f1112);

  /* THE ACCENT — blue (2026-08-25). Everything that used to fill with the
     inverse ink as its "selected / primary" statement fills with this pair
     instead: primary buttons, checked boxes, today badges, active segments,
     the workspace mark, selection. --dd-inverse stays for true inverse
     SURFACES (the toast) — a dark pill is not an accent. Tied to --dd-blue
     so the status blue and the accent can never drift apart. */
  --dd-accent: var(--dd-blue);
  --dd-on-accent: light-dark(#fcfbfc, #0f1112);

  /* semantic — status only, everything else monochrome. Red is Claude's
     danger step for the scheme (light danger-100, dark danger-000 — the
     lighter dark step, since danger-100 is only 3.4:1 on the panel). */
  /* Blue is BOTH the status blue and the product's default accent, so the
     re-tone moves it onto Attio's own accent hue (~222°, the #2563EB
     family) rather than leaving the two a few degrees apart. That single
     value is what makes the DERIVED soft states land where Attio's do:
     mixed 35% over white it comes out at #B2C8F8, within a step of the
     sampled #AEC6FB resting send button, with nothing hardcoded.
     The dark half stays LIGHT and pastel — see the accent-hue block below
     for why that rule is not negotiable — retuned to the same 222°. That is
     also the one place the sample is deliberately NOT matched, and it is
     worth writing down before somebody "corrects" it: Attio's dark resting
     send button reads #1E3A6E because its dark accent is the same saturated
     blue as its light one, mixed down into a dark card. Ours cannot be —
     --dd-on-accent paints near-black on an accent ground in dark, so that
     ground has to stay light enough to carry it, and this pastel mixed into
     --dd-panel lands nearer #4B5D81. Matching the sample here would cost
     every primary button in dark its label. */
  --dd-green:  light-dark(#1a7742, #63d18c);
  --dd-red:    light-dark(#b53333, #fe8181);
  --dd-amber:  light-dark(#8a6410, #f0c057);
  --dd-blue:   light-dark(#2563eb, #8aafff);

  /* The four hues that exist ONLY to be chosen as an accent (Settings ->
     Appearance). They are declared here beside the status colours because they
     are built to the same rule and must not drift from it: DARK and saturated
     in light mode, LIGHT and pastel in dark mode — exactly how --dd-blue's
     #2563eb / #8aafff pair works. That rule is not a matter of taste. Whatever
     is chosen fills --dd-accent, and --dd-accent is a GROUND: primary buttons,
     the checked box, the today badge, the workspace mark all paint
     --dd-on-accent on top of it, and --dd-on-accent is a single fixed pair
     (light-dark(#fcfbfc, #0f1112)) that flips with the SCHEME, not with the
     accent. So every value below has to be dark enough to carry near-white in
     light mode and light enough to carry near-black in dark mode. A hue that
     is merely "the same colour, brighter" breaks one of the two.
     Green and amber are reused from the status set for the same reason blue is
     — one hue, one definition, so the accent and the status colour can never
     disagree about what green means. */
  --dd-teal:   light-dark(#0f6d70, #63cfd0);
  --dd-orange: light-dark(#a8500f, #f5a26a);
  --dd-pink:   light-dark(#b03565, #f79bbd);
  --dd-violet: light-dark(#6741c8, #b3a0f5);

  /* washes and scrims — a tint is still a colour, so it still needs a token.
     --dd-backdrop went TRANSPARENT with the shadows on 2026-08-25, on the
     argument that a floating surface is told by its BORDER and its fill step
     the way the reference tells every other one. It is a dim again, and the
     note it replaces named this exact remedy: "if a dialog is ever judged too
     thin against a busy page, the answer is to give this token a dim, never
     to reintroduce the shadow." Measured, it was: a dialog opens over a
     module pane, and a module pane is --dd-panel, which is a dialog's own
     fill — so in dark the entire separation was one --dd-border hairline at
     ΔL* 3.5 with the same colour on both sides of it and nothing else, while
     the ⌘K palette, a strictly LESS modal surface, was dimming its ground the
     whole time. It borrows that palette's dim rather than inventing a third
     number, so the two floating surfaces cannot drift apart. (Forward
     reference: --dd-scrim-light is declared just below. Custom properties
     resolve at computed-value time, so order within the block does not
     matter, and the token belongs beside the wash it is grouped with.)
     Drop shadows are still gone and stay gone — a dim is not elevation.
     The blue wash is the tint behind blue text (a calendar chip) and the
     halo on the inline rename field, so its dark half is pitched to sit
     visibly on the near-black rail without ever rivalling the ink on it. */
  --dd-blue-wash: light-dark(#edf2fd, #1e2a44);
  --dd-backdrop: var(--dd-scrim-light);
  /* …but a DRAWER still dims what it covers: it replaces the content under
     it rather than floating above it, so its scrim keeps a real color. The
     light variant is for surfaces that float rather than cover — the ⌘K
     palette wants the page behind it readable, so it dims about half as much
     as a drawer does. Both live here so the two dims stay a deliberate pair
     instead of raw hexes drifting apart at their use sites. */
  --dd-scrim: light-dark(#0a0a0a47, #000000a6);
  --dd-scrim-light: light-dark(#0a0a0a26, #00000059);

  /* Slight rounding only — enough to take the bite off a corner, never
     enough to read as a pill. Every surface in the suite draws its corner
     from one of these three, so the whole app's softness is set here. */
  --dd-radius-s: 3px;
  --dd-radius-m: 5px;
  --dd-radius-l: 8px;
  --dd-radius-full: 999px;

  --dd-sidebar-w: 232px;
  --dd-view-max: 1240px;

  /* layout rhythm — these four values are the whole system */
  --dd-header-gap: 24px;
  --dd-stack-gap: 16px;
  --dd-grid-gap: 16px;
  --dd-pad-x: 20px;

  /* THERE IS NO ELEVATION SHADOW ANY MORE, in this file or in the reference
     it is built from: surfaces are separated by a 1px --dd-border and a step
     of fill, never by a drop. Every menu, popover, dialog, toast, card, auth
     surface and drawer in this file had its shadow deleted outright and
     leans on the hairline it already carried.
     The three tokens survive at `none` rather than being deleted, and that
     is deliberate on both counts. Deleted, every `box-shadow:
     var(--dd-shadow-pop)` still standing in a module stylesheet would become
     an invalid substitution — the same end result, reached silently, with
     nothing at the declaration site to read. At `none` the rule resolves to
     exactly what the design now asks for and this comment is where the
     answer lives. Keeping the names also means nothing had to be renamed
     across four other stylesheets to say one thing.
     Do not compose these into a shadow LIST (`var(--dd-shadow-pop), 0 0 0
     2px …`): `none` is not a valid list member and the whole declaration
     would be dropped. Rings stand on their own — see .hx-ring, which used to
     stack the dialog shadow underneath its accent ring and no longer does. */
  --dd-shadow-pop: none;
  --dd-shadow-dialog: none;
  --dd-shadow-btn: none;

  --dd-ease: cubic-bezier(.3, .7, .3, 1);
  /* THE REFERENCE'S ONE CURVE. It arrived in this file inside the .dd-glide
     block, for the highlight that travels between rows, with a note saying to
     promote it here the day a second rule wanted it. That day is the chat
     primitives: beautifului.dev drives every disclosure, every entrance and
     every width morph across all twenty of its designs with this single
     bezier, and our folds, reveals and the selection pill's morph all want the
     same one. It is a long tail — nearly all of the distance is covered in the
     first third — which is what makes a thing look thrown into place rather
     than dragged there. */
  --dd-ease-glide: cubic-bezier(.23, 1, .32, 1);
  --dd-dur: 160ms;
  --dd-dur-slow: 220ms;  /* chrome that changes size, e.g. a collapsing rail */
  /* An ANCHORED surface (menu, popover, palette, toast, dialog) does not travel
     and therefore does not need a duration long enough to be watched. It needs
     only enough time not to read as a hard flash — the eye registers the change
     without being asked to follow anything. 90ms is a little over half
     --dd-dur: below ~60ms a fade is indistinguishable from an instant swap, and
     above ~120ms a surface that isn't moving starts to feel slow to arrive. */
  --dd-dur-pop: 90ms;
  /* ---- and one pair for moves the size of a PANE ----
     --dd-ease leaves the start line at 2.3x its own average speed. On a chip or
     a row that reads as crisp; on half the window it reads as a lunge, and half
     the window is exactly what moves when a module is dragged in beside the one
     you are in, when the two panes trade sides, or when a split closes. This
     curve starts from nothing instead and spends its length settling, so the
     layout gives way under the pointer rather than being thrown. The extra
     80ms is the same argument: --dd-dur-slow was measured against a rail
     collapsing, and a pane travels several times further than that. */
  --dd-ease-pane: cubic-bezier(.4, .01, .2, 1);
  --dd-dur-pane: 300ms;

  /* One row/card growing open (--dd-dur-slow) settles its own chrome first;
     content inside (notes, footer) should read as arriving a beat later, not
     as part of the same motion, or the eye can't tell the container from its
     contents. 60ms is a quarter of --dd-dur-slow — late enough to read as a
     second event, timed so a --dd-dur fade started at this delay finishes
     exactly when the container does (60 + 160 = 220). */
  --dd-delay-settle: 60ms;

  /* ---- and the handful of surfaces that are an EVENT rather than a reaction ----
     Everything above is chrome answering a pointer, and --dd-dur is the right
     length for all of it. The four below are things that HAPPEN — a dialog
     arriving, a rail section folding, a tick being drawn, a toast leaving —
     and each one is a beat the eye is meant to follow, so each is measured
     against what it actually does instead of against the hover it isn't.

     A dialog is the one surface in this file allowed to SCALE, and that is not
     the travel the dd-pop-in note bans: a menu appears where you pointed and
     has somewhere it could be said to come from, while a dialog is centred in
     the window and anchored to nothing, so .98 → 1 reads as depth rather than
     as a direction. Out is shorter than in, as every exit here is — arriving is
     an event worth watching, leaving is one that has already happened. */
  --dd-dur-modal: 140ms;
  --dd-dur-modal-out: 110ms;
  /* A rail section folding. A height change, so it belongs nearer
     --dd-dur-slow than --dd-dur; 180ms is the distance a row's own reveal
     covers and a folded section is the same kind of move. */
  --dd-dur-fold: 180ms;
  /* The checkbox tick drawing itself, left to right along the stroke. A touch
     under the box's own --dd-dur fill so the mark lands ON a filled box rather
     than after one. */
  --dd-dur-draw: 140ms;
  /* A toast leaving. It rose 6px to say something; once that is said only the
     words go, so this is a fade and it is shorter than the arrival. */
  --dd-dur-toast-out: 120ms;

  /* identity palette — names things (calendars, mail labels), never state.
     Muted per scheme; chips derive tints via color-mix so one token per hue. */
  --dd-id-blue:   light-dark(#2a5fd0, #7fb2ff);
  --dd-id-green:  light-dark(#1a7742, #63d18c);
  --dd-id-amber:  light-dark(#8a6410, #f0c057);
  --dd-id-violet: light-dark(#6d4fc4, #b3a0f2);
  --dd-id-rose:   light-dark(#b13a72, #f094bd);
  --dd-id-teal:   light-dark(#0f766e, #5eead4);

  --dd-subbar-w: 240px;
  --dd-app-gap: 16px;   /* air around and between an app's panes */

  /* checkbox tick, used as a mask so it inherits the scheme's ink */
  --dd-tick: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M2.6 6.3 4.8 8.5 9.4 3.7' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  /* alert-circle, same mask technique as --dd-tick (currentColor paints it) */
  --dd-alert: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='9.25' fill='none' stroke='%23000' stroke-width='1.8'/%3E%3Cpath d='M12 7.6v5.2' stroke='%23000' stroke-width='1.9' stroke-linecap='round'/%3E%3Ccircle cx='12' cy='16.4' r='1.15' fill='%23000'/%3E%3C/svg%3E");
}

html[data-theme="light"] { color-scheme: light; }
html[data-theme="dark"]  { color-scheme: dark; }

/* ---------- the chosen accent (shell/accent.ts) ----------
   Three states, two attribute values — the same idiom shell/theme.ts uses for
   "system". BLUE IS THE PRODUCT'S DEFAULT AND HAS NO BLOCK HERE: choosing blue
   deletes data-accent and deletes the stored key, so an account that never
   opened Appearance and an account that deliberately chose blue produce the
   identical DOM, and the default can be changed in one place (--dd-accent in
   the tokens block) without a stale attribute somewhere pinning an old one.
   Do not "complete the set" by adding :root[data-accent="blue"] — an attribute
   that is only ever absent cannot go stale, and one that is sometimes written
   can.

   Each block only ever reassigns --dd-accent to one of the hue tokens above.
   Nothing here invents a colour, because --dd-on-accent does NOT change with
   the accent: it is one pair that flips with the scheme, so the ONLY thing
   keeping white legible on a primary button is that every hue above is dark in
   light mode and light in dark mode. A new accent belongs in that list, built
   to that rule, before it can appear here. */
:root[data-accent="teal"]   { --dd-accent: var(--dd-teal); }
:root[data-accent="green"]  { --dd-accent: var(--dd-green); }
:root[data-accent="amber"]  { --dd-accent: var(--dd-amber); }
:root[data-accent="orange"] { --dd-accent: var(--dd-orange); }
:root[data-accent="pink"]   { --dd-accent: var(--dd-pink); }
:root[data-accent="violet"] { --dd-accent: var(--dd-violet); }

* { box-sizing: border-box; }

html {
  /* The root must paint the canvas itself. Left transparent, any exposed edge
     of the document — a macOS rubber-band, an overscroll bounce — is filled by
     the browser's OWN default canvas, which `color-scheme: light dark` allows
     to be near-black. That is the "black strip beside the sidebar". */
  background: var(--dd-canvas);
  scrollbar-width: thin;
  scrollbar-color: var(--dd-gray-300) transparent;
  /* No page in this suite scrolls sideways, so a horizontal trackpad gesture
     must never reach the browser and fire swipe-to-go-back. Panes that DO
     scroll horizontally contain their own overscroll as well. And no SHELL
     page scrolls vertically either — the document is locked (see .shell) —
     yet Safari still rubber-bands the root and shows blank canvas above the
     topbar (David, 2026-08-26: "empty space on top of where you see vecka").
     Both axes off: the root never chains, never bounces. Scrollers inside
     the page keep their own behaviour. */
  overscroll-behavior: none;
}

body {
  margin: 0;
  font-family: var(--dd-font-sans);
  font-size: var(--dd-fs-sm);
  color: var(--dd-ink);
  background: var(--dd-canvas);
  -webkit-font-smoothing: antialiased;
}

[hidden] { display: none !important; }
::selection { background: var(--dd-accent); color: var(--dd-on-accent); }

a { color: inherit; }

/* One focus treatment everywhere — for the controls a keyboard WALKS to.
   Never restyle it per component.

   TEXT-ENTRY FIELDS ARE EXEMPT (David, 2026-08-26): a text field matches
   :focus-visible on ANY focus — a mouse click included — so the ink ring
   flashed around every field ever clicked into, and read as a glitch rather
   than as focus. The ring cannot be made keyboard-only there (that is the
   selector's own heuristic), so the fields opt out as a class and their two
   honest tells carry it: the caret, and each field's own border/background
   warm (.input:focus and friends). Buttons, links and checkable controls
   keep the ring, where it genuinely appears on keyboard focus alone. The
   same decision removed the wrapper rings that mirrored this one onto
   composite fields (.settings-searchpill, .mail-search, .auth-prefix-field)
   and the rename field's 3px wash. */
:focus-visible {
  outline: 2px solid var(--dd-ink);
  outline-offset: 2px;
}
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="button"]):not([type="submit"]):focus-visible,
textarea:focus-visible,
[contenteditable]:focus-visible {
  outline: none;
}

/* ---------- shell: sidebar + main column ---------- */

/* The DOCUMENT NEVER SCROLLS. Every page manages its own scrolling — the app
   pages always did (body[data-app] main below), and since 2026-08-25 the hub
   does too (main scrolls itself, further down). Locking the shell to the
   viewport is what keeps the sidebar and topbar planted: before this, a hub
   whose content ran a few pixels long put a scrollbar on the whole site and
   let the chrome shift underfoot. */
.shell { display: flex; height: 100vh; height: 100dvh; overflow: hidden; }
/* And the DOCUMENT agrees with the shell: everything that is not an auth page
   (login/signup/welcome carry .auth-body and keep their document-scroll model)
   clamps the body, so no portal parked below the fold — a menu, a toast, a
   drawer mid-measure — can quietly hand the root a scrollbar and let the whole
   chrome drift (the second half of the same 2026-08-26 report). */
body:not(.auth-body) { overflow: hidden; }

.sidebar {
  width: var(--dd-sidebar-w);
  flex: none;
  height: 100vh;
  display: flex; flex-direction: column;
  background: var(--dd-rail);
  border-right: 1px solid var(--dd-border);
  /* No top padding: .side-head below claims the same --dd-topbar-h band the
     main column's .topbar occupies, so switcher and page name sit on one
     line across the hairline. */
  padding: 0 12px 16px;
  /* The rail must scroll when it is taller than a short viewport, or its footer
     (Theme / Settings / account) is unreachable. Note: pairing `clip` with a
     scrollable axis is defined to resolve to `hidden`, so the used value here is
     `hidden auto` — written explicitly rather than implying `clip` does
     something it cannot. Each label clips itself, so the x axis is belt only. */
  overflow-x: hidden;
  overflow-y: auto;
  scrollbar-width: none;
  /* Collapsing is a size change, so it gets the slower duration. Labels give up
     their width instead of being display:none'd — that is what lets the rail
     animate rather than snap.
     background and box-shadow are here for a different gesture on the same box:
     .sidebar.is-drop-target (further down) is added and removed imperatively
     while a pane is carried back to the rail, and with only width/padding
     declared the whole rail flashed grey with an ink edge in a single frame and
     flashed back the same way. They ride --dd-dur, not --dd-dur-slow: "I will
     take this" has to answer the cursor, not the rail's own size clock. */
  transition: width var(--dd-dur-slow) var(--dd-ease),
              padding var(--dd-dur-slow) var(--dd-ease),
              background var(--dd-dur) var(--dd-ease),
              box-shadow var(--dd-dur) var(--dd-ease);
}
.sidebar::-webkit-scrollbar { width: 0; }
/* The third leg is the reference's garnish on a gesture this rail already had:
   a label does not merely fade as the rail shuts, it slides a few pixels the
   way the rail is going. On the OPACITY clock, not the width one — the fade and
   the drift are one event, and 110ms is the number every other opacity leg in
   this gesture already rides. Nothing under prefers-reduced-motion needs a
   resting override: the offset only exists at max-width 0 and opacity 0, where
   there is nothing left to see it on. */
.side-nav a > span:not(.avatar),
.side-nav button > span:not(.avatar) {
  overflow: hidden; white-space: nowrap;
  max-width: 180px;
  transition: max-width var(--dd-dur-slow) var(--dd-ease),
              opacity 110ms var(--dd-ease),
              transform 110ms var(--dd-ease);
}
/* SCOPED TO .sidebar ON PURPOSE. `.side-label` is a shared class — every app's
   subbar uses it for its section headings ("Areas", "My calendars") — so an
   unscoped rail rule collapses those headings too whenever the rail collapses. */
.sidebar .side-search, .sidebar .side-label {
  overflow: hidden;
  max-height: 60px;
  transition: max-height var(--dd-dur-slow) var(--dd-ease),
              opacity 110ms var(--dd-ease),
              padding var(--dd-dur-slow) var(--dd-ease);
}

/* The head is the workspace switcher plus the collapse control — the Attio
   arrangement. The switcher's whole row is the click target; the collapse
   button sits on its right and is the one control that operates the rail. */
.side-head {
  display: flex; align-items: center; gap: 4px;
  /* Same band as .topbar next door — height instead of padding so the two
     rows centre-align across the rail border. The collapsed, mobile, and
     hero-mock variants all opt back out (column stack / zeroed token /
     scaled mock would clip at a fixed 41px). */
  height: var(--dd-topbar-h);
  flex: none;
  /* The negative margins pull the head's hairline out to the sidebar's edges
     so it reads as one continuous line with the topbar's border next door;
     the horizontal padding puts the content back on the 12px grid. */
  padding: 0 12px;
  margin: 0 -12px 10px;
  border-bottom: 1px solid var(--dd-hairline);
  /* Both of those numbers CHANGE when the rail collapses (0 12px → 0 8px and
     0 -12px 10px → 0 -8px 8px, in the rail-collapsed block), and until this
     line existed they changed in one frame in the middle of the rail's own
     220ms width tween — the head's hairline jumping while everything under it
     eased. Same duration as the width it is riding. */
  transition: padding var(--dd-dur-slow) var(--dd-ease),
              margin var(--dd-dur-slow) var(--dd-ease);
}
.ws-switch {
  flex: 1; min-width: 0;
  display: flex; align-items: center; gap: 8px;
  border: 0; background: transparent;
  font: inherit; color: inherit; text-align: left;
  padding: 4px 6px;
  border-radius: var(--dd-radius-m);
  cursor: pointer;
  /* The switcher is the one section leaving the collapsed rail that lives in a
     HORIZONTAL flex row, and that changes the answer. Fading it in place would
     hold its `flex: 1` box for the length of the fade, pinning the collapse
     button to the head's right edge and then teleporting it to centre halfway
     through the rail's own tween — a jump nothing on screen explains.
     So it leaves the way its neighbours already leave: the box shrinks WITH
     the fade (max-width and the horizontal padding to 0 — border-box floors a
     zero-width box at padding+border, so the padding has to go too, or 12px of
     switcher survives), which glides the collapse button to its centred
     position instead of jumping it. Same 110ms as every other opacity leg in
     this gesture. `max-width: 100%` exists only so there is a length to
     interpolate FROM; `none` would not animate. It never binds — a flex:1 item
     sharing a row is already narrower than its container.
     `display: none` still lands at the end (see .side-projects' note on
     allow-discrete): a 0-width transparent button is not clickable but it is
     still TABBABLE, and this suite has twice shipped a hidden thing that was
     still reachable. */
  max-width: 100%;
  overflow: hidden;
  transition: background var(--dd-dur) var(--dd-ease),
              opacity 110ms var(--dd-ease),
              max-width 110ms var(--dd-ease),
              padding 110ms var(--dd-ease),
              gap var(--dd-dur-slow) var(--dd-ease),
              display 110ms var(--dd-ease);
  /* The keyword is its own declaration and not `display … allow-discrete`
     inside the shorthand above, because ONE unrecognised token invalidates the
     WHOLE shorthand: on an engine without it, the switcher would have lost its
     hover fade as well as the fade this line is for. Here the old engine drops
     one ignorable declaration and keeps every leg it can actually run.
     After the shorthand on purpose — `transition` resets this longhand. */
  transition-behavior: allow-discrete;
}
/* Scoped to the rail: shared/auth.js's hero mock wears .ws-switch too (inside
   .hx-side, not .sidebar) and that rail is a still life — it must not fade
   itself in behind the sign-in card. */
@starting-style {
  .sidebar .ws-switch { opacity: 0; }
}
.ws-switch:hover { background: var(--dd-gray-100); }
.ws-mark {
  width: 22px; height: 22px; flex: none;
  border-radius: var(--dd-radius-m);
  background: var(--dd-accent); color: var(--dd-on-accent);
  display: grid; place-items: center;
  font-weight: 700; font-size: var(--dd-fs-2xs);
  letter-spacing: -.02em;
}
.ws-name {
  min-width: 0; max-width: 150px;
  overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
  font-weight: 650; font-size: var(--dd-fs-sm); letter-spacing: -.01em;
  transition: max-width var(--dd-dur-slow) var(--dd-ease),
              opacity 110ms var(--dd-ease);
}
.ws-caret {
  width: 14px; height: 14px; flex: none;
  stroke-width: 2; color: var(--dd-ink-quiet);
  transition: width var(--dd-dur-slow) var(--dd-ease),
              opacity 110ms var(--dd-ease);
}
.rail-btn {
  width: 28px; height: 28px; flex: none;
  display: grid; place-items: center;
  border: 0; background: transparent;
  border-radius: var(--dd-radius-m);
  color: var(--dd-ink-quiet);
  cursor: pointer;
  transition: background var(--dd-dur) var(--dd-ease), color var(--dd-dur) var(--dd-ease);
}
.rail-btn:hover { background: var(--dd-gray-100); color: var(--dd-ink); }
.rail-btn svg { width: 16px; height: 16px; stroke-width: 1.75; }
/* Still worn by the mobile drawer's way-in (RailOpenButton, MobileShell.tsx),
   which pairs it with .rail-btn — the rail's own head shows the workspace
   mark instead. */
/* THE PRODUCT MARK — the drawn v, not a letter in a tile.
   .ws-mark next to it is a different thing and stays a tile: that one is the
   WORKSPACE's avatar and carries the workspace's own initial, so a person with
   three workspaces can tell them apart at a glance. This one is vecka itself
   and is the same shape for everybody.
   The glyph is one polyline stroked at 5.2 on a 24 grid with round caps and a
   round join; the caps ARE the rounded corners, so there is no second path to
   keep in sync and no size at which hand-fitted arcs go wrong. It inherits
   currentColor, which is why the rail can tint it with the accent and the
   mobile drawer button can tint it with the button's own ink without either
   one restating the geometry.
   No background, no radius, no shadow: the mark is the logo, and a box around
   it would say the logo was not enough. */
.brand-mark {
  width: 26px; height: 26px; flex: none;
  display: grid; place-items: center;
  color: var(--dd-accent);
}
.brand-mark .brand-v { width: 100%; height: 100%; display: block; }

.microlabel {
  font-size: var(--dd-fs-2xs);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: .08em;
  color: var(--dd-ink-quiet);
}

/* The search row: the pill, with the inbox button on its right — a beat of
   chrome between the switcher and the nav, like the reference. */
.side-search { display: flex; align-items: center; gap: 4px; padding: 0 2px 12px; }
.side-search .search-pill { flex: 1; min-width: 0; width: auto; }
/* Bordered like the search pill beside it — the reference draws the inbox as
   a card-shaped button, not a bare glyph. */
.side-inbox {
  width: 34px; height: 34px;
  border: 1px solid var(--dd-border);
  background: var(--dd-canvas);
  /* This list REPLACES .rail-btn's (same specificity, later source), which is
     why background is spelled out here as well as border-color: without it the
     inbox is the one rail button whose pressed fill would snap on and off while
     every other one eases. */
  transition: border-color var(--dd-dur) var(--dd-ease),
              background var(--dd-dur) var(--dd-ease),
              color var(--dd-dur) var(--dd-ease);
}
.side-inbox:hover { border-color: var(--dd-gray-300); background: var(--dd-canvas); }
/* The press, one shade past hover — and AFTER .side-inbox:hover, which is the
   only rule at this specificity that also writes a background.
   [data-rail-open] is excluded because it is not a grey button: the mobile
   drawer's opener is an ACCENT-filled mark and already answers its own press by
   dimming (see its rules in the ≤720px block, and the note there about this
   exact specificity leak). A grey wash over the accent would be the same
   "less visible at the moment of use" bug that block already fixed once. */
.ws-switch:active,
.rail-btn:active:not([data-rail-open]) { background: var(--dd-gray-200); }

.side-label { padding: 6px 8px 4px; }

.side-nav { display: flex; flex-direction: column; gap: 1px; }
.side-nav a, .side-nav button {
  display: flex; align-items: center; gap: 10px;
  border: 0; background: transparent; width: 100%; text-align: left;
  font: inherit; cursor: pointer;
  /* 8px left matches .ws-switch's and .side-label's, so every item shares one
     edge; 6px vertical is the compact row this redesign asked for */
  padding: 6px 8px;
  border-radius: var(--dd-radius-m);
  text-decoration: none;
  font-size: var(--dd-fs-sm);
  font-weight: 480;
  color: var(--dd-ink-light);
  /* padding and gap are animated because collapsing re-centres the icon by
     changing them (see the collapsed block) — without these the icon jumps;
     transform is the press dip further down, on the fill's own clock */
  transition: background var(--dd-dur) var(--dd-ease),
              padding var(--dd-dur-slow) var(--dd-ease),
              gap var(--dd-dur-slow) var(--dd-ease),
              transform var(--dd-dur) var(--dd-ease);
}
.side-nav a:hover, .side-nav button:hover { background: var(--dd-gray-50); color: var(--dd-ink); }
.side-nav a.active { background: var(--dd-gray-100); color: var(--dd-ink); font-weight: 600; }
/* After the hover rule on purpose: same specificity, so order keeps the
   active row's full fill when pointed at — hovering the row you are already
   on must not DIM it a step (gray-100 active losing to gray-50 hover). */
.side-nav a.active:hover, .side-nav button.active:hover { background: var(--dd-gray-100); }
/* THE PRESS. Every control in this rail faded a background in when the pointer
   ARRIVED and answered the finger landing with nothing at all — the rail had no
   :active rule of any kind. One shade past hover, on the same background leg the
   hover already rides, so a press is a step further in and not a second clock.
   AFTER the .active:hover rule above on purpose: same specificity, later source
   wins, so pressing the row you are already on cannot re-open the dimming bug
   that rule exists to close. */
.side-nav a:active, .side-nav button:active { background: var(--dd-gray-100); }
/* …AND THE PRESS DIPS THE ROW, which is the half of the reference's row feel
   that a fill cannot express: a background says "this one", a 2% dip says "I
   have got it". Same clock as the fill it rides with (the reference presses at
   150ms, --dd-dur is 160, and a second number in this file to buy 10ms nobody
   can perceive would be one more thing to keep in step), added to the row's own
   transition list above.
   Rail-scoped, because .side-nav is a shared class: the settings nav and the
   auth page's still-life mock wear it too and neither is a row you press. */
.sidebar .side-nav a:active,
.sidebar .side-nav button:active { transform: scale(.98); }
/* The dip is MOTION, and the blanket reduced-motion rule further down only
   stops transitions — a scale left standing would simply snap on and off for a
   reader who asked for less of exactly this. The fill still answers the press. */
@media (prefers-reduced-motion: reduce) {
  .sidebar .side-nav a:active,
  .sidebar .side-nav button:active { transform: none; }
}
/* THE ROWS HAND THEIR FILL TO THE GLIDE while the highlight is up — one lit row
   at a time, and a row that kept its own background would paint OVER the
   highlight and hide it (see the .dd-glide block). `is-glide-on` rather than
   :hover on the group, because it is on the container exactly while there IS a
   highlight standing somewhere, which is also the honest condition in the window
   before the bundle attaches one: the served rail keeps filling its own rows.

   The ACTIVE row gives its fill up too, which the reference is explicit about
   and which is only bearable because the highlight's ground is the SAME
   gray-100 the active row wears — so the current screen's fill is not dimmed
   while you point at the rail, it is picked up and carried to whatever your
   pointer is over, and dropped back when it leaves. That is also why the
   highlight is not restyled to the rows' gray-50 hover here: a glide at gray-50
   arriving on the active row would dim it a step, which is the exact bug the
   .active:hover rule above exists to close. */
.sidebar .side-nav.is-glide-on a:hover,
.sidebar .side-nav.is-glide-on button:hover,
.sidebar .side-nav.is-glide-on a.active,
.sidebar .side-nav.is-glide-on button.active { background: transparent; }
/* And a PRESS still reads as more than a hover while the glide is up: the
   highlight already stands at gray-100, so the pressed row steps one past it,
   the identical move the settings nav makes (spa.css). Without this the :hover
   yield above outranks the rows' plain :active fill and a press would paint
   nothing but the scale dip. The split-drag case needs no carve-out — the
   body.split-dragging .split-source rule outranks this on specificity, so a
   held drag keeps the carried row's own fill rather than a press fill. */
.sidebar .side-nav.is-glide-on a:active,
.sidebar .side-nav.is-glide-on button:active { background: var(--dd-gray-200); }
.side-nav svg { width: 16px; height: 16px; flex: none; stroke-width: 1.75; }

/* The tasks module's open projects, under the nav — records under the
   modules, the way the reference lists its records. Emoji marks share the
   icons' 16px slot so names line up with the nav labels above. */
/* `flex: none`, NOT the `min-height: 0` this used to carry. Both sections are
   flex children of a column rail that is allowed to scroll (see .sidebar's own
   note: the foot has to stay reachable on a short viewport). A shrinkable child
   whose min-height is zero never makes the rail scroll — it compresses instead,
   and because neither section clips, the rows it can no longer fit are PAINTED
   OUTSIDE ITS BOX, over whatever comes next. That is what it looked like: the
   last project sitting under the "Chats" label, and the last chat under the
   Getting-started card. Refusing to shrink is what hands the overflow to the
   rail, which knows how to scroll it. The fold still folds: that animation is
   the grid track on .side-sec inside, and an auto-basis child follows its
   content down. */
.side-projects { padding-top: 10px; flex: none; }

/* ---- the sections that leave whole when the rail collapses ----
   Projects, Chats, the workspace switcher and (in spa.css) the Getting-started
   card are the four things the collapsed rail drops ENTIRELY rather than
   narrowing — there is no 64px version of a project list. They used to do it
   with a bare `display: none`, which lands at frame 1 of a 220ms width tween:
   the rail eased shut while a third of its contents teleported.

   So the display change is transitioned too. `allow-discrete` makes `display`
   a transitionable property — going out, the switch to `none` is held until
   the end of the leg, so the fade finishes on a section that is still there;
   coming back, the switch to visible happens immediately and the opacity leg
   plays over it. @starting-style is the other half and is REQUIRED, not
   decorative: an element at `display: none` is not rendered, so it has no
   before-change style for the return transition to start from, and without a
   starting style there is simply nothing to animate. It also gives the section
   its 110ms fade when it first mounts (a project list arriving from the
   store), which is the same event by another name.

   110ms is not a new number. It is the exact opacity leg `.side-nav a > span`,
   `.ws-name` and `.ws-caret` already ride in this same gesture, so the labels
   narrowing and the sections leaving are one event and not two.

   Where allow-discrete/@starting-style are unsupported the declarations are
   dropped, the collapsed rule is still `display: none`, and the behaviour is
   exactly what shipped before this — there is no broken fallback to inherit.

   NOT a max-height ceiling: Projects and Chats are variable-length lists in a
   scrolling rail, so any fixed ceiling either clips a long one or finishes
   visually early on a short one. */
.side-projects,
.side-chats {
  transition: opacity 110ms var(--dd-ease),
              display 110ms var(--dd-ease);
  /* Its own declaration, never a token inside the shorthand: one unrecognised
     token invalidates the whole list, and after the shorthand because
     `transition` resets this longhand. Same everywhere it appears. */
  transition-behavior: allow-discrete;
}
@starting-style {
  .side-projects,
  .side-chats { opacity: 0; }
}
.side-project-mark {
  width: 16px; flex: none;
  display: grid; place-items: center;
  font-size: var(--dd-fs-xs); line-height: 1;
}
/* :is(a, button) — the project rows became anchors when a project got a page
   of its own to link to, and this rule is what keeps a long name from pushing
   its row past the 232px rail. Left scoped to buttons it silently stopped
   applying, which does not fail a test and does not fail in jsdom; it fails on
   somebody's screen, on the one project whose name is long. */
.side-projects .side-nav :is(a, button) span:not(.side-project-mark) {
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* opacity BEFORE display, and both stated: see the transition note above. */
:root.rail-collapsed .sidebar .side-projects { opacity: 0; display: none; }

/* The assistant's history FLOWS after the projects like any other section —
   David rejected pinning it to the rail's bottom (the reference stacks its
   sections top-down and lets the foot alone hug the bottom edge). The foot's
   own margin-top:auto is the only thing that pushes down. Chat titles are one
   line, ellipsized — the rail is 232px and a question is a sentence. */
.side-chats { padding-top: 10px; flex: none; } /* see .side-projects above */
.side-chats .side-nav button span:not(.side-chat-star) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.side-chats .side-nav button.active { background: var(--dd-gray-100); color: var(--dd-ink); font-weight: 600; }
/* A STARRED chat wears its mark at the row's trailing edge, so the starred
   ones read as a marked column rather than a second glyph competing with the
   chat icon at the head of the row. The accent, not an ink step: starred is a
   STATE the user chose, and it follows Settings -> Appearance the way the
   checked box and the workspace mark do.
   pointer-events is the load-bearing line here, not polish — the row IS the
   button, and a decoration inside it that can take the pointer is a chat row
   that sometimes does nothing when you click its right half. */
.side-chat-star {
  margin-left: auto; flex: none;
  display: grid; place-items: center;
  pointer-events: none;
  color: var(--dd-accent);
}
/* After .side-nav svg, which sizes the row's leading glyph to 16px; the mark
   is a trailing annotation and reads at 12. */
.side-chat-star svg { width: 12px; height: 12px; }
:root.rail-collapsed .sidebar .side-chats { opacity: 0; display: none; }

/* The rail's MIDDLE — modules, projects, chats — is the rail's one scroller.
   The head, search and foot stay planted; a rail taller than the window used
   to just clip its tail (side-nav bottom measured at 988 in an 850px window),
   and a wheel over it either did nothing or, on Safari, bounced the whole
   document. overscroll contained so reaching its end never chains upward. */
.side-mid {
  flex: 1; min-height: 0;
  display: flex; flex-direction: column;
  overflow-y: auto;
  overscroll-behavior: contain;
}
.side-foot { margin-top: auto; display: flex; flex-direction: column; gap: 2px; padding-top: 12px; border-top: 1px solid var(--dd-hairline); }

.main-col {
  flex: 1; min-width: 0; display: flex; flex-direction: column;
}

/* ---------- the top bar ----------
   One slim line across the main column: the page's icon and name at the left,
   Help at the right (shell/Topbar.tsx renders into #dd-topbar, a static child
   of .main-col). This is where the page names itself — the subbars carry no
   brand row. It needs no stickiness: the document is locked (.shell) and the
   hub's main scrolls itself beneath this line; the app pages size themselves
   against --dd-topbar-h instead. */
.topbar {
  flex: none;
  height: var(--dd-topbar-h);
  display: flex; align-items: center; gap: 8px;
  padding: 0 16px;
  border-bottom: 1px solid var(--dd-hairline);
  background: var(--dd-canvas);
  z-index: 60;
}
.topbar-page {
  display: flex; align-items: center; gap: 8px;
  font-size: var(--dd-fs-sm-);
  font-weight: 600;
  letter-spacing: -.005em;
}
.topbar-page svg { width: 15px; height: 15px; flex: none; stroke-width: 1.75; color: var(--dd-ink-light); }
/* ---------- the breadcrumb ----------
   A page that lives under something names it first — "Classes › 🧮 Data
   Structures", "Settings › Appearance" (shell/Topbar.tsx). The parent is
   quiet ink and plain words, not a link: the rail beside it is the
   navigation, and this line only says where you are. The separator overrides
   .topbar-page's 15px glyph size — it is punctuation, not an icon.

   OVERFLOW IS THE CRUMB'S PROBLEM, NOT THE BAR'S (the "kind of bugged"
   report, 2026-08-26): a long project name in a flex row with no min-width
   ran the crumb into the Help button. min-width: 0 on the page slot + one
   ellipsis on the NAME (the leaf is the part worth keeping whole-ish; the
   parent is context and may not shrink below its words). */
.topbar-page { min-width: 0; }
.topbar-page .topbar-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.topbar-crumb { flex: none; color: var(--dd-ink-quiet); font-weight: 500; white-space: nowrap; }
.topbar-page svg.topbar-crumb-sep {
  width: 11px; height: 11px;
  stroke-width: 2;
  color: var(--dd-ink-quiet);
  margin: 0 -2px;
}
/* The project's own mark, between the arrow and the name — text, not one of
   .topbar-page's 15px stroked glyphs. */
.topbar-crumb-emoji { flex: none; font-size: 14px; line-height: 1; }
.topbar-help {
  display: flex; align-items: center; gap: 6px;
  border: 0; background: transparent; font: inherit; cursor: pointer;
  font-size: var(--dd-fs-sm-);
  color: var(--dd-ink-quiet);
  padding: 4px 8px; border-radius: var(--dd-radius-m);
  transition: background var(--dd-dur) var(--dd-ease), color var(--dd-dur) var(--dd-ease);
}
.topbar-help:hover { background: var(--dd-gray-100); color: var(--dd-ink); }
.topbar-help svg { width: 15px; height: 15px; flex: none; stroke-width: 1.75; }

/* ---------- the top bar with a CONVERSATION open ----------
   One screen renames this line (shell/Topbar.tsx): the chat's own title takes
   the page-name slot with a star toggle beside it, and new / history / more
   take the corner Help usually occupies. Help is displaced for that one state
   and nothing else — every other screen ends this line exactly as it did.

   The glyphs are .topbar-help's 15px at 1.75, NOT the rail's 16px: these sit
   on the same 48px line as the page name and the Help word, so they have to
   read as one row of controls rather than two families of icon. The menus they
   open are the house openMenu — anchored, travel-free, .dd-menu's own surface.
   The hover fades below are covered by this file's blanket
   prefers-reduced-motion rule (`* { transition: none !important }`), so they
   are already off for a reader who asked for less motion. */
/* Tighter than .topbar-page's own 8px: that gap parts a module's GLYPH from
   its name, two separate things, while the star belongs to the title it marks
   — and the button's 26px box already carries ~5px of air around its 15px
   glyph, so 8px more reads as a stray control. */
.topbar-chat { min-width: 0; gap: 4px; }
/* One line, always: a chat's title is a whole question, and the bar is 48px. */
.topbar-chat .topbar-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* THE ONE PHONE SCREEN THAT KEEPS THE TOPBAR. The ≤720px block below zeroes
   --dd-topbar-h and hides .topbar because every module's subbar names the
   page and carries the drawer button there — but the chat view has no subbar:
   its title, star, new/history/actions AND the drawer button all live on this
   bar, so hiding it left a phone inside a chat with no chrome and no way out.
   Gated on the bar actually being in chat mode (.topbar-chat is rendered only
   then), and the height comes back through the TOKEN so every layout that
   subtracts it — the chat scroller included — adapts without a second rule.
   Topbar.tsx renders the drawer's RailOpenButton first in this state. */
@media (max-width: 720px) {
  :root:has(.topbar-chat) { --dd-topbar-h: 48px; }
  :root:has(.topbar-chat) .topbar { display: flex; }
}

.topbar-chat-star,
.topbar-chat-btn {
  display: grid; place-items: center;
  width: 26px; height: 26px; flex: none;
  padding: 0;
  border: 0; background: transparent; font: inherit; cursor: pointer;
  border-radius: var(--dd-radius-m);
  color: var(--dd-ink-quiet);
  transition: background var(--dd-dur) var(--dd-ease), color var(--dd-dur) var(--dd-ease);
}
.topbar-chat-star:hover,
.topbar-chat-btn:hover { background: var(--dd-gray-100); color: var(--dd-ink); }
/* An unsaved chat has no id to star yet. The control stays in place and says
   so, rather than appearing under the pointer the moment the first answer
   lands and gives the conversation an id. */
.topbar-chat-star[disabled] { opacity: .4; cursor: default; }
.topbar-chat-star[disabled]:hover { background: transparent; color: var(--dd-ink-quiet); }
/* Colour comes from the BUTTON here, unlike .topbar-page svg one rule up,
   which paints every glyph in the page slot --dd-ink-light — right for a
   module's mark, wrong for a control that has a hover and a pressed state. */
.topbar-chat-star svg,
.topbar-chat-btn svg { width: 15px; height: 15px; flex: none; stroke-width: 1.75; color: inherit; }
/* STARRED IS THE ACCENT, so the mark follows Settings -> Appearance rather
   than being a colour of its own — the same argument as the checked box, the
   today badge and the workspace mark. It holds through hover: the fill is the
   state, not the pointer. */
.topbar-chat-star[aria-pressed="true"] svg,
.topbar-chat-star[aria-pressed="true"]:hover svg { color: var(--dd-accent); }

.topbar-chat-actions { display: flex; align-items: center; gap: 2px; }

/* THE HISTORY MENU's rows carry their star at the RIGHT edge. ui/Menu.tsx
   emits its 16px icon slot on every row whether or not the row filled one, so
   moving the slot moves the whole column: the titles still start on one edge
   and the starred chats mark a clean trailing column. Scoped to this menu's
   own surface class — the shared .dd-menu rules are untouched. */
.topbar-chat-history .dd-menu-icon { order: 1; margin-left: auto; color: var(--dd-accent); }

.spacer { margin-left: auto; }

.search-pill {
  display: flex; align-items: center; gap: 8px;
  font-family: inherit;
  height: 34px; width: 100%;
  padding: 0 14px;
  border: 1px solid var(--dd-border);
  border-radius: var(--dd-radius-m);
  background: var(--dd-canvas);
  color: var(--dd-ink-quiet);
  font-size: var(--dd-fs-sm-);
  cursor: pointer;
  transition: border-color var(--dd-dur) var(--dd-ease);
}
.search-pill:hover { border-color: var(--dd-gray-300); }
/* The pill has no fill to deepen — it is a bordered card — so the press is one
   step further down the same border it already lightens on hover, riding the
   border-color leg declared above. */
.search-pill:active { border-color: var(--dd-gray-500); }
.search-pill > svg { width: 14px; height: 14px; flex: none; stroke-width: 1.75; color: var(--dd-ink-quiet); }
/* One line, always — the label truncates before the pill ever grows a second
   row. (It says "Search" today, but the rule guards the pill's shape, not the
   current word.) */
.search-pill-label {
  min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.search-pill kbd {
  margin-left: auto;
  font-size: var(--dd-fs-3xs);
  border: 1px solid var(--dd-border);
  border-radius: var(--dd-radius-s);
  padding: 1px 5px;
  background: var(--dd-panel);
}

.avatar {
  width: 32px; height: 32px; flex: none;
  font-family: inherit; color: inherit;
  border-radius: var(--dd-radius-full);
  background: var(--dd-gray-100);
  border: 1px solid var(--dd-border);
  display: grid; place-items: center;
  font-size: var(--dd-fs-2xs); font-weight: 650;
  cursor: pointer;
  transition: background var(--dd-dur) var(--dd-ease),
              border-color var(--dd-dur) var(--dd-ease);
}
.avatar:hover { background: var(--dd-gray-200); border-color: var(--dd-gray-300); }

/* ---------- main / view ---------- */

main { padding: 32px 40px 40px; }
/* The hub's main (no data-app) scrolls ITSELF — the document is locked (see
   .shell). flex:1 + min-height:0 hands it the column's remaining height.
   .auth-body is EXCLUDED by name: the auth pages also have no data-app, and
   this rule's min-height:0 outweighs .auth-split's min-height:100vh (it
   carries one more simple selector), which collapsed the sign-in page to its
   content height and cut the hero short — the auth pages keep their own
   document-scroll model. */
body:not([data-app]):not(.auth-body) main { flex: 1; min-height: 0; overflow-y: auto; }
.view { max-width: var(--dd-view-max); margin: 0 auto; }
.view > * + * { margin-top: var(--dd-stack-gap); }

/* ---------- app load-in: THERE ISN'T ONE (2026-08-25) ----------
   The dd-arrive cascade — every region fading up 40ms apart on each arrival
   — is deleted on request: content that exists paints complete in its first
   frame, because a stagger reads as loading even when nothing is being
   loaded. What waiting remains (a cold module chunk) shows the honest
   .route-loading spinner from App.tsx instead of a fade. */
.route-loading {
  display: grid; place-items: center;
  min-height: 40vh;
}
.route-loading::after {
  content: "";
  width: 22px; height: 22px;
  border: 2px solid var(--dd-border);
  border-top-color: var(--dd-ink-quiet);
  border-radius: var(--dd-radius-full);
  /* TWO ANIMATIONS, AND THE SECOND IS A GATE. The spin is the honest part and
     its 700ms is left alone — a spinner is the one thing here that is not a
     transition between two states, so the house durations have nothing to say
     about it. The fade is what changed: 180ms of nothing before its first
     frame, so a module chunk that arrives in 40ms shows no spinner at all
     rather than a flash of one, which is the same argument .dd-nav-progress
     makes at the bottom of this file. `both` is what holds the from-state
     (opacity 0) through the delay.
     The RESTING opacity stays 1 on purpose: under prefers-reduced-motion the
     whole declaration is dropped, and a gate implemented as a resting
     `opacity: 0` would leave those users with a spinner that never appears. */
  animation: dd-spin 700ms linear infinite,
             dd-fade-in var(--dd-dur) var(--dd-ease) 180ms both;
}
@keyframes dd-spin { to { transform: rotate(360deg); } }

.view-header {
  min-height: 48px;
  display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
  margin-bottom: var(--dd-header-gap);
}
.view-eyebrow { display: block; margin-bottom: 6px; }
.view-title {
  margin: 0;
  font-size: 2.125rem;
  font-weight: 600;
  letter-spacing: -.025em;
  line-height: 1.1;
}
.view-actions { margin-left: auto; display: flex; gap: 8px; align-items: center; }

/* ---------- buttons ---------- */

.btn {
  display: inline-flex; align-items: center; gap: 7px;
  height: 34px; padding: 0 16px;
  border-radius: var(--dd-radius-m);
  border: 1px solid var(--dd-border);
  background: var(--dd-panel);
  color: var(--dd-ink);
  font: inherit; font-size: var(--dd-fs-sm-); font-weight: 550;
  cursor: pointer;
  /* LONGHANDS, because the press and the release are not the same event and a
     shorthand can only give them one clock. Going down is instant — the finger
     is already there and any ramp on the way in reads as a button that did not
     hear the click. Coming back up is 90ms, the same --dd-dur-pop every other
     "it happened" in this file uses, so the button settles rather than snaps.
     The asymmetry is written on :active below (transition-duration 0s for the
     transform leg only) and is the same shape as .check's draw-on-check /
     instant-on-uncheck.
     `opacity` is in the list for [disabled] at the bottom of this block: a
     button that dims the moment a save starts used to jump to .5 in one frame,
     which is why .auth-pane .login-submit — the ONE button in the product that
     transitions its opacity — has always felt a tier better than the rest.
     Anything appended to this list later must be appended as a LONGHAND value;
     a `transition:` shorthand on a .btn descendant replaces all four legs. */
  transition-property: background, border-color, transform, opacity;
  transition-duration: var(--dd-dur), var(--dd-dur), var(--dd-dur-pop), var(--dd-dur);
  transition-timing-function: var(--dd-ease);
}
.btn:hover { background: var(--dd-gray-50); border-color: var(--dd-gray-300); }
.btn:active {
  transform: translateY(1px);
  transition-duration: var(--dd-dur), var(--dd-dur), 0s, var(--dd-dur);
}
.btn-primary { background: var(--dd-accent); color: var(--dd-on-accent); border-color: var(--dd-accent); }
/* 88%, RE-DERIVED AGAINST THE NEW GROUNDS and not a taste change. This mix
   is the only accent state in the file that carries --dd-on-accent as TEXT
   while being diluted, so the percentage is a contrast number, not a
   brightness one. The Attio re-tone moved the canvas it mixes into at both
   ends — nearly to white in light, near-black in dark — which pulls the
   result further from the accent in the same step it always took: at the old
   82% the light hover measured 3.8:1 against its own label, under the 4.5:1
   the resting button clears comfortably. 88% puts it back at 4.2:1, a shade
   better than the 4.1:1 this hover had before any of this moved, and in dark
   it keeps the darkening step the same size against a ground that is now
   much further down. Re-measure it if either --dd-canvas or --dd-blue moves
   again; do not nudge it for looks. */
.btn-primary:hover {
  background: color-mix(in srgb, var(--dd-accent) 88%, var(--dd-canvas));
  border-color: color-mix(in srgb, var(--dd-accent) 88%, var(--dd-canvas));
}
/* Red ink on the resting panel, filling only on hover — a destructive button
   announces itself without shouting from across the page. */
.btn-danger { color: var(--dd-red); border-color: color-mix(in srgb, var(--dd-red) 35%, var(--dd-border)); }
.btn-danger:hover {
  background: color-mix(in srgb, var(--dd-red) 9%, var(--dd-panel));
  border-color: color-mix(in srgb, var(--dd-red) 60%, var(--dd-border));
}
.btn-icon { width: 34px; padding: 0; justify-content: center; }
.btn-sm { height: 28px; padding: 0 13px; font-size: var(--dd-fs-xs); }
.btn svg { width: 16px; height: 16px; stroke-width: 1.75; }
.btn[disabled] { opacity: .5; cursor: default; pointer-events: none; }

/* ---------- a label that changes while the button works ----------
   Save → Saving…, Create invite → Creating…, Sync now → Syncing…. Ten buttons
   in Settings alone swap a word the moment a request leaves, and every one of
   them used to do it by replacing a text node: the label was one thing and then
   it was another, with the button's box jumping to the new word's width in the
   same frame.

   ONE MECHANISM, NOT TEN COPIES, and it is CSS with no timers in it at all.
   ui/BusyLabel.tsx renders BOTH labels, always, and marks which one is on; this
   stacks them in a single grid cell and crossfades between them.

   Three things fall out of stacking them that a JS text swap has to solve by
   hand and usually doesn't:
     · THE BOX NEVER MOVES. Both labels are in flow in the same cell, so the
       column is the wider of the two from the start and stays there — the width
       jump mid-swap is gone because there is no width change left to make.
       (The resting button is therefore as wide as its busy word. On the four
       where the busy word is longer that is a few pixels of extra air, and it
       is the right trade for a control that must not resize under a finger.)
     · REDUCED MOTION IS FREE. The file's blanket `* { transition: none }` turns
       this into the instant swap it replaces. A JS-timed version has to read
       matchMedia itself or it hands those readers a DELAYED hard cut, which is
       strictly worse than the un-animated swap it set out to improve.
     · THE REST LABEL IS NEVER LOST. Nothing reads textContent to remember what
       the button said, so nothing can restore the wrong word.

   TWO LEGS, NOT A CROSSFADE: the outgoing label leaves over --dd-dur-pop and
   the incoming one starts exactly when it has finished, so the two are never
   both half-visible on top of each other — stacked text at 50% opacity reads as
   a rendering fault, which is the trap in doing this simultaneously. 90 + 90 =
   180ms end to end, inside this system's 220ms ceiling; the reference effect's
   own sequencing at its own 150ms would have been 300 and over it.

   The blur is 2px and it is the only blur in this file that stays. The two that
   were removed were removed for reasons that do not reach here: the view
   transition's blur rasterised two FULL-PAGE snapshots at several radii, and
   dd-content-in's would have made live elements a containing block for sticky
   descendants. This is six characters of text in a span with no descendants at
   all, and the softening is what makes a word leaving read as a word leaving
   rather than as one blinking out. */
.dd-swap { display: inline-grid; align-items: center; justify-items: center; }
.dd-swap > span {
  grid-area: 1 / 1;
  /* A label may be glyph + word (welcome's withBusy adopts the button's real
     child nodes): flex keeps that pair on the button's own rhythm. Pure-text
     labels are unaffected — one text node has nothing to space. */
  display: inline-flex; align-items: center; gap: 6px;
  opacity: 1;
  transform: translateY(0);
  filter: none;
  transition: opacity var(--dd-dur-pop) var(--dd-ease),
              transform var(--dd-dur-pop) var(--dd-ease),
              filter var(--dd-dur-pop) var(--dd-ease);
}
/* Off: below and soft, so the label that arrives rises into place. Both labels
   rest here, which also means a button that mounts already busy paints its busy
   word with no animation — a transition never runs on first style resolution,
   the same guarantee .check's tick relies on. */
.dd-swap > span[data-on="false"] {
  opacity: 0;
  transform: translateY(3px);
}
/* The softness is the ONLY half of that state behind the motion query, and the
   split is deliberate. A static filter: blur() is neither a transition nor an
   animation, so the blanket reduced-motion rule cannot reach it (see the blur
   law further down this file) — but opacity and transform must stay in the
   base rule, because they are what makes the resting label invisible. Gating
   the whole block would leave BOTH labels painted on top of each other for the
   reader who asked for less motion. 1.6px, not the 2px this shipped with: that
   is the ceiling the law names. */
@media (prefers-reduced-motion: no-preference) {
  .dd-swap > span[data-on="false"] { filter: blur(1.6px); }
}
/* The arriving half waits for the leaving half to finish. Harmless at rest and
   at mount, where there is no transition running for a delay to apply to. */
.dd-swap > span[data-on="true"] { transition-delay: var(--dd-dur-pop); }

/* ---------- stat row ---------- */

.stats-row {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: var(--dd-grid-gap);
}
.stat-tile {
  background: var(--dd-panel);
  border: 1px solid var(--dd-border);
  border-radius: var(--dd-radius-l);
  padding: 16px var(--dd-pad-x) 14px;
  text-decoration: none;
  display: block;
  min-width: 0;
  transition: border-color var(--dd-dur) var(--dd-ease);
}
a.stat-tile:hover { border-color: var(--dd-gray-300); }
.stat-value {
  font-size: 2rem;
  font-weight: 620;
  letter-spacing: -.02em;
  margin-top: 8px;
  line-height: 1;
  font-variant-numeric: tabular-nums;
}
.stat-value .of { font-size: var(--dd-fs-md); font-weight: 500; color: var(--dd-ink-quiet); }
.stat-sub { margin-top: 8px; font-size: var(--dd-fs-xs); color: var(--dd-ink-quiet); }
.stat-sub b { color: var(--dd-ink); font-weight: 600; }

/* ---------- panels ---------- */

.panel {
  background: var(--dd-panel);
  border: 1px solid var(--dd-border);
  border-radius: var(--dd-radius-l);
  overflow: hidden;
  display: flex;
  flex-direction: column;
}
.panel-header {
  min-height: 56px;
  display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
  padding: 8px var(--dd-pad-x);
  border-bottom: 1px solid var(--dd-hairline);
}
.panel-title { font-size: var(--dd-fs-md); font-weight: 600; letter-spacing: -.01em; }
.panel-count { font-size: var(--dd-fs-xs); color: var(--dd-ink-quiet); }
.panel-body { padding: var(--dd-pad-x); }
.panel-link {
  font-size: var(--dd-fs-sm-); font-weight: 550;
  color: var(--dd-ink-light); text-decoration: none;
}
.panel-link:hover { color: var(--dd-ink); }

/* Pinned bottom action for a truncated list: full-width, its own hairline,
   always flush with the panel's bottom edge — so the action sits on the same
   line in both panels of a row instead of floating in the header. */
.panel-foot {
  margin-top: auto;
  display: flex; align-items: center; justify-content: center;
  min-height: 44px;
  padding: 0 var(--dd-pad-x);
  border-top: 1px solid var(--dd-hairline);
  font-size: var(--dd-fs-sm-); font-weight: 550;
  color: var(--dd-ink-light);
  text-decoration: none;
  transition: background var(--dd-dur) var(--dd-ease), color var(--dd-dur) var(--dd-ease);
}
.panel-foot:hover { background: var(--dd-gray-50); color: var(--dd-ink); }

.panel-columns {
  display: grid; gap: var(--dd-grid-gap);
  /* Side-by-side panels are the same size — a jagged pair of boxes reads as
     unfinished. The rows inside absorb the extra height (see .row-list below),
     so equal height never costs a dead void either. Pair panels with
     comparable row counts and this stays invisible. */
  align-items: stretch;
}
/* opt out when a panel must hug its content (e.g. beside a much longer list) */
.panel-columns.cols-top { align-items: start; }
.cols-2 { grid-template-columns: 1fr 1fr; }
.cols-2-1 { grid-template-columns: 2fr 1fr; }

/* stack several panels inside ONE column of a .panel-columns row. The panels
   share the column's height so the stack ends flush with its neighbour. */
.col-stack { display: flex; flex-direction: column; gap: var(--dd-grid-gap); }
.col-stack > .panel { margin-top: 0; flex: 1; }

/* ---------- rows / lists ---------- */

.row-list {
  list-style: none; margin: 0; padding: 0;
  flex: 1;
  display: flex; flex-direction: column;
}
/* Rows grow to fill a stretched panel instead of leaving a gap beneath the
   last one. min-height keeps a single-row list from ballooning. */
.row-list > li { flex: 1 0 auto; min-height: 46px; }
.row-list li {
  display: flex; align-items: center; gap: 12px;
  padding: 12px var(--dd-pad-x);
  border-bottom: 1px solid var(--dd-hairline);
  transition: background var(--dd-dur) var(--dd-ease);
}
.row-list li:last-child { border-bottom: 0; }
.row-list li:hover { background: var(--dd-gray-50); }
.row-main { min-width: 0; flex: 1; }
.row-title { font-weight: 520; }
.row-sub { font-size: var(--dd-fs-xs); color: var(--dd-ink-quiet); margin-top: 2px; }
/* Rows are a scannable rhythm: title and sub-line each stay on ONE line and
   ellipsize. A single wrapping row makes every row height in the list differ. */
.row-title, .row-sub { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row-meta {
  margin-left: auto; flex: none;
  font-size: var(--dd-fs-xs);
  color: var(--dd-ink-quiet);
  font-variant-numeric: tabular-nums;
}
/* leading meta (a time column on the left) — same look, no auto margin.
   The width is fixed so titles align across rows; keep lead labels short. */
.row-meta.lead { margin-left: 0; width: 96px; flex: none; }

/* trailing numeric VALUE (money, size, count) — data, not meta, so it reads
   in full ink. Right-aligned with a fixed column so values line up. */
.row-value {
  margin-left: auto; flex: none;
  min-width: 88px; text-align: right;
  font-size: var(--dd-fs-sm); font-weight: 550;
  font-variant-numeric: tabular-nums;
}

.check {
  width: 17px; height: 17px; flex: none;
  border: 1.5px solid var(--dd-gray-300);
  border-radius: var(--dd-radius-s);
  appearance: none; margin: 0; cursor: pointer;
  background: var(--dd-panel);
  transition: background var(--dd-dur) var(--dd-ease),
              border-color var(--dd-dur) var(--dd-ease),
              transform var(--dd-dur) var(--dd-ease);
}
.check:hover { border-color: var(--dd-gray-500); }
/* A press the finger feels and the eye barely does. .88 was a squeeze — the box
   became an event of its own on the way to reporting one. */
.check:active { transform: scale(.96); }
.check:checked {
  background: var(--dd-accent);
  border-color: var(--dd-accent);
}
/* The tick is a mask, so it takes --dd-on-inverse and inverts with the scheme
   automatically. Masking keeps the stroke crisp and optically centred at any
   box size — the old rotated-border tick sat low and read as a heavy blob.

   IT IS DRAWN, NOT SWITCHED ON, and the pseudo-element therefore exists in
   BOTH states. This rule used to be `.check:checked::after`, which has nothing
   to animate from: a pseudo-element that the state change CREATES starts life
   at its final style and no transition on it can ever run. So the tick is
   always here and always clipped — to nothing while the box is empty, and the
   clip opens left to right, which is the direction --dd-tick's own stroke is
   painted in.

   UNCHECKING IS INSTANT, and that asymmetry is the whole of it: the transition
   is declared only on the :checked rule, so the browser finds one going in and
   none coming back. A to-do you just finished draws its mark; a to-do you
   re-open is simply open again, with nothing to sit through. */
.check::after {
  content: "";
  display: block; width: 100%; height: 100%;
  background: var(--dd-on-accent);
  -webkit-mask: var(--dd-tick) center / 10px 10px no-repeat;
  mask: var(--dd-tick) center / 10px 10px no-repeat;
  /* Both edges written as percentages so the two insets interpolate against
     each other directly rather than through a calc() of mixed units. */
  clip-path: inset(0 100% 0 0);
}
.check:checked::after {
  clip-path: inset(0 0% 0 0);
  transition: clip-path var(--dd-dur-draw) var(--dd-ease);
}
/* The visual box stays tight at 17px; the label wrapper (added by app.js)
   pads the hit area out to ~25px so the touch target is honest. */
.check-hit {
  display: inline-flex; align-items: center;
  padding: 4px; margin: -4px;
  flex: none; cursor: pointer;
}

/* A finished task steps back quietly: quiet ink + a hairline-weight rule
   through the title, never a heavy default strikethrough. Every trailing chip
   dims with it — a struck-through row showing a full-strength red "Overdue"
   contradicts its own state. */
/* Scoped to .row-list: a checkbox does not always mean "completed". A mail list
   uses checkboxes for bulk selection, and an unscoped rule struck through the
   subject of every selected message. */
.row-list li:has(.check:checked) .row-title {
  color: var(--dd-ink-quiet);
  text-decoration: line-through;
  text-decoration-color: var(--dd-gray-300);
  text-decoration-thickness: 1px;
}
.row-list li:has(.check:checked) .tag,
.row-list li:has(.check:checked) .status-pill { opacity: .65; }

/* ---------- status pills / tags ---------- */

.status-pill {
  display: inline-flex; align-items: center; gap: 6px;
  height: 22px; padding: 0 10px;
  border-radius: var(--dd-radius-s);
  background: var(--dd-gray-100);
  font-size: var(--dd-fs-2xs); font-weight: 550;
  white-space: nowrap;
}
.status-pill::before {
  content: ""; width: 6px; height: 6px; border-radius: 50%;
  background: var(--dd-gray-500);
}
.status-pill.green::before { background: var(--dd-green); }
.status-pill.yellow::before { background: var(--dd-amber); }
.status-pill.red::before { background: var(--dd-red); }
.status-pill.blue::before { background: var(--dd-blue); }
.status-pill.outline { background: transparent; border: 1px solid var(--dd-border); }
.status-pill.outline::before { display: none; }

/* inside rows a pill is a column: uniform width, centered, so titles and
   right edges line up across rows */
.row-list > li > .status-pill { min-width: 92px; justify-content: center; }
/* trailing category chip is a column too, so times/values below it align */
.row-list > li > .tag { min-width: 60px; justify-content: center; }

.tag {
  display: inline-flex; align-items: center;
  height: 20px; padding: 0 8px;
  border-radius: var(--dd-radius-s);
  background: var(--dd-gray-100);
  font-size: var(--dd-fs-3xs);
  color: var(--dd-ink-light);
  white-space: nowrap;
}

/* ---------- tables ---------- */

.table-scroll { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th {
  text-align: left;
  padding: 10px var(--dd-pad-x);
  border-bottom: 1px solid var(--dd-hairline);
  font-size: var(--dd-fs-2xs);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: .08em;
  color: var(--dd-ink-quiet);
  white-space: nowrap;
}
td {
  padding: 12px var(--dd-pad-x);
  border-bottom: 1px solid var(--dd-hairline);
  vertical-align: middle;
}
tr:last-child td { border-bottom: 0; }
tbody tr { transition: background var(--dd-dur) var(--dd-ease); }
tbody tr:hover { background: var(--dd-gray-50); }
td.num { font-size: var(--dd-fs-sm-); font-variant-numeric: tabular-nums; }
th.right, td.right { text-align: right; }
td .neg { color: var(--dd-ink); }
td .pos { color: var(--dd-green); }

/* ---------- calendar grid ---------- */

.cal-grid {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
}
/* grid items default to min-width:auto, so a long event chip would otherwise
   force its 1fr track wider and steal width from the other days */
.cal-grid > * { min-width: 0; }
.cal-dow {
  padding: 10px 0 8px;
  text-align: center;
  border-bottom: 1px solid var(--dd-hairline);
}
.cal-cell {
  min-height: 96px;
  padding: 8px 10px;
  border-bottom: 1px solid var(--dd-hairline);
  border-right: 1px solid var(--dd-hairline);
  font-size: var(--dd-fs-xs);
}
.cal-cell:nth-child(7n) { border-right: 0; }
.cal-cell .d {
  font-size: var(--dd-fs-2xs);
  color: var(--dd-ink-quiet);
}
.cal-cell.out { color: var(--dd-ink-quiet); background: var(--dd-gray-50); }
.cal-cell.today .d {
  display: inline-grid; place-items: center;
  width: 20px; height: 20px; border-radius: 50%;
  background: var(--dd-accent); color: var(--dd-on-accent);
}
.cal-event {
  margin-top: 5px;
  padding: 3px 7px;
  border-radius: var(--dd-radius-s);
  background: var(--dd-gray-100);
  font-size: var(--dd-fs-2xs);
  font-weight: 520;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.cal-event.tint { background: var(--dd-blue-wash); color: var(--dd-blue); }

/* No weekend tint and no today wash: Google Calendar marks today with the date
   circle alone and treats weekends like any other day, which keeps the grid
   calm. --dd-weekend-tint was removed with this rule. */

/* ---------- mail (dot column is scoped to .mail-list — never global) ---------- */

.mail-row-unread .row-title { font-weight: 650; }
/* THE READ ROW OWNS A REAL DOT, not a hole where one used to be. The read state
   was `content: ""; width: 7px; flex: none` — a box that reserved the column and
   had no height, no radius and no fill — so opening a conversation deleted a
   blue circle in a single frame, which is the most frequent state change in the
   whole module. Stated as ONE baseline plus a modifier, the two states are the
   same box under two conditions and the difference between them is something
   the engine can interpolate.

   scale(.4) AND NOT scale(0): a dot that collapses to a point reads as being
   sucked out of the row, where the same dot easing down to a fraction of its
   area reads as a mark going quiet — and marking a thread unread again plays
   this rule backwards for free.

   THE OPACITY LEG IS FOR RULES THAT LIVE ELSEWHERE. mail/app.css takes the dot
   to `opacity: 0` on hover, on the keyboard cursor and whenever the checkbox
   claims the same 6px slot; those were snapping while the checkbox itself faded
   in over --dd-dur, so the two halves of one substitution ran on different
   clocks. Declaring the leg here is what puts them on one.

   font-weight is deliberately NOT in the list: .mail-row-unread's 650 → 520 on a
   static face interpolates through weights the face does not have, and the
   synthesised midpoint reads as a glitch rather than as a transition. */
.mail-list li::before {
  content: ""; width: 7px; height: 7px; flex: none;
  border-radius: 50%; background: transparent;
  transform: scale(.4);
  transition: background-color var(--dd-dur) var(--dd-ease),
              transform var(--dd-dur) var(--dd-ease),
              opacity var(--dd-dur) var(--dd-ease);
}
/* Two class steps against the baseline's one, so the modifier wins without an
   `!important` and without a `:not()` on the other side. */
.mail-list .mail-row-unread::before { background: var(--dd-blue); transform: none; }

/* ---------- app cards ---------- */

.card-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--dd-grid-gap);
}
.app-card {
  background: var(--dd-panel);
  border: 1px solid var(--dd-border);
  border-radius: var(--dd-radius-l);
  padding: 18px var(--dd-pad-x) 16px;
  display: flex; flex-direction: column; gap: 10px;
  transition: border-color var(--dd-dur) var(--dd-ease);
}
.app-card:hover { border-color: var(--dd-gray-300); }
.app-card-head { display: flex; align-items: center; gap: 10px; }
.app-name { font-size: var(--dd-fs-md); font-weight: 620; letter-spacing: -.01em; }
.app-host {
  font-size: var(--dd-fs-xs);
  color: var(--dd-ink-quiet);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.app-meta { display: flex; gap: 6px; flex-wrap: wrap; }
.app-actions { margin-top: auto; padding-top: 6px; display: flex; gap: 8px; }
.app-actions .btn { height: 30px; padding: 0 13px; font-size: var(--dd-fs-xs); }


/* ---------- breakdown rows (label + proportional bar + value) ----------
   The standard way to show parts-of-a-whole: categories, storage, budgets.
   Never a pie chart. */

.breakdown { padding: 6px var(--dd-pad-x) 16px; }
.breakdown-row {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 4px 12px;
  padding: 9px 0;
}
.breakdown-row + .breakdown-row { border-top: 1px solid var(--dd-hairline); }
.breakdown-label { font-size: var(--dd-fs-sm); }
.breakdown-value {
  font-size: var(--dd-fs-sm); font-weight: 550;
  font-variant-numeric: tabular-nums;
  text-align: right;
}
.breakdown-bar {
  grid-column: 1 / -1;
  height: 4px; border-radius: 2px;
  background: var(--dd-gray-100);
  overflow: hidden;
}
.breakdown-bar i {
  display: block; height: 100%; border-radius: 2px;
  background: var(--dd-ink);
}

/* ---------- empty / placeholder ---------- */

.empty-state {
  padding: 40px var(--dd-pad-x);
  text-align: center;
  color: var(--dd-ink-quiet);
  font-size: var(--dd-fs-sm-);
}

/* EVERY empty state in the product, on one declaration, and it is deliberately
   the SAME declaration .dd-enter carries at the bottom of this file — there is
   one number for "content arrived inside a page" and this is not allowed to
   become a second one.

   What it closes: ui/rowTransitions.ts runs a real height+opacity+transform
   exit on a row leaving a list, so archiving the last conversation animated the
   row out beautifully and then hard-cut to a sentence. The list was in motion
   and the thing that replaced it was not.

   OPACITY ONLY, via dd-content-in, for the reason written where that keyframe
   is declared, and with no layout participation of any kind, so this can never
   shove what is around it. The gate on it running twice is that a CSS animation
   only restarts on MOUNT — no previous-item-count ref, no per-list state; a
   140ms fade does not get machinery.

   .cal-empty is deliberately NOT in this list even though it is an empty state:
   it is a centring wrapper whose only child is always an .empty-state, so
   naming it too would run two identical fades over one sentence and compound
   them into a slower, muddier curve than either. */
.empty-state,
.dd-palette-none,
.t-secempty,
.mail-readempty,
.detail-empty,
.panel-empty {
  animation: dd-content-in 140ms var(--dd-ease) both;
}

.chart-placeholder { padding: 14px var(--dd-pad-x) 6px; }
.chart-placeholder svg { width: 100%; height: 180px; display: block; }
.chart-axis {
  display: flex; justify-content: space-between;
  padding: 8px var(--dd-pad-x) 0;
}
.chart-footer {
  padding: 10px var(--dd-pad-x) 14px;
  border-top: 1px solid var(--dd-hairline);
  margin-top: 10px;
  display: flex; align-items: center; gap: 10px;
}

/* ---------- toasts ---------- */

.toast-stack {
  position: fixed; bottom: 24px; left: 50%;
  transform: translateX(-50%);
  display: flex; flex-direction: column; align-items: center; gap: 8px;
  z-index: 100;
  pointer-events: none;
}
.toast.toast-action {
  display: flex; align-items: center; gap: 14px;
  pointer-events: auto;
  padding-right: 8px;
}
.toast-action button {
  border: 0; background: transparent; cursor: pointer;
  font: inherit; font-size: var(--dd-fs-sm-); font-weight: 650;
  color: var(--dd-on-inverse);
  text-decoration: underline; text-underline-offset: 2px;
  padding: 4px 8px; border-radius: var(--dd-radius-s);
}
.toast-action button:hover { background: color-mix(in srgb, var(--dd-on-inverse) 16%, transparent); }
/* Deliberately NOT the accent: a toast is an inverse SURFACE (a dark pill
   carrying a sentence), not a selected thing. See the --dd-accent note. */
.toast {
  background: var(--dd-inverse);
  color: var(--dd-on-inverse);
  border-radius: var(--dd-radius-m);
  padding: 9px 18px;
  font-size: var(--dd-fs-sm-);
  font-weight: 550;
  /* The one floating surface in this file that gets NO hairline when its
     shadow goes, and it is the one that needs none: an inverse pill is the
     far end of the scheme from everything it can ever be laid over, so the
     fill is already the separation. A border on it would only be a second
     colour drawn between two that are as far apart as this system goes. */
  animation: dd-toast-in var(--dd-dur) var(--dd-ease);
}
/* A TOAST IS THE ONE EXCEPTION TO THE NOTE BELOW, and it is an exception
   because it is not anchored to anything. A menu appears where you pointed and
   a dialog is centred in the window; a toast comes up from the bottom edge
   whether or not anything animates, and 6px is that direction said quietly
   rather than a slide. It is a whole 6px because a toast is not the thing you
   asked for — it is the thing the app volunteered — and a surface nobody
   pointed at needs the motion to earn the glance the fade alone would not. */
@keyframes dd-toast-in {
  from { opacity: 0; transform: translateY(6px); }
}
/* ui/Toast.tsx keeps the pill in the stack for exactly this long past the
   dismiss, and takes it out afterwards. Fade only: it rose to say something and
   the saying is over, so the words go and the box does not travel back down.
   pointer-events after .toast-action's `auto` on purpose — an Undo that is on
   its way out must not still be clickable, which is the [hidden] click-catcher
   lesson in its other form. */
.toast.is-leaving {
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--dd-dur-toast-out) var(--dd-ease);
}
/* NO TRAVEL, deliberately (2026-08-25). This used to rise 6px, and every
   surface that borrowed it — the context menu, the palette, and at the time
   the toast and the dialog too, which have their own keyframes above and
   below now — read as sliding up into place. That is the wrong story for an
   anchored surface: a menu is not arriving from somewhere else, it is the
   thing you just asked for, appearing exactly where you asked for it. The
   6px lift also fought the placement logic, since a menu that flips ABOVE its
   anchor rose INTO the button that opened it. Opacity only, so there is no
   direction to read and no transform-origin to pin — the surface is simply
   there, one frame after the click, with just enough fade that it doesn't
   register as a flash. */
@keyframes dd-pop-in { from { opacity: 0; } }

/* ---------- dialogs (native <dialog class="dd-modal">) ---------- */

dialog.dd-modal {
  border: 1px solid var(--dd-border);
  border-radius: var(--dd-radius-l);
  background: var(--dd-panel);
  color: var(--dd-ink);
  padding: 0;
  width: min(480px, calc(100vw - 32px));
  animation: dd-modal-in var(--dd-dur-modal) var(--dd-ease);
}
dialog.dd-modal.wide { width: min(680px, calc(100vw - 32px)); }
/* --dd-backdrop carries a real dim again (see its token — it is the ⌘K
   palette's), so this fade is a fade of something, in the dialog's own time
   rather than the palette's: the ground and the panel arrive together. */
dialog.dd-modal::backdrop {
  background: var(--dd-backdrop);
  animation: dd-fade-in var(--dd-dur-modal) var(--dd-ease);
}

/* THE ONE SCALE IN THIS FILE. Not travel — see the --dd-dur-modal note: the
   panel has no anchor to grow out of, so .98 is depth and the eye reads it as
   the surface coming forward rather than arriving from a direction. */
@keyframes dd-modal-in {
  from { opacity: 0; transform: scale(.98); }
}
@keyframes dd-modal-out {
  to { opacity: 0; transform: scale(.98); }
}
/* ui/Dialog.tsx puts this on at the dismiss and takes the element out of the
   document when the animation ends — or when its own deadline passes,
   whichever is first, because a missed animationend must never be able to
   wedge a dialog open.
   UNCLICKABLE FOR EVERY FRAME IT IS HERE. A full-window surface on its way out
   that still swallows the pointer is the click outage this suite has shipped
   twice; the component pairs this with `inert`, which is the same statement to
   the keyboard. */
dialog.dd-modal.is-closing {
  animation: dd-modal-out var(--dd-dur-modal-out) var(--dd-ease) both;
  pointer-events: none;
}
dialog.dd-modal.is-closing::backdrop {
  animation: dd-fade-out var(--dd-dur-modal-out) var(--dd-ease) both;
  pointer-events: none;
}
.modal-title {
  margin: 0;
  padding: 20px var(--dd-pad-x) 0;
  font-size: 1.375rem;
  font-weight: 620;
  letter-spacing: -.02em;
  line-height: 1.25;
}
.modal-sub {
  padding: 8px var(--dd-pad-x) 0;
  font-size: var(--dd-fs-sm-);
  color: var(--dd-ink-quiet);
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
}
.modal-sub .seg { margin-left: auto; }
.modal-body { padding: 12px var(--dd-pad-x) 18px; color: var(--dd-ink-light); }
.modal-body > p { margin: 0; }
.detail-empty { font-size: var(--dd-fs-sm-); color: var(--dd-ink-quiet); }

/* label/value rows inside a detail dialog */
.detail-list { margin: 8px 0 0; }
.detail-list > div {
  display: flex; justify-content: space-between; align-items: baseline; gap: 16px;
  padding: 10px 0;
  border-top: 1px solid var(--dd-hairline);
  font-size: var(--dd-fs-sm);
}
.detail-list > div:first-child { border-top: 0; }
.detail-list dt { color: var(--dd-ink-quiet); flex: none; }
.detail-list dd {
  margin: 0; text-align: right; font-weight: 520; color: var(--dd-ink);
  font-variant-numeric: tabular-nums; min-width: 0; overflow-wrap: anywhere;
}

/* rows/cards that open a detail dialog (class added by app.js) */
.clickable { cursor: pointer; }
.modal-footer {
  display: flex; gap: 8px; justify-content: flex-end;
  padding: 14px var(--dd-pad-x);
  border-top: 1px solid var(--dd-hairline);
}
.modal-footer .danger { margin-right: auto; color: var(--dd-red); }

/* ---------- the glide (app/src/ui/glide.ts) ----------
   ONE highlight per list, and it travels. Every selectable list in this app
   answered the pointer by switching a background on under the cursor and off
   under the row it left; this is the same fill made into an object that MOVES
   between rows, which is the whole of the beautifului.dev GlideMenu feel David
   asked for. The script writes four numbers and an opacity; everything about
   how those changes look is here.

   THE ROWS MUST NOT KEEP A FILL OF THEIR OWN WHILE IT IS UP. A row is raised
   above the highlight (it has to be — the label would otherwise be painted
   over), and a raised row with a background paints that background over the
   highlight and hides it completely. So every surface that attaches a glide
   also drops its rows' own hover/active/selected ground while `is-glide-on` is
   on the container: .dd-menu below, .dd-palette-list further down, and the
   settings nav in app/src/styles/spa.css. Those rules are not cosmetic
   reconciliation, they are what makes the highlight visible at all.

   `is-glide-on` rather than `:hover` on the container, because the KEYBOARD
   drives this element too (the palette's cursor, a menu's roving focus) and a
   suppression keyed on the pointer would leave two filled rows the moment an
   arrow key moved one of them. */
.dd-glide-host { position: relative; }
/* .dd-menu is already positioned — it is fixed to the viewport — and a bare
   .dd-glide-host would out-order that and drop the surface back into the flow.
   Two classes, so the menu's own position wins wherever this block sits. */
.dd-menu.dd-glide-host { position: fixed; }
/* The rows, above the highlight. Stated on the host rather than on each
   surface's row class so a list that attaches a glide needs no second rule to
   keep its own labels legible.

   Excluded on the ATTRIBUTE glide.ts stamps on its element, not on .dd-glide:
   a surface may override the highlight's class to get a different ground (the
   primitive takes `className` for exactly that), and a class-keyed exclusion
   would quietly stop excluding it — the highlight would join the rows at
   z-index 1 and paint over the labels it is meant to sit under. */
.dd-glide-host > :not([data-dd-glide]) { position: relative; z-index: 1; }
.dd-glide {
  /* --dd-ease-glide, the reference's one curve, now lives in :root beside the
     rest of the --dd-ease family: the chat primitives' folds, reveals and the
     selection pill's width morph are the second, third and fourth rules to
     want it, which is exactly the condition this block's note set for
     promoting it. Nothing about the highlight's timing changed. */
  position: absolute; z-index: 0;
  top: 0; left: 0; width: 0; height: 0;
  opacity: 0;
  /* It is scenery under the row that owns the click. */
  pointer-events: none;
  background: var(--dd-gray-100);
  border-radius: var(--dd-radius-m);
  /* 220ms for the travel, which is --dd-dur-slow to the millisecond and is the
     token for "chrome that changes size" — this element changes size on every
     move. The fade rides --dd-dur: the reference fades over 150 and ours is
     160, and a second duration declared in this file to buy 10ms nobody can
     see would be one more number to keep in step for nothing. */
  transition: top var(--dd-dur-slow) var(--dd-ease-glide),
              left var(--dd-dur-slow) var(--dd-ease-glide),
              width var(--dd-dur-slow) var(--dd-ease-glide),
              height var(--dd-dur-slow) var(--dd-ease-glide),
              opacity var(--dd-dur) var(--dd-ease);
}
/* WORN FOR ONE WRITE, by glide.ts's place(): a highlight that is invisible has
   no position worth easing from, so the arrival that brings it back snaps its
   box under this class and only then fades. Without it the first hover of every
   menu slid in from wherever the last menu happened to leave it. */
.dd-glide.is-snap { transition: none; }

/* ---------- ddMenu · ddRename · ddEmojiPicker ----------
   The three "edit it where it sits" primitives (shared/app.js). One menu
   surface for every module: Calendar's .menu-pop and Mail's .mail-menu were
   the same component drawn twice, 168px vs 208px wide, 5px vs 6px of padding,
   9px vs 10px of gap — visibly two menus in one product. This is the one.
   Fixed to the viewport (never clipped by a scroller), on the panel fill and
   the 1px border every other anchored surface in the system uses — that pair
   IS the elevation here, there is no drop under it. */
.dd-menu {
  position: fixed; z-index: 120;
  min-width: 196px; max-width: 300px;
  padding: 6px;
  background: var(--dd-panel);
  border: 1px solid var(--dd-border);
  border-radius: var(--dd-radius-m);
  animation: dd-pop-in var(--dd-dur-pop) var(--dd-ease);
}
.dd-menu button[data-i] {
  display: flex; align-items: center; gap: 10px;
  width: 100%; text-align: left;
  padding: 7px 10px;
  border: 0; background: transparent; cursor: pointer;
  border-radius: var(--dd-radius-s);
  font: inherit; font-size: var(--dd-fs-sm-); font-weight: 500;
  color: var(--dd-ink);
  /* NO background transition, unlike the rail's rows. A menu row's highlight is
     not decoration, it is the answer to "which one am I about to click" — it
     has to be under the pointer at the same instant the pointer is, and a
     160ms fade means the highlight is always a beat behind a hand moving down
     a list of eight items. Everything else about the row may ease; this may
     not. */
}
/* gray-50, the rail's hover, was nearly invisible here: on the RAIL it tints a
   row against --dd-rail, which sits far below it, but the menu's ground is
   --dd-panel, which in dark is barely three steps under gray-50 — the hover
   was a colour change nobody could see. gray-100 is the next step up and is
   the tint the palette's selected row and the emoji picker's cells already use
   on this same panel ground, so all three overlay surfaces highlight
   identically. The Attio re-tone widened the rail's side of that gap and left
   the panel's side where it was, so the argument is only more true. */
.dd-menu button[data-i]:hover,
.dd-menu button[data-i]:focus-visible { background: var(--dd-gray-100); }
/* …AND IT HANDS THAT FILL TO THE GLIDE while the highlight is up. Identical
   ground (gray-100 either way, so nothing about this menu changes colour), and
   the row's own background has to go because a row paints ABOVE the highlight
   and would hide it — see the .dd-glide block above.

   The focus ring goes quiet under the same condition on purpose. `is-glide-on`
   means the highlight is standing on a row, whether the pointer put it there or
   ui/Menu.tsx's arrow keys did, and the point of the whole exercise is that
   there is one lit row at a time. When the pointer leaves, the highlight fades
   and this rule lifts, so a keyboard user roving a menu nobody is pointing at
   gets exactly the focus fill they have always had. */
.dd-menu.is-glide-on button[data-i]:hover,
.dd-menu.is-glide-on button[data-i]:focus-visible { background: transparent; }
/* The highlight wears the ROW's corner, not the primitive's default: menu rows
   are radius-s, and a 5px fill standing in for a 3px one is a different shape
   in the same place. */
.dd-menu .dd-glide { border-radius: var(--dd-radius-s); }
.dd-menu button[data-i][disabled] { opacity: .4; cursor: default; }
.dd-menu button[data-i][disabled]:hover { background: transparent; }
.dd-menu button.danger { color: var(--dd-red); }
/* The icon slot is emitted at a fixed width whether or not the item has a
   glyph, so labels line up down a column — the same alignment argument as
   Tasks' emoji slot, one component over. */
.dd-menu-icon {
  width: 16px; height: 16px; flex: none;
  display: grid; place-items: center;
  color: var(--dd-ink-quiet);
}
.dd-menu-icon svg { width: 16px; height: 16px; stroke-width: 1.75; }
.dd-menu button.danger .dd-menu-icon { color: currentColor; }
/* A workspace row carries the rail's own mark rather than a line glyph, so the
   thing you switch to in the menu is the thing you see in the rail's head —
   the switcher was the only list in the app where some rows had a mark and the
   workspaces, the rows the menu exists for, had none. The tile is 22px in the
   rail and the slot is 16px, so it is resized to 18px here: a filled square
   reads smaller than a stroked glyph of the same box, and 18px is what makes
   the two columns of marks look like one column. It must also opt OUT of the
   slot's --dd-ink-quiet, which exists to mute strokes and would otherwise
   repaint the letter sitting on the accent. */
.dd-menu-icon .ws-mark {
  width: 18px; height: 18px;
  font-size: var(--dd-fs-3xs);
  color: var(--dd-on-accent);
}
.dd-menu .dot { flex: none; }
.dd-menu-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dd-menu-sep { height: 1px; background: var(--dd-hairline); margin: 6px 4px; }
.dd-menu-head { display: block; padding: 8px 10px 4px; }
/* The tick on a checked row, and the ONE thing inside a menu that changes while
   the menu is still open: a `keep: true` item runs its handler and repaints in
   place instead of dismissing. So it gets the house tick treatment — the same
   clip-path draw .check uses, on the same --dd-dur-draw, opening left to right
   the way the glyph's own stroke is painted.

   The opacity toggle it used to carry is GONE rather than joined to this: a
   fade and a wipe running together on the same 140ms produce a mark that is
   half-drawn AND half-there, which reads as a rendering fault rather than as a
   mark being made. The svg is always rendered by ui/Menu.tsx, so — exactly as
   with .check — there is a real element here in both states for the clip to
   move on, and UNCHECKING IS INSTANT because the transition is declared only
   on the checked rule.
   A menu opened with rows already ticked paints them fully drawn: a transition
   never runs on an element's first style resolution. */
.dd-menu-tick { margin-left: auto; display: grid; place-items: center; }
.dd-menu-tick svg { width: 14px; height: 14px; clip-path: inset(0 100% 0 0); }
.dd-menu button[aria-checked="true"] .dd-menu-tick svg {
  clip-path: inset(0 0% 0 0);
  transition: clip-path var(--dd-dur-draw) var(--dd-ease);
}

/* ---------- the command palette (⌘K — app/src/ui/Palette.tsx) ----------
   One surface for quick actions and search-everywhere results, floated high
   over a scrim like every ⌘K since the form was invented. Same panel language
   as .dd-menu, a size up. */
.dd-palette-back {
  position: fixed; inset: 0; z-index: 115;
  background: var(--dd-scrim-light);
  display: flex; align-items: flex-start; justify-content: center;
  padding: 14vh 16px 16px;
  /* dd-fade-in is defined once, further down with the other shared keyframes. */
  animation: dd-fade-in var(--dd-dur) var(--dd-ease);
}
.dd-palette {
  width: 640px; max-width: 100%;
  max-height: min(460px, 70vh);
  display: flex; flex-direction: column;
  background: var(--dd-panel);
  border: 1px solid var(--dd-border);
  border-radius: var(--dd-radius-l);
  overflow: hidden;
  animation: dd-pop-in var(--dd-dur-pop) var(--dd-ease);
}
/* THE DISMISS, and it is the arrival run backwards: opacity only, no travel, no
   scale — the palette is an anchored surface by the same argument dd-pop-in's
   note makes, and a surface that appears in place has to leave in place too or
   the two halves describe different objects.

   ONE ANIMATION, ON THE BACKDROP, and the panel leaves inside it. Coming in the
   two are declared separately because they start together and the panel wants
   the shorter clock; going out they are one object and fading the ground twice
   — once for itself and once through a panel fading inside it — compounds into
   a curve steeper than either, which reads as a flicker rather than a fade.
   --dd-dur-pop, the panel's own entrance number, so 90ms out is faster than the
   160ms the ground took coming in: the rule the rest of this file already keeps
   (--dd-dur-modal 140 in / --dd-dur-modal-out 110 out; the toast's 120 out). A
   dismissal is not a thing to sit through.

   UNCLICKABLE AND UNTYPEABLE FOR EVERY FRAME IT IS STILL HERE. `pointer-events`
   is this half of the statement; ui/Palette.tsx marks the same element `inert`,
   which is the other half and is also what takes focus back out of the field so
   the next keystroke goes to the app rather than into a field on its way out.
   A full-window surface that still answers while it fades is the click outage
   this suite has shipped twice. */
.dd-palette-back.is-closing {
  animation: dd-fade-out var(--dd-dur-pop) var(--dd-ease) both;
  pointer-events: none;
}
.dd-palette-field {
  display: flex; align-items: center; gap: 10px;
  padding: 12px 14px;
  border-bottom: 1px solid var(--dd-hairline);
}
.dd-palette-field > svg { width: 16px; height: 16px; flex: none; stroke-width: 1.75; color: var(--dd-ink-quiet); }
/* THE RING STAYS, and the argument is the one .input's note above already
   makes: `outline: none` used to sit on this line, where it outranked the
   file's single :focus-visible rule and left a borderless, transparent field
   with no focus story of any kind.
   It matters here specifically because the field is NOT the only thing in this
   row that takes focus — .dd-palette-ask is a real button sitting beside it —
   so a keyboard user moving between the two has nothing else to tell them
   which one they are standing on.
   ON THE INPUT'S OWN BOX, not on the row: .dd-palette-field runs full-bleed
   inside a panel with overflow: hidden, so an outline at the shared 2px offset
   out there would be clipped on three sides. The input's box has 12px of the
   row's padding above and below it and a 10px gap to the icon, which is room
   for the ring and then some. */
.dd-palette-field input {
  flex: 1; min-width: 0;
  border: 0; background: transparent;
  font: inherit; font-size: var(--dd-fs-sm); color: var(--dd-ink);
}
.dd-palette-field input::placeholder { color: var(--dd-ink-quiet); }
.dd-palette-ask {
  flex: none; display: flex; align-items: center; gap: 6px;
  border: 0; background: transparent; cursor: pointer;
  font: inherit; font-size: var(--dd-fs-xs); color: var(--dd-ink-quiet);
  padding: 3px 6px; border-radius: var(--dd-radius-s);
  transition: background var(--dd-dur) var(--dd-ease), color var(--dd-dur) var(--dd-ease);
}
.dd-palette-ask:hover { background: var(--dd-gray-100); color: var(--dd-ink); }
.dd-palette-ask kbd,
.dd-palette-hint kbd,
.dd-palette-go kbd {
  font-size: var(--dd-fs-3xs);
  border: 1px solid var(--dd-border);
  border-radius: var(--dd-radius-s);
  padding: 0 4px; min-width: 16px; text-align: center;
  background: var(--dd-panel);
}
.dd-palette-list { flex: 1; min-height: 0; overflow-y: auto; padding: 6px; }
.dd-palette-head { display: block; padding: 6px 10px 4px; }
.dd-palette-row {
  display: flex; align-items: center; gap: 10px;
  width: 100%; text-align: left;
  padding: 7px 10px;
  border: 0; background: transparent; cursor: pointer;
  border-radius: var(--dd-radius-s);
  font: inherit; font-size: var(--dd-fs-sm-); font-weight: 500;
  color: var(--dd-ink);
}
.dd-palette-row[aria-selected="true"] { background: var(--dd-gray-100); }
/* The active row's ground, handed to the glide. ui/Palette.tsx points the
   highlight at the SAME row this attribute marks — the pointer sets the cursor
   as it goes, and the arrow keys call setGlideTo — so this is one fill changing
   hands, not two fills taking turns: the highlight travels between rows and the
   attribute is left to say which row is active to a screen reader.
   When the pointer leaves the list the highlight fades and this rule lifts in
   the same commit, and because the row paints ABOVE the highlight the swap is
   invisible: identical ground, identical box. */
.dd-palette-list.is-glide-on .dd-palette-row[aria-selected="true"] { background: transparent; }
/* Palette rows are radius-s like the menu's, so the highlight takes their
   corner rather than the primitive's default. */
.dd-palette-list .dd-glide { border-radius: var(--dd-radius-s); }
.dd-palette-icon {
  width: 16px; height: 16px; flex: none;
  display: grid; place-items: center;
  color: var(--dd-ink-quiet);
}
.dd-palette-icon svg { width: 16px; height: 16px; stroke-width: 1.75; }
.dd-palette-label { flex: none; max-width: 55%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dd-palette-sub {
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  color: var(--dd-ink-quiet); font-weight: 450; font-size: var(--dd-fs-xs);
}
.dd-palette-where {
  margin-left: auto; flex: none;
  color: var(--dd-ink-quiet); font-size: var(--dd-fs-2xs); font-weight: 500;
}
.dd-palette-none { padding: 18px 12px; color: var(--dd-ink-quiet); font-size: var(--dd-fs-sm-); }
.dd-palette-foot {
  display: flex; align-items: center; gap: 10px;
  padding: 8px 12px;
  border-top: 1px solid var(--dd-hairline);
  background: var(--dd-canvas);
}
.dd-palette-hint {
  display: flex; align-items: center; gap: 4px;
  font-size: var(--dd-fs-xs); color: var(--dd-ink-quiet);
}
.dd-palette-go {
  display: flex; align-items: center; gap: 6px;
  border: 0; cursor: pointer;
  padding: 5px 10px;
  border-radius: var(--dd-radius-m);
  font: inherit; font-size: var(--dd-fs-xs); font-weight: 600;
  background: var(--dd-accent); color: var(--dd-on-accent);
}
.dd-palette-go kbd { border-color: transparent; background: transparent; color: inherit; padding: 0; }

/* The rename field, laid over the name it is replacing (shared/app.js sizes
   and positions it every frame). It inherits the name's own font inline, so
   only the surface is described here: enough of a box to read as editable,
   not so much that the row appears to have grown a form. */
.dd-rename {
  position: fixed; z-index: 130;
  margin: 0; padding: 0 6px;
  border: 1px solid var(--dd-blue);
  border-radius: var(--dd-radius-s);
  background: var(--dd-panel);
  color: var(--dd-ink);
  /* The 3px --dd-blue-wash halo left with the other field highlights (see
     :focus-visible's note): the blue hairline alone says "editable". */
  outline: none;
}

/* The emoji picker: a grid on ddMenu's surface, with one field for anything
   the grid does not carry. Cells are square and sized to the touch floor. */
.dd-emoji-pop { padding: 8px; min-width: 0; max-width: none; }
.dd-emoji-grid {
  display: grid;
  grid-template-columns: repeat(8, 30px);
  gap: 2px;
}
.dd-emoji-cell {
  width: 30px; height: 30px;
  display: grid; place-items: center;
  border: 0; padding: 0; background: transparent;
  border-radius: var(--dd-radius-s);
  font-size: 17px; line-height: 1;
  cursor: pointer;
  /* NO background transition, the same rule .dd-menu's rows are given above and
     for the same reason, only more so: a highlight is not decoration, it is the
     answer to "which one am I about to click", and it has to be under the
     pointer at the instant the pointer is. That case was eight rows in a
     column. This is a 7×8 grid a hand sweeps across, so a 160ms fade left a
     comet-tail of half-lit cells trailing the cursor.
     .on's ring stays un-transitioned as it always was: committing a cell closes
     the picker, so a chosen cell never changes in place. */
}
.dd-emoji-cell:hover,
.dd-emoji-cell:focus-visible { background: var(--dd-gray-100); }
.dd-emoji-cell.on { background: var(--dd-gray-100); box-shadow: inset 0 0 0 1.5px var(--dd-ink); }
.dd-emoji-foot {
  display: flex; align-items: center; gap: 6px;
  margin-top: 8px; padding-top: 8px;
  border-top: 1px solid var(--dd-hairline);
}
.dd-emoji-any { flex: 1; min-width: 0; height: 30px; padding: 0 8px; font-size: var(--dd-fs-sm-); }
.dd-emoji-foot .btn { flex: none; height: 30px; }

/* An emoji you can click IS a control, and DESIGN.md's rule 5 says a control
   must look like one. Only a slot that actually holds an emoji takes this —
   an empty slot is alignment padding, and a picker opening out of blank space
   in the middle of a row is a dead click in reverse. */
.t-emoji[data-emoji-edit]:not(:empty) {
  cursor: pointer;
  border-radius: var(--dd-radius-s);
  transition: background var(--dd-dur) var(--dd-ease);
}
.t-emoji[data-emoji-edit]:not(:empty):hover { background: var(--dd-gray-100); }

/* ---------- responsive ---------- */

@media (max-width: 1100px) {
  .panel-columns { grid-template-columns: 1fr !important; }
  .card-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 900px) {
  /* The rail's WIDTH is not set here. It used to be pinned to 64px at this
     breakpoint, which silently outranked :root.rail-collapsed and turned the
     collapse control into a dead button — clicking it toggled the class while
     the width never moved. The rail is now driven by that one class in every
     case, and shared/app.js picks the initial state from the viewport, so a
     narrow window still opens collapsed but the user can always override it. */
  /* Same lesson as the rail, one control over: narrow the subbar's DEFAULT, never
     its used width. `body[data-app] .subbar { width: 184px }` used to live here
     and outranked the clamp on `--dd-subbar-w`, so below this width the resize
     handle was a dead control — you could drag it and nothing moved. Lowering the
     token instead keeps the floor (`flex: none` means the subbar never yields, and
     .app-content is overflow:hidden, so an unyielding subbar clips the work
     surface with no scrollbar to recover it) while an explicit drag still wins:
     the drag writes --dd-subbar-w inline on <html>, which beats this. */
  :root { --dd-subbar-w: 184px; }
  main { padding: 24px 20px 32px; }
}
@media (max-width: 640px) {
  .stats-row { grid-template-columns: 1fr 1fr; }
  .card-grid { grid-template-columns: 1fr; }
  .view-title { font-size: 1.55rem; }
  .cal-cell { min-height: 56px; }
  .cal-event { display: none; }
}
@media (prefers-reduced-motion: reduce) {
  * { transition: none !important; animation: none !important; }
}


/* ============================================================
   App pages — full-viewport standalone apps (tasks, calendar,
   mail). body[data-app] opts a page into this layout; the page
   manages its own scrolling, the document never scrolls.
   ============================================================ */

body[data-app] main {
  padding: 0;
  /* Both spellings so it tracks .shell's 100dvh exactly — a vh/dvh mismatch
     inside an overflow:hidden shell clips the page's bottom on phones. */
  height: calc(100vh - var(--dd-topbar-h));
  height: calc(100dvh - var(--dd-topbar-h));
  display: flex;
  overflow: hidden;
  background: var(--dd-canvas);
}
/* The app's panes are inset cards floating on the canvas — they never touch the
   window edges or each other. Edge-to-edge panes read as enterprise software;
   a little air and a radius make the app feel like an app. */
.app {
  flex: 1; min-width: 0;
  display: flex;
  overflow: hidden;
  gap: var(--dd-app-gap);
  padding: var(--dd-app-gap);
}

/* ---------- secondary sidebar (per-app) ---------- */

.subbar {
  width: clamp(184px, var(--dd-subbar-w), 340px);
  flex: none;
  display: flex; flex-direction: column;
  background: var(--dd-panel);
  border: 1px solid var(--dd-border);
  border-radius: var(--dd-radius-l);
  overflow-y: auto;
  padding: 12px 10px 14px;
  gap: 2px;
  transition: width var(--dd-dur-slow) var(--dd-ease),
              padding var(--dd-dur-slow) var(--dd-ease),
              opacity var(--dd-dur) var(--dd-ease);
}
/* (.pane-brand is gone — the page names itself in the shell's .topbar now.) */

/* ---- one create block for every app ----
   Calendar had `.subbar-create`, Mail had `.mail-compose-slot`, and Tasks had
   nothing. Measured, the two buttons came out 202px and 214px wide and sat 6px
   apart horizontally — neither aligned with the nav rows beneath them. The block
   is defined ONCE here, its button fills the subbar's content box exactly like
   every row below it, and an app supplies only the label and the icon. */
.subbar-create { padding: 2px 0 12px; }
.subbar-create .btn { width: 100%; justify-content: center; }

/* ---- one section heading for every app ----
   `.subbar-head` is the heading-with-a-trailing-action variant (Mail's Labels +).
   Both share the label's padding, so a heading sits in the same place whether or
   not it carries a button. 16px of air above, because 10px read as cramped after
   a list — which is why two apps had each hand-tuned their own value (14 and 24)
   and no two headings in the suite lined up. */
.subbar .side-label,
.subbar-head { padding: 16px 8px 6px; }
.subbar .side-label:first-child,
.subbar-head:first-child { padding-top: 0; }
.subbar-head { display: flex; align-items: center; gap: 6px; }
.subbar-head > .microlabel { flex: 1 1 auto; min-width: 0; }
/* The trailing action is a 22px hit target sitting on a 13px text line, so left
   in flow it grew the heading box to 44px — Mail's "Labels" sat 9px lower than
   "Mailboxes" directly above it, and lower than every other section heading in
   the suite. Negative block margin keeps the target honest at 22px and gives
   the heading back the .side-label box exactly. */
.subbar-head > button { margin-block: -5px; }

/* subbar nav rows reuse the rail's anatomy: icon + name + trailing count */
.subbar-nav { display: flex; flex-direction: column; gap: 1px; }
/* Direct children only. A nested wrapper row (label + edit affordance) puts two
   buttons side by side; if both claimed width:100% they would fight over the
   row and the flexible one would collapse to nothing. */
/* `.cal-row` is in this list because Calendar's row is a DIV — its primary
   control is a checkbox, not a link — so it cannot match the selectors above.
   It used to carry a hand-written copy of this box (7px 4px 7px 8px, gap 9)
   and came out 36px tall against everyone else's 31. One definition, one row. */
.subbar-nav > a, .subbar-nav > button,
.subbar-nav > * > a, .subbar-nav > * > button,
.cal-row {
  display: flex; align-items: center; gap: 10px;
  padding: 7px 8px;
  border: 0; background: transparent; text-align: left;
  border-radius: var(--dd-radius-m);
  text-decoration: none; cursor: pointer;
  font: inherit; font-size: var(--dd-fs-sm); font-weight: 480;
  color: var(--dd-ink-light);
  transition: background var(--dd-dur) var(--dd-ease);
}
.subbar-nav > a, .subbar-nav > button { width: 100%; }
/* inside a wrapper row the primary button takes the leftover space */
.subbar-nav > * > a, .subbar-nav > * > button { flex: 1 1 auto; min-width: 0; }
.subbar-nav > * > button + button,
.subbar-nav > * > a + button { flex: 0 0 auto; }
.subbar-nav a:hover, .subbar-nav button:hover,
.cal-row:hover { background: var(--dd-gray-50); color: var(--dd-ink); }
.subbar-nav .active { background: var(--dd-gray-100); color: var(--dd-ink); font-weight: 600; }
.subbar-nav svg { width: 16px; height: 16px; flex: none; stroke-width: 1.75; }
.subbar-nav .count, .cal-row .count {
  margin-left: auto;
  flex: none;              /* or a date like "Sep 27" wraps to two lines of its own */
  font-size: var(--dd-fs-2xs);
  color: var(--dd-ink-quiet);
  font-variant-numeric: tabular-nums;
  transition: opacity var(--dd-dur) var(--dd-ease);
}

/* A colour dot stands in for an icon on the rows that have no glyph (Mail's
   labels), so it has to occupy the icon's slot rather than its own 8px. It did
   not, which put every label name 8px left of every mailbox name in the same
   sidebar. 4px either side of an 8px dot is the 16px slot an icon takes. */
.subbar-nav .dot, .cal-row .dot { margin-inline: 4px; }

/* ---- trailing row action ----
   A hover-revealed control at the end of a row must not take flow width, or the
   row's count lands in a different column from every row that has none.
   Measured before this rule: Mail's mailbox counts right-aligned at one x, its
   label counts 22px inside that, and Calendar's another 5px inside again —
   three count columns in one product. The action is absolutely positioned over
   the count and the two cross-fade, so at rest every count in every app sits on
   the same line, and the control still gets a full 22px target on hover. */
.subbar-nav > *, .cal-row { position: relative; }
.row-action {
  position: absolute; right: 4px; top: 50%;
  translate: 0 -50%;
  width: 22px; height: 22px; flex: none;
  display: grid; place-items: center;
  border: 0; background: transparent; cursor: pointer;
  border-radius: var(--dd-radius-s);
  color: var(--dd-ink-quiet);
  opacity: 0; pointer-events: none;
  transition: opacity var(--dd-dur) var(--dd-ease),
              background var(--dd-dur) var(--dd-ease),
              color var(--dd-dur) var(--dd-ease);
}
.row-action svg { width: 15px; height: 15px; flex: none; stroke-width: 1.75; }
.row-action:hover { background: var(--dd-gray-100); color: var(--dd-ink); }
:is(.subbar-nav > *, .cal-row):hover > .row-action,
.row-action:focus-visible { opacity: 1; pointer-events: auto; }
/* :has(), not a sibling combinator — Calendar writes the count BEFORE the
   action and Mail writes it one level deeper inside the row's button, so
   neither is reachable from .row-action by ~ . */
:is(.subbar-nav > *, .cal-row):hover:has(> .row-action) .count { opacity: 0; }
/* A name that fills the whole line would have the action land on top of its
   last characters — the price of taking the action out of flow. These two names
   are the flex-filling ones, so their box already ends at the row's content
   edge: masking the tail fades the text out under the button, and does nothing
   at all when the name is short. Reserving the space instead would reflow the
   ellipsis on every hover. */
:is(.subbar-nav > *, .cal-row):hover:has(> .row-action) :is(.cal-name, .mail-lb-name) {
  -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 30px), transparent calc(100% - 10px));
  mask-image: linear-gradient(to right, #000 calc(100% - 30px), transparent calc(100% - 10px));
}
/* Touch has no hover, so the action cannot hide behind one — it goes back into
   flow and the count keeps its place beside it. Same patch DESIGN.md requires
   for every hover-reveal in the suite. */
@media (hover: none) {
  .row-action { position: static; translate: none; opacity: 1; pointer-events: auto; }
  :is(.subbar-nav > *, .cal-row):hover:has(> .row-action) .count { opacity: 1; }
}
/* A row's NAME is one line, always. Left at flexbox defaults the label span has
   min-width:auto and white-space:normal, so once the column tightened it wrapped
   at word boundaries — "Intern presentation" and four of its neighbours came out
   two and three lines tall in a 184px subbar. This is the pattern the file
   already uses for the rail (.side-nav a > span, above) and that .subbar-nav's
   own nested-wrapper variant already carries (min-width: 0, three rules up); it
   was simply never applied to the plain rows every list, area and project uses.
   Icons are excluded by :not() rather than by relying on their fixed widths —
   an ellipsis belongs to text. */
.subbar-nav :is(a, button) > span:not(.count):not(.t-emoji) {
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.subbar-foot { margin-top: auto; padding-top: 10px; }

/* drag handle between subbar and content; double-click resets */
.subbar-resizer {
  width: var(--dd-app-gap); flex: none;
  transition: width var(--dd-dur-slow) var(--dd-ease);
  margin: 0 calc(var(--dd-app-gap) * -1);
  cursor: col-resize;
  position: relative; z-index: 5;
}
.subbar-resizer:hover::after, .subbar-resizer.dragging::after {
  content: ""; position: absolute;
  top: 12px; bottom: 12px; left: 50%;
  width: 3px; margin-left: -1.5px;
  border-radius: 2px;
  background: var(--dd-gray-300);
}
/* The subbar collapses by giving up its width so the content pane grows into
   the space smoothly. Border and padding go with it, or a 1px seam and 10px of
   padding survive at width 0. */
body.subbar-collapsed .subbar {
  width: 0; min-width: 0;
  padding-left: 0; padding-right: 0;
  border-width: 0;
  opacity: 0;
}
/* Width goes, the MARGINS STAY. The resizer's base rule parks it inside the
   flex gap with `margin: 0 calc(var(--dd-app-gap) * -1)` — those two negative
   margins are what make the handle cost zero space. Zeroing them here (as this
   line used to) left a zero-width element sitting between two live `gap`s, so a
   collapsed subbar opened up 32px of dead air before the content instead of
   closing to nothing: the "larger-than-normal gap where the sidebar was". */
body.subbar-collapsed .subbar-resizer { width: 0; pointer-events: none; }

/* ---------- app content column ---------- */

/* Every pane in an app is one of these cards. .app-content is the flexible one
   (there is exactly one per app); .app-pane is an additional fixed-or-resizable
   column, e.g. mail's message list sitting between the subbar and the reader.
   An app's panes are siblings of .subbar inside .app, so they all share the
   same gap and radius automatically. */
.app-content,
.app-pane {
  display: flex; flex-direction: column;
  overflow: hidden;
  background: var(--dd-panel);
  border: 1px solid var(--dd-border);
  border-radius: var(--dd-radius-l);
}
.app-content { flex: 1; min-width: 0; }
.app-pane { flex: none; min-width: 0; }

/* A card's own header strip. Reuses .app-toolbar's shape but sized for a
   narrower column. */
.pane-head {
  min-height: 48px; flex: none;
  display: flex; align-items: center; gap: 8px;
  padding: 6px 14px;
  border-bottom: 1px solid var(--dd-hairline);
}
.app-toolbar {
  min-height: 56px; flex: none;
  display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
  padding: 8px 20px;
  border-bottom: 1px solid var(--dd-hairline);
  /* A toolbar sizes its contents from ITS OWN width, not the window's: in a split
     view the window is wide while the pane is half of it, so viewport media
     queries are blind and the right-hand cluster overflowed into the pane's
     clipped edge. Containment is deliberately scoped to the toolbar rather than
     the whole pane — `container-type` implies `contain: layout`, which would make
     the container the containing block for FIXED descendants too, and the apps
     hang their editors, popovers and toasts off the viewport. */
  container-type: inline-size;
  container-name: toolbar;
}
/* Whatever an app puts in its toolbar, the groups wrap before they clip. */
@container toolbar (max-width: 560px) {
  .app-toolbar > * { flex-wrap: wrap; }
  .app-toolbar h1 { font-size: var(--dd-fs-md); }
}
/* A segmented control cannot wrap and stay legible as one control, so instead it
   gives up padding — five views still fit, and none of them disappears. */
@container toolbar (max-width: 460px) {
  .seg button { padding: 0 8px; font-size: var(--dd-fs-2xs); letter-spacing: 0; }
}
@container toolbar (max-width: 360px) {
  .seg button { padding: 0 6px; }
}
/* Narrower than its own min-content and the switcher has to fold. It wraps rather
   than dropping views: a control you cannot reach is worse than an unusual shape,
   so the stadium becomes a rounded block for two rows. */
@container toolbar (max-width: 330px) {
  .seg { flex-wrap: wrap; justify-content: center; border-radius: var(--dd-radius-m); }
}
.app-toolbar h1 {
  margin: 0;
  font-size: var(--dd-fs-lg);
  font-weight: 620;
  letter-spacing: -.015em;
}
.app-body { flex: 1; min-height: 0; display: flex; overflow: hidden; }
.app-scroll { overflow-y: auto; min-height: 0; }

/* toolbar icon button that toggles the subbar */
.subbar-toggle { margin-left: -8px; }

/* ---- the way back to a hidden list ----
   While the list is showing, this is just a collapse control and stays as
   quiet as the rest of the toolbar's chrome. The moment the list is NOT
   showing it is the only way back to it, and a transparent 16px glyph is not
   how this system draws "press me" — so it takes a surface, full ink and the
   same 1px border every other resting control in the file wears. Three states
   mean "hidden": the persisted desktop collapse, a split pane too tight to
   hold the list in flow, and (in the mobile block near the end of this file)
   a closed drawer. Border rather than fill alone: .btn is the shape a user
   already reads as a button here, and matching it costs no new token.
   The border needs no margin compensation: box-sizing is border-box for
   everything in this file, so a 1px border inside a 28px button leaves it 28px.
   Compensating anyway wrote a full `margin` shorthand, which outranked
   .tb-nav's own margins in the calendar and pulled that app's toggle out of
   line with the other two — the button was in a different place depending on
   which module the pane held. */
body.subbar-collapsed .subbar-toggle,
:root[data-pane-list="over"] .subbar-toggle {
  background: var(--dd-panel);
  border: 1px solid var(--dd-border);
  color: var(--dd-ink);
}
body.subbar-collapsed .subbar-toggle:hover,
:root[data-pane-list="over"] .subbar-toggle:hover {
  background: var(--dd-gray-100);
  border-color: var(--dd-gray-300);
}

/* ---------- segmented control ---------- */

.seg {
  /* the containing block the sliding pill is measured and placed against */
  position: relative;
  display: inline-flex; align-items: center;
  border: 1px solid var(--dd-border);
  border-radius: var(--dd-radius-m);
  background: var(--dd-panel);
  padding: 2px;
}
/* THE ACTIVE PILL, SLID RATHER THAN REPAINTED (ui/SegPill.tsx).
   One absolutely-positioned box behind the labels. The component writes the
   active button's own offsetLeft/offsetTop/offsetWidth/offsetHeight onto the
   .seg as --seg-x/-y/-w/-h and this tweens between two measured positions, so
   nothing here has to know how many options a seg has or how wide their words
   are. The pill is the ONLY thing that moves: every button keeps its label,
   its padding and its whole hit area, and the pill is aria-hidden because the
   pressed button already carries the same fact.
   The two coordinate systems agree for free — an absolutely-positioned child
   is placed from its ancestor's PADDING box and offsetLeft is measured to the
   same edge — so this needs no correction for the seg's 2px or its border.
   A seg with NO pill element (every one on the legacy pages, and any React one
   that has not adopted the component) never matches [data-seg-on] and keeps
   the painted-background look below, untouched. */
.seg-pill {
  position: absolute;
  top: var(--seg-y, 0); left: 0;
  width: var(--seg-w, 0); height: var(--seg-h, 26px);
  border-radius: var(--dd-radius-s);
  background: var(--dd-accent);
  transform: translateX(var(--seg-x, 0));
  opacity: 0;
  pointer-events: none;
  z-index: 0;
  /* OPACITY IS STATED IN BOTH RULES, IDENTICALLY, and that is not a duplicate.
     The pill's position is suppressed while it is being placed (below) and its
     appearance is not: a pill that is switched on where it already belongs
     should still fade in, and a property declared the same way either side of
     the switch is one the toggle cannot interrupt. */
  transition: opacity var(--dd-dur) var(--dd-ease);
}
/* No option is the one on screen — Calendar's seg while a search has replaced
   the body — so there is nothing to point at. */
.seg[data-seg-on] .seg-pill { opacity: 1; }
/* The travel arrives one step AFTER the placement, and that order is the whole
   reason the component forces a reflow between them: a pill that can move
   while it is being put somewhere flies in from x:0, width:0, which is a
   fly-in nobody asked for on every toolbar that mounts. */
.seg[data-seg-ready] .seg-pill {
  transition: transform var(--dd-dur) var(--dd-ease),
              width var(--dd-dur) var(--dd-ease),
              opacity var(--dd-dur) var(--dd-ease);
}
.seg button {
  /* above the pill, so the label reads on top of the accent it slides under */
  position: relative; z-index: 1;
  border: 0; background: transparent; cursor: pointer;
  height: 26px; padding: 0 12px;
  border-radius: var(--dd-radius-s);
  font: inherit; font-size: var(--dd-fs-sm-); font-weight: 550;
  color: var(--dd-ink-light);
  transition: background var(--dd-dur) var(--dd-ease), color var(--dd-dur) var(--dd-ease);
}
.seg button:hover { color: var(--dd-ink); }
.seg button.active { background: var(--dd-accent); color: var(--dd-on-accent); }
/* ...and where the pill IS drawn it is the background, so the button must not
   paint a second one under it — two accent boxes, one of them jumping, is
   exactly the repaint the pill replaces. */
.seg[data-seg-on] button.active { background: transparent; }

/* ---------- forms (dialogs and inline) ---------- */

.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
.field > label {
  font-size: var(--dd-fs-2xs); font-weight: 500;
  text-transform: uppercase; letter-spacing: .08em;
  color: var(--dd-ink-quiet);
}

/* A field's caption line (hint at rest, a check's refusal when wrong) —
   reserved height so an arriving error never shoves the footer. */
.field-note {
  min-height: 1.2em;
  font-size: var(--dd-fs-2xs);
  color: var(--dd-ink-quiet);
}
.field-note.is-err { color: var(--dd-red); }

/* A prefix fused to an input — "app.vecka.ai/" ahead of a workspace handle.
   The WRAP wears the border; the input inside goes naked so the pair reads
   as one control (the Attio reference's handle field). */
.input-affix {
  display: flex; align-items: center;
  background: var(--dd-panel);
  border: 1px solid var(--dd-border);
  border-radius: var(--dd-radius-m);
  transition: border-color var(--dd-dur) var(--dd-ease);
}
.input-affix:hover { border-color: var(--dd-gray-300); }
.input-affix:focus-within { border-color: var(--dd-gray-300); }
.input-affix .input {
  border: 0; background: transparent; border-radius: 0;
  padding-left: 2px; flex: 1; min-width: 0;
}
.input-affix .input:focus-visible { outline: none; }
.input-affix-pre {
  flex: none; padding: 8px 0 8px 12px;
  font-size: var(--dd-fs-sm); color: var(--dd-ink-quiet);
  user-select: none; white-space: nowrap;
}
/* The refusal ring rides the WRAP (a class the check toggles — not :has(),
   which has silently failed in this codebase before). */
.input-affix.is-bad { border-color: var(--dd-red); }
.input-affix .input.is-bad { border: 0; }
.input {
  font: inherit; font-size: var(--dd-fs-sm);
  color: var(--dd-ink);
  background: var(--dd-panel);
  border: 1px solid var(--dd-border);
  border-radius: var(--dd-radius-m);
  padding: 8px 12px;
  width: 100%;
  transition: border-color var(--dd-dur) var(--dd-ease);
}
.input:hover { border-color: var(--dd-gray-300); }
/* THE RING IS BACK, and the fix was to stop removing it rather than to draw a
   fourth one: `outline: none` used to sit on this line, which outranked the
   file's one `:focus-visible` rule and left the app's general field class with
   a border half a step darker as its entire focus story — the same tell hover
   gives, so a keyboard user landing on a dialog's third field saw what looked
   like the mouse hovering it. .quick-add's field has always done it this way
   ("focus keeps the ONE shared outline — this only warms the field underneath
   it") and this is that same sentence, one class up.
   The warmed border stays: it is not the focus indicator, it is the field
   answering that it is the one being typed into.
   A CLICKED <select> keeps just the warm border, because :focus-visible's own
   heuristic excludes a pointer press on a non-text control — which is the
   behaviour anyone would want and is a second reason not to hand-roll this.
   .auth-pane .input:focus overrides all of it with the blue wash ring it has
   always drawn; that one is a focus treatment, not the absence of one. */
.input:focus { border-color: var(--dd-gray-500); }
textarea.input { min-height: 72px; resize: vertical; }
select.input { cursor: pointer; }
.input-row { display: flex; gap: 10px; }
.input-row > * { min-width: 0; }
.input-row .grow { flex: 1; }
/* "Switch" is the ROW, not a control: what sits in it is the same .check every
   list uses, and this suite has no track-and-thumb switch anywhere (settings,
   the form dialogs, the module toolbars — all of them are checkboxes). So there
   is no thumb here to slide, and giving this row one would be a new control
   rather than motion added to an existing one. The row's feedback is the tick
   drawing itself; see .check above. */
.switch-row {
  display: flex; align-items: center; gap: 10px;
  font-size: var(--dd-fs-sm); color: var(--dd-ink-light);
  margin-bottom: 14px; cursor: pointer;
}

/* ---------- identity dots & chips ---------- */

.dot { width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--id, var(--dd-gray-500)); }
.id-chip {
  --id: var(--dd-gray-500);
  background: color-mix(in srgb, var(--id) 13%, var(--dd-panel));
  color: var(--id);
}

/* Set an element's identity hue by class, since inline style is banned. Any
   element can carry one: .dot, .id-chip, or a container whose children read
   var(--id). */
.id-blue   { --id: var(--dd-id-blue); }
.id-green  { --id: var(--dd-id-green); }
.id-amber  { --id: var(--dd-id-amber); }
.id-violet { --id: var(--dd-id-violet); }
.id-rose   { --id: var(--dd-id-rose); }
.id-teal   { --id: var(--dd-id-teal); }

/* ---------- mini calendar (subbar) ---------- */

.mini-cal { padding: 4px 6px 8px; user-select: none; }
.mini-cal-head {
  display: flex; align-items: center; gap: 4px;
  padding: 2px 2px 8px;
  font-size: var(--dd-fs-sm-); font-weight: 600;
}
.mini-cal-head .spacer { margin-left: auto; }
.mini-cal-head button {
  border: 0; background: transparent; cursor: pointer;
  width: 22px; height: 22px; border-radius: var(--dd-radius-s);
  color: var(--dd-ink-quiet); font: inherit;
  display: grid; place-items: center;
}
.mini-cal-head button:hover { background: var(--dd-gray-100); color: var(--dd-ink); }
.mini-cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px; }
.mini-cal-grid .dow {
  font-size: var(--dd-fs-3xs); text-align: center;
  color: var(--dd-ink-quiet); padding: 2px 0 4px;
  text-transform: uppercase; letter-spacing: .04em;
}
.mini-cal-grid button {
  border: 0; background: transparent; cursor: pointer;
  aspect-ratio: 1; border-radius: var(--dd-radius-s);
  font: inherit; font-size: var(--dd-fs-2xs);
  color: var(--dd-ink-light);
  font-variant-numeric: tabular-nums;
}
.mini-cal-grid button:hover { background: var(--dd-gray-100); }
.mini-cal-grid button.out { color: var(--dd-gray-500); }
.mini-cal-grid button.today { font-weight: 700; color: var(--dd-ink); }
.mini-cal-grid button.sel { background: var(--dd-accent); color: var(--dd-on-accent); }

/* ---------- chart (shared ddChart renderer) ---------- */

.chart { position: relative; }
.chart svg { display: block; width: 100%; }
.chart-legend {
  display: flex; gap: 16px; flex-wrap: wrap;
  padding: 8px 0 2px;
  font-size: var(--dd-fs-xs);
  color: var(--dd-ink-light);
}
.chart-legend span { display: inline-flex; align-items: center; gap: 6px; }
.chart-legend i {
  width: 14px; height: 2px; border-radius: 1px; flex: none;
  /* the swatch inherits the series colour via inline `color`, so a dashed
     series can reuse currentColor for its gradient */
  background: currentColor;
}
.chart-legend i.dash {
  background: linear-gradient(90deg, currentColor 0 4px, transparent 4px 7px);
  background-size: 7px 2px; background-repeat: repeat-x;
}
.chart-tip {
  position: absolute; pointer-events: none;
  background: var(--dd-panel);
  border: 1px solid var(--dd-border);
  border-radius: var(--dd-radius-m);
  padding: 6px 10px;
  font-size: var(--dd-fs-xs);
  white-space: nowrap;
  z-index: 10;
}
.chart-tip b { font-variant-numeric: tabular-nums; }

/* ---------- global rail collapse ----------
   The state class lives on <html>, NOT on <body>: an inline script in every
   page's <head> sets it before first paint. Setting it from shared/app.js (which
   loads at the end of <body>) meant each navigation painted the rail expanded
   and then animated it shut — a flash on every single page load.

   Collapsed geometry, all measured from the rail's 64px:
     64 − 2×8 rail padding      = 48px row box
     icon   16 → padding-x 16   → 24..40, centre 32 ✓
     avatar 22 → padding-x 13   → 21..43, centre 32 ✓
     mark   28 → padding-x 10   → 18..46, centre 32 ✓
   Centring is done with that arithmetic and NOT with `justify-content: center`,
   which cannot work here: the labels keep their flex gaps when they drop to
   max-width 0, so a "centred" row put its icon hard against the left edge.
   Kill the gap, then pad to centre. */

:root.rail-collapsed .sidebar { width: 64px; padding: 0 8px 16px; }
/* The label gives up its width, its opacity AND a few pixels of ground: it
   drifts the way the rail is going as it fades, which is the reference's
   garnish on this gesture. See the transform leg on the base rule for why it
   rides the 110ms opacity clock and needs no reduced-motion resting state. */
:root.rail-collapsed .sidebar .side-nav a > span:not(.avatar),
:root.rail-collapsed .sidebar .side-nav button > span:not(.avatar) { max-width: 0; opacity: 0; transform: translateX(-8px); }
:root.rail-collapsed .sidebar .side-search,
:root.rail-collapsed .sidebar .side-label { max-height: 0; opacity: 0; padding-top: 0; padding-bottom: 0; }
:root.rail-collapsed .sidebar .side-nav a,
:root.rail-collapsed .sidebar .side-nav button { gap: 0; padding: 10px 16px; }

/* Collapsed, the rail is 64px of icons and the head is ONE control: the
   expand toggle, centred in the same topbar-height band the expanded head
   keeps. The workspace switcher disappears whole (2026-08-25 request — the
   mark added nothing at 64px); it comes back with the width, and on the
   mobile drawer below, which never actually collapses. It no longer VANISHES
   whole: it shrinks and fades over the first 110ms of the move and is only
   then taken out of the layout — see its own note where the transition is
   declared. */
:root.rail-collapsed .sidebar .side-head {
  justify-content: center;
  padding: 0 8px;
  margin: 0 -8px 8px;
}
:root.rail-collapsed .sidebar .ws-switch {
  opacity: 0;
  max-width: 0;
  padding-left: 0; padding-right: 0;
  display: none;
}

/* The collapse control's glyph is a panel outline with a divider bar down its
   left third, and it was byte-identical in both states — only the title string
   sidebars.ts writes said which way the control went. Mirrored, the bar travels
   from the left third to the right third across the same 220ms the rail's width
   does, so the icon ends up describing the state it just moved into. The rect
   is symmetric and only the divider moves, so the shape still reads as a panel.
   Keyed on the <html> class app/index.html's pre-paint script already sets: a
   rail that LOADS collapsed paints pre-flipped with no animation, and no markup
   changes (the served-bytes contract in Rail.test.tsx is untouched). */
.side-collapse svg { transition: transform var(--dd-dur-slow) var(--dd-ease); }
:root.rail-collapsed .sidebar .side-collapse svg { transform: scaleX(-1); }

/* ============================================================
   Split screen — two modules side by side in one window.
   Drag a module out of the rail and drop it on either half.

   The pane you are already in stays the LIVE DOCUMENT (its scroll
   position, open editors and unsaved state all survive); the module
   you drop arrives as a same-origin iframe with ?embed=1, which
   hides its own rail. That asymmetry is the whole trick: no app had
   to be rewritten to be embeddable, and each one still runs exactly
   as it does standalone — which is also what keeps them separable
   into products later.
   ============================================================ */

/* In split mode every page behaves like an app page: the document
   itself never scrolls, each pane scrolls on its own. */
/* GRID, not flex, and the columns come from a custom property. Two reasons, both
   learned the hard way: flex bases negotiate with the iframe's intrinsic size, so
   a 50/50 split came out 750/442; and a media query cannot override an inline
   `grid-template-columns`, whereas it can always override the fallback of a
   variable the script sets. Same rule as the rail and the subbar — never let a
   breakpoint fight a used value, give it a token to change. */
body.split main {
  height: calc(100vh - var(--dd-topbar-h));
  padding: 0;
  display: grid;
  grid-template-columns: var(--dd-split-cols, 1fr 0px 1fr);
  overflow: hidden;
}
body.split .split-pane {
  min-width: 0;               /* or a long title in either pane sets the floor */
  display: flex;
  overflow: hidden;
  position: relative;
}
/* A dashboard page (hub, finances, apps) keeps its air and scrolls inside its
   own half instead of scrolling the document. */
body.split:not([data-app]) .split-mine {
  display: block;
  overflow: auto;
  padding: 24px var(--dd-app-gap) 32px;
}
/* ---- the seam between the two apps ----
   Each pane carries its own --dd-app-gap of padding, so left alone the two cards
   sit TWO gaps apart — double the distance between the rail and the first card,
   which is the rhythm everything else in the suite follows. So the two edges that
   FACE each other give up half each, and 8px + 8px reads as one 16px gap.
   The live pane knows which edge faces from body[data-split-side] (the guest's
   side); the guest is told directly, via ?edge= on its URL, because it is a
   separate document and cannot see any of this. */
body.split[data-split-side="right"] .split-mine .app { padding-right: calc(var(--dd-app-gap) / 2); }
body.split[data-split-side="left"]  .split-mine .app { padding-left:  calc(var(--dd-app-gap) / 2); }
body.split[data-split-side="right"]:not([data-app]) .split-mine { padding-right: calc(var(--dd-app-gap) / 2); }
body.split[data-split-side="left"]:not([data-app])  .split-mine { padding-left:  calc(var(--dd-app-gap) / 2); }

/* An embedded DASHBOARD page takes the pane padding the live pane uses, not the
   one its own viewport width happens to select. `main`'s padding is
   viewport-tiered (32/40/40 wide, 24/20/32 under 900px), and an iframe's
   viewport is the pane — so Home beside Finances in one split had its header
   8px higher and its outer edge 24px further in than the pane next to it. This
   is the same correction `:root.embed .app` makes for app pages, and the same
   number `body.split:not([data-app]) .split-mine` uses above; a pane is a pane
   whichever document is standing in it. Declared before the seam rules below so
   they still get to halve the edge that faces the divider. */
:root.embed body:not([data-app]) main { padding: 24px var(--dd-app-gap) 32px; }

:root.embed[data-edge="left"]  body[data-app] .app { padding-left:  calc(var(--dd-app-gap) / 2); }
:root.embed[data-edge="right"] body[data-app] .app { padding-right: calc(var(--dd-app-gap) / 2); }
:root.embed[data-edge="left"]  body:not([data-app]) main { padding-left:  calc(var(--dd-app-gap) / 2); }
:root.embed[data-edge="right"] body:not([data-app]) main { padding-right: calc(var(--dd-app-gap) / 2); }

body.split .split-guest-frame {
  width: 100%; height: 100%;
  border: 0; display: block;
  background: transparent;
  color-scheme: normal;       /* the frame inherits our scheme, not the UA's */
}

/* ---- which side each pane is on is CSS, not DOM order ----
   Track 1 is always the left column. Assigning the columns from
   body[data-split-side] is what lets a swap be one attribute write: ordering
   the NODES instead would mean moving the guest, and moving an iframe reloads
   it — so flipping the arrangement used to throw the loaded module away. */
/* grid-ROW is not optional here. A grid item with a definite column but no row
   is still auto-placed, and the sparse algorithm never moves its cursor
   backwards: with the guest on the LEFT the columns run 3, 2, 1 against the DOM
   order [mine, divider, guest], so after the live pane took column 3 the
   divider could not fit in column 2 of that row and started a new one, and the
   guest started a third. The split came apart into a diagonal staircase. Pinning
   every item to row 1 removes auto-placement from the question entirely. */
body.split .split-div { grid-row: 1; grid-column: 2; }
body.split[data-split-side="right"] .split-mine { grid-row: 1; grid-column: 1; }
body.split[data-split-side="right"] .split-pane.is-guest { grid-row: 1; grid-column: 3; }
body.split[data-split-side="left"] .split-pane.is-guest { grid-row: 1; grid-column: 1; }
body.split[data-split-side="left"] .split-mine { grid-row: 1; grid-column: 3; }
/* The travel itself is a Web Animation started in split.js — measure, reassign
   the columns, then play the panes across from where they were. It is not a
   transition on purpose: see the note there. Nothing to declare here. */
/* Closing gives the leaving pane's share back before the DOM comes apart. */
body.split-animating main { transition: grid-template-columns var(--dd-dur-pane) var(--dd-ease-pane); }

/* ---- the pane grip ----
   A pane you can pick up. Dragging this across the seam swaps the two panes;
   dragging it out to the rail puts the module back and closes the split. The
   rail's Swap / Close rows still do both — a gesture cannot be the only way
   out of a mode — but this is the one that says what it does by doing it.
   Both grips are host-document elements, including the guest's: an element
   inside the iframe would lose the pointer the instant it left the frame.
   It names the MODULE, which the app's own title bar never does — the H1 says
   which view you are in ("Today"), not which app the pane is holding. */
body.split .split-pane { flex-direction: column; }
body.split .split-guest-frame { flex: 1 1 auto; height: auto; }

.pane-grip {
  flex: none;
  align-self: center;
  display: flex; align-items: center; gap: 6px;
  height: 18px;
  margin: 2px 0 0;
  padding: 0 9px 0 6px;
  /* Half the 18px height — a capsule, stated as one, so --dd-radius-full
     keeps meaning "true circle" everywhere else. */
  border-radius: 9px;
  color: var(--dd-ink-quiet);
  font-size: var(--dd-fs-2xs);
  font-weight: 560;
  letter-spacing: .01em;
  cursor: grab;
  user-select: none;
  touch-action: none;                 /* the gesture is ours, not the scroller's */
  transition: background var(--dd-dur) var(--dd-ease), color var(--dd-dur) var(--dd-ease);
}
.pane-grip svg { width: 12px; height: 12px; stroke-width: 1.75; flex: none; }
/* The thing that says "pick me up". A name and a cursor only announce
   themselves once you are already hovering the right six pixels; the ribbed
   handle is the shape every draggable edge in every OS uses, so it reads as
   grabbable before the pointer arrives. Drawn, not iconography: two hairlines
   in a 8px box cost nothing and never need a viewBox. */
.pane-grip::before {
  content: "";
  width: 8px; height: 8px; flex: none;
  background:
    linear-gradient(var(--dd-gray-300) 0 0) 0 1px / 8px 1.5px no-repeat,
    linear-gradient(var(--dd-gray-300) 0 0) 0 5px / 8px 1.5px no-repeat;
  transition: background-image var(--dd-dur) var(--dd-ease);
}
.pane-grip:hover, .pane-grip.is-held { background: var(--dd-gray-100); color: var(--dd-ink); }
.pane-grip:hover::before, .pane-grip.is-held::before {
  background-image: linear-gradient(var(--dd-ink) 0 0), linear-gradient(var(--dd-ink) 0 0);
}
.pane-grip.is-held { cursor: grabbing; }
/* A dashboard pane scrolls as one block, so its grip rides the top of it
   rather than sitting in a flex row that pane does not have. */
body.split:not([data-app]) .split-mine .pane-grip {
  position: sticky; top: 0; z-index: 4;
  margin: -12px auto 6px;
  background: var(--dd-canvas);
}
body.pane-dragging { user-select: none; cursor: grabbing; }
/* Carrying a pane back to the rail: the rail says it will take it. */
.sidebar.is-drop-target {
  background: var(--dd-gray-100);
  box-shadow: inset -2px 0 0 var(--dd-ink);
}

/* An iframe is a separate document and takes the pointer the moment the cursor
   crosses into it, which is what froze the divider halfway through a drag and
   would freeze a grip the same way. While either gesture is live the frame
   leaves hit-testing entirely. */
body.split-resizing .split-guest-frame,
body.pane-dragging .split-guest-frame,
body.split-dragging .split-guest-frame { pointer-events: none; }

/* ---- one pane at a time ----
   Two live apps side by side both shouting at full strength is two things to
   read and no answer to "which one am I in". The pane you are not working in
   steps back — the same thing macOS does to an inactive window, and the same
   thing this system already does with --dd-ink-quiet: quieter, never hidden.
   Focus follows interaction (shared/split.js), because in a split there is no
   other signal: the host document keeps DOM focus even while the pointer is
   inside the guest's iframe.

   It steps back by losing its COLOUR, not its contrast. Dimming the whole
   pane made the inactive half hard to read — every label, every row of text,
   half-erased for the crime of not being clicked in — and a pane you cannot
   read is not a pane you are keeping an eye on. Draining the colour instead
   leaves every word exactly as legible as before while the pane stops
   competing: the status dots, calendar hues and label chips go quiet, which
   is where all the pull actually was. Same idea as this system's identity
   palette, run backwards.

   It lands on the CONTENT of each pane, never on the pane element itself: a
   filter (like an opacity below 1) makes an element the containing block for
   its fixed-position descendants, and .split-mine holds the app's fixed
   layers — the magic-plus button, quick find, modals. Filtering the wrapper
   would trap all three inside half the window. .app / .view are the cards;
   those layers are their siblings, so they are untouched, which is right — a
   dialog is not part of the pane it was opened from, and it should keep its
   colour whichever half raised it. */
body.split .split-mine .app,
body.split .split-mine .view,
body.split .split-guest-frame {
  transition: filter var(--dd-dur-slow) var(--dd-ease);
}
/* Colour drained, plus the faintest step back in presence. Grayscale alone
   left the inactive half sitting at exactly the same weight as the active one
   — quieter in hue but equal in force, so the eye still had to decide which
   pane it was in. A tenth of the pane's opacity settles that without costing
   legibility: below about .85 it starts reading as disabled, which is a
   different and wrong claim. Both live in the same `filter` chain rather than
   filter + opacity, so the pane gains no second stacking context. */
body.split[data-pane-focus="guest"] .split-mine .app,
body.split[data-pane-focus="guest"] .split-mine .view,
body.split[data-pane-focus="mine"] .split-guest-frame { filter: grayscale(1) opacity(.9); }

/* The divider owns the split: it resizes it, and it carries the two controls
   that end or flip it. Always visible, never hover-revealed — the same rule the
   rail's collapse row learned. It sits in the air between two inset cards, so it
   needs no background of its own. */
/* Zero width: the two panes already hold `--dd-app-gap` of their own padding, so
   the divider lives in that air rather than adding more. Its hit area and its
   controls overhang it, the way .subbar-resizer does. */
.split-div {
  width: 0;
  position: relative;
  z-index: 6;
  display: flex;
  justify-content: center;
}
.split-div::before {                    /* the grab area — the whole gap */
  content: "";
  position: absolute;
  top: 0; bottom: 0; left: calc(var(--dd-app-gap) / -2); right: calc(var(--dd-app-gap) / -2);
  cursor: col-resize;
}
/* The grabber, macOS Split View's: a short rounded bar at the MIDDLE of the
   divider, always visible, so the seam says "drag me" without being hovered
   first. It is the divider's only content — the swap and close buttons used to
   sit here and made every press near them ambiguous with the resize. */
.split-div::after {
  content: "";
  position: absolute;
  top: 50%; left: 50%;
  width: 4px; height: 40px;
  margin: -20px 0 0 -2px;
  /* Half the 4px width — a capsule handle, not a circle, so it doesn't
     borrow the circles-only token. */
  border-radius: 2px;
  background: var(--dd-gray-300);
  transition: background var(--dd-dur) var(--dd-ease),
              height var(--dd-dur-slow) var(--dd-ease);
}
.split-div:hover::after, .split-div.dragging::after {
  background: var(--dd-gray-500);
  height: 56px;
}

/* ---- the drag: the pane's own tab, lifted ----
   What follows the cursor is the tab the pane will wear once it lands — the
   same .pane-grip, built by the same grip() call, carrying the same icon, the
   same name and the same ribbed handle. It used to be a chip of its own
   design, and that is what made the gesture read as a label ABOUT a drag
   rather than as the thing being dragged: nothing on the cursor was ever going
   to appear on the page, so the eye had no object to follow from the rail to
   the pane. Now it does, and the drop is that object coming to rest.
   Only what says "in the air" is declared here — out of the page, at full
   ink, and held rather than merely grabbable. Everything about its anatomy
   is inherited, so the tab in flight cannot drift from the tab at rest. */
.split-ghost {
  position: fixed;
  z-index: 90;
  pointer-events: none;
  height: 22px;                        /* a grip's 18, plus the air a lifted thing has */
  margin: 0;
  padding: 0 11px 0 8px;
  background: var(--dd-panel);
  /* --dd-border, not the hairline it wore under a shadow: this tab is the
     one surface here that can be over ANY ground — a card, the canvas, the
     rail, the other pane's app — and with the drop gone the edge is the only
     thing holding it off whatever is behind it. An internal divider's weight
     is not enough for that; a card's edge is. */
  border: 1px solid var(--dd-border);
  color: var(--dd-ink);
  transform: translate(-50%, -50%);
  /* The same 90ms opacity-only entrance .dd-menu and .dd-palette carry, and for
     the same law: this is an anchored surface in the strictest sense there is —
     it IS the pointer — so no travel and no scale, only enough fade that a tab
     arriving under the cursor at full ink in one frame does not read as a
     flash at the exact moment the eye is being asked to pick up a new object. Its left/top are written imperatively ~60×/second and a CSS
     animation cannot be retriggered by inline style changes, so the two never
     interact. THE EXIT STAYS A CUT: you let go of it. */
  animation: dd-pop-in var(--dd-dur-pop) var(--dd-ease);
}
/* Same override .pane-grip:hover uses, for the same reason: the handle is dark
   when the thing is in your hand. Image only — the shorthand above it carries
   the two hairlines' position and size. */
.split-ghost::before {
  background-image: linear-gradient(var(--dd-ink) 0 0), linear-gradient(var(--dd-ink) 0 0);
}

/* ---- the work area makes room ----
   The page itself answers "where would this go": the live pane gives up the
   half the module is about to take, and the module arrives as a GHOST — its
   name over a skeleton of its shape. What used to be here instead was a pair
   of fixed dashed zones with a real iframe of the target app laid over the hot
   one. Two things were wrong with that. It never moved the live pane, because
   a fixed overlay covers the page rather than displacing it — so the drag
   showed you a layout the drop would not produce. And loading the module for
   real, under a cursor that had committed to nothing, cost a page load per
   drag and promised working content that was still one mouse-up away.

   Two tracks, both `fr`, in their own property: the committed split's middle
   `auto` divider track does not interpolate, and this is the one place the
   columns have to animate. The resting state — no half armed — is `1fr 0fr` or
   `0fr 1fr` depending on which track the live pane is standing in, so the same
   declaration animates the pane out and back whichever half it is holding. */
body.split-dragging:not(.split) main {
  display: grid;
  grid-template-columns: var(--dd-drag-cols, 1fr 0fr);
  transition: grid-template-columns var(--dd-dur-pane) var(--dd-ease-pane);
}
/* The live content keeps whichever column the preview is not taking. Both are
   placed explicitly because auto-placement would keep the preview second no
   matter which side the cursor is on.

   Placement is keyed on data-drag-TRACK, not on which half is armed, and the
   difference is the whole reason the gesture stopped being violent. A grid area
   is not interpolable: the frame it changes, the item is simply in the other
   column. Keyed on the armed half, that reassignment landed at the one moment
   the destination track was still 0px wide — so arming a half collapsed the
   entire app into nothing against one edge of the window and grew it back out.
   split.js now sets the track while no half is armed, where the pane fills the
   row and the choice cannot be seen, and arming a half only ever changes the
   track WIDTHS. The one reassignment left that is visible — crossing the seam,
   both tracks already equal — is played across with a FLIP there. */
body.split-dragging:not(.split) main > :not(.split-preview-pane) { grid-area: 1 / 1; min-width: 0; }
body.split-dragging:not(.split) .split-preview-pane { grid-area: 1 / 2; }
body.split-dragging:not(.split)[data-drag-track="2"] main > :not(.split-preview-pane) { grid-area: 1 / 2; }
body.split-dragging:not(.split)[data-drag-track="2"] .split-preview-pane { grid-area: 1 / 1; }
/* The track swap itself is silent: see setTrack() in split.js. Interpolating
   between the two resting states would empty one track while filling the other
   and drag the live pane clean across the window to arrive where it already
   was, so that one write is made with the transition off. */
body.split-dragging:not(.split) main.split-drag-jump { transition: none; }

/* Dragging INTO an existing split: the two panes are already the size they
   will stay, so there is nothing to animate — the preview simply takes the
   half under the cursor, opaque, covering whichever pane is there (dropping
   on the live pane's side is how you send the live pane across).
   Laid over that half rather than placed in its grid column: an extra child
   in a three-track grid is auto-placed, and an explicitly-placed pane pushed
   the guest onto an implicit second row. Absolute, off the two coordinates
   split.js measures from the pane it is covering — which is also the only way
   to cover it exactly when the divider has been dragged off centre. */
body.split.split-dragging main,
body.split.pane-dragging main { position: relative; }
body.split .split-preview-pane {
  position: absolute;
  top: 0; bottom: 0;
  left: var(--dd-ghost-left, 50%);
  width: var(--dd-ghost-width, 50%);
  z-index: 3;
  background: var(--dd-canvas);
}

/* The preview pane wears the same air as a real one: --dd-app-gap all round,
   halved on the edge that faces the seam. That is what lets the drop be a
   promotion rather than a replacement — the pane is already the exact size
   and position the guest will have, so committing moves nothing. */
.split-preview-pane {
  min-width: 0;
  display: flex;
  overflow: hidden;
  padding: var(--dd-app-gap);
  pointer-events: none;                /* the drag owns the pointer */
  opacity: 0;
  /* On the columns' clock, not its own: the pane fades in over exactly the
     length of time it takes to grow into its half, so one thing arrives instead
     of two. At --dd-dur it was fully opaque a third of the way through the
     move, which is what made the preview appear to slam into place and then
     wait for the layout to catch up. Padding rides along because the seam edge
     gives up half its gap the moment a half arms — an 8px step, small enough to
     go unnoticed while sliding and not while snapping. */
  transition: opacity var(--dd-dur-pane) var(--dd-ease-pane),
              padding var(--dd-dur-pane) var(--dd-ease-pane);
}
body.split-dragging[data-drag-side] .split-preview-pane { opacity: 1; }
/* The same 8px, on the pane that is giving it up. .app carries no transition of
   its own, and this one is scoped to the drag, so nothing outside the gesture
   gains motion it did not ask for. */
body.split-dragging:not(.split) .app {
  transition: padding var(--dd-dur-pane) var(--dd-ease-pane);
}
body.split-dragging[data-drag-side="right"] .split-preview-pane { padding-left: calc(var(--dd-app-gap) / 2); }
body.split-dragging[data-drag-side="left"]  .split-preview-pane { padding-right: calc(var(--dd-app-gap) / 2); }
/* and the live pane gives up its half of the seam, exactly as it will once the
   split is committed */
body.split-dragging:not(.split)[data-drag-side="right"] .app { padding-right: calc(var(--dd-app-gap) / 2); }
body.split-dragging:not(.split)[data-drag-side="left"]  .app { padding-left:  calc(var(--dd-app-gap) / 2); }

/* The module itself, running, in the half it would land in — and now declared
   to be EXACTLY the real thing, byte for byte the same rule as
   .split-guest-frame above.

   It used to add a dashed outline and a corner radius, to keep a pane you had
   not dropped from passing for one you had. Both were drawn on the frame's own
   box, and that box is not an edge of anything: the pane pads itself by
   --dd-app-gap and the embedded page pads itself again inside that, so the
   dashes traced a rectangle out in the air a good 30px clear of the card they
   were supposedly around, and the radius rounded corners nothing occupied. That
   is why the preview never read as the app it was: the only part of it that was
   not the real UI was also the loudest.

   What answers "have I dropped this yet" instead is the state the suite already
   uses for a pane that is not the one you are in — colour drained, presence a
   notch back, every word as legible as before (see the one-pane-at-a-time note
   above). A preview is the truest case of that there is: it is not a pane you
   are working in, it is not yet a pane at all. And it makes the drop mean
   something — build() promotes this element and focuses it, so the half you
   were only considering comes alive in your hand. Same vocabulary, no second
   one to learn, and nothing on screen that the real pane would not draw. */
.split-preview-frame {
  flex: 1; min-width: 0;
  border: 0; display: block;
  background: transparent;
  color-scheme: normal;
  filter: grayscale(1) opacity(.9);
  transition: filter var(--dd-dur-pane) var(--dd-ease-pane);
}

/* ---- the drop ----
   A promoted preview needs no arrival: it is already the pane, already loaded,
   already in the right column, and geometrically the commit changes nothing at
   all. What changes is that it stops being a preview — build() promotes it and
   focuses it, so the drained colour of a pane you are not in comes back. That
   IS the drop, and `is-settling` is only here to put it on the pane clock
   instead of the shorter one an ordinary focus swap uses, so the commit lands
   as an event rather than a flicker. It used to be a keyframed opacity dip,
   which now had a real filter transition to fight: two answers for one moment,
   and the dip was the one that was not saying anything.
   `is-arriving` is the other path — a guest built from scratch, which still has
   a load to wait out. */
.split-pane.is-guest.is-settling .split-guest-frame {
  transition: filter var(--dd-dur-pane) var(--dd-ease-pane);
}
.split-pane.is-guest.is-arriving .split-guest-frame { opacity: 0; }
.split-pane.is-guest .split-guest-frame { transition: opacity var(--dd-dur-slow) var(--dd-ease); }

/* A rail row must never be dragged as a LINK: WebKit needs this property (it has
   no equivalent to draggable=false for its own link-drag heuristics), and the
   labels are navigation, so losing text selection on them costs nothing. */
.side-nav a { -webkit-user-drag: none; user-select: none; }

/* The row being dragged reads as picked-up, and text selection must not start
   anywhere while a drag is live. */
body.split-dragging { user-select: none; }
/* .split-source changes TWO things — a fill and an opacity — and only the fill
   was on a clock, so the row half-dissolved and half-snapped. The opacity leg
   is added for the length of the gesture only (90ms: picking a row up must
   answer the hand, not ease behind it), which is why the rest of the row's list
   is restated rather than appended to the base rule — dropping background here
   would just move the snap onto the fill, and the four other .side-nav
   transition lists in this file have no business acquiring an opacity leg for
   a drag they never see. */
body.split-dragging .sidebar .side-nav a {
  transition: background var(--dd-dur) var(--dd-ease),
              padding var(--dd-dur-slow) var(--dd-ease),
              gap var(--dd-dur-slow) var(--dd-ease),
              opacity var(--dd-dur-pop) var(--dd-ease);
}
body.split-dragging .sidebar .side-nav a.split-source { background: var(--dd-gray-100); opacity: .55; }
/* A DRAG IS NOT A PRESS. :active stays true for the whole gesture, so the row's
   press dip would be HELD for as long as the module is being carried instead of
   for the length of a press — and the row is already saying it has been picked
   up, in the two properties above. */
body.split-dragging .sidebar .side-nav a:active { transform: none; }

/* An embedded page has no rail of its own — the host's rail is the only one. */
:root.embed .sidebar { display: none; }
:root.embed .shell { min-height: 0; }

/* ---- the live pane's own width ----
   split.js mirrors the live pane's width onto <html> as data-pane, because that
   pane is narrow inside a WIDE window and every viewport breakpoint is blind to
   it. (The guest needs none of this: an iframe's window is its pane, so its own
   media queries are already right — which is exactly why the guest fitted
   perfectly while the host clipped.) These thresholds match the viewport ones. */
:root[data-pane="narrow"] { --dd-subbar-w: 184px; }

/* Tighter than this, an app's own sidebar cannot coexist with its work surface:
   at a 360px pane a 240px subbar left the calendar a 40px toolbar. So the subbar
   yields entirely — forced by width, not by the user's toggle, and it returns the
   moment the pane grows. Mirrors body.subbar-collapsed so the two look identical
   rather than merely similar. */
:root[data-pane-list="over"] .subbar {
  width: 0; min-width: 0;
  padding-left: 0; padding-right: 0;
  border-width: 0;
  opacity: 0;
}
/* Same as the collapsed case above: keep the negative margins, or the vanished
   subbar leaves two full gaps behind it. Measured in a 524px pane: content
   started 48px in from the pane edge where 16px was intended. */
:root[data-pane-list="over"] .subbar-resizer { width: 0; pointer-events: none; }

/* ---- and the way back to it ----
   The rule above is a protection, not a preference: at this pane width the
   list and the work surface cannot both stand in flow. But the toolbar's
   [data-subbar-toggle] still has to DO something when pressed, or it is
   exactly the dead control this file has already had to fix twice (see the
   900px block's two comments). Above 720px the toggle's handler took the
   desktop branch and flipped body.subbar-collapsed — a class this rule
   outranks — so in a split pane on a normal laptop the list simply could not
   be opened at all.
   So at a tight pane the toggle stops collapsing in place and opens the list
   the only way a pane this narrow can hold it: as an overlay drawer over its
   OWN pane. Same shape, tokens and inset as the mobile drawer at the end of
   this file; positioned against .split-mine rather than the viewport, because
   in a split the viewport is not the pane — .split-mine is the pane's only
   positioned ancestor and its overflow:hidden is the pane's own clip, so the
   drawer can neither escape the pane nor be cropped by .app.
   shared/app.js adds .subbar-over only while data-pane is "tight", and drops
   it the moment the pane grows, the drawer is dismissed, or a list is picked. */
:root[data-pane-list="over"] body.subbar-over {
  /* a drawer shares nothing, so it takes the design default back — same
     argument as the mobile drawer's own width note */
  --dd-subbar-w: 240px;
}
:root[data-pane-list="over"] body.subbar-over .subbar {
  position: absolute;
  top: var(--dd-app-gap);
  bottom: var(--dd-app-gap);
  left: var(--dd-app-gap);
  height: auto;
  width: var(--dd-subbar-w);
  /* 64px: this file's "still a rail's worth of surface showing" floor, the
     same one the mobile drawer uses — of the PANE here, not the window. */
  max-width: calc(100% - 64px);
  padding: 12px 10px 14px;
  /* The border is what lifts it off the pane it covers now that nothing
     drops beneath it — hence border-width back to 1px, which the collapsed
     rule above had zeroed. The scrim on .split-mine::after does the rest. */
  border-width: 1px;
  opacity: 1;
  z-index: 6;
}
/* One dim behind it, drawn by the pane itself: .dd-scrim is fixed to the
   viewport and would dim the other pane too, which is not what is behind
   this drawer. */
:root[data-pane-list="over"] body.subbar-over .split-mine::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 5;
  background: var(--dd-scrim);
}

/* A dashboard page in a narrow pane needs the same treatment its own breakpoints
   would give it in a narrow window — two-column panels in a 600px pane truncated
   row titles to a single letter. These mirror the 1100px and 640px viewport rules
   exactly, so a pane and a window of the same width look the same. */
:root[data-pane="narrow"] .panel-columns,
:root[data-pane="tight"] .panel-columns { grid-template-columns: 1fr !important; }
:root[data-pane="narrow"] .card-grid { grid-template-columns: 1fr 1fr; }
:root[data-pane="tight"] .stats-row { grid-template-columns: 1fr 1fr; }
:root[data-pane="tight"] .card-grid { grid-template-columns: 1fr; }
:root[data-pane="tight"] .view-title { font-size: 1.55rem; }

/* Below this width two panes cannot both be useful, so the split collapses back
   to one. The state is kept, not discarded: widen the window and it returns. */
@media (max-width: 720px) {
  body.split main { grid-template-columns: 1fr; }
  body.split .split-pane.is-guest, body.split .split-div { display: none; }
  /* One pane again, so the halved seam edge goes back to a whole one — but a
     WHOLE one at THIS width is the halved token every other app page uses down
     here (".app { padding: calc(var(--dd-app-gap) / 2) }", further down this
     file). Written as the full 16px, this rule's three classes outranked that
     one class and a folded split sat 16px off every edge while an identical
     unsplit page sat 8px off — the same page, twice the inset, purely because a
     split was still open behind it. */
  /* [data-split-side] is in the selector for a reason: it stays set through the
     fold, so the four seam-halving rules further up still match — and with four
     class-level selectors each they outrank a plain `body.split .split-mine
     .app`. The pane that is left ended up 8px in on the seam side and a full
     gap on the other: an off-centre card with no seam to justify it. Matching
     their specificity and landing later in the file is what puts an even inset
     back. (The app-page case looked fine only by accident — its fold value and
     the seam value are the same 8px number.) */
  body.split[data-split-side] .split-mine .app { padding: calc(var(--dd-app-gap) / 2); }
  /* There is only one pane down here, so there is nothing for it to be the
     quieter half of — whatever the focus was when the window narrowed. */
  body.split .split-mine .app, body.split .split-mine .view { filter: none; }
  /* Dashboard pages: back to the padding `main` itself carries when no split
     has taken it over (the 900px rule above), for the same reason. */
  body.split[data-split-side]:not([data-app]) .split-mine { padding: 24px 20px 32px; }
}

/* ============================================================
   Drag and drop — one look, three apps (see ddDrag in shared/app.js).
   A record is picked up, every place it could go says so, and the one
   under the cursor says it will take it. Nothing here is app-specific:
   a to-do onto a project and a message onto a mailbox are the same
   gesture and must not look like two.
   ============================================================ */

/* The chip on the cursor. Same anatomy as split view's .split-ghost —
   this system has one answer for "a thing is following your pointer". */
.dd-chip {
  position: fixed;
  z-index: 90;
  pointer-events: none;              /* or it is the only thing ever hit-tested */
  transform: translate(10px, 10px);  /* beside the cursor, never under it */
  padding: 6px 11px;
  border-radius: var(--dd-radius-m);
  background: var(--dd-panel);
  border: 1px solid var(--dd-gray-300);
  font-size: var(--dd-fs-sm-);
  font-weight: 560;
  color: var(--dd-ink-quiet);
  white-space: nowrap;
  transition: color var(--dd-dur) var(--dd-ease), border-color var(--dd-dur) var(--dd-ease);
}
/* Armed: over somewhere it can actually land. The chip is the only part
   of the gesture always in view, so it carries the yes/no. */
.dd-chip.is-armed { color: var(--dd-ink); border-color: var(--dd-ink); }

body.dd-dragging { cursor: grabbing; user-select: none; }
body.dd-dragging * { cursor: grabbing !important; }

/* The rows that were picked up stay in place and go quiet, rather than
   being removed: a list that reflows the moment you press it has moved
   the thing you were aiming at.
   The dim is EASED because the press that causes it is one event and the row
   going quiet is its answer: .4 arriving in a single frame reads as the row
   breaking rather than as it stepping back. Coming BACK is a cut, and not by
   oversight: the transition is declared on the state class, so dropping the
   class drops the ramp with it — the same honesty .split-ghost's exit has, and
   at the drop the row is usually moving or being re-rendered anyway. */
.dd-drag-src {
  opacity: .4;
  transition: opacity var(--dd-dur) var(--dd-ease);
}

/* Every legal destination, for as long as the drag lasts. Quiet — this is
   a map of what is possible, not an alarm.
   Both states ramp, and both ramp at --dd-dur and NEVER --dd-dur-slow. The
   reason for the ramp: every legal target lights at drag START, so one press
   makes six subbar rows change in the same frame — a whole pane repainting at
   once, which is the alarm this comment says it is not. The reason for the
   number: .dd-drop-over is the answer to "this one", and the ring has to land
   the instant the cursor crosses the target, not a fifth of a second after the
   hand has already moved on. --dd-dur is the ceiling that stays honest. */
.dd-drop-open {
  background: var(--dd-gray-50);
  box-shadow: inset 0 0 0 1px var(--dd-hairline);
}
/* And the one that will take it. */
.dd-drop-over {
  background: var(--dd-gray-100);
  box-shadow: inset 0 0 0 1.5px var(--dd-ink);
  color: var(--dd-ink);
}
.dd-drop-open, .dd-drop-over {
  transition: background-color var(--dd-dur) var(--dd-ease),
              box-shadow var(--dd-dur) var(--dd-ease);
}

/* An element mid-reveal has a height that does not fit its content yet. */
.dd-revealing { overflow: hidden; }
/* A row on its way out is no longer a target for anything, and its content
   must not spill as the box closes under it. */
.dd-departing { overflow: hidden; pointer-events: none; }

/* ---------- quick add: one natural-language field, every app ----------
   Calendar had this as a wide grey pill whose placeholder was a fake entry
   ("Lunch with Tom tomorrow 12:30") — it read as content rather than as an
   affordance, and it was too long for the pane, so it arrived pre-truncated.
   The field now NAMES what it does, and the example moves to a hint that appears
   while you are actually typing, where it can afford to be specific.
   The sparkle is the promise that this understands language, not syntax. */

.quick-add { position: relative; padding: 0 0 12px; }
.quick-add > svg {
  position: absolute; left: 10px; top: 9px;
  width: 15px; height: 15px;
  stroke-width: 1.6;
  color: var(--dd-ink-quiet);
  pointer-events: none;
}
.quick-add input {
  width: 100%; height: 33px;
  padding: 0 11px 0 31px;
  font: inherit;
  font-size: var(--dd-fs-sm-);
  color: var(--dd-ink);
  /* canvas on panel — a step DOWN from the card it sits in, never up, so it
     reads as a place to type without the heavy grey fill the old pill used.
     Since the Attio re-tone that step is a real one in dark (the canvas sits
     well below the panel) and almost nothing in light, where white cards sit
     on an off-white ground: down there the 1px border is what says "field",
     which is exactly how the reference draws one. */
  background: var(--dd-canvas);
  border: 1px solid var(--dd-border);
  border-radius: var(--dd-radius-m);
  transition: border-color var(--dd-dur) var(--dd-ease),
              background var(--dd-dur) var(--dd-ease);
}
.quick-add input::placeholder { color: var(--dd-ink-quiet); }
.quick-add:hover input { border-color: var(--dd-gray-300); }
/* focus keeps the ONE shared outline — this only warms the field underneath it */
.quick-add input:focus { background: var(--dd-panel); }
.quick-add.busy input { color: var(--dd-ink-quiet); }

/* The example, revealed only while the field has focus: at rest it would be one
   more line of grey text in a pane that already carries two headings. */
.quick-hint {
  margin: 0;
  overflow: hidden;
  max-height: 0;
  opacity: 0;
  padding: 0 2px;
  font-size: var(--dd-fs-2xs);
  line-height: 1.45;
  color: var(--dd-ink-quiet);
  transition: max-height var(--dd-dur-slow) var(--dd-ease),
              opacity var(--dd-dur) var(--dd-ease),
              padding var(--dd-dur-slow) var(--dd-ease);
}
.quick-add:focus-within .quick-hint {
  max-height: 48px;
  opacity: 1;
  padding: 6px 2px 0;
}

/* ---------- sign in ----------
   The suite's own chrome, minus the chrome: one panel card on the canvas, the
   brand mark it opens with everywhere else, and exactly two fields. Nothing here
   is a new visual idea — .input, .btn-primary, .microlabel and the tokens are the
   same ones the dashboard is built from, which is the whole point. */

.login-body { min-height: 100vh; }
.login-wrap {
  min-height: 100vh;
  display: grid;
  place-items: center;
  /* align-content, NOT just place-items, and the distinction is the whole rule.
     place-items centres each item inside ITS OWN row; it says nothing about how
     the rows are distributed, and align-content defaults to stretch. While this
     wrapper held exactly one child that was invisible — one stretched row, card
     centred in it. The auth pages added a second child (the .login-foot link),
     so the two auto rows stretched to fill 100vh and the card centred in the top
     half while the footer centred in the bottom one, a gap that grows with the
     window. Packing the rows here centres the pair as a group instead. */
  align-content: center;
  /* The optical lift, moved here from .login-card's margin-bottom. Sitting on
     the card it worked only while the card was the only thing in this wrapper:
     it lifted the card off dead centre because there was nothing below it to
     push away. The auth pages put the .login-foot link below, and that same
     6vh became a visible gap between the two that grew with the window — at a
     tall viewport the card and its own "Create an account" link were 130px
     apart. As bottom padding it lifts the card AND the link together, which is
     what "optically centred" meant in the first place. */
  padding: 24px 24px calc(24px + 6vh);
}
.login-card {
  width: 100%; max-width: 344px;
  display: flex; flex-direction: column;
  background: var(--dd-panel);
  border: 1px solid var(--dd-border);
  border-radius: var(--dd-radius-l);
  padding: 28px 26px 26px;
}
.login-mark { width: 34px; height: 34px; border-radius: var(--dd-radius-m); font-size: var(--dd-fs-md); }
.login-title {
  margin: 16px 0 0;
  font-size: var(--dd-fs-lg);
  font-weight: 650;
  letter-spacing: -.015em;
}
.login-title span { color: var(--dd-ink-quiet); font-weight: 450; }
.login-sub {
  margin: 5px 0 22px;
  font-size: var(--dd-fs-sm);
  color: var(--dd-ink-quiet);
}
/* position:relative so a field can pin a trailing helper link (the sign-in
   page's "Forgot password?") to its own label row — see .login-forgot. */
.login-field { position: relative; display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
.login-field .microlabel { padding-left: 1px; }
/* Feedback is a CALLOUT, not a bare red sentence: icon + tinted wash +
   hairline in the message's own hue, sliding in with a small settle. One
   slot per form, two tones — error (red, alert icon) and .login-note
   (green, tick icon) for the happy path. The [hidden] guard is load-
   bearing: display:flex on the class would otherwise beat the UA's
   [hidden]{display:none} and every dismissed message would stay visible.
   This slot is for what a FIELD cannot own — "that didn't work", "couldn't
   reach the server". Everything that is about one field (empty, malformed,
   mismatched) says so on that field's own caption line instead, where it
   costs no layout: see .field-hint above. */
.login-error {
  display: flex; align-items: flex-start; gap: 9px;
  margin: 2px 0 14px;
  padding: 10px 12px;
  border-radius: var(--dd-radius-m);
  font-size: var(--dd-fs-sm-);
  line-height: 1.45;
  color: var(--dd-red);
  background: color-mix(in srgb, var(--dd-red) 7%, transparent);
  border: 1px solid color-mix(in srgb, var(--dd-red) 22%, transparent);
  overflow: hidden;
  animation: dd-callout-in 200ms var(--dd-ease);
}
.login-error[hidden] { display: none; }
.login-error::before {
  content: "";
  width: 15px; height: 15px; flex: none;
  margin-top: 1.5px;
  background: currentColor;
  -webkit-mask: var(--dd-alert) center / contain no-repeat;
  mask: var(--dd-alert) center / contain no-repeat;
}
/* Grows into place instead of appearing at full size, so what sits below is
   eased aside rather than jumped. max-height is the interpolable stand-in for
   `height: auto`: it runs 0 → 200px over the first 55% (the callout's real
   height is reached well inside that), and the discrete flip back to the
   base rule's `none` afterwards is invisible because the box has already
   stopped growing. Padding, margin and border start at zero for the same
   reason — a box that begins 24px tall still shoves. */
@keyframes dd-callout-in {
  0% {
    opacity: 0; max-height: 0;
    padding-top: 0; padding-bottom: 0;
    margin-top: 0; margin-bottom: 0;
    border-top-width: 0; border-bottom-width: 0;
  }
  55% { max-height: 200px; }
  100% { opacity: 1; }
}
.login-submit { width: 100%; justify-content: center; height: 38px; margin-top: 2px; }

/* ---------- sign in — auth-page additions (signup, forgot/reset password) ----------
   Everything below reuses .login-card/.login-field/.login-error/.login-submit
   verbatim (no second visual language for onboarding) and adds only the small
   set of primitives those extra pages need: a static field caption, a quiet
   footer link line, an inline quiet link, and a success-toned variant of the
   one feedback slot every card already has. */

/* A quiet, ALWAYS-visible caption under a field — for the invite-code
   explanation, which a stranger following a link needs to understand without
   any prior context, so unlike .quick-hint (above) this never hides behind
   focus. Typography matches .quick-hint's resting size/line-height so the two
   read as the same family of caption; layout is the plain flow this file
   already gives every static line, no reveal transition to skip under
   prefers-reduced-motion because there is no motion here to begin with. */
/* ...and, on the auth pages, the field's ONE feedback line: it holds the
   resting caption when there is nothing wrong and the validation message when
   there is (shared/auth.js parks the original in data-hint and puts it back).
   min-height reserves exactly one line whether or not anything is written
   there, which is the whole point — a message appearing must not move the
   button under it. Keep messages to one line; two would move things again. */
.field-hint {
  display: block;
  min-height: 1.45em;
  margin: 6px 2px 0;
  font-size: var(--dd-fs-2xs);
  line-height: 1.45;
  color: var(--dd-ink-quiet);
  transition: color var(--dd-dur) var(--dd-ease);
}
.field-hint.bad { color: var(--dd-red); }
.field-hint.warn { color: var(--dd-amber); }

/* A field that was just rejected. Held, not flashed: the mark stays until the
   field is edited, so the answer to "which one was wrong" survives longer
   than an animation does. Beats the base :hover border via specificity, and
   survives :focus so a field you are fixing keeps saying it is the one.

   IT IS THE BORDER, AND ONLY THE BORDER (2026-08-25). Both of these rules
   used to stack a 3px red wash behind the box — a soft red halo, at the one
   weight nothing else in this product draws. The reference does less than
   this: a plain border and a red sentence underneath. We keep the border
   recoloured because "which field" is worth saying twice, at the SAME 1px
   weight every other control in the suite is edged with, and the sentence on
   the caption line does the explaining (.field-hint.bad). */
.input.is-bad,
.auth-pane .input.is-bad:hover,
.auth-pane .input.is-bad:focus {
  border-color: var(--dd-red);
}

/* ---------- the rejection, felt once ----------
   The red ring above says WHICH field and its caption line says WHY; this
   says "no" in the same instant your eye is still on the button you pressed.
   180ms total — four legs of 50/50/40/40ms, so the %-stops below are
   50/180, 100/180, 140/180 — and a 5px throw with a 3px overshoot: enough to
   register as a refusal, short enough that it is over before you could
   describe it. This is the "if a felt rejection is ever wanted back, it
   belongs on the field, once, and under 200ms" note that the deleted
   400ms card shake left behind; shared/auth.js's rejectField() replays it by
   removing the class, forcing a reflow and re-adding it, so a second bad
   submit shakes again instead of sitting still.
   The explicit guard is belt-and-braces over this file's blanket
   prefers-reduced-motion rule: an animation on an input is exactly the kind
   of motion that rule exists for, and it must not survive a future edit that
   narrows the blanket. */
@keyframes auth-shake {
  0%     { transform: translateX(0); }
  27.78% { transform: translateX(5px); }
  55.56% { transform: translateX(-5px); }
  77.78% { transform: translateX(3px); }
  100%   { transform: translateX(0); }
}
.auth-shake { animation: auth-shake 180ms var(--dd-ease); }
@media (prefers-reduced-motion: reduce) {
  .auth-shake { animation: none !important; transform: none !important; }
}

/* The quiet centered link line below the card — "New here? Create an
   account", "Back to sign in". Lives in .login-wrap, a sibling of
   .login-card, not inside it: it is not part of the card's own bordered
   surface, the way a page footnote sits below a dialog rather than inside
   it. The <a> is the one piece that must read as clickable (DESIGN.md rule
   5), so it takes the underline treatment already used for .toast-action's
   inline text action rather than inventing a second link style. */
.login-foot {
  width: 100%; max-width: 344px;
  margin: 14px 0 0;
  text-align: center;
  font-size: var(--dd-fs-sm-);
  color: var(--dd-ink-quiet);
}
.login-foot a {
  color: var(--dd-ink);
  font-weight: 550;
  text-decoration: underline;
  text-underline-offset: 2px;
}

/* Forgot-password's "Try a different address" — a <button> (it restores
   in-place state, it does not navigate) reset to plain text. */
.login-foot-link {
  display: block;
  margin: 6px 2px 0;
  padding: 0; border: 0; background: transparent;
  width: 100%; text-align: right;
  font: inherit; font-size: var(--dd-fs-2xs);
  color: var(--dd-ink-quiet);
  text-decoration: underline; text-underline-offset: 2px;
  cursor: pointer;
}
.login-foot-link:hover { color: var(--dd-ink); }

/* (.login-forgot lived here as an absolute label-row pin through two
   placements; the current rule — centred under the Sign in button — is in
   the auth-split section below. This stale copy kept position:absolute
   alive in the cascade and pinned the link to the top of .auth-views.) */

/* Success-toned variant of .login-error — same slot (one card, one feedback
   line, never two), recolored via a modifier class rather than a second
   element: "Account created, sign in to continue", the reset-password
   landing note, an inline "passwords match now" clear. --dd-green hasn't
   previously carried a full text line (only status-pill dots/fills) — worth
   a manual contrast look in both themes if that pairing is ever revisited,
   though it reuses the same token already verified AA elsewhere in this
   family for --dd-red. */
.login-error.login-note {
  color: var(--dd-green);
  background: color-mix(in srgb, var(--dd-green) 8%, transparent);
  border-color: color-mix(in srgb, var(--dd-green) 25%, transparent);
}
.login-error.login-note::before {
  -webkit-mask-image: var(--dd-tick);
  mask-image: var(--dd-tick);
}

/* ---------- password strength (signup + reset) ----------
   Built and mounted entirely by shared/auth.js's wirePasswordStrength() —
   no auth-page HTML references these classes directly. Reuses the existing
   four status hues 1:1 with the four levels (Weak=red, Fair=amber, Good=blue,
   Strong=green — the system's own danger/waiting/in-motion/healthy ladder,
   applied here to a password) rather than adding new color tokens, and pins
   the bar's own geometry to DESIGN.md's already-established "thin
   proportional bar" spec (4px height, 2px radius — same numbers as
   .breakdown-bar / .vps-meter elsewhere in this system), not a fresh value. */

.pw-strength { margin: 2px 2px 10px; }

/* Empty field = no meter. The bars fill via scaleX, so at rest they are
   INVISIBLE yet still reserved ~30px of dead air between the password field
   and the checklist (the "why is there a hole here" screenshot). Until the
   first keystroke gives the meter something to say, only the requirement
   checklist shows; the meter and its word appear with the first character. */
/* (Two rules used to hide the meter and its word until the first keystroke,
   and one more hid an empty word. All three are gone: they made the block
   grow by ~30px on the first character typed, which pushed the confirm
   field, Back/Create and the whole panel below it down the screen — the
   jitter this section exists to not have. The track and its label are on
   screen from the start; the track reads as an empty meter, and the label
   names it, so neither is a hole waiting to be filled.) */

/* Scoped under .pw-strength on purpose, not a bare `.pw-meter` rule: the
   onboarding wizard (further below in this file) already has its own
   older, differently-shaped `.pw-meter` — a data-score/reveal design for
   its own step-2 password field. Nesting this component's selectors under
   its own `.pw-strength` wrapper (the root this file's auth.js mounts)
   keeps the two from ever colliding on the shared class name, without
   renaming either one. */
/* Each segment is a TRACK (always drawn) holding a FILL (scaled from zero).
   The segment itself used to be the fill, which meant an unfilled meter was
   four invisible boxes — so it had to be hidden until it had something to
   show, and revealing it moved the page. A visible empty track costs the
   same pixels at rest that it costs full. */
.pw-strength .pw-meter { display: flex; gap: 4px; margin-bottom: 6px; }
.pw-meter-bar {
  flex: 1; height: 4px; border-radius: 2px;
  background: var(--dd-gray-100);
  overflow: hidden;
}
.pw-meter-bar > i {
  display: block; height: 100%; border-radius: inherit;
  background: var(--dd-gray-300);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform var(--dd-dur) var(--dd-ease),
              background var(--dd-dur) var(--dd-ease);
}
/* Staggered so filling (or draining) more than one segment at once reads as
   a quick left-to-right cascade rather than four bars moving in lockstep. */
.pw-meter-bar:nth-child(1) > i { transition-delay: 0ms; }
.pw-meter-bar:nth-child(2) > i { transition-delay: 40ms; }
.pw-meter-bar:nth-child(3) > i { transition-delay: 80ms; }
.pw-meter-bar:nth-child(4) > i { transition-delay: 120ms; }

.pw-meter-bar.filled > i { transform: scaleX(1); }
/* The pre-10-char state: only ever bar 1, filled to a live proportion via
   inline transform (the DESIGN.md-sanctioned proportional-bar exception —
   the value IS the datum). Neutral gray, deliberately never a status hue:
   "too short" is a policy gate, not a quality judgment, and coloring it red
   would conflate the two the way DESIGN.md's honesty rule forbids. */
.pw-meter-bar.gate > i { background: var(--dd-gray-300); }

.pw-strength .pw-meter.level-weak   .pw-meter-bar.filled > i { background: var(--dd-red); }
.pw-strength .pw-meter.level-fair   .pw-meter-bar.filled > i { background: var(--dd-amber); }
.pw-strength .pw-meter.level-good   .pw-meter-bar.filled > i { background: var(--dd-blue); }
.pw-strength .pw-meter.level-strong .pw-meter-bar.filled > i { background: var(--dd-green); }

/* Plain ink, never colored by level — the bars already carry color, and
   small colored text is its own readability risk. Text is the redundant
   signal for anyone who can't distinguish the bar colors, so it must stay
   maximally legible on its own. */
.pw-meter-label {
  display: block;
  min-height: 1.3em;
  font-size: var(--dd-fs-2xs);
  color: var(--dd-ink-quiet);
  transition: opacity var(--dd-dur) var(--dd-ease);
}
.pw-meter-label.fade { opacity: 0; }

.pw-reqs { list-style: none; margin: 9px 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.pw-strength .pw-meter + .pw-meter-label + .pw-reqs { margin-top: 8px; }
.pw-req {
  display: flex; align-items: center; gap: 7px;
  font-size: var(--dd-fs-2xs);
  color: var(--dd-ink-quiet);
  transition: color var(--dd-dur) var(--dd-ease);
}
.pw-req.met { color: var(--dd-ink); }

/* Read-only, one size down from .check (14px vs 17px) since it's never a
   touch target — mirrors the same mask-tick technique as .check:checked so
   the tick inverts correctly with the scheme instead of needing its own
   light/dark SVG. */
.pw-req-dot {
  width: 14px; height: 14px; flex: none;
  border: 1.5px solid var(--dd-gray-300);
  border-radius: 50%;
  position: relative;
  transition: background var(--dd-dur) var(--dd-ease), border-color var(--dd-dur) var(--dd-ease);
}
.pw-req-dot::after {
  content: "";
  position: absolute; inset: 0;
  background: var(--dd-on-accent);
  -webkit-mask: var(--dd-tick) center / 8px 8px no-repeat;
  mask: var(--dd-tick) center / 8px 8px no-repeat;
  opacity: 0; transform: scale(.5);
  transition: opacity var(--dd-dur) var(--dd-ease), transform var(--dd-dur) var(--dd-ease);
}
.pw-req.met .pw-req-dot { background: var(--dd-green); border-color: var(--dd-green); }
.pw-req.met .pw-req-dot::after { opacity: 1; transform: scale(1); }

/* Visually hidden, still announced — the debounced SR summary that
   shared/auth.js's wirePasswordStrength() writes to on a 900ms typing-pause
   debounce. Scoped to this one consumer, not proposed as a general utility. */
.pw-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;
}

/* (@keyframes dd-shake and .login-card.shake lived here. A rejected sign-in
   used to shake the whole card for 400ms; a rejected FIELD flashed a red ring
   for 950ms and shook for 380 on top of it. Both are gone. The card now says
   what went wrong in the callout, the field holds a red ring until it is
   edited, and neither of those moves anything or has to be waited out. If a
   "felt" rejection is ever wanted back, it belongs on the field, once, and
   under 200ms.) */

/* ============================================================
   Auth card — the shell every signed-out page shares (login,
   signup, forgot, reset) and, in exactly the same anatomy, the
   first-run wizard at /welcome.

   The PAGE is no longer the shell. It is a canvas with one card
   floating on it (.auth-stage > .auth-card), because a full-bleed
   split made signing in read as a destination when it is a
   doorway — and because a card on the canvas is what this
   product's own dialogs already are. Two cells inside it:

     left   .auth-pane — the form column: a fixed ~440px of panel
            with generous padding, brand row pinned to its top.
            The .login-card inside stays UNBOXED (a bordered card
            inside a bordered card reads as a box in a box).
     right  .auth-hero — the preview: a SKELETON of the real shell
            (rail with workspace mark and name, search pill, module
            rows, topbar, quiet bars where content would be),
            inset on the left and bleeding off the card's right
            edge so it reads as a screen being looked at rather
            than a diagram being presented. Injected by
            shared/auth.js on the four auth pages, authored
            statically on /welcome; with JS off the form still
            stands alone.

   The skeleton is painted by the app's OWN component CSS
   (.side-head/.ws-switch/.search-pill/.side-nav/.topbar), so its
   anatomy cannot drift out of date without the app itself
   changing — but it holds NO content: bars, never sentences,
   never somebody else's to-dos. Flat surfaces, tokens only, so
   both themes and all seven accents come for free.
   ============================================================ */

.auth-body { min-height: 100vh; }

/* The padding lives HERE, not on `main`: the app-wide
   `main { padding: 32px 40px 40px }` element rule would otherwise decide
   this page's gutter, and the gutter is the only thing between the card and
   the viewport edge. dvh so a mobile browser's own chrome cannot push the
   card's bottom out of reach. */
/* THE PAGE CARRIES THE SCENE (2026-08-25 — the onboarding composition pass).
   Three rows: the product mark at the top of the PAGE, the card centred in
   what is left, a quiet link row at the foot. The brand used to be tucked
   into .auth-pane's own top-left corner and there was no footer at all, so
   the canvas was doing nothing but padding the card. Pulling both out is what
   turns the ground into a stage.
   The card stays a DIRECT child of this element — tests/smoke/sign-in.spec.ts
   asserts `.auth-stage > .auth-card > .auth-pane` — so the two new rows are
   its SIBLINGS and never wrappers. */
.auth-stage {
  min-height: 100vh; min-height: 100dvh;
  display: grid;
  grid-template-rows: auto minmax(0, 1fr) auto;
  justify-items: center; align-items: center;
  row-gap: clamp(14px, 2.6vh, 30px);
  padding: clamp(16px, 2.8vh, 38px) clamp(0px, 2.4vw, 40px);
  background: var(--dd-canvas);
}

/* Row 1 — the product lockup, centred on the card's own axis. */
.auth-mark {
  display: inline-flex; align-items: center; gap: 9px;
  text-decoration: none; color: var(--dd-ink);
}
.auth-mark .brand-mark { width: 24px; height: 24px; font-size: var(--dd-fs-2xs); }
.auth-mark-name {
  font-weight: 650; font-size: var(--dd-fs-sm); letter-spacing: -.01em;
  color: var(--dd-ink);
}
.auth-mark-name span { color: var(--dd-ink-quiet); font-weight: 450; }

/* Row 3 — the quiet foot. ONLY LINKS THIS PRODUCT ACTUALLY HAS: /welcome puts
   "Sign out" here (POST /api/logout is real), and the four signed-out pages
   put the panel-swap line here — the one that used to be a full-width
   bordered button under a labelled divider inside the card, competing with
   the primary action it sat beneath. There is no Privacy or Support page in
   this suite, so there is no Privacy or Support link; a footer full of dead
   words is worse than a short one. */
.auth-foot {
  display: flex; align-items: center; justify-content: center;
  gap: 6px; flex-wrap: wrap;
  min-height: 20px;
  font-size: var(--dd-fs-2xs);
  color: var(--dd-ink-quiet);
}
.auth-foot-link {
  border: 0; padding: 2px 3px; background: transparent;
  font: inherit; font-weight: 550;
  color: var(--dd-ink-light);
  cursor: pointer;
  border-radius: var(--dd-radius-s);
  transition: color var(--dd-dur) var(--dd-ease);
}
.auth-foot-link:hover { color: var(--dd-ink); }

/* ~1100x700 is what the interior asks for, not a number picked for looks:
   440px is the widest a single-column form reads comfortably at, and 700px
   is the height of the tallest panel (signup's password step, meter and
   checklist included) with air around it. The vw/vh caps hand the whole
   thing back to the viewport on a laptop that has less than that. */
/* An author `display` ALWAYS outranks the UA's `[hidden] { display: none }`,
   whatever their specificities — so a `display: grid` card would render right
   through its own hidden attribute. /welcome ships the card [hidden] until
   GET /api/session has answered (it displays the name, the address and the
   photo, and half a card that fills itself in a beat later is worse than one
   that arrives whole), and this line is the only reason that works. This exact
   trap — an author display rule beating [hidden] — has shipped a full-page
   click outage in this codebase before. */
.auth-card[hidden] { display: none; }

/* THE CARD IS PAGE-COLOURED, and only its right cell steps up. The form half
   used to be --dd-panel on a --dd-canvas ground, which made the whole card
   read as a lifted sheet; the reference does the opposite — the form column
   is virtually the page itself, told apart by nothing but a barely-there
   edge, and the PREVIEW is the surface that steps up (see .auth-hero). One
   consequence worth knowing: every control in the pane now sits on canvas,
   which is exactly the ground the app's own panes give .input and .btn. */
.auth-card {
  width: min(1100px, 94vw);
  height: min(700px, 100%);
  display: grid;
  grid-template-columns: 1fr;
  background: var(--dd-canvas);
  border: 1px solid var(--dd-border);
  border-radius: var(--dd-radius-l);
  /* Load-bearing: this radius is what crops the preview's bleed. */
  overflow: hidden;
}

.auth-pane {
  min-width: 0; min-height: 0;
  display: flex; flex-direction: column;
  padding: 26px 40px 30px;
  background: var(--dd-canvas);
  /* A panel taller than the card scrolls INSIDE this column instead of
     growing the card off-screen — the card's own height never changes, so
     the preview beside it never moves. The bar is hidden because this column
     scrolls only in the rare tall-form/short-window case and a permanent
     gutter would be visible in every other one. */
  overflow: hidden auto;
  overscroll-behavior: contain;
  scrollbar-width: none;
}
.auth-pane::-webkit-scrollbar { width: 0; }

/* (.auth-brand / .auth-brand-name lived here — the lockup pinned to the top
   of the form column. It is a PAGE element now: see .auth-mark above. What
   takes its place at the top of the pane is the back chevron, and on the
   steps that have no back there is simply air, which is what the reference
   does.) */

/* The card's own top-left affordance: one chevron, no word, on every step
   that has somewhere to go back to. ONE vocabulary for both wizards: /welcome
   had .welcome-head/.welcome-back/.welcome-progress and the signup wizard had
   a "Back" text button down in its action row beside Continue — two competing
   actions on one line, where the reference has one full-width primary and a
   chevron in the corner. */
.auth-nav {
  flex: none;
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  min-height: 30px;
}
.auth-back {
  display: inline-flex; align-items: center; justify-content: center;
  width: 30px; height: 30px;
  margin-left: -7px;
  border: 0; padding: 0; background: transparent;
  border-radius: var(--dd-radius-m);
  color: var(--dd-ink-quiet);
  cursor: pointer;
  transition: background var(--dd-dur) var(--dd-ease), color var(--dd-dur) var(--dd-ease);
}
.auth-back:hover { background: var(--dd-gray-100); color: var(--dd-ink); }
.auth-back svg { width: 17px; height: 17px; stroke-width: 1.75; }
/* Whatever the wizard says about WHERE you are, pushed to the far end of the
   row. margin-left:auto rather than justify-content, so the row is laid out
   the same whether or not the chevron is there. */
.auth-nav-meta { margin-left: auto; display: flex; align-items: center; gap: 10px; }

/* Auto margins, NOT justify-content:center: both centre the form in what the
   nav row leaves, but an auto margin resolves to zero once there is no
   positive free space, so a form taller than the column stays fully
   scrollable instead of having its top cropped past the scroll origin. */
/* ~40ch — the reference's own measure, and the width a single-column form
   reads at without the eye having to track back. */
.auth-form-col {
  width: 100%; max-width: 384px;
  margin-block: auto;
  padding: 20px 0 4px;
}

/* Two cells from tablet up. The preview needs a real width to read as a
   screen, so the form column gives ground first (clamp) and only settles at
   its full 440px once the card is wide enough to afford both. */
@media (min-width: 721px) {
  /* 44%, not 40%: at 1000px — where the wider breakpoint pins the column at
     440px — 44% IS 440px, so the two rules meet and the column settles
     rather than jumping 64px the moment the threshold is crossed. The 320px
     floor still catches from ~727px down. */
  .auth-card { grid-template-columns: clamp(320px, 44%, 440px) minmax(0, 1fr); }
  .auth-pane {
    border-right: 1px solid var(--dd-border);
  }
  /* (The sign-in morph's view-transition names lived here and on .auth-hero;
     the cinematic is deleted — see the note where its block was.) */
}
@media (min-width: 1000px) {
  .auth-card { grid-template-columns: 440px minmax(0, 1fr); }
}

/* Phones: the card gives up being a card. Full-bleed, one column, no border,
   no radius, document-scrolled — the same clean degradation the full-screen
   layout had, minus the preview it could never fit. */
@media (max-width: 720px) {
  /* The mark and the foot STAY — they are the page's own chrome now, and a
     phone that dropped them would be the one screen in the flow with no
     product name on it. They simply stop being spaced off a floating card:
     the stage packs its three rows from the top and the card, borderless,
     takes whatever is between them. */
  .auth-stage { padding: 22px 0 20px; align-content: start; row-gap: 18px; }
  .auth-card {
    width: 100%; height: auto; min-height: 0;
    border: 0; border-radius: 0;
  }
  .auth-pane { overflow: visible; padding: 4px 22px 12px; }
  .auth-form-col { max-width: 400px; margin-inline: auto; padding-top: 8px; }
}

/* ---------- the view stack: login / signup / forgot / reset ----------
   One page, four panels. Every auth page ships ALL four forms; auth.js
   swaps them in place (hidden attribute + pushState) so moving between
   sign-in, sign-up and forgot-password never navigates — the product pane
   on the right never reloads, never re-enters, never so much as blinks.
   The height change animates on the WRAPPER (WAAPI, driven by auth.js);
   the incoming panel's content cascades in via .av-in below. */
.auth-views { position: relative; }
.auth-view { min-height: 0; }
.auth-view[hidden] { display: none; }

/* ---------- the panel swap: IT RESOLVES OUT OF BLUR, AND IT DOES NOT MOVE ----
   Three generations of this. First a cascade — title, sub, form and footer
   each rising 6px from a ±22px sideways offset, staggered 30/90/150/220/280ms
   at 380ms apiece: over half a second of choreography on a swap people make
   several times in a sitting. Then one 180ms fade with a 3px rise. Now no
   travel at all: the panel arrives EXACTLY where it will stand and simply
   comes into focus, 200ms of opacity plus a 1.6px blur resolving to sharp.

   A 3px rise sounds like nothing and is not: every step of a six-screen
   wizard drifting upward as it lands turns a flow into a slideshow, and the
   thing the eye follows should be the words, not the box. Blur is the same
   "not settled yet" language the streaming tail speaks elsewhere in this
   product, which is what makes the swap read as one panel coming into
   focus rather than as two panels trading places.

   1.6px is the ceiling this file sets for blur. It does not need the usual
   `prefers-reduced-motion: no-preference` wrapper the blur law asks for,
   because that law exists for STATIC blurs — a filter inside an @keyframes is
   removed outright by this file's blanket `animation: none !important` rule,
   so a reader who asked for less motion gets the panel instantly and sharp.
   Shared by all three swappers: the auth panels, the signup wizard's steps
   and /welcome's. */
@keyframes av-in { from { opacity: 0; filter: blur(1.6px); } }
.auth-view.av-in { animation: av-in 200ms var(--dd-ease) both; }

.auth-title {
  margin: 0;
  font-size: 1.7rem;
  font-weight: 650;
  letter-spacing: -.022em;
  line-height: 1.15;
}
.auth-sub {
  margin: 8px 0 28px;
  font-size: var(--dd-fs-sm);
  line-height: 1.5;
  color: var(--dd-ink-quiet);
}

/* The card, unboxed. Scoped to .auth-pane on purpose: /welcome keeps the
   boxed .login-card because it never sits inside a pane. */
.auth-pane .login-card {
  max-width: none;
  background: transparent;
  border: 0; border-radius: 0;
  padding: 0;
}

/* ---------- THE CONTROLS ARE THE APP'S CONTROLS ----------
   2026-08-25, and this replaces a block that read "one tier more deliberate
   than the app's default input". That tier was the problem: signing in looked
   like a DIFFERENT PRODUCT from the thing you signed into. What made it one
   were three auth-only treatments, and all three are gone:

     1. a canvas-coloured fill, where the app's .input is --dd-panel;
     2. an `outline: none` plus a 3px accent wash on :focus — a soft halo
        behind every text box, fired by a mouse click as well as a keyboard
        landing, where the app's .input warms its border and lets the file's
        ONE :focus-visible outline be the focus indicator;
     3. a `transform: scale(.988)` press, where every other button in the
        product presses with .btn's `translateY(1px)`.

   What is left here is SIZE and nothing else: 44px instead of ~36. A doorway
   gets bigger controls than a dialog does; it does not get different ones.
   Anything added to this block later has to answer the same question — is
   this a scale difference, or a second visual language? */
/* Every field ends in its own reserved caption line (.field-hint, ~17px), so
   the gap between fields is that line plus this margin, not this margin
   alone. */
.auth-pane .login-field { margin-bottom: 8px; }
.auth-pane .input,
.auth-pane select.input {
  height: 44px;
  padding: 0 12px;                 /* the shell's own 12px, at this height */
}
/* Sentence case, not the app's UPPERCASE micro-label. .microlabel is a shared
   class (subbar heads wear it too), so this is an override inside the pane
   rather than a change to it: a form somebody is filling in for the first
   time wants its labels read, not scanned. */
.auth-pane .microlabel,
.auth-form-col .microlabel {
  text-transform: none;
  letter-spacing: 0;
  font-size: var(--dd-fs-2xs);
  font-weight: 500;
}
.auth-pane .login-field:focus-within .microlabel { color: var(--dd-ink); }
.auth-pane .login-field .microlabel { transition: color var(--dd-dur) var(--dd-ease); }

/* ---------- the scoped CTA ----------
   THE ONE PLACE IN THIS PRODUCT THAT DOES NOT WEAR --dd-accent, and it is a
   deliberate exception with a hard boundary: the primary button on the four
   auth pages and the five wizard steps, in DARK only.

   Why: --dd-accent's dark half is LIGHT and pastel by law (see the token
   block — --dd-on-accent paints near-black on an accent ground in dark, so
   every accent hue has to be light enough to carry it). That law is right for
   an app full of small accent surfaces and wrong for the one full-width slab
   of colour a person meets before they have seen anything else: a pastel
   Continue on a near-black page reads as a highlighter, not as the way in.
   The sampled reference uses a saturated #256DF0 with a WHITE label, and that
   is what this token is.

   CONTRAST TRADEOFF, WRITTEN DOWN: white on #256DF0 measures ~4.4:1. That is
   under the 4.5:1 AA threshold for body text and over the 3:1 threshold for
   large/bold text — and this label is 550-weight at --dd-fs-sm on a 44px
   slab, which is the case the large-text threshold exists for. It is
   deliberately accepted HERE and nowhere else; do not lift this pair onto
   small text, and do not let it leak past .auth-pane.

   LIGHT KEEPS THE ACCENT, unchanged: light mode's accent halves are dark and
   saturated already, so the reference's look is what light mode was doing all
   along, and a user who chose teal still gets teal there.
   The idle state below is untouched by any of this — a Continue whose
   question is not answered yet is quiet in both schemes. */
.auth-pane {
  --dd-auth-cta:     light-dark(var(--dd-accent), #256df0);
  --dd-auth-cta-ink: light-dark(var(--dd-on-accent), #ffffff);
}
.auth-pane .login-submit {
  height: 44px;
  border-radius: var(--dd-radius-m);
  font-size: var(--dd-fs-sm);
}
/* :not(.idle) so the idle treatment (a quiet fill, stated in tokens) keeps
   winning at rest — see .login-submit.idle. */
.auth-pane .login-submit.btn-primary:not(.idle) {
  background: var(--dd-auth-cta);
  border-color: var(--dd-auth-cta);
  color: var(--dd-auth-cta-ink);
}
.auth-pane .login-submit.btn-primary:not(.idle):hover {
  background: color-mix(in srgb, var(--dd-auth-cta) 88%, var(--dd-canvas));
  border-color: color-mix(in srgb, var(--dd-auth-cta) 88%, var(--dd-canvas));
}

/* Password extras: the show/hide eye (injected by auth.js). The eye is a 28px
   target sitting inside the field's right padding; the input pads out of its
   way. It anchors to .pw-wrap — the span auth.js puts around the input —
   NOT to .login-field, which is taller than its input by the caption line
   below it; against the field the eye would sit in that caption's row.
   (The caps-lock notice used to be an element here. It writes into the
   field's caption line now — see wirePasswordExtras.) */
.pw-wrap { position: relative; display: block; }
.auth-pane .login-field:has(.pw-eye) .input { padding-right: 44px; }
.pw-eye {
  position: absolute; right: 8px; bottom: 8px;
  width: 28px; height: 28px;
  display: grid; place-items: center;
  border: 0; padding: 0; background: transparent;
  border-radius: var(--dd-radius-s);
  color: var(--dd-ink-quiet);
  cursor: pointer;
  transition: color var(--dd-dur) var(--dd-ease), background var(--dd-dur) var(--dd-ease);
}
.pw-eye:hover { color: var(--dd-ink); background: var(--dd-gray-100); }
.pw-eye svg { width: 16px; height: 16px; stroke-width: 1.75; }

/* "Forgot your password?" — centred under the primary action. It tried the
   field's label row first and kept reading as a stray annotation on the
   input; under the button it sits exactly where the eye lands after a
   sign-in fails, styled as the quiet recovery line it is. */
.login-forgot {
  display: block;
  margin: 16px auto 0;
  border: 0; padding: 2px 4px; background: transparent;
  font: inherit;
  font-size: var(--dd-fs-sm-); font-weight: 550;
  color: var(--dd-ink-quiet);
  cursor: pointer;
  transition: color var(--dd-dur) var(--dd-ease);
}
.login-forgot:hover { color: var(--dd-ink); }

/* The panel swap under each form: a labeled hairline divider and a
   full-width SECONDARY button. The old left-aligned "New here? Create an
   account" sentence was the one ragged element under a symmetric stack —
   everything below the fields now shares the form's own axis: full-width
   button over full-width button, one centred recovery line between. */
.auth-div {
  display: flex; align-items: center; gap: 12px;
  margin: 26px 0 16px;
  font-size: var(--dd-fs-2xs);
  color: var(--dd-ink-quiet);
  white-space: nowrap;
}
.auth-div i { flex: 1; height: 1px; background: var(--dd-hairline); }
.auth-alt-btn {
  width: 100%; height: 44px;
  justify-content: center;
  border-radius: var(--dd-radius-m);
  font-size: var(--dd-fs-sm-);
}
/* Without a divider above it (forgot/reset), the button carries the gap. */
.auth-view > .auth-alt-btn { margin-top: 26px; }
.auth-view > .auth-div + .auth-alt-btn { margin-top: 0; }
/* Google's mark keeps its own four colours — the one place in this file a
   glyph isn't currentColor, because a recoloured G is a brand violation. */
.auth-alt-btn .auth-g { flex: none; margin-right: 8px; }

/* ---------- cross-document view transitions: THERE AREN'T ANY ----------
   The sign-in cinematic lived here: @view-transition { navigation: auto },
   two named groups (dd-rail, dd-main) morphing the auth pane into the rail
   over 1.8s, a vt-morph tier, and the vt-nav suppression rules. David asked
   for all of it to go (2026-08-26, after the vecka move): signing in and out
   is now an ordinary navigation — the new document simply paints. The names
   on .sidebar/.main-col/.auth-pane/.auth-hero went with it, as did the auth
   pages' head scripts that set html.vt-morph/html.vt-nav and the SPA's
   arrival plumbing that cleaned those classes up. Do not reintroduce a
   cross-document transition here without a fresh decision from David. */

/* ---------- the preview pane ----------
   The card's right cell. Hidden below 721px (see the card block above): a
   preview stacked under a form is a screenshot you have to scroll past to
   reach the fields, which is worse than no preview at all.

   The mock inside is inset on the left and TALL — top and bottom insets are
   deliberately equal, so the rail is whole from its switcher to its foot.
   That matters beyond looks: /welcome's step rings point at both ends of it,
   and a rail cropped at the bottom would put the invite row off-screen on
   the step that is about inviting people. Only the RIGHT edge bleeds, and
   the card's own radius is what crops it. */
/* THE PANEL IS THE SURFACE THAT STEPS UP. It used to be --dd-gray-50 — a
   wash — beside a --dd-panel form column; the two are the other way round
   now (the form half is page-coloured, see .auth-card), which is the
   reference's own arrangement and the reason the preview reads as a screen
   being looked at rather than as a lighter half of the same card. */
.auth-hero {
  display: none;
  position: relative;
  overflow: hidden;
  background: var(--dd-panel);
  /* Declared here rather than in the token block at the top of this file: it
     is one duration used by one component, and the top of this file is shared
     ground. 320ms is the size class the segmented pill's morph already
     occupies — the one step above --dd-dur-pane for a move that is also a
     scale. */
  --dd-dur-zoom: 320ms;
}
/* The one place that decides whether the preview is on screen, and it sits
   AFTER the base rule on purpose — see the note in the min-width:721px block
   above the card, which used to carry a second, losing copy of this. Phones
   get the form alone: a preview stacked under it is a screenshot you have to
   scroll past to reach the fields. */
@media (min-width: 721px) {
  .auth-hero { display: block; }
}

/* (.auth-hero-bg and .hb-a/-b/-c/-dots — three drifting flat shapes and a
   panning dot grid behind the frame — went with the full-bleed layout: in a
   ~660px card cell the mock covers nearly all of them, and four infinite
   animations behind a sign-in form is motion nobody asked for. The pane's
   ground is one flat token now. .auth-hero-copy/-head/-sub — the marketing
   headline — went too: this is a card, not a landing page, and the
   product's own anatomy is the whole argument.) */

/* THE RAIL STAYS WHOLE, TOP TO BOTTOM, and .hx-cut does the running-off.
   This was tried the other way first — a real bottom bleed of -72px, with the
   invite step's camera travelling down to the rail's foot to fetch it back.
   The arithmetic kills it: the mock's rail is ~630px of a ~660px frame, so at
   any zoom past about 1.1 the cell cannot hold both the module rows and the
   foot, and framing the foot alone fills the panel with the stretch of rail
   that is deliberately empty. So the frame keeps a real bottom inset, both
   ends of the rail are always in the mock, and the cutoff the reference shows
   is drawn by the fade instead of by the layout — which is what it is in the
   reference too. */
.hx-frame {
  position: absolute;
  top: clamp(24px, 5%, 40px);
  bottom: clamp(18px, 3.6%, 30px);
  left: clamp(26px, 6.5%, 54px);
  right: -76px;                /* the bleed */
  display: flex;
  border: 1px solid var(--dd-border);
  border-radius: var(--dd-radius-l);
  background: var(--dd-canvas);
  overflow: hidden;
  /* THE ZOOM STAGE. welcome/index.html writes one `translate() scale()` here
     per step and the transition does the rest — which is also why moving
     between two ringed regions MORPHS rather than cuts: it is one property
     on one element, so the browser interpolates the whole journey. Origin
     0 0 so the maths in zoomTo() is a plain affine map with no half-box
     corrections in it.
     320ms and --dd-ease-pane: this is a PANE-sized move (the whole preview
     travels and grows), which is exactly the case that curve and that
     duration class were derived for — see the --dd-ease-pane note. */
  transform-origin: 0 0;
  transition: transform var(--dd-dur-zoom) var(--dd-ease-pane);
}
/* No entrance at all any more — the 240ms hx-frame-in resolve went with the
   rest of the auth-boundary animation (2026-08-26): arriving at this page,
   including by signing out, just paints. The zoom transition above stays;
   it is the welcome wizard's step framing, not an entrance. */

/* ---------- the cutoff ----------
   Where the mock runs off the bottom of the cell it does not stop, it stops
   BEING there: a slab of the panel's own ground washed up over it on a mask
   fade, with the house blur behind it.

   The fill is FLAT --dd-panel — the gradient is the mask's alpha, not a
   painted colour, which is the distinction the no-gradients rule draws and
   the reference's own cutoff is built on. The blur is 1.6px, the ceiling this
   file sets, and it lives inside `prefers-reduced-motion: no-preference` like
   every other blur here: blur means "not settled yet", and a reader who
   turned motion off must not be left with permanently soft pixels instead. */
.hx-cut {
  position: absolute; left: 0; right: 0; bottom: 0;
  height: 132px; z-index: 3;
  pointer-events: none;
  background: var(--dd-panel);
  -webkit-mask-image: linear-gradient(to top, #000 20%, transparent 100%);
  mask-image: linear-gradient(to top, #000 20%, transparent 100%);
}
@media (prefers-reduced-motion: no-preference) {
  .hx-cut {
    -webkit-backdrop-filter: blur(1.6px);
    backdrop-filter: blur(1.6px);
  }
}

/* position:relative because the SPOTLIGHT's dim is this element's own ::after
   (see the .hx-ring block at the foot of this section) — no extra markup, so
   auth.js's mountHero() and /welcome's static copy stay identical. */
.hx-app { position: relative; flex: 1; min-width: 0; display: flex; align-items: stretch; }

/* ---------- the rail, skeletonised ----------
   .side-head, .ws-switch, .ws-mark, .search-pill, .side-nav, .side-inbox and
   .rail-btn are the APP'S OWN rules, reused verbatim. Only the container,
   the <span> stand-ins for what are buttons and links in the real rail, and
   one size step down (the mock's rows are ~28px against the rail's 34) are
   new here. Nothing in this block invents a component. */
/* 196px, not the 176 it was: at the workspace step the mock zooms to the
   switcher and a workspace called anything longer than "Wedge Stud" was
   truncating mid-word at 2x, which is the one thing that step's live mirror
   exists to show. The real rail is 232px, so this is still a step down. */
.hx-side {
  width: 196px; flex: none;
  display: flex; flex-direction: column;
  background: var(--dd-rail);
  border-right: 1px solid var(--dd-border);
  padding: 0 8px 10px;
}
.hx-side .side-head { height: 38px; padding: 0 8px; margin: 0 -8px 8px; border-bottom: 1px solid var(--dd-hairline); }
.hx-side .ws-switch { padding: 3px 5px; gap: 7px; }
.hx-side .ws-mark { width: 19px; height: 19px; font-size: var(--dd-fs-3xs); border-radius: var(--dd-radius-s); }
.hx-side .ws-name { font-size: var(--dd-fs-2xs); max-width: 106px; }
.hx-side .ws-caret { width: 12px; height: 12px; }
.hx-side .rail-btn { width: 22px; height: 22px; }
.hx-side .rail-btn svg { width: 13px; height: 13px; }
.hx-side .side-search { padding: 0 1px 9px; gap: 4px; }
.hx-side .search-pill { height: 26px; font-size: var(--dd-fs-3xs); padding: 0 8px; gap: 6px; }
.hx-side .search-pill > svg { width: 11px; height: 11px; }
.hx-side .search-pill kbd { font-size: var(--dd-fs-3xs); padding: 1px 3px; }
.hx-side .side-inbox { width: 26px; height: 26px; }
.hx-side .side-label { padding: 5px 7px 3px; font-size: var(--dd-fs-3xs); }
.hx-side .side-nav > span {
  display: flex; align-items: center; gap: 8px;
  padding: 5px 7px;
  border-radius: var(--dd-radius-m);
  font-size: var(--dd-fs-2xs); font-weight: 480;
  color: var(--dd-ink-light);
}
.hx-side .side-nav > span.active { background: var(--dd-gray-100); color: var(--dd-ink); font-weight: 600; }
.hx-side .side-nav svg { width: 13px; height: 13px; flex: none; stroke-width: 1.75; }
.hx-side .side-foot { padding-top: 9px; gap: 1px; }
.hx-side .avatar { width: 18px; height: 18px; font-size: var(--dd-fs-3xs); }

/* The records the real rail lists under its modules (projects, calendars,
   chats) — bars, because the preview knows the SHAPE of that list and has no
   business inventing its contents. */
.hx-side-records { display: flex; flex-direction: column; gap: 9px; padding: 9px 7px 0; }

/* (.hx-account — an account row in the mock's rail foot — is gone, and its
   absence is the point: the real rail has no such row. The account chip moved
   behind the workspace switcher and onto /settings/profile (app/src/shell/
   Rail.tsx), so drawing one here would be the preview inventing anatomy, which
   is the one thing it must never do. /welcome's first step is about YOU rather
   than about a region, and it rings nothing at all.) */

/* ---------- the main column, skeletonised ---------- */
.hx-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
/* The app's own .topbar, one step down — the mock's rows are 38px, not 48. */
.hx-main .topbar { height: 38px; padding: 0 12px; justify-content: space-between; }
.hx-main .topbar-page { font-size: var(--dd-fs-2xs); gap: 6px; }
.hx-main .topbar-page svg { width: 13px; height: 13px; }
.hx-topbar-help { font-size: var(--dd-fs-3xs); color: var(--dd-ink-quiet); }

.hx-content {
  flex: 1; min-height: 0;
  padding: 13px;
  display: flex; flex-direction: column; gap: 10px;
  overflow: hidden;
}

/* Quiet bars where content would be. ONE flat grey, never a hue and never a
   word: the preview shows the shape of the product, and inventing rows of
   plausible-looking to-dos to make it look busy is exactly the thing it must
   not do. Everything below is built from those bars plus the surfaces the
   real screens are built from. */
.hx-bar { display: block; height: 6px; border-radius: var(--dd-radius-s); background: var(--dd-gray-100); }
.hx-bar.wide { width: 68%; }
.hx-bar.mid { width: 46%; }
.hx-bar.narrow { width: 26%; }
.hx-bar.strong { height: 8px; background: var(--dd-gray-200); }

.hx-tiles { flex: none; display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.hx-tile {
  height: 50px; padding: 9px 10px;
  display: flex; flex-direction: column; justify-content: center; gap: 8px;
  border: 1px solid var(--dd-border); border-radius: var(--dd-radius-m);
  background: var(--dd-panel);
}

.hx-panel {
  flex: 1; min-height: 0;
  display: flex; flex-direction: column;
  border: 1px solid var(--dd-border); border-radius: var(--dd-radius-m);
  background: var(--dd-panel);
  overflow: hidden;
}
.hx-panel-head {
  flex: none; height: 32px;
  display: flex; align-items: center; gap: 8px;
  padding: 0 12px;
  border-bottom: 1px solid var(--dd-hairline);
}
.hx-row {
  flex: none; height: 33px;
  display: flex; align-items: center; gap: 9px;
  padding: 0 12px;
  border-bottom: 1px solid var(--dd-hairline);
}
.hx-row:last-child { border-bottom: 0; }
.hx-row > .hx-bar { flex: 1; min-width: 0; }
.hx-box { width: 12px; height: 12px; flex: none; border: 1.5px solid var(--dd-gray-300); border-radius: var(--dd-radius-s); }
.hx-chip { flex: none; width: 32px; height: 12px; border-radius: var(--dd-radius-s); background: var(--dd-gray-100); }

/* ---------- /welcome's step ring ----------
   The reason the preview earns its keep in the wizard: the step you are on
   points at the part of the product it is about to change. 2px of accent, on
   a box-shadow rather than a border — so the ring costs the ringed element no
   size, adds no surface, and is not a gradient or a wash. Every target is a
   real region of the mock; nothing is drawn on top of it.
   A second layer used to sit under that ring: a soft accent-tinted spread at
   `0 4px 18px -4px`. It went with every other blurred, offset shadow in this
   file — an accent glow is still a drop, and the flat-spread discipline
   .welcome-accent's own rings already keep ("no blur radius — nothing here
   glows") is the one this had been the exception to.
   The transition rides this file's blanket prefers-reduced-motion rule
   (`* { transition: none !important; animation: none !important }`), so a
   reader who asked for less motion gets the ring instantly and still gets
   the ring. */
.hx-ring {
  border-radius: var(--dd-radius-m);
  box-shadow: 0 0 0 0 transparent;
  transition: box-shadow 180ms var(--dd-ease), background var(--dd-dur) var(--dd-ease);
}
.hx-ring.on { box-shadow: 0 0 0 2px var(--dd-accent); }

/* ---------- THE SPOTLIGHT ----------
   A ring alone tells you WHERE to look; it does not stop the rest of the mock
   competing for the same glance. The reference dims everything but the region
   the step configures, and this is that, built out of the seam the ring
   already was.

   NO NEW MARKUP AND NO TRAVEL. The dim is .hx-app's own ::after — one flat
   fill of the canvas, faded in on OPACITY, over a mock whose children all
   stack at `auto` and therefore in DOM order beneath it. The lit region rises
   through it by taking a z-index, which is the whole mechanism: nothing
   moves, nothing scales, nothing blurs, and a reader who asked for less
   motion still gets the spotlight (this file's blanket prefers-reduced-motion
   rule zeroes the fade, leaving the two states themselves intact).

   .spot is set by /welcome's ring() and only for a REGION. The step that
   changes the whole app (theme + accent) rings .hx-frame instead and lights
   nothing down, because there is nothing to dim when the subject is
   everything. */
.hx-app::after {
  content: "";
  position: absolute; inset: 0; z-index: 1;
  background: var(--dd-canvas);
  opacity: 0;
  pointer-events: none;
  transition: opacity 180ms var(--dd-ease);
}
.hx-frame.spot .hx-app::after { opacity: .58; }

/* The lit region: above the dim, and given a fill so what rises through is a
   surface rather than a hole. .hx-panel already carries --dd-panel; the two
   rail regions are bare spans in the real rail too, so they take the LIT wash
   the rail gives a selected row — the brighten is the app's own vocabulary,
   not a new one. */
.hx-frame.spot .hx-ring.on { position: relative; z-index: 2; }
/* One rail region left: the switcher (the invite step rings IT now — the foot
   row became "New list" and inviting lives behind the workspace menu). */
.hx-frame.spot .ws-switch.hx-ring.on { background: var(--dd-gray-100); }
/* The frame is the target for the step that changes the WHOLE app (theme and
   accent), so it keeps its own larger radius. It used to restate a dialog
   shadow here as well, purely so that .hx-ring's `box-shadow: 0 0 0 0
   transparent` could not wipe the frame's own elevation; with no elevation
   left anywhere in this file there is nothing to protect and the ring stands
   by itself, exactly as it does on every other region of the mock. */
.hx-frame.hx-ring { border-radius: var(--dd-radius-l); }

/* (.hx-stage / .hx-view / .hx-appshot lived here: three REAL module screens,
   captured from the seed fixture into shared/auth-screens.js, overlaid on
   the frame and cross-faded on a 5s timer through a blur morph. All of it is
   gone. A 1240px-wide capture cropped into a ~500px card cell shows a
   quarter of one screen, the rotation animated a card people look at for
   four seconds, and the screens carried somebody else's to-dos and mail
   subjects — "you don't have to show any content in there". The four auth
   pages no longer load auth-screens.js or the three module stylesheets
   either, which is most of what a signed-out visitor used to download.) */

/* ---------- onboarding: signup wizard + welcome (first sign-in) ----------
   Two additions, both built from primitives already above rather than a
   parallel visual language. The signup wizard — THREE steps now (invite,
   then email, then password; the name step moved to /welcome) — is a
   .login-card form inside signup's .auth-view, and reuses
   .login-field/.btn/.field-hint; it adds only the step chrome below and a
   directional slide-in. The post-login welcome screen wears the SAME card as
   the four auth pages (.auth-stage > .auth-card > .auth-pane + .auth-hero,
   further up this file) and reuses .auth-title/.auth-sub/.login-field/
   .auth-chip/.btn-primary; it adds only its six steps' own chrome and the
   preview's camera. NOT .login-title/.login-sub: those are the pre-rebuild
   names, the card's headings are .auth-title/.auth-sub now, and no markup
   carries the old pair any more. */

/* Step chrome: one quiet row — three SEGMENTS on the left (one per step of
   signup's invite/email/password wizard, filling as you pass them; a wizard
   has countable stages, so it gets countable marks rather than a percentage
   bar), the counter on the right. auth.js drives the same contract as the old
   bar: data-step + the counter's text. Three is not a free number: the markup
   ships three <i> (login/index.html's .onboard-progress), the :nth-child
   rules below fill at most three, and shared/auth.js's SIGNUP_STEPS is 3 and
   no-ops the whole wizard if the step count disagrees. Move all four
   together. */
.onboard-head {
  display: flex; align-items: center; justify-content: space-between;
  margin: 0 0 22px;
}
.onboard-progress { display: flex; gap: 6px; }
.onboard-progress i {
  width: 28px; height: 4px; border-radius: 2px;
  background: var(--dd-gray-100);
  transition: background var(--dd-dur-slow) var(--dd-ease);
}
.onboard-progress[data-step="1"] i:nth-child(-n+1),
.onboard-progress[data-step="2"] i:nth-child(-n+2),
.onboard-progress[data-step="3"] i:nth-child(-n+3) { background: var(--dd-ink); }
.onboard-stepnum {
  font-size: var(--dd-fs-2xs);
  color: var(--dd-ink-quiet);
  font-variant-numeric: tabular-nums;
}

/* One entrance per step, and it is the same one the panels use — 200ms, no
   travel, blurring into focus. Each step used to slide 30px in from its own
   side and land with a scale overshoot past centre; three of those in a row,
   on a wizard you walk through in under a minute, is the app performing
   rather than responding. auth.js still sets .enter-r / .enter-l (the
   direction is real information for anything that wants it later); both
   resolve here to the same blur-in. */
.onboard-step.enter-r,
.onboard-step.enter-l { animation: av-in 200ms var(--dd-ease) both; }

/* The step's question — THE heading of its screen (there is no separate
   panel title on the wizard; title + question + field label was three text
   tiers asking one thing). Sized just under the other panels' .auth-title
   so the wizard reads as the same family, one register warmer.

   It is an <h1> in the markup, not a <p> styled to look like one: the signup
   panel carries no .auth-title, so this element is the ONLY heading /signup
   exposes, and a paragraph left the page with none at all for anyone moving
   by heading. Every property a UA h1 would bring is restated below, so the
   tag carries semantics and nothing else. /welcome's .welcome-q is the same
   decision on the same anatomy — keep the two in step. */
.step-q {
  margin: 0 0 20px;
  font-size: 1.45rem;
  font-weight: 650;
  letter-spacing: -.02em;
  line-height: 1.2;
}
/* More air around the wizard chrome now that it opens each screen. */
.onboard-head { margin-bottom: 28px; }

/* Quiet centred one-liners (the resting waitlist path). */
.auth-quiet {
  margin: 20px 0 0;
  text-align: center;
  font-size: var(--dd-fs-sm-);
  color: var(--dd-ink-quiet);
}
.auth-quiet-link {
  border: 0; padding: 0; background: transparent;
  font: inherit; font-weight: 600;
  color: var(--dd-ink);
  cursor: pointer;
}
.auth-quiet-link:hover { text-decoration: underline; text-underline-offset: 3px; }

/* Continue at rest before its question is answered: visually idle, still
   clickable — a dead button can't explain itself, an idle one nudges (see
   .input.is-bad). aria-hidden nothing, disabled nothing.
   STATED IN TOKENS, NOT OPACITY: a .45 on the whole button dragged label and
   fill down together and the label composited at 2.0:1 against the pane in
   light mode — a washed-out primary control on the first screen a new account
   ever sees. A quiet fill with quiet-but-legible ink says "not yet" and still
   reads. */
.login-submit.idle {
  background: var(--dd-gray-100);
  border-color: var(--dd-border);
  color: var(--dd-ink-quiet);
}
/* LONGHANDS, and the .btn block above says why in full: a `transition:`
   shorthand on a .btn descendant replaces all four of its legs, which is
   exactly what this rule used to do — it overwrote .btn's asymmetric
   press/release clock with one flat 80ms and gave this button a different
   feel from every other button in the product. Same four properties, same
   durations, plus `color` (the idle state changes ink). */
.login-submit {
  transition-property: background, border-color, color, transform, opacity;
  transition-duration: var(--dd-dur), var(--dd-dur), var(--dd-dur), var(--dd-dur-pop), var(--dd-dur);
  transition-timing-function: var(--dd-ease);
}
.login-submit:active {
  transition-duration: var(--dd-dur), var(--dd-dur), var(--dd-dur), 0s, var(--dd-dur);
}

/* (.field-bad's shake + 950ms red flash lived here. The nudge is silent now:
   .input.is-bad holds the ring until the field is edited, and the reason sits
   on the field's caption line. See the .field-hint block above.) */

/* ---------- the waitlist (signup step 1's no-code path) ----------
   Rests as one quiet line; the email row rises in only when asked for. */
/* It resolves out of blur like every other reveal on these pages — it used
   to drop 4px into place, which is the vertical travel the step swap just
   gave up. */
.waitlist-row { display: flex; gap: 8px; margin-top: 12px; animation: av-in 200ms var(--dd-ease) both; }
.waitlist-row .input { flex: 1; min-width: 0; height: 40px; }
.waitlist-row .btn { flex: none; height: 40px; border-radius: var(--dd-radius-m); font-size: var(--dd-fs-sm-); }
.waitlist-note { margin: 10px 2px 0; font-size: var(--dd-fs-2xs); color: var(--dd-green); }
.waitlist-note.err { color: var(--dd-red); }

/* ---------- Google pending signup (the wizard's other mode) ----------
   One card finishing a signup Google already vouched for: identity row
   (photo + editable name + fixed address), one optional invite field, one
   button. It enters the way every auth panel does — resolve, not travel. */
.gsignup { animation: av-in 200ms var(--dd-ease) both; }
.gsignup-id {
  display: flex; align-items: center; gap: 12px;
  margin: 4px 0 18px;
}
.gsignup-avatar {
  flex: none; width: 44px; height: 44px; border-radius: var(--dd-radius-full);
  object-fit: cover; background: var(--dd-gray-100);
  border: 1px solid var(--dd-border);
}
.gsignup-avatar-fb {
  display: grid; place-items: center;
  font-size: var(--dd-fs-md); font-weight: 600; color: var(--dd-ink-quiet);
}
.gsignup-who { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.gsignup-names { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.gsignup-who .input { height: 36px; }
.gsignup-email {
  font-size: var(--dd-fs-2xs); color: var(--dd-ink-quiet);
  padding-left: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.gsignup-opt {
  margin-left: 6px; font-size: 10px; font-weight: 500;
  color: var(--dd-ink-quiet); text-transform: uppercase; letter-spacing: 0.04em;
}
#gsignup-done { animation: av-in 200ms var(--dd-ease) both; }

/* THE PRIMARY TAKES THE WHOLE COLUMN. Back used to sit in this row as a
   ghost "‹ Back" text button with Continue flexing beside it — two actions on
   one line, on a card whose only question is the one above them. Back is a
   chevron in the card's top-left corner now (.auth-nav / .auth-back, the same
   place and the same control /welcome uses), which is what the reference does
   and what leaves the CTA its full width.
   The margin clears the field's caption line above it — a rejection message
   must not sit flush against Continue. */
.onboard-actions { display: flex; flex-direction: column; align-items: stretch; margin-top: 10px; }
.onboard-actions .login-submit { width: 100%; margin-top: 0; }

/* The password-strength meter lives in the auth-pages section above, scoped
   under .pw-strength. A second, older implementation used to sit here — a
   bare `.pw-meter` with data-score segments and a max-height/opacity reveal —
   and it was not merely dead: `.pw-meter` matched the NEW markup too, and
   because the cascade resolves per property, its `opacity:0; max-height:0;
   overflow:hidden` kept winning over a newer rule that only ever set display,
   gap and margin. The live meter rendered, scored correctly, and was invisible.
   Scoping the new rules was not enough; the old ones had to go. */

/* (.field-hint.pw-hint-weak/fair/good/strong lived here: the live strength
   word painted onto the field's own caption line, recolored to match the
   meter. Two things ended it. The word moved to .pw-meter-label, a component
   of the meter rather than a borrowed caption line — shared/auth.js's hintOf()
   hard-assigns `className = "field-hint"`, so a hint class could never have
   survived the next rejection anyway. And the color lost the argument on
   readability: see .pw-meter-label above, which is deliberately plain ink,
   because the bars already carry the color and the word is the redundant
   signal for whoever the color does not reach.) */

/* ---------- /welcome — the first-run wizard ----------
   Five steps in the SAME card as the auth pages (.auth-stage/.auth-card/
   .auth-pane/.auth-hero above): the left column is the step, the right one
   is the shell mock with an accent ring around the region this step is about
   to change. Nothing here is decorative — every step writes real state
   through the same doors the app itself uses, so the wizard IS the product
   being set up, not a tour of it.

   The single card the old /welcome used (.welcome-card, a to-do row list and
   an "add your first to-do" field) is gone; so are .welcome-list and
   .row-link-fill, which nothing else in the suite used. */

/* (.welcome-head / .welcome-back / .welcome-progress lived here — the pane's
   top row, back on the left and position on the right. The signup wizard had
   the same row under a different name and its Back as a text button DOWN in
   the action beside Continue. Both speak one vocabulary now: .auth-nav /
   .auth-back / .auth-nav-meta, defined once beside the card they sit in.) */

/* (.welcome-dots and .welcome-stepnum lived here — five dots and "3 of 6" in
   the card's top-right corner. BOTH ARE GONE BY REQUEST: the reference shows
   no position indicator on any of its eight screens, and a counter on a
   six-screen flow spends its whole life telling you how much is left. What
   survives is the part that was never visual — the live region that announces
   each step, which is now visually hidden (.auth-sr-only) so a screen-reader
   user keeps the orientation the dots were only ever decorating.) */

/* Announced, never seen. Same technique as .pw-sr-only above; declared
   separately because that one is explicitly scoped to its own consumer. */
.auth-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;
}

/* The wrapper whose HEIGHT is tweened between steps (welcome/index.html
   measures both and runs one WAAPI animation on it — the same technique
   .auth-views uses for its panel swaps, at the same speed). Steps are
   [hidden], never stacked and faded, so only one is ever in the tab order. */
.welcome-steps { position: relative; }
.welcome-step[hidden] { display: none; }
/* One entrance, and it is the same one every other panel in this file uses:
   200ms, no travel, blurring into focus — see @keyframes av-in. */
.welcome-step.in { animation: av-in 200ms var(--dd-ease) both; }

.welcome-q {
  margin: 24px 0 6px;
  font-size: 1.35rem;
  font-weight: 650; letter-spacing: -.02em; line-height: 1.2;
}
.welcome-lede {
  margin: 0 0 20px;
  font-size: var(--dd-fs-sm); line-height: 1.5;
  color: var(--dd-ink-quiet);
}

/* ---- step 1: who you are ----
   (.welcome-avatar-row / .welcome-avatar / .welcome-avatar-actions lived
   here — a 56px circle, an "Add a photo" button and a Remove link, wired to
   POST /api/account/avatar. The whole affordance is gone from this screen by
   request: a photo is not what the first minute of an account is for, and
   Settings → Profile still has the identical uploader, down to the same
   centre-crop-to-128px. Nothing was lost, only moved out of the doorway.)

   (.welcome-readonly — "Signed in as <address>" as a sentence in a bordered
   row — went too. The address is a FIELD now, disabled: it is one of the
   three things this step shows, and showing it as prose while the two beside
   it were inputs made it read as a different kind of fact.) */

/* Two fields on one line. The reference asks first and last separately and so
   do we; POST /api/account/profile takes one `name`, so they are joined on
   the way out (see saveStep1). */
.welcome-name-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 8px; }
.welcome-name-grid .login-field { margin-bottom: 0; }

/* A field that is shown rather than asked. Same box as every other field —
   the ONLY difference is quiet ink and no caret, which is the whole of what
   "you cannot change this here" needs to look like. Changing the address IS
   possible, just not here: /api/account/email wants the password, which is a
   Settings conversation. */
.auth-pane .input[readonly] { color: var(--dd-ink-quiet); cursor: default; }
.auth-pane .input[readonly]:hover { border-color: var(--dd-border); }

/* ---- a labelled toggle row ----
   THE CONTROL IS THE APP'S CHECKBOX, deliberately. The reference draws a
   track-and-thumb switch; this suite has never had one (see the .switch-row
   note above: settings, the form dialogs and every module toolbar are all
   .check), and the standing instruction for this pass is that the doorway
   must wear the app's controls rather than lookalikes of somebody else's. So
   this is the reference's ROW — title, explanation, control on its own line —
   speaking our vocabulary, tick-draw animation and all. */
.welcome-switch {
  display: flex; align-items: flex-start; gap: 12px;
  margin: 18px 0 2px;
  cursor: pointer;
}
.welcome-switch .check { margin-top: 2px; }
.welcome-switch-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.welcome-switch-title { font-size: var(--dd-fs-sm-); font-weight: 550; color: var(--dd-ink); }
.welcome-switch-sub { font-size: var(--dd-fs-2xs); color: var(--dd-ink-quiet); line-height: 1.45; }

/* ---- a field whose first part is not editable ----
   The workspace handle: our host, then the slug. Shaped as ONE .input — the
   box, the edge, the hover and the focus all belong to the WRAPPER and the
   real <input> inside it is stripped bare — so a compound field and a plain
   one are visibly the same control rather than two things that nearly match.
   Focus is the wrapper's border warm, same as .input:focus — text fields
   carry no ink ring anywhere any more (see :focus-visible's note). */
.auth-prefix-field {
  display: flex; align-items: center;
  height: 44px; padding: 0 12px;
  font: inherit; font-size: var(--dd-fs-sm);
  color: var(--dd-ink);
  background: var(--dd-panel);
  border: 1px solid var(--dd-border);
  border-radius: var(--dd-radius-m);
  transition: border-color var(--dd-dur) var(--dd-ease);
}
.auth-prefix-field:hover { border-color: var(--dd-gray-300); }
/* The border warm is the whole focus story — the ink ring text fields used to
   mirror out to this box left with the fields' own (see :focus-visible). */
.auth-prefix-field:focus-within { border-color: var(--dd-gray-500); }
.auth-prefix-field.is-bad { border-color: var(--dd-red); }
.auth-prefix { flex: none; color: var(--dd-ink-quiet); white-space: nowrap; }
.auth-pane .auth-prefix-field .input,
.auth-pane .auth-prefix-field .input:hover,
.auth-pane .auth-prefix-field .input:focus {
  flex: 1; min-width: 0; height: auto; padding: 0;
  border: 0; background: transparent; border-radius: 0;
}
.auth-prefix-field .input:focus-visible { outline: none; }

/* ---------- option chips ----------
   The reference's one control for "pick from a short, closed list": a
   bordered pill with an optional leading glyph, the selected one plainly
   marked. IT IS A .btn, and that is the whole point of adding it as a
   MODIFIER rather than as a component — a chip and a button in this product
   are the same box, the same 34px, the same 1px edge, the same fill and the
   same press. All this class does is round the corner one step (to
   --dd-radius-l, which is 8px; the 999px pill is not available here — this
   system took its pills down to the 3/5/8 scale and only true circles keep
   --dd-radius-full), quieten the resting ink, and say which one is chosen.

   Chosen = the accent's own tint behind full-strength ink, with the edge
   mixed toward the accent. NOT a solid accent fill: a row of solid accent
   slabs sitting above a single accent CTA would have two things shouting the
   same colour, and the CTA is the one that has to win. */
.auth-chips { display: flex; flex-wrap: wrap; gap: 8px; }
.auth-chip {
  border-radius: var(--dd-radius-l);
  font-weight: 500;
  color: var(--dd-ink-light);
}
.auth-chip svg { width: 15px; height: 15px; }
/* 20/75, measured on screen rather than guessed: at 12/55 the chosen chip was
   a shade of the unchosen one in dark, which is not "clearly marked". Both
   halves are mixes of the accent, so all seven accents and both schemes come
   out of one pair of numbers. */
.auth-chip.active {
  background: color-mix(in srgb, var(--dd-accent) 20%, var(--dd-panel));
  border-color: color-mix(in srgb, var(--dd-accent) 75%, var(--dd-border));
  color: var(--dd-ink);
  font-weight: 600;
}
.auth-chip.active:hover {
  background: color-mix(in srgb, var(--dd-accent) 26%, var(--dd-panel));
  border-color: color-mix(in srgb, var(--dd-accent) 90%, var(--dd-border));
}

/* ---------- the disclosure a choice unlocks ----------
   grid-template-rows 0fr → 1fr, NEVER mount/unmount: the follow-up group is
   in the DOM from the start, so nothing is rebuilt, the tab order is stable,
   and the open eases without a measured pixel height anywhere. The child
   needs min-height:0 as well as overflow:hidden — a grid item's automatic
   minimum size is its own content, and without that it simply refuses to
   shrink below it and the "closed" state is not closed at all.
   The child keeps its top padding inside the clip so a focus ring on the
   first control in the group has somewhere to be. */
.auth-reveal {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows var(--dd-dur-fold) var(--dd-ease);
}
/* The 3px inset is the focus ring's room: :focus-visible draws 2px at 2px
   offset, and without this the clip that makes "closed" mean closed also
   sliced the top and bottom off the outline of the very field this thing
   exists to reveal. */
.auth-reveal > * { min-height: 0; overflow: hidden; padding: 3px 0; }
.auth-reveal.open { grid-template-rows: 1fr; }
/* A disclosed group is still a group, and .welcome-field + .welcome-field
   cannot see across the wrapper to give it its own air. */
.auth-reveal .welcome-field { margin-top: 16px; }

/* ---- step 3: theme + accent ----
   The three theme options are CHIPS now. They used to be three cards, each
   carrying a hand-painted mini diagram of the scheme it named, built out of
   six hardcoded hexes that had to be re-frozen by hand every time the palette
   moved (the comment that lived here said so at length). The mock beside the
   step already IS the diagram: step 3 rings the whole frame and the mock
   repaints from the tokens the instant a chip is picked, live, at full size.
   A postage-stamp drawing of a theme, next to the theme, was the redundant
   half — and it was the only place in this stylesheet still holding literal
   colours. (app/src/styles/spa.css keeps its own .theme-mini for the settings
   page, where there is no live mock to point at.) */
.welcome-accents { margin-top: 4px; }

/* Seven circles, each painting itself from its own data-accent — the same
   attribute shell/accent.ts writes on <html>, so the swatch and the thing it
   sets are named by one vocabulary. Setting `color` beside `background` is
   what lets the chosen ring be the swatch's own hue with no per-colour rule. */
.welcome-accents { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.welcome-accent {
  width: 22px; height: 22px; flex: none;
  padding: 0; border: 0;
  border-radius: var(--dd-radius-full);   /* true circles */
  cursor: pointer;
  transition: box-shadow var(--dd-dur) var(--dd-ease);
}
.welcome-accent[data-accent="blue"]   { background: var(--dd-blue);   color: var(--dd-blue); }
.welcome-accent[data-accent="teal"]   { background: var(--dd-teal);   color: var(--dd-teal); }
.welcome-accent[data-accent="green"]  { background: var(--dd-green);  color: var(--dd-green); }
.welcome-accent[data-accent="amber"]  { background: var(--dd-amber);  color: var(--dd-amber); }
.welcome-accent[data-accent="orange"] { background: var(--dd-orange); color: var(--dd-orange); }
.welcome-accent[data-accent="pink"]   { background: var(--dd-pink);   color: var(--dd-pink); }
.welcome-accent[data-accent="violet"] { background: var(--dd-violet); color: var(--dd-violet); }
/* Two flat spreads: a gap in the ground the swatches sit on, then the hue.
   No blur radius — nothing here glows.
   THE GAP IS PAINTED --dd-canvas, not --dd-panel, and that had to move with
   the composition pass: the form column is page-coloured now, so a panel-
   coloured gap would have stopped being a gap and started being a pale halo
   around every swatch — the exact "soft shadow behind a control" this pass
   went looking for. A ring that stands in for empty ground has to be the
   colour of the ground it stands in for. */
.welcome-accent:hover { box-shadow: 0 0 0 2px var(--dd-canvas), 0 0 0 3px var(--dd-gray-300); }
.welcome-accent.active { box-shadow: 0 0 0 2px var(--dd-canvas), 0 0 0 4px currentColor; }

.welcome-sub-head {
  margin: 22px 0 8px;
  font-size: var(--dd-fs-sm-); font-weight: 600;
}
/* The first group heading on a step opens straight under the lede, which
   already carries the air. */
.welcome-sub-head-first { margin-top: 0; }

/* One labelled group of chips (or of anything else that is not a field):
   .login-field owns a caption line it does not need, so a group gets this
   instead. */
.welcome-field { display: flex; flex-direction: column; gap: 7px; }
.welcome-field + .welcome-field { margin-top: 18px; }
.welcome-field .microlabel { padding-left: 1px; }

/* ---- step 5: invites ----
   The reference's own row: "+ Add more" on the left, "Copy invite link" as a
   quiet link-button on the right, the primary below. Ours says what this
   server actually does — it MINTS single-use codes and does not send mail, so
   the primary is "Create invites" and the link is a link you paste yourself.
   The second address is a disclosure (.auth-reveal) rather than a field that
   is simply there: one teammate is the common case, and "Add another" is the
   honest unlock for the second. */
/* No gap: each .login-field already ends in its own reserved caption line
   plus its margin, and a third space on top pulled two email rows a third of
   the column apart. */
.welcome-invite-fields { display: flex; flex-direction: column; }
/* flex-end plus an auto margin on the FIRST child, not space-between: "Add
   another" retires once it has been used, and space-between with one child
   left would have slid "Copy invite link" over to the left-hand end. */
.welcome-invite-row {
  display: flex; align-items: center; justify-content: flex-end; gap: 10px;
  margin-top: 4px;
}
.welcome-invite-row > :first-child { margin-right: auto; }
/* Both are quiet TEXT actions at the same tier — neither is the way this step
   is finished, and a bordered button on either would say otherwise. */
.welcome-linkbtn {
  display: inline-flex; align-items: center; gap: 7px;
  height: 30px; padding: 0 8px;
  margin-inline: -8px;
  border: 0; background: transparent;
  border-radius: var(--dd-radius-m);
  font: inherit; font-size: var(--dd-fs-sm-); font-weight: 550;
  color: var(--dd-ink-quiet);
  cursor: pointer;
  transition: background var(--dd-dur) var(--dd-ease), color var(--dd-dur) var(--dd-ease);
}
.welcome-linkbtn:hover { background: var(--dd-gray-100); color: var(--dd-ink); }
.welcome-linkbtn svg { width: 15px; height: 15px; flex: none; stroke-width: 1.75; }
.welcome-linkbtn[hidden] { display: none; }

/* ---- the action block every step ends with ----
   A COLUMN, not a row: the primary is the full width of the form column (the
   reference's proportion, and the one that makes a CTA read as the way
   forward rather than as one of two options), and the quiet way past an
   OPTIONAL step sits centred UNDER it as text. It used to share the row —
   `flex: 1` for the primary and a 44px text button beside it — which made
   Skip look like the other half of a pair. */
.welcome-actions {
  display: flex; flex-direction: column; align-items: stretch; gap: 2px;
  margin-top: 26px;
}
.welcome-actions .login-submit { width: 100%; margin-top: 0; }
.welcome-skip {
  align-self: center;
  height: 34px; padding: 0 12px;
  border: 0; background: transparent;
  border-radius: var(--dd-radius-m);
  font: inherit; font-size: var(--dd-fs-sm-); font-weight: 550;
  color: var(--dd-ink-quiet);
  cursor: pointer;
  transition: background var(--dd-dur) var(--dd-ease), color var(--dd-dur) var(--dd-ease);
}
.welcome-skip:hover { background: var(--dd-gray-100); color: var(--dd-ink); }

.welcome-note { margin: 10px 2px 0; font-size: var(--dd-fs-2xs); color: var(--dd-ink-quiet); }
.welcome-note.err { color: var(--dd-red); }
.welcome-note.ok { color: var(--dd-green); }

/* ============================================================
   Navigation feedback — skeletons + the top progress bar.
   This is a traditional multi-page suite (a click is a real
   navigation, not a client-side route swap), so the gap between
   click and next paint is real network+render time. app.js names
   the philosophy directly: no spinners — "a spinner says wait and
   nothing else, a shape says what for." Two pieces:
     1. .dd-nav-progress — fires the instant a link is clicked, so
        the click itself feels acknowledged.
     2. .dd-skel* — sized to the real component each stands in for,
        so its arrival doesn't reflow whatever loaded around it.
   ============================================================ */

/* ---------- skeletons ---------- */

/* The placeholder surface itself. Kept separate from .dd-skel-line so a later
   placeholder shape (an avatar, a thumbnail) gets the same tint + beat just by
   adding this class alongside its own sizing — radius-s matches the Shape
   foundation's "small chips/tags" step, which is the closest existing shape
   family to a placeholder bar.

   THE SWEEP IS GONE, and it is not coming back. What used to be here was a
   ::after pseudo painting a five-stop linear-gradient and an infinite
   translateX across it: a gradient AND a shimmer, the two things this system's
   design rules name outright as banned, sitting in the one stylesheet every
   page in the suite loads. It also cost the most where it was used most — the
   grid placeholder puts 49 of these on screen at once, and each one was a
   gradient rasterised every frame rather than an opacity the compositor lerps.

   What replaces it is the bar itself breathing: THIS element is the leaf, so
   there is nothing to clip and no second box to own. 1000ms because this is an
   ambient loop rather than chrome answering an action — none of the --dd-dur*
   tokens apply, they all time a reaction — and ease-in-out rather than
   --dd-ease because a pulse has no arrival to front-load; it should be slowest
   at both ends of its travel, which is what makes it read as breathing instead
   of blinking. .5 is as far down as it goes: at .35 the row reads as disabled
   content rather than as an outline of content still on its way.
   `position: relative` and `overflow: hidden` went with the pseudo — the first
   was a containing block for it and the second existed only to clip its
   sweep. */
.dd-skel {
  border-radius: var(--dd-radius-s);
  background: var(--dd-gray-100);  /* the resting tint — also IS the reduced-
                                       motion appearance, see below */
  animation: dd-skel-pulse 1000ms ease-in-out infinite;
}
@keyframes dd-skel-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .5; } }

/* One placeholder line of body text. --dd-fs-sm (body) rendered inside a
   single-line .row-list li computes to 22px: the li's own min-height (46px)
   minus its own top+bottom padding (12px + 12px, from ".row-list li{padding:
   12px var(--dd-pad-x)}"). That's the box a real line of body text already
   occupies in this codebase, so it's the box its placeholder needs too. */
.dd-skel-line {
  display: block;
  height: 22px;
}
.dd-skel-line.w30  { width: 30%; }
.dd-skel-line.w40  { width: 40%; }
.dd-skel-line.w60  { width: 60%; }
.dd-skel-line.w80  { width: 80%; }
.dd-skel-line.w100 { width: 100%; }

/* One placeholder list row. Copies ".row-list > li" (min-height: 46px) and
   ".row-list li" (padding: 12px var(--dd-pad-x); border-bottom: 1px solid
   var(--dd-hairline)) directly, so a row of real data dropped in its place
   costs the list no height. Its 1-2 lines stack with the same 2px gap
   ".row-sub" already uses as margin-top under a title line — the one gap
   this codebase uses between two adjacent lines of text. */
.dd-skel-row {
  display: flex; flex-direction: column; justify-content: center;
  gap: 2px;
  min-height: 46px;
  padding: 12px var(--dd-pad-x);
  border-bottom: 1px solid var(--dd-hairline);
}
.dd-skel-row:last-child { border-bottom: 0; }

/* Vertical container for either bare lines or whole rows. flex:1 mirrors
   ".row-list{flex:1}" so a placeholder fills a stretched panel the same way
   the list it precedes will, instead of the panel shrinking now and growing
   once data lands. Zero gap matches ".row-list" too (it sets none — adjacent
   rows touch and rely on their own border-bottom), which is exactly right
   when the stack holds .dd-skel-row (each already carries that border). When
   it instead holds bare lines (kind "lines" has no such border), give those
   the same 2px rhythm as above so they read as distinct lines and not one
   solid tint. */
.dd-skel-stack {
  display: flex; flex-direction: column;
  flex: 1;
  gap: 0;
}
.dd-skel-stack > .dd-skel-line:not(:last-child) { margin-bottom: 2px; }

/* Placeholder for a whole app pane: a header line, then rows. The header
   line stands in for ".pane-head" (min-height: 48px; padding: 6px 14px) —
   the strip a narrow app-pane column opens with. A bare line has no wrapper
   to pad the way pane-head pads a title, so the same box is rebuilt with
   margin instead: 14px keeps pane-head's own horizontal inset; 13px + 12px +
   the line's own 1px border-bottom add up, with the line's 22px (see
   .dd-skel-line above), to pane-head's exact 48px — so the line it replaces
   costs the rows beneath it no shift either. */
.dd-skel-pane {
  display: flex; flex-direction: column;
  height: 100%;
}
.dd-skel-pane > .dd-skel-line:first-child {
  flex: none;
  margin: 13px 14px 12px;
  border-bottom: 1px solid var(--dd-hairline);
}

/* ---------- top progress bar ---------- */

/* Fixed to the very top of the viewport, above every other fixed layer in
   this file — split screen's ghost/zones/preview/label sit at 6, 80-82 and
   90, and the toast stack (this file's previous highest) sits at 100; 200
   clears all of it with room left over, since a navigation can start while
   any of those are open. Driven by transform, not width: a scaleX transform
   never triggers layout on every tick the way animating width would. */
.dd-nav-progress {
  position: fixed;
  top: 0; left: 0;
  width: 100%; height: 2px;
  z-index: 200;
  background: var(--dd-ink);        /* the one brand accent this system has */
  transform-origin: left;
  transform: scaleX(0);              /* at rest: zero width, invisible */
  transition: transform 900ms var(--dd-ease);
  pointer-events: none;
}
/* .9, never 1: reaching 100% would claim the navigation is complete, which
   would be a lie the bar can't back up — the incoming document replaces it
   before it's ever true. --dd-ease is reused rather than a new curve: it is
   already front-loaded (cubic-bezier(.3,.7,.3,1) is most of the way to its
   end by 30% of the duration), which is what reads as "moves immediately,
   then slows" here without inventing a second easing for one component. */
body.dd-navigating .dd-nav-progress { transform: scaleX(.9); }

/* Redundant with the file's global `* { transition: none; animation: none }`
   kill switch above — stated here so
   the reduced-motion behaviour for this section reads as deliberate rather
   than incidental. With the transition off, .dd-navigating's scaleX(.9) is
   applied instantly instead of eased into ("the bar simply appears at a
   fixed width"). With the animation off, .dd-skel rests at the opacity it was
   declared with — 1 — so the placeholder is simply its own gray-100 fill, a
   flat tint, with no extra rule needed to produce it. (This used to name
   .dd-skel::after and the transform its sweep rested at; that pseudo-element
   and its gradient are gone, see the skeleton block above. The rule moved onto
   .dd-skel itself because the bar IS the animated leaf now.) */
@media (prefers-reduced-motion: reduce) {
  .dd-nav-progress { transition: none; }
  .dd-skel { animation: none; }
}

/* ---------- skeleton variants: one shape per real component ----------
   The base .dd-skel-row is modelled on `.row-list li`, which is right for the hub
   and wrong for all three apps — their panes are not built from that component.
   An audit of the first pass caught it: a placeholder whose proportions differ
   from the thing that replaces it reads as a reflow on arrival, which is the one
   job it had. Each variant below is measured from the rule it stands in for. */

/* Tasks — `.t-line`: min-height 44px, padding 12px, no divider, rounded hover.
   Kept in sync with tasks/app.css by hand — a placeholder whose proportions
   drift from the row it stands in for is exactly the "reflow on arrival"
   this file exists to prevent, so when that row's numbers move, these do too. */
.dd-skel-stack.compact .dd-skel-row {
  min-height: 44px;
  padding: 12px;
  border-bottom: 0;
}
.dd-skel-stack.compact .dd-skel-line { height: 15px; }

/* Mail — `.mail-list li.mail-row`: padding 9px 10px, a 1px reserved border and
   radius-m, rows separated by a gap instead of a hairline */
.dd-skel-stack.pills { gap: 2px; padding: 0 8px; }
.dd-skel-stack.pills .dd-skel-row {
  min-height: 0;
  padding: 9px 10px;
  border: 1px solid transparent;
  border-bottom: 0;
  border-radius: var(--dd-radius-m);
}

/* Calendar — a time grid has no rows, so columns are the only honest stand-in:
   a header strip over evenly divided day columns. */
/* flex: 1 and width: 100% are both load-bearing: this lands inside `.app-body`,
   which is a ROW flex container, so without them the placeholder shrink-wraps its
   own content — measured 36px wide with 0px columns. */
.dd-skel-grid {
  flex: 1; width: 100%; min-width: 0;
  display: flex; flex-direction: column;
  height: 100%; padding: 10px 12px; gap: 10px;
}
.dd-skel-grid > .dd-skel-line { height: 34px; }
.dd-skel-cols { flex: 1; display: flex; gap: 6px; min-height: 0; }
.dd-skel-col { flex: 1; height: 100%; border-radius: var(--dd-radius-s); }

/* ---------- page transitions: the swap, not softened ----------
   The cross-document crossfade lived here (::view-transition-old/new(root),
   riding the @view-transition opt-in). Both went with the sign-in cinematic
   (see "THERE AREN'T ANY" above): post-cutover the only document swaps left
   ARE the auth boundary, so the root fade was the sign-in/out animation by
   another name. A navigation now just paints. The keyframes stay — the
   skeleton exit and .dd-enter below reuse them for in-page work. */
@keyframes dd-fade-out {
  to { opacity: 0; }
}
@keyframes dd-fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

/* Content arriving inside a page fades on OPACITY ONLY — deliberately no blur.
   `filter` on a live element makes it a containing block for fixed and sticky
   descendants, which is exactly the jitter this section removes. */
@keyframes dd-content-in { from { opacity: 0; } to { opacity: 1; } }
.dd-enter { animation: dd-content-in 140ms var(--dd-ease) both; }

/* The placeholder itself arrives softly too, or the delay just moves the flash
   150ms later. */
.dd-skel-stack[data-skel], .dd-skel-pane[data-skel], .dd-skel-grid[data-skel] {
  animation: dd-content-in 120ms var(--dd-ease) both;
}

/* AND IT LEAVES. This is the other half of the swap, and without it the
   placeholder had an entrance and an exit that was a cut: the bars vanished in
   one frame and the real content started its 140ms ramp from nothing, so the
   container was at its emptiest exactly at the seam the fade exists to hide.

   TWO LEGS, NOT A CROSSFADE, and the argument is the one written out for
   .dd-swap higher up this file: the placeholder leaves over --dd-dur-pop and
   the content's own dd-content-in starts when it has finished, so the two are
   never both half-visible stacked on each other. ui/Skeleton.tsx holds the two
   phases apart — this class, then .dd-enter, on the SAME container the caller
   already had — which is why no element is inserted and no caller changes.

   Reusing dd-fade-out rather than declaring a mirror of dd-content-in: it is
   already an opacity-only "to 0" in this file, and a second keyframe that did
   the identical thing is how two numbers for one idea start.

   IT COSTS THE WARM LOAD NOTHING. A placeholder only exists at all on a load
   that already ran past SKEL_DELAY, so the 90ms this adds is never paid by the
   fast path — which is the whole objection the skeleton primitive's own header
   raises against fading things that did not need it. */
.dd-skel-out { animation: dd-fade-out var(--dd-dur-pop) var(--dd-ease) both; }

/* The bar fades in, and only after the delay in shared/app.js has elapsed, so a
   fast navigation never paints it at all. */
.dd-nav-progress { opacity: 0; transition: opacity 120ms var(--dd-ease), transform .9s var(--dd-ease); }
body.dd-navigating .dd-nav-progress { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  /* .dd-skel-out is listed for completeness only: ui/Skeleton.tsx does not
     enter that phase at all when the reader has asked for less motion, because
     a JS-held phase with its animation switched off is a DELAYED hard cut,
     which is strictly worse than the undelayed one it replaced. */
  .dd-enter, .dd-skel-out,
  .dd-skel-stack[data-skel], .dd-skel-pane[data-skel], .dd-skel-grid[data-skel] { animation: none; }
}

/* ============================================================
   Mobile shell — the rail and the subbar become drawers (≤720px)
   ============================================================
   Measured on a 390×844 phone before this pass: rail (64px collapsed) +
   subbar (184px, already forced down by the 900px rule above) left Tasks a
   94px work surface, Mail's message list 94px and its reader 0 (invisible),
   Calendar 0.4 of a day column visible, and 33 tap targets under 36px on
   Tasks alone. The cause is singular — two sidebars cannot coexist with
   content at this width — so below it neither sidebar spends flow space:
   both become fixed-position overlay drawers, opened by body.nav-open /
   body.subbar-open (shared/app.js) and off-canvas by default. Same
   breakpoint the split-screen fold two sections up already uses, so a phone
   never meets a second "narrow" threshold with a different number.

   z-index ladder already in this file, lowest to highest: .subbar-resizer 5,
   the tight-pane subbar drawer and .split-div 6, .chart-tip 10, the ghost
   pane's drop overlay 2 (inside its own pane, not this ladder — the drag no
   longer stacks anything over the page, it displaces the page instead),
   .split-ghost 90, .toast-stack 100, .dd-nav-progress
   200 ("above every other fixed layer in this file", by that rule's own
   comment). A drawer is a navigation overlay, not a toast and not the nav
   bar: it belongs ABOVE the split-drag layer (moot here — split has already
   folded to one pane at this width, above) and BELOW the toast stack, so a
   toast confirming an action still reads over an open drawer, and BELOW the
   nav bar, which must outrank everything. That puts the scrim and the two
   drawers at 95–97 — clear of 90 with room, clear under 100. */
@media (max-width: 720px) {

  /* ---- 0. no top bar ----
     The drawer's opener and each screen's own header carry the page identity
     on a phone; a 41px name-plate would be pure cost. Zeroing the token is
     what keeps every height that subtracts it honest. */
  :root { --dd-topbar-h: 0px; }
  .topbar { display: none; }
  /* The head's desktop height rides that token — at 0px it would clip the
     switcher to nothing, so the drawer goes back to padding-driven sizing. */
  .side-head { height: auto; padding: 2px 0 12px; margin: 0; border-bottom: 0; }

  /* ---- 1. the rail becomes a drawer ----
     Full height and flush to the edge, because that is its RESTING shape
     today — .sidebar is chrome (no radius, no gap), not an inset card —
     repositioning it must not hand it a new shape. Width is the EXPANDED
     --dd-sidebar-w (232px), not the 64px collapsed rail: a drawer covering
     the canvas has room a permanent rail never did, and a 64px drawer would
     just reproduce the tiny-tap-target rail this pass exists to fix. */
  .sidebar {
    position: fixed;
    top: 0; left: 0; bottom: 0;
    height: 100vh; height: 100dvh;
    width: var(--dd-sidebar-w);
    z-index: 97;
    padding-top: calc(20px + env(safe-area-inset-top));
    padding-bottom: calc(16px + env(safe-area-inset-bottom));
    padding-left: calc(12px + env(safe-area-inset-left));
    padding-right: 12px;
    /* No drop under it: the rail's own border-right (base rule) is the edge
       between the drawer and the page, and .dd-scrim below is what says the
       page behind it is not the thing you are touching. */
    transform: translateX(-100%);
    /* Only the slide is animated here — width and padding never change at
       this breakpoint (see the rail-collapsed override right below), so the
       desktop rule's width/padding transition would be dead weight. */
    transition: transform var(--dd-dur-slow) var(--dd-ease);
  }
  body.nav-open .sidebar { transform: translateX(0); }

  /* :root.rail-collapsed is the DESKTOP preference, and shared/app.js also
     defaults it ON for any window ≤900px — so a phone can carry it before a
     single tap happens. Left alone, ":root.rail-collapsed .sidebar" a few
     hundred lines up (width: 64px, hidden labels, re-centred icons) would
     collapse this very drawer to icons-only the moment it slides in — the
     opposite of "a drawer has room." Every property that block and its
     siblings touch is restated here, at the SAME selectors — so equal
     specificity, later source wins — back to the values the expanded rail
     already uses elsewhere in this file. Nothing new invented; just turned
     back on for as long as the rail is a drawer. */
  :root.rail-collapsed .sidebar {
    width: var(--dd-sidebar-w);
    padding-top: calc(20px + env(safe-area-inset-top));
    padding-bottom: calc(16px + env(safe-area-inset-bottom));
    padding-left: calc(12px + env(safe-area-inset-left));
    padding-right: 12px;
  }
  :root.rail-collapsed .sidebar .side-nav a > span:not(.avatar),
  :root.rail-collapsed .sidebar .side-nav button > span:not(.avatar) { max-width: 180px; opacity: 1; transform: none; }
  :root.rail-collapsed .sidebar .side-search { max-height: 60px; opacity: 1; padding-top: 0; padding-bottom: 12px; }
  :root.rail-collapsed .sidebar .side-label { max-height: 60px; opacity: 1; padding-top: 6px; padding-bottom: 4px; }
  :root.rail-collapsed .sidebar .side-nav a,
  :root.rail-collapsed .sidebar .side-nav button { gap: 10px; padding: 6px 8px; }
  :root.rail-collapsed .sidebar .side-head { justify-content: flex-start; height: auto; padding: 2px 0 12px; margin: 0; border-bottom: 0; }
  /* opacity and the switcher's box are restated alongside `display` for the
     same reason every other property in this block is: the desktop collapse
     now FADES these three out (opacity: 0) and shrinks the switcher's box on
     the way, so restoring display alone would open a drawer with three
     invisible sections in it and a zero-width workspace switcher. */
  :root.rail-collapsed .sidebar .ws-switch {
    display: flex;
    opacity: 1;
    max-width: 100%;
    padding-left: 6px; padding-right: 6px;
  }
  :root.rail-collapsed .sidebar .side-projects { display: block; opacity: 1; }
  :root.rail-collapsed .sidebar .side-chats { display: block; opacity: 1; }
  /* The drawer is the EXPANDED rail, so its collapse glyph must point the way
     the expanded one does — the desktop mirror is keyed on the same class a
     phone can be carrying before a single tap happens. */
  :root.rail-collapsed .sidebar .side-collapse svg { transform: none; }

  /* ---- 2. the subbar becomes a drawer, same principle ----
     Unlike the rail, an app's subbar is ALREADY an inset card everywhere
     else in this file (border + --dd-radius-l + its own --dd-app-gap of
     air), so its drawer form keeps that shape — floating the same
     --dd-app-gap off the top, bottom and (once open) the left edge, rather
     than snapping flush the way the rail does. That is also what keeps
     content behind it visible: max-width holds a rail's worth of canvas
     open beside it, so it is never fully covered. */

  /* Two rules narrow --dd-subbar-w to 184px above — the 900px viewport one and
     the [data-pane] one for a narrow split pane — and both narrow it for the
     same reason: a subbar that must SHARE its pane cannot afford more. Down
     here it shares nothing; it is a drawer floating over the work surface, so
     it takes the design default back. Exactly the argument the rail's drawer
     makes one section up ("a drawer covering the canvas has room a permanent
     rail never did"). At the inherited 184px, five of Tasks' own project rows
     wrapped to two and three lines (and, once they were taught not to wrap,
     four of them ellipsised instead): the longest real row needs 232px of
     subbar to sit on one line whole, and 240px is the width this system
     already calls a subbar.
     Both narrowing selectors are restated, because [data-pane] outranks a bare
     :root and a split that folded at this width leaves that attribute set. */
  :root,
  :root[data-pane="narrow"],
  :root[data-pane-list="over"] { --dd-subbar-w: 240px; }

  .subbar {
    position: fixed;
    top: calc(var(--dd-app-gap) + env(safe-area-inset-top));
    bottom: calc(var(--dd-app-gap) + env(safe-area-inset-bottom));
    left: var(--dd-app-gap);
    height: auto;
    width: var(--dd-subbar-w);
    /* 64px: this file's own definition of "still a rail's worth of canvas
       showing" (the collapsed .sidebar's width) — reused as the floor of
       space a subbar drawer must always leave visible, even on a phone
       narrower than the ones this was measured on. */
    max-width: calc(100vw - 64px);
    z-index: 96;
    /* Same as the rail drawer above: the subbar is already a bordered card
       everywhere else in this file, and that border is now the whole of its
       separation from the work surface it floats over. */
    opacity: 1;
    /* Closed: pushed left by its own full width PLUS the gap it opens at,
       so it lands exactly off-canvas (its right edge at x:0) instead of
       leaving a sliver showing or overshooting further than it needs to. */
    transform: translateX(calc(-100% - var(--dd-app-gap)));
    transition: transform var(--dd-dur-slow) var(--dd-ease);
  }
  body.subbar-open .subbar { transform: translateX(0); }

  /* Same reasoning as the rail: body.subbar-collapsed (the persisted "I hid
     this" mode) and :root[data-pane="tight"] (split's forced collapse —
     moot here since split has already folded above, restated for belt) both
     zero the subbar's width/padding/border/opacity. Restated back on so
     opening the drawer works regardless of which of those happens to be
     set; transform is untouched by either rule, so only the SHAPE needed
     restoring, never the open/closed state itself. */
  body.subbar-collapsed .subbar,
  :root[data-pane-list="over"] .subbar {
    width: var(--dd-subbar-w);
    max-width: calc(100vw - 64px);
    padding: 12px 10px 14px;
    border-width: 1px;
    opacity: 1;
  }
  /* No drag on a phone, and with the subbar out of flow the resizer would
     otherwise sit in .app's flex row as a dead --dd-app-gap of nothing. */
  .subbar-resizer { display: none; }

  /* ---- 3. the scrim ----
     One dim behind whichever drawer is open (shared/app.js creates the
     element; this is its only styling). Sits under both drawers (95 < 96,
     97) and fades instead of snapping.
     ON THE DRAWER'S CLOCK, not the chrome one: both panels slide on
     --dd-dur-slow, and at --dd-dur the dim finished 60ms early — opening, the
     page went dark and the drawer then arrived late; closing, the dim cleared
     while the panel was still travelling over undimmed content. Same duration
     makes the two halves one event. */
  .dd-scrim {
    position: fixed; inset: 0;
    z-index: 95;
    background: var(--dd-scrim);
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--dd-dur-slow) var(--dd-ease);
  }
  body.nav-open .dd-scrim,
  body.subbar-open .dd-scrim { opacity: 1; pointer-events: auto; }

  /* ---- 4. the work surface gets the whole width ----
     Both sidebars are position:fixed now, so they already left the .app
     flex row on their own — .app-content grows into the space for free.
     What is left is tightening the air around it: --dd-app-gap (16px) is
     this system's one padding/gap value for an app's panes, so mobile
     halves it THROUGH the token rather than a new literal — keeping the
     inset-card look (border and radius untouched) while reading closer to
     full-bleed, per "tighter, not gone." */
  .app { padding: calc(var(--dd-app-gap) / 2); gap: calc(var(--dd-app-gap) / 2); }

  /* ...but an embedded pane is not a phone. :root.embed only ever happens
     inside a split, and a split has already folded to one pane by this width
     (top of this file) — so a ?embed=1 document narrower than 720px is always
     a PANE in a window wide enough to hold two, sitting beside a host that is
     still spending the full gap. Halved here, the guest card sat 8px off the
     window edge against the host's 16, and 8px off the top and bottom against
     the host's 16: the two panes of one split stopped lining up. The seam
     edge is halved separately, above, and still is. */
  :root.embed .app { padding: var(--dd-app-gap); gap: var(--dd-app-gap); }

  /* ---- 5. the way in ----
     shared/app.js injects [data-rail-open] into .app-toolbar / .view-header
     on mobile only — with both sidebars off-canvas it is the ONLY way back
     to either. Same look as the .rail-btn / .subbar-toggle already living
     beside it in those rows (28px, 16px icon), just sized to the 40px floor
     every mobile tap target gets below, instead of resting at 28px. */
  /* AUDIT FIX: this rule used to set `background: transparent; color: quiet-ink`,
     and because it sits later in the file than `.brand-mark` at equal specificity
     it WON — so the button the code calls a brand mark rendered as a bare grey
     "D", indistinguishable from the hamburger beside it. That similarity was the
     one thing the injection was designed to avoid. It now keeps the mark's own
     ink, and only the geometry is stated here. */
  [data-rail-open] {
    display: inline-grid; place-items: center;
    flex: none;
    width: 40px; height: 40px;
    border: 0;
    cursor: pointer;
    /* the mark's solid fill, restated so source order cannot undo it */
    background: var(--dd-accent);
    color: var(--dd-on-accent);
    border-radius: var(--dd-radius-m);
    font-weight: 700;
    font-size: var(--dd-fs-sm);
    letter-spacing: -.02em;
    transition: opacity var(--dd-dur) var(--dd-ease);
  }
  /* The same specificity leak the AUDIT FIX above closed for the resting
     state was still open for hover: `.rail-btn:hover` (two classes) outranks
     the bare `[data-rail-open]` attribute selector (one), so touching the
     button swapped its solid mark for the rail-button grey wash — it got
     LESS visible at the exact moment of use. Restated at a specificity that
     holds, and darkened rather than greyed. */
  [data-rail-open]:hover,
  [data-rail-open]:focus-visible {
    background: var(--dd-accent);
    color: var(--dd-on-accent);
    opacity: .88;
  }
  [data-rail-open]:active { opacity: .82; }

  [data-rail-open] svg { width: 16px; height: 16px; stroke-width: 1.75; }

  /* A closed drawer is the third "the list is hidden" state — same treatment
     as the two up in the desktop section, restated here because the class
     that names this state (body.subbar-open) only exists at this width. */
  body:not(.subbar-open) .subbar-toggle {
    background: var(--dd-panel);
    border: 1px solid var(--dd-border);
    color: var(--dd-ink);
  }

  /* ---- 6. the desktop-only placeholder ----
     One centred card for a view that genuinely has no mobile form yet.
     Panel / border / radius language copied from .login-card — this
     system's only other example of a single quiet card sitting alone on the
     canvas — which since the shadow strip is the WHOLE of that card's
     anatomy: the two are now the same three declarations, and the note that
     used to sit here explaining why this one skipped the shadow describes a
     difference that no longer exists. margin:auto centres it
     on BOTH axes inside the flex panes it is meant for (.app-content,
     .app-body, .app-pane are all display:flex, and an auto-margined flex
     item centres on every axis it has free space in) and falls back to
     plain horizontal centring in a non-flex container. */
  .dd-desktop-only {
    margin: auto;
    max-width: 300px;
    padding: 28px 26px 26px;
    display: flex; flex-direction: column; align-items: center;
    text-align: center;
    background: var(--dd-panel);
    border: 1px solid var(--dd-border);
    border-radius: var(--dd-radius-l);
    color: var(--dd-ink);
  }
  .dd-desktop-only svg {
    width: 30px; height: 30px;
    stroke-width: 1.5;
    color: var(--dd-ink-quiet);
  }
  .dd-desktop-only h2 {
    margin: 12px 0 0;
    font-size: var(--dd-fs-lg);
    font-weight: 620;
    letter-spacing: -.015em;
  }
  .dd-desktop-only p {
    margin: 6px 0 0;
    font-size: var(--dd-fs-sm-);
    color: var(--dd-ink-quiet);
    line-height: 1.5;
  }
  .dd-coming {
    display: inline-flex; align-items: center;
    margin-top: 14px;
    height: 22px; padding: 0 10px;
    border-radius: var(--dd-radius-s);
    background: var(--dd-gray-100);
    color: var(--dd-ink-quiet);
    font-size: var(--dd-fs-2xs);
    font-weight: 550;
    text-transform: uppercase;
    letter-spacing: .04em;
  }

  /* ---- 7. tap targets: 40px is the floor for anything a finger hits ----
     min-height/min-width, not height/width — a floor added on top of
     whatever a component already sets (.btn-sm's 28px, .rail-btn's 28px
     square) rather than a value that has to out-specificity them: CSS
     already resolves a smaller `height` against a larger `min-height` in
     the min-height's favour, so this reaches every existing size variant —
     .btn-sm, .btn-primary, .app-actions .btn — without restating each one. */
  .side-nav a, .side-nav button,
  .subbar-nav > a, .subbar-nav > button,
  .subbar-nav > * > a, .subbar-nav > * > button {
    min-height: 40px;
  }
  .btn { min-height: 40px; }
  .btn-icon, .rail-btn {
    min-width: 40px;
    min-height: 40px;
  }
  /* ...but NOT in an embedded pane. This floor exists for fingers, and
     :root.embed only ever happens inside a split view — which only exists
     above 720px, on a pointer device. Applied there it sized the guest's
     toolbar buttons to 40px while the live pane beside it, keying off the real
     (wide) viewport, kept them at 28px: the same control, two sizes, side by
     side. Same asymmetry as the pane-width band and the app padding, and the
     same answer — a pane is not a phone. */
  :root.embed .btn { min-height: 0; }
  :root.embed .btn-icon, :root.embed .rail-btn { min-width: 0; min-height: 0; }
  :root.embed .side-nav a, :root.embed .side-nav button,
  :root.embed .subbar-nav > a, :root.embed .subbar-nav > button,
  :root.embed .subbar-nav > * > a, :root.embed .subbar-nav > * > button { min-height: 0; }

  /* ---- 8. safe area ----
     Every OTHER fixed element that touches an edge at this breakpoint is
     covered above at the point it is declared (.sidebar's top/bottom/left
     padding, .subbar's top/bottom inset) — env() is threaded into each one
     there rather than collected here, so it sits beside the number it is
     adjusting rather than in a block that has to be cross-referenced. */
}

/* ---------- mobile: three fixes an audit of the first pass caught ---------- */
@media (max-width: 720px) {
  /* 1. HIGH — an open drawer did not lock the page behind it. Invisible on the
     three app pages, because body[data-app] main already sets overflow:hidden, but
     the hub, finances and apps pages scroll normally: the dimmed content slid
     under the fixed drawer while it was open. Reproduced at scrollY 500 -> 700
     with the rail open. */
  body.nav-open, body.subbar-open { overflow: hidden; }

  /* 2. MEDIUM — the rail's own collapse row is meaningless inside a drawer: this
     breakpoint forces the drawer to full width, so tapping it flipped the label
     and the chevron and changed nothing on screen. A control that cannot act is
     worse than an absent one, so it is absent. The drawer closes by tapping the
     scrim, pressing Escape, or following a link — all three already wired. */
  .sidebar .rail-collapse { display: none; }
}

/* ---------- mobile-only chrome, made inert everywhere else ----------
   Both of these are created/injected by shared/app.js on mobile, and both survived
   a viewport that crossed the breakpoint: after resizing a phone-width window up to
   1440px the injected "D" opener was still sitting in the toolbar (display: grid),
   and the scrim stayed in the DOM as a zero-height transparent div. Neither is
   reachable on a real desktop load, but a resize should not leave debris. Doing it
   in CSS rather than in a resize handler means there is no timing to get wrong. */
@media (min-width: 721px) {
  [data-rail-open] { display: none; }
  .dd-scrim { display: none; pointer-events: none; }
}


/* ============================================================
   THE CHAT PRIMITIVES — app/src/ui/chat
   ============================================================

   One region for everything the assistant's transcript is made of: the two
   shared mechanisms, the streaming text, the markdown rhythm, and the five
   pieces of transcript furniture. The components in that folder carry no
   decorative inline styles at all — every number about how these look is
   here, which is the same bargain .dd-glide and .dd-swap already made.

   THREE PREFIXES, and they mean different things:

     .dd-fold / .dd-reveal   the mechanisms. Not chat-specific; anything in
                             the app may open a panel or reveal a control with
                             them.
     .dd-md-                 an answer's typography.
     .dd-ai-                 the furniture: loader, trace, tool row, approval
                             card, context cards, action bar.
     .dd-pill-               the selection actions bar (design 20).

   THE BLUR RULE, and it is the one thing in this region that is easy to get
   wrong twice. This file's blanket reduced-motion rule stops `transition` and
   `animation`. A static `filter: blur()` is neither, so it SURVIVES the
   blanket completely — a reader who asked for less motion would be left with
   permanently soft text, which is strictly worse than the motion they turned
   off. Every blur below therefore lives inside
   `@media (prefers-reduced-motion: no-preference)` and simply does not exist
   otherwise. Blur here means one thing only, "not settled yet", and its
   ceiling is 1.6px.
*/

/* The system mono stack, same one the home module declares for its code
   panels. Not a new face: it is whatever the platform already ships. */
:root {
  --dd-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
}

/* ---------- the fold ----------
   A grid track animating between a fraction and zero. That is the whole
   mechanism: no measurement, no max-height guessed high enough to be wrong for
   the one long answer, no library. The inner clip is not optional — 0fr only
   hides what overflow already refuses to paint.

   --dd-dur-fold is this file's existing number for a row's own reveal, and it
   is what the reference's 300-400ms becomes in our ladder. */
.dd-fold {
  display: grid;
  grid-template-rows: 0fr;
  opacity: 0;
  transition: grid-template-rows var(--dd-dur-fold) var(--dd-ease-glide),
              opacity var(--dd-dur-fold) var(--dd-ease-glide);
}
.dd-fold[data-open="true"] { grid-template-rows: 1fr; opacity: 1; }
/* min-height:0 as well as overflow:hidden — inside another grid track a child's
   automatic minimum size is its content, and a track that cannot go below its
   content cannot go to zero. */
.dd-fold-body { overflow: hidden; min-height: 0; }

/* ---------- the reveal ----------
   Opacity and pointer-events, and the element never leaves the tree. An action
   bar that MOUNTS when a stream ends pushes everything below it down at exactly
   the moment the reader reaches the last sentence; this one has been holding
   its line since the first token. */
.dd-reveal {
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--dd-dur-slow) var(--dd-ease-glide);
}
.dd-reveal[data-on="true"] { opacity: 1; pointer-events: auto; }

/* ---------- streaming text ----------
   The trailing few words are soft and fade off to the right, so the sentence
   resolves rather than pops. Two declarations do it: a blur, and a horizontal
   mask that drops the alpha past the first fifth. */
.dd-stream { display: inline; }
.dd-stream-tail { opacity: 0; transition: opacity var(--dd-dur) var(--dd-ease); }
.dd-stream-tail[data-on="true"] { opacity: 1; }
@media (prefers-reduced-motion: no-preference) {
  .dd-stream-tail[data-on="true"] {
    filter: blur(1.6px);
    -webkit-mask-image: linear-gradient(90deg, #000 20%, #0003);
    mask-image: linear-gradient(90deg, #000 20%, #0003);
  }
}
/* The caret is state, not decoration: SOLID means tokens are landing, BLINKING
   means the connection is open and nothing is coming down it. A reader learns
   the model stalled from the mark they are already watching. */
.dd-stream-caret {
  display: inline-block;
  width: 2px; height: 1.05em;
  margin-left: 2px;
  translate: 0 2px;
  border-radius: 1px;
  background: var(--dd-ink);
  opacity: 0;
}
.dd-stream-caret[data-on="true"] { opacity: 1; }
.dd-stream-caret[data-on="true"][data-flowing="false"] { animation: dd-caret-blink 1s step-end infinite; }
@keyframes dd-caret-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }

/* ---------- markdown ----------
   THE SPACING IS THE POINT. David asked for "proper formatting and spacing
   too, like between bullet points", and the answer is a rhythm rather than a
   margin: 10px between blocks, 6px between list items, 4px inside a nested
   list, and headings that take more air above them than below so they read as
   belonging to what follows. */
.dd-md {
  font-size: var(--dd-fs-sm-);
  line-height: 1.62;
  color: var(--dd-ink-light);
  overflow-wrap: anywhere;
}
.dd-md > * + * { margin-top: 12px; }
.dd-md-p { margin: 0; }
/* THE ONE PHRASE THAT MATTERS reads at full ink — bold that stays the body
   colour is emphasis nobody can see. */
.dd-md strong { color: var(--dd-ink); font-weight: 600; }
.dd-md-list { margin: 0; padding-left: 22px; }
/* A list gets more air than two paragraphs do: it is a change of texture, and
   the eye needs the seam. The intro line above it stays closest ("…:" reads
   as belonging to its bullets) — 12px like any block — but the list's own
   items breathe at 8px and what follows the list steps back with 14px. */
.dd-md > * + .dd-md-list { margin-top: 12px; }
.dd-md > .dd-md-list + * { margin-top: 14px; }
.dd-md-list li { padding-left: 3px; }
.dd-md-list li + li { margin-top: 8px; }
/* A nested list sits closer to its parent item than two siblings do — it is
   part of the item above it, not a peer of it. */
.dd-md-list .dd-md-list { margin-top: 6px; }
.dd-md-list .dd-md-list li + li { margin-top: 5px; }
.dd-md-list li > * + * { margin-top: 6px; }
.dd-md-list li::marker { color: var(--dd-ink-quiet); }
ol.dd-md-list li::marker { font-variant-numeric: tabular-nums; font-size: var(--dd-fs-xs); }
.dd-md-h {
  margin: 0;
  color: var(--dd-ink);
  font-weight: 600;
  line-height: 1.35;
  font-size: var(--dd-fs-sm);
}
.dd-md-h[data-level="3"], .dd-md-h[data-level="4"],
.dd-md-h[data-level="5"], .dd-md-h[data-level="6"] { font-size: var(--dd-fs-sm-); }
/* More air above than below: a heading belongs to what comes after it. */
.dd-md > .dd-md-h + * { margin-top: 6px; }
.dd-md > * + .dd-md-h { margin-top: 18px; }
.dd-md-quote {
  margin: 0;
  padding: 2px 0 2px 12px;
  border-left: 2px solid var(--dd-gray-200);
  color: var(--dd-ink-quiet);
}
.dd-md-rule { margin: 0; border: 0; border-top: 1px solid var(--dd-hairline); }

/* ---- the table (2026-08-26) ----
   The one construct allowed to scroll sideways, inside its own container —
   the transcript page itself must never grow a horizontal scrollbar. Quiet
   hairlines, a slightly inked header, no zebra: data furniture, not a poster. */
.dd-md-tablewrap { overflow-x: auto; max-width: 100%; }
.dd-md-table {
  border-collapse: collapse;
  font-size: var(--dd-fs-xs);
  line-height: 1.5;
  min-width: max-content;
}
.dd-md-table th, .dd-md-table td {
  text-align: left;
  vertical-align: top;
  padding: 6px 14px 6px 0;
  border-bottom: 1px solid var(--dd-hairline);
  max-width: 340px;
}
.dd-md-table th {
  font-weight: 600;
  color: var(--dd-ink-light);
  font-size: var(--dd-fs-2xs);
  letter-spacing: 0.01em;
}
.dd-md-table tbody tr:last-child td { border-bottom: 0; }

/* The highlighter pen — ==one number the user must not miss==. An accent wash,
   never a yellow marker: it has to survive both schemes. */
.dd-md-mark {
  background: color-mix(in srgb, var(--dd-accent) 16%, transparent);
  color: inherit;
  border-radius: 3px;
  padding: 0 3px;
}
.dd-md del { color: var(--dd-ink-quiet); }
.dd-md-link { color: var(--dd-accent); text-decoration: none; }
.dd-md-link:hover { text-decoration: underline; }
.dd-md-code {
  font-family: var(--dd-mono);
  font-size: .92em;
  padding: 1px 4px;
  border-radius: var(--dd-radius-s);
  background: var(--dd-gray-100);
  color: var(--dd-ink);
}

/* ---------- the code panel (design 18) ---------- */
.dd-md-panel {
  overflow: hidden;
  border: 1px solid var(--dd-border);
  border-radius: var(--dd-radius-l);
  background: var(--dd-panel);
}
.dd-md-panel-head {
  display: flex; align-items: center; gap: 8px;
  padding: 6px 8px 6px 12px;
  border-bottom: 1px solid var(--dd-hairline);
}
.dd-md-panel-lang {
  font-family: var(--dd-mono);
  font-size: var(--dd-fs-2xs);
  color: var(--dd-ink-quiet);
  letter-spacing: .02em;
}
.dd-md-panel-copy {
  margin-left: auto;
  padding: 3px 7px;
  border: 0; background: transparent; cursor: pointer;
  border-radius: var(--dd-radius-s);
  font: inherit; font-size: var(--dd-fs-2xs); font-weight: 500;
  color: var(--dd-ink-quiet);
  transition: background var(--dd-dur) var(--dd-ease), color var(--dd-dur) var(--dd-ease);
}
.dd-md-panel-copy:hover { background: var(--dd-gray-100); color: var(--dd-ink); }
/* Confirmed. The word changes inside a .dd-swap, so the button's box was sized
   to "Copied" from the first paint and nothing moves when it lands. */
.dd-md-panel-copy[data-done="true"] { color: var(--dd-green); }
.dd-md-panel-body {
  margin: 0;
  padding: 10px 0;
  overflow: hidden;
  font-family: var(--dd-mono);
  font-size: var(--dd-fs-xs);
  line-height: 1.65;
  color: var(--dd-ink-light);
  /* ONE continuous rule down the gutter rather than a border per row, so it
     does not break where a row is tinted. */
  background-image: linear-gradient(var(--dd-hairline), var(--dd-hairline));
  background-size: 1px 100%;
  background-position: 32px 0;
  background-repeat: no-repeat;
}
.dd-md-panel-body code { display: block; }
/* A long line WRAPS under the code column instead of scrolling: a horizontal
   scrollbar inside a transcript is a thing nobody finds. minmax(0,1fr) is what
   allows the second column to be narrower than its content. */
.dd-md-line { display: grid; grid-template-columns: 20px minmax(0, 1fr); align-items: start; gap: 12px; padding: 0 12px; }
.dd-md-num {
  text-align: right;
  font-size: var(--dd-fs-2xs);
  color: var(--dd-ink-quiet);
  opacity: .7;
  /* Selecting the code must not drag a column of digits along with it. */
  user-select: none;
}
.dd-md-src { white-space: pre-wrap; overflow-wrap: anywhere; }
/* Three roles and no theme: literals, keywords, call sites. At this size more
   colour is noise. */
.dd-md-lit { color: var(--dd-orange); }
.dd-md-key { color: var(--dd-accent); }
.dd-md-call { color: var(--dd-ink); font-weight: 500; }

/* ---------- shared marks ---------- */
/* The 12px ring with one lit quarter, on the existing dd-spin keyframe. */
.dd-ai-spin {
  display: inline-block;
  width: 12px; height: 12px; flex: none;
  border: 1.5px solid var(--dd-gray-300);
  border-top-color: var(--dd-ink-quiet);
  border-radius: var(--dd-radius-full);
  animation: dd-spin 700ms linear infinite;
}
/* The generic count treatment: an inset pill with a hairline, tabular so it
   cannot jitter when the number changes. */
.dd-ai-count {
  display: inline-flex; align-items: center;
  height: 18px; padding: 0 6px;
  border: 1px solid var(--dd-hairline);
  border-radius: var(--dd-radius-m);
  background: var(--dd-gray-50);
  font-size: var(--dd-fs-2xs); font-weight: 500;
  color: var(--dd-ink-quiet);
  font-variant-numeric: tabular-nums;
  flex: none;
}
/* One chevron rule for every disclosure in this region. */
.dd-ai-chev svg, .dd-ai-tool-mark svg, .dd-ai-trace-mark svg {
  width: 14px; height: 14px; stroke-width: 2;
  stroke-linecap: round; stroke-linejoin: round;
}
.dd-ai-chev { display: grid; place-items: center; color: var(--dd-ink-quiet); transition: transform var(--dd-dur-slow) var(--dd-ease-glide); }
/* Scoped to the trace's own header rather than written as a bare
   [aria-expanded="true"] descendant rule: the tool row is a disclosure too and
   its chevron rotates from -90deg to 0, not to 180. */
.dd-ai-think-head[aria-expanded="true"] .dd-ai-chev { transform: rotate(180deg); }

/* ---------- 01 · the loader ----------
   Nine cells, ONE keyframe, and the wave is entirely a per-cell delay. No
   JavaScript touches this. The cycle is deliberately shorter than the sweep, so
   two wavefronts are always in flight — that is what makes it read as
   continuous rather than as a loop restarting. */
.dd-ai-load { display: flex; align-items: center; gap: 10px; width: fit-content; }
.dd-ai-pixels { display: grid; grid-template-columns: repeat(3, 4px); gap: 1.5px; }
.dd-ai-pixels i {
  width: 4px; height: 4px;
  border-radius: 1px;
  background: var(--dd-ink);
  opacity: .15;
  animation: dd-ai-pixel 650ms ease-in-out infinite;
}
@keyframes dd-ai-pixel { 0%, 100% { opacity: .15; } 18%, 42% { opacity: 1; } 62% { opacity: .15; } }
/* chevron: delay = (column + |row - 1|) * 90ms — a V-shaped front travelling
   left to right. */
.dd-ai-load[data-pattern="chevron"] i:nth-child(1) { animation-delay: 90ms; }
.dd-ai-load[data-pattern="chevron"] i:nth-child(2) { animation-delay: 180ms; }
.dd-ai-load[data-pattern="chevron"] i:nth-child(3) { animation-delay: 270ms; }
.dd-ai-load[data-pattern="chevron"] i:nth-child(4) { animation-delay: 0ms; }
.dd-ai-load[data-pattern="chevron"] i:nth-child(5) { animation-delay: 90ms; }
.dd-ai-load[data-pattern="chevron"] i:nth-child(6) { animation-delay: 180ms; }
.dd-ai-load[data-pattern="chevron"] i:nth-child(7) { animation-delay: 90ms; }
.dd-ai-load[data-pattern="chevron"] i:nth-child(8) { animation-delay: 180ms; }
.dd-ai-load[data-pattern="chevron"] i:nth-child(9) { animation-delay: 270ms; }
/* orbit: the eight perimeter cells in ring order at 110ms apart; the centre is
   inert and sits dimmer than the rest so it reads as excluded rather than as a
   cell that failed to light. */
.dd-ai-load[data-pattern="orbit"] { --dd-ai-orbit: 950ms; }
.dd-ai-load[data-pattern="orbit"] i { animation-duration: var(--dd-ai-orbit); }
.dd-ai-load[data-pattern="orbit"] i:nth-child(1) { animation-delay: 0ms; }
.dd-ai-load[data-pattern="orbit"] i:nth-child(2) { animation-delay: 110ms; }
.dd-ai-load[data-pattern="orbit"] i:nth-child(3) { animation-delay: 220ms; }
.dd-ai-load[data-pattern="orbit"] i:nth-child(6) { animation-delay: 330ms; }
.dd-ai-load[data-pattern="orbit"] i:nth-child(9) { animation-delay: 440ms; }
.dd-ai-load[data-pattern="orbit"] i:nth-child(8) { animation-delay: 550ms; }
.dd-ai-load[data-pattern="orbit"] i:nth-child(7) { animation-delay: 660ms; }
.dd-ai-load[data-pattern="orbit"] i:nth-child(4) { animation-delay: 770ms; }
.dd-ai-load[data-pattern="orbit"] i:nth-child(5) { animation: none; opacity: .07; }
.dd-ai-load-label { font-size: var(--dd-fs-sm-); font-weight: 500; color: var(--dd-ink-light); }
/* Tabular, or the row jitters ten times a second. */
.dd-ai-elapsed {
  font-family: var(--dd-mono);
  font-size: var(--dd-fs-xs);
  color: var(--dd-ink-quiet);
  font-variant-numeric: tabular-nums;
}

/* ---------- 02 · the trace ---------- */
.dd-ai-think { display: flex; flex-direction: column; width: 100%; max-width: 380px; }
.dd-ai-think-head {
  display: flex; align-items: center; gap: 8px;
  margin: 0 -6px;
  padding: 4px 6px;
  border: 0; background: transparent; cursor: pointer;
  border-radius: var(--dd-radius-m);
  font: inherit; font-size: var(--dd-fs-sm-);
  color: var(--dd-ink-quiet);
  text-align: left;
  transition: background var(--dd-dur) var(--dd-ease), color var(--dd-dur) var(--dd-ease);
}
.dd-ai-think-head:hover { background: var(--dd-gray-50); color: var(--dd-ink-light); }
.dd-ai-think-mark { display: grid; place-items: center; color: var(--dd-ink-quiet); }
.dd-ai-think-mark svg { width: 15px; height: 15px; }
.dd-ai-think[data-working="true"] .dd-ai-think-mark { color: var(--dd-ink-light); }
.dd-ai-think-label { flex: 1; min-width: 0; }
.dd-ai-trace-wrap { position: relative; margin: 4px 0 0 5px; padding-left: 16px; }
/* THE MEASURED RAIL. Its height is written by the component and it arrives on
   the PANE clock while the fold runs on the fold clock — the lag is the effect,
   because a rail that is simply 100% tall looks dead beside one that appears to
   be drawn down the page as the steps land. */
.dd-ai-rail {
  position: absolute; left: 3px; top: -2px;
  width: 1px; height: 0;
  background: var(--dd-gray-200);
  transition: height var(--dd-dur-pane) var(--dd-ease-glide);
}
.dd-ai-trace { display: flex; flex-direction: column; gap: 4px; padding: 4px 0; }
.dd-ai-trace-row {
  display: flex; align-items: center; gap: 8px;
  min-height: 22px;
  padding: 1px 6px;
  margin: 0 -6px;
  border-radius: var(--dd-radius-m);
  font-size: var(--dd-fs-xs);
  color: var(--dd-ink-light);
  animation: dd-ai-rise 220ms var(--dd-ease-glide) both;
}
/* THE SOFT ENTRANCE. Blur INSIDE a keyframe is safe against the reduced-motion
   trap the header warns about: the blanket kills the animation itself, so the
   from-frame's blur never paints for a reader who asked for less motion. It
   means the one thing blur means here — "not settled yet" — for the first
   beat of a row's life. */
@keyframes dd-ai-rise { from { opacity: 0; transform: translateY(4px); filter: blur(3px); } }
/* The card-sized sibling: the transitions.dev modal scale, at transcript
   scale. Cards bloom; rows rise. */
@keyframes dd-ai-bloom { from { opacity: 0; transform: translateY(5px) scale(.985); filter: blur(4px); } }
/* The drawn check — the checkbox-draw of the standing motion set, for marks
   that appear inside a tile. Dasharray generously past the path's length. */
@keyframes dd-ai-draw-in { to { stroke-dashoffset: 0; } }
.dd-ai-draw { stroke-dasharray: 20; stroke-dashoffset: 20; animation: dd-ai-draw-in 420ms var(--dd-ease-glide) 140ms forwards; }
/* The stagger, capped at six: past that the eye has stopped counting rows in. */
.dd-ai-trace-row[data-i="1"] { animation-delay: 60ms; }
.dd-ai-trace-row[data-i="2"] { animation-delay: 120ms; }
.dd-ai-trace-row[data-i="3"] { animation-delay: 180ms; }
.dd-ai-trace-row[data-i="4"] { animation-delay: 240ms; }
.dd-ai-trace-row[data-i="5"] { animation-delay: 300ms; }
.dd-ai-trace-mark { display: grid; place-items: center; width: 14px; flex: none; color: var(--dd-green); }
.dd-ai-trace-row[data-state="failed"] .dd-ai-trace-mark { color: var(--dd-red); }
.dd-ai-trace-row[data-state="active"] .dd-ai-trace-mark { color: var(--dd-ink-quiet); }
.dd-ai-trace-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dd-ai-trace-detail { margin-left: auto; flex: none; font-size: var(--dd-fs-2xs); color: var(--dd-ink-quiet); }
.dd-ai-trace-detail[data-mono="true"] { font-family: var(--dd-mono); }

/* ---------- 05 · the tool row ---------- */
.dd-ai-tool { width: 100%; }
.dd-ai-tool-row {
  display: flex; align-items: center; gap: 8px;
  width: calc(100% + 6px);
  min-height: 28px;
  margin: 0 -3px;
  padding: 0 3px;
  border: 0; background: transparent;
  border-radius: var(--dd-radius-m);
  font: inherit; font-size: var(--dd-fs-xs);
  color: var(--dd-ink-light);
  text-align: left;
  transition: background var(--dd-dur) var(--dd-ease);
}
/* The hover pill bleeds 3px past the content column without moving the content
   — the negative margin and the matching padding are one gesture. */
button.dd-ai-tool-row { cursor: pointer; }
button.dd-ai-tool-row:hover { background: var(--dd-gray-50); }
/* THE SIGNATURE DETAIL: two glyphs in one 16px box, cross-fading. Nothing
   changes size, so nothing in the row moves, and there is no permanently
   visible disclosure arrow shouting at a reader with no interest in the
   detail. */
.dd-ai-tool-mark { display: grid; place-items: center; width: 16px; height: 16px; flex: none; color: var(--dd-ink-quiet); }
/* Both glyphs in the SAME grid cell, so they stack without either one being
   taken out of flow — an absolutely-positioned chevron would need its own
   centring and would drift a pixel off the icon it replaces. */
.dd-ai-tool-mark > svg { grid-area: 1 / 1; transition: opacity var(--dd-dur) var(--dd-ease), transform var(--dd-dur-slow) var(--dd-ease-glide); }
.dd-ai-tool-mark .dd-ai-chev { opacity: 0; transform: rotate(-90deg); }
button.dd-ai-tool-row:hover .dd-ai-tool-glyph,
.dd-ai-tool[data-open="true"] .dd-ai-tool-glyph { opacity: 0; }
button.dd-ai-tool-row:hover .dd-ai-chev { opacity: 1; }
.dd-ai-tool[data-open="true"] .dd-ai-chev { opacity: 1; transform: rotate(0deg); }
.dd-ai-tool-name { flex: none; font-weight: 500; color: var(--dd-ink); }
.dd-ai-tool-arg {
  display: inline-flex; align-items: center;
  flex: 1; min-width: 0;
  height: 20px; padding: 0 6px;
  border: 1px solid var(--dd-hairline);
  border-radius: var(--dd-radius-s);
  background: var(--dd-gray-50);
  font-size: var(--dd-fs-2xs);
  color: var(--dd-ink-quiet);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* ONE STATUS HUE, ONE MEANING. Green is a thing that happened, red a thing
   that did not. `denied` is quiet ink on purpose: a person refusing a tool is
   not an error, and painting it as one teaches the wrong thing about a control
   they are meant to use freely. */
.dd-ai-tool-out {
  display: inline-flex; align-items: center; gap: 5px;
  flex: none;
  font-size: var(--dd-fs-2xs);
  color: var(--dd-ink-quiet);
}
.dd-ai-tool-out[data-outcome="ran"], .dd-ai-tool-out[data-outcome="approved"] { color: var(--dd-green); }
.dd-ai-tool-out[data-outcome="failed"] { color: var(--dd-red); }
.dd-ai-tool-detail {
  margin: 2px 0 4px 8px;
  padding: 2px 0 2px 14px;
  border-left: 1px solid var(--dd-hairline);
  font-size: var(--dd-fs-2xs);
  line-height: 1.6;
  color: var(--dd-ink-quiet);
}

/* ---------- 04 · the approval card ----------
   CONCENTRIC RADII, which the reference states outright and which this app's
   3/5/8 scale happens to satisfy exactly: an 8px card with a 3px inset wraps
   5px children, and 8 − 3 = 5. */
.dd-ai-approve {
  width: 100%; max-width: 380px;
  padding: 3px;
  border: 1px solid var(--dd-hairline);
  border-radius: var(--dd-radius-l);
  background: var(--dd-panel);
  animation: dd-ai-bloom var(--dd-dur-pane) var(--dd-ease-glide);
}
.dd-ai-approve-head { padding: 9px 9px 8px; }
.dd-ai-approve-eyebrow {
  display: block;
  font-size: var(--dd-fs-2xs); font-weight: 500;
  text-transform: uppercase; letter-spacing: .07em;
  color: var(--dd-ink-quiet);
}
.dd-ai-approve-line { margin: 4px 0 0; font-size: var(--dd-fs-sm-); font-weight: 500; color: var(--dd-ink); }
.dd-ai-diff {
  display: flex; flex-direction: column; gap: 1px;
  padding: 4px;
  border-radius: var(--dd-radius-m);
  background: var(--dd-canvas);
}
.dd-ai-diff-row { padding: 6px 8px; border-radius: var(--dd-radius-s); background: var(--dd-panel); }
/* The tint is a mix of the status hue this app already owns, not a new colour:
   the same three-roles-from-one-hue technique the tag chips use. */
.dd-ai-diff-row[data-kind="insert"] { background: color-mix(in srgb, var(--dd-green) 8%, var(--dd-panel)); }
.dd-ai-diff-row[data-kind="remove"] { background: color-mix(in srgb, var(--dd-red) 8%, var(--dd-panel)); }
.dd-ai-diff-head { display: flex; align-items: baseline; gap: 6px; font-size: var(--dd-fs-xs); }
.dd-ai-diff-verb { flex: none; font-weight: 600; color: var(--dd-ink-quiet); }
.dd-ai-diff-row[data-kind="insert"] .dd-ai-diff-verb { color: var(--dd-green); }
.dd-ai-diff-row[data-kind="remove"] .dd-ai-diff-verb { color: var(--dd-red); }
.dd-ai-diff-label { min-width: 0; color: var(--dd-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dd-ai-diff-fields { margin: 4px 0 0; display: flex; flex-direction: column; gap: 2px; }
.dd-ai-diff-field { display: flex; gap: 8px; font-size: var(--dd-fs-2xs); }
.dd-ai-diff-field dt { flex: none; min-width: 64px; color: var(--dd-ink-quiet); font-family: var(--dd-mono); }
.dd-ai-diff-field dd { margin: 0; min-width: 0; display: flex; gap: 6px; color: var(--dd-ink-light); }
/* The old value is struck rather than dropped: "when: —" and "when: 26 Aug,
   was 24 Aug" are different facts, and the person approving needs the second. */
.dd-ai-diff-was { color: var(--dd-ink-quiet); text-decoration-color: var(--dd-gray-300); }
.dd-ai-diff-now { color: var(--dd-ink); }
/* ONE CELL, TWO FACES — both children sit in the same grid area, so the card's
   height is decided at first paint and a decision cannot move the page under a
   reader who is mid-sentence above it. */
.dd-ai-approve-foot { display: grid; padding: 8px 5px 5px; }
.dd-ai-approve-face { grid-area: 1 / 1; display: flex; align-items: center; justify-content: flex-end; gap: 6px; min-height: 28px; }
.dd-ai-approve-said { justify-content: flex-start; padding-left: 4px; }
.dd-ai-approve-verdict { font-size: var(--dd-fs-xs); font-weight: 500; color: var(--dd-ink-quiet); }
.dd-ai-approve-verdict[data-state="approved"] { color: var(--dd-green); }
.dd-ai-btn {
  display: inline-flex; align-items: center; justify-content: center;
  height: 28px; padding: 0 12px;
  border: 1px solid var(--dd-border);
  border-radius: var(--dd-radius-m);
  background: var(--dd-panel);
  font: inherit; font-size: var(--dd-fs-xs); font-weight: 500;
  color: var(--dd-ink-light);
  cursor: pointer;
  transition: background var(--dd-dur) var(--dd-ease), color var(--dd-dur) var(--dd-ease), border-color var(--dd-dur) var(--dd-ease);
}
.dd-ai-btn:hover { background: var(--dd-gray-50); color: var(--dd-ink); }
.dd-ai-btn:disabled { opacity: .5; cursor: default; }
.dd-ai-btn-go { border-color: transparent; background: var(--dd-ink); color: var(--dd-on-inverse); }
.dd-ai-btn-go:hover { background: var(--dd-ink); color: var(--dd-on-inverse); opacity: .9; }
/* THE DANGER TREATMENT IS THE ONE THIS APP ALREADY HAS — red text on the
   ordinary ground, exactly as the chat menu's Delete row wears it. No second
   destructive hue, no red fill. */
.dd-ai-btn-go[data-danger="true"] { background: var(--dd-panel); border-color: var(--dd-border); color: var(--dd-red); }
.dd-ai-btn-go[data-danger="true"]:hover { background: var(--dd-gray-50); color: var(--dd-red); opacity: 1; }

/* ---------- the approval's domain tiles ----------
   The batch drawn as the THING it makes: a to-do wearing its ring and date
   chips, an event wearing its colour bar and time, a conversation wearing its
   subject and the verb about to happen to it. The field diff these replace is
   still on the card, behind the Details fold below. */
.dd-ai-approve-body { padding: 2px 4px 2px; }
.dd-ai-prev-list { display: flex; flex-direction: column; gap: 4px; }
.dd-ai-prev-tile {
  display: flex; align-items: flex-start; gap: 9px;
  padding: 8px 9px;
  border: 1px solid var(--dd-hairline);
  border-radius: var(--dd-radius-m);
  background: var(--dd-canvas);
  animation: dd-ai-rise 240ms var(--dd-ease-glide) both;
}
/* The same status-hue tinting the diff rows use — one hue, one meaning. */
.dd-ai-prev-tile[data-removed="true"] { background: color-mix(in srgb, var(--dd-red) 6%, var(--dd-canvas)); }
.dd-ai-prev-tile[data-done="true"] { background: color-mix(in srgb, var(--dd-green) 6%, var(--dd-canvas)); }
.dd-ai-prev-ring { display: grid; place-items: center; flex: none; margin-top: 1px; color: var(--dd-ink-quiet); }
.dd-ai-prev-ring svg { width: 15px; height: 15px; stroke-width: 1.5; }
.dd-ai-prev-tile[data-done="true"] .dd-ai-prev-ring { color: var(--dd-green); }
.dd-ai-prev-bar { width: 3px; align-self: stretch; flex: none; border-radius: 2px; background: var(--dd-accent); }
.dd-ai-prev-tile[data-removed="true"] .dd-ai-prev-bar { background: var(--dd-red); }
.dd-ai-prev-mailmark { display: grid; place-items: center; flex: none; margin-top: 1px; color: var(--dd-ink-quiet); }
.dd-ai-prev-mailmark svg { width: 14px; height: 14px; stroke-width: 1.5; }
.dd-ai-prev-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.dd-ai-prev-title { font-size: var(--dd-fs-sm-); font-weight: 500; color: var(--dd-ink); overflow-wrap: anywhere; }
.dd-ai-prev-title[data-struck="true"] { text-decoration: line-through; text-decoration-color: var(--dd-gray-300); color: var(--dd-ink-light); }
.dd-ai-prev-notes {
  font-size: var(--dd-fs-xs); color: var(--dd-ink-light);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.dd-ai-prev-chips { display: flex; flex-wrap: wrap; gap: 4px; }
.dd-ai-prev-chip {
  display: inline-flex; align-items: center; gap: 4px;
  height: 18px; padding: 0 6px;
  border: 1px solid var(--dd-hairline);
  border-radius: var(--dd-radius-m);
  background: var(--dd-panel);
  font-size: var(--dd-fs-2xs); font-weight: 500;
  color: var(--dd-ink-light);
}
.dd-ai-prev-chip-icon { display: grid; place-items: center; }
.dd-ai-prev-chip-icon svg { width: 11px; height: 11px; stroke-width: 1.75; color: var(--dd-ink-quiet); }
.dd-ai-prev-verb {
  margin-left: auto; flex: none; align-self: center;
  font-size: var(--dd-fs-2xs); font-weight: 600;
  text-transform: uppercase; letter-spacing: .05em;
  color: var(--dd-ink-quiet);
}
.dd-ai-prev-verb[data-danger="true"] { color: var(--dd-red); }
.dd-ai-prev-changes {
  display: flex; flex-direction: column; gap: 3px;
  margin-top: 4px; padding: 7px 9px;
  border: 1px solid var(--dd-hairline);
  border-radius: var(--dd-radius-m);
  background: var(--dd-canvas);
}
.dd-ai-prev-change { display: flex; align-items: baseline; gap: 8px; font-size: var(--dd-fs-xs); }
.dd-ai-prev-change-label { flex: none; min-width: 56px; color: var(--dd-ink-quiet); }
.dd-ai-prev-more { padding: 2px 4px; font-size: var(--dd-fs-2xs); color: var(--dd-ink-quiet); }
/* The audit's own toggle: quiet text, the shared chevron, the shared fold. */
.dd-ai-approve-details {
  display: flex; align-items: center; gap: 3px;
  width: fit-content;
  margin: 2px 4px 0; padding: 3px 5px;
  border: 0; border-radius: var(--dd-radius-s);
  background: none;
  font: inherit; font-size: var(--dd-fs-2xs); font-weight: 500;
  color: var(--dd-ink-quiet);
  cursor: pointer;
  transition: background var(--dd-dur) var(--dd-ease), color var(--dd-dur) var(--dd-ease);
}
.dd-ai-approve-details:hover { background: var(--dd-gray-50); color: var(--dd-ink-light); }
.dd-ai-approve-details[aria-expanded="true"] .dd-ai-chev { transform: rotate(180deg); }
.dd-ai-approve .dd-fold .dd-ai-diff { margin-top: 2px; }

/* ---------- the question card ----------
   ask_user, drawn. Numbered options under one question, a free-text row, and
   Skip; the whole card settles into one quiet line once the person decides.
   Same geometry as the approval card — these two are siblings in the
   transcript and must read as one family. */
.dd-ai-quiz {
  width: 100%; max-width: 380px;
  padding: 3px;
  border: 1px solid var(--dd-hairline);
  border-radius: var(--dd-radius-l);
  background: var(--dd-panel);
  animation: dd-ai-bloom var(--dd-dur-pane) var(--dd-ease-glide);
}
.dd-ai-quiz-head { padding: 9px 9px 6px; }
.dd-ai-quiz-eyebrow {
  display: block;
  font-size: var(--dd-fs-2xs); font-weight: 500;
  text-transform: uppercase; letter-spacing: .07em;
  color: var(--dd-ink-quiet);
}
.dd-ai-quiz-q { margin: 4px 0 0; font-size: var(--dd-fs-sm-); font-weight: 500; color: var(--dd-ink); }
.dd-ai-quiz-body { display: flex; flex-direction: column; padding: 0 3px 3px; }
.dd-ai-quiz-opts { display: flex; flex-direction: column; }
.dd-ai-quiz-opt {
  display: flex; align-items: center; gap: 10px;
  padding: 8px 6px;
  border: 0;
  border-radius: var(--dd-radius-m);
  background: none;
  font: inherit; font-size: var(--dd-fs-sm-); text-align: left;
  color: var(--dd-ink-light);
  cursor: pointer;
  animation: dd-ai-rise 240ms var(--dd-ease-glide) both;
  transition: background var(--dd-dur) var(--dd-ease), color var(--dd-dur) var(--dd-ease);
}
/* The hairline between rows, inset so it never collides with the hover
   ground's rounded corners. */
.dd-ai-quiz-opt + .dd-ai-quiz-opt { position: relative; }
.dd-ai-quiz-opt + .dd-ai-quiz-opt::before {
  content: ""; position: absolute; top: 0; left: 6px; right: 6px;
  border-top: 1px solid var(--dd-hairline);
}
.dd-ai-quiz-opt:hover { background: var(--dd-gray-50); color: var(--dd-ink); }
.dd-ai-quiz-opt[data-i="1"] { animation-delay: 50ms; }
.dd-ai-quiz-opt[data-i="2"] { animation-delay: 100ms; }
.dd-ai-quiz-opt[data-i="3"] { animation-delay: 150ms; }
.dd-ai-quiz-opt[data-i="4"] { animation-delay: 200ms; }
.dd-ai-quiz-opt[data-i="5"] { animation-delay: 250ms; }
/* The number square: two faces in one box, the digit and the drawn tick,
   cross-fading exactly the way the tool row's icon slot does. */
.dd-ai-quiz-num {
  display: grid; place-items: center;
  width: 20px; height: 20px; flex: none;
  border: 1px solid var(--dd-hairline);
  border-radius: var(--dd-radius-s);
  background: var(--dd-gray-50);
  transition: background var(--dd-dur) var(--dd-ease), border-color var(--dd-dur) var(--dd-ease);
}
.dd-ai-quiz-num > * { grid-area: 1 / 1; transition: opacity var(--dd-dur) var(--dd-ease); }
.dd-ai-quiz-digit {
  font-size: var(--dd-fs-2xs); font-weight: 600;
  color: var(--dd-ink-quiet);
  font-variant-numeric: tabular-nums;
}
.dd-ai-quiz-tick { display: grid; place-items: center; opacity: 0; color: var(--dd-on-inverse); }
.dd-ai-quiz-tick svg { width: 12px; height: 12px; stroke-width: 2; }
.dd-ai-quiz-opt:hover .dd-ai-quiz-num { border-color: var(--dd-gray-300); background: var(--dd-panel); }
.dd-ai-quiz-opt[data-picked="true"] .dd-ai-quiz-num { background: var(--dd-ink); border-color: var(--dd-ink); }
.dd-ai-quiz-opt[data-picked="true"] .dd-ai-quiz-digit { opacity: 0; }
.dd-ai-quiz-opt[data-picked="true"] .dd-ai-quiz-tick { opacity: 1; }
.dd-ai-quiz-label { min-width: 0; overflow-wrap: anywhere; }
.dd-ai-quiz-fields { display: flex; flex-direction: column; gap: 7px; padding: 4px 3px 2px; }
.dd-ai-quiz-field { display: flex; flex-direction: column; gap: 3px; animation: dd-ai-rise 240ms var(--dd-ease-glide) both; }
.dd-ai-quiz-field[data-i="1"] { animation-delay: 50ms; }
.dd-ai-quiz-field[data-i="2"] { animation-delay: 100ms; }
.dd-ai-quiz-field[data-i="3"] { animation-delay: 150ms; }
.dd-ai-quiz-field-label {
  font-size: var(--dd-fs-2xs); font-weight: 500;
  text-transform: uppercase; letter-spacing: .05em;
  color: var(--dd-ink-quiet);
}
.dd-ai-quiz-field input {
  height: 30px; padding: 0 9px;
  border: 1px solid var(--dd-hairline);
  border-radius: var(--dd-radius-m);
  background: var(--dd-panel);
  font: inherit; font-size: var(--dd-fs-sm-);
  color: var(--dd-ink);
  outline: none;
  transition: border-color var(--dd-dur) var(--dd-ease);
}
.dd-ai-quiz-field input:focus { border-color: var(--dd-gray-300); }
.dd-ai-quiz-field input::placeholder { color: var(--dd-ink-quiet); }
.dd-ai-quiz-foot { display: flex; align-items: center; gap: 6px; padding: 6px 3px 3px; }
.dd-ai-quiz-other {
  flex: 1; min-width: 0;
  display: flex; align-items: center; gap: 8px;
  height: 30px; padding: 0 4px 0 9px;
  border: 1px solid var(--dd-hairline);
  border-radius: var(--dd-radius-m);
  background: var(--dd-gray-50);
  transition: border-color var(--dd-dur) var(--dd-ease), background var(--dd-dur) var(--dd-ease);
}
.dd-ai-quiz-other:focus-within { border-color: var(--dd-gray-300); background: var(--dd-panel); }
.dd-ai-quiz-other-mark { display: grid; place-items: center; flex: none; color: var(--dd-ink-quiet); }
.dd-ai-quiz-other-mark svg { width: 12px; height: 12px; stroke-width: 1.75; }
.dd-ai-quiz-other input {
  flex: 1; min-width: 0;
  border: 0; background: none; outline: none;
  font: inherit; font-size: var(--dd-fs-sm-);
  color: var(--dd-ink);
}
.dd-ai-quiz-other input::placeholder { color: var(--dd-ink-quiet); }
.dd-ai-quiz-go-slot { width: 22px; height: 22px; flex: none; }
.dd-ai-quiz-go {
  display: grid; place-items: center;
  width: 22px; height: 22px;
  border: 0; border-radius: var(--dd-radius-s);
  background: var(--dd-ink);
  color: var(--dd-on-inverse);
  cursor: pointer;
}
.dd-ai-quiz-go:hover { opacity: .9; }
.dd-ai-quiz-go svg { width: 12px; height: 12px; stroke-width: 2; }
/* The settled line: the reveal's cross-fade while the fold above it closes —
   two halves of one motion. The row is in the tree from the first paint. */
.dd-ai-quiz-said { display: flex; align-items: center; gap: 6px; min-height: 24px; padding: 0 9px 6px; }
.dd-ai-quiz-said-mark { display: grid; place-items: center; flex: none; color: var(--dd-green); }
.dd-ai-quiz-said-mark svg { width: 13px; height: 13px; stroke-width: 2; }
.dd-ai-quiz-said-text { font-size: var(--dd-fs-sm-); color: var(--dd-ink-light); overflow-wrap: anywhere; }
.dd-ai-quiz[data-state="skipped"] .dd-ai-quiz-said-text { color: var(--dd-ink-quiet); }

/* ---------- the assistant's own panel ----------
   show_panel, drawn. The model composes a small exhibit out of a fixed block
   vocabulary — stats, rows, key-values, a timeline, progress, a note — and
   this region is the whole of what any of them may look like. The model
   chooses WHAT; these rules decide HOW, which is what keeps an AI-designed
   surface inside the app's own design system. */
.dd-ai-panel {
  width: 100%; max-width: 420px;
  border: 1px solid var(--dd-hairline);
  border-radius: var(--dd-radius-l);
  background: var(--dd-panel);
  overflow: hidden;
  animation: dd-ai-bloom var(--dd-dur-pane) var(--dd-ease-glide);
}
/* A week strip or a table needs the room a stat strip does not — the renderer
   stamps data-wide when the spec holds one (structural, not :has(), so it
   cannot quietly fail on an engine that lacks the selector). */
.dd-ai-panel[data-wide] { max-width: 640px; }
.dd-ai-panel-head { padding: 9px 11px 0; }
.dd-ai-panel-title { font-size: var(--dd-fs-sm-); font-weight: 600; color: var(--dd-ink); }
.dd-ai-panel-body { display: flex; flex-direction: column; padding: 9px 11px 11px; gap: 8px; }
.dd-ai-panel-block { animation: dd-ai-rise 240ms var(--dd-ease-glide) both; }
.dd-ai-panel-block[data-i="1"] { animation-delay: 50ms; }
.dd-ai-panel-block[data-i="2"] { animation-delay: 100ms; }
.dd-ai-panel-block[data-i="3"] { animation-delay: 150ms; }
.dd-ai-panel-block[data-i="4"] { animation-delay: 200ms; }
.dd-ai-panel-block[data-i="5"] { animation-delay: 250ms; }
/* Consecutive stats group into one strip of tiles. Tabular numerals: a value
   that jitters is a value nobody trusts. */
.dd-ai-panel-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(96px, 1fr)); gap: 6px; }
.dd-ai-panel-stat {
  padding: 9px 10px;
  border: 1px solid var(--dd-hairline);
  border-radius: var(--dd-radius-m);
  background: var(--dd-canvas);
  min-width: 0;
}
.dd-ai-panel-stat-value {
  display: block;
  font-size: var(--dd-fs-lg); font-weight: 600; letter-spacing: -.01em;
  color: var(--dd-ink);
  font-variant-numeric: tabular-nums;
}
.dd-ai-panel-stat-label {
  display: block; margin-top: 2px;
  font-size: var(--dd-fs-2xs); font-weight: 500;
  color: var(--dd-ink-quiet);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.dd-ai-panel-stat-hint { display: block; margin-top: 1px; font-size: var(--dd-fs-2xs); color: var(--dd-ink-quiet); }
/* Rows: the same shape a to-do or a thread wears elsewhere in the transcript. */
.dd-ai-panel-list { display: flex; flex-direction: column; }
.dd-ai-panel-list-title { margin: 0 0 4px; font-size: var(--dd-fs-2xs); font-weight: 500; text-transform: uppercase; letter-spacing: .07em; color: var(--dd-ink-quiet); }
.dd-ai-panel-row {
  display: flex; align-items: baseline; gap: 8px;
  padding: 6px 2px;
}
.dd-ai-panel-row + .dd-ai-panel-row { border-top: 1px solid var(--dd-hairline); }
.dd-ai-panel-row-main { flex: 1; min-width: 0; }
.dd-ai-panel-row-title { display: block; font-size: var(--dd-fs-sm-); font-weight: 500; color: var(--dd-ink); overflow-wrap: anywhere; }
.dd-ai-panel-row[data-state="done"] .dd-ai-panel-row-title { color: var(--dd-ink-light); text-decoration: line-through; text-decoration-color: var(--dd-gray-300); }
.dd-ai-panel-row-sub { display: block; margin-top: 1px; font-size: var(--dd-fs-xs); color: var(--dd-ink-light); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dd-ai-panel-row-meta { flex: none; font-size: var(--dd-fs-xs); color: var(--dd-ink-quiet); font-variant-numeric: tabular-nums; }
/* Key-values: quiet dictionary rows. */
.dd-ai-panel-kv { display: flex; flex-direction: column; gap: 5px; margin: 0; }
.dd-ai-panel-kv > div { display: flex; gap: 10px; font-size: var(--dd-fs-xs); }
.dd-ai-panel-kv dt { flex: none; min-width: 88px; color: var(--dd-ink-quiet); }

/* ---- the panel's table block ---- */
.dd-ai-panel-tablewrap { overflow-x: auto; max-width: 100%; }
.dd-ai-panel-table { border-collapse: collapse; font-size: var(--dd-fs-xs); min-width: max-content; width: 100%; }
.dd-ai-panel-table th, .dd-ai-panel-table td {
  text-align: left; vertical-align: top;
  padding: 5px 12px 5px 0;
  border-bottom: 1px solid var(--dd-hairline);
  max-width: 300px;
}
.dd-ai-panel-table th { font-weight: 600; font-size: var(--dd-fs-2xs); color: var(--dd-ink-quiet); }
.dd-ai-panel-table tbody tr:last-child td { border-bottom: 0; }

/* ---- the days block: a small week strip ----
   Columns share one hairline frame; each day is a label over its items. On a
   narrow pane the grid wraps to two columns rather than shrinking to slivers. */
.dd-ai-panel-days {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
  gap: 1px;
  background: var(--dd-hairline);
  border: 1px solid var(--dd-hairline);
  border-radius: var(--dd-radius-m);
  overflow: hidden;
}
.dd-ai-panel-day {
  background: var(--dd-panel);
  padding: 8px 9px 9px;
  display: flex; flex-direction: column; gap: 4px;
  min-width: 0;
}
.dd-ai-panel-day-label {
  font-size: var(--dd-fs-2xs);
  font-weight: 650;
  color: var(--dd-ink-quiet);
  letter-spacing: 0.02em;
}
.dd-ai-panel-day-item { display: flex; flex-direction: column; font-size: var(--dd-fs-2xs); line-height: 1.4; min-width: 0; }
.dd-ai-panel-day-item[data-state="done"] .dd-ai-panel-day-title { text-decoration: line-through; color: var(--dd-ink-quiet); }
.dd-ai-panel-day-item[data-state="active"] .dd-ai-panel-day-title { color: var(--dd-accent); font-weight: 600; }
.dd-ai-panel-day-time { color: var(--dd-ink-quiet); font-variant-numeric: tabular-nums; }
.dd-ai-panel-day-title { overflow: hidden; text-overflow: ellipsis; }
.dd-ai-panel-day-free { font-size: var(--dd-fs-2xs); color: var(--dd-ink-quiet); font-style: italic; }
.dd-ai-panel-kv dd { margin: 0; min-width: 0; color: var(--dd-ink); overflow-wrap: anywhere; }
/* The timeline: a rail down the left, times in a fixed column, the same
   drawn-down-the-page rail the thinking trace owns. */
.dd-ai-panel-tl { position: relative; display: flex; flex-direction: column; gap: 8px; padding-left: 2px; }
.dd-ai-panel-tl-row { display: flex; gap: 10px; align-items: baseline; }
.dd-ai-panel-tl-time { flex: none; width: 64px; font-size: var(--dd-fs-xs); color: var(--dd-ink-quiet); font-variant-numeric: tabular-nums; text-align: right; }
.dd-ai-panel-tl-dot { flex: none; align-self: center; width: 7px; height: 7px; border-radius: var(--dd-radius-full); border: 1.5px solid var(--dd-gray-300); background: var(--dd-panel); }
.dd-ai-panel-tl-row[data-state="active"] .dd-ai-panel-tl-dot { border-color: var(--dd-accent); background: var(--dd-accent); }
.dd-ai-panel-tl-main { flex: 1; min-width: 0; }
.dd-ai-panel-tl-title { display: block; font-size: var(--dd-fs-sm-); font-weight: 500; color: var(--dd-ink); }
.dd-ai-panel-tl-sub { display: block; font-size: var(--dd-fs-xs); color: var(--dd-ink-light); }
/* Progress: one flat bar, no gradient, the width IS the datum (the one inline
   style the component writes). */
.dd-ai-panel-prog-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 5px; }
.dd-ai-panel-prog-label { font-size: var(--dd-fs-xs); font-weight: 500; color: var(--dd-ink-light); }
.dd-ai-panel-prog-value { font-size: var(--dd-fs-xs); color: var(--dd-ink-quiet); font-variant-numeric: tabular-nums; }
.dd-ai-panel-prog-track { height: 4px; border-radius: 2px; background: var(--dd-gray-100); overflow: hidden; }
.dd-ai-panel-prog-fill { height: 100%; border-radius: 2px; background: var(--dd-ink); transition: width var(--dd-dur-pane) var(--dd-ease-glide); }
/* A note: the quote treatment, because that is what an aside already looks
   like in this transcript. */
.dd-ai-panel-note {
  padding: 2px 0 2px 10px;
  border-left: 2px solid var(--dd-gray-200);
  font-size: var(--dd-fs-xs); line-height: 1.6;
  color: var(--dd-ink-light);
}
.dd-ai-panel-rule { border: 0; border-top: 1px solid var(--dd-hairline); margin: 0; }

/* ---------- the docked question ----------
   The same quiz, grown OUT OF the composer: the ask box is where answering
   already lives, so the question unfolds from its top edge (the accordion
   mechanism) and the box's own input becomes “answer in your own words”.
   No card chrome here — the composer is the chrome. */
.dd-ai-quiz[data-docked="true"] {
  max-width: none;
  padding: 0;
  border: 0;
  border-radius: 0;
  background: none;
  animation: none;
}
.dd-ai-quiz[data-docked="true"] .dd-ai-quiz-head { padding: 10px 2px 2px; }
.dd-ai-quiz[data-docked="true"] .dd-ai-quiz-body { padding: 0 0 4px; }
.dd-ai-quiz[data-docked="true"] .dd-ai-quiz-foot { padding: 4px 0 0; }

/* ---------- 10 · context cards ---------- */
.dd-ai-ctx { display: flex; flex-direction: column; gap: 8px; width: 100%; max-width: 380px; }
.dd-ai-ctx-head { display: flex; align-items: center; gap: 8px; }
.dd-ai-ctx-title { font-size: var(--dd-fs-sm-); font-weight: 600; color: var(--dd-ink); }
.dd-ai-ctx-card {
  overflow: hidden;
  border: 1px solid var(--dd-border);
  border-radius: var(--dd-radius-l);
  background: var(--dd-panel);
  animation: dd-ai-rise 260ms var(--dd-ease-glide) both;
}
.dd-ai-ctx-card[data-i="1"] { animation-delay: 60ms; }
.dd-ai-ctx-card[data-i="2"] { animation-delay: 120ms; }
.dd-ai-ctx-card[data-i="3"] { animation-delay: 180ms; }
.dd-ai-ctx-card[data-i="4"] { animation-delay: 240ms; }
.dd-ai-ctx-card[data-i="5"] { animation-delay: 300ms; }
.dd-ai-ctx-bar { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-bottom: 1px solid var(--dd-hairline); }
.dd-ai-ctx-name { min-width: 0; font-size: var(--dd-fs-sm-); font-weight: 500; color: var(--dd-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dd-ai-ctx-bar .dd-ai-count { margin-left: auto; }
.dd-ai-ctx-body { margin: 0; padding: 8px 12px 4px; font-size: var(--dd-fs-xs); line-height: 1.6; color: var(--dd-ink-light); }
.dd-ai-ctx-foot { padding: 0 12px 12px; }
/* A TRANSITION, NOT AN ANIMATION, and the difference is not stylistic: an
   animation with `both` holds its final frame forever and would out-rank the
   hover this chip also needs. Cards are scenery and animate; chips are
   controls and transition. */
.dd-ai-ctx-src {
  display: inline-flex; align-items: center; gap: 6px;
  height: 24px; padding: 0 8px;
  border: 1px solid var(--dd-border);
  border-radius: var(--dd-radius-m);
  background: var(--dd-gray-50);
  font-size: var(--dd-fs-xs); font-weight: 500;
  color: var(--dd-ink-light);
  text-decoration: none;
  opacity: 0; transform: scale(.96);
  transition: opacity var(--dd-dur-pane) var(--dd-ease-glide),
              transform var(--dd-dur-pane) var(--dd-ease-glide),
              background var(--dd-dur) var(--dd-ease);
}
.dd-ai-ctx-src[data-on="true"] { opacity: 1; transform: none; }
a.dd-ai-ctx-src:hover { background: var(--dd-gray-100); color: var(--dd-ink); }
.dd-ai-ctx-card[data-i="1"] .dd-ai-ctx-src { transition-delay: 40ms; }
.dd-ai-ctx-card[data-i="2"] .dd-ai-ctx-src { transition-delay: 80ms; }
.dd-ai-ctx-card[data-i="3"] .dd-ai-ctx-src { transition-delay: 120ms; }
.dd-ai-ctx-card[data-i="4"] .dd-ai-ctx-src { transition-delay: 160ms; }
.dd-ai-ctx-card[data-i="5"] .dd-ai-ctx-src { transition-delay: 200ms; }
.dd-ai-ctx-kind {
  display: grid; place-items: center;
  width: 14px; height: 14px; flex: none;
  border-radius: var(--dd-radius-s);
  background: var(--dd-ink);
  color: var(--dd-on-inverse);
  font-size: 7px; font-weight: 700; letter-spacing: .02em;
}
.dd-ai-ctx-src-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dd-ai-ctx-out { width: 10px; height: 10px; flex: none; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }

/* ---------- the per-message action bar ---------- */
.dd-ai-acts-wrap { width: fit-content; }
.dd-ai-acts { display: flex; align-items: center; gap: 2px; }
.dd-ai-act {
  display: grid; place-items: center;
  width: 24px; height: 24px;
  border: 0; background: transparent; cursor: pointer;
  border-radius: var(--dd-radius-s);
  color: var(--dd-ink-quiet);
  transition: background var(--dd-dur) var(--dd-ease), color var(--dd-dur) var(--dd-ease);
}
.dd-ai-act:hover { background: var(--dd-gray-100); color: var(--dd-ink); }
.dd-ai-act[data-on="true"] { color: var(--dd-ink); }
.dd-ai-act:disabled { cursor: default; }
.dd-ai-act svg { width: 14px; height: 14px; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
.dd-ai-act .dd-ai-spin { width: 13px; height: 13px; }

/* ---------- 20 · the selection pill ----------
   ONE PILL THAT MORPHS. The bar's width is animated between whole-content
   replacements by the Web Animations API (the element is sized to its content,
   so there is no width for a CSS transition to start from); within a mode,
   revealing a group is a max-width transition. Two mechanisms, no overlap.

   --dd-dur-morph IS THE ONE DOCUMENTED EXCEPTION to this file's 220ms ceiling
   for chrome that changes size, and it is deliberate: the whole bar is
   changing identity rather than resizing, and at 220ms the two contents read
   as a cut instead of as one object reshaping. The reference measures 320ms
   and David asked for this one exact. The component reads the number back off
   this declaration rather than carrying its own copy. */
.dd-pill {
  --dd-dur-morph: 320ms;
  position: absolute; top: 0; left: 0; z-index: 40;
  opacity: 0;
  pointer-events: none;
  will-change: transform;
  /* THE MOTION SPLIT: the bar TRAVELLING to a new anchor eases in AND out,
     because a thing moving through space accelerates and decelerates. Content
     changing inside it uses the house ease-out. Keep them distinct. */
  transition: transform var(--dd-dur-morph) cubic-bezier(.77, 0, .175, 1),
              opacity var(--dd-dur) var(--dd-ease);
}
.dd-pill[data-visible="true"] { opacity: 1; pointer-events: auto; }
/* Concentric: a 36px bar at radius-l with 3px of padding wraps 30px controls
   whose radius-m is exactly 8 − 3. The reference's fully-round pill is not
   available to us — the 999px pills came down in the August radius pass and
   --dd-radius-full is for true circles now. */
.dd-pill-bar {
  display: flex; align-items: center;
  width: fit-content; max-width: calc(100vw - 48px);
  padding: 3px;
  overflow: hidden;
  border: 1px solid var(--dd-border);
  border-radius: var(--dd-radius-l);
  background: var(--dd-panel);
}
.dd-pill-content { display: flex; align-items: center; gap: 2px; width: fit-content; }
.dd-pill-btn {
  display: inline-flex; align-items: center; gap: 5px;
  flex: none;
  height: 30px; padding: 0 10px;
  border: 0; background: transparent; cursor: pointer;
  border-radius: var(--dd-radius-m);
  font: inherit; font-size: var(--dd-fs-xs); font-weight: 400;
  color: var(--dd-ink-light);
  white-space: nowrap;
  transition: background var(--dd-dur) var(--dd-ease), color var(--dd-dur) var(--dd-ease);
}
.dd-pill-btn:hover { background: var(--dd-gray-100); color: var(--dd-ink); }
.dd-pill-go { background: var(--dd-ink); color: var(--dd-on-inverse); }
.dd-pill-go:hover { background: var(--dd-ink); color: var(--dd-on-inverse); opacity: .9; }
.dd-pill-icon { padding: 0; width: 30px; justify-content: center; }
.dd-pill-btn svg { width: 14px; height: 14px; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.dd-pill-rule { width: 1px; height: 16px; flex: none; margin: 0 4px; background: var(--dd-border); }
.dd-pill-busy { display: inline-flex; align-items: center; gap: 7px; height: 30px; padding: 0 10px; font-size: var(--dd-fs-xs); color: var(--dd-ink-quiet); white-space: nowrap; }
/* REVEALED, NEVER MOUNTED: max-width, opacity, and an 8px slide out of the
   way. The slide is what makes a collapsing group read as moving aside rather
   than merely shrinking. */
.dd-pill-extra {
  display: inline-flex; align-items: center; gap: 2px;
  max-width: 0;
  opacity: 0;
  overflow: hidden;
  transform: translateX(-8px);
  transition: max-width var(--dd-dur-pane) var(--dd-ease-glide),
              opacity var(--dd-dur-pane) var(--dd-ease-glide),
              transform var(--dd-dur-pane) var(--dd-ease-glide);
}
.dd-pill-extra[data-on="true"] { max-width: var(--dd-pill-extra-w, 260px); opacity: 1; transform: none; }
.dd-pill-more svg { transition: transform var(--dd-dur-pane) var(--dd-ease-glide); }
.dd-pill-more[aria-expanded="true"] svg { transform: rotate(180deg); }


/* ==========================================================================
   THE ASSISTANT'S COMPOSER — promoted out of the home module, 2026-08.

   These rules were written for Home and are now drawn by two screens: Home's
   assistant block and a project page's. They moved here rather than being
   copied because a composer that looks subtly different depending on which
   page you started the sentence from is the exact failure DESIGN.md's "use
   these; don't invent parallels" is about.

   WHAT MOVED is the composer SURFACE — the recent-chat pill above it, the
   bordered box, the textarea, the controls row, Send and its idle state, the
   permission-mode chip and Stop. WHAT STAYED in home/app.css is the chat PAGE:
   the scroller, the turns, the bubbles, the follow-ups, the starters, the
   docked composer. One screen draws those, and it is the one they live with.

   The container is .ask-block (it was .home-chat, which named the wrong thing
   once a second page had one). Its zero gap is load-bearing: the pill and the
   composer meet on a shared edge, which is why the pill has border-bottom: 0
   and the composer zeroes its top radii when a pill precedes it.
   ========================================================================== */

.ask-block { display: flex; flex-direction: column; gap: 0; }

.chat-pill {
  display: flex; align-items: center; gap: 6px;
  padding: 8px 12px;
  border: 1px solid var(--dd-hairline);
  border-bottom: 0;
  border-radius: var(--dd-radius-m) var(--dd-radius-m) 0 0;
  background: color-mix(in srgb, var(--dd-panel) 55%, var(--dd-canvas));
  font-size: var(--dd-fs-xs);
  min-width: 0;
}
.chat-pill-btn { width: 100%; text-align: left; cursor: pointer; font: inherit; color: inherit; }
.chat-pill-btn:hover .chat-pill-title { color: var(--dd-ink); }
.chat-pill-label { color: var(--dd-ink-quiet); flex: none; }
.chat-pill-label::after { content: " ·"; }
.chat-pill-title { color: var(--dd-ink-light); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.chat-composer {
  border: 1px solid var(--dd-border);
  border-radius: var(--dd-radius-l);
  background: var(--dd-panel);
  padding: 12px 12px 10px;
  transition: border-color var(--dd-dur) var(--dd-ease);
}
.chat-pill + .chat-composer { border-top-left-radius: 0; border-top-right-radius: 0; }
.chat-composer:focus-within { border-color: var(--dd-gray-300); }
.chat-composer textarea {
  display: block; width: 100%;
  border: 0; outline: 0; resize: none; background: none;
  font: inherit; font-size: var(--dd-fs-sm); line-height: 1.5;
  color: var(--dd-ink); min-height: 60px;
}
.chat-composer textarea::placeholder { color: var(--dd-ink-quiet); }
.chat-controls { display: flex; align-items: center; justify-content: flex-end; gap: 10px; }
/* (.chat-model lived here — the "Auto" model chip. It named no real choice
   and opened no menu, which makes it decoration wearing a control's clothes,
   so it came out of BOTH composers: the chat page's and the home page's. The
   rule went with it; nothing in the repo carries the class any more. The row
   above is now the send button alone.) */
.chat-send {
  display: grid; place-items: center;
  width: 30px; height: 30px;
  border: 0; border-radius: var(--dd-radius-m);
  background: var(--dd-accent); color: var(--dd-on-accent);
  cursor: pointer;
  /* Flat. A solid accent square inside a panel composer is the largest fill
     step on this screen, and the resting button shadow it used to wear was
     restating that in a language the suite no longer speaks. */
  transition: opacity var(--dd-dur) var(--dd-ease);
}
/* IDLE — the button before there is anything to send. NOT an opacity: a .45 on
   the whole control drags the glyph and the fill down together and lands the
   arrow at about 2:1 against the panel. The auth pane settled this exact
   question for .login-submit.idle and settled it in tokens — a quiet fill with
   quiet-but-legible ink, which says "not yet" and still reads. Same three
   properties, same transition, so the two buttons wake up the same way.

   It is `disabled` as well as idle, unlike Continue, because there is nothing
   for it to explain: a Send with no words behind it has no question to ask. */
.chat-send.idle {
  background: var(--dd-gray-100);
  color: var(--dd-ink-quiet);
}
.chat-send {
  transition: background var(--dd-dur) var(--dd-ease),
              color var(--dd-dur) var(--dd-ease),
              opacity var(--dd-dur) var(--dd-ease);
}
.chat-send:disabled { cursor: default; }
.chat-send:disabled:not(.idle) { opacity: .45; }
.chat-send svg { width: 15px; height: 15px; stroke-width: 2; }

/* ------------------------------------------------------------- the mode chip

   What the assistant may change without asking, stated where the asking
   happens. A statement first and a control second, so it is drawn as quiet ink
   on the composer's own ground rather than as a button — the menu behind it is
   the standard one (shell/Topbar.tsx's openPermissionMenu, shared with the ⋯
   menu so the two can never disagree).

   Disabled before the conversation has an id: the override is a field on the
   stored chat, and there is nothing yet to write it to. Present rather than
   absent for the reason the topbar's star is (DESIGN.md rule 14) — nothing in
   this row may move when the first answer arrives. */
.chat-mode {
  display: inline-flex; align-items: center; gap: 6px;
  min-width: 0;
  height: 26px; padding: 0 8px;
  border: 0; border-radius: var(--dd-radius-s);
  background: none; cursor: pointer;
  font: inherit; font-size: var(--dd-fs-xs);
  color: var(--dd-ink-quiet);
  white-space: nowrap;
  transition: background var(--dd-dur) var(--dd-ease), color var(--dd-dur) var(--dd-ease);
}
.chat-mode:hover:not(:disabled) { background: var(--dd-gray-100); color: var(--dd-ink); }
.chat-mode:disabled { cursor: default; }
.chat-mode svg { width: 13px; height: 13px; flex: none; stroke-width: 1.75; }
.chat-mode span { overflow: hidden; text-overflow: ellipsis; }
/* Send becomes Stop for as long as the answer is streaming — the SAME button
   in the same place, because it is the same slot in the sentence ("do the
   thing" / "stop doing the thing") and a second button appearing beside it
   would ask the user to re-find the one they were already aiming at. */
.chat-stop svg { width: 13px; height: 13px; }



/* ==========================================================================
   PANEL FURNITURE — a section header, its row list, its empty state.

   Promoted out of the home module, 2026-08, when a project page grew the same
   shape. They were spelled .home-* because Home was the only screen that drew
   them; two screens in, that name described where they came from rather than
   what they are, and .panel-link was already sitting here under the right one.

   WHAT STAYED behind in home/app.css is Home's own: .home-view's column,
   .home-greeting, and the day-stepper (.home-day, .home-nav-btn) that walks
   the Meetings panel back and forth. One screen has a day-stepper.
   ========================================================================== */

.panel-head { display: flex; align-items: center; gap: 8px; }
.panel-title { font-size: var(--dd-fs-sm); font-weight: 600; margin: 0; }
.panel-count { color: var(--dd-ink-quiet); font-weight: 400; margin-left: 2px; }
.panel-rows { margin-top: 10px; }
.panel-empty {
  display: flex; flex-direction: column; align-items: center; gap: 4px;
  padding: 36px 16px;
  text-align: center;
}
.panel-empty-title { font-size: var(--dd-fs-sm); font-weight: 500; color: var(--dd-ink-light); }
.panel-empty-sub { font-size: var(--dd-fs-sm-); color: var(--dd-ink-quiet); }
.panel-empty-btn { margin-top: 12px; display: inline-flex; align-items: center; gap: 8px; }
.panel-empty-btn svg { width: 15px; height: 15px; }
.panel-empty-btn .auth-g { width: 15px; height: 15px; flex: none; }
