* { box-sizing: border-box; }

html, body { height: 100%; }

body {
  margin: 0;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  background: var(--bg);
  color: var(--text-primary);
  font-family: var(--font-body);
  position: relative;
}

/* -- Background: a soft, very-slowly-drifting gradient mesh --
   Two stacked layers: a flat base (so the mesh's own opacity never
   washes out the page background) and the bloom mesh itself, blurred so
   the blooms read as soft light rather than hard-edged gradients. Fixed
   positioning keeps it from scrolling with content; -10% inset plus a
   gentle scale in the animation means the blur/animation never reveals a
   hard edge. */
body::before {
  content: "";
  position: fixed;
  inset: -10%;
  z-index: -2;
  background: var(--base);
}

body::after {
  content: "";
  position: fixed;
  inset: -10%;
  z-index: -1;
  opacity: var(--mesh-opacity);
  background:
    radial-gradient(38% 38% at 18% 22%, var(--mesh-bloom-1), transparent 70%),
    radial-gradient(34% 34% at 82% 68%, var(--mesh-bloom-2), transparent 70%),
    radial-gradient(46% 46% at 60% 12%, var(--mesh-bloom-3), transparent 72%);
  filter: blur(60px);
  animation: meshDrift 60s ease-in-out infinite alternate;
  pointer-events: none;
  will-change: transform;
}

@keyframes meshDrift {
  0%   { transform: translate3d(0, 0, 0) scale(1); }
  50%  { transform: translate3d(-3%, 2%, 0) scale(1.06); }
  100% { transform: translate3d(3%, -2%, 0) scale(1.03); }
}

/* -- Page chrome -- */

header.site {
  display: flex; align-items: center; justify-content: space-between;
  gap: 1rem; padding: 1.75rem var(--page-pad-inline) 1rem;
  flex-wrap: wrap;
  max-width: var(--content-max-width); width: 100%;
  margin: 0 auto;
}

header.site h1 {
  margin: 0; color: var(--ink);
  font-family: var(--font-display);
  font-weight: 700;
  letter-spacing: -0.01em;
  /* The masthead is the one place the display face gets real presence,
     so let it grow with the viewport instead of sitting at a single
     fixed size -- noticeably bigger at desktop width than on mobile. */
  font-size: clamp(1.6rem, 1rem + 2vw, 2.75rem);
}
header.site p { margin: 0; color: var(--text-secondary); }

main {
  /* Top-anchored at every width (mobile already read this way simply
     because its content fills most of the viewport; desktop needs it
     stated explicitly since two short panels leave plenty of room for
     vertical centring to strand them in the middle of the page). Gap to
     the masthead above is deliberately small so the header and the
     panels read as one composed block, not two separate sections. */
  max-width: var(--content-max-width); width: 100%;
  margin: 0 auto;
  padding: .25rem var(--page-pad-inline) 3rem;
  /* index.html stacks two zones here (the admin-owned template, then the
     user's own categories) -- each a sibling <section>. Without this,
     main was a plain block and its two .categories children (each its
     own flex row with gap:1.5rem *inside* itself, see .categories below)
     had no rule at all governing the space *between* them, so their glass
     panels visually collided. gap on main is the single place that
     separation is expressed, at the same 1.5rem rhythm .categories uses
     internally, so the whole page reads as one consistent scale rather
     than a patched-on margin. admin.html reuses main/app.css too, but it
     has only one child there, so this is a no-op for it. */
  display: flex;
  flex-direction: column;
  gap: 1.5rem;
}

/* -- Dashboard zone (template vs. personal) --
   Each zone is a heading (who owns these links) stacked over its
   .categories row, so a user can tell the un-editable admin-owned links
   above from their own editable ones below -- see .dashboard-section
   above for why the *space between* zones lives on `main` rather than
   here. */
.dashboard-section {
  display: flex;
  flex-direction: column;
  gap: .75rem;
}

/* Collapses the zone to nothing when its .categories container is truly
   empty, the same way .category-footer:empty / .dashboard-footer:empty
   (below) already zero their own margin rather than leaving a dead gap.
   This only ever fires for #template-root: renderDashboard() is called
   for it with no `footer` option, so an empty template leaves it with
   zero child nodes. #root is different -- main.js always passes a
   `footer` callback for it, so it always gets a `.dashboard-footer` node
   appended (empty of buttons outside edit mode, but still a child node),
   and :empty never matches it. That is deliberate, not a gap in this
   rule: an empty personal zone still has a heading worth showing (it is
   the one place "+ Add category" appears in edit mode), it just has
   nothing under it, which is not the "orphaned space between two
   panels" problem this rule exists to prevent. */
.dashboard-section:has(> .categories:empty) {
  display: none;
}

.section-header h2 {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 1.1rem;
  color: var(--ink);
}

.section-header .section-note {
  margin: .2rem 0 0;
  font-size: .85rem;
  color: var(--text-secondary);
}

.categories {
  /* Flexbox, not CSS Grid: an auto-fit/auto-fill grid was tried first
     (equal-width columns that collapse when there are fewer categories
     than tracks), but the always-present `.dashboard-footer` sibling
     below needs to span the full row -- and a grid item spanning
     `1 / -1` touches every generated track, including the ones with no
     `.category` in them, which stops auto-fit from ever collapsing them
     empty. That silently kept reserving a dead extra column no matter
     how the track-sizing function was written. Flexbox has no such
     trap: items simply wrap onto new lines based on the space actually
     used, and `.dashboard-footer`'s own flex-basis:100% (below) forces
     it onto its own line without needing to know the column count. */
  display: flex; flex-wrap: wrap; gap: 1.5rem;
}

/* -- Category: outer glass panel -- */

.category {
  /* flex-basis 380px is a *preferred* width, not a minimum (flex-shrink
     stays at its default 1): with 2 categories they split the row
     evenly and each ends up well over 380px; a 3rd or 4th joins the
     same row if there's room, and any that don't fit wrap to a new row.
     min-width:0 plus min(380px, 100%) keeps a lone category on a very
     narrow viewport from being forced wider than the viewport itself. */
  flex: 1 1 min(380px, 100%);
  min-width: 0;
  position: relative;
  overflow: hidden;
  background: var(--glass-outer-bg);
  border: 1px solid var(--glass-outer-border);
  border-radius: var(--radius-lg);
  padding: 1.25rem 1.25rem 1rem;
  box-shadow: 0 8px 32px var(--glass-outer-shadow);
  /* Scoped to the outer panel only -- one compositing layer per
     .category, not per .tile. A per-tile blur was considered (and
     rejected) while chasing the entrance-animation paint bug above: with
     a page of, say, 4 categories x 8 tiles that would be 32 blurred
     layers instead of 4, for no visible gain (the tiles already read as
     opaque glass chips via --icon-chip-bg, not translucency over the
     background mesh). Keeping the blur here, where it actually earns its
     cost, is the fix -- see .tile-icon below, which has no
     backdrop-filter of its own. */
  backdrop-filter: blur(var(--glass-blur)) saturate(160%);
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(160%);
}

@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .category { background: var(--glass-outer-bg-solid); }
}

.category h2 {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: 0.95rem; text-transform: uppercase; letter-spacing: .08em;
  color: var(--text-secondary); margin: 0 0 .85rem;
}

/* -- Tile grid: Chrome-new-tab-style circular shortcuts --
   Each category's links render into their own wrapping grid (render.js's
   renderDashboard() -> `.tile-grid`), one circular icon chip with a
   centred label beneath it per link -- the shape of Chrome's own new-tab
   shortcut row, built from this product's own glass/brand tokens instead
   of copied wholesale. auto-fill + a fixed track minimum (rather than a
   fixed column COUNT) is what makes "5-6 across on desktop, 3-4 on a
   phone" happen automatically as the category panel's own width changes,
   without a media query duplicating that number. Grid row-sizing also
   means every card sharing a row is exactly as tall as its tallest
   sibling, so the row reads as even regardless of a one- vs two-line
   label next to it. */
.tile-grid {
  display: grid;
  /* 6.75rem (108px), not the original 4.75rem: measured against the real
     rendered .tile-label font (Inter's fallback stack at .78rem/600,
     since Inter itself is not self-hosted -- see fonts.css), "BoondManager"
     -- the longest real link name in the live template/personal data --
     needs 92.6px on one line; minus this track's ~3.2px of .tile padding
     that leaves 104.8px, a ~12px margin over the measured word. Verified
     with a headless-Chromium measurement, not eyeballed (see the PR that
     introduced this comment for the script). */
  grid-template-columns: repeat(auto-fill, minmax(6.75rem, 1fr));
  gap: 1.15rem .5rem;
  margin: 0;
}
/* Edit mode needs a wider column: the ↑ ↓ Edit Delete cluster below the
   label (see .tile-controls) wants two 44px-plus controls per row, which
   does not fit the view-mode track width. :has() scopes the wider track
   to a grid that actually contains a decorated `.tile-cell` -- i.e. only
   while editing -- so the compact view-mode density is untouched.
   Progressive enhancement: a browser without :has() keeps the narrower
   track and the controls simply wrap to one per row instead of two,
   which still works, just taller. */
.tile-grid:has(.tile-cell) {
  grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
}

.tile {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: .5rem;
  padding: .3rem .1rem;
  border-radius: var(--radius);
  text-decoration: none;
  color: var(--text-on-glass);
  text-align: center;
  transition: transform var(--duration-hover) var(--ease-out);
}

.tile:hover,
.tile:focus-visible {
  transform: translateY(-2px);
}

/* The circle itself: a neutral glass chip holding a colourful uploaded
   logo (its own <img>, untouched) or a Font Awesome glyph tinted by the
   theme (see render.js's iconNode() / app.css's .icon-glyph below). Sized
   well past the 44x44px touch-target floor. */
.tile-icon {
  flex-shrink: 0;
  width: 3.4rem; height: 3.4rem;
  display: flex; align-items: center; justify-content: center;
  border-radius: 50%;
  background: var(--icon-chip-bg);
  color: var(--icon-chip-fg);
  font-size: 1.2rem;
  box-shadow: 0 2px 10px var(--glass-inner-shadow);
  transition:
    background var(--duration-hover) var(--ease-out),
    box-shadow var(--duration-hover) var(--ease-out);
}
.tile-icon img {
  width: 100%; height: 100%;
  padding: .65rem;
  border-radius: 50%;
  object-fit: contain;
}

/* Font Awesome icons are drawn as a mask over `currentColor` rather than
   as a webfont glyph, so the chip's own colour still tints them and only
   the icons actually used are ever downloaded. --icon-url is set per tile
   by render.js. */
.tile .icon-glyph {
  /* display:inline-block (not the bare inline default a <span> starts
     with) so width/height actually apply: this glyph sits two levels
     down from the flex .tile-icon circle (inside <i class="icon-chip">,
     itself not a flex container), where relying on an ancestor's flex
     context to blockify it -- the trick .theme-toggle .icon-glyph gets
     away with by being a *direct* flex child -- silently fails and the
     mask renders at its content-box size of nothing. */
  display: inline-block;
  width: 1.4rem;
  height: 1.4rem;
  background: currentColor;
  -webkit-mask: var(--icon-url) center / contain no-repeat;
  mask: var(--icon-url) center / contain no-repeat;
}

/* Hover/focus: lift + a soft glow around the circle -- the same "lift and
   brighten" language as the rest of the product's glass surfaces, reused
   here at the scale of a single chip instead of a whole row. */
.tile:hover .tile-icon,
.tile:focus-visible .tile-icon {
  background: var(--icon-chip-bg-hover);
  box-shadow: 0 10px 22px var(--glass-inner-shadow), 0 0 0 6px var(--icon-chip-bg-hover);
}

.tile-label {
  font-size: .78rem;
  font-weight: 600;
  line-height: 1.25;
  color: var(--text-on-glass);
  max-width: 100%;
  /* Up to two lines (e.g. "SIJO Assistant") before ellipsizing, rather
     than truncating a one-line label early -- every card in a row still
     matches height because the grid track, not this box, sets row height. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  /* No `overflow-wrap: anywhere` (nor `break-word`, which -- despite the
     name -- breaks mid-word too; the two differ only in min-content sizing,
     not in where an actual line break can land). Word-wrapping stays at
     its default: a break may only happen at a space, so an ordinary name
     never splits mid-word. .tile-grid's track above is now wide enough
     that every real name (including "BoondManager", the longest) fits
     without needing to. A name with two+ words that still overflows two
     lines (e.g. "SIJO Assistant" if it were ever longer) still gets a
     clean ellipsis from -webkit-line-clamp itself, no extra CSS needed --
     that mechanism only ever breaks at the space it already wrapped at. */
}

/* The one case default word-wrapping cannot help: a single unbroken token
   (no space to wrap at) that is still too wide for the track despite the
   measurement above -- a name longer than anything in the live data today,
   but not one this layout should ever again resolve by chopping a word in
   half. render.js's labelNode() adds this class only when link.name has no
   whitespace; it swaps the multi-line clamp for a single line that
   ellipsizes instead, which -webkit-line-clamp cannot do on its own for a
   width (rather than line-count) overflow -- verified empirically: line-
   clamp's automatic ellipsis fires only when wrapping pushes content past
   the line count, never for an unbroken line that is merely too wide. */
.tile-label.tile-label-solid {
  display: block;
  -webkit-line-clamp: unset;
  white-space: nowrap;
  text-overflow: ellipsis;
}

/* A link whose URL fails the client-side scheme allowlist renders as a
   plain (non-anchor) tile so it stays visible but is never clickable --
   flagged on the circle's own boundary (a non-text UI boundary, so the
   3:1 floor applies) and echoed in the label's colour (text, 4.5:1). */
.tile-invalid {
  cursor: default;
}
.tile-invalid .tile-icon {
  border: 1.5px dashed var(--danger);
  box-shadow: none;
}
.tile-invalid .tile-label { color: var(--danger); }

/* "+ Add link": the reference's trailing "+ Add shortcut" circle, shaped
   like every other tile in the grid (same .tile/.tile-icon/.tile-label
   classes) but dashed and muted at rest so it reads as an affordance, not
   a real link -- and fills in like a real tile on hover/focus, which is
   the detail that makes it feel native rather than imitated. */
.tile-add {
  cursor: pointer;
  background: transparent;
  border: 0;
  font: inherit;
  /* In edit mode this sits in the same grid row as .tile-cell siblings,
     which are taller (label + control cluster); grid's default stretch
     alignment grows this item to match, so re-centre its own icon+label
     in the extra height instead of leaving it pinned to the top. */
  justify-content: center;
}
.tile-icon-add {
  border: 1.5px dashed var(--text-on-glass-muted);
  background: transparent;
  color: var(--text-on-glass-muted);
  font-size: 1.5rem;
  font-weight: 400;
  box-shadow: none;
}
.tile:hover .tile-icon-add,
.tile:focus-visible .tile-icon-add {
  /* --accent (royal) sits at only ~2.5:1 on the dark-mode category glass
     -- well under the 3:1 non-text floor for this boundary -- while
     --link already carries the right contrast in both themes (it is the
     same brand value as --focus-ring: royal in light, mist in dark). */
  border-color: var(--link);
  color: var(--link);
}
.tile-add .tile-label { color: var(--text-on-glass-muted); }

/* -- Signature element: a faint pointer-tracking spotlight --
   --mx/--my are set per-element (percentages local to that element's own
   box) by static/js/spotlight.js on pointermove, throttled with
   requestAnimationFrame. Disabled outright for touch/coarse pointers and
   under prefers-reduced-motion -- both below and defensively in JS. */
.category::after, .tile::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: radial-gradient(
    var(--spotlight-size) circle at var(--mx, 50%) var(--my, 50%),
    var(--spotlight-color),
    transparent 65%
  );
  opacity: 0;
  transition: opacity var(--duration-hover) var(--ease-out);
  pointer-events: none;
}
.category.spotlight-active::after,
.tile.spotlight-active::after {
  opacity: 1;
}
@media (pointer: coarse) {
  .category::after, .tile::after { display: none; }
}

button, .button {
  font: inherit; cursor: pointer; border-radius: 10px; border: 0;
  padding: .5rem .9rem; background: var(--accent); color: #fff;
  display: inline-block; text-decoration: none; box-sizing: border-box;
  transition: background var(--duration-hover) var(--ease-out), opacity var(--duration-hover) var(--ease-out);
}
button:hover, .button:hover { background: var(--accent-strong); }
button.secondary, .button.secondary { background: transparent; color: var(--link); }
button.secondary:hover, .button.secondary:hover { background: var(--icon-chip-bg); }
/* Ghost by default: a destructive action reads as a warning the instant it
   fills the same solid red as everything else, and a category can carry a
   dozen of these (one per link, plus "Delete category") -- filled-red
   everywhere stops meaning "careful" and starts meaning "this page is
   broken". At rest it is transparent with a --danger label and a matching
   border (a non-text UI boundary, so it only needs to clear 3:1, not the
   text floor); it only becomes the solid --danger-surface block -- with
   white text, the pairing already validated in tokens.css's dark-mode
   comment for exactly this background -- at the moment someone is actually
   about to use it: hover or keyboard focus. See
   tests/test_theme_tokens.py-style measurement in this change's report for
   the rest-state numbers in both themes. */
button.danger, .button.danger {
  background: transparent;
  color: var(--danger);
  border: 1.5px solid var(--danger);
}
button.danger:hover, .button.danger:hover,
button.danger:focus-visible, .button.danger:focus-visible {
  background: var(--danger-surface);
  color: var(--danger-surface-fg);
  border-color: transparent;
}
button:disabled { opacity: .5; cursor: not-allowed; }

/* A real panel, not a one-line caption: matches .signin's card weight
   (see the signed-out section below) so "loading" reads as a deliberate,
   full state of the page rather than a sliver of text over an otherwise
   blank main. main.js hides <main> for the whole time this is visible
   (see main[hidden] below), so there is never a moment where the header
   sits over an empty content area -- only "loading card" or "real
   content", never both, never neither. */
.loading {
  max-width: 26rem;
  margin: 4rem auto;
  padding: 2rem;
  text-align: center;
  border-radius: var(--radius-lg);
  background: var(--glass-inner-bg);
  border: 1px solid var(--glass-inner-border);
  backdrop-filter: blur(var(--glass-blur-inner));
  color: var(--text-on-glass-muted);
}
.loading[hidden] { display: none; }

/* main sets `display: flex` itself (see "-- Page chrome --" above), which
   -- as an author-stylesheet rule -- otherwise wins the cascade over the
   user-agent's own `[hidden] { display: none }` (origin trumps
   specificity here, not the reverse); every other hideable element in
   this file that sets its own `display` carries the same explicit
   override for the same reason (.loading, .banner above). Without this,
   giving <main> a `hidden` attribute while the dashboard is loading or a
   fatal error/sign-in panel occupies #root instead would not actually
   hide it. */
main[hidden] { display: none; }

.banner {
  margin: 0 auto 1rem; max-width: var(--content-max-width);
  padding: .8rem 1rem; border-radius: 10px;
  background: var(--banner-bg); color: var(--banner-fg);
}
.banner.error { background: var(--banner-error-bg); color: var(--danger); }
.banner[hidden] { display: none; }
.banner a.button { margin-left: .5rem; }

#session { display: flex; align-items: center; gap: .6rem; }

/* Edit mode: wraps a rendered tile (an <a class="tile"> or, for a link
   whose URL fails the client-side scheme allowlist, a non-clickable
   <span class="tile tile-invalid">) together with its per-link controls.
   Deliberately NOT `.tile` itself -- nesting `.tile` inside `.tile` was a
   bug that was fixed; keep them siblings. A grid item like any other tile
   (see .tile-grid above), just taller: the ↑ ↓ Edit Delete cluster no
   longer fits beside a circle, so it sits in a compact row underneath the
   label instead. Its visibility is governed by .tile-controls below --
   hover/focus-revealed where a hover exists, always on where it doesn't --
   but the layout here (this wrapper, and the reserved space for the
   cluster) is unconditional, so nothing shifts when it appears. */
.tile-cell {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: .4rem;
  /* Positioning context for .tile-controls once it's taken out of flow
     under `@media (hover: hover)` below -- see that block's comment for
     why. On the fallback (in-flow) path this is inert: a static child
     inside a relative parent lays out exactly as it always did. */
  position: relative;
}
.tile-cell .tile { padding-bottom: 0; }

/* Always exactly 2 columns (↑ ↓ on row 1, Edit Delete on row 2) rather
   than a flex row left to wrap wherever the available width happens to
   break it -- with 4 controls, that produced an uneven 3-then-1 split at
   some widths. A grid with a fixed column count has no such ambiguity. */
.tile-controls {
  display: grid;
  grid-template-columns: repeat(2, auto);
  justify-content: center;
  gap: .3rem;
  max-width: 100%;
  /* Visible by default (and always on a browser that supports neither
     media feature below): the safe fallback is "shown", never "silently
     unreachable". */
  opacity: 1;
}
/* Each control clears the 44x44px touch-target floor even though the
   glyph/label inside it is small. */
.tile-controls button {
  min-width: 2.75rem;
  min-height: 2.75rem;
  padding: .3rem .4rem;
  font-size: .78rem;
}

/* A category with eight links used to mean eight always-visible ↑ ↓ Edit
   Delete clusters stacked down the panel -- roughly tripling its height
   and reading as a wall of controls rather than an editable list. Where a
   hover exists, the cluster now stays hidden until its own tile is
   hovered or a control inside it has keyboard focus (:focus-within, not
   :focus -- it must stay visible while any one of its four buttons holds
   focus, not just the first). `pointer-events: none` while hidden stops an
   invisible control from swallowing a click meant for something behind
   it; it does not affect Tab order or focusability, which is exactly what
   keeps this reachable by keyboard even before it is visible -- tabbing
   onto a hidden button focuses it, which triggers :focus-within, which
   reveals it, in that order. */
@media (hover: hover) {
  /* `opacity: 0` alone hides the pixels but the box still sits in the
     .tile-cell flex column and still reserves its own height -- every
     row in edit mode ended up as tall as an invisible 2-row control
     cluster, stretching the grid out with dead vertical gaps even though
     nothing was visibly there. Taking it out of flow (`position:
     absolute`, anchored to .tile-cell above) fixes that: .tile-cell's
     rendered height collapses to just its .tile child again, so edit-mode
     rows sit exactly as tight as view-mode ones, and revealing the
     cluster on hover/focus is a pure overlay -- opacity/pointer-events
     only, nothing else on the page moves. `top: 100%` + `margin-top`
     reproduces the .4rem gap the flex `gap` used to provide; `left: 0;
     right: 0` + `width: max-content; max-width: 100%` centres it the same
     way `justify-content: center` did when it was a normal grid/flex
     item. The background/border/blur are new -- a transparent overlay
     sitting on top of the *next* tile down would be illegible -- tried
     first with one of the translucent --glass-inner-* tokens (the ones
     every other floating chip in this file uses), but at their normal
     opacity the tile underneath still showed clearly through, with its
     own label text visually colliding with "Edit"/"Delete". `--surface`
     is this file's one *solid*, non-glass card token (already used by
     dialog.editor for exactly this "must read as clearly separated from
     whatever is behind it" reason -- see tokens.css's comment on it), so
     the overlay uses that instead: fully opaque in both themes, nothing
     shows through. See .tile-grid's `padding-bottom` below for how the
     resulting overlay avoids being clipped by .category's `overflow:
     hidden` in the bottom row. */
  .tile-controls {
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    margin-top: .4rem;
    width: max-content;
    max-width: 100%;
    padding: .35rem;
    border-radius: var(--radius);
    background: var(--surface);
    border: 1px solid var(--glass-inner-border);
    box-shadow: 0 10px 22px var(--glass-inner-shadow);
    z-index: 2;
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--duration-hover) var(--ease-out);
  }
  .tile-cell:hover .tile-controls,
  .tile-cell:focus-within .tile-controls {
    opacity: 1;
    pointer-events: auto;
  }
  /* The entrance animation below (`.tile-grid > .tile-cell { animation:
     tileEnter ... both; }`) ends on `transform: translateY(0)`, and
     `both` keeps that computed value applied forever after the animation
     finishes -- a `transform` of any value, including a no-op
     translateY(0), establishes a stacking context (CSS Transforms
     spec). That makes *every* .tile-cell its own stacking context, which
     traps this z-index:2 overlay inside its own cell: it wins against
     its own sibling `.tile`, but a *later* tile-cell (a lower row, or
     the next column) is a separate stacking context painted after it in
     DOM order regardless of z-index, so that cell's own opaque tile
     would still paint over this one's revealed overlay wherever they
     visually overlap. Raising the *cell's* z-index on the same
     hover/focus trigger (not just the overlay's) promotes its whole
     stacking context -- overlay included -- above every sibling cell's. */
  .tile-cell:hover,
  .tile-cell:focus-within {
    z-index: 3;
  }
  /* .tile-controls above is now an absolutely-positioned overlay anchored
     below its .tile-cell, roughly 2 rows of 2.75rem buttons + row gap +
     its own padding + the .4rem offset above (~7rem total). A tile in the
     grid's last row has nothing beneath it to push the panel taller, so
     without this the overlay would extend past .category's own padding
     box and get silently clipped by its `overflow: hidden` (kept
     deliberately, elsewhere in this file, for the glass panel's rounded
     corners) -- Edit/Delete would be unreachable for exactly the tiles at
     the bottom of every category. Reserving that height as bottom padding
     on the grid itself (rather than removing the clip) means the *empty
     space* at the very end of the panel grows instead, not the space
     between rows -- the dead-gap regression this whole fix exists to
     avoid. Scoped to edit mode only (`:has(.tile-cell)`, same guard the
     wider edit-mode track above uses) so view mode's panel height is
     untouched. */
  .tile-grid:has(.tile-cell) {
    padding-bottom: 7.5rem;
  }
}
/* A touch device has no hover to reveal the cluster with, so it stays
   exactly as visible as it always was -- the only way it would ever be
   reachable there. Unlike the hover-capable branch above, it is
   deliberately left in normal flow: an always-visible *overlay* would
   permanently cover the tiles below it, which is fine as a hover-only
   transient but not as this branch's permanent state. */
@media (hover: none) {
  .tile-controls {
    opacity: 1;
    pointer-events: auto;
  }
}

.category-footer { display: flex; gap: .4rem; flex-wrap: wrap; margin: .4rem 0 1rem; }
.category-footer button { font-size: .8rem; padding: .35rem .6rem; }
.category-footer:empty { margin: 0; }

/* #root (.categories) is a flex row; render.js appends this footer node as
   a direct sibling of the .category panels, so without forcing it onto
   its own line it would just sit at the end of the last row next to a
   category instead of reading as a page-level action row underneath
   everything. flex-basis:100% makes it wider than any remaining space on
   the current line, which flex-wrap resolves by pushing it onto a new one. */
.dashboard-footer { flex: 1 1 100%; margin-top: .5rem; }
.dashboard-footer:empty { margin: 0; }

/* Empty personal zone: render.js's renderDashboard() only ever appends
   this (via main.js's emptyState option) when the signed-in user has zero
   personal categories -- see main.js's personalEmptyState(). Same
   flex-basis:100% device as .dashboard-footer above, since #root is the
   same flex row and this sits alongside (before) that always-present
   footer node. */
.empty-state { flex: 1 1 100%; color: var(--text-secondary); }
.empty-state p { margin: 0 0 .3rem; }
.empty-state p:last-child { margin-bottom: 0; }

/* Edit-mode controls fade in on mount rather than popping -- since they're
   freshly-created DOM nodes on every paint() (see main.js), a CSS
   *animation* (not a *transition*, which needs a prior state to move from)
   plays automatically the moment each node is inserted. Opacity-only, so
   the flex/grid layout space is reserved instantly and nothing shifts.
   .tile-controls is deliberately NOT included here: an animation with
   fill-mode `both` keeps overriding its property's plain value for as
   long as it holds (that is the point of `both`), which would permanently
   pin opacity at 1 and make the hover/focus-reveal opacity toggle above
   inert. Its own reveal transition covers the same "don't just pop in"
   goal instead. */
.category-footer, .dashboard-footer {
  animation: editFadeIn var(--duration-fade) var(--ease-out) both;
}
@keyframes editFadeIn {
  from { opacity: 0; }
  to { opacity: 1; }
}

dialog.editor {
  border: 0; border-radius: var(--radius-lg); padding: 1.5rem;
  background: var(--surface); color: var(--text-primary); min-width: 320px;
  max-width: min(480px, 90vw);
  box-shadow: 0 20px 60px var(--glass-outer-shadow);
}
dialog.editor::backdrop { background: var(--backdrop); }
dialog.editor h3 { margin: 0 0 .5rem; font-family: var(--font-display); }
dialog.editor label { display: block; margin: .6rem 0 .2rem; font-size: .85rem; color: var(--text-secondary); }
dialog.editor input[type="text"], dialog.editor input[type="url"], dialog.editor select {
  width: 100%; padding: .5rem .6rem; border-radius: 8px;
  border: 1px solid var(--text-secondary); background: transparent; color: inherit;
  font: inherit; box-sizing: border-box;
}
dialog.editor input[type="checkbox"] { width: auto; margin-right: .4rem; }
dialog.editor .actions { display: flex; gap: .5rem; justify-content: flex-end; margin-top: 1rem; }
dialog.editor .field-error { color: var(--danger); font-size: .8rem; min-height: 1rem; margin-top: .5rem; }
dialog.editor .icon-upload { margin: .4rem 0 .2rem; }
dialog.editor .icon-upload label { margin: 0 0 .2rem; }
dialog.editor .icon-upload input[type="file"] { width: 100%; font-size: .8rem; color: inherit; }
dialog.editor .upload-status { display: block; font-size: .78rem; color: var(--text-secondary); min-height: 1rem; margin-top: .2rem; }

/* -- Entrance motion --
   Tiles rise in with a short stagger. The stagger is capped at the 5th
   item (rather than growing unbounded with tile count) specifically so
   the whole sequence finishes well under the ~500ms budget regardless of
   how many links a category ends up with.

   Deliberately transform-only -- no `opacity` in this keyframe. An
   earlier version ramped opacity 0 -> 1 alongside the rise, which meant
   every tile's *default* state (the `from` keyframe, held by
   `animation-fill-mode: both` before the animation starts) was
   `opacity: 0`. If the animation's start is ever deferred by the
   compositor -- observed in practice on this exact combination of a
   `backdrop-filter` ancestor (`.category`, see above) and many staggered
   `animation-delay`d children -- that `opacity: 0` is what the browser
   paints, indefinitely, until some unrelated forced repaint (any
   interaction) makes it catch up. That is the "links are in the DOM but
   invisible until I click something" bug: content's visibility must
   never depend on an animation actually running. Restricting the
   animation to `transform` closes the failure mode by construction --
   `transform` has no rest state that hides content, so a tile is exactly
   as visible whether this animation runs, is skipped
   (`prefers-reduced-motion`, see below), or never starts at all.

   `both` is kept (not dropped) for a second reason unrelated to this bug:
   it keeps the *computed* `transform: translateY(0)` applied forever
   after the animation ends, which is what gives every `.tile-cell` its
   own stacking context for `.tile-controls`' hover-reveal overlay -- see
   that block's own comment on `z-index: 3` for why. Losing `both` would
   silently break that unless it were re-derived some other way. */
@keyframes tileEnter {
  from { transform: translateY(10px); }
  to   { transform: translateY(0); }
}
.tile-grid > .tile, .tile-grid > .tile-cell {
  animation: tileEnter var(--duration-entrance) var(--ease-out) both;
}
.tile-grid > .tile:nth-child(2), .tile-grid > .tile-cell:nth-child(2) { animation-delay: 0ms; }
.tile-grid > .tile:nth-child(3), .tile-grid > .tile-cell:nth-child(3) { animation-delay: var(--stagger-step); }
.tile-grid > .tile:nth-child(4), .tile-grid > .tile-cell:nth-child(4) { animation-delay: calc(var(--stagger-step) * 2); }
.tile-grid > .tile:nth-child(5), .tile-grid > .tile-cell:nth-child(5) { animation-delay: calc(var(--stagger-step) * 3); }
.tile-grid > .tile:nth-child(n+6), .tile-grid > .tile-cell:nth-child(n+6) { animation-delay: calc(var(--stagger-step) * 4); }

/* -- Focus visibility --
   Never remove an outline without replacing it with something at least
   as visible. A thick offset ring in the accent colour reads clearly on
   both the flat page background and the glass surfaces in both themes. */
:where(a, button, input, select, [tabindex]):focus-visible {
  outline: 3px solid var(--focus-ring);
  outline-offset: 2px;
  border-radius: 6px;
}
.tile:focus-visible {
  outline: 3px solid var(--focus-ring);
  outline-offset: 2px;
}

/* -- Motion budget: reduced motion -- */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
  body::after { animation: none; }
  .tile:hover, .tile:focus-visible { transform: none; }
  .category::after, .tile::after { display: none; }
}

/* -- Theme toggle --
   Three states rather than a cycling button so "follow the system" is
   visible and reachable, not just an initial condition. */
.site-actions { display: flex; align-items: center; gap: .75rem; }

.theme-toggle {
  display: inline-flex;
  padding: 2px;
  border-radius: 10px;
  background: var(--icon-chip-bg);
}

.theme-toggle .theme-option {
  background: transparent;
  padding: .35rem .5rem;
  border-radius: 8px;
  color: var(--text-secondary);
  display: inline-flex;
  align-items: center;
  transition: background var(--duration-hover) var(--ease-out),
              color var(--duration-hover) var(--ease-out);
}

.theme-toggle .theme-option:hover { color: var(--text-primary); }

.theme-toggle .theme-option[aria-pressed="true"] {
  background: var(--control-selected-bg);
  color: var(--control-selected-fg);
}

.theme-toggle .icon-glyph {
  width: 1rem;
  height: 1rem;
  background: currentColor;
  -webkit-mask: var(--icon-url) center / contain no-repeat;
  mask: var(--icon-url) center / contain no-repeat;
}

/* -- Signed out --
   The only thing on the page before authentication. */
.signin {
  max-width: 26rem;
  margin: 4rem auto;
  padding: 2rem;
  text-align: center;
  border-radius: var(--radius-lg);
  background: var(--glass-inner-bg);
  border: 1px solid var(--glass-inner-border);
  backdrop-filter: blur(var(--glass-blur-inner));
}
.signin h2 { margin: 0 0 .5rem; color: var(--text-on-glass); }
.signin p { margin: 0 0 1.5rem; color: var(--text-on-glass-muted); }

/* -- Admin surface --
   Navy rather than paper in both themes, so this page can never be
   mistaken for a personal one at a glance. The background alone is not
   enough: --ink/--muted/--text-on-glass* default to the LIGHT scheme's
   values whenever the viewer's OS isn't set to dark, which paints the
   "Common links" heading (--ink) the same navy as the page behind it --
   0:1 contrast, an invisible heading. Every foreground token that lands
   on this surface is pinned to its dark-scheme value here, so the page
   carries its own self-contained dark palette regardless of the ambient
   scheme. --text-primary/--text-secondary need no entry of their own:
   both are defined in :root as var(--ink)/var(--muted), which re-resolve
   against whatever --ink/--muted this element carries. */
body.admin {
  --base: var(--admin-surface);
  --bg: var(--base);
  --ink: var(--dark-ink);
  --muted: var(--dark-muted);
  --text-on-glass: var(--dark-text-on-glass);
  --text-on-glass-muted: var(--dark-muted);
}
body.admin .provenance {
  margin: 0 var(--page-pad-inline) 1rem;
  color: var(--text-on-glass-muted);
  font-size: .85rem;
}
