/* ============================================================
   CODE EDITOR — app-specific styles
   Shared chrome (tokens, base, buttons, header, rail, panels,
   modal, save/share UI) comes from the hosted shared.css.
   Follow the CSS ground rules in CLAUDE.md.
   ============================================================ */

:root {
  /* A deliberate extra accent beyond the shared tokens: green = "go" on the
     Play control (the green-flag convention). There is no shared green token,
     and this one meaning justifies the colour. */
  --go: #2fa84f;

  /* Soft resting border: primary at low opacity. The calm-input look shared
     by the sprite fields, the mode select, and unselected sprite chips —
     one definition so they can't drift apart. */
  --border-soft: color-mix(in srgb, var(--primary) 20%, transparent);

  /* The one colour that means "this block is executing" — Scratch's glow
     yellow. A second deliberate extra beyond the shared tokens, on the same
     grounds as --go above: one meaning, used in one place, and a meaning
     nothing in the shared palette carries. It is only free because the
     Blockly selection glow (also yellow) is turned off; if that ever comes
     back, these two have to stop being the same colour. */
  --running: #fff200;

  /* How far a CATEGORY heading rides below the top of its own box in the
     palette — the only lever there is on the gap between "Events" and the
     "When" under it (Blockly ignores `gap` on a label; see blocks.js).
     IT WAS A TOKEN BECAUSE TWO RULES HAD TO AGREE: .cat-rail padded its
     top by the same amount so the rail's first row stayed level with the
     heading. That coupling is gone — the rail's first element is a filled
     row, which lines up against the first BLOCK by its box rather than
     against the heading by a baseline, so .cat-col carries a measured 3px
     of its own and this now has exactly one reader. Kept named because
     what it means ("how far a heading rides below its box") is worth more
     than the 4px it stands for. */
  --head-drop: 4px;

  /* The narrowest the app is allowed to be. Read the html/body rules
     directly below for where the number comes from and what it trades
     against — it is measured, not picked. */
  --app-min: 1200px;
}

/* --- The app has a FLOOR, and under it the window scrolls ---
   Nothing in this layout is responsive, and it can't be: the stage is a
   fixed 482px because 480 is the canvas's own width, the palette is a
   fixed 214.7px because Blockly sizes a flyout to its widest block, and
   the rail is content-width. So the only thing a narrower window can
   take from is the block area, and it takes ALL of it, linearly, until
   the editor is a slot. Squeezing is the wrong failure here: a kid on a
   small laptop gets a workspace too narrow to lay a block in, and
   nothing on screen says why. Scrolling is the right one — it keeps
   every part of the app at the size it was designed at and makes the
   shortfall visible as a scrollbar.

   1200 IS MEASURED. At 1200 the block area comes out at 413px against a
   widest palette block of 287px (`change [ghost] effect by (25)`, which
   is what sets the flyout's width), so the widest thing a kid can drag
   out still fits with an indent's room to spare inside an `if`. The hard
   floor is ~1140, where that block plus one indent is all that's left;
   1200 is the nearest round number above it. The other end of the trade
   is the target screen: a 14" laptop at 1920x1080 and 150% scaling is
   1280 CSS px, so this fits maximised with 80px of slack and an
   unmaximised window scrolls instead of squashing. Raising the number
   buys block area and spends that slack — they move against each other
   and there is nothing else in it.

   THE OVERFLOW GOES ON `html`, NOT ON `body`, and that is mechanism
   rather than preference. shared.css sets `body { overflow: hidden }`
   and leaves html at its default `visible` — and a visible root
   PROPAGATES the body's overflow up to the viewport, so what that rule
   actually says today is "the window cannot scroll". Naming html's
   overflow is what stops the propagation and makes the window a
   scroller; body's own `hidden` then goes back to meaning what it looks
   like it means, which is that body clips its children.
   NOTE: candidate for shared.css. Every app on this layout has a
   fixed-width column in it somewhere and wants the same don't-squash
   answer, and because of the propagation above none of them can add it
   by touching only their own file. Local until it can be made centrally.

   The min-width goes on BODY because from there it reaches everything:
   the header is `width: 100%` of it and .workspace is a stretched flex
   item, so one number sizes both and the two cannot drift apart. */
html {
  overflow-x: auto;
  overflow-y: hidden;
}

body {
  min-width: var(--app-min);
}

/* --- Workspace layout ---
   Two columns, one row: editor | stage. Everything that used to sit in a
   toolbar row above them has moved — the category rail into the editor column
   beside the palette, Inventory to the foot of that rail, the mode chooser
   into the header. The ecosystem rail that used to hold a third column on the
   left is gone too; Home lives in the header now. */
.workspace {
  display: grid;
  /* 482 = a 480px stage canvas + .stage-frame's 1px border either side. 480
     is the canvas's own internal width, so at this track size the stage
     renders 1:1 — Scratch parity, and no resampling of the kids' art.
     Change the canvas and this has to move with it.
     It was 492 while the frame carried a --small mat inside its border; the
     mat went so those 10px could go to the editor column, which was the
     short one. */
  grid-template-columns: minmax(0, 1fr) 482px;
  grid-template-rows: minmax(0, 1fr);
  gap: var(--small);
  flex: 1;
  overflow: hidden;
  /* Matches the header's padding on both sides so the workspace lines up with
     the brand above it. No bottom inset — the panels run to the foot of the
     window, which is where the height for a 360px stage comes from. */
  padding: 0 var(--medium);
}

/* The editor column holds the category rail and the Blockly surface side by
   side, so the rail sits FLUSH against the flyout it scrolls (no grid gap
   between them). The grid placement lives here rather than on .editor. */
.workspace > .editor-col {
  grid-column: 1;
  grid-row: 1;
  display: flex;
  min-width: 0;
  min-height: 0;
}

.editor-col > .editor {
  flex: 1;
  min-width: 0;
}

.workspace > .stage-panel {
  grid-column: 2;
  grid-row: 1;
}

/* ~~NOTE: candidate for shared.css.~~ SHIPPED there (August 2026): shared.css
   now fades `.btn` and `.header__btn` when disabled, and adds `:not(:disabled)`
   to their hover rules so a faded button doesn't light up under the pointer.
   It had no disabled treatment at all before, which made an unavailable
   control look exactly like a live one.

   THIS RULE STAYS, and is no longer a stub: `.header__mode` is the app's own
   select, styled in this file, so its disabled state is this file's to define.
   The shared rules don't reach it and shouldn't.

   Not a colour change: a greyed-out control in a header that is itself
   coloured chrome ends up a different colour rather than a quieter one.
   Transparency dims it against whatever it sits on — matching what shared.css
   now does for the buttons beside it. */
.header__mode:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

/* --- Category rail (always-open palette navigation) ---
   A column of rows beside the flyout, one per palette section, in palette
   order. Takes the slack in .cat-col and scrolls internally if the rows
   outgrow the height.

   IT IS A COMPLETE INDEX, and every rule below assumes that. It replaced
   eight curated shortcuts that needed a two-line legend nailed to the top
   of the column to be understood — see editor.js. There is no key here
   and there must not be one: if a legend ever looks necessary again, the
   rail has gone partial, and that is the bug.
   2px between rows; .cat-rail__item--opens adds the larger gap that
   separates one category's run from the next, which is what makes twenty
   rows read as eight families.

   `flex-end`, WITH CONTENT-WIDTH ITEMS — a ragged left edge and a
   straight right one. Equal-width tiles were the earlier answer, on the
   argument that boxes of eight different widths read as debris. That
   holds for eight and fails at twenty-one: a column of identical
   rectangles is a WALL, and the eye gets no purchase on it because every
   row has the same silhouette. Ragged gives each row a shape, which is
   most of how you find a word in a list without reading it.
   Right-aligned rather than left, so the straight edge is the one facing
   the flyout — the index stays flush against the thing it indexes, and
   the ragged edge falls on the window side where there is nothing to
   line up with. */
/* Nothing here reaches out through the column's padding, and nothing
   should: `overflow-y: auto` computes overflow-x to `auto` as well, so
   anything hanging out is CLIPPED — a colour stripe on the window's edge
   silently disappeared this way while getBoundingClientRect still
   reported left: 0. Nothing inside a scroll container can hang out of it. */
.cat-rail {
  display: flex;
  flex: 1 1 auto;
  flex-direction: column;
  flex-wrap: nowrap;
  align-items: flex-end;
  gap: var(--small);
  overflow-y: auto;
}

.cat-rail[hidden] {
  display: none;
}

/* The column that holds the rail. Content-width — no magic number, so
   adding a category can't clip its own label. It outlived the Inventory
   tile it was built to pair the rail with (that moved to the stage panel),
   and stays because it is what `hidden` is toggled on in click-to-open mode
   and what gives the rail its scroll context.

   NO SURFACE OF ITS OWN. It carried a --bg panel for a while, on the
   argument that a filled shape stops the rail reading as loose text beside
   the palette. The rows do that job — they are the filled shapes — and the
   panel was left saying something that turned out to be wrong: --bg is the
   CANVAS colour, so it paired the rail with the stage while the white
   flyout sat between them, when the thing the rail indexes is the flyout.
   Gone, the rows sit on the same near-white ground the palette does, which
   is the pairing that was wanted all along.

   IT REACHES PAST .workspace's PADDING, but no longer all the way through
   it. The negative margin used to be exactly --medium, cancelling that
   padding outright so the widest row ran to the window's edge — bought
   when this column was fighting for every pixel against the palette. The
   glides wrapping gave 31px back to the editor, and --small of it is
   spent here: the rail now stops 5px short, so its widest row has air to
   its left instead of being sliced by the window. It is the only thing on
   the page that touches that edge, so it was the only thing that looked
   cut off.

   Still negative rather than a padding dropped upstream, and for the same
   reason as before: that padding stays one fact — "the workspace lines up
   with the header" — with one element opting partly out, instead of a
   rule rewritten and re-added in three places. It still cannot overflow:
   it stops --small inside .workspace's border box, which is where
   overflow:hidden clips.

   NO LEFT PADDING, which is now slack rather than a rule: the rows are
   content-width and aligned right, so the left of this column is empty
   whatever the padding says. It stays at 0 because a padding on the side
   the ragged edge falls on would be describing an alignment that no
   longer happens. The three other sides keep theirs. */
.cat-col {
  display: flex;
  flex: 0 0 auto;
  flex-direction: column;
  min-height: 0;
  gap: var(--small);
  margin-left: calc(-1 * var(--small));
  /* Top is 3px and it is doing ALIGNMENT rather than spacing: it levels
     the first row with the top of the palette's first block beside it.
     A RAW PIXEL, not a token, because it is the residue of a measurement
     — .cat-rail used to add --head-drop on top of --small here, which was
     fitted to the baseline of a text heading this column no longer has.
     The first thing here is a filled row now, so it aligns by its BOX,
     and the two rules that had to agree became one. Re-measure if the
     flyout's margin or scale moves; it cannot be derived, only looked at.
     Everything else keeps --medium. */
  padding: 3px var(--small) var(--medium) 0;
}

.cat-col[hidden] {
  display: none;
}

/* A rail ROW: one per palette section, in palette order.
   It was an icon-over-label TILE while the rail was eight curated
   shortcuts and needed presence to read as navigation rather than as a
   second palette. At twenty rows presence is the problem, and the icons
   went with the curation — there is no icon that means "Visibility" or
   "Checks", which is why the eight that existed were chosen partly for
   being drawable. A row of plain text, tinted and content-width, is what
   twenty of them can be. */
.cat-rail__item {
  display: flex;
  align-items: center;
  gap: 2px;
  /* CONTENT WIDTH — see .cat-rail. The tile used to fill the column and
     be square on the left where it met the window's edge; right-aligned
     and ragged it touches no edge at all, so all four corners curve. A
     square corner in mid-air is a tile that has been cut off, not one
     that is part of something. */
  width: auto;
  padding: var(--small);
  border: 0;
  border-radius: var(--radius-sm);
  /* TINTED AT REST, not only under the pointer. A row that only fills on
     hover is a link that happens to be square; one that is always filled
     is a place you can go. It is also where the eight category colours
     live now that no icon carries them — the fill says which family a row
     belongs to, and a run of one tint is what makes a chunk read as one. */
  background: color-mix(in srgb, var(--cat, var(--black)) 12%, transparent);
  font-family: var(--font);
  font-size: var(--fs-sm);
  font-weight: var(--font-weight);
  line-height: 1.2;
  white-space: nowrap;
  color: color-mix(in srgb, var(--black) 65%, var(--white));
  cursor: pointer;
}

/* THE GAP IS THE CHUNKING, and it is what makes twenty rows scannable.
   It goes on the row that OPENS a category rather than closes one, so
   the space belongs to the family below it — a trailing gap reads as the
   run having ended, which is the same information one row too late.
   --medium against the 2px row gap, a 5x differential: --small was tried
   first and at 5px-vs-2px the gap was there without being SEEN, which is
   the failure that matters. A chunking cue you have to look for has
   already lost to the twenty rows it exists to break up. */
.cat-rail__item--opens {
  margin-top: var(--medium);
}

.cat-rail__item--opens:first-child {
  margin-top: 0;
}

/* Hover and active are a LADDER on the same tint — 12 / 20 / 30 — because
   the resting state took the 12% hover used to own. Three steps of one
   colour reads as the same row getting more present; three different
   treatments would read as three different rows.

   NO BOLD ON THE ACTIVE ROW. It had one and it was doing the ladder's job
   twice over: the tint already says which row, and a weight change also
   REFLOWS the text, so the row grew a pixel or two as the light moved down
   the column during a scroll. A signal that makes its neighbours shift is
   louder than the thing it is signalling. */
.cat-rail__item:hover {
  background: color-mix(in srgb, var(--cat, var(--black)) 20%, transparent);
  color: var(--black);
}

/* The one you last jumped to. Nothing is lit on load: the light means
   "this is what you clicked", not "this is where you are" — see
   _setupCatRail, which also records why the second one isn't built. */
.cat-rail__item--active,
.cat-rail__item--active:hover {
  background: color-mix(in srgb, var(--cat, var(--black)) 30%, transparent);
  color: var(--black);
}

/* --- Canvas area ---
   NOTE: .canvas-area is a cross-app positional convention (SiteStack,
   Studio, Animation Station all use it). Candidate to move into
   shared.css; kept here for now. Here it hosts the Blockly workspace,
   which fills it absolutely. */
.canvas-area {
  position: relative;
  min-height: 0;
  min-width: 0;
}

/* The workspace colour is repeated here, under Blockly's SVG, and it is the
   only thing standing between the page and the frame's border: Blockly sets
   the SVG's width to a WHOLE number of px, so a fractional container leaves
   up to 1px of the div uncovered at the right edge. The border is 1px and
   sits inside the same box, so that sliver is normally underneath it — but
   it depends on where the fractional part lands, and the failure is a white
   hairline between the surface and its own border. Painting the div means
   there is nothing behind the SVG to show through, at any window width.
   #eaf5ff is `workspaceBackgroundColour` in blocks.js's theme, which can't
   read a CSS var; --bg is the same colour from the shared tokens. */
.editor__blockly {
  position: absolute;
  inset: 0;
  border-radius: var(--radius-md);
  overflow: hidden;
  background: var(--bg);
}

/* A block stack is being dragged — let it OUT of the canvas, so a kid can
   watch it travel to the sprite chip they're aiming at instead of losing it
   at the edge halfway through the gesture.
   Blockly paints the dragged stack into a layer of its own (an <svg
   class="blocklyBlockDragSurface"> over the workspace, already
   overflow:visible), so the only things clipping it are this div and
   Blockly's .injectionDiv inside it. The rest of the workspace does NOT
   spill with it: .blocklySvg — which holds the palette and every block
   that isn't being dragged — carries its own overflow:hidden.
   Reaches into a Blockly-owned class, hence the modifier scope rather than
   a global .injectionDiv rule. Class goes on and off with the drag event. */
.editor__blockly--dragging,
.editor__blockly--dragging .injectionDiv {
  overflow: visible;
}

/* Border around the workspace (block-placement) region only. `left` is set in
   JS to the flyout's right edge; pointer-events:none keeps drags working. */
/* THE CORNER WEDGES. Blockly's workspace background is a square rectangle
   and this border is round, so at the frame's two LEFT corners a wedge of
   workspace blue sits outside the curve and the corner reads as unclipped.
   Only the left two: the right corners are the div's own corners, and
   .editor__blockly clips those with the same radius.
   An outer box-shadow is clipped to outside the BORDER BOX, and a rounded
   border box excludes its own corner squares — so the wedge is exactly
   where an outer shadow is allowed to paint. One shadow fills both corners,
   under the border rather than over it, with no extra element and nothing
   for _updateEditorFrame to keep in step.
   Offset left rather than spread on all four sides: a spread would paint
   bands above, below and right of the frame, over the page. Offset, the
   only thing it can cover is the flyout to its left — whose colour this is
   (theme `flyoutBackgroundColour` is #fafafe, the same as --white; if those
   two ever diverge, this band is where it will show).
   THE OFFSET IS TWICE THE RADIUS, and one radius is not enough: a rounded
   rect's topmost row starts a full radius in from its left edge, so at
   y = 0 a one-radius offset covers nothing of the band and leaves the
   flyout's own top-right corner showing through — a half-pixel grey mark
   that is invisible at 1x and plain at 2x. At twice the radius the shape's
   curve has cleared the band by the time it reaches the top, so the cover
   is square where it needs to be. The extra width lands on the flyout,
   which costs nothing. Blockly's scrollbars paint above this (they have
   their own stacking), so widening the band cannot hide them. */
.editor__frame {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  border: var(--border-width) solid var(--border);
  border-radius: var(--radius-md);
  box-shadow: calc(-2 * var(--radius-md)) 0 0 0 var(--white);
  pointer-events: none;
  z-index: 5;
}

.editor__frame[hidden] {
  display: none;
}

/* --- Whose blocks these are ---
   A label on the CODE, because that is what it labels. The failure it
   exists for is silent: a kid writes a script, presses Play, nothing
   happens, because they were editing the Cat and watching the Ball. The
   sprite panel already answers that — over on the far side of the window,
   where their eyes are not.

   Top-right of the block-placement area. `right`, never `left`: the
   flyout's width changes with the palette and with FLYOUT_SCALE, and
   .editor__frame already needs JS to track its left edge — anchoring to
   the right keeps this one out of that entirely. Blockly's zoom controls
   are bottom-right, so the corner is free.

   The right inset clears BLOCKLY'S VERTICAL SCROLLBAR, which lives at the
   workspace's right edge and was cutting into the badge at a plain 10px.
   It is not a token because it isn't spacing — it's the width of a
   Blockly part (scrollbarThickness) plus a gap. That thickness is now set
   to 12 in editor.js (it was Blockly's 15), so this came down by the same 3
   and the gap either side of the badge is unchanged.

   pointer-events:none is load-bearing rather than tidiness: this floats
   over a drag surface, and a block parked under it has to stay grabbable.
   z-index sits above .editor__frame (5) so the badge is inside the border
   rather than clipped by it, and BELOW nothing — the Stage tab's mask is
   also 5 and paints later in the DOM, but the badge is hidden in that
   state anyway (see _syncEditorSprite), which is the honest fix: with the
   mask up there is no sprite's code on screen to label. */
.editor__sprite {
  position: absolute;
  top: 10px;
  right: 25px;
  z-index: 6;
  display: flex;
  align-items: center;
  gap: var(--small);
  max-width: 40%;
  padding: 2px var(--medium) 2px var(--small);
  border-radius: var(--radius-sm);
  border: var(--border-width) solid var(--border-soft);
  background: var(--white);
  color: var(--primary);
  font-family: var(--font);
  /* --fs-md, not --fs-sm. It was one step down and read as a watermark;
     at eight, small grey text in a corner is text you don't read. This is
     the size the app uses for anything a kid is meant to take in. */
  font-size: var(--fs-md);
  font-weight: 600;
  line-height: 1.6;
  pointer-events: none;
}

.editor__sprite[hidden] {
  display: none;
}

/* The picture half. Faded on purpose — it's the fast way to recognise
   which sprite this is, not a control, and it sits over the kid's blocks.
   Sized on the box rather than on `width` alone because _thumb returns an
   <img> for a real drawing and a <span> holding a glyph when there is no
   file to load; inline-flex makes the two occupy the same square. */
.editor__sprite-thumb {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: 22px;
  height: 22px;
  object-fit: contain;
  font-size: 18px;
  line-height: 1;
  opacity: 0.55;
}

/* min-width:0 is what lets the ellipsis happen at all — a flex item's
   default min-width is its content, so without it a long sprite name
   pushes the badge wider than its max-width instead of truncating. */
.editor__sprite-name {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* --- The workspace view controls: Fit all + zoom ---
   Four buttons, ONE family, because Blockly's own zoom controls are off
   (zoom.controls:false at inject). They could not be made to match: they
   are a base64 PNG sprite sheet, so CSS can move them and hide them and
   nothing else. Two labelled pills sitting on three foreign grey circles
   is what this replaced.

   BOTTOM-RIGHT, where zoom controls live in every canvas app a kid will
   ever meet. It shipped once under the sprite badge and that was wrong
   for a reason worth keeping: the badge is a LABEL, so buttons pinned to
   it read as things you do to the sprite.

   `right` matches .editor__sprite exactly, so the badge above and this
   cluster share one line down the right side of the workspace — and it
   is the same number for the same reason (Blockly's vertical scrollbar,
   whose thickness editor.js sets, plus a gap). `bottom` is that number
   again against the horizontal scrollbar.

   A COLUMN, stretched to one width, so four controls read as one object
   rather than a scatter. Width comes from the longest label; the zoom
   pair divides that width between them.

   pointer-events on the CONTAINER is none and on the buttons auto, not
   the other way round: this floats over a drag surface, so the gaps
   between buttons have to stay transparent to a block being dragged
   past. Only the buttons themselves take the pointer. */
.editor__tools {
  position: absolute;
  bottom: 25px;
  right: 25px;
  z-index: 6;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: var(--small);
  pointer-events: none;
}

/* Zoom out / zoom in, sharing one row so the cluster stays three rows
   deep. flex:1 on the buttons splits the column's width evenly, which is
   what keeps the block rectangular. */
.editor__zoom {
  display: flex;
  gap: var(--small);
}

.editor__tools[hidden] {
  display: none;
}

/* Quieter than the app's other buttons on purpose — these sit ON the
   kid's blocks, so at rest they are white with a soft border and become
   chrome-blue only under the pointer. The border is --border-soft at rest
   and --primary on hover, both 1px, so the button doesn't change size
   when it lights up. */
.editor__tool {
  display: flex;
  align-items: center;
  gap: var(--small);
  padding: 2px var(--small);
  border: var(--border-width) solid var(--border-soft);
  border-radius: var(--radius-sm);
  background: var(--white);
  color: var(--primary);
  font-family: var(--font);
  /* --fs-sm, a step under the sprite badge. The badge is an ANSWER a kid
     has to read without looking for it; these are controls they go to,
     and they sit with Blockly's zoom icons, which carry no text at all. */
  font-size: var(--fs-sm);
  font-weight: 600;
  line-height: 1.6;
  white-space: nowrap;
  cursor: pointer;
  pointer-events: auto;
}

/* The zoom pair. No label, so the icon carries it and grows; flex:1
   splits the row evenly between them. */
.editor__tool--icon {
  flex: 1;
  justify-content: center;
  padding: 3px var(--small);
}

.editor__tool:hover:not(:disabled) {
  border-color: var(--primary);
  background: var(--primary);
  color: var(--white);
}

/* At the end of the zoom range. Faded rather than hidden: the button
   staying put says "this is as far as it goes", where a disappearing
   button says the control moved. Same reason the Stop button is lit
   rather than absent on a stopped program. */
.editor__tool:disabled {
  opacity: 0.35;
  cursor: default;
}

.editor__tool svg {
  width: 16px;
  height: 16px;
  flex-shrink: 0;
}

.editor__tool--icon svg {
  width: 18px;
  height: 18px;
}

/* The blocks themselves get Kode Mono from the Blockly theme (SVG text),
   but the toolbox category list is HTML Blockly injects with its own font.
   Two-class specificity beats Blockly's runtime-injected `.blocklyTreeLabel`.
   (Blockly is a CDN lib, not a locked shared file, so styling it here is ok.) */
.blocklyToolboxDiv .blocklyTreeLabel {
  font-family: var(--font);
  font-weight: 600;
}

/* Section headings inside the palette — SVG text, so the colour is `fill`,
   not `color`. Colour is ALL this sets: the theme already gives the labels
   Barlow 600 at the same size as block text, with no tracking, so anything
   else here would just restate Blockly and drift from it later.
   The selector is long for the same reason .blocklyTreeLabel above is:
   Blockly injects its own rules at inject() time, AFTER this stylesheet, so
   an equal-specificity match loses. The one being beaten is
   `.zelos-renderer.codebloom-theme .blocklyFlyoutLabelText` at (0,3,0) —
   hence four classes, not the two that were enough for the tree label. */
/* THE HEADINGS ARE COUNTER-SCALED, and that's what makes them stylable at
   all. The flyout renders its whole workspace at FLYOUT_SCALE (0.70) so the
   palette's blocks come out smaller than the workspace ones — Scratch's
   look, and the right call, because Zelos BLOCK GEOMETRY (not font size) is
   what sets a block's width. But the labels live in that same scaled
   workspace, so every size set here was arriving multiplied by that scale:
   at the 0.75 this shipped with, a 16px category heading rendered at 12px
   effective and a 13px group heading at 9.75px, under the legibility floor
   for an eight-year-old. Anything written here shrinks again every time the
   block scale is lowered, which is exactly what happened next.
   Dividing by the scale makes a size written here the size that lands, so
   the labels can be styled on their own terms and the block scale can move
   without dragging them along. `--flyout-scale` is published onto the root
   by editor.js's _applyFlyoutScale, from the one constant, so the two can't
   drift; the `, 1` fallback keeps this correct before it's set (and if the
   flyout scale is ever retired). */
/* THE 2px NUDGE IS COUNTER-SCALED TOO, and it has to be: this text lives in
   the flyout's scaled workspace, so a raw `2px` here would arrive as 1.4px
   on screen and change again the next time FLYOUT_SCALE moves. Divided by
   the scale, 2px is 2px at any scale.
   What it buys: "Events" sitting level with the first row of the rail
   beside it. The nudge is optical, not arithmetic — it was measured
   against the rail's old "Quick find" heading, whose BASELINE already
   matched to 0.3px, and two texts of different sizes, weights and cases
   don't read as level when their baselines agree, because the eye lines
   up cap-heights and the taller one has to come down. The last 2px came
   from looking at it, which is why the sign is worth stating: DOWN.
   RE-MEASURE ALONGSIDE .cat-col's top padding — the rail's first element
   is now a filled row rather than a line of text, so what this is being
   levelled against has changed shape.
   Applies to group headings too, so the palette's vertical rhythm shifts as
   one piece rather than opening a 2px gap under every category name. */
.zelos-renderer.codebloom-theme .blocklyFlyoutLabel .blocklyFlyoutLabelText {
  fill: var(--black);
  font-size: calc(var(--fs-md) / var(--flyout-scale, 1));
  transform: translateY(calc(2px / var(--flyout-scale, 1)));
}

/* A CATEGORY heading rides 4px lower than the rest, and it is the only way
   to close the gap under it. Blockly ignores `gap` on a label (0 and 40
   measure the same) and a label's height comes from FlyoutButton's internal
   padding, which isn't exposed — so the distance between "Events" and the
   "When" beneath it is not a number this app can set. Moving the text down
   inside its own box is: the pair tightens by 4px, and the 4px reappears
   above the category name, where it reads as extra air between sections and
   is wanted anyway.
   Counter-scaled like everything else in the flyout, and added to the 2px
   optical nudge the base rule already applies. It moves the scroll anchor by
   the same amount, which the harness's "every section reaches the top" check
   tolerates (12px) and would catch if --head-drop were pushed much further.
   .cat-rail pads its top by the same token — see --head-drop. */
.zelos-renderer.codebloom-theme .blocklyFlyoutLabel:not(.cb-subhead) .blocklyFlyoutLabelText {
  transform: translateY(calc((2px + var(--head-drop)) / var(--flyout-scale, 1)));
}

/* A GROUP heading — the named runs inside a category ("Repeat", "If",
   "Wait & stop" inside Control). Blockly puts our `web-class` on the same
   <g> it gives .blocklyFlyoutLabel, so this is one class more specific than
   the rule above and needs no !important. Lighter and smaller: a group
   heading matching its category's weight would make the palette read as
   twenty sections rather than eight with runs inside them. */
.zelos-renderer.codebloom-theme .blocklyFlyoutLabel.cb-subhead .blocklyFlyoutLabelText {
  /* Mixed toward transparent rather than --white: same result over the
     flyout's near-white ground, one fewer token to resolve. A var() that
     fails substitution doesn't fall through to the rule below — the
     property takes its initial value — so every token here is a way for
     this rule to silently render as plain black. */
  fill: color-mix(in srgb, var(--black) 45%, transparent);
  font-size: calc(var(--fs-sm) / var(--flyout-scale, 1));
}

/* --- "Make a Variable": the palette's one button ---
   It shipped as stock Blockly — a grey pill with a drop shadow under it, in
   Blockly's own font — which made it the only control in the app that
   belonged to no family at all. It is app furniture, not a block, and the
   look has to say so: a kid who reads it as a block will try to drag it.
   So it takes .btn--outline's treatment (white fill, --primary border and
   text), which is what the app already uses for "a thing you press that
   isn't the featured action". Filled would have out-shouted the orange
   blocks it sits above; Variables-orange would have looked draggable.

   THE SHADOW RECT GOES. Blockly draws a second rect offset by 1px behind
   the background to fake depth; nothing else in this app has a drop shadow
   and nothing is going to start here.

   Everything is counter-scaled by --flyout-scale like the labels above:
   the flyout is rendered at 0.7, so a plain --fs-md would come out at 70%
   of every other --fs-md on the page. Blockly measures the text AFTER the
   stylesheet applies and sizes the background from it, so the box follows
   the font rather than clipping it.

   Scoped to .cb-newvar (the button's `web-class`) rather than to
   .blocklyFlyoutButton, because that class is what a variable's monitor
   checkbox used to carry too — the hook is kept even though those are gone,
   since the next flyout button to appear should have to opt in. */
.zelos-renderer.codebloom-theme .blocklyFlyoutButton.cb-newvar .blocklyFlyoutButtonShadow {
  display: none;
}

.zelos-renderer.codebloom-theme .blocklyFlyoutButton.cb-newvar .blocklyFlyoutButtonBackground {
  fill: var(--white);
  stroke: var(--primary);
  stroke-width: calc(var(--border-width) / var(--flyout-scale, 1));
}

.zelos-renderer.codebloom-theme .blocklyFlyoutButton.cb-newvar .blocklyText {
  fill: var(--primary);
  font-size: calc(var(--fs-sm) / var(--flyout-scale, 1));
  font-weight: 600;
}

/* Hover matches .btn--outline's — fills with --primary, text goes white.
   On the <g>, so the rect and the text change together. */
.zelos-renderer.codebloom-theme .blocklyFlyoutButton.cb-newvar:hover .blocklyFlyoutButtonBackground {
  fill: var(--primary);
}

.zelos-renderer.codebloom-theme .blocklyFlyoutButton.cb-newvar:hover .blocklyText {
  fill: var(--white);
}

/* --- The checkbox beside a variable's block ---
   Drawn into the flyout's own SVG canvas (Editor._syncVariableChecks), so
   it scrolls with the palette for free. Styled here rather than with
   attributes for the same reason everything else in this file is: the
   colours are tokens, and a hover belongs in a stylesheet.

   Sizes are NOT here — the geometry is written as SVG attributes in JS,
   because the box has to be positioned against a block Blockly measured,
   and a size split across two files could only ever drift. */
.var-check {
  cursor: pointer;
}

.var-check__hit {
  fill: transparent;
}

.var-check__box {
  fill: var(--white);
  stroke: var(--border-soft);
  stroke-width: 2;
}

/* Checked is FILLED, not a tick on white: at 13px on screen a hairline
   tick is the difference between "on" and "off", and it has to read from
   a glance across a classroom. */
.var-check--on .var-check__box {
  fill: var(--primary);
  stroke: var(--primary);
}

.var-check__tick {
  fill: none;
  stroke: var(--white);
  stroke-width: 2.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.var-check:hover .var-check__box {
  stroke: var(--primary);
}

/* --- Selecting a block is not an event worth lighting ---
   Zelos glows the selected block by CLONING its path: `updateSelected`
   appends a second <path class="blocklyPath"> to the block's own <g>, with
   fill:none and `filter="url(#blocklySelectedGlowFilter…)"` (theme
   `selectedGlowColour`, Scratch yellow). The filter is on that CLONE, not
   on the block group and not in any stylesheet — which is why searching
   the injected CSS for a `.blocklySelected` stroke rule finds nothing, and
   why a `filter: none` aimed at `.blocklySelected` itself does exactly
   nothing while looking like it worked.

   Removing it matters twice over here:

   - Clicking a block RUNS it, so a halo left on the last block touched is
     noise attached to the wrong verb — and it's yellow, the colour that
     has to mean "executing". Two yellows three pixels apart meaning "this
     ran" and "you touched this" is worse than no selection cue at all.
   - The clone is a second .blocklyPath, so the running ring below stroked
     it TOO: a selected running block was drawn with a doubled border.

   `~` rather than :last-of-type on purpose — the clone is always preceded
   by the real path, so this selector cannot match the block's own outline
   even if Blockly ever stops appending the clone. */
.zelos-renderer.codebloom-theme .blocklySelected > .blocklyPath ~ .blocklyPath {
  display: none;
}

/* --- A block that is running right now ---
   Set on the block's SVG <g> by _syncGlow, on every frame the running set
   changes.

   YELLOW, and a RING rather than a glow. Yellow is Scratch's, it is what a
   kid who has seen Scratch already reads as "this is going", and killing
   the selection glow above is what freed it. A ring rather than a halo
   because flat is the house style and a halo means a per-block SVG filter
   recomputed on every frame a script runs.

   `stroke-linejoin: round` is not cosmetic. A C-block's path encloses its
   substack cutout as well as its outer silhouette, so the notches are
   sharp reversals — and a mitred join at a reversal shoots a spike as long
   as the miter limit allows. Mitred, `if` and `forever` grew visible barbs
   at every notch and read as a heavier stroke than the plain blocks beside
   them, which measured identically. Round joins stop the stroke at the
   corner.

   `stroke` as a CSS property beats the presentation attribute Blockly sets
   on the path, which is what makes this reachable at all. Two leading
   classes for specificity, the same trap the palette-heading rules
   document above: Blockly injects its own rules AFTER this sheet, so an
   equal-specificity match would lose. */
.zelos-renderer.codebloom-theme .block--running > .blocklyPath {
  stroke: var(--running);
  stroke-width: 4;
  stroke-linejoin: round;
}

/* What a clicked reporter answers with — `x position` -> `0`. Anchored to
   the block in JS, so this only has to look like a read-out: dark card,
   the app's mono face, and no pointer target of its own (any press
   dismisses it, including one aimed at what's underneath). */
.block-value {
  position: fixed;
  z-index: 40;
  max-width: 260px;
  padding: var(--small) var(--medium);
  border-radius: var(--radius-sm);
  background: var(--black);
  color: var(--white);
  font-family: var(--font);
  font-size: var(--fs-sm);
  font-weight: 600;
  line-height: 1.4;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  pointer-events: none;
}

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

/* --- The read-out's right-click menu ---
   Normal / large / slider, then Hide. Placed at the pointer in JS, so this
   only has to look like a menu: a white card with a soft border, the same
   pairing .section and the sprite fields use, and rows that fill on hover.
   No shadow — the border does the lifting, as everywhere else here.

   It is the only menu the app draws, so it gets no shared component: one
   caller, one definition, and a second one can generalise it if it ever
   arrives. */
.stage-menu {
  position: fixed;
  z-index: 50;
  display: flex;
  flex-direction: column;
  min-width: 150px;
  padding: var(--small);
  border: var(--border-width) solid var(--border-soft);
  border-radius: var(--radius-sm);
  background: var(--white);
}

.stage-menu[hidden] {
  display: none;
}

.stage-menu__item {
  padding: var(--small) var(--medium);
  border: 0;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--black);
  font-family: var(--font);
  font-size: var(--fs-sm);
  text-align: left;
  white-space: nowrap;
  cursor: pointer;
}

.stage-menu__item:hover {
  background: var(--bg);
}

/* The scope line. The three items above it change ONE read-out and the
   three below change all of them; a flat list of six would hide that.
   Not a button and not focusable — it is a label, and a menu row you can
   land on but not press reads as broken. */
.stage-menu__head {
  margin-top: var(--small);
  padding: var(--small) var(--medium) 2px;
  border-top: var(--border-width) solid var(--border-soft);
  color: color-mix(in srgb, var(--black) 50%, transparent);
  font-family: var(--font);
  font-size: var(--fs-sm);
  white-space: nowrap;
}

/* The mode this read-out is IN. Filled, because the three modes are one
   choice among three and the menu should answer "which am I on?" without
   the kid having to remember what they last picked. */
.stage-menu__item--on,
.stage-menu__item--on:hover {
  background: var(--primary);
  color: var(--white);
}

/* Hide ends the read-out where the three above only change its shape, so
   it sits below a rule rather than reading as a fourth display mode. */
.stage-menu__item--last {
  margin-top: var(--small);
  padding-top: calc(var(--small) + var(--small));
  border-top: var(--border-width) solid var(--border-soft);
}

/* The workspace dot grid: Blockly draws each grid point as two short lines.
   A thick, round-capped stroke over a tiny length collapses the pair into a
   single round dot — the Scratch/MakeCode look. Colour stays Blockly's (set
   inline on the lines); we only reshape it. */
svg [id^="blocklyGridPattern"] line {
  stroke-width: 0.6;
  stroke-linecap: round;
}

/* --- Right column: stage + controls + sprites ---
   Bar and canvas sit fixed at the top; the definition panels live below in
   one bordered, rounded frame that scrolls internally. Mirrors Art Studio's
   inspector card so the two apps read as one system. */
.stage-panel {
  display: flex;
  flex-direction: column;
  gap: var(--small);
  min-height: 0;
  overflow: hidden;
}

/* Mode select, now living in the header beside Save / Programs. */
.header__mode {
  font-family: var(--font);
  font-size: var(--fs-sm);
  font-weight: var(--font-weight);
  padding: var(--small) var(--medium);
  border: var(--border-width) solid var(--border-soft);
  border-radius: var(--radius-sm);
  background: var(--white);
  color: var(--black);
  cursor: pointer;
}

/* --- The definition-panel scroll region ---
   Just an invisible scroll viewport now: each definition panel (the Sprites
   panel, and the guided-lab Instructions in project mode) is a self-framed
   .section, so a wrapper frame around a single panel would only double the
   border. Panels scroll here instead of pushing the stage. */
.stage-panel__scroll {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  /* Anchor for the floated Inventory button below. */
  position: relative;
}

/* Inventory. This replaced a square tile at the foot of the category
   column, which read as part of the palette — the wrong promise, since that
   column is blocks and the inventory is the program's images. It was also
   the furthest corner of the window from anything it affects.
   Absolutely placed rather than in flow, for two reasons: the panels below
   are in a tight column and can't spare a row, and it must not join the
   .tab-row beside it — the tabs choose what this panel shows, this opens a
   window, and a third folder tab would say they were the same kind of
   thing. Filled --primary — the shared .btn--secondary look — so it reads
   as its own control against the white panels rather than more chrome.
   Filled --secondary was tried first and is wrong HERE specifically: the
   palette a few inches away is a wall of block colours, and a yellow-green
   button joins that argument instead of standing outside it. Blue is the
   app's chrome colour, so it stays chrome. */
.stage-panel__inventory {
  position: absolute;
  top: 0;
  right: 0;
  z-index: 2;
  display: flex;
  align-items: center;
  gap: var(--small);
  /* --large across, not --medium: at the tabs' size it read as a chip on a
     row of chrome and got lost. Width is what it had to buy, since the one
     thing it can't do is grow into a third folder tab beside Sprites and
     Stage — those choose what this panel shows, and this opens a window
     over it. A fixed width was the other candidate and was rejected at
     200px: that is 40% of a 482px panel for a secondary control, and it
     collides with the Stage tab as the panel narrows. Padding grows with
     the label instead of against its neighbours. */
  padding: var(--small) var(--large);
  border: var(--border-width) solid var(--primary);
  border-radius: var(--small);
  background: var(--primary);
  font-family: var(--font);
  font-size: var(--fs-sm);
  font-weight: var(--font-weight);
  text-transform: uppercase;
  letter-spacing: 0.02em;
  white-space: nowrap;
  color: var(--white);
  cursor: pointer;
}

/* Inverts on hover: the border is already --primary and stays put, so the
   button keeps its exact size and only the fill and the label swap. */
.stage-panel__inventory:hover {
  background: var(--white);
  color: var(--primary);
}

.stage-panel__inventory svg {
  width: 18px;
  height: 18px;
  flex-shrink: 0;
}

.stage-panel__scroll-inner {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: var(--medium);
}

/* --- Panel primitives (mirrors Art Studio's inspector sections) ---
   NOTE: .section / .section__header / .section__body and .opt-card are
   duplicated across Art Studio, SiteStack and Animation Station. Candidates
   for shared.css; kept local for now per the shared-files rule. */
.section {
  border: var(--border-width) solid var(--border);
  border-radius: var(--radius-md);
  overflow: hidden;
  flex-shrink: 0;
  background: var(--white);
}

/* projectPanel toggles the [hidden] attribute in sandbox — it must win. */
.section[hidden] {
  display: none;
}

/* Solid primary title bar: uppercase, letter-spaced, white. This one bar is
   most of the "polish". Static label, not a collapse toggle — hiding a core
   panel confuses an eight-year-old more than it tidies. */
.section__header {
  display: flex;
  align-items: center;
  gap: var(--small);
  padding: var(--medium);
  background: var(--primary);
  font-family: var(--font);
  font-size: var(--fs-sm);
  font-weight: var(--font-weight);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--white);
}

/* A class rule beats the `hidden` attribute's UA display:none, so a
   hidden .section__body would otherwise stay on screen. Same trap the
   .section[hidden] rule above exists for. */
.section__body[hidden] {
  display: none;
}

.section__body {
  display: flex;
  flex-direction: column;
  gap: var(--medium);
  padding: var(--medium);
}

/* A row of related controls, grouped on a light-blue card so options never
   float on white. Art Studio's .inspector__group. */
.opt-card {
  background: var(--bg);
  padding: var(--small) var(--medium);
  border-radius: var(--radius-sm);
}

/* --- Stage bar ---
   The run controls. This used to be the stage section's header, which meant
   the two could only ever be styled as one object. They're separate elements
   now — and with no bar chrome of its own, it's just the row that positions
   them: each button carries its own outline, so the controls read as loose
   controls rather than as a lid on the stage. */
.stage-bar {
  display: flex;
  align-items: center;
  gap: var(--small);
  flex-shrink: 0;
}

.stage-run {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 30px;
  height: 30px;
  padding: 0;
  border: var(--border-width) solid var(--primary);
  border-radius: var(--radius-sm);
  background: var(--white);
  color: var(--black);
  cursor: pointer;
}

.stage-run svg {
  width: 17px;
  height: 17px;
}

/* Icon colour carries the meaning: green = go, pink = stop, dark for the
   two that don't act on the program (snapshot, present). */
.stage-run--play {
  color: var(--go);
}

.stage-run--stop {
  color: var(--accent);
}

/* Pause is coloured even while it's disabled — which is most of the time,
   since nothing can be paused until something runs. `:disabled` already
   drops it to 0.4 opacity, and a muted blue reads as "this button is for
   later" where a muted black read as "this button is broken". Every other
   control in the bar carries its colour in both states; this one was the
   odd one out. */
.stage-run--pause {
  color: var(--primary);
}

/* The right-hand cluster: Snapshot then Present. The slack sits on the
   FIRST of the two, so the pair travels together — put it on Present and
   two auto margins would share the free space and open a gap between them.
   Everything left of here is transport; these two are what you do with the
   picture. (The Background button briefly lived here too and moved to a
   panel tab: "what's ON the stage" is neither.) */
.stage-run--snap {
  margin-left: auto;
}

.stage-run:hover:not(:disabled) {
  background: var(--bg);
}

.stage-run:disabled {
  opacity: 0.4;
  cursor: default;
}

/* --- Active: the button that names the state the program is IN ---
   Exactly one of the three is lit at any moment — Stop while stopped, Play
   while running, Pause while held — so the bar answers "where am I?" without
   anyone reading the stage. One filled treatment for all three: a per-button
   flourish (Play's old green ring) made the lit states look like unrelated
   effects rather than one row of indicators.

   Two ordering notes, both load-bearing:
   - This must stay BELOW .stage-run--play / --stop. Those set `color` at the
     same specificity, so it's source order that lets the white icon win.
   - The hover selector is listed here rather than left to lose against
     .stage-run:hover — same definition covering both states, so nothing has
     to be overridden on specificity. */
.stage-run--active,
.stage-run--active:hover:not(:disabled) {
  background: var(--primary);
  color: var(--white);
}

/* --- Run read-outs (in the stage bar) ---
   "Finished" and the live sprite total. Sits between Stop and the
   right-hand cluster, so Snapshot's margin-left:auto still pins that pair
   to the right end and the chips grow leftward from there. Empty most of
   the time: neither read-out earns permanent space. */
.stage-status {
  display: flex;
  align-items: center;
  gap: var(--small);
  margin-left: var(--small);
  min-width: 0;
}

/* No fill at rest — a read-out that's true most of the run shouldn't look
   like a badge. The filled variants below are what earn the attention. */
.stage-status__chip {
  padding: 2px var(--small);
  border-radius: var(--radius-sm);
  color: var(--black);
  font-size: var(--fs-sm);
  white-space: nowrap;
}

/* Approaching the clone cap, then at it. Two escalating states because a
   SILENT ceiling is the whole complaint against Scratch's clone limit — by
   the time it bites, the count has to have been shouting. Filled tokens
   rather than the ad-hoc orange/red these carried on the canvas. */
.stage-status__chip--warn {
  background: var(--primary);
  color: var(--white);
}

.stage-status__chip--alert {
  background: var(--accent);
  color: var(--white);
}

/* --- Stage frame ---
   Nothing in here but the canvas, flush to the border. The border alone
   gives the white stage its edge against the near-white page, so it reads
   as a surface rather than a hole; the --small tinted mat that used to do
   that with it was worth 10px of window width and the border does the job
   on its own. Width math: 480 canvas + 1px border either side = the 482px
   grid track in .workspace. Change one, change both.
   `overflow: hidden` is what the mat's removal costs: the frame now owns
   the corner shape and clips the canvas to its inner curve. Without it the
   canvas's square corners would poke out past the radius. Its background
   matches the canvas for the same reason — nothing is left of the frame
   but a border and a clip, so any antialiased sliver at a corner should be
   the colour of the stage rather than a tint. */
.stage-frame {
  flex-shrink: 0;
  overflow: hidden;
  border: var(--border-width) solid var(--border);
  border-radius: var(--radius-md);
  background: var(--white);
}

/* No radius of its own: .stage-frame clips it, so a corner here would only
   round INSIDE that clip and leave four slivers of frame showing. Present
   mode borrows this canvas out of the frame and onto black, where square
   corners are what a full-screen picture should have anyway. */
.stage-canvas {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  background: var(--white);
  image-rendering: auto;
}

/* --- Present mode ---
   Kids show each other their work constantly and a 480x360 canvas in a
   side panel doesn't do that. Fixed-position rather than relying on the
   Fullscreen API: the API is requested as an enhancement, but if the
   browser refuses it (or the kid is in an embed) this still fills the
   window, which is most of the benefit. One layout path either way. */
.present {
  position: fixed;
  inset: 0;
  z-index: 60;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--medium);
  background: var(--black);
}

/* the [hidden] attribute must win over display:flex */
.present[hidden] {
  display: none;
}

.present__bar {
  display: flex;
  align-items: center;
  gap: var(--small);
}

.present__stage {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 0;
}

/* The largest 4:3 box that fits, minus room for the control bar. The
   canvas is 480x360 internally and CSS-scaled, so everything drawn on
   it — sprites, monitors, badges — scales up together for free. */
.stage-canvas--present {
  width: min(100vw, calc((100vh - 90px) * 4 / 3));
  height: auto;
  max-width: 100vw;
}

/* --- Guided-project instructions panel ---
   A .section (frame + title bar handled by the primitives); these rules only
   style its inner content. Hidden in sandbox via .section[hidden]. */
.project-panel__step {
  font-size: var(--fs-sm);
  font-weight: var(--font-weight);
  color: var(--primary);
}

.project-panel__text {
  font-size: var(--fs-sm);
  line-height: 1.45;
  margin: 0;
}

/* --- Sprite info strip ---
   The selected sprite's STARTING properties. Definition, not execution:
   while a program runs the fields mirror live values and go read-only,
   because "where it starts" isn't a thing you can set mid-run. The strip is
   a .section; layout comes from .section__body. */
.sprite-info__fields {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: var(--medium);
}

/* The two field rows, beside the image swatch. */
.sprite-info__rows {
  display: flex;
  flex-direction: column;
  gap: var(--small);
  flex: 1 1 auto;
  min-width: 0;
}

/* The starting image, as a square swatch at the head of the card — the one
   property you can show instead of spell. Square and roughly two rows tall
   so it reads as the card's anchor ("this is the sprite you're editing")
   rather than as a fifth field. */
.sprite-info__image {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: 52px;
  height: 52px;
  padding: 0;
  border: var(--border-width) solid var(--border-soft);
  border-radius: var(--radius-sm);
  background: var(--white);
  cursor: pointer;
}

.sprite-info__image:hover:not(:disabled) {
  border-color: var(--primary);
}

.sprite-info__image:disabled {
  opacity: 0.55;
  cursor: default;
}

.sprite-info__image-slot {
  display: flex;
  align-items: center;
  justify-content: center;
}

/* Sized on the thumb itself, not the slot: _thumb() swaps the <img> for a
   glyph <span> carrying the SAME class when an image can't load, so one
   rule has to fit both. */
.sprite-info__image-thumb {
  display: block;
  width: 38px;
  height: 38px;
  object-fit: contain;
  font-size: var(--fs-xl);
  line-height: 38px;
  text-align: center;
}

/* A square holding a picture is not obviously a BUTTON, and this one has no
   text label to say so. The pencil is the affordance. */
.sprite-info__image-badge {
  position: absolute;
  right: -1px;
  bottom: -1px;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 16px;
  height: 16px;
  border-radius: var(--radius-sm) 0 var(--radius-sm) 0;
  background: var(--primary);
  color: var(--white);
}

.sprite-info__image-badge svg {
  width: 10px;
  height: 10px;
}

/* One row of fields. Row 1 is Name + the "Show on stage" checkbox; row 2 is
   x / y / Size / Direction. Labels sit INLINE with their inputs to save a
   line of height each; the row wraps if it can't fit. */
.sprite-info__row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--small) var(--medium);
}

/* Label beside input, input takes the slack. */
.sprite-info__field {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: var(--small);
  flex: 1 1 96px;
  min-width: 0;
}

.sprite-info__field .sprite-info__input {
  flex: 1 1 auto;
  min-width: 0;
}

/* Name still takes the slack on row 1, but from a much smaller base: it now
   shares the row with Show, Layer AND the hitbox swatch, and a 160px floor
   pushed the last of those off the card. */
.sprite-info__field--grow {
  flex: 1 1 90px;
}

/* x / y / Size / Direction belong on ONE line — they're read together, and
   a wrapped Direction reads as a different kind of property than the three
   above it. So this row never wraps: the labels hold their width and the
   number inputs absorb the squeeze, which they can afford because 4 digits
   is the widest value any of them ever holds.

   The leftover width goes BETWEEN the fields rather than into them: the
   gap separating two fields has to stay clearly bigger than the gap between
   a label and its own input, or "x 0 y 0" reads as one run of four things
   instead of two pairs. */
.sprite-info__row--nums {
  flex-wrap: nowrap;
  justify-content: space-between;
}

.sprite-info__field--num {
  flex: 0 1 auto;
}

/* A field that takes exactly the width it needs, for a row it doesn't have
   to share — the Hitbox buttons. Without it .sprite-info__field's
   `flex: 1 1 96px` stretches the pair across the whole card. */
.sprite-info__field--fixed {
  flex: 0 0 auto;
}

/* appearance: textfield drops the spinner arrows, which Chrome floats over
   the last ~14px of the field on hover — fine on a roomy input, not on one
   sized to its digits. */
.sprite-info__field--num .sprite-info__input {
  flex: 0 1 50px;
  min-width: 38px;
  -moz-appearance: textfield;
  appearance: textfield;
}

.sprite-info__field--num .sprite-info__input::-webkit-inner-spin-button,
.sprite-info__field--num .sprite-info__input::-webkit-outer-spin-button {
  -webkit-appearance: none;
  margin: 0;
}

.sprite-info__label {
  display: flex;
  align-items: center;
  gap: 3px;
  flex: 0 0 auto;
  font-size: var(--fs-sm);
  font-weight: var(--font-weight);
  text-transform: uppercase;
  letter-spacing: 0.03em;
}

/* A label that runs to more than one line. Undoes the flex above — that's
   there so an axis arrow can sit beside its letter, and it swallows the
   line break a multi-line heading needs. */
.sprite-info__label--wrap {
  display: block;
  line-height: 1.35;
}

/* Which way the axis runs, next to the letter that names it (Scratch's).
   Stroked rather than filled like the app's other icons: at 12px a filled
   arrowhead turns into a blob, and the two glyphs have to be told apart at
   a glance or they're worse than nothing. */
.sprite-info__axis {
  flex: 0 0 auto;
  width: 12px;
  height: 12px;
}

/* Soft resting border, solid primary + focus ring only when focused — Art
   Studio's coord-input. Keeps the panel calm instead of a grid of hard blue
   boxes. */
.sprite-info__input {
  width: 100%;
  font-family: var(--font);
  font-size: var(--fs-sm);
  font-weight: var(--font-weight);
  padding: 4px var(--small);
  border: var(--border-width) solid var(--border-soft);
  border-radius: var(--radius-sm);
  background: var(--white);
  color: var(--black);
  outline: none;
}

.sprite-info__input:focus {
  border-color: var(--primary);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
}

.sprite-info__input:disabled {
  opacity: 0.55;
  cursor: default;
}

.sprite-info__side {
  display: flex;
  flex-direction: column;
  gap: var(--small);
  min-width: 0;
}

/* Stacked, not a row: the dial beside them is 152px tall, and three equal
   full-width buttons read as one three-way choice where a wrapping row read
   as three separate things. */
.sprite-info__styles {
  display: flex;
  flex-direction: column;
  gap: var(--small);
}

/* Side by side, for the two hitbox shapes on the info strip's own row. The
   stacked default belongs to the direction popup, which has a column to
   fill and three options to line up. */
.sprite-info__styles--row {
  flex-direction: row;
}

.sprite-info__check {
  display: flex;
  align-items: center;
  gap: var(--small);
  flex: 0 0 auto;
  font-size: var(--fs-sm);
  font-weight: var(--font-weight);
  white-space: nowrap;
  cursor: pointer;
}

/* One of the three rotation styles. A Phosphor glyph PLUS the word: Scratch's
   three unlabelled glyphs mean nothing until somebody explains them, so the
   word stays and the icon just reinforces it. Soft border at rest, filled
   when active. */
.pick-btn {
  display: inline-flex;
  align-items: center;
  justify-content: flex-start; /* stacked: the three labels line up */
  gap: var(--small);
  font-family: var(--font);
  font-size: var(--fs-sm);
  font-weight: var(--font-weight);
  padding: var(--small);
  border: var(--border-width) solid var(--border-soft);
  border-radius: var(--radius-sm);
  background: var(--white);
  color: var(--black);
  cursor: pointer;
}

.pick-btn svg {
  width: 15px;
  height: 15px;
  flex-shrink: 0;
}

.pick-btn:hover:not(.pick-btn--on):not(:disabled) {
  border-color: var(--primary);
  background: var(--bg);
}

.pick-btn--on {
  background: var(--primary);
  color: var(--white);
  border-color: var(--primary);
}

.pick-btn:disabled {
  opacity: 0.55;
  cursor: default;
}

/* --- Popover ---
   A property that is set once per sprite and then left alone is a lot of
   panel for the card to carry permanently, so it lives behind a small
   control that opens this. Two users: the Direction field (dial + rotation
   styles) and the Hitbox swatch (the two collision shapes).
   Positioned in JS off the opener's rect; fixed + high z so no ancestor's
   overflow clips it.
   NOTE: candidate for shared.css — this is generic popover chrome, and the
   suite has no shared definition of one. */
.popover {
  position: fixed;
  z-index: 80;
  background: var(--white);
  /* Framed, not floated. A drop shadow is how most popovers say "above the
     page"; this suite says it with a solid edge instead, which is the same
     language as every panel below it. */
  border: var(--border-width) solid var(--border);
  border-radius: var(--radius-md);
  padding: var(--medium);
}

.popover[hidden] {
  display: none;
}

.dir-popup__row {
  display: flex;
  align-items: center;
  gap: var(--medium);
}

/* Held off the dial by more than the row gap alone: the dial sets an angle,
   these set how the picture reacts to it, and the space is what separates
   the two jobs. */
.dir-popup__side {
  padding-left: var(--medium);
}

/* The Direction field opens the popup — signal it's more than a text box. */
#infoDir {
  cursor: pointer;
}

/* The Hitbox swatch: shows the sprite's current shape, opens the popover.
   Sized to a number input's height so it sits level with the four fields
   beside it rather than growing the row. */
.shape-swatch {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 30px;
  height: 30px;
  padding: 0;
  border: var(--border-width) solid var(--border-soft);
  border-radius: var(--radius-sm);
  background: var(--white);
  color: var(--black);
  cursor: pointer;
}

.shape-swatch__icon {
  width: 16px;
  height: 16px;
}

/* The swatch shows ONE shape — whichever the sprite is using.
   This is a CLASS rather than the `hidden` attribute, which is what the
   first version used and which silently failed: `[hidden]` comes from the
   UA stylesheet, so any author rule setting `display` on an svg outranks
   it and BOTH icons painted, side by side, in a 30px box. */
.shape-swatch__icon--off {
  display: none;
}

.shape-swatch:hover:not(:disabled) {
  border-color: var(--primary);
  background: var(--bg);
}

.shape-swatch:disabled {
  opacity: 0.55;
  cursor: default;
}

.hitbox-popup {
  display: flex;
  flex-direction: column;
  gap: var(--small);
  max-width: 260px;
}

/* The sentence the row it replaced had no room for. The two shapes are easy
   to SEE and hard to choose between — what a kid can't guess is that a floor
   wants the straight one, so the popover says it. */
.hitbox-popup__hint {
  margin: 0;
  font-size: var(--fs-sm);
  /* The same dimmed black the flyout's group headings use (line ~161), not
     a new colour — the palette is capped at 3-4 and secondary text is a
     tint, never another hue. */
  color: color-mix(in srgb, var(--black) 50%, var(--white));
}


/* Shown over the Blockly surface while the Background tab is up. Sits above
   .editor__frame and takes pointer events, so a stack can't be dragged onto
   a workspace the kid isn't looking at. */
.editor__mask {
  position: absolute;
  inset: 0;
  z-index: 5;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: var(--radius-md);
  background: color-mix(in srgb, var(--bg) 92%, transparent);
}

.editor__mask[hidden] {
  display: none;
}

.editor__mask-card {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--medium);
  padding: var(--large);
  border: var(--border-width) solid var(--border-soft);
  border-radius: var(--radius-md);
  background: var(--white);
  color: var(--primary);
}

.editor__mask-card svg {
  width: 40px;
  height: 40px;
}

.editor__mask-text {
  margin: 0;
  font-size: var(--fs-md);
  font-weight: var(--font-weight);
  line-height: 1.45;
  text-align: center;
  color: var(--black);
}

/* The card a .tab-row opens into, for a panel rather than the inventory
   modal: top-left square so the first tab sits flush, and the active tab's
   dip hides the seam. The bodies inside bring their own padding. */
.sprite-panel__card {
  border: var(--border-width) solid var(--border);
  border-radius: 0 var(--radius-md) var(--radius-md) var(--radius-md);
  background: var(--white);
  overflow: hidden;
}

/* --- Background panel --- */
/* Both columns stretch to the taller one and push their LAST child to the
   bottom, so the grid checkbox and the hex field sit on one line however
   the two columns' contents change. */
.bg-panel__cols {
  display: flex;
  align-items: stretch;
  gap: var(--medium);
}

.bg-panel__left {
  display: flex;
  flex: 1 1 auto;
  flex-direction: column;
  justify-content: space-between;
  gap: var(--medium);
  min-width: 0;
}

/* The picture and what to do with it, grouped on the app's tinted card —
   the same .opt-card the sprite info strip sits on, so the two tabs read
   as one system. */
.bg-panel__main {
  display: flex;
  flex: 1 1 auto;
  flex-direction: column;
  gap: var(--medium);
  min-width: 0;
  padding: var(--medium);
}

.bg-panel__side {
  display: flex;
  flex: 0 0 auto;
  flex-direction: column;
  justify-content: space-between;
  gap: var(--small);
  width: 140px;
}

.bg-panel__row {
  display: flex;
  align-items: stretch;
  gap: var(--medium);
}

/* Anchors the corner delete over the chip. */
.bg-panel__pic {
  position: relative;
  flex: 0 0 auto;
}

/* Same anatomy as a sprite chip — picture on top, name in a footer bar —
   because it IS the same idea one tier up, and a kid meets that chip one
   tab over. */
.bg-panel__chip {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  width: 132px;
  padding: 0;
  overflow: hidden;
  border: var(--border-width) solid var(--border-soft);
  border-radius: var(--radius-sm);
  background: var(--white);
  cursor: pointer;
}

.bg-panel__chip:hover {
  border-color: var(--primary);
}

/* 4:3, because unlike a 48px sprite thumb this IS what the stage will look
   like — big enough to judge a crop by. Relative so the pencil can sit in
   the picture's corner rather than the footer's. */
.bg-panel__box {
  position: relative;
  display: block;
  width: 100%;
  aspect-ratio: 4 / 3;
  background: var(--bg);
}

.bg-panel__slot {
  display: block;
  width: 100%;
  height: 100%;
}

.bg-panel__thumb {
  width: 100%;
  height: 100%;
  object-fit: cover;
  font-size: var(--fs-xl);
  text-align: center;
}

/* The pencil, exactly as on the sprite info strip's swatch: a square
   holding a picture is not obviously a BUTTON, and there's no text label
   here to say so. */
.bg-panel__badge {
  position: absolute;
  right: 0;
  bottom: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 18px;
  height: 18px;
  border-radius: var(--radius-sm) 0 0 0;
  background: var(--primary);
  color: var(--white);
}

.bg-panel__badge svg {
  width: 11px;
  height: 11px;
}

/* The chip's name bar. Filled like an ACTIVE sprite chip rather than a
   resting one: the chip sits on a --bg card here, so a --bg footer would
   vanish into it — and there is only ever one background, so "the selected
   one" is the only state it has. */
.bg-panel__name {
  display: block;
  padding: 4px var(--small);
  font-size: var(--fs-sm);
  font-weight: var(--font-weight);
  text-align: center;
  background: var(--primary);
  color: var(--white);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The sprite chip's delete, same corner and same weight. */
.bg-panel__del {
  position: absolute;
  top: 2px;
  right: 4px;
  border: none;
  background: transparent;
  color: var(--black);
  font-size: var(--fs-md);
  line-height: 1;
  opacity: 0.6;
  cursor: pointer;
}

.bg-panel__del:hover:not(:disabled) {
  opacity: 1;
}

.bg-panel__del:disabled {
  display: none;
}

/* Everything that acts on the picture, in one column beside it. */
.bg-panel__stack {
  display: flex;
  flex: 1 1 auto;
  flex-direction: column;
  gap: var(--small);
  min-width: 0;
}

/* Three full-width buttons read as one three-way choice, the way the
   direction popover's rotation styles do. */
.bg-panel__fits {
  display: flex;
  flex: 1 1 auto;
  flex-direction: column;
  gap: var(--small);
  min-width: 0;
}

.bg-panel__fits .pick-btn {
  flex: 1;
}

.bg-panel__field {
  display: flex;
  align-items: center;
  gap: var(--medium);
}

.bg-panel__fits {
  display: flex;
  gap: var(--small);
  flex: 1 1 auto;
}

.bg-panel__fits .pick-btn {
  flex: 1;
  justify-content: center;
}

.bg-panel__num {
  width: 64px;
  flex: 0 0 auto;
  -moz-appearance: textfield;
  appearance: textfield;
}

.bg-panel__num::-webkit-inner-spin-button,
.bg-panel__num::-webkit-outer-spin-button {
  -webkit-appearance: none;
  margin: 0;
}

.bg-panel__check {
  padding-bottom: 0;
}

.bg-panel__picker {
  width: 100%;
}

/* The picker is built to fill its container, so its proportions are the
   container's call: 5:4 here rather than square, which lands the colour
   column at the same height as the card beside it instead of leaving a
   hole under one of them. */
.bg-panel__picker .cpick__area {
  aspect-ratio: 5 / 4;
}

.bg-panel__hex {
  width: 100%;
  padding: 4px var(--small);
  font-family: var(--font);
  font-size: var(--fs-sm);
  font-weight: var(--font-weight);
  text-align: center;
  text-transform: uppercase;
  border: var(--border-width) solid var(--border-soft);
  border-radius: var(--radius-sm);
  background: var(--white);
  color: var(--black);
  outline: none;
}

.bg-panel__hex:focus {
  border-color: var(--primary);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
}

/* An unparseable code isn't an error state — it's a half-typed one. */
.bg-panel__hex--bad {
  border-color: var(--accent);
}

/* --- Custom colour picker (SV square + hue slider) ---
   NOTE: candidate for a shared util. `makeCustomPicker` and these .cpick
   rules are PORTED FROM Art Studio, which took them from SiteStack — three
   copies now, which is the point at which it should move into the shared
   files. Copied rather than reimplemented so the three stay identical until
   someone can merge them. Replaces <input type="color">, whose popup is the
   browser's and can't be styled at all. */
.cpick {
  display: flex;
  flex-direction: column;
  gap: var(--small);
}

.cpick__area {
  position: relative;
  width: 100%;
  aspect-ratio: 1;
  border-radius: var(--radius-sm);
  cursor: crosshair;
  touch-action: none;
}

.cpick__hue {
  position: relative;
  width: 100%;
  height: 14px;
  border-radius: 999px;
  cursor: pointer;
  touch-action: none;
  background: linear-gradient(
    to right,
    #f00 0%,
    #ff0 17%,
    #0f0 33%,
    #0ff 50%,
    #00f 67%,
    #f0f 83%,
    #f00 100%
  );
}

/* The ring is a CONTRAST device, not depth — a thumb has to stay visible on
   any colour it lands on, white included. */
.cpick__area-thumb,
.cpick__hue-thumb {
  position: absolute;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  border: 2px solid var(--white);
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
  transform: translate(-50%, -50%);
  pointer-events: none;
}

.cpick__hue-thumb {
  top: 50%;
}

/* --- Direction dial ---
   A 112px face inside a 152px box. The 20px gutter is what the cardinal
   numbers live in: they used to sit ON the ring, where the needle ran
   straight through them.

   THE CONVENTION IS THE WHOLE DESIGN. The needle is drawn pointing UP and
   rotated by the direction in degrees with NO conversion, because CSS
   rotate() is clockwise-from-up — which is exactly this app's direction
   convention (90 = right). A conic-gradient's `from 0deg` is that same
   convention a second time, which is why the swept arc below is also an
   angle in degrees with nothing done to it. If any of the three ever needs
   an offset, the convention has broken somewhere else. */
.dial {
  position: relative;
  flex: none;
  width: 152px;
  height: 152px;
  cursor: grab;
  touch-action: none;
}

.dial--grabbing {
  cursor: grabbing;
}

/* Keyboard focus lands on .dial, but the ring is what a kid sees — so the
   focus ring goes on the face, matching the sprite fields' treatment. */
.dial:focus-visible {
  outline: none;
}

.dial:focus-visible .dial__face {
  border-color: var(--primary);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
}

.dial__face {
  position: absolute;
  inset: 20px;
  border-radius: 50%;
  border: 2px solid var(--border-soft);
  background: var(--white);
}

/* The scale and the swept angle share one band at the rim, so the sweep
   FILLS IN the scale as it grows. Both are masked to a ring, and the px
   radii are measured against the face's 108px padding box — resize the
   face and these move with it. */
.dial__ticks,
.dial__sweep {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  -webkit-mask: radial-gradient(circle at center, transparent 0 43px, #000 44px 51px, transparent 52px);
  mask: radial-gradient(circle at center, transparent 0 43px, #000 44px 51px, transparent 52px);
}

/* Twelve ticks every 30 deg, with the four quarter turns heavier — drawn
   as two repeating gradients rather than twelve elements. The half-tick
   offsets (-1.2deg, -0.8deg) centre each mark ON its angle instead of
   starting it there. */
.dial__ticks {
  background:
    repeating-conic-gradient(from -1.2deg, var(--border) 0 2.4deg, transparent 2.4deg 90deg),
    repeating-conic-gradient(from -0.8deg, var(--border-soft) 0 1.6deg, transparent 1.6deg 30deg);
}

/* The angle itself, drawn. A positive direction fills clockwise from
   straight up; a negative one is the same arc STARTED at 360 + d, so it
   fills anticlockwise back to 0. JS sets the two custom properties and
   computes nothing else. */
.dial__sweep {
  background: conic-gradient(
    from var(--sweep-from, 0deg),
    var(--primary) 0 var(--sweep-len, 0deg),
    transparent var(--sweep-len, 0deg) 360deg
  );
}

.dial__needle {
  position: absolute;
  left: 50%;
  top: 12px;
  width: 4px;
  height: 42px; /* 54px centre - 12px top: the tip stops just inside the ticks */
  margin-left: -2px;
  border-radius: 2px;
  background: var(--primary);
  transform-origin: 50% 100%;
}

/* A pivot, not a dot: white centre with a primary rim, so the needle reads
   as turning ABOUT something. */
.dial__hub {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 14px;
  height: 14px;
  margin: -7px 0 0 -7px;
  border-radius: 50%;
  border: 3px solid var(--primary);
  background: var(--white);
}

.dial__cardinal {
  position: absolute;
  font-size: var(--fs-sm);
  font-weight: var(--font-weight);
  color: var(--primary);
}

.dial__cardinal--up {
  top: 2px;
  left: 50%;
  transform: translateX(-50%);
}

.dial__cardinal--down {
  bottom: 2px;
  left: 50%;
  transform: translateX(-50%);
}

.dial__cardinal--left {
  left: 0;
  top: 50%;
  transform: translateY(-50%);
}

.dial__cardinal--right {
  right: 0;
  top: 50%;
  transform: translateY(-50%);
}

/* --- Sprite list --- */
.sprite-panel {
  position: relative;
  display: flex;
  flex-direction: column;
  flex-shrink: 0;
}

.sprite-panel__list {
  display: flex;
  flex-wrap: wrap;
  gap: var(--small);
}

/* A chip is a vertical tile — image on top, name below — code navigation,
   not a stage preview: the NAME carries the identity and the thumbnail is a
   secondary anchor showing the sprite's starting image. (Any shelf image can
   dress any sprite, so a live thumbnail would show a Zombie wearing an
   explosion.) */
.sprite-chip {
  position: relative; /* anchors the corner delete + run count */
  display: flex;
  flex-direction: column;
  align-items: stretch; /* image box + footer both span full width */
  min-width: 76px;
  max-width: 132px;
  border: var(--border-width) solid var(--border-soft);
  border-radius: var(--radius-sm);
  overflow: hidden; /* clips the footer bar to the rounded corners */
  background: var(--white);
  cursor: pointer;
}

/* Selected sprite: the footer fills with --primary (below) and the border
   goes full-strength to match; unselected chips rest on --border-soft. */
.sprite-chip--active {
  border-color: var(--primary);
}

/* A script is being dragged and the pointer is over this chip — drop and
   this sprite takes a COPY. Green because it's "this will happen", not
   "this is selected": the --primary chip is still the one whose scripts
   are open in the workspace, and the drop doesn't change that. */
.sprite-chip--drop {
  border-color: var(--go);
  background: var(--bg);
}

.sprite-chip--drop .sprite-chip__thumb-box {
  background: var(--bg);
}

/* Adding a sprite is the last tile in the list, not a control on the title
   bar: "which sprite" is one decision and the list is where it's made. It
   stretches to the row's height for free (the list is a flex row), and
   dashed + unfilled footer keeps it from reading as a sprite named "New". */
.sprite-chip--add {
  align-items: center;
  justify-content: center;
  gap: 2px;
  padding: var(--small);
  border-style: dashed;
  border-color: var(--primary);
  background: none;
  font-family: var(--font);
  font-size: var(--fs-sm);
  font-weight: var(--font-weight);
  color: var(--primary);
}

.sprite-chip--add:hover {
  background: var(--bg);
}

.sprite-chip--add svg {
  width: 20px;
  height: 20px;
}

/* One-shot flash confirming a script landed here. The copy goes to a
   sprite the kid ISN'T looking at, so without this (and the toast) a
   good drop and a missed one look exactly the same. */
@keyframes spriteChipTook {
  0% {
    border-color: var(--go);
  }
  60% {
    border-color: var(--go);
  }
  100% {
    border-color: var(--border-soft);
  }
}

.sprite-chip--took {
  animation: spriteChipTook 900ms ease-out;
}

/* The image sits in a white box up top. Deliberately NOT a control: the
   whole chip is one click target that selects, so the picture can't steal
   a click that meant "show me this sprite's scripts". Changing the starting
   image is the swatch in the info strip above. */
.sprite-chip__thumb-box {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--small);
  background: var(--white);
}

.sprite-chip__thumb {
  display: block;
  width: 48px;
  height: 48px;
  object-fit: contain;
  font-size: var(--fs-xl);
  line-height: 48px;
  text-align: center;
}

/* The name is a full-width footer bar, and its colour is the active
   indicator: --primary for the selected sprite, --bg for the rest. */
.sprite-chip__name {
  padding: 4px var(--small);
  font-size: var(--fs-sm);
  font-weight: var(--font-weight);
  text-align: center;
  background: var(--bg);
  color: var(--black);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.sprite-chip--active .sprite-chip__name {
  background: var(--primary);
  color: var(--white);
}

.sprite-chip__rename {
  padding: 4px var(--small);
  font-family: var(--font);
  font-size: var(--fs-sm);
  font-weight: var(--font-weight);
  text-align: center;
  border: none;
  border-top: var(--border-width) solid var(--primary);
  background: var(--white);
  color: var(--black);
  outline: none;
}

/* Live instance count during a run — a badge on the image, so a runaway
   `create` is obvious. Top-left, opposite the delete. */
.sprite-chip__count {
  position: absolute;
  top: 2px;
  left: 4px;
  font-size: var(--fs-sm);
  font-weight: var(--font-weight);
  opacity: 0.7;
}

/* Delete sits in the tile's top-right corner, over the image box. */
.sprite-chip__del {
  position: absolute;
  top: 2px;
  right: 4px;
  border: none;
  background: transparent;
  color: var(--black);
  font-size: var(--fs-md);
  line-height: 1;
  opacity: 0.6;
  cursor: pointer;
}

.sprite-chip__del:hover {
  opacity: 1;
}

/* --- Project images shelf (inside the shared draggable modal) --- */
.image-shelf {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
  gap: var(--medium);
}

.image-shelf__card {
  display: flex;
  flex-direction: column;
  gap: var(--small);
}

/* Same tile treatment as a sprite chip, used as an on/off TOGGLE: image in
   a white box, name in a footer bar. Resting (not in the program) sits on
   --border-soft; --on (included) takes the full --primary border + filled
   footer, exactly like an active sprite chip. */
.image-shelf__pick {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  padding: 0;
  border: var(--border-width) solid var(--border-soft);
  border-radius: var(--radius-sm);
  overflow: hidden;
  background: var(--white);
  font-family: var(--font);
  cursor: pointer;
}

.image-shelf__pick:hover {
  border-color: var(--primary);
}

.image-shelf__pick--on {
  border-color: var(--primary);
}

.image-shelf__pick--on .image-shelf__name {
  background: var(--primary);
  color: var(--white);
}

/* One-line "what is this screen for" — the toggle grid needs a verb stated,
   or it reads as "pick one" (which it isn't). */
.image-shelf__hint {
  margin: 0 0 var(--medium);
  font-size: var(--fs-sm);
  line-height: 1.45;
}

/* Drawings are cropped to their artwork's bbox, so they arrive at wildly
   different aspect ratios — letterbox them into a uniform tile. */
.image-shelf__thumb {
  display: block;
  height: 64px;
  padding: var(--small);
  object-fit: contain;
  font-size: var(--fs-xl);
  line-height: 64px;
  text-align: center;
}

.image-shelf__name {
  padding: 4px var(--small);
  font-size: var(--fs-sm);
  font-weight: var(--font-weight);
  text-align: center;
  background: var(--bg);
  color: var(--black);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.image-shelf__acts {
  display: flex;
  justify-content: center;
  gap: var(--small);
}

.image-shelf__act {
  font-family: var(--font);
  font-size: var(--fs-sm);
  padding: 2px var(--small);
  border: var(--border-width) solid var(--primary);
  border-radius: var(--radius-sm);
  background: var(--white);
  cursor: pointer;
}

.image-shelf__act:hover {
  border-color: var(--black);
}

/* "picture only" — why a remixed drawing has no Edit button. It sits in the
   actions row where that button would be, so the gap has a reason standing in
   it rather than being a gap.

   DELIBERATELY NOT A BUTTON, and not a disabled one either: a door that
   doesn't open reads as broken, which is the same rule that keeps a
   coming-soon Remix off the borrowed header. It reads as a label — no border,
   no background, no pointer — and carries the full explanation in its
   title. Sized and padded like the buttons beside it so the row still lines
   up. */
.image-shelf__note {
  font-size: var(--fs-sm);
  padding: 2px var(--small);
  color: color-mix(in srgb, var(--black) 55%, transparent);
  align-self: center;
  cursor: help;
}

/* --- Inventory (the project's assets: "In this project" + "Gallery") ---
   A large draggable window with tabs; the grid scrolls internally. Reuses
   the .image-shelf tile treatment for its tiles. */
.inventory {
  width: min(1000px, 92vw);
  max-width: 92vw;
  height: 84vh;
  max-height: 84vh;
  display: flex;
  flex-direction: column;
}

/* Two panes: the project's images on the left (always visible), the sources
   to add from (My Library / Gallery) tabbed on the right. */
.inventory__content {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: row;
  gap: var(--medium);
  padding: var(--medium);
}

/* The left pane is a panel like the others — .section supplies the border,
   radius, white bg and primary title bar; these rules just make it a
   fixed-width flex column whose body scrolls. */
.inventory__project {
  flex: 0 0 34%;
  min-width: 0;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

.inventory__project-body {
  flex: 1;
  min-height: 0;
}

.inventory__sources {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  /* no gap — the tab row sits directly on the card it opens into */
}

/* Folder tabs — mirrors Art Studio's inspector__tabs. The tab row sits on
   the card below it; the active tab dips a border-width to connect into it
   (the open-folder look). */
.tab-row {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 4px;
  flex-shrink: 0;
}

.tab {
  font-family: var(--font);
  font-size: var(--fs-sm);
  font-weight: var(--font-weight);
  text-transform: uppercase;
  letter-spacing: 0.5px;
  padding: var(--medium);
  border: var(--border-width) solid var(--border);
  border-bottom: none;
  border-radius: var(--radius-md) var(--radius-md) 0 0;
  background: var(--bg);
  color: var(--primary);
  cursor: pointer;
}

.tab:hover:not(.tab--on) {
  background: var(--white);
}

.tab--on {
  background: var(--white);
  color: var(--black);
  margin-bottom: calc(-1 * var(--border-width));
  position: relative;
  z-index: 1;
  cursor: default;
}

/* The card the tabs open into. Top-left square so the first tab sits flush;
   the active tab's dip hides the seam. Holds the toolbar + scrolling grid. */
.inventory__card {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  gap: var(--medium);
  padding: var(--medium);
  background: var(--white);
  border: var(--border-width) solid var(--border);
  border-radius: 0 var(--radius-lg) var(--radius-lg) var(--radius-lg);
  overflow: hidden;
}

.inventory__toolbar {
  display: flex;
  gap: var(--small);
}

/* Author display:flex would otherwise beat the [hidden] UA rule — restore it
   so the gallery's category+search only show on the Gallery tab. */
.inventory__toolbar[hidden] {
  display: none;
}

.inventory__category,
.inventory__search {
  font-family: var(--font);
  font-size: var(--fs-sm);
  font-weight: var(--font-weight);
  padding: var(--small) var(--medium);
  border: var(--border-width) solid var(--border-soft);
  border-radius: var(--radius-sm);
  background: var(--white);
  color: var(--black);
}

.inventory__search {
  flex: 1;
  min-width: 0;
  outline: none;
}

.inventory__search:focus {
  border-color: var(--primary);
}

/* The grid is a .image-shelf too (same columns/gap); here it takes the
   remaining height and scrolls. */
.inventory__grid {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  align-content: start;
}

/* "New drawing" (and the fallback "From my drawings") — a filled action tile
   that reads as a DO, standing apart from the white asset tiles around it. */
.inventory__source {
  align-self: stretch; /* match the image cards' row height */
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--small);
  padding: var(--small);
  border: var(--border-width) solid var(--primary);
  border-radius: var(--radius-sm);
  background: var(--primary);
  color: var(--white);
  cursor: pointer;
}

.inventory__source:hover {
  background: color-mix(in srgb, var(--primary) 88%, black);
}

.inventory__source svg {
  width: 28px;
  height: 28px;
}

.inventory__source-title {
  font-family: var(--font);
  font-size: var(--fs-sm);
  font-weight: var(--font-weight);
  color: var(--white);
  text-align: center;
  line-height: 1.3;
}

.inventory__source-sub {
  font-family: var(--font);
  font-size: var(--fs-sm);
  color: var(--white);
  opacity: 0.85;
  text-align: center;
}

/* A full-row status line (loading / empty), spanning the grid. */
.inventory__note {
  grid-column: 1 / -1;
  margin: var(--small) 0;
  font-size: var(--fs-sm);
  opacity: 0.7;
}

/* --- Drag-and-drop: drag a source tile onto the project pane --- */
.inventory__draggable {
  touch-action: pan-y; /* vertical swipes still scroll the grid */
  cursor: grab;
  user-select: none;
  -webkit-user-drag: none;
}

.inventory__draggable:active {
  cursor: grabbing;
}

/* The image is the picture, not the drag handle — let pointer events fall
   through to the button, and never let the browser drag the image itself. */
.inventory__draggable img {
  pointer-events: none;
  -webkit-user-drag: none;
}

/* The tile that follows the pointer. No shadow (house rule) — it lifts via
   opacity + a slight scale. */
.inventory__drag-ghost {
  position: fixed;
  z-index: 9999;
  width: 88px;
  pointer-events: none;
  opacity: 0.9;
  transform: translate(-50%, -50%) scale(1.05);
}

/* Whole-drag invite on the project pane — primary when adding, the
   destructive accent when removing. */
.inventory--adding .inventory__project,
.inventory--removing .inventory__project {
  border-style: dashed;
}

.inventory--adding .inventory__project {
  border-color: var(--primary);
}

.inventory--removing .inventory__project {
  border-color: var(--accent);
}

/* Will-fire-on-release: solid border + a tinted background, coloured per
   mode. Higher specificity than the dashed hint so it wins. */
.inventory--adding .inventory__project--target {
  border-style: solid;
  background: color-mix(in srgb, var(--primary) 8%, var(--white));
}

.inventory--removing .inventory__project--target {
  border-style: solid;
  background: color-mix(in srgb, var(--accent) 10%, var(--white));
}

/* --- Image picker popup (per-sprite selection) ---
   Functional baseline; a fuller styling pass is still to come. Anchored by
   JS, so position:fixed with left/top set inline. */
.img-picker {
  position: fixed;
  z-index: 70;
  width: 300px;
  max-height: 60vh;
  display: flex;
  flex-direction: column;
  gap: var(--small);
  padding: var(--medium);
  background: var(--white);
  border: var(--border-width) solid var(--border);
  border-radius: var(--radius-md);
  overflow: hidden;
}

.img-picker[hidden] {
  display: none;
}

.img-picker__grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(64px, 1fr));
  gap: var(--small);
  overflow-y: auto;
}

/* Same tile treatment as a sprite chip: image box + name footer, resting on
   --border-soft and going full --primary on hover to signal "pick me". */
.img-picker__item {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  border: var(--border-width) solid var(--border-soft);
  border-radius: var(--radius-sm);
  overflow: hidden;
  background: var(--white);
  cursor: pointer;
}

.img-picker__item:hover {
  border-color: var(--primary);
}

.img-picker__thumb {
  display: block;
  height: 44px;
  padding: var(--small);
  object-fit: contain;
  font-size: var(--fs-lg);
  line-height: 44px;
  text-align: center;
}

.img-picker__name {
  padding: 4px var(--small);
  font-size: var(--fs-sm);
  font-weight: var(--font-weight);
  text-align: center;
  background: var(--bg);
  color: var(--black);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.img-picker__actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--small);
}

/* --- Current program name in the header --- */
.header__doc {
  font-size: var(--fs-sm);
  font-weight: 600;
  color: var(--primary);
  margin-left: var(--small);
}

/* A classmate's program, open from the Class tab: two lines, the name and
   what it means for saving. Accent (pink) because it is the one thing in the
   header that is NOT about the kid's own work, and primary is what everything
   else in this bar already is — a second blue would say nothing.

   THIS COSTS NO HEADER HEIGHT, which is the only reason it is two lines and
   not a strip. The header's height comes from its BUTTONS — 25px of button in
   a 47px bar — so there is ~12px of unused room under a one-line label, and
   the note fits inside it. Measured, and asserted in test/harness.html at
   --app-min with a long name and a long username, because the moment the note
   wraps to a third line it starts pushing the stage down and becomes the strip
   that was already rejected.

   `align-self: center` is load-bearing, not tidiness. .header__center aligns
   its children on the BASELINE, and a baseline-aligned two-line label hangs
   its second line below the buttons' baseline — which grew the header by
   6.3px in exactly the way this is trying to avoid. Centering takes the label
   out of the baseline row, where a two-line thing has no business being. */
.header__doc--borrowed {
  color: var(--accent);
  display: flex;
  flex-direction: column;
  align-self: center;
  line-height: 1.15;
}

/* The consequence, said in the kid's own terms and pointing at the button
   that fixes it.

   THE SAME SIZE AS THE NAME ABOVE IT — .header__doc is already --fs-sm, and
   --fs-sm is 11px, so the whole separation is weight and opacity. That is not
   a compromise, it is the four-size rule doing its job: a fifth step was
   written here first (a hardcoded 11px, which turned out to BE the token) and
   the app never needed one. Weight carries the hierarchy on a line this
   short, and it is what keeps the two lines inside the header's spare
   height. */
.header__doc-note {
  font-weight: 400;
  opacity: 0.85;
}

/* --- Class-code "Join" button ---
   NOTE: the shared class-code widget (input/badge/leave) is in
   shared.css, but the Join button style is not yet. Candidate to
   move into shared.css. */
.header__class-btn {
  font-family: var(--font);
  font-size: var(--fs-sm);
  font-weight: var(--font-weight);
  text-transform: uppercase;
  padding: var(--small) var(--medium);
  border: var(--border-width) solid var(--primary);
  border-radius: var(--radius-sm);
  background: var(--primary);
  color: var(--white);
  cursor: pointer;
}

.header__class.active .header__class-btn {
  display: none;
}

/* --- Toast ---
   NOTE: showToast() is a dependency of the shared saves.js, but its
   implementation + styling live per-app. Strong candidate to become a
   shared util so every app's toast is identical. */
.toast {
  position: fixed;
  bottom: 20px;
  left: 50%;
  transform: translateX(-50%) translateY(100px);
  font-family: var(--font);
  font-size: var(--fs-sm);
  font-weight: 600;
  padding: 10px 20px;
  border-radius: var(--radius-md);
  background: var(--black);
  color: var(--white);
  z-index: 2000;
  transition: transform 0.3s ease;
  pointer-events: none;
}

.toast--visible {
  transform: translateX(-50%) translateY(0);
}
