/* Canvas — stage, artboard, elements, selection.
   Owned by src/canvas.js and src/render.js.
   Loaded from index.html. See the ownership lanes in CLAUDE.md. */

/* ------------------------------------------------------------------ stage */
/* The canvas layer: full-bleed, under the panels, so a zoomed canvas runs on
   beneath them rather than stopping at their edge. The padding is what keeps
   the fitted view centred in the gap between them — the box stays full width,
   only its content box is inset. fit() reads this padding, so the two cannot
   drift apart. */
.stage {
  position: absolute;
  inset: 0;
  z-index: 0;
  background: var(--bg-stage);
  display: flex;
  overflow: hidden;
  padding:
    var(--stage-gutter)
    calc(var(--panel-right) + var(--panel-float-inset) + var(--stage-gutter))
    calc(var(--toolbar-h) + var(--panel-float-inset) + var(--stage-gutter))
    calc(var(--panel-left) + var(--panel-float-inset) + var(--stage-gutter));
  /* The stage's own default, gutter included — a handle's resize glyph, a
     link's pointer, text's own caret, and the rest all set their cursor
     directly on the element that shows them, which is what lets this sit at
     the root and never fight any of them: an inherited value always loses to
     one set on the element itself, regardless of which rule looks more
     specific. */
  cursor: var(--cursor-canvas);
}
.stage__scroll { margin: auto; }
/* Held space, and the drag it starts: a blunt override, on purpose — every
   element under the pointer has its own cursor (a handle's resize glyph, a
   link's pointer, text's own caret), and none of that should keep showing
   while the whole stage has turned into one big pan handle. */
.stage.is-pannable,
.stage.is-pannable * { cursor: var(--cursor-grab) !important; }
.stage.is-panning,
.stage.is-panning * { cursor: var(--cursor-grabbing) !important; }
/* Alt-dragging a selection off a copy of itself (beginMove, canvas.js) — the
   same blunt override as panning, and for the same reason: whatever the
   pointer happens to be riding over mid-drag (the moving copy's own text
   caret, say) shouldn't outrank "this drag is about to leave a duplicate
   behind". */
.stage.is-duplicating,
.stage.is-duplicating * { cursor: var(--cursor-duplicate) !important; }
/* Comment mode: the same blunt-override idea, for the same reason — every
   editing gesture is off in this mode (see canvas.js's own mode gate), so
   nothing under the pointer should keep advertising one with its own cursor —
   but the class lands on .artboard itself rather than .stage, unlike the two
   rules above: panning genuinely works anywhere in the stage, gutter
   included, but a comment can only ever land on the slide itself (canvas.js's
   own gate on the gesture agrees — see its own
   e.target.closest('.artboard') check), so showing this cursor over the
   gutter would be inviting a click that does nothing. Kept at two classes
   deep on purpose, same as .stage.is-pannable above: comments.css's own
   carve-outs for the pin/card (its cursor rules load after this file) were
   tuned to beat exactly that specificity, and a third class here would win
   the tie instead and make their own cursor rules stop taking effect. */
.artboard.is-commenting,
.artboard.is-commenting * { cursor: var(--cursor-comment) !important; }
/* The stage's own top-left corner — just the current zoom level now.
   Presentation dimensions and the theme toggle used to float here too, but
   both are a right-click away now (openCanvasContextMenu, canvas.js), so
   this cluster is one control, not three. Left inset matches the plain
   status text this whole cluster replaces used to sit at; top is new, since
   that text used to sit at the *bottom* of the stage instead, clear of the
   toolbar's own band down there — nothing to clear up here, so a plain small
   inset is enough. */
.stage__controls {
  position: absolute;
  top: 12px;
  left: calc(var(--panel-left) + var(--panel-float-inset) + 12px);
  z-index: 2;
  display: flex;
  align-items: center;
  gap: 8px;
}
/* Nothing to say until canvas.js's first fit() has worked out a zoom — an
   empty pill, or a 100% that changes a moment later, is noise while the
   page is still loading. */
.stage__controls:not(.is-ready) { visibility: hidden; opacity: 0; }
.stage__controls { transition: opacity 0.15s ease; }
/* Round-ended, white, floating the same shadow every card in this app does
   (--shadow-panel), so the one thing sitting in this corner still reads as a
   considered control rather than a stray widget now that it's alone here. */
.zoom-btn {
  height: 32px;
  padding: 0 12px;
  border: none;
  border-radius: 16px;
  display: inline-grid;
  place-items: center;
  background: var(--bg-panel);
  box-shadow: var(--shadow-panel);
  color: var(--ink);
  cursor: var(--cursor-pointer);
  /* A run of digits reads better lined up than jittering a pixel or two as
     one glyph's own width differs from the next. */
  font-variant-numeric: tabular-nums;
}
.zoom-btn:hover { background: var(--bg-hover); }

/* The dimensions modal — link.css's own .modal/.modal__dialog/.modal__title/
   .modal__actions shell (already loaded on index.html), filled here with a
   W/H pair instead of an address field. Small and self-contained rather than
   a cross-lane import for the fields alone, the same call slides.js's own
   copydialog already makes for its own shell. */
.dimensionsmodal__row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 4px; }
.dimensionsmodal__field { display: flex; flex-direction: column; gap: 4px; }
.dimensionsmodal__label {
  font-size: 11px;
  font-weight: 600;
  color: var(--ink-soft);
}
.dimensionsmodal__field .input--num { width: 100%; box-sizing: border-box; }

.artboard {
  position: relative;
  background: #fff;
  box-shadow: 0 1px 3px rgba(16, 20, 26, 0.14), 0 8px 24px rgba(16, 20, 26, 0.10);
}
/* The slide's place, held while the deck loads — before canvas.js's first
   fit() gives the artboard its real size (and `is-ready`). Sized the way
   fit() sizes a 16:9 deck — the stage's content box, less the same 28px it
   leaves, capped at its 2x maximum — so for most decks the real slide lands
   exactly where this was. The deck's own ratio isn't known yet; one that
   isn't 16:9 settles to its own shape when it arrives. */
.artboard:not(.is-ready) {
  width: min(
    calc(100vw - var(--panel-left) - var(--panel-right) - 2 * (var(--panel-float-inset) + var(--stage-gutter))),
    calc((100vh - var(--toolbar-h) - var(--panel-float-inset) - 2 * var(--stage-gutter) - 28px) * 16 / 9),
    3840px
  );
  aspect-ratio: 16 / 9;
  background: var(--bg-panel);
  animation: artboard-loading 1.4s ease-in-out infinite;
}
@keyframes artboard-loading { 50% { opacity: 0.55; } }
@media (prefers-reduced-motion: reduce) {
  .artboard:not(.is-ready) { animation: none; }
}
/* The slide's own background, under everything on it. Its own layer rather
   than the host's background because a blur is a filter on an element — there
   is no blurring just a background — and because the tint has to sit above the
   blur rather than be blurred along with it. Rendered by render.js, so the
   sidebar thumbnails get the same thing. */
.slidebg { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.slidebg__picture { position: absolute; inset: 0; }
.slidebg__tint { position: absolute; inset: 0; }

/* A shader background's own poster/canvas pair (render.js's own
   buildShaderLayer) — the same is-loaded split as a rive element's three
   children above, minus the placeholder mark: an empty box reads better at
   full-bleed slide size than a centred icon would, and the host's own solid
   background (paintSlideBackground) already shows through a poster with no
   src. */
.slidebg__shader-poster,
.slidebg__shader-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}
.slidebg__shader-poster { object-fit: cover; -webkit-user-drag: none; }
.slidebg__shader-poster:not([src]) { display: none; }
.slidebg__shader-canvas { visibility: hidden; }
.slidebg--shader.is-loaded .slidebg__shader-canvas { visibility: visible; }
.slidebg--shader.is-loaded .slidebg__shader-poster { display: none; }

/* A rectangle's own picture fill — the same shape as .slidebg above, painted
   by the same buildPictureLayer in render.js. The element itself clips it to
   the border radius (see renderElement), so this only has to fill the box. */
.elfill { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.elfill__picture { position: absolute; inset: 0; }
.elfill__tint { position: absolute; inset: 0; }

/* A dashed/dotted border, hand-drawn with stroke-dasharray — see
   buildDashedBorder in render.js. Sits above the fill layer by DOM order. */
.elborder { position: absolute; inset: 0; pointer-events: none; }

/* A triangle/diamond/star's own fill, clipped to the shape's own outline one
   level down from the element itself — see paintShapeFill in render.js for
   why: a drop-shadow on the element would be clipped by a clip-path set on
   that same element. */
.elshapefill { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }

/* A triangle/diamond/star's own border — border-radius has no dial for a
   shape like this, so it is masked the same way .elborder is, just against
   a closed polygon path instead of a rounded rect. See paintPolygonBorder
   in render.js. */
.elshapeborder { position: absolute; inset: 0; pointer-events: none; }

/* A bar chart's own bars — see paintChart in render.js. Positioned in
   pixels rather than inset: 0 like the layers above, since each one is its
   own width and height rather than filling the whole box. .el is already
   position: absolute, which is what gives these something to be positioned
   against. */
.chartbar { position: absolute; pointer-events: none; }

/* A line chart's own series — see paintLineChart in render.js. Each .chartline
   fills the whole box (its area and its stroke are both paths measured in the
   box's own deck pixels, not a sub-rect of it the way a bar's own slot is),
   and .chartline__layer is whichever of the two — area fill,  stroke — a call
   into maskedPathLayer actually painted; the other stays an empty, unpainted
   div rather than being left out, so paintLineChart always appends the same
   two children regardless of what either one turned out to be. */
.chartline { position: absolute; inset: 0; pointer-events: none; }
.chartline__layer { position: absolute; inset: 0; }

/* The x-axis' own label row, one per row in the chart's own shared data —
   see paintLineAxisLabels in render.js. A direct child of the element's
   own box (position: absolute is what .el already gives that box), not of
   .chartline, since a label marks a column position on the axis, not any
   one line's own point — left/top are set inline per label, in deck
   pixels, the same as every other JS-positioned piece in this file.
   Centred on that x position (not left-aligned to it) the same reason a
   pie chart's own value/label pair centres on its point (.chartpie__text,
   below) rather than growing off to one side of it. Cumulative value
   (paintLineAxisLabels's own second pass, same file) shares this rule
   rather than repeating it: it is positioned exactly the same way, just
   stacked above the label instead of on the axis itself. */
.chartline__axislabel,
.chartline__axisvalue {
  position: absolute;
  transform: translateX(-50%);
  white-space: nowrap;
  pointer-events: none;
}

/* The shared Plot points/Trail line overlay, one per chart rather than one
   per label — see paintLineAxisLabels in render.js for why a plain SVG
   `<line>`/`<circle>` pair needs neither a mask nor a layer of its own.
   overflow: visible is what lets either reach past the box the same way a
   label's own Y position already can (a trail running from a point above
   or below el.h, or a curve near the very top/bottom of it) rather than
   being clipped flat at the SVG's own edge. */
.chartline__trail {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
}

/* A pie chart's own ring — see paintPieChart in render.js. .chartpie fills
   the whole box the same reason .chartline does (an arc is a path measured
   in the box's own deck pixels), and holds one .chartpie__segment per
   positive-value column — each its own maskedPathLayer, cut to that
   column's own arc. */
.chartpie { position: absolute; inset: 0; pointer-events: none; }
.chartpie__segment { position: absolute; inset: 0; }

/* A slice's own value/label pair — a child of .chartpie, positioned by its
   own left/top in the box's own deck pixels (paintPieChart computes those
   from the column's own Trail/Distance fields, inspector-chart.js) rather
   than bottom: 100% off a shared edge the way a bar's own pair is: a wedge
   has no "top" of its own to sit above, only the point that pair's own line
   from the ring's centre lands on. transform centres the *point* on that
   spot vertically and horizontally — so Trail 50 (the arc's own midpoint,
   makePieColumn's own default) still reads as "over this slice" — but the
   two lines inside are left-aligned to each other (align-items/text-align),
   not centred over one another: a value and a label rarely run the same
   width, and ragged-both-sides reads as unanchored where a shared left edge
   reads as one pair. Font family/weight/size, colour and the gap between
   the two lines are all authored (textStyleSection, inspector.js) and set
   inline by styleChartText in render.js, the same division bar's own pair
   already keeps — only what is never authored lives here. */
.chartpie__text {
  position: absolute;
  transform: translate(-50%, -50%);
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  text-align: left;
  white-space: nowrap;
}

/* The value/label pair above each bar. Children of .chartbar, which is
   position: absolute and so their containing block — bottom: 100% is what
   "right above this bar's own top edge" comes to, whatever the bar's own
   height is, and left/right: 0 matches them to that same bar's own width
   now that a bar is its own slot (see paintChart in render.js) rather than
   a narrower shape centred inside a wider one. Font family/weight/size,
   colour and margin-bottom are all authored per field (see
   textStyleSection in inspector.js) and set inline by styleChartText in
   render.js, so only what is never authored — alignment and overflow —
   lives here. */
.chartbar__value,
.chartbar__label {
  position: absolute;
  bottom: 100%;
  left: 0;
  right: 0;
  text-align: center;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* A bar's own reflection — either paintReflectionShader's own <canvas>
   (chart-reflection-gl.js) or, when that can't run, paintReflectionMasked's
   div plus ensureChartReflectionFilter's SVG <filter> (render.js); see
   paintReflectionInto for which. Also a child of .chartbar, so top: 100%
   would mean "right below this bar's own bottom edge", the mirror of the
   value/label pair's bottom: 100% above it — except it is pulled up a few
   pixels short of that, tucked slightly under the bar instead of starting
   flush against it. Blur softens both edges of whatever it's applied to,
   this reflection's own top edge included, so a flush seam showed the
   reflection at less than its own filter's peak strength right where it
   meets the bar — a visible pale line. A child still paints over its own
   parent's background, so the sliver this overlaps is the bar's own fill
   showing through a slightly-softened, identically-coloured copy of itself:
   invisible in practice, and cheaper than reworking the stacking order to
   tuck it behind instead. No `filter`/`left`/`right`/`width`/`height` here,
   unlike most of this file's own painted-by-JS properties: every one of
   those differs per instance now (a masked reflection's own filter id, a
   shader canvas's own wider box to hold its bleed), so render.js sets them
   all inline rather than this rule pointing at one fixed shape.
   transform-origin is deliberately left at its default (the box's own
   centre): scaleY(-1) around the centre mirrors the rendered result without
   moving the container itself, which is what keeps the reflection sitting
   below the bar rather than flipping back up into it — a corner or an edge
   origin would move the box's own visual bounds along with the flip. */
.chartbar__reflection {
  position: absolute;
  top: calc(100% - 5px);
  left: 0;
  right: 0;
  transform: scaleY(-1);
  pointer-events: none;
}

/* paintReflectionGlowMasked's own copy (the masked fallback's second layer)
   and paintReflectionShader's own <canvas> — both share the base rule above
   with no properties of their own to add. The modifiers exist so the three
   are still tellable apart in devtools, since what actually differs between
   them (which filter a masked layer points at, or a shader canvas's own
   wider left/width) is set inline per instance. */
.chartbar__reflection--glow,
.chartbar__reflection--shader {
}

.artboard__layer {
  position: absolute;
  top: 0;
  left: 0;
  transform-origin: 0 0;
  /* Not clipped: an element dragged or resized past the slide's own edge
     still has to paint out here for .artboard__bleed below to dim it. A
     fully off-slide element painting nowhere at all would be indistinguishable
     from one that was deleted. */
}
/* One box, exactly the artboard's own size, whose spread shadow paints
   everywhere *outside* that box and nowhere inside it — so the slide itself
   is untouched and only whatever bleeds past its edge gets covered. The
   colour is the stage's own flat background at 70% opacity: compositing a
   70%-opaque layer of that background on top of whatever is under it lands
   on the exact same pixels a plain 30%-opacity paint of that content would,
   which is what "outside the canvas" is meant to look like. That only holds
   because the stage behind it is a flat colour with nothing else painted
   there — true everywhere this can reach, since the spread is clipped by
   .stage's own overflow before it could ever reach a floating panel. Sits
   above .artboard__layer, so it dims the elements, and below
   .artboard__overlay, so a selected element's own handles stay at full
   strength even while it sits partway off the slide. */
.artboard__bleed {
  position: absolute;
  inset: 0;
  pointer-events: none;
  box-shadow: 0 0 0 100000px color-mix(in srgb, var(--bg-stage) 70%, transparent);
}
.artboard__overlay { position: absolute; inset: 0; pointer-events: none; }

/* ------------------------------------------------------------------ elements */
.el { position: absolute; }
/* A colleague's move, eased between the positions collab.js batches up
   (canvas.js's syncRemoteMoves) — a little longer than one batch, so each
   new position picks up from wherever the last one had got to. Position
   only: easing a rotation through 360 would spin the long way round. */
.el.is-gliding { transition: left 80ms linear, top 80ms linear; }
/* A locked element (render.js's own renderElement) is click-through by
   construction rather than by a check in every handler that might otherwise
   reach it: the browser's own hit-testing walks straight past it to
   whatever unlocked thing — or bare layer — is underneath, which is what
   "clicks don't register" actually means for a press landing on the DOM
   rather than going through elementAt's model-side hit test (canvas.js). */
.el--locked { pointer-events: none; }
.el--image img,
.el--video img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: fill;
  -webkit-user-drag: none;
}
/* A video's live player, mounted over its poster only once it is played
   (canvas.js's toggleVideo, and a presentation's own). Click-through, so a
   press still lands on the element and selects or drags it. */
.el--video .el__video {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  object-fit: fill;
  pointer-events: none;
}
/* A rive element's three children (render.js's own paintRive) sit stacked on
   top of each other rather than swapped in and out: the poster/placeholder
   pair is what every host but the live stage ever shows (thumbnails, the
   homepage, analytics, PDF export — none of them mount a player), and the
   canvas underneath is only ever revealed once canvas.js (or
   present-view.js/view.js) actually has one loaded on it, via .is-loaded.
   contain, not image's own fill: a Rive artboard resized into a box shaped
   differently from its own is how the live canvas already scales (Rive's
   own default Layout fit), so the still frame standing in for it agrees. */
.el__rive-poster,
.el__rive-placeholder,
.el__rive-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}
.el__rive-poster {
  object-fit: contain;
  -webkit-user-drag: none;
}
.el__rive-poster:not([src]) { display: none; }
.el__rive-placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--surface-sunken, #f4f5f7);
  color: var(--ink-soft, #6b7280);
}
.el__rive-placeholder .icon { --icon-size: clamp(16px, 32%, 96px); }
.el--rive.is-loaded .el__rive-placeholder,
.el--rive:has(.el__rive-poster[src]) .el__rive-placeholder { display: none; }
.el__rive-canvas { visibility: hidden; }
.el--rive.is-loaded .el__rive-canvas { visibility: visible; }
.el--rive.is-loaded .el__rive-poster { display: none; }

/* .gridcell's own display: grid / grid-template-columns/rows / gap are set
   inline (render.js's paintGrid), since they're per-element data, not a
   fixed rule — only what's true of every cell regardless of the element's
   own columns/rows/gap lives here. */
.gridcell {
  position: relative;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
}

/* Fills the cell's own content box (the padding already lives on .gridcell
   itself); render.js's own inline transform: scale(...) is what the item's
   `scale` field multiplies on top of this, about this box's own centre —
   the default transform-origin — which is what keeps it centred while it
   scales, and .gridcell's own overflow: hidden (above) is what clips it
   once it grows past the cell. */
.griditem {
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}

.griditem img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  display: block;
  pointer-events: none;
}

/* width/height: 100% plus the source's own preserveAspectRatio="xMidYMid
   meet" (forced at inject time — render.js's buildSvgTemplate) is what
   plays the same "contain, centred" role object-fit plays for an <img>:
   object-fit itself doesn't reliably apply to an inline <svg> the way it
   does to a real replaced element. */
.griditem__svg {
  width: 100%;
  height: 100%;
  display: block;
}

.el--text {
  display: flex;
  flex-direction: column;
  white-space: pre-wrap;
  word-break: break-word;
  /* Hugging an empty box would collapse it out of reach of the pointer. */
  min-width: 24px;
}
/* Chrome draws its own focus ring on a focused contenteditable; the selection
   box is the affordance here. */
.el__text:focus { outline: none; }
.el__text[contenteditable] { cursor: var(--cursor-text); }

/* One block per line (see render.js) rather than one flat run of characters,
   so a bullet or number can be a CSS marker instead of a typed character. */
.el__line { white-space: pre-wrap; word-break: break-word; }
/* An empty line has no line box and so no height; a zero-width space gives it
   one, so an emptied line keeps its place and stays selectable. */
.el__line:empty::after { content: '\200b'; }

/* Hanging indent: the marker occupies the space padding-left reserves for
   it, so text that wraps lines up under the text above it, not under the
   bullet. Nesting (Tab/Shift+Tab, canvas.js) adds `--indent` pixels to that
   same base, and shifts the marker's own left the same amount, so it always
   sits right where the padding starts.

   The marker is positioned absolutely rather than through the usual
   negative-text-indent hanging-indent trick (padding-left the full amount,
   text-indent the marker's own width negative, so the first line alone pulls
   left into the reserved slot) — that trick reads fine painted, but breaks
   the caret on a line with nothing typed into it yet: contenteditable's own
   placeholder for an empty block is a bare <br>, and Chrome anchors the
   native caret to wherever that <br> lands rather than to the padding edge,
   which with a negative text-indent in play is somewhere inside the
   marker's own reserved space rather than after it — confirmed directly via
   Selection/Range: the collapsed caret's own client rect sits inside the
   marker's horizontal span the moment a fresh list line has no <br>-adjacent
   text node of its own to anchor to. Taking the marker out of the text flow
   entirely removes the special case text-indent creates for an empty
   line's own first line box, so there is nothing left to place the caret
   inside of but the padding edge itself — the same edge a real character
   would land on. */
.el__line[data-list] {
  position: relative;
  padding-left: calc(1.4em + var(--indent, 0) * 24px);
}
.el__line[data-list]::before {
  position: absolute;
  left: calc(var(--indent, 0) * 24px);
  top: 0;
  display: inline-block;
  width: 1.4em;
}
/* Nesting cycles the bullet glyph itself every third level (data-marker,
   render.js's own applyLineAttrs) — a numbered line never needs this, its
   own ordinal already tells depth-mates apart. */
.el__line[data-list="bullet"]::before { content: '•'; }
.el__line[data-list="bullet"][data-marker="1"]::before { content: '◦'; }
.el__line[data-list="bullet"][data-marker="2"]::before { content: '▪'; }
.el__line[data-list="number"]::before { content: attr(data-n) '.'; }

/* While editing, the selection box stays as the outline but stops swallowing
   clicks, so the caret can be placed in the text underneath it. */
.artboard__overlay.is-editing .selbox { pointer-events: none; }
.artboard__overlay.is-editing .handle,
.artboard__overlay.is-editing .rothandle { display: none; }

/* Held Option over an existing image, mid-drag: canvas.js's own
   paintSwapTarget paints this host directly rather than through paintOverlay,
   so a state change mid-drag (the document model doesn't know this gesture is
   happening at all) can never wipe it out from under the pointer. Sits above
   .artboard__overlay, since a native OS drag has no selection or handles of
   its own to stay clear of the way the ordinary selection overlay does. */
.artboard__swap { position: absolute; inset: 0; pointer-events: none; }
.swap-target {
  position: absolute;
  border: 2px solid var(--accent);
  border-radius: 4px;
  background: rgba(59, 108, 246, 0.22);
  display: grid;
  place-items: center;
}
.swap-target__badge {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  background: var(--accent);
  display: grid;
  place-items: center;
  box-shadow: 0 2px 8px rgba(16, 20, 26, 0.35);
}
.swap-target__badge .icon { color: #fff; }

/* A library asset dragged over a grid frame (canvas.js's own
   paintGridDropTarget) — the same swapTarget host and "state can't wipe
   this out mid-drag" reasoning as .swap-target just above, since this is a
   native OS drag too. A highlighted cell for a swap, or a line for an
   insert — never both, so the two gestures always read as visually
   distinct, the same reason gridinsertline (below) exists.
   .grid-drop-target itself (the wrap paintGridDropTarget positions with
   g.x/g.y*scale) needs position: absolute of its own, not just its two
   children — without it, the wrap sits in plain static flow (its own left/
   top inline styles doing nothing) and its absolutely-positioned children
   resolve against #artboardSwap directly instead, landing at the artboard's
   own top-left corner regardless of where the grid actually is. */
.grid-drop-target { position: absolute; }
.grid-drop-target__cell {
  position: absolute;
  border: 2px solid var(--accent);
  border-radius: 4px;
  background: rgba(59, 108, 246, 0.22);
}
.grid-drop-target__line {
  position: absolute;
  width: 3px;
  margin-left: -1.5px;
  border-radius: 2px;
  background: var(--accent);
}

/* ------------------------------------------------------------------ selection */
.selbox {
  position: absolute;
  border: 1px solid var(--accent);
  pointer-events: auto;
  /* No drag affordance on purpose — the stage's own plain arrow throughout,
     not a hand that only shows up once something happens to be selected. */
  cursor: var(--cursor-canvas);
  /* Rotates about its own centre, matching the element's transform-origin. */
  transform-origin: 50% 50%;
  /* How far past a corner the rotate zone reaches. Screen pixels, not deck
     ones: it is a target for the hand, so it must not change with zoom. */
  --rotate-zone: 22px;
}
/* The sweep itself, drawn in the overlay in screen pixels. */
.marquee {
  position: absolute;
  border: 1px solid var(--accent);
  background: rgba(59, 108, 246, 0.10);
  pointer-events: none;
}
/* A member of a selection: a soft tinted outline, so it is clear which objects
   are in the group without competing with the frame around them. Never takes
   the pointer — the group box is what a press lands on. */
.memberbox {
  position: absolute;
  border: 1px solid rgba(59, 108, 246, 0.5);
  border-radius: 3px;
  background: rgba(59, 108, 246, 0.08);
  pointer-events: none;
}

/* The box around a whole selection: dashed, so it reads as a frame holding
   several things rather than the outline of one. */
.selbox--group {
  pointer-events: auto;
  border-style: dashed;
}

/* A smoothed line chart's own curve, wrapped — see lineChartCurveBounds in
   render.js. Dashed, the same reason .selbox--group's own is: a frame
   around something rather than the outline of it. Never takes the pointer
   or carries handles of its own — an outline with no fill of its own, so it
   never hides the ordinary .selbox and its handles underneath, which are
   what a press and a resize both still act on regardless of paint order. */
.curvebox {
  position: absolute;
  border: 1px dashed var(--accent);
  border-radius: 2px;
  pointer-events: none;
}

.handle {
  position: absolute;
  width: 7px; height: 7px;
  margin: -3.5px 0 0 -3.5px;
  background: #fff;
  border: 1px solid var(--accent);
  border-radius: 2px;
}

/* The grabbable stretch of one edge (paintResizeAffordances, canvas.js).
   Invisible: an edge is a thing you can already see, drawn by .selbox, so
   there is nothing for a second mark to add — it only needs to be reachable.
   As thick as the handle it replaced and centred on the edge the same way,
   so the band that answers to a press is the one that always did, just run
   the whole length instead of gathered into a square at the middle. The
   corners are painted after these and win the overlap, so a press at a
   corner still scales both axes rather than stretching one. */
.edgezone { position: absolute; }
.edgezone--n { top: 0;   left: 0; right: 0; height: 9px; margin-top: -4.5px;  cursor: var(--cursor-vertical); }
.edgezone--s { top: 100%; left: 0; right: 0; height: 9px; margin-top: -4.5px; cursor: var(--cursor-vertical); }
.edgezone--w { left: 0;   top: 0; bottom: 0; width: 9px; margin-left: -4.5px; cursor: var(--cursor-horizontal); }
.edgezone--e { left: 100%; top: 0; bottom: 0; width: 9px; margin-left: -4.5px; cursor: var(--cursor-horizontal); }

/* Nothing on the box is grabbable mid-move, so nothing on it is drawn: the
   rays are what the gesture is being steered by, and the chrome would only
   sit over them. The overlay keeps its class across repaints; beginMove
   (canvas.js) sets and clears it. */
.artboard__overlay.is-moving .handle,
.artboard__overlay.is-moving .edgezone,
.artboard__overlay.is-moving .rothandle { display: none; }
/* The rotate zone: a square filling the outer quadrant of each corner, so it
   starts exactly where the resize handle ends and stops before it reaches
   anything else. Painted before the handles in the DOM, so the handle keeps
   the corner itself and this only takes over as the pointer moves off it.
   Past the square there is nothing to hit and the stage handles the click, so
   one gesture reads resize, then rotate, then plain select purely by distance.
   Its cursor is set in canvas.js (ROTATE_CURSORS), one of the four
   --cursor-rotate-* vendored SVGs (base.css) keyed by which corner this is,
   so the glyph itself curves to match. */
.rothandle {
  position: absolute;
  width: var(--rotate-zone);
  height: var(--rotate-zone);
}
.rothandle--nw { top: 0;    left: 0;    margin: calc(-1 * var(--rotate-zone)) 0 0 calc(-1 * var(--rotate-zone)); }
.rothandle--ne { top: 0;    left: 100%; margin-top: calc(-1 * var(--rotate-zone)); }
.rothandle--se { top: 100%; left: 100%; }
.rothandle--sw { top: 100%; left: 0;    margin-left: calc(-1 * var(--rotate-zone)); }

.handle--nw { top: 0;    left: 0;    cursor: var(--cursor-diagonal-1); }
.handle--ne { top: 0;    left: 100%; cursor: var(--cursor-diagonal-2); }
.handle--se { top: 100%; left: 100%; cursor: var(--cursor-diagonal-1); }
.handle--sw { top: 100%; left: 0;    cursor: var(--cursor-diagonal-2); }

/* A corner or centre of the moving selection landing on another element's
   own, a matching gap, or a ruler guide (calculateSnapping, snap.js;
   paintSnapGuides, canvas.js) — a red hairline, deliberately apart from the
   accent blue everything else on the overlay uses, so a guide never reads as
   a selection, and apart from the ruler guide's own teal, so the two don't
   read as the same kind of line either — a ruler guide is content the
   author placed, this is transient feedback about the drag underway.
   Always a fixed screen size: like --rotate-zone above, the overlay itself
   isn't CSS-scaled, so a plain px value here holds at any zoom without its
   own scale math. Half a pixel, not one — a hairline, since this is drawn
   over the author's own artwork and has only to be seen, not to carry any
   weight of its own. On a 2x display that is one real device pixel; on a 1x
   one the browser renders it as a lighter antialiased line, which is the
   same trade every hairline on the web makes. */
.smartguide {
  position: absolute;
  background: #ff3b30;
  pointer-events: none;
}
.smartguide--v { top: -100000px; height: 200000px; width: 0.5px; }
.smartguide--h { left: -100000px; width: 200000px; height: 0.5px; }

/* A ray between two anchors overrides that span inline (paintSnapGuides) —
   the full-length rule above is what the canvas's own sides and centre lines
   still fall back to, since neither has a second box for a ray to end at.
   Every anchor party to an alignment is marked with one of these: two
   hairlines crossing, in the ray's own red and at its own hairline weight,
   since it is the same fact about the same alignment. A cross rather than a
   dot because an anchor is a *point* — a filled circle is a small area, and
   reads as "about here" where two crossing lines read as "exactly here".
   Turned 45°, so it reads as an × rather than a +: upright, its arms run
   parallel to the ray and to the box edges they sit on, and a mark that
   lies along what it is marking is the one thing it cannot be told apart
   from. Only the anchors on the matched line are marked — three per box,
   six for a pair — since one away from that line took no part in the
   alignment. Kept very small even so: anything heavier would bury the
   objects it describes. Pulled back by half its own size so the crossing
   point, not the corner of the box, sits on the anchor — which is also what
   puts the rotation's own centre there, since a transform turns about the
   box's middle. */
/* The two shapes a duplication is between, ringed for as long as the drag is
   held (duplicateOutlines, canvas.js). The ray's own red and its own hairline
   weight, since it is the same gesture being described. It rings the boxes
   the measurement is between — the selections' upright extents — rather than
   the shapes' own outlines, so a turned object is ringed by the box its
   numbers actually describe. Click-through like everything else here: this is
   drawn over the very objects being dragged. */
/* The dashed run from where a duplication's measurement stops to the copy it
   is about (duplicateOffsetRibbons, canvas.js). A measurement leaves the
   original at its own edge midpoint and rarely lands on the copy, which sits
   off at some angle, so this carries on square to it until it meets the
   copy's nearest edge — tying the number to the object rather than leaving it
   pointing into empty space. Dashed, to read as the leader it is rather than
   as a second measurement, and drawn as a gradient rather than a dashed
   border: at a hairline's width a fractional dashed border is at the mercy of
   how the engine rounds it, where the stops here are exact. */
.measureleader { position: absolute; pointer-events: none; }
.measureleader--h {
  height: 0.5px;
  background: repeating-linear-gradient(to right, #ff3b30 0 4px, transparent 4px 8px);
}
.measureleader--v {
  width: 0.5px;
  background: repeating-linear-gradient(to bottom, #ff3b30 0 4px, transparent 4px 8px);
}

.dupoutline {
  position: absolute;
  border: 0.5px solid #ff3b30;
  pointer-events: none;
}

.smartguide__cross {
  position: absolute;
  width: 3.5px;
  height: 3.5px;
  margin: -1.75px 0 0 -1.75px;
  transform: rotate(45deg);
  pointer-events: none;
}
.smartguide__cross::before,
.smartguide__cross::after {
  content: '';
  position: absolute;
  background: #ff3b30;
}
.smartguide__cross::before { left: 0; right: 0; top: 1.5px; height: 0.5px; }
.smartguide__cross::after { top: 0; bottom: 0; left: 1.5px; width: 0.5px; }

/* An equidistant-gap match (spacingRibbons, snap.js) — one ribbon per gap
   the matched pattern covers, including the new one the snap itself just
   created next to the dragged object, so a repeating pattern reads as one
   whole rhythm rather than a single arrow pointing at the nearest neighbour.
   Positioned at the pair's own shared cross-axis midpoint, the same "measure
   what's actually there" reasoning selectionBounds() already uses for a
   rotated group's own frame — just on two objects' overlap instead of a
   whole selection's. The label sits centred on the ribbon itself rather
   than floating beside it, so it reads as "this many pixels of gap" without
   a leader line to draw and keep aimed. */
.spacingribbon {
  position: absolute;
  pointer-events: none;
}
.spacingribbon--x { height: 1px; background: #ff3b30; }
.spacingribbon--y { width: 1px; background: #ff3b30; }
/* Centred on the ribbon's own midpoint regardless of axis — an x-ribbon is
   wide and thin, a y-ribbon the opposite, so the label needs both halves of
   the translate every time rather than picking one axis to centre and
   leaving the other at its default (flow) position. */
/* The selected object's own size (sizePill, canvas.js). Accent blue, not the
   measurement red: this is a property of the thing selected and belongs with
   the selection box, where red is kept for the relationship between two
   objects a drag is working out. Its placement is set inline (sizePill,
   canvas.js) rather than here: it is pinned to the midpoint of the box's own
   bottom edge and turns with it, which no static rule can express.

   The vertical padding is deliberately uneven, by a measured amount. These
   are digits, so nothing sits below the baseline, but the line box still
   reserves the descent, and the number needs help to sit right in the pill.

   Measuring the ink and equalising the air either side of it gets close but
   not all the way: balanced by that arithmetic the pill still read as
   bottom-heavy, so the bottom is tighter than the measurement calls for.
   Which is the ordinary way of it — glyphs are judged by where their weight
   falls, not by the box the engine reserves for them, and the two do not
   have to agree. The numbers here are what looked right at this type size,
   so re-check them by eye if the size changes rather than scaling them. */
.sizepill {
  position: absolute;
  padding: 0.8px 4px 0.6px;
  border-radius: 3px;
  background: var(--accent);
  color: #fff;
  font-size: 8px;
  font-weight: 500;
  line-height: 1.4;
  white-space: nowrap;
  pointer-events: none;
}

.spacingribbon__label {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  padding: 1px 4px;
  border-radius: 3px;
  background: #ff3b30;
  color: #fff;
  font-size: 10px;
  font-weight: 600;
  line-height: 1.4;
  white-space: nowrap;
}

/* Option/Alt hover measurement (refreshHoverMeasure, canvas.js). The
   measurement itself is drawn by the very same ribbons and leaders an
   Alt-drag already uses, so there is nothing of it to style here — only the
   object being measured against, outlined to say "this one". A thin outline
   is enough, since the ribbons themselves carry the distance, and a fill
   would just compete with whatever the object already paints. */
.measuretarget {
  position: absolute;
  outline: 1px solid #ff3b30;
  outline-offset: -1px;
  pointer-events: none;
}

/* A persistent ruler guide (addGuide/moveGuide, state.js; paintRulerGuides,
   canvas.js) — dropped from the canvas's own right-click menu, dragged into
   place, deleted with Delete. It stays up regardless of selection, and has
   to be grabbable — a bare 1px line is too thin a target, so the element
   itself is a wide strip (opting back into pointer-events despite
   .artboard__overlay's own blanket `none`) and the coloured hairline is its
   ::before, centred inside that strip. Vivid teal blue is deliberately apart
   from this app's own accent blue and the smart guide's red, so a ruler
   guide never reads as either. Always exactly 1 screen pixel: like
   --rotate-zone above, the overlay itself isn't CSS-scaled, so a plain `1px`
   here stays crisp at any zoom without its own scale math.

   The strip runs the length of a guide, not just the artboard: an author
   lines things up against the edge of the slide as often as against
   something already off it, so a guide has to reach into the grey gutter
   around the artboard too. There is nothing to size it to there — the
   gutter's own extent depends on the window, the panels' widths, and
   zoom/pan all at once — so it uses .artboard__bleed's own trick instead of
   computing any of that: a value absurdly larger than any real canvas, left
   for .stage's own `overflow: hidden` to clip to whatever is actually
   visible. It reads as fully opaque out there, not dimmed the way an
   element that bleeds past the slide is: .artboard__bleed's dimming
   box-shadow is a sibling that paints *before* .artboard__overlay in the
   DOM, so this — a child of the overlay — paints on top of it rather than
   being caught by it. */
.rulerguide {
  position: absolute;
  pointer-events: auto;
}
.rulerguide--v { top: -100000px; height: 200000px; width: 9px; margin-left: -4px; cursor: var(--cursor-horizontal); }
.rulerguide--h { left: -100000px; width: 200000px; height: 9px; margin-top: -4px; cursor: var(--cursor-vertical); }
.rulerguide::before {
  content: '';
  position: absolute;
  background: #06b6d4;
}
.rulerguide--v::before { left: 4px; top: 0; bottom: 0; width: 1px; }
.rulerguide--h::before { top: 4px; left: 0; right: 0; height: 1px; }
/* Selected reads as a colour change, not a thicker line: a guide's whole
   job is precise alignment, and a thicker hairline is a less precise one to
   align against — the opposite of what selecting it is for. Amber reads as
   clearly "active" against the teal default without landing on this app's
   own accent blue or the smart guide's red, both already spoken for above. */
.rulerguide.is-selected::before { background: #f59e0b; }
/* An object being moved or resized has landed exactly on this guide this
   frame (snappedGuideIds, canvas.js) — the same red .smartguide already uses
   elsewhere for "this drag is aligned to something", reused here rather than
   a fourth colour: it is the same fact (a snap is live right now) about a
   line that, unlike a smart guide's, was already on screen before the drag
   started. Wins over .is-selected — a drag can snap to a guide the author
   had selected a moment before letting go of it, and the snap is the more
   current, more relevant state of the two. */
.rulerguide.is-snapped::before { background: #ff3b30; }

/* ------------------------------------------------------------- grid frame */
/* An occupied cell's own padding band (gridPaddingFill, canvas.js) — a soft
   wash over the inset between a cell's outer edge and its content box, the
   one place to actually see where cellPaddingX/Y falls (the sidebar's own
   Horizontal/Vertical padding fields say how much, not where). Skipped for
   an empty cell — there's no content there for it to be a safe zone around,
   and showing one anyway read as an unrelated gap between columns. Editor-
   only since it's overlay content, not render.js's — a thumbnail, a
   presentation, an export never see it. A border, not a real fill-with-a-
   hole: box-sizing: border-box plus a border exactly as thick as the
   padding paints only that band, with nothing to mask or clip for the
   transparent middle. No border-color here — canvas.js always sets it
   inline, off the grid's own guides.paddingColor/paddingAlpha (Visual
   guides section, inspector-grid.js). */
.gridpaddingfill { position: absolute; pointer-events: none; }
.gridpaddingfill__band {
  position: absolute;
  box-sizing: border-box;
  border-style: solid;
  pointer-events: none;
}

/* A droppable slot's own wash (gridEmptyCellFill, canvas.js) — a plain flat
   fill, not an outline: an outline here would be the same per-cell
   "borders" this app's own grid overlay used to draw everywhere and was
   asked to stop drawing, just narrowed to empty cells instead of every one.
   A flat wash says "you can drop here" without redrawing those lines. Not
   author-facing the way the padding band's own colour now is — there's
   nothing on the Visual guides section for it, just the section's own eye
   toggle (guides.visible), same as every other guide here. */
.gridemptyfill { position: absolute; pointer-events: none; }
.gridemptyfill__cell {
  position: absolute;
  background: rgba(107, 114, 128, 0.08);
  pointer-events: none;
}

/* Column/row divider lines (gridLineGuides, canvas.js) — off by default
   (guides.lineColor is null until an author picks one, Visual guides
   section, inspector-grid.js), unlike the padding band above: this line
   never existed before at all, so it has no inherited colour of its own to
   open with. Always 1px flat, no border-radius or anything else to draw —
   canvas.js sets width/height itself (1px on the cross-axis, the frame's
   own full length on the other) and pointer-events stays off, the same
   reason every other guide here is click-through. */
.gridlineguides { position: absolute; pointer-events: none; }
.gridlineguide { position: absolute; pointer-events: none; }
.gridlineguide--v { top: 0; width: 1px; }
.gridlineguide--h { left: 0; height: 1px; }

/* canvas.js's gridItemOverlay. Stays itself pointer-events: none so only its
   own children (the actual controls) ever take the pointer. */
.griditemoverlay { position: absolute; pointer-events: none; transform-origin: 50% 50%; }
/* A plain, invisible hit region the full size of the cell — see its own
   dataset.gridCell comment in canvas.js for why a drag on it still moves
   the whole frame and only a plain click selects the item. */
.gridcellhit { position: absolute; pointer-events: auto; }
.gridcellhit--target { background: rgba(59, 108, 246, 0.18); border-radius: 4px; }
/* A round dot, centred on the cell — hovering reveals it, grabbing it starts
   the swap/insert-between drag, and a plain click on it selects the item.
   Small and unlabelled on purpose: the ghost that follows the cursor once a
   real drag starts (griddragghost, below) is what actually says what's
   being moved, so the dot itself only ever has to say "there's a handle
   here", not carry an icon of its own. */
.gridswap {
  position: absolute;
  width: 13px;
  height: 13px;
  margin: -6.5px 0 0 -6.5px;
  border-radius: 50%;
  background: #8ec5ff;
  pointer-events: auto;
  cursor: var(--cursor-grab);
  box-shadow: 0 0 0 1px rgba(16, 20, 26, 0.25), 0 1px 2px rgba(16, 20, 26, 0.25);
}
.gridswap:active { cursor: var(--cursor-grabbing); }
/* A corner scale handle — the exact same square .handle already draws for
   an element's own resize, just on the active cell's own corners instead
   of the frame's, so "selecting an item shows corner handles" reads as the
   same kind of control this app always uses for a size, not a new shape. */
.gridscale {
  position: absolute;
  width: 9px;
  height: 9px;
  margin: -5px 0 0 -5px;
  border-radius: 2px;
  background: #fff;
  box-shadow: 0 0 0 1px rgba(16, 20, 26, 0.35), 0 1px 3px rgba(16, 20, 26, 0.3);
  pointer-events: auto;
}
.gridscale--nw, .gridscale--se { cursor: var(--cursor-diagonal-1); }
.gridscale--ne, .gridscale--sw { cursor: var(--cursor-diagonal-2); }
/* The insert-between gesture's own live feedback, mid-drag — same shape and
   colour as .grid-drop-target__line (a native asset-library drag's own
   equivalent, above), just drawn through paintOverlay instead of straight
   into the swapTarget host, since this one *is* reacting to canvas.js's own
   pointer drag rather than a native dragover with no model of its own to
   read. */
.gridinsertline {
  position: absolute;
  width: 3px;
  margin-left: -1.5px;
  border-radius: 2px;
  background: var(--accent);
  pointer-events: none;
}
/* The "something's about to be replaced here" badge — shown over a swap
   target that already has an item in it, on both the internal drag
   (gridItemOverlay) and the native asset-library one (paintGridDropTarget),
   the same reason .swap-target__badge already marks an image/icon swap this
   way: naming the gesture beats a bare highlight guessing at it. */
.gridreplace {
  position: absolute;
  width: 36px;
  height: 36px;
  margin: -18px 0 0 -18px;
  border-radius: 50%;
  background: var(--accent);
  display: grid;
  place-items: center;
  pointer-events: none;
  box-shadow: 0 2px 8px rgba(16, 20, 26, 0.35);
}
.gridreplace .icon { color: #fff; }
/* An item mid-drag, following the raw screen cursor — canvas.js's own
   createGridDragGhost/positionGridDragGhost, the hand-built equivalent of
   what a native HTML5 drag gets for free from setDragImage (see
   inspector-library.js's own asset-tile dragstart). Centred on the cursor
   via the transform rather than an offset baked into left/top, so
   positioning it is just "put it at the pointer". */
.griddragghost {
  position: fixed;
  top: 0;
  left: 0;
  transform: translate(-50%, -50%);
  pointer-events: none;
  z-index: 3000;
  display: flex;
  align-items: center;
  justify-content: center;
}
.griddragghost img { max-width: 100%; max-height: 100%; object-fit: contain; }

/* ------------------------------------------------------------- gradient */
/* The gradient's handles, over the object rather than in the panel: a
   gradient is aimed at the copy, so it is aimed on the copy. The layer sits
   in the element's own frame and turns with it; the handles inside stay a
   fixed size on screen, because they are targets for the hand and must not
   shrink as the canvas zooms out. */
.gradbar {
  position: absolute;
  pointer-events: none;
  transform-origin: 50% 50%;
}
.gradbar__line { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
/* A white line, and a shadow under it so it still reads on a white slide —
   which is the default, and exactly where a white line would otherwise vanish.
   The handles carry the same pairing in their own box-shadow, so the whole
   control stays visible over any fill without knowing what it is over. */
.gradbar__line line {
  stroke: #fff;
  stroke-width: 3;
  filter: drop-shadow(0 1px 2px rgba(16, 20, 26, 0.55));
  pointer-events: none;
}
/* The ring an angular gradient's own stops sweep around — same white-on-
   shadow pairing as the line, thinner, since the line already carries the
   pairing's own weight and the ring only needs to read as a guide.
   pointer-events: none is set directly rather than left to inherit from
   .gradbar/.gradbar__line: an SVG shape's own default (visiblePainted) can
   still register a hit along a stroked, fill:none path like this one in some
   engines even under an ancestor that says none, so a decorative shape like
   this states it for itself instead of trusting the cascade to cross the
   HTML/SVG boundary. */
.gradbar__ring {
  stroke: #fff;
  stroke-width: 1.5;
  filter: drop-shadow(0 1px 2px rgba(16, 20, 26, 0.55));
  pointer-events: none;
}
.gradbar__end,
.gradbar__stop {
  position: absolute;
  pointer-events: auto;
}
/* Square for the two ends: an end moves the gradient itself. */
.gradbar__end {
  width: 13px; height: 13px;
  margin: -7px 0 0 -7px;
  background: #fff;
  border-radius: 2px;
  box-shadow: 0 0 0 1px rgba(16, 20, 26, 0.35), 0 1px 3px rgba(16, 20, 26, 0.3);
  cursor: var(--cursor-grab);
}
/* A tag, not a dot centred on the line: two stops ship sitting exactly on the
   two ends, and a shape filled there would sit right on top of the square
   that moves the gradient and hide it. See assets/references/Gradient.png —
   a coloured swatch on a neck, its point touching the line and nothing else
   of it there, so the end square underneath stays reachable. `.gradbar__stop`
   itself is the zero-size anchor at the stop's own point; the two pieces
   below are positioned off it, not off each other, so nothing has to add up
   its predecessor's size. */
.gradbar__stop { width: 0; height: 0; cursor: var(--cursor-horizontal); }
.gradbar__stop__neck {
  position: absolute;
  left: 0; bottom: 0;
  margin-left: -4px;
  width: 0; height: 0;
  border-left: 4px solid transparent;
  border-right: 4px solid transparent;
  border-top: 6px solid #fff;
  filter: drop-shadow(0 1px 1px rgba(16, 20, 26, 0.3));
}
.gradbar__stop__head {
  position: absolute;
  left: 0; bottom: 6px;
  margin-left: -8px;
  width: 16px; height: 16px;
  border-radius: 4px;
  box-shadow: 0 0 0 2px #fff, 0 0 0 3px rgba(16, 20, 26, 0.35), 0 1px 3px rgba(16, 20, 26, 0.3);
}
/* The same accent ring the editor's own chip gets for being selected
   (.gradedit__chip.is-on, inspector.css) — one shared state.selectedGradientStop
   drives both, so the two read as the same highlight rather than two
   different ones that happen to mean the same thing. */
.gradbar__stop.is-on .gradbar__stop__head {
  box-shadow: 0 0 0 2px #fff, 0 0 0 4px var(--accent), 0 1px 3px rgba(16, 20, 26, 0.3);
}
/* Out of the way while text is being typed, like every other handle. */
.artboard__overlay.is-editing .gradbar { display: none; }

/* The reflection blur line — same white-on-shadow chrome as gradbar just
   above, and the same shape (a connecting line, a knob at each end), minus
   the ring and stops neither this nor lineBar has a use for. overflow:
   visible on .blurbar__line is what lets the line and its own far knob
   reach below the chart's own box — the default, since `to.y` starts past
   1 — without needing the svg sized any taller than the box itself. */
.blurbar {
  position: absolute;
  pointer-events: none;
  transform-origin: 50% 50%;
}
.blurbar__line { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
.blurbar__line line {
  stroke: #fff;
  stroke-width: 3;
  filter: drop-shadow(0 1px 2px rgba(16, 20, 26, 0.55));
  pointer-events: none;
}
.blurbar__end {
  position: absolute;
  pointer-events: auto;
  width: 13px; height: 13px;
  margin: -7px 0 0 -7px;
  background: #fff;
  border-radius: 50%;
  box-shadow: 0 0 0 1px rgba(16, 20, 26, 0.35), 0 1px 3px rgba(16, 20, 26, 0.3);
  cursor: var(--cursor-grab);
}
/* The near end: always exactly at the bar with exactly 0 blur, so there is
   nothing left to drag it to — a plain marker for context, not a second
   working handle. Smaller, and not grabbable, so it doesn't invite a drag
   the far end's own knob is the only one that actually answers. */
.blurbar__end--fixed {
  width: 7px; height: 7px;
  margin: -4px 0 0 -4px;
  pointer-events: none;
  cursor: default;
}
.artboard__overlay.is-editing .blurbar { display: none; }

/* Progressive Blur's own bar reuses .gradbar/.gradbar__line/.gradbar__end
   verbatim (see progressiveBlurBar, canvas.js) — Start and End are always
   the line's own two ends, with no stop pinned anywhere along it, so it
   needs no rules of its own beyond that. */
.artboard__overlay.is-editing .pblurbar { display: none; }

/* --------------------------------------------------------------- line */
/* A line or an arrow's own two endpoints — the one thing there is to
   manipulate about either, so this replaces the ordinary selection box and
   its resize/rotate handles entirely rather than sitting over them the way
   gradbar does. Unlike gradbar, though, there is no connecting line drawn
   here: gradbar's own line is the only visual guide a gradient's direction
   has, where a line/arrow shape already paints its own real stroke
   (paintLineShape, render.js) — a second white line traced right over it
   would just hide whatever colour, width or dash pattern the author actually
   chose the moment the shape was selected. So this is knobs only: pointer-
   events stays off the container itself, so a click on the "empty" part of
   the box falls through to elementAt's own hit-test (canvas.js), and only
   the two knobs opt back in. */
.linebar {
  position: absolute;
  pointer-events: none;
  transform-origin: 50% 50%;
}
.linebar__end {
  position: absolute;
  pointer-events: auto;
  width: 13px; height: 13px;
  margin: -7px 0 0 -7px;
  background: #fff;
  border-radius: 50%;
  box-shadow: 0 0 0 1px rgba(16, 20, 26, 0.35), 0 1px 3px rgba(16, 20, 26, 0.3);
  cursor: var(--cursor-grab);
}
/* A plain click on this knob (no drag — beginLinePointDrag, canvas.js) picks
   it out for the keyboard's own arrow-key nudge; the same blue ring
   .commentpin.is-active already uses for "this one" elsewhere in the app,
   replacing the neutral one outright rather than adding to it. */
.linebar__end.is-selected {
  box-shadow: 0 0 0 2px var(--accent), 0 1px 3px rgba(16, 20, 26, 0.3);
}
/* Shrinks the knob to the line's own stroke thickness on screen —
   --shrink-scale, a fraction of this element's own resting size set inline
   per knob (lineBar, canvas.js), since it depends on el.border.width and
   the current zoom, neither of which a plain CSS rule can know — the
   instant it's actually dragged or arrow-key-nudged (touchLinePoint,
   canvas.js). No transition on this rule on purpose: the shrink is
   feedback for a gesture already in progress, so it has to track the
   pointer/key exactly, not lag behind it easing in. Default
   transform-origin (50% 50%) is what scales it from its own centre rather
   than a corner. */
.linebar__end.is-shrunk {
  transform: scale(var(--shrink-scale));
}
/* Three idle seconds with no further move (touchLinePoint's own per-endpoint
   timer) — or an immediate switch to a different endpoint, or Escape, or
   dropping a line dragged by its own body (growLinePointNow, canvas.js) —
   swaps this in for is-shrunk, easing back up to full size over 400ms. A
   CSS animation's own 0% keyframe matches is-shrunk's value exactly (both
   read the same --shrink-scale), so there's nothing to jump between the
   two classes. Moving again mid-grow re-enters touchLinePoint, which sets
   the underlying state (linePointAnim, canvas.js) straight back to shrunk
   — the next rebuild swaps this class for is-shrunk again, which snaps the
   knob straight back to that rule's own plain, transition-less value, no
   less instant than the very first shrink was. */
@keyframes linebar-idle-grow {
  0% { transform: scale(var(--shrink-scale)); }
  100% { transform: scale(1); }
}
.linebar__end.is-growing {
  animation: linebar-idle-grow 400ms ease;
}

/* ----------------------------------------------------------------- crop */
/* A picture being cropped runs past the slide, and what runs past is what you
   are choosing between — so it is shown, faintly, rather than clipped away.
   The solid part is the layer's own, clipped by the slide; this supplies only
   the rest, masked to exclude the slide exactly so the two never overlap and
   the kept part is never washed out by a copy of itself. */
/* Sized and turned like gradbar above, rather than spanning the whole overlay
   with inset: 0 — a rectangle's own crop frame has to sit over its box only,
   not the whole slide. */
.cropframe { position: absolute; pointer-events: none; transform-origin: 50% 50%; }
.cropframe__ghost {
  position: absolute;
  opacity: 0.3;
  background-size: cover;
  background-repeat: no-repeat;
  background-position: center;
}
/* The frame takes the pointer: dragging it moves the picture under the slide,
   and its corners scale it. */
.cropframe__box {
  position: absolute;
  pointer-events: auto;
  border: 1px solid var(--accent);
  cursor: var(--cursor-grab);
}

/* ------------------------------------------------------------- placement */
/* The ghost the T/R/O/D/S/L keyboard shortcuts drop into place (beginPlacing,
   canvas.js) — fixed to the viewport and centred on the cursor with a plain
   transform, the same reason the composer's own preview pin and the colour
   picker are: it belongs to no particular zoom or pan, it just floats above
   whatever's under the pointer right now. pointer-events stays off so the
   ghost itself is never what a click actually lands on — canvas.js's own
   window-level listener is what reads the click, this is only ever drawn.
   The base rule is a plain dashed box — right for a Rectangle or a Circle,
   which clip through border-radius the same way the real elements do below
   — but a shape kind overrides or drops parts of it so the ghost reads as
   the thing it is about to become, not one generic box standing in for
   all of them. */
.placement-ghost {
  position: fixed;
  z-index: 1000;
  pointer-events: none;
  transform: translate(-50%, -50%);
  border: 1.5px dashed var(--accent);
  border-radius: 6px;
  background: color-mix(in srgb, var(--accent) 10%, transparent);
}
/* Sized in JS to the same fraction of the deck a real rectangle opens at
   (state.js's own makeElement), scaled by zoom — this one has a box worth
   previewing at that size. */
.placement-ghost--rect { box-sizing: border-box; }
/* A circle clips through border-radius: 50%, the same as the real element
   (paintShape, render.js) — nothing else about the base box needs to
   change for it to read as round. */
.placement-ghost--ellipse { box-sizing: border-box; border-radius: 50%; }
/* Triangle/Diamond/Star and Line don't clip or scale the base box into
   shape — canvas.js draws them instead, an actual stroked SVG <polygon> or
   <line> (shapePolygonPoints, render.js, for the three that have one), since
   a plain CSS border has no way to follow an outline whose edges run
   through the middle of the box rather than along it. The base rule's own
   border and fill are cleared here so they don't show through underneath
   what's actually drawn on top of them. */
.placement-ghost--drawn { border: none; background: none; }
/* Text has no fixed box to preview (see "Text sizing" in CLAUDE.md — it
   hugs its own content), so this one is sized by its own content instead:
   the same placeholder copy and font size a real text element opens with,
   in an inline box that hugs it the same way a real one would. */
.placement-ghost--text {
  display: inline-flex;
  align-items: center;
  white-space: nowrap;
  padding: 0.15em 0.3em;
  font-family: Figtree, system-ui, sans-serif;
  color: var(--ink-soft);
}

/* ------------------------------------------------ someone else is typing */
/* Whose text box is whose while a collaborator has a caret in one — see
   paintRemoteEdits in canvas.js. An outline plus a name, deliberately not in
   the accent colour: the accent means "this is your selection" everywhere
   else on this stage, and a box you cannot currently edit is the opposite of
   that. Click-through, since it is a label about the element rather than a
   thing to press, and the element underneath still selects and drags
   normally — only typing into it is refused. */
.remoteedit {
  position: absolute;
  pointer-events: none;
  outline: 2px solid var(--remote-edit, #a855f7);
  outline-offset: 1px;
  border-radius: 2px;
}
.remoteedit__name {
  position: absolute;
  left: 0;
  bottom: 100%;
  margin-bottom: 4px;
  padding: 2px 6px;
  border-radius: 4px;
  background: var(--remote-edit, #a855f7);
  color: #fff;
  font-size: 10px;
  font-weight: 600;
  line-height: 1.4;
  white-space: nowrap;
  /* The box is rotated with its element; the label reads horizontally
     regardless, the same way a measurement readout does. */
  transform: none;
}

/* --------------------------------------------------- other people's cursors */
/* The layer itself covers the artboard and never takes a press: a pointer is
   something to see, not something to hit, and the slide underneath has to
   keep receiving every click exactly as it did before cursors existed. */
.artboard__cursors {
  position: absolute;
  inset: 0;
  pointer-events: none;
  /* Above the slide and the overlay both — a collaborator's pointer is never
     the thing that should be hidden. */
  z-index: 5;
}
/* Positioned by the artboard, deliberately not clipped to it. The canvas
   someone clicks and drags on is the whole area between the two panels, not
   just the slide sitting in the middle of it — a marquee starts out in the
   grey gutter, and a pointer is out there whenever anyone reaches around the
   edge of something. Clipping here sliced the arrow in half at the slide's
   edge and erased it a little further out, which reads as the cursor
   glitching rather than as a boundary.

   What keeps a cursor out of the sidebars is the stacking that was already
   there: .stage carries z-index 0 and so opens a stacking context, and
   .panel carries z-index 2 as its sibling — so nothing inside the stage,
   this layer included, can paint over a panel however high its own z-index
   climbs. The outer edge is .stage's own overflow: hidden. Checked with the
   deck zoomed past 500%, where the slide really does run on underneath both
   panels: a cursor out there is occluded by the panel, not clipped. */
/* Positioned by transform rather than left/top so the browser can move it on
   the compositor, and — the reason it matters here — so the transition below
   has a single property to animate. Positions arrive about twenty times a
   second; without this the cursor would visibly teleport between samples.
   Linear, not eased: an ease-out on a continuous stream reads as the cursor
   repeatedly arriving and stopping, which is exactly what it is not doing.
   The duration is a shade over the send interval, so each sample is still
   animating when the next one replaces it. */
.livecursor {
  position: absolute;
  top: 0;
  left: 0;
  display: flex;
  align-items: flex-start;
  gap: 0;
  transition: transform 60ms linear;
  will-change: transform;
}
/* Two custom properties per person, set on the node itself (paintCursors):
   the colour, and what can actually be read *on* that colour. Properties
   rather than plain `color`, because the label wants the colour as its
   background while its text is the other one — `background: currentColor`
   on an element that also sets its own `color` just resolves to that same
   colour, which is how the label once came out white on white. */
.livecursor { color: var(--cursor-color, var(--accent)); }
/* The designed artwork, one file per palette entry
   (assets/cursors/canvas-*.svg, vendored beside the stage's own cursors).
   Nothing is added here and nothing tints it: the colour is in the file,
   and so are the white outline and drop shadow that keep it legible over
   whatever it happens to be crossing. */
.livecursor__arrow {
  display: block;
  width: 24px;
  height: 24px;
}
.livecursor__name {
  /* Just clear of the arrow's own point, which sits near the top-left of its
     24px box rather than at the corner. */
  margin-top: 8px;
  padding: 1px 6px;
  /* Fully round: a name is a chip, and at this size a small corner radius
     reads as a rectangle that failed to be round rather than as a
     deliberate one. */
  border-radius: 999px;
  background: var(--cursor-color, var(--accent));
  color: var(--cursor-ink, #fff);
  font-size: 9px;
  font-weight: 600;
  line-height: 1.5;
  white-space: nowrap;
  /* The label is a name, not a slab of colour: it should not grow with the
     deck's own zoom the way the artboard's content does. */
  transform-origin: 0 0;
}

/* What everyone else has selected on this slide (paintRemoteSelections) —
   always, not only while following one of them: knowing what a colleague is
   holding is the point, and following is just a way of travelling with
   them. An outline in that person's own colour — which is what tells it
   apart from .selbox's own accent ring, rather than the line style doing
   that work; the two are routinely on screen together.
   No handles: it is a report, not something to drag. Click-through for the
   same reason. */
.remotesel {
  position: absolute;
  pointer-events: none;
  outline: 2px solid var(--remote-color, var(--accent));
  outline-offset: 2px;
  border-radius: 2px;
}
/* A line is its two ends, not its box — see paintRemoteSelections. The box
   still positions and rotates the frame those ends are fractions of, but
   draws nothing itself. */
.remotesel--line { outline: none; }
.remotesel__point {
  position: absolute;
  width: 9px;
  height: 9px;
  margin: -4.5px 0 0 -4.5px;
  border-radius: 50%;
  background: var(--remote-color, var(--accent));
  /* The same ring-and-shadow a live cursor's arrow carries, and for the same
     reason: this sits on top of whatever the author has drawn, so it has to
     stay legible over an arbitrary colour rather than over the panel. */
  box-shadow: 0 0 0 1.5px #fff, 0 1px 2px rgba(16, 20, 26, 0.35);
}

/* The name is an introduction, not a permanent label. It holds long enough
   to read, then fades and leaves the arrow — on a busy slide half a dozen
   name tags cover more of the deck than the pointers they belong to.
   No timer in canvas.js for this: a cursor's node lives exactly as long as
   that person is on your slide (see paintCursors), so the delay restarts by
   itself whenever someone arrives, and someone who leaves and comes back is
   introduced again. */
@keyframes livecursor-name-out {
  to { opacity: 0; }
}
.livecursor__name {
  animation: livecursor-name-out 600ms ease 5s forwards;
}
/* Still gets out of the way, just without the fade — the label going quiet
   is the point here, and only the manner of it is decoration. */
@media (prefers-reduced-motion: reduce) {
  .livecursor__name { animation-duration: 1ms; }
}
/* Once the deck holds more tabs than colours, a colour stops identifying
   anybody on its own, so the names stay up (see paintCursors). Every one of
   them, so the slide reads as one rule rather than as some labels
   mysteriously outlasting others. */
.artboard__cursors.is-crowded .livecursor__name {
  animation: none;
  opacity: 1;
}
/* The followed tab, mid-hop between slides: kept rather than dropped so its
   name countdown survives the gap (see paintCursors). visibility, not
   display, because display: none cancels a running animation and would
   restart it on the way back. */
.livecursor.is-offslide { visibility: hidden; }
