/* ============================================================
   Workspace — 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;

  /* 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: Claude Code Desktop -------------------------------
     Lifted from the app's own [data-theme=claude] token block (its --bg-*,
     --text-* and --border-* HSL values converted to hex), so both schemes
     share Claude's warm-gray family rather than a cool slate.

     dark   canvas = bg-200 · panel = bg-000 · wash = bg-100
     light  canvas = bg-200 · panel = bg-100  (bg-000 is pure #fff — skipped
            on purpose: no pure white, no pure black anywhere in this system)

     Ink is Claude's text-100/200/400. The one deviation: the light quiet tier
     is darkened from Claude's #73726c to #676660, because Claude uses that
     step for larger secondary text while we use it for 11px micro-labels,
     where #73726c measured 3.97:1 on the hover wash — below AA. Everything
     here was contrast-checked against panel, canvas AND the wash; the quiet
     tier is the tightest at 4.7:1, so re-measure before touching it.
     ------------------------------------------------------------------- */

  /* grays — washes, hovers, dividers (Claude's border hue over the panel) */
  --dd-gray-50:  light-dark(#eae9e3, #262624);
  --dd-gray-100: light-dark(#e4e3df, #41413e);
  --dd-gray-200: light-dark(#d7d6d1, #4b4a47);
  --dd-gray-300: light-dark(#cac9c5, #565652);
  --dd-gray-500: light-dark(#979694, #7e7d77);

  /* ink tiers — Claude text-100 / text-200 / text-400 */
  --dd-ink:       light-dark(#141413, #faf9f5);
  --dd-ink-light: light-dark(#3d3d3a, #c2c0b6);
  --dd-ink-quiet: light-dark(#676660, #9c9a92); /* AA on panel, canvas + wash */

  /* chrome */
  --dd-canvas:  light-dark(#f5f4ed, #1f1e1d);
  --dd-panel:   light-dark(#faf9f5, #30302e);
  --dd-border:  light-dark(#e0dfdb, #474643);
  --dd-hairline:light-dark(#ebeae6, #3e3e3b);
  --dd-inverse: light-dark(#141413, #faf9f5);
  --dd-on-inverse: light-dark(#faf9f5, #1f1e1d);

  /* 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). */
  --dd-green:  light-dark(#1a7742, #63d18c);
  --dd-red:    light-dark(#b53333, #fe8181);
  --dd-amber:  light-dark(#8a6410, #f0c057);
  --dd-blue:   light-dark(#2a5fd0, #7fb2ff);

  /* washes and scrims — a tint is still a colour, so it still needs a token */
  --dd-blue-wash: light-dark(#eef2fb, #23293a);
  --dd-backdrop: light-dark(#14141347, #0a0a0999);

  --dd-radius-s: 6px;
  --dd-radius-m: 10px;
  --dd-radius-l: 16px;
  --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;

  --dd-shadow-pop: 0 4px 16px light-dark(#14141317, #00000066);
  --dd-shadow-dialog: 0 12px 40px light-dark(#14141322, #00000099);

  --dd-ease: cubic-bezier(.3, .7, .3, 1);
  --dd-dur: 160ms;
  --dd-dur-slow: 220ms;  /* chrome that changes size, e.g. a collapsing rail */
  /* ---- 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;

  /* 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");
}

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

* { 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. */
  overscroll-behavior-x: 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-inverse); color: var(--dd-on-inverse); }

a { color: inherit; }

/* One focus treatment everywhere. Never remove it, never restyle per component. */
:focus-visible {
  outline: 2px solid var(--dd-ink);
  outline-offset: 2px;
}

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

.shell { display: flex; min-height: 100vh; }

.sidebar {
  width: var(--dd-sidebar-w);
  flex: none;
  position: sticky; top: 0; height: 100vh;
  display: flex; flex-direction: column;
  background: var(--dd-panel);
  border-right: 1px solid var(--dd-border);
  padding: 20px 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. */
  transition: width var(--dd-dur-slow) var(--dd-ease),
              padding var(--dd-dur-slow) var(--dd-ease);
}
.sidebar::-webkit-scrollbar { width: 0; }
.brand-name,
.side-nav a > span:not(.avatar),
.side-nav button > span:not(.avatar),
.side-nav .sub {
  overflow: hidden; white-space: nowrap;
  max-width: 180px;
  transition: max-width var(--dd-dur-slow) var(--dd-ease),
              opacity 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 control that collapses the rail lives IN the rail, on the brand's row —
   it belongs to the thing it operates, not to the content area's topbar. */
.side-head {
  display: flex; align-items: center; gap: 6px;
  padding: 4px 0 18px;
}
.brand {
  display: flex; align-items: center; gap: 10px;
  padding: 0 8px;
  flex: 1; min-width: 0;
  text-decoration: none;
  /* on the BASE rule so the fade runs in both directions — a transition
     declared only in the collapsed state does not animate the way back */
  transition: opacity var(--dd-dur) var(--dd-ease),
              padding var(--dd-dur-slow) var(--dd-ease),
              gap var(--dd-dur-slow) 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; }
.brand-mark {
  width: 28px; height: 28px; flex: none;
  border-radius: 8px;
  background: var(--dd-inverse); color: var(--dd-on-inverse);
  display: grid; place-items: center;
  font-weight: 700; font-size: var(--dd-fs-sm-);
  letter-spacing: -.02em;
}
.brand-name { font-weight: 650; font-size: var(--dd-fs-sm); letter-spacing: -.01em; }
.brand-name span { color: var(--dd-ink-quiet); font-weight: 450; }

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

.side-search { padding: 0 2px 14px; }
.side-search .search-pill { width: 100%; }

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

.side-nav { display: flex; flex-direction: column; gap: 2px; }
.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 matches .brand and .side-label, so every item shares one left edge */
  padding: 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 */
  transition: background var(--dd-dur) var(--dd-ease),
              padding var(--dd-dur-slow) var(--dd-ease),
              gap var(--dd-dur-slow) 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; }
.side-nav svg { width: 16px; height: 16px; flex: none; stroke-width: 1.75; }
.side-nav .sub {
  margin-left: auto;
  font-size: var(--dd-fs-3xs);
  color: var(--dd-ink-quiet);
}

.side-foot { margin-top: auto; display: flex; flex-direction: column; gap: 2px; padding-top: 12px; border-top: 1px solid var(--dd-hairline); }

/* The rail's collapse row: always visible, in both states, saying which way it
   goes — « to collapse, flipped to » once collapsed. A control that operates
   the panel lives in the panel, at the bottom where every tool keeps it. */
.rail-collapse svg { transition: transform var(--dd-dur-slow) var(--dd-ease); }
:root.rail-collapsed .rail-collapse svg { transform: rotate(180deg); }

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

/* ---------- account row (the topbar's only survivor) ----------
   There is no global topbar: it cost 64px of vertical space to show a hostname
   the sidebar already implies. Page identity now lives in each app's subbar
   header (.pane-brand) and the account chip sits at the bottom of the rail. */

.side-user .avatar {
  width: 22px; height: 22px;
  font-size: var(--dd-fs-4xs, .625rem);
  border-radius: var(--dd-radius-full);
}
.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-full);
  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); }
.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; }
.view { max-width: var(--dd-view-max); margin: 0 auto; }
.view > * + * { margin-top: var(--dd-stack-gap); }

.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-full);
  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;
  transition: background var(--dd-dur) var(--dd-ease), border-color var(--dd-dur) var(--dd-ease);
}
.btn:hover { background: var(--dd-gray-50); border-color: var(--dd-gray-300); }
.btn:active { transform: translateY(1px); }
.btn-primary { background: var(--dd-inverse); color: var(--dd-on-inverse); border-color: var(--dd-inverse); }
.btn-primary:hover {
  background: color-mix(in srgb, var(--dd-inverse) 82%, var(--dd-canvas));
  border-color: color-mix(in srgb, var(--dd-inverse) 82%, var(--dd-canvas));
}
.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; }

/* ---------- 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); }
.check:active { transform: scale(.88); }
.check:checked {
  background: var(--dd-inverse);
  border-color: var(--dd-inverse);
}
/* 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. */
.check:checked::after {
  content: "";
  display: block; width: 100%; height: 100%;
  background: var(--dd-on-inverse);
  -webkit-mask: var(--dd-tick) center / 10px 10px no-repeat;
  mask: var(--dd-tick) center / 10px 10px no-repeat;
}
/* 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-full);
  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-inverse); color: var(--dd-on-inverse);
}
.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; }
.mail-list .mail-row-unread::before {
  content: ""; width: 7px; height: 7px; flex: none;
  border-radius: 50%; background: var(--dd-blue);
}
.mail-list li:not(.mail-row-unread)::before { content: ""; width: 7px; flex: 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-);
}

.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-full);
}
.toast-action button:hover { background: color-mix(in srgb, var(--dd-on-inverse) 16%, transparent); }
.toast {
  background: var(--dd-inverse);
  color: var(--dd-on-inverse);
  border-radius: var(--dd-radius-full);
  padding: 9px 18px;
  font-size: var(--dd-fs-sm-);
  font-weight: 550;
  box-shadow: var(--dd-shadow-pop);
  animation: dd-pop-in var(--dd-dur) var(--dd-ease);
}
@keyframes dd-pop-in { from { opacity: 0; transform: translateY(6px); } }

/* ---------- 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));
  box-shadow: var(--dd-shadow-dialog);
  animation: dd-pop-in var(--dd-dur) var(--dd-ease);
}
dialog.dd-modal.wide { width: min(680px, calc(100vw - 32px)); }
dialog.dd-modal::backdrop { background: var(--dd-backdrop); }
.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); }

/* ---------- 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;
  height: 100vh;
  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);
}
/* The app's identity, where the topbar used to be: icon + page name, with the
   rest of the row free for controls a module wants to add later. */
.pane-brand {
  display: flex; align-items: center; gap: 9px;
  padding: 4px 8px 14px;
  font-size: var(--dd-fs-md);
  font-weight: 620;
  letter-spacing: -.01em;
}
.pane-brand svg { width: 18px; height: 18px; flex: none; stroke-width: 1.75; }

/* ---- 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; }

/* 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. */
.subbar-nav > a, .subbar-nav > button,
.subbar-nav > * > a, .subbar-nav > * > button {
  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 { 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 {
  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;
}
/* 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 {
  display: inline-flex; align-items: center;
  border: 1px solid var(--dd-border);
  border-radius: var(--dd-radius-full);
  background: var(--dd-panel);
  padding: 2px;
}
.seg button {
  border: 0; background: transparent; cursor: pointer;
  height: 26px; padding: 0 12px;
  border-radius: var(--dd-radius-full);
  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-inverse); color: var(--dd-on-inverse); }

/* ---------- 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);
}
.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); }
.input:focus { border-color: var(--dd-gray-500); outline: none; }
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-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-full);
  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-full);
  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-inverse); color: var(--dd-on-inverse); }

/* ---------- 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);
  box-shadow: var(--dd-shadow-pop);
  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, and `.sub`'s `margin-left: auto` swallows every pixel of free
   space, so a "centred" row put its icon hard against the left edge — and one
   pixel further left on rows that have a `.sub` than on rows that don't. That
   ragged left edge is the misalignment. Kill the gap, kill the auto margin,
   then pad to centre. */

:root.rail-collapsed .sidebar { width: 64px; padding: 20px 8px 16px; }
:root.rail-collapsed .sidebar .brand-name,
:root.rail-collapsed .sidebar .side-nav a > span:not(.avatar),
:root.rail-collapsed .sidebar .side-nav button > span:not(.avatar),
:root.rail-collapsed .sidebar .side-nav .sub { max-width: 0; opacity: 0; }
: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; }
:root.rail-collapsed .sidebar .side-nav .sub { margin-left: 0; }
:root.rail-collapsed .sidebar .side-nav .side-user { padding-left: 13px; padding-right: 13px; }

/* Collapsed, the rail is 64px of icons and the brand mark sits alone at the
   top. The collapse/expand control does NOT live here any more: hiding it
   behind a hover over the mark made the expand affordance invisible — the
   classic hover-reveal failure. It is an always-visible footer row now
   (.rail-collapse), which also makes touch work for free. */
:root.rail-collapsed .sidebar .side-head { padding: 4px 0 14px; }
:root.rail-collapsed .sidebar .brand { gap: 0; padding: 0 10px; }

/* ============================================================
   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: 100vh;
  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;
  border-radius: var(--dd-radius-full);
  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;
  border-radius: var(--dd-radius-full);
  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, elevated,
   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);
  border: 1px solid var(--dd-hairline);
  box-shadow: var(--dd-shadow-pop);
  color: var(--dd-ink);
  transform: translate(-50%, -50%);
}
/* 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; }
body.split-dragging .sidebar .side-nav a.split-source { background: var(--dd-gray-100); opacity: .55; }

/* 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;
  border-width: 1px;
  opacity: 1;
  z-index: 6;
  box-shadow: var(--dd-shadow-dialog);
}
/* 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-backdrop);
}

/* 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);
  box-shadow: var(--dd-shadow-pop);
  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. */
.dd-drag-src { opacity: .4; }

/* Every legal destination, for as long as the drag lasts. Quiet — this is
   a map of what is possible, not an alarm. */
.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);
}

/* 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: one step down, so it reads as a place to type without the
     heavy grey fill the old pill used */
  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;
  padding: 24px;
}
.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;
  box-shadow: var(--dd-shadow-pop);
  /* lifted slightly above dead centre: optically centred beats measured */
  margin-bottom: 6vh;
}
.login-mark { width: 34px; height: 34px; border-radius: 10px; 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);
}
.login-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
.login-field .microlabel { padding-left: 1px; }
.login-error {
  margin: 0 0 14px;
  font-size: var(--dd-fs-sm-);
  color: var(--dd-red);
}
.login-submit { width: 100%; justify-content: center; height: 38px; margin-top: 2px; }

/* A wrong password should be felt, not just read. Small and once — and skipped
   entirely for anyone who asked for less motion. */
@keyframes dd-shake {
  0%, 100% { transform: translateX(0); }
  20% { transform: translateX(-5px); }
  40% { transform: translateX(4px); }
  60% { transform: translateX(-3px); }
  80% { transform: translateX(2px); }
}
.login-card.shake { animation: dd-shake 400ms var(--dd-ease); }
@media (prefers-reduced-motion: reduce) {
  .login-card.shake { animation: none; }
}

/* ============================================================
   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 shimmering surface itself. Kept separate from .dd-skel-line so a later
   placeholder shape (an avatar, a thumbnail) gets the same tint + sweep 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. */
.dd-skel {
  position: relative;
  overflow: hidden;              /* clips the sweep to the bar's own bounds */
  border-radius: var(--dd-radius-s);
  background: var(--dd-gray-100);  /* the resting tint — also IS the reduced-
                                       motion appearance, see below */
}
/* One sweep, built only from the gray ramp: gray-200 as a faint leading/
   trailing shade either side of a gray-50 peak, transparent at both ends so
   it fades into the gray-100 base rather than showing a hard edge — a hard
   edge is what would make this read as a loading bar instead of a surface
   with light passing over it. Percent stops keep the lit band narrow (a wide
   one would look like the tint just changed, not like something swept
   across it). */
.dd-skel::after {
  content: "";
  position: absolute; inset: 0;
  background: linear-gradient(90deg,
    transparent,
    var(--dd-gray-200) 40%,
    var(--dd-gray-50) 50%,
    var(--dd-gray-200) 60%,
    transparent);
  transform: translateX(-100%);
  /* 1.8s: this is an ambient loop, not a state change, so neither --dd-dur
     (160ms) nor --dd-dur-slow (220ms) apply — those time chrome reacting to
     an action. The curve is still the system's one easing, never a second
     hand-rolled one. */
  animation: dd-shimmer 1.8s var(--dd-ease) infinite;
}
@keyframes dd-shimmer { to { transform: translateX(100%); } }

/* 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, same as .login-card.shake's own block — 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::after sits at its
   un-animated transform (translateX(-100%): fully outside the clipped
   surface), leaving only .dd-skel's own gray-100 fill visible — a flat tint,
   with no extra rule needed to produce it. */
@media (prefers-reduced-motion: reduce) {
  .dd-nav-progress { transition: none; }
  .dd-skel::after { 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 24px, padding 7px 8px, no divider, rounded hover */
.dd-skel-stack.compact .dd-skel-row {
  min-height: 24px;
  padding: 7px 8px;
  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, softened ----------
   Navigation between modules is a real document swap, and once the caching work
   made warm loads land in ~30ms the loading UI became the problem rather than the
   cure: a progress bar and a skeleton each appearing and vanishing inside a tenth
   of a second reads as a flicker. Three fixes, in order of how much they matter:

   1. The browser cross-fades the two documents itself. `navigation: auto` opts
      every same-origin navigation in; the old page stays on screen until the new
      one is ready, then they dissolve. No blank frame, nothing to jitter.
   2. The blur lives ONLY here, on the transition snapshots. Blurring real
      elements would create a containing block mid-animation and make the
      calendar's sticky headers jump — trading one jitter for another. A snapshot
      has no layout to disturb.
   3. Loading UI is DELAYED rather than sped up: see SKEL_DELAY in shared/app.js.
      A placeholder that appears for 40ms taught nobody anything. */

@view-transition { navigation: auto; }

/* Opacity only, and quick. The blur went: it read as a focus pull on every
   single navigation, which is a lot of drama for moving between two pages that
   look alike — and blur is the expensive half, since the compositor has to
   rasterise both snapshots at several radii instead of just varying their alpha.
   The two run concurrently: the outgoing page clears fast while the incoming one
   is already coming up, so there is no moment of empty canvas between them. */
::view-transition-old(root) {
  animation: dd-fade-out 90ms var(--dd-ease) both;
}
::view-transition-new(root) {
  animation: dd-fade-in 150ms var(--dd-ease) both;
}
@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;
}

/* 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) {
  ::view-transition-old(root), ::view-transition-new(root) { animation: none; }
  .dd-enter, .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) {

  /* ---- 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;
    box-shadow: var(--dd-shadow-dialog);
    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 .brand-name,
  :root.rail-collapsed .sidebar .side-nav a > span:not(.avatar),
  :root.rail-collapsed .sidebar .side-nav button > span:not(.avatar),
  :root.rail-collapsed .sidebar .side-nav .sub { max-width: 180px; opacity: 1; }
  :root.rail-collapsed .sidebar .side-search { max-height: 60px; opacity: 1; padding-top: 0; padding-bottom: 14px; }
  :root.rail-collapsed .sidebar .side-label { max-height: 60px; opacity: 1; padding-top: 8px; padding-bottom: 6px; }
  :root.rail-collapsed .sidebar .side-nav a,
  :root.rail-collapsed .sidebar .side-nav button { gap: 10px; padding: 8px; }
  :root.rail-collapsed .sidebar .side-nav .sub { margin-left: auto; }
  :root.rail-collapsed .sidebar .side-nav .side-user { padding-left: 8px; padding-right: 8px; }
  :root.rail-collapsed .sidebar .side-head { padding: 4px 0 18px; }
  :root.rail-collapsed .sidebar .brand { gap: 10px; padding: 0 8px; }

  /* ---- 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;
    box-shadow: var(--dd-shadow-dialog);
    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 on the shared duration/easing instead of snapping. */
  .dd-scrim {
    position: fixed; inset: 0;
    z-index: 95;
    background: var(--dd-backdrop);
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--dd-dur) 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-inverse);
    color: var(--dd-on-inverse);
    border-radius: 9px;
    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-inverse);
    color: var(--dd-on-inverse);
    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 — but deliberately NOT its shadow: that card is a whole dedicated
     page, this one is ordinary page content dropped into a pane, and "no
     drop shadows on page content" already covers it. 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-full);
    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; }
}
