/* Dark is the default regardless of the system's own light/dark
   setting (a deliberate choice, not an oversight); light only applies
   when the theme toggle has set data-theme="light" on the root, read
   back from localStorage on load (see the blocking script in <head>,
   which sets that attribute before first paint to avoid a flash of
   the wrong theme). */
:root {
  color-scheme: dark;
  /* The fixed bottom bar's full footprint below the sheet breakpoint:
     8px of padding either side of a touch target, its 1px top border,
     and whatever the device reserves under it. Read by the sheet, the
     artboard and the site footer, all of which stop short of it. Zero
     while the bar is empty on wider layouts. */
  --action-bar-h: 0px;
  /* A split button's caret half. One value everywhere it appears, so
     the GIF menu in a panel and the speed menu on the bar can't drift
     to different widths; the touch tiers below resize it with the
     control it's attached to. */
  /* The primary fill under a pointer. Everything wearing that fill dims
     by the same amount, background and border together, so a filled
     control never ends up with a ring brighter than its own face. */
  --primary-hover: color-mix(in srgb, var(--primary) 85%, var(--panel));
  --caret-w: 30px;
  /* The progress wash on a hold-to-confirm button. Tinted from the
     primary so it reads as the action arriving, and kept translucent so
     the label stays legible while it passes underneath. */
  --hold-tint: color-mix(in srgb, var(--primary) 30%, transparent);
  /* Surfaces, darkest to lightest: --bg is the canvas surround and
     every sunken control (inputs, chips, lock buttons, the tab track);
     --well is the sidebar's scrollable tab body; --panel is the sidebar
     chrome, popovers, the modal, and the raised active tab. --well-deep
     sits below --bg, for the color picker's value row, which wants a
     stronger, more visually distinct sunken well. --muted is the lift a
     flat control gets on hover. */
  --bg: #09090b;
  --well: #0f0f11;
  --well-rgb: 15, 15, 17;
  --panel: #18181b;
  --well-deep: #050506;
  --muted: #27272a;
  /* Borders are white at low alpha rather than a fixed zinc, the way
     shadcn's dark theme does it, so the same hairline reads at the same
     contrast on the well, the panel, and a card alike. Controls that
     take input (text fields, outline buttons, the lock tiles) share a
     translucent "input" fill and a slightly stronger border, and lift
     to --input-hover on hover instead of changing border color. */
  --border: rgba(255, 255, 255, 0.1);
  --border-strong: rgba(255, 255, 255, 0.18);
  --input: rgba(255, 255, 255, 0.05);
  --input-hover: rgba(255, 255, 255, 0.09);
  --input-border: rgba(255, 255, 255, 0.15);
  --tab-active-border: var(--input-border);
  /* Four text levels instead of one color at assorted opacities, so
     muted text stays a real, predictable color on every surface.
     --text-heading sits one step off the body text rather than at it:
     a setting's name wants to sit clearly between the section rule over
     it and its own description under it. */
  /* The sidebar's vertical rhythm, as five steps rather than the six
     near-identical values it grew: 4 and 8 group a control's own parts,
     20 separates one control from the next, and 32 opens a section. The
     gap between steps is what makes them read as structure; 4 against 6
     against 8 just reads as drift. 12 earns a step of its own because a
     heading sits closer to what it heads than controls sit to each
     other. */
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 20px;
  --space-5: 32px;

  --text: #fafafa;
  --text-heading: #d4d4d8;
  --text-muted: #a1a1aa;
  --text-subtle: #71717a;
  /* Primary is the foreground inverted onto the surface (white on dark,
     near-black on light), the way a neutral shadcn theme does it, so
     the one primary action, a locked field, an active segment, all read
     as the strongest thing on screen without needing a hue. --accent
     stays as an alias since every control below was written against
     it. */
  /* Zinc-200 rather than the full text white: on a #18181b panel the
     pure white fill glows, and this keeps the inverted look at a
     contrast that reads as a button rather than a light source. */
  --primary: #e4e4e7;
  --primary-fg: #18181b;
  --accent: var(--primary);
  /* Sliders fill in the muted text color rather than primary: a layer
     tab shows nine tracks at once, and nine bright lines competed with
     Random and the locks for attention. */
  --slider-fill: var(--text-muted);
  --ring: #71717a;
  /* shadcn's scale: 10px base, controls two steps down, cards and the
     modal one step up. */
  --radius-sm: 8px;
  --radius: 10px;
  --radius-lg: 14px;
  --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3);
  --shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.5);
  accent-color: var(--primary);
}

:root[data-theme="light"] {
  color-scheme: light;
  --bg: #f4f4f5;
  --well: #fafafa;
  --well-rgb: 250, 250, 250;
  --panel: #ffffff;
  --well-deep: #e4e4e7;
  --muted: #f4f4f5;
  --border: #e4e4e7;
  --border-strong: #d4d4d8;
  --input: #ffffff;
  --input-hover: #f4f4f5;
  --input-border: #e4e4e7;
  --tab-active-border: transparent;
  --text: #09090b;
  /* Brighter reads as darker here; the step is the same size. */
  --text-heading: #3f3f46;
  --text-muted: #71717a;
  --text-subtle: #a1a1aa;
  --primary: #18181b;
  --primary-fg: #fafafa;
  --ring: #a1a1aa;
  --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05);
  --shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.16);
}

/* Smooths the theme toggle: an element with its own transition (most
   interactive controls; see button, .field input/select, .color-swatch)
   overrides this outright rather than adding to it, since transition is
   a single cascaded property, not a per-item merge, so those keep their
   own faster hover/active feel. This is the fallback for everything
   else, buttons, tabs and inputs would already snap on hover if only
   this was here so the color-related ones are duplicated there. */
* {
  box-sizing: border-box;
  transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease,
    box-shadow 0.2s ease;
}

html, body {
  height: 100%;
}

body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  background: var(--bg);
  color: var(--text);
  display: flex;
  /* dvh, so the layout tracks the viewport a mobile browser actually
     leaves once its URL bar is on screen; 100vh is the tallest it ever
     gets, which put the footer underneath that bar. The vh line stays
     as the fallback for anything without dvh. */
  height: 100vh;
  height: 100dvh;
  overflow: hidden;
}

/* One keyboard focus treatment for everything, shadcn's: the border
   takes the ring color and a 3px half-opacity ring sits outside it.
   :focus-visible only, so a mouse click never leaves a ring behind.
   Range inputs put the ring on their thumb instead (see below). */
button:focus-visible,
.field input[type="text"]:focus-visible,
.field input[type="number"]:focus-visible,
.field select:focus-visible,
.split-btn-select:focus-visible {
  outline: none;
  border-color: var(--ring);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--ring) 50%, transparent);
}

input[type="range"]:focus-visible {
  outline: none;
}

.main-area {
  position: relative;
  flex: 1;
  display: flex;
  flex-direction: column;
  min-height: 0;
  /* Shared by the canvas area and the site footer, so the legend and
     links sit on the same inset as the artwork's edges: sidebar, right
     edge, and bottom. The brand keeps its own 20px at the top. */
  --artboard-pad: 25px;
  /* The gap under the footer strip. It matches the sidebar's own bottom
     padding rather than the artboard's inset, so the name and links line
     up with the last row of buttons in the sidebar instead of floating
     5px above it. The mobile block below hands it back to the artboard's
     inset, where there is no sidebar to line up with. */
  --footer-inset: 20px;
  /* The footer's own footprint off the bottom edge: its 32px line plus
     the inset under it. The artboard stops here rather than running
     underneath, which is what lets it centre in the space it actually
     occupies. Anything else pinned to that edge adds to it. */
  --footer-strip: calc(32px + var(--footer-inset));
  /* Now just the artboard's own margin, top and bottom alike. It used
     to carry the footer's height as well, on both ends, so that the
     artwork stayed centred on the full window: the cost was a gap above
     the artwork the size of a footer and a gap below it the size of a
     margin, which read as the artwork sitting low. Dropped to a plain
     inset here and set to a tighter one while a sheet is up. */
  --artboard-clear: var(--artboard-pad);
}

/* The page's h1, first item in the footer strip. It takes the strip's
   own size rather than one of its own, and carries its weight instead:
   at this scale bold is enough to separate a name from the links beside
   it, and one size across the strip is what makes it read as a single
   line of chrome rather than two things that happen to be adjacent. */
.brand {
  flex-shrink: 0;
  margin: 0;
  font-size: inherit;
  font-weight: 700;
  letter-spacing: -0.01em;
  color: var(--text-muted);
}

.brand a {
  color: inherit;
  text-decoration: none;
}

.brand a:hover {
  color: var(--text);
}

/* Content that should reach screen readers and search crawlers without
   occupying space in this single-viewport app, where nothing scrolls
   into view: kept in the accessibility tree and DOM text, clipped to a
   1px box instead of display:none (which drops it from both). */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.controls {
  width: 300px;
  flex-shrink: 0;
  height: 100%;
  background: var(--panel);
  border-right: 1px solid var(--border);
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.sheet-handle {
  display: none;
}

@media (max-width: 768px) {
  .sheet-handle {
    display: block;
    position: relative;
    flex-shrink: 0;
    width: 100%;
    height: 24px;
    margin: 0;
    padding: 0;
    border: 0;
    border-radius: 16px 16px 0 0;
    background: var(--panel);
    box-shadow: none;
    /* The drag is handled in script; without this the browser claims
       the gesture for a scroll before the first pointermove lands. */
    touch-action: none;
  }

  .sheet-handle::before {
    content: "";
    position: absolute;
    top: 9px;
    left: 50%;
    transform: translateX(-50%);
    width: 36px;
    height: 4px;
    border-radius: 2px;
    background: var(--border-strong);
  }
}

/* Hidden wherever it happens to be sitting; .action-bar below shows it
   once script has moved it in there, which is the only place it appears
   at all. That also covers the tick between parse and script, when it
   is still a child of <body>. */
.sidebar-toggle {
  display: none;
  padding: 0;
  align-items: center;
  justify-content: center;
  margin-top: 0;
}

.sidebar-backdrop {
  display: none;
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  z-index: 24;
}

/* One setting, parked on top of the bar while the sheet is closed (see
   showLastField in index.html, which moves the real field in here). It
   shares the bar's inset so the two stack as one block of chrome, and
   it sits under the sheet's z-index because the two are never up at the
   same time. */
.last-control {
  display: none;
  position: fixed;
  left: 0;
  right: 0;
  bottom: var(--action-bar-h);
  z-index: 24;
  align-items: flex-end;
  gap: var(--touch-gap);
  padding: 12px var(--edge-pad);
  background: var(--panel);
  border-top: 1px solid var(--border);
}

.last-control:not([hidden]) {
  display: flex;
}

.last-control-slot {
  flex: 1;
  min-width: 0;
}

/* The field brought its sidebar spacing with it; in a one-row strip the
   bottom margin would just be dead height. */
.last-control-slot .field {
  margin-bottom: 0;
}

/* The thumb bubble sits above the track, which in the sidebar means
   over a label with room to spare above it. Here the strip is one row
   tall and the label is the only thing up there, and it is already
   showing the same live value at the end of the line. */
.last-control-slot .slider-tooltip {
  display: none;
}

.last-control .last-control-close {
  flex-shrink: 0;
  width: 32px;
  margin-top: 0;
}

/* The exploration loop, plus the two buttons that choose which panel
   the sheet shows. Parked at the bottom of the screen on narrow layouts
   where the sidebar becomes that sheet (see syncActionBar() in
   index.html, which moves the live controls in here). The sheet rises
   off this bar and stops at its top edge, so Random stays one tap away
   with a panel open and the way back out is always in the same place.
   display:none on wider layouts, where all of this is in the sidebar. */
.action-bar {
  display: none;
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 26;
  align-items: center;
  gap: var(--touch-gap);
  padding: 8px var(--edge-pad);
  padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px));
  background: var(--panel);
  border-top: 1px solid var(--border);
}

.action-bar:not([hidden]) {
  display: flex;
}

/* Four fixed targets and Random taking the rest, so the primary action
   is the widest thing on the bar here too. It keeps the fill and drops
   the word: at 375px the row cannot seat both the label and an inset
   the panels can share, and the fill is what marks it as primary. The
   sidebar copy is untouched. */
.action-bar .explore-row {
  flex: 1;
  min-width: 0;
  gap: var(--touch-gap);
  margin-bottom: 0;
}

.action-bar #randomizeBtn {
  min-width: var(--touch-h);
}

.action-bar #randomizeBtn .btn-label {
  display: none;
}

/* Two arrows crossing sideways: shuffle is the widest glyph in the row
   and, at 10.8px against play's 12.7, the shortest. In a line of icons
   the eye takes height for size, so at the shared 15px it read as the
   smallest button on the bar while being the primary one. 18px puts its
   height just past the others and gives the extra width Random carries
   something to hold. Only while it stands alone; beside a word it goes
   back to matching. */
.action-bar #randomizeBtn i {
  font-size: 18px;
}

/* Each panel's button wears Random's own fill while that panel is the
   one showing, the way the tabs inside the sheet mark their own. It
   reads better than flipping one of them to a close icon, which would
   say nothing about the other panel being up.

   The :hover half is not decoration: it has to outrank
   button.secondary's hover rule, which has the same specificity and is
   declared later. Touch browsers leave :hover stuck on whatever was
   tapped last, so without it the button you just pressed is the one
   that loses its fill. */
.action-bar button.is-active,
.action-bar button.is-active:hover:not(:disabled) {
  background: var(--primary);
  border-color: var(--primary);
  color: var(--primary-fg);
}

/* 44px here rather than the 32px these controls carry in the sidebar:
   this is the one row meant to be hit with a thumb. The width reset
   opts every one of them out of the full-width button default, which
   in a flex row reads as "take everything, then shrink". */
.action-bar button,
.action-bar .split-btn-select {
  height: var(--touch-h);
  flex-shrink: 0;
  margin-top: 0;
}

/* Only the buttons need this: it undoes `button { width: 100% }`, which
   in a flex row reads as "take everything, then shrink". A select never
   had that default, and auto would collapse the caret onto its own
   background image instead of holding the token width. */
.action-bar button {
  width: auto;
}

.action-bar .icon-btn {
  min-width: var(--touch-h);
}

/* In the bar the toggle is just another button in the row. */
.action-bar .sidebar-toggle {
  display: flex;
  width: var(--touch-h);
}

/* Confirms an action whose effect isn't visible from wherever the user
   is looking, e.g. Lock All changes lock icons that may be sitting on
   a layer tab that isn't even the one currently open. Shown via
   showToast() in index.html; opacity/transform (not display) so it can
   transition, and pointer-events stays off so it never blocks a click. */
.toast {
  position: absolute;
  /* Inset from both edges, not left:50%. An absolutely positioned box
     with only a left offset gets shrink-to-fit against the space from
     that offset to the container's edge, which is half the artboard:
     anything longer than that wrapped early and looked clipped. Both
     offsets plus auto margins give it the full width to size against
     and still centre it. */
  left: var(--artboard-pad);
  right: var(--artboard-pad);
  width: fit-content;
  max-width: 100%;
  margin: 0 auto;
  top: 20px;
  transform: translateY(-8px);
  padding: 10px 16px;
  border-radius: var(--radius);
  background: var(--panel);
  border: 1px solid var(--border);
  color: var(--text);
  font-size: 13px;
  font-weight: 500;
  box-shadow: var(--shadow-lg);
  opacity: 0;
  pointer-events: none;
  z-index: 20;
  transition: opacity 0.2s ease, transform 0.2s ease;
}

.toast.visible {
  opacity: 1;
  transform: translateY(0);
}

/* First-visit "take the tour" nudge (see #tourChip in index.html).
   Same spot and surface as the toast, one layer beneath it, so a toast
   fired in the meantime still shows on top. */
.tour-chip {
  position: absolute;
  /* Same reasoning as .toast above: both offsets, so the pill can grow
     to the artboard's width before it wraps. */
  left: var(--artboard-pad);
  right: var(--artboard-pad);
  width: fit-content;
  max-width: 100%;
  margin: 0 auto;
  top: 20px;
  align-items: center;
  gap: 12px;
  /* Room on both sides of the text, and the pill grows with its
     content on desktop; below the sheet breakpoint it wraps inside
     the artboard's inset instead. */
  padding: 6px 6px 6px 18px;
  border-radius: 999px;
  background: var(--panel);
  border: 1px solid var(--border);
  color: var(--text-muted);
  font-size: 13px;
  white-space: nowrap;
  box-shadow: var(--shadow-lg);
  z-index: 19;
  animation: tourChipIn 0.3s ease both;
}

.tour-chip:not([hidden]) {
  display: inline-flex;
}

@keyframes tourChipIn {
  from { opacity: 0; transform: translateY(-6px); }
  to { opacity: 1; transform: translateY(0); }
}

/* The play offer is one line; the longer explanation lives in the
   text's tooltip. A dotted underline marks that there is one. */
.tour-chip > span:first-child {
  padding-right: 4px;
}

@media (max-width: 768px) {
  .tour-chip {
    white-space: normal;
    line-height: 1.4;
    border-radius: 20px;
  }
}

.tour-chip.play-offer > span {
  text-decoration: underline dotted;
  text-underline-offset: 3px;
  text-decoration-color: var(--text-subtle);
  cursor: help;
}

.tour-chip button {
  width: auto;
  height: 26px;
  margin-top: 0;
  padding: 0 12px;
  border-radius: 999px;
  font-size: 12px;
  flex-shrink: 0;
}

.tour-chip-dismiss {
  min-width: 26px;
  padding: 0;
  background: transparent;
  color: var(--text-subtle);
  border-color: transparent;
}

.tour-chip-dismiss:hover {
  background: var(--muted);
  color: var(--text);
}

@media (prefers-reduced-motion: reduce) {
  .tour-chip {
    animation: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .toast {
    transition: opacity 0.2s ease;
    transform: translateY(0);
  }
}

/* Below this width there's no room for a permanent side-by-side layout,
   so the sidebar becomes a sheet over the bottom of the screen, toggled
   by .sidebar-toggle instead of always being on screen. */
@media (max-width: 768px) {
  /* The touch tier. One height, one caret, one inset and one gap, read
     by the bar and by both sheet panels, so the whole bottom assembly
     lines up: a download button's left edge sits directly over Undo's.
     Gap stays under the inset, the usual toolbar ratio, so controls
     never crowd the screen edge harder than they crowd each other. */
  :root {
    --touch-h: 44px;
    --caret-w: 36px;
    --edge-pad: 16px;
    --touch-gap: 12px;
    --action-bar-h: calc(var(--touch-h) + 17px + env(safe-area-inset-bottom, 0px));
    /* Capped in px as well as dvh so the sheet stops growing on a tall
       phone and leaves the artwork the rest. At this height the canvas
       clears the sheet whole on every phone down to a 568px viewport. */
    --sheet-h: min(58dvh, 460px);
    /* The second stop, for when the settings matter more than the view:
       the sheet takes what it can and still leaves a 96px band of
       artboard, so the artwork thins to a strip rather than vanishing. */
    --sheet-h-tall: min(88dvh, calc(100dvh - var(--action-bar-h) - 96px));
  }

  /* A sheet rising off the action bar, not a side drawer. A panel that
     flew in from one edge covered the artwork it was editing, so every
     slider was a guess; this leaves the canvas on screen above it and
     .canvas-area shrinks to match, which is the whole point of the
     shape. It also lands where the bar that opens it already is. */
  .controls {
    position: fixed;
    top: auto;
    left: 0;
    right: 0;
    bottom: var(--action-bar-h);
    width: auto;
    height: var(--sheet-h);
    z-index: 25;
    border-right: none;
    border-top: 1px solid var(--border);
    border-radius: 16px 16px 0 0;
    /* Its own height, so it parks out of sight behind the bar. */
    transform: translateY(100%);
    transition: transform 0.2s ease;
    box-shadow: 0 -2px 16px rgba(0, 0, 0, 0.35);
  }

  body.sidebar-open .controls {
    transform: translateY(0);
  }

  body.sheet-tall .controls {
    height: var(--sheet-h-tall);
  }

  /* Nothing is being sent to the background here: the canvas above the
     sheet is the thing being watched, so the backdrop keeps only its
     job of closing the sheet when the artwork is tapped. */
  body.sidebar-open .sidebar-backdrop {
    display: block;
    background: transparent;
  }

  .main-area {
    --artboard-pad: var(--edge-pad);
    --footer-inset: var(--edge-pad);
  }

  /* With the sheet up, the artwork gets the shortened area to itself:
     the name would otherwise sit across the top of it, and the footer
     is behind the sheet anyway. */
  body.sidebar-open .main-area {
    --artboard-clear: 12px;
  }
}

/* A 320px screen is 44px short of seating the cluster at full size, so
   everything gives a little rather than the bar overflowing. 40px is
   still a usable target, and only the smallest phones ever see it. */
/* A 320px screen can't seat the touch tier at full size, so the tier
   itself steps down and every rule above follows. 40px is still a
   usable target, and only the smallest phones ever see it. */
@media (max-width: 359px) {
  :root {
    --touch-h: 40px;
    --caret-w: 32px;
    --edge-pad: 12px;
    --touch-gap: 8px;
  }
}

.controls-header {
  flex-shrink: 0;
  padding: 20px 20px 0;
  /* Full sidebar width, same as .controls-footer's own border-top. */
  border-bottom: 1px solid var(--border);
}

.controls-body {
  flex: 1;
  overflow-y: auto;
  padding: 16px 20px 20px;
  background: var(--well);
}

/* Hints that a tab's content continues past the visible edge. Sticky
   (not fixed/absolute) so each tracks its own edge of the scrolled
   area without JS needing to measure anything; negative margin keeps
   it from adding scroll height of its own. JS (see updateScrollFade)
   only toggles the "visible" class, based on scroll position and
   whether the panel overflows at all. */
.scroll-fade {
  position: sticky;
  left: 0;
  right: 0;
  height: 40px;
  margin-bottom: -40px;
  pointer-events: none;
  opacity: 0;
  /* Sits as the first and last child of .controls-body respectively;
     without this, default stacking paints every tab-panel's own content
     (a later sibling) over the top one, since it comes before them in
     the DOM. The bottom one comes after them, so it never showed the
     same problem. */
  z-index: 2;
  transition: opacity 0.15s ease;
}

/* Sticky offsets resolve against .controls-body's padding edge, not its
   true outer edge, so top:0/bottom:0 would leave each fade stopping
   short by exactly that padding (16px/20px), with a hard, unfaded cutoff
   in the gap where content is still being clipped. Offsetting by the
   negative of that padding pushes each fade flush with the real edge.
   Each gradient holds solid for roughly its first sixth, rather than
   starting to fade immediately, so the part nearest the true edge reads
   clearly rather than being mid-fade already; the fade-out end uses an explicit
   0-alpha var(--well) rather than the transparent keyword, since
   interpolating a solid color straight to "transparent" (rgba(0,0,0,0))
   drifts visibly toward black partway through instead of just thinning. */
.scroll-fade-top {
  top: -16px;
  background: linear-gradient(to bottom, var(--well) 0%, var(--well) 17.5%, rgba(var(--well-rgb), 0) 100%);
}

.scroll-fade-bottom {
  bottom: -20px;
  margin-bottom: 0;
  margin-top: -40px;
  background: linear-gradient(to top, var(--well) 0%, var(--well) 17.5%, rgba(var(--well-rgb), 0) 100%);
}

.scroll-fade.visible {
  opacity: 1;
}

.controls-footer {
  flex-shrink: 0;
  padding: 20px;
  border-top: 1px solid var(--border);
}

/* Undo, Auto-randomize, Random: the two icon controls at their fixed
   width and Random taking whatever is left, so the primary action is
   also the widest thing in the sidebar. */
.explore-row {
  display: flex;
  align-items: center;
  gap: 8px;
  /* Same spacing as .button-row below it keeps to the tabs, so the
     three header rows read as evenly stacked rather than paired. */
  margin-bottom: 16px;
}

.explore-row > button,
.explore-row .split-btn button {
  width: auto;
  flex-shrink: 0;
  margin-top: 0;
}

.explore-row #randomizeBtn {
  flex: 1;
}

/* min-width rather than width: .explore-row buttons and the footer's
   icon buttons both set width:auto to opt out of the full-width button
   default, and with the padding gone that would shrink these to the
   bare icon. */
.icon-btn {
  min-width: 32px;
  padding: 0;
}

.icon-btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

.split-btn {
  display: flex;
  flex-shrink: 0;
}

.split-btn button {
  width: auto;
  margin-top: 0;
}

.split-btn-main {
  border-top-right-radius: 0;
  border-bottom-right-radius: 0;
}

.split-btn-select {
  width: var(--caret-w);
  height: 32px;
  flex-shrink: 0;
  margin-top: 0;
  padding: 0;
  border: 1px solid var(--input-border);
  border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
  /* Invisible at rest (matches the shared fill), so the only visible
     line at the seam is split-btn-main's own right border, at the same
     1px weight as every other edge. */
  border-left-color: transparent;
  background-color: var(--input);
  box-shadow: var(--shadow-xs);
  color: var(--text);
  font-size: 13px;
  cursor: pointer;
  appearance: none;
  -webkit-appearance: none;
  /* Pushes the selected option's text off the visible box (the popup
     list itself is unaffected) so only the chevron shows at rest; the
     real value only needs to be visible once the list is open. */
  text-indent: -9999px;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%2371717a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6l4 4 4-4'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: center;
  background-size: 15px;
  transition: border-color 0.15s ease, background-color 0.15s ease;
}

/* The darker "well" fill only appears once the dropdown is actually
   open (a native select stays focused while its list is up), rather
   than sitting on permanently and mismatching its other half. */
.split-btn-select:focus {
  background-color: var(--input-hover);
  outline: none;
}

.split-btn-select:hover {
  background-color: var(--input-hover);
}

#playBtn.playing {
  background-color: var(--primary);
  color: var(--primary-fg);
  border-color: var(--primary);
}

/* A segmented control: the track is a sunken well, the active segment
   a raised pill with a soft shadow, and the rest plain muted text.
   shadcn's Tabs: a muted track, and the active trigger takes the input
   fill and border in dark (a plain white card in light). */
.tabs {
  display: flex;
  gap: 2px;
  padding: 3px;
  margin-bottom: 16px;
  background: var(--muted);
  border-radius: var(--radius);
}

.tab-btn {
  flex: 1;
  width: auto;
  height: 26px;
  margin-top: 0;
  padding: 0 2px;
  gap: 4px;
  /* The label plus its lock count (see .tab-count below) has to stay on
     one line even at "Layer A" + a two-digit count, or the whole track
     grows a second row. */
  white-space: nowrap;
  background: transparent;
  color: var(--text-muted);
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  font-size: 12px;
  font-weight: 500;
  transition: background-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease,
    border-color 0.15s ease;
}

.tab-btn.active {
  background: var(--input);
  color: var(--text);
  border-color: var(--tab-active-border);
  box-shadow: var(--shadow-xs);
}

.tab-btn:hover:not(.active) {
  color: var(--text);
}

/* Per-tab lock indicator (see updateLockCounts in index.html): one
   small padlock trailing the tab's name, outline when some of that
   tab's fields are locked and solid when all are, in the subtle text
   color so it reads as an annotation rather than part of the label.
   The count itself is in the tab's tooltip. Collapses to nothing when
   the tab has no locks. */
.tab-count {
  display: inline-flex;
  align-items: center;
  color: var(--text-subtle);
}

.tab-count:empty {
  display: none;
}

.tab-count svg {
  width: 11px;
  height: 11px;
}

.tab-count.all,
.tab-btn.active .tab-count {
  color: var(--text-muted);
}

.tab-btn.active .tab-count.all {
  color: var(--text);
}

.field {
  margin-bottom: var(--space-4);
}

/* A trailing fill of "░" (see ::after) stands in for the divider line
   this used to draw above the title: enough to mark a new section
   without a hard rule cutting across the sidebar, and it reads as a
   heading's own underline-in-spirit rather than a separator between
   unrelated blocks. The margin above is the same spacing the border
   used to sit inside; keep that even though the line itself is gone,
   or sections stop reading as distinct groups. */
.section-title {
  display: flex;
  align-items: center;
  gap: 10px;
  overflow: hidden;
  white-space: nowrap;
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  /* The brightest text in its group, above the setting titles it
     introduces: 12.9:1 on the dark panel against their 9.9:1, and
     19.9:1 against 10.4:1 on the light one. It sat below them for a
     while, which inverted the hierarchy, a section reading quieter than
     the fields inside it. Going the other way instead, to
     --text-subtle, measures 3.7:1 and 2.4:1, both under AA, and no
     single grey fixes that because the two themes want it moved in
     opposite directions. */
  color: var(--text);
  /* Further from what came before than from what it heads. */
  margin: var(--space-5) 0 var(--space-3);
}

/* A title that opens its panel (Layer A/B's "Fill") has nothing above
   it to separate from. */
.section-title:first-child {
  margin-top: 0;
}

.section-title::after {
  content: "░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░";
  flex: 1;
  overflow: hidden;
  color: var(--border-strong);
  /* The UI's own system-sans font smooths this glyph into a soft,
     nearly continuous smudge; a monospace face renders its dot texture
     with crisp edges instead, so it stays visibly pixelated even
     packed edge to edge with no letter-spacing pulling it apart into
     separate dots. */
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  font-size: 12px;
  line-height: 1;
}

/* One card per collapsible: the toggle is the card's header row and
   the content unfolds inside the same border below a divider, rather
   than the header and body being two separate boxes with a gap. */
.palette-section {
  margin: 0 0 var(--space-4);
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
}

.palette-toggle {
  width: 100%;
  height: 32px;
  margin-top: 0;
  padding: 0 8px 0 10px;
  background: transparent;
  border: none;
  border-radius: 0;
  color: var(--text);
  font-size: 12px;
  font-weight: 500;
  display: flex;
  align-items: center;
  justify-content: space-between;
  cursor: pointer;
}

.palette-toggle:hover {
  background: var(--muted);
}

.palette-toggle i {
  color: var(--text-muted);
  transition: transform 0.15s ease;
}

.palette-toggle[aria-expanded="true"] i {
  transform: rotate(180deg);
}

.palette-content {
  padding: 12px 10px;
  border-top: 1px solid var(--border);
}

.palette-content .hint {
  margin-top: 0;
}

.preset-chip-row {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-bottom: var(--space-4);
}

.preset-chip-row button {
  width: auto;
  flex: 0 0 auto;
  height: 26px;
  margin-top: 0;
  padding: 0 10px;
  font-size: 11px;
  background: var(--input);
  color: var(--text);
  border: 1px solid var(--input-border);
  box-shadow: var(--shadow-xs);
}

.preset-chip-row .dimension-preset-btn {
  flex: 1;
  text-align: center;
}

.preset-chip-row button:hover {
  background: var(--input-hover);
}

/* A chip whose values the composition currently matches (see
   updatePresetChips in index.html): the same lifted, muted tile a
   locked field gets, since it reports a state rather than inviting a
   click. */
.preset-chip-row button.active {
  background: var(--muted);
  border-color: var(--border-strong);
  color: var(--text);
}

.field label {
  display: flex;
  justify-content: space-between;
  align-items: center;
  font-size: 12px;
  font-weight: 500;
  margin-bottom: var(--space-2);
  /* Between the section title above and the description below, which is
     where a setting's name belongs. The description could not be dimmed
     to open that gap: on the light panel the muted grey it uses already
     measures 4.5:1, with nothing under it that still reads. Lifting
     this instead opens the same gap and gains contrast rather than
     spending it. */
  color: var(--text-heading);
}

/* The live value: plain tabular monospace in the full text color, so it
   reads as the answer next to the quieter name that asks for it. Matched
   by class rather than by being a span in a label, because a label whose
   name changes with the layer's texture carries a span for that too, and
   it is not data. */
.field label .field-value {
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  font-variant-numeric: tabular-nums;
  font-size: 11px;
  font-weight: 400;
  color: var(--text);
}

.field input[type="text"],
.field input[type="number"],
.field select {
  width: 100%;
  height: 32px;
  padding: 0 10px;
  border: 1px solid var(--input-border);
  border-radius: var(--radius-sm);
  background: var(--input);
  color: var(--text);
  font-family: inherit;
  font-size: 13px;
  box-shadow: var(--shadow-xs);
  transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease,
    box-shadow 0.15s ease;
}

.field select:hover {
  background-color: var(--input-hover);
}

/* Width/Height: the native spinner arrows are too small to hit at this
   size and clash with the flat look; the arrow keys still step them. */
.field input[type="number"]::-webkit-outer-spin-button,
.field input[type="number"]::-webkit-inner-spin-button {
  -webkit-appearance: none;
  margin: 0;
}

.field input[type="number"] {
  -moz-appearance: textfield;
}

/* The native select arrow eats into the right padding without being part
   of the declared padding box, so the text sits visibly closer to the
   right edge than the left. Replace it with a custom chevron and give
   the text equal breathing room on both sides. */
.field select {
  appearance: none;
  -webkit-appearance: none;
  padding-right: 28px;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%2371717a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6l4 4 4-4'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 8px center;
  background-size: 15px;
}

/* The input's own box is the actual click/drag target, so it's sized
   well past the 4px track drawn inside it (see the track/thumb rules
   below) rather than matching it. Browsers center a shorter track
   pseudo-element vertically within this taller box on their own, and
   the thumb's own centering (its margin-top on WebKit, automatic on
   Firefox) is relative to that track, not this height, so neither
   needs to change to match. */
.field input[type="range"] {
  -webkit-appearance: none;
  appearance: none;
  width: 100%;
  height: 24px;
  margin: 0;
  background: transparent;
  cursor: pointer;
}

/* shadcn's Slider: a 6px muted track with the filled range in primary,
   and a hollow 16px thumb (surface fill, primary border) that grows a
   half-opacity ring on hover and keyboard focus. */
.field input[type="range"]::-webkit-slider-runnable-track {
  height: 6px;
  border-radius: 3px;
  background: linear-gradient(
    to right,
    var(--slider-fill) 0%,
    var(--slider-fill) calc(var(--pct, 0) * 1%),
    var(--muted) calc(var(--pct, 0) * 1%),
    var(--muted) 100%
  );
}

.field input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 16px;
  height: 16px;
  margin-top: -5px;
  border-radius: 50%;
  background: var(--well);
  border: 1px solid var(--slider-fill);
  box-shadow: var(--shadow-xs);
  transition: box-shadow 0.15s ease;
}

.field input[type="range"]:hover::-webkit-slider-thumb,
.field input[type="range"]:focus-visible::-webkit-slider-thumb {
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--ring) 50%, transparent);
}

.field input[type="range"]::-moz-range-track {
  height: 6px;
  border-radius: 3px;
  background: var(--muted);
}

.field input[type="range"]::-moz-range-progress {
  height: 6px;
  border-radius: 3px;
  background: var(--slider-fill);
}

.field input[type="range"]::-moz-range-thumb {
  width: 16px;
  height: 16px;
  border-radius: 50%;
  background: var(--well);
  border: 1px solid var(--slider-fill);
  box-shadow: var(--shadow-xs);
  transition: box-shadow 0.15s ease;
}

.field input[type="range"]:hover::-moz-range-thumb,
.field input[type="range"]:focus-visible::-moz-range-thumb {
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--ring) 50%, transparent);
}

:root[data-theme="light"] .field select,
:root[data-theme="light"] .split-btn-select {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23a1a1aa' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6l4 4 4-4'/%3E%3C/svg%3E");
}

.slider-wrap {
  position: relative;
  width: 100%;
}

.field-row .slider-wrap {
  flex: 1;
  min-width: 0;
}

.slider-tooltip {
  position: absolute;
  bottom: 100%;
  margin-bottom: 5px;
  transform: translateX(-50%);
  background: var(--primary);
  color: var(--primary-fg);
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  font-size: 11px;
  font-variant-numeric: tabular-nums;
  font-weight: 600;
  padding: 3px 7px;
  border-radius: 6px;
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.12s ease;
  z-index: 5;
}

.slider-tooltip.visible {
  opacity: 1;
}

.slider-tooltip::after {
  content: "";
  position: absolute;
  top: 100%;
  left: 50%;
  transform: translateX(-50%);
  border: 4px solid transparent;
  border-top-color: var(--primary);
}

.field-row {
  display: flex;
  align-items: center;
  gap: 8px;
}

.field-row input,
.field-row select {
  flex: 1;
  min-width: 0;
}

.lock-btn {
  flex-shrink: 0;
  width: 32px;
  height: 32px;
  padding: 0;
  margin-top: 0;
  background: var(--input);
  color: var(--text-subtle);
  border: 1px solid var(--input-border);
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow-xs);
}

.lock-btn:hover:not(.locked) {
  background: var(--input-hover);
  color: var(--text);
}

/* Locked is a state, not an action, and a layer tab shows ten of these
   at once, so it gets a lifted, muted tile with the solid padlock glyph
   at full text strength rather than the primary fill, which would
   scatter bright squares down the sidebar competing with Random. */
.lock-btn.locked {
  background: var(--muted);
  border-color: var(--border-strong);
  color: var(--text);
}

.color-row {
  display: flex;
  gap: 10px;
}

.color-row .field {
  flex: 1;
}

.color-input-native {
  display: none;
}

.color-picker {
  position: relative;
  flex: 1;
  min-width: 0;
}

.color-swatch {
  width: 100%;
  height: 32px;
  margin-top: 0;
  padding: 2px;
  display: flex;
  align-items: center;
  border: 1px solid var(--input-border);
  border-radius: var(--radius-sm);
  background: var(--input);
  color: var(--text);
  box-shadow: var(--shadow-xs);
  transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease;
}

.color-swatch:hover {
  background: var(--input-hover);
}

.color-swatch-fill {
  flex-shrink: 0;
  width: 26px;
  height: 26px;
  border-radius: 4px;
  border: 1px solid var(--border);
}

.color-swatch-hex {
  flex: 1;
  min-width: 0;
  text-align: center;
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  font-size: 12px;
  font-variant-numeric: tabular-nums;
  text-transform: uppercase;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.color-popover {
  position: absolute;
  top: 0;
  left: 0;
  width: 220px;
  z-index: 50;
  padding: 12px;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  box-shadow: var(--shadow-lg);
}

.color-sl-square {
  position: relative;
  width: 100%;
  height: 140px;
  border-radius: 6px;
  overflow: hidden;
  border: 1px solid var(--border);
  cursor: crosshair;
  touch-action: none;
}

.color-sl-square canvas {
  display: block;
  width: 100%;
  height: 100%;
}

.color-sl-thumb {
  position: absolute;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  border: 2px solid #fff;
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4);
  transform: translate(-50%, -50%);
  pointer-events: none;
}

.color-hue-strip {
  position: relative;
  width: 100%;
  height: 14px;
  margin-top: 10px;
  border-radius: 7px;
  border: 1px solid var(--border);
  cursor: pointer;
  touch-action: none;
  background: linear-gradient(
    to right,
    hsl(0, 100%, 50%),
    hsl(60, 100%, 50%),
    hsl(120, 100%, 50%),
    hsl(180, 100%, 50%),
    hsl(240, 100%, 50%),
    hsl(300, 100%, 50%),
    hsl(360, 100%, 50%)
  );
}

.color-hue-thumb {
  position: absolute;
  top: 50%;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  border: 2px solid #fff;
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4);
  transform: translate(-50%, -50%);
  pointer-events: none;
}

.color-format-tabs {
  display: flex;
  gap: 4px;
  margin-top: 10px;
}

.color-format-btn {
  flex: 1;
  width: auto;
  height: 24px;
  margin-top: 0;
  padding: 0;
  font-size: 11px;
  background: var(--input);
  color: var(--text-muted);
  border: 1px solid var(--input-border);
}

.color-format-btn:hover:not(.active) {
  background: var(--input-hover);
  color: var(--text);
}

.color-format-btn.active {
  background: var(--primary);
  border-color: var(--primary);
  color: var(--primary-fg);
}

/* Bleeds past the popover's own 12px padding to sit flush against its
   edges, like a sunken well let into the popover rather than a box
   floating inside it. The matching 12px side padding put back on top
   of that negative margin cancels it out for the row's own content,
   so the value text still lines up under the square and hue strip
   above instead of drifting outward with the bleed. The popover's own
   overflow:hidden clips the well's square corners into its rounded
   ones, so this doesn't need to match that radius itself. */
.color-value-row {
  display: flex;
  align-items: center;
  gap: 6px;
  margin: 8px -12px -12px;
  padding: 10px 12px 12px;
  border-top: 1px solid var(--border);
  background: var(--well-deep);
}

.color-value-row[hidden] {
  display: none;
}

.color-hex-prefix {
  font-size: 13px;
  color: var(--text-subtle);
}

.color-hex-input {
  flex: 1;
  min-width: 0;
  border: none;
  background: transparent;
  color: var(--text);
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  font-size: 13px;
  font-variant-numeric: tabular-nums;
  text-transform: uppercase;
  outline: none;
}

.color-channel {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 4px;
  font-size: 11px;
  color: var(--text-muted);
}

.color-channel input {
  width: 32px;
  min-width: 0;
  border: none;
  background: transparent;
  color: var(--text);
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  font-size: 13px;
  font-variant-numeric: tabular-nums;
  text-align: center;
  outline: none;
}

/* Chrome/Safari add spinner arrows to number inputs by default; they don't
   fit this compact a field and duplicate the drag/hex ways of changing
   the value, so they're switched off here (Firefox via -moz-appearance
   on the input itself, below). */
.color-channel input::-webkit-outer-spin-button,
.color-channel input::-webkit-inner-spin-button {
  -webkit-appearance: none;
  margin: 0;
}

.color-channel input[type="number"] {
  -moz-appearance: textfield;
}

/* A press long enough to hold a button is also long enough for a phone to
   offer to select its label, or to raise a callout over it. Both are the
   OS reading the press as text handling rather than as a control.
   Prefixed as well as not: Safari only understood -webkit-user-select
   until 17, which is where this was still happening after the unprefixed
   property had been set on the hold buttons for a while.

   Scoped to the things you press and drag. Seeds, hex values and the
   prose on the guide stay selectable, because copying those is the
   point of them. */
button,
.sheet-handle,
.color-swatch,
.color-sl-square,
.color-hue-slider,
input[type="range"] {
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
}

button {
  touch-action: manipulation;
  width: 100%;
  height: 32px;
  padding: 0 12px;
  margin-top: 6px;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  background: var(--primary);
  color: var(--primary-fg);
  font-family: inherit;
  font-size: 13px;
  font-weight: 500;
  line-height: 1;
  box-shadow: var(--shadow-xs);
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  transition: border-color 0.15s ease, background-color 0.15s ease,
    color 0.15s ease, opacity 0.15s ease, transform 0.08s ease;
}

/* The bare default above is the primary style, and Random is the only
   button that uses it; every other button opts into .secondary or its
   own flat treatment and carries its own hover. */
/* Everything wearing the primary fill dims by the same amount on hover.
   Play carries that fill while it's running, but its own rule is an id
   selector and outranked button.secondary's hover, so it was the one
   primary-filled control on screen that didn't answer the pointer.

   Behind (hover: hover) because a touch browser leaves :hover stuck on
   whatever was tapped last: without the guard, Random would sit dimmed
   on a phone from the tap that ran it until the next tap somewhere
   else. */
@media (hover: hover) {
  #randomizeBtn:hover:not(:disabled),
  #playBtn.playing:hover:not(:disabled) {
    background-color: var(--primary-hover);
    border-color: var(--primary-hover);
  }
}

button:active:not(:disabled):not(.tab-btn) {
  transform: scale(0.98);
}

button i {
  font-size: 15px;
  line-height: 1;
}

/* shadcn's "outline" variant: input fill and border, lifting on hover. */
button.secondary {
  /* background-color, not the shorthand: [data-hold] below paints its
     progress into background-image, and the shorthand would wipe it out
     on every hover. */
  background-color: var(--input);
  color: var(--text);
  border: 1px solid var(--input-border);
}

button.secondary:hover:not(:disabled) {
  background-color: var(--input-hover);
}

/* Lock All, Unlock All and Undo arm on a hold rather than a press (see
   HOLD_MS in index.html). The bar fills left to right for as long as
   the button is held and snaps back the moment it is let go, because
   only the .holding state carries a transition. */
[data-hold] {
  background-image: linear-gradient(var(--hold-tint), var(--hold-tint));
  background-repeat: no-repeat;
  background-position: left center;
  background-size: 0% 100%;
}

[data-hold].holding {
  background-size: 100% 100%;
  /* Set from HOLD_MS in index.html, which owns the duration. The
     fallback only matters for the frame before that runs. */
  transition: background-size var(--hold-duration, 1000ms) linear;
}

@media (prefers-reduced-motion: reduce) {
  [data-hold].holding {
    transition: none;
  }
}

/* Fills the whole artboard side (the brand and footer paint over its
   padding), so centering within it is centering on the viewport. The
   canvas itself sits at this box's top-left corner and is centered by
   a transform (see #preview below), not by flex. */
/* Ends at the top of the footer, not at the bottom of the window, so
   the artwork centres between the top edge and that strip and the gap
   above it matches the gap below. --action-bar-h is 0 above the sheet
   breakpoint, so this one line covers both layouts. */
.canvas-area {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: calc(var(--footer-strip) + var(--action-bar-h));
  padding: var(--artboard-clear) var(--artboard-pad);
  overflow: hidden;
}

/* The artboard ends at the bottom edge, and below the breakpoint that
   edge belongs to the action bar, then to the sheet on top of it. This
   is what keeps the artwork on screen while it's being edited. Kept
   next to the rule it overrides rather than up in the sheet's own media
   query, which comes earlier in the file than the inset it has to beat. */
@media (max-width: 768px) {
  .canvas-area {
    /* Matches the sheet's own slide, so the artwork resizes with it
       rather than snapping once it lands. */
    transition: bottom 0.2s ease;
  }

  /* --sheet-actual is the sheet's measured height, published by the
     observer in index.html. One rule then covers all three of its
     sizes, including the export panel's, which is only as tall as the
     handful of buttons on it. The var's fallback is the first stop, for
     the frame before the observer's first callback. */
  body.sidebar-open .canvas-area {
    bottom: calc(var(--action-bar-h) + var(--sheet-actual, var(--sheet-h)));
  }

  body.has-last-control .canvas-area {
    bottom: calc(
      var(--footer-strip) + var(--action-bar-h) + var(--last-control-h, 0px)
    );
  }

  /* The handle already supplies the gap above the first row. */
  .controls-header {
    padding-top: 4px;
  }
}

/* On desktop the wrapper leaves no box of its own, so the footer's link
   row lays its two buttons out exactly as it did before they were
   grouped. Below the breakpoint the group becomes a real row, at the
   end of the settings scroll (see syncActionBar in index.html). */
.app-chrome {
  display: contents;
}

/* Names the panel the export button switches to, the way "Settings"
   names the other one. Only ever on screen as that panel. */
.export-title {
  display: none;
  margin-top: 0;
}

/* The sheet shows one panel at a time, chosen from the bar. Deleting
   the footer from the settings view is the point of the split: it was
   121px of permanently pinned chrome above a scroll area that had 82px
   left on a small phone. */
@media (max-width: 768px) {
  body.sheet-export .controls-header,
  body.sheet-export .controls-body {
    display: none;
  }

  body:not(.sheet-export) .controls-footer {
    display: none;
  }

  /* A handful of buttons doesn't need the settings panel's height, so
     the sheet shrinks to them and hands the difference back to the
     artwork. It still can't outgrow the first stop. */
  body.sheet-export .controls {
    height: auto;
    max-height: var(--sheet-h);
  }

  /* As a panel it owns the sheet, so it scrolls if it ever has to, and
     it drops the divider it wore as a footer. */
  body.sheet-export .controls-footer {
    flex: 0 1 auto;
    overflow-y: auto;
    border-top: none;
    background: var(--well);
  }

  /* This is a thumb surface now, not a corner of the sidebar, and it
     shares the bar's inset and gap so the two read as one stack. */
  body.sheet-export .controls-footer button,
  body.sheet-export .controls-footer .split-btn-select {
    height: var(--touch-h);
  }

  body.sheet-export .controls-footer .button-row,
  body.sheet-export .controls-footer .footer-link-row {
    gap: var(--touch-gap);
  }

  /* Three equal labels. Sizing each unit outright, rather than leaving
     it to grow from a flex basis, because the split's inner row makes
     the shared-remainder maths come out lopsided: the caret has to be
     an addition to the group, not a bite out of the label beside it.
     The split gets one unit plus the caret; the other two get a unit
     each; three units and the caret and two gaps fill the row. */
  body.sheet-export .controls-footer .button-row {
    --export-unit: calc((100% - 2 * var(--touch-gap) - var(--caret-w)) / 3);
  }

  body.sheet-export .controls-footer .button-row > button {
    flex: 0 0 var(--export-unit);
  }

  body.sheet-export .controls-footer .button-row > .split-btn {
    flex: 0 0 calc(var(--export-unit) + var(--caret-w));
  }

  body.sheet-export .controls-footer .button-row > .split-btn > .split-btn-main {
    flex: 1;
    min-width: 0;
  }

  /* Every panel takes the same side inset as the bar below it, so the
     edges of the whole bottom assembly line up. */
  .controls-header {
    padding-left: var(--edge-pad);
    padding-right: var(--edge-pad);
  }

  .controls-body {
    padding: 16px var(--edge-pad) var(--edge-pad);
  }

  .controls-footer {
    padding: var(--edge-pad);
  }

  .export-title {
    display: block;
  }

  /* Tutorial and theme, once script has moved them to the end of the
     settings scroll. They sit on their own line above the bottom fade,
     off the pinned chrome they used to be part of. */
  .app-chrome {
    display: flex;
    gap: 8px;
    margin-top: 20px;
    padding-top: 16px;
    border-top: 1px solid var(--border);
  }

  .app-chrome button {
    width: auto;
    margin-top: 0;
  }
}

/* A phone held sideways gets the sidebar layout, because it is wider
   than the sheet breakpoint, and then has no height to give it: at
   812x375 the pinned header and footer take 286 of 375px and leave 89px
   to scroll 570px of settings in. Rather than a third layout, the whole
   sidebar becomes one scroll here with the exploration row stuck to the
   top, so the settings and the export row both get the full column.
   Keyed on height, so a desktop window dragged short gets it too. */
@media (max-height: 520px) and (min-width: 769px) {
  .controls {
    overflow-y: auto;
  }

  .controls-header {
    position: sticky;
    top: 0;
    z-index: 2;
    background: var(--panel);
  }

  .controls-body {
    flex: 0 0 auto;
    overflow: visible;
  }

  /* They mark the edges of a scroll box that no longer scrolls. */
  .scroll-fade {
    display: none;
  }
}

/* The sheet's slide and the artboard resize that tracks it are the same
   gesture, so they sit out together. Both then land in their open or
   closed position on the same frame the class changes. */
@media (prefers-reduced-motion: reduce) {
  .controls,
  .canvas-area {
    transition: none;
  }
}

/* One quiet strip under the canvas: the shortcut legend on the left,
   the links on the right, both at the subtle text color and both
   waking to full strength together on hover. Inset by the artboard's
   own padding on the sides and the bottom, so it frames the artwork
   rather than floating off its edges; the brand above keeps its own
   20px, since it belongs to the header row. */
.site-footer {
  flex-shrink: 0;
  margin-top: auto;
  position: relative;
  display: flex;
  align-items: center;
  gap: 16px;
  height: 32px;
  margin-bottom: var(--footer-inset);
  padding: 0 var(--artboard-pad);
  font-size: 11px;
  color: var(--text-subtle);
  transition: color 0.15s ease;
}

.site-footer:hover {
  color: var(--text);
}

/* The footer animates its color once; its descendants must not add
   their own transition on top (the global * rule would), or each
   nesting level re-eases the inherited value and text at different
   depths (a link, a separator, the year inside its own span, a key
   chip) appears to fade at different speeds. Only the chip border keeps
   a transition of its own. */
.site-footer a,
.site-footer span,
.site-footer kbd {
  transition: none;
}

.site-footer kbd {
  transition: border-color 0.15s ease;
}

.site-footer-links,
.site-footer-keys {
  display: flex;
  align-items: center;
  gap: 6px;
}

.site-footer-keys {
  gap: 14px;
}

/* Pinned to the right edge by an auto margin rather than by
   space-between, so the strip keeps the same shape on the narrow layout
   where the legend beside the name is hidden. */
.site-footer-links {
  margin-left: auto;
}

.site-footer-keys span {
  display: inline-flex;
  align-items: center;
  gap: 5px;
}

.site-footer kbd {
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  font-size: inherit;
  line-height: 1;
  padding: 3px 5px;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--input);
  color: inherit;
}

.site-footer:hover kbd {
  border-color: var(--border-strong);
}

.site-footer a {
  color: inherit;
  text-decoration: none;
}

.site-footer a:hover {
  text-decoration: underline;
}

.site-footer-links span {
  opacity: 0.7;
}

/* Same as the artboard above: the strip clears the action bar instead
   of sitting under it. Placed after .site-footer's own margin-bottom
   for the same source-order reason. */
@media (max-width: 768px) {
  .site-footer {
    margin-bottom: calc(var(--footer-inset) + var(--action-bar-h));
  }

  body.has-last-control .site-footer {
    margin-bottom: calc(
      var(--footer-inset) + var(--action-bar-h) + var(--last-control-h, 0px)
    );
  }
}

/* No keyboard to bind on a phone or a touch-only screen; the links go
   back to centered on their own. */
@media (max-width: 768px), (hover: none) {
  .site-footer-keys {
    display: none;
  }
}

/* Anchored at the artboard's top-left corner; centerCanvas() in the
   script translates it to the middle. A translate doesn't move an
   element's layout position, and a resize that keeps its corner in
   place isn't a shift either, so the canvas taking a new size on every
   Random pass or Auto-randomize tick no longer counts toward CLS the
   way it did when flex re-centered it. Both caps resolve against
   .canvas-area's padding box, so a tall preset shrinks to fit between
   the brand and the footer instead of running under either; the
   browser keeps the canvas's aspect ratio. */
#preview {
  position: absolute;
  top: var(--artboard-clear);
  left: var(--artboard-pad);
  max-width: calc(100% - 2 * var(--artboard-pad));
  max-height: calc(100% - 2 * var(--artboard-clear));
  border: 1px solid var(--border);
  border-radius: 4px;
  box-shadow: var(--shadow-lg);
}

/* Until the script sizes it, the canvas is the default 300x150 box;
   keeping that out of sight means a slow load shows nothing in the
   artboard, then the artwork, rather than a blank frame that jumps. */
#preview:not([width]) {
  visibility: hidden;
}

.hint {
  font-size: 12px;
  line-height: 1.5;
  color: var(--text-muted);
  margin-top: 8px;
  margin-bottom: 12px;
}

/* A description belongs to the setting above it, so it sits between the
   title and the control rather than below the whole thing, where it read
   as a caption on the control before it and a preamble to the next one
   after. Same size as the title it sits under; the color carries the
   difference on its own. */
.field .hint {
  /* Pulled back to --space-1 from the label's --space-2, so a
     description sits nearer its own title than the control it explains. */
  margin-top: calc(var(--space-1) - var(--space-2));
  margin-bottom: var(--space-2);
}

/* Two descriptions are one block, not two. */
.field .hint + .hint {
  margin-top: calc(var(--space-1) - var(--space-2));
}

/* One hint carries a health warning rather than an explanation. It reads
   in the same colour as the description above it, because a notice that
   shouts gets taken for chrome and read past. A hanging marker carries
   the difference instead, the way a footnote does. */
.hint-warn {
  padding-left: 13px;
  text-indent: -13px;
}

/* An asterisk is drawn small and parked up near the ascender, so at the
   text's own size it reads as a footnote mark rather than as part of the
   line. 1.54em makes its ink exactly the x-height of the 12px text, and
   the negative vertical-align drops it from its own baseline onto the
   text's, so it fills the same band the lowercase letters do. The zero
   line-height keeps the bigger glyph from opening the line box up.
   5px plus the glyph's advance is the 13px the hang is indented by, so
   a second line starts under the first word rather than under the mark. */
.hint-warn::before {
  content: "*";
  font-size: 1.54em;
  line-height: 0;
  vertical-align: -0.356em;
  padding-right: 5px;
  color: var(--text);
}

/* Not in the strip above the bar on a phone: that row exists to put one
   control within reach of the artwork, and a paragraph in it costs a
   line of the picture for something already read in the sheet. */
#lastControl .hint {
  display: none;
}

.button-row {
  display: flex;
  gap: 8px;
  margin-bottom: 16px;
}

.button-row > button {
  flex: 1;
  /* Lets a button shrink below its content width instead of spilling
     past the row's padding; the export row's three buttons otherwise
     overflow the sidebar by a few pixels. */
  min-width: 0;
  margin-top: 0;
}

/* In the 260px sidebar there isn't room to give all three an equal
   third, so the GIF split keeps its natural width and PNG and SVG share
   what's left. The sheet's export panel is wide enough to do better and
   overrides this. */
.button-row > .split-btn {
  flex: 0 0 auto;
}

/* Three labeled buttons plus a select in one 260px sidebar: the default
   12px side padding is 4px too much per button to fit. The sheet's
   export panel is wider than that and keeps the standard padding. */
@media (min-width: 769px) {
  .controls-footer .button-row > button,
  .controls-footer .button-row .split-btn-main {
    padding: 0 8px;
  }
}

.footer-link-row {
  display: flex;
  gap: 8px;
}

.footer-link-row .split-btn {
  flex: 1;
  min-width: 0;
}

.footer-link-row #copyLinkBtn {
  flex: 1;
  min-width: 0;
  margin-top: 0;
}

.footer-link-row .icon-btn {
  width: auto;
  flex: 0 0 auto;
  margin-top: 0;
}

.preset-row {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 8px;
}

.preset-label {
  font-size: 12px;
  font-weight: 600;
}

.preset-status {
  flex: 1;
  font-size: 11px;
  font-variant-numeric: tabular-nums;
  color: var(--text-subtle);
}

/* The three colors a slot holds (background, Layer A, Layer B), so one
   slot is recognizable next to another without loading it. One small
   strip rather than three separate chips, and dimmed until the row is
   hovered: enough to tell the slots apart at a glance without three
   saturated squares pulling the eye away from the controls. */
.preset-swatches {
  display: flex;
  /* A square the height of the slot label's text (12px at line-height
     normal), split into three vertical bands. */
  width: 14px;
  height: 14px;
  border-radius: 3px;
  border: 1px solid var(--border);
  overflow: hidden;
  opacity: 0.4;
  transition: opacity 0.15s ease;
}

/* The stronger border on hover keeps a dark band from dissolving into
   the well behind it once the square is at full strength. */
.preset-row:hover .preset-swatches {
  opacity: 1;
  border-color: var(--border-strong);
}

.preset-swatches:empty {
  display: none;
}

.preset-swatches i {
  display: block;
  flex: 1;
}

.preset-btn {
  flex-shrink: 0;
  width: 30px;
  height: 30px;
  padding: 0;
  margin-top: 0;
  background: var(--input);
  color: var(--text);
  border: 1px solid var(--input-border);
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow-xs);
}

.preset-btn:hover:not(:disabled) {
  background: var(--input-hover);
}

.preset-btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

/* The <i> reserves its 1em box on its own, so a button lays out at
   its final width before the script below fills the icon in; without
   this, every button with an icon grew by 1em when the SVGs landed. */
.ph {
  display: inline-block;
  width: 1em;
  height: 1em;
  flex: none;
}

.ph svg {
  width: 1em;
  height: 1em;
  display: block;
}

.ph-spinner-gap {
  animation: spin 0.8s linear infinite;
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

button:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}


/* Tutorial modal: opened from the "?" button next to the theme toggle,
   and automatically on a visitor's first visit (see the "don't show
   again" checkbox below). One step at a time rather than a nav-plus-
   content split, so there's no sidebar text to misalign and the whole
   thing collapses to a single narrow column on a small screen with
   nothing to reflow. */
.tutorial-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.55);
  z-index: 40;
}

.tutorial-modal {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: min(420px, 92vw);
  max-height: 88vh;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-lg);
  z-index: 41;
  flex-direction: column;
  overflow: hidden;
}

.tutorial-modal:not([hidden]) {
  display: flex;
}

.tutorial-modal-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 14px 16px 14px 20px;
  border-bottom: 1px solid var(--border);
  flex-shrink: 0;
}

.tutorial-modal-header h2 {
  margin: 0;
  font-size: 16px;
}

.tutorial-modal-header button {
  width: auto;
  margin-top: 0;
}

/* Reserves room for the tallest step (see the 900px+ override below,
   which drops this once the nav arrows move outside the modal) so the
   footer's Prev/Next never shift under a cursor that's already sitting
   on them mid-click-through. Measured against the tallest step's actual
   content at a narrow (375px) width, plus the body's own padding. */
.tutorial-modal-body {
  flex: 1;
  min-height: 332px;
  overflow-y: auto;
  padding: 20px 24px 28px;
}

.tutorial-step {
  display: none;
}

.tutorial-step.active {
  display: block;
}

.tutorial-step .tutorial-eyebrow {
  margin: 4px 0 14px;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.tutorial-step h3 {
  margin: 0 0 14px;
  font-size: 16px;
  letter-spacing: -0.01em;
  text-wrap: balance;
}

.tutorial-step p {
  margin: 0;
  font-size: 13px;
  line-height: 1.6;
  color: var(--text-muted);
}

/* Echoes the real control it names, in miniature, so the reader can
   match the word in the sentence to the button they'll actually look
   for in the sidebar. */
.ui-ref {
  display: inline-block;
  padding: 1px 6px;
  border-radius: 4px;
  border: 1px solid var(--border);
  background: var(--well);
  font-size: 0.92em;
  font-weight: 600;
  white-space: nowrap;
}

.tutorial-modal-footer {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 10px 16px 16px;
  border-top: 1px solid var(--border);
  flex-shrink: 0;
}

.tutorial-nav-arrow {
  width: auto;
  margin-top: 0;
  flex-shrink: 0;
}

.tutorial-nav-arrow.prev i svg {
  transform: rotate(90deg);
}

.tutorial-nav-arrow.next i svg {
  transform: rotate(-90deg);
}

/* Replay (the last step) swaps in a restart icon, which already points
   the right way on its own, so the generic 90deg rotation above needs
   canceling out here specifically. */
.tutorial-nav-arrow.next.replay i svg {
  transform: rotate(0deg);
}

/* .tutorial-nav-arrow-wide duplicates tutorialPrevBtn/tutorialNextBtn
   for the 900px+ layout (see index.html, kept in sync in
   goToTutorialStep) rather than being those same buttons repositioned:
   .tutorial-modal centers itself with a transform (see above), which
   makes it the containing block for any position:fixed descendant and
   clips it via the modal's own overflow:hidden, so a footer button
   can't actually escape to float beside the modal from inside it. Living
   outside the modal's DOM subtree entirely sidesteps that. */
.tutorial-nav-arrow-wide {
  display: none;
}

/* At 900px+ there's enough side gutter to show these floating beside
   the modal instead of the in-footer pair, fixed to the viewport's own
   vertical center rather than the modal's. The modal is also centered
   via top:50% + translate(-50%,-50%), so its center never moves even as
   its height changes step to step; anchoring these to that same fixed
   point means they stay under the cursor across the whole walkthrough
   without needing the min-height reservation below 900px uses instead.
   The modal's own width is a flat 420px anywhere this query applies
   (min(420px, 92vw) only ever clamps to 92vw under ~456px), so the
   offset from center can be a plain number instead of replicating that
   min(). */
@media (min-width: 900px) {
  .tutorial-modal-body {
    min-height: 0;
  }

  .tutorial-modal-footer .tutorial-nav-arrow {
    display: none;
  }

  /* Centered via top + a negative margin rather than the more usual
     translateY(-50%): the global button:active rule (see button, way
     above) sets its own transform for the press-down feel, and a single
     transform can only hold one value, so translateY here would just
     get replaced by that scale(), silently dropping the centering
     offset and snapping the button down by half its own height on every
     press, moving it out from under the cursor before the click even
     fires. Leaving transform free for that rule keeps this element in
     place while still letting the press effect apply normally. */
  /* Bigger than the icon inside needs (that stays at the usual 15px via
     button i, way above) purely to widen the click target: this button
     floats alone against the backdrop, with the backdrop's own
     click-to-close listener right behind it, so a near-miss here closes
     the tutorial instead of just failing to advance a step. */
  .tutorial-nav-arrow-wide:not([hidden]) {
    display: flex;
    position: fixed;
    top: 50%;
    margin-top: -28px;
    width: 56px;
    height: 56px;
    padding: 0;
    border-radius: 50%;
    background: var(--panel);
    box-shadow: var(--shadow-lg);
    z-index: 41;
  }

  .tutorial-nav-arrow-wide.prev {
    left: calc(50% - 210px - 28px - 56px);
  }

  .tutorial-nav-arrow-wide.next {
    left: calc(50% + 210px + 28px);
  }
}

/* The tutorial's own controls (close, the footer arrows, the floating
   arrows) stay opaque: the outline button's translucent input fill
   they'd otherwise inherit all but disappears on hover over the dimmed
   backdrop. Solid panel at rest, a solid one-step-lighter mix on
   hover, and a small lift with a shadow as the motion. transform is
   free to use here: button:active's own scale() replaces it for the
   press, which is the intended feel. */
.tutorial-modal button.secondary,
button.secondary.tutorial-nav-arrow-wide:not([hidden]) {
  background: var(--panel);
  transition: background-color 0.15s ease, border-color 0.15s ease,
    box-shadow 0.15s ease, transform 0.15s ease;
}

.tutorial-modal button.secondary:hover:not(:disabled),
button.secondary.tutorial-nav-arrow-wide:hover:not(:disabled) {
  background: color-mix(in srgb, var(--panel) 90%, var(--text));
  box-shadow: var(--shadow-lg);
  transform: translateY(-1px);
}

@media (prefers-reduced-motion: reduce) {
  .tutorial-modal button.secondary:hover:not(:disabled),
  button.secondary.tutorial-nav-arrow-wide:hover:not(:disabled) {
    transform: none;
  }
}

.tutorial-dots {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
}

.tutorial-dot {
  width: 6px;
  height: 6px;
  padding: 0;
  margin: 0;
  border: none;
  border-radius: 3px;
  background: var(--border-strong);
  transition: width 0.2s ease, background-color 0.2s ease;
}

.tutorial-dot.active {
  width: 18px;
  background: var(--accent);
}

/* --- Demo preview: same sunken frame as the app's own .controls-body,
   so every demo reads as a miniature of the real UI. --- */
.tutorial-demo {
  position: relative;
  height: 108px;
  border-radius: var(--radius);
  background: var(--well);
  border: 1px solid var(--border);
  margin-bottom: 16px;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 14px;
  overflow: hidden;
  padding: 0 20px;
}

/* --- Step 1: Intro. A grid of short strokes, each rotated to its own
   angle, the way a flow field is conventionally visualized (many small
   vectors, not one continuous line). Static: nothing to click yet. --- */
.mini-flow-field {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 28px 20px;
}

.mini-flow-line {
  display: block;
  width: 24px;
  height: 4px;
  border-radius: 2px;
  background: var(--accent);
}

/* --- Step 3: Tabs. "Layer A" lights up on a loop. --- */
.tutorial-demo-tabs {
  align-items: center;
}

/* Mirrors the real .tabs segmented control: muted track, and the
   target segment takes the active pill's input fill and border. */
.mini-tabs {
  display: flex;
  gap: 2px;
  padding: 3px;
  background: var(--muted);
  border-radius: var(--radius);
}

.mini-tab {
  font-size: 11px;
  font-weight: 500;
  padding: 0 12px;
  height: 26px;
  display: flex;
  align-items: center;
  border-radius: var(--radius-sm);
  border: 1px solid transparent;
  color: var(--text-muted);
}

.mini-tab-target {
  animation: tutorialTabClick 3.4s ease-in-out infinite;
}

@keyframes tutorialTabClick {
  0%, 34% { background: transparent; color: var(--text-muted); border-color: transparent; box-shadow: none; }
  40%, 88% { background: var(--input); color: var(--text); border-color: var(--tab-active-border); box-shadow: var(--shadow-xs); }
  96%, 100% { background: transparent; color: var(--text-muted); border-color: transparent; box-shadow: none; }
}

/* Open/closed crossfade for the Lock demo below; timing matches the
   state-change moment (34-40%) and reset (88-96%) every other demo
   animates around. */
@keyframes tutorialFadeShow {
  0%, 34% { opacity: 1; }
  40%, 88% { opacity: 0; }
  96%, 100% { opacity: 1; }
}

@keyframes tutorialFadeHide {
  0%, 34% { opacity: 0; }
  40%, 88% { opacity: 1; }
  96%, 100% { opacity: 0; }
}

.mini-lock-a {
  opacity: 1;
  animation: tutorialFadeShow 3.4s ease-in-out infinite;
}

.mini-lock-b {
  opacity: 0;
  animation: tutorialFadeHide 3.4s ease-in-out infinite;
}

/* --- Step 5: Dimension presets & Tile. Reuses .mini-tab/.mini-tab-target
   from the Tabs demo above; "Tile" is the target that lights up. --- */

/* --- Step 6: Shape mask. Three shapes cycle through on a loop (diamond,
   circle, stripes), standing in for the full set (those two plus
   vignette and its inverse, a corner or side vignette, and stripes)
   without trying to cram all six into one small preview. Three
   evenly-spaced phases of the same shared timeline rather than the
   two-state tutorialFadeShow/Hide pair the Theme/Lock demos use. --- */
.mini-mask-preview {
  position: relative;
  width: 56px;
  height: 56px;
  flex-shrink: 0;
}

.mini-mask-shape {
  position: absolute;
  inset: 0;
  border: 1px solid var(--border);
  background: linear-gradient(135deg, #38bdf8, #f472b6);
}

@keyframes tutorialMaskPhase1 {
  0%, 27% { opacity: 1; }
  33%, 94% { opacity: 0; }
  100% { opacity: 1; }
}

@keyframes tutorialMaskPhase2 {
  0%, 27% { opacity: 0; }
  33%, 60% { opacity: 1; }
  66%, 100% { opacity: 0; }
}

@keyframes tutorialMaskPhase3 {
  0%, 60% { opacity: 0; }
  66%, 94% { opacity: 1; }
  100% { opacity: 0; }
}

.mini-mask-diamond {
  border-radius: 6px;
  opacity: 1;
  /* Rotated a plain square: scaled down first so the rotated corners
     (a square's diagonal is ~1.4x its side) still land inside the
     56px box instead of overflowing it. */
  transform: rotate(45deg) scale(0.7);
  animation: tutorialMaskPhase1 5.1s ease-in-out infinite;
}

.mini-mask-circle {
  border-radius: 50%;
  opacity: 0;
  animation: tutorialMaskPhase2 5.1s ease-in-out infinite;
}

.mini-mask-stripes {
  border-radius: 6px;
  background: repeating-linear-gradient(90deg, #38bdf8 0px, #38bdf8 8px, #f472b6 8px, #f472b6 16px);
  opacity: 0;
  animation: tutorialMaskPhase3 5.1s ease-in-out infinite;
}

/* --- Step 4: Layer settings. The thumb drifts across the track. --- */
.mini-slider-row {
  display: flex;
  flex-direction: column;
  gap: 8px;
  width: 100%;
}

.mini-slider-label {
  font-size: 11px;
  color: var(--text);
  opacity: 0.7;
}

.mini-slider-track {
  position: relative;
  height: 6px;
  border-radius: 3px;
  background: var(--muted);
}

.mini-slider-thumb-drag {
  position: absolute;
  top: 50%;
  width: 13px;
  height: 13px;
  border-radius: 50%;
  background: var(--slider-fill);
  transform: translate(-50%, -50%);
  animation: tutorialSliderDrag 3.4s ease-in-out infinite;
}

@keyframes tutorialSliderDrag {
  0%, 6% { left: 12%; }
  40%, 88% { left: 86%; }
  100% { left: 12%; }
}

/* --- Step 8: Palette Range & Density Range. A static track: two thumbs
   bounding a highlighted middle segment reads as a range without needing
   any motion. --- */
.mini-range-row {
  display: flex;
  flex-direction: column;
  gap: 8px;
  width: 100%;
}

.mini-range-track {
  position: relative;
  height: 6px;
  border-radius: 3px;
  background: var(--muted);
}

.mini-range-fill {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 25%;
  right: 25%;
  background: var(--slider-fill);
  border-radius: 2px;
}

.mini-range-thumb {
  position: absolute;
  top: 50%;
  width: 13px;
  height: 13px;
  border-radius: 50%;
  background: var(--slider-fill);
  transform: translate(-50%, -50%);
}

.mini-range-thumb-min {
  left: 25%;
}

.mini-range-thumb-max {
  left: 75%;
}

/* --- Step 7: Locking. The lock fills solid on a loop. --- */
.tutorial-demo-lock {
  gap: 12px;
}

.mini-lock-btn {
  position: relative;
  width: 32px;
  height: 32px;
  flex-shrink: 0;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: var(--input);
  animation: tutorialLockBg 3.4s ease-in-out infinite;
}

.mini-lock-btn i {
  position: absolute;
  inset: 0;
  /* Fills the 32px tile rather than keeping the 1em box .ph reserves. */
  width: auto;
  height: auto;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 16px;
}

.mini-lock-label {
  font-size: 11px;
  color: var(--text);
  opacity: 0.7;
}

@keyframes tutorialLockBg {
  0%, 34% { background: var(--input); border-color: var(--input-border); }
  40%, 88% { background: var(--muted); border-color: var(--border-strong); }
  96%, 100% { background: var(--input); border-color: var(--input-border); }
}

.tutorial-demo-lock .mini-lock-b {
  color: var(--text);
}

/* --- Step 2: Random. The swatch snaps to a new look, an instant cut
   (steps(1)) rather than a smooth blend, since a reroll doesn't ease
   into place. --- */
.mini-canvas-swatch {
  width: 60px;
  height: 60px;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: linear-gradient(135deg, #38bdf8, #f472b6);
  flex-shrink: 0;
  animation: tutorialSwatchChange 3.4s steps(1) infinite;
}

@keyframes tutorialSwatchChange {
  0%, 39% { background: linear-gradient(135deg, #38bdf8, #f472b6); }
  40%, 100% { background: linear-gradient(135deg, #facc15, #22d3ee); }
}

.mini-random-btn {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 8px 14px;
  border-radius: 6px;
  background: var(--accent);
  color: var(--primary-fg);
  font-size: 12px;
  animation: tutorialBtnPress 3.4s ease-in-out infinite;
}

@keyframes tutorialBtnPress {
  0%, 34% { opacity: 1; }
  40% { opacity: 0.75; }
  46%, 100% { opacity: 1; }
}

/* --- Step 9: Auto-randomize. The live canvas (see startTutorialDemo
   in index.html) cycles for real. --- */
.tutorial-demo-explore canvas,
.tutorial-demo-animate canvas {
  border-radius: 6px;
  border: 1px solid var(--border);
}

/* --- Step 13: Animated exports. The other live canvas, running one
   cycle of each kind in turn; the kind's name sits to its left (menu
   side, like the sidebar), in a chip wide enough for any of the four
   so nothing shifts when it changes. --- */
.tutorial-demo-kind {
  flex-shrink: 0;
  min-width: 44px;
  padding: 4px 10px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: var(--panel);
  color: var(--text-muted);
  font-size: 11px;
  font-weight: 500;
  text-align: center;
}

/* Stacked to the left of the canvas, in the sidebar's own real order:
   the play/auto-randomize button lives up in the title row, well above
   any per-field lock icon, so it sits above the lock here too. */
.mini-explore-controls {
  display: flex;
  flex-direction: column;
  gap: 8px;
  flex-shrink: 0;
}

.tutorial-demo-lock-badge {
  position: relative;
  width: 26px;
  height: 26px;
}

.tutorial-demo-lock-badge i {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 13px;
}

.mini-play-btn {
  width: 26px;
  height: 26px;
  border-radius: 6px;
  background: var(--accent);
  color: var(--primary-fg);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 13px;
  animation: tutorialBtnPress 3.4s ease-in-out infinite;
}

/* --- Step 10: Undo. The swatch reverts on a loop. --- */
.mini-canvas-swatch-b {
  animation: tutorialSwatchRevert 3.4s steps(1) infinite;
}

@keyframes tutorialSwatchRevert {
  0%, 39% { background: linear-gradient(135deg, #a78bfa, #34d399); }
  40%, 100% { background: linear-gradient(135deg, #fb7185, #fbbf24); }
}

.mini-undo-btn {
  width: 32px;
  height: 32px;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: var(--input);
  color: var(--text);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 15px;
  animation: tutorialLockBg 3.4s ease-in-out infinite;
}

/* --- Step 11: Saved states & links. A "Saved" badge flashes on a loop. --- */
.mini-icon-btn {
  width: 40px;
  height: 40px;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--input);
  color: var(--text);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 18px;
}

.mini-icon-btn-save {
  animation: tutorialBtnPress 3.4s ease-in-out infinite;
}

.mini-saved-badge {
  font-size: 11px;
  padding: 4px 10px;
  border-radius: 12px;
  background: var(--accent);
  color: var(--primary-fg);
  animation: tutorialBadgeFlash 3.4s ease-in-out infinite;
}

@keyframes tutorialBadgeFlash {
  0%, 38% { opacity: 0; transform: translateY(2px); }
  44%, 82% { opacity: 1; transform: translateY(0); }
  92%, 100% { opacity: 0; transform: translateY(2px); }
}

/* --- Step 12: Exporting. The icon nudges down on a loop. --- */
.mini-icon-btn-export {
  animation: tutorialDownloadNudge 3.4s ease-in-out infinite;
}

@keyframes tutorialDownloadNudge {
  0%, 34% { transform: translateY(0); background: var(--input); border-color: var(--input-border); color: var(--text); }
  40% { transform: translateY(3px); background: var(--accent); border-color: var(--accent); color: var(--primary-fg); }
  46%, 88% { transform: translateY(0); background: var(--accent); border-color: var(--accent); color: var(--primary-fg); }
  96%, 100% { transform: translateY(0); background: var(--input); border-color: var(--input-border); color: var(--text); }
}

@media (prefers-reduced-motion: reduce) {
  .mini-tab-target,
  .mini-lock-a,
  .mini-lock-b,
  .mini-slider-thumb-drag,
  .mini-lock-btn,
  .mini-random-btn,
  .mini-canvas-swatch,
  .mini-canvas-swatch-b,
  .mini-undo-btn,
  .mini-icon-btn-save,
  .mini-saved-badge,
  .mini-icon-btn-export,
  .mini-play-btn,
  .mini-mask-diamond,
  .mini-mask-circle,
  .mini-mask-stripes {
    animation: none;
  }
}

@media (max-width: 480px) {
  .tutorial-modal {
    width: 94vw;
    max-height: 92vh;
  }

  .tutorial-modal-body {
    padding: 16px 16px 4px;
  }

  .tutorial-demo {
    height: 92px;
    padding: 0 14px;
  }
}
