/* Overlay+ compositor stylesheet.
   Layout for N objects on one canvas, plus the object animation keyframes. */

html, body {
  margin: 0;
  padding: 0;
  width: 100%;
  height: 100%;
  overflow: hidden;
  background: transparent;   /* OBS composites this over your scene */
}

* { box-sizing: border-box; }

/* A preview - the panel's, or a popped-out LIVE or STAGED window - is almost
   never exactly the canvas's shape: a tall panel, a maximised window. The
   canvas is fitted inside and the rest is black, so where the canvas ends is
   never in doubt. The checkerboard used to cover the whole page, and the
   spare room either side looked like more canvas. */
body.preview { background: #000; }
body.preview #stage {
  background-color: #14161c;
  background-image:
    linear-gradient(45deg, #1b1e26 25%, transparent 25%),
    linear-gradient(-45deg, #1b1e26 25%, transparent 25%),
    linear-gradient(45deg, transparent 75%, #1b1e26 75%),
    linear-gradient(-45deg, transparent 75%, #1b1e26 75%);
  /* In canvas pixels: the stage is scaled down to fit, and so is this. */
  background-size: 48px 48px;
  background-position: 0 0, 0 24px, 24px -24px, -24px 0;
  /* Nothing past the edge of the canvas shows on a stream, so nothing past it
     shows here either - an overlay leaving by the side goes, it does not
     carry on across the black. */
  overflow: hidden;
}

/* -------------------------------- stage --------------------------------- */

#stage {
  position: absolute;
  top: 0;
  left: 0;
  transform-origin: top left;
  /* size and scale come from JS to match the chosen canvas */
}

/* Every object gets its own full-stage anchor layer, so anchoring and
   auto-sizing behave per object exactly as they did for a single overlay. */
.op-anchor {
  position: absolute;
  inset: 0;
  display: flex;
  pointer-events: none;
}

.op-anchor[data-anchor$="-left"]   { justify-content: flex-start; }
.op-anchor[data-anchor$="-center"] { justify-content: center; }
.op-anchor[data-anchor$="-right"]  { justify-content: flex-end; }
.op-anchor[data-anchor^="top-"]    { align-items: flex-start; }
.op-anchor[data-anchor^="middle-"] { align-items: center; }
.op-anchor[data-anchor^="bottom-"] { align-items: flex-end; }

.op-obj {
  position: relative;
  /* Moving to a new spot when the scene changes. `cut` sets this to 0ms. */
  transition-property: left, top;
  transition-timing-function: cubic-bezier(0.33, 1, 0.68, 1);
  transition-duration: 0ms;
}
.op-obj[hidden] { display: none !important; }

/* Being shoved aside by something with more priority. Its own layer and its own
   clock: it has nothing to do with arriving or leaving, and sharing a transform
   with them made a push landing mid-exit interpolate through nonsense. */
.op-place {
  position: relative;
  transition-property: transform;
  transition-timing-function: cubic-bezier(0.33, 1, 0.68, 1);
  transition-duration: 320ms;
  will-change: transform;
  transform-origin: center center;
}

/* Arriving and leaving, and nothing else. */
.op-motion {
  position: relative;
  transition-property: transform, opacity, filter, clip-path;
  transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
  transition-duration: 300ms;
  will-change: transform, opacity;
  transform-origin: center center;
}

/* Object animations live on their own layer so they never fight the
   transition transform on .op-motion. */
.op-anim {
  position: relative;
  /* Its own formatting context, so a cropped box's negative margins stop here
     rather than collapsing up through op-motion and op-place: every layer
     above the box is then exactly what is shown, and scales, animates and
     wears the helper about the middle of it. */
  display: flow-root;
  transform-origin: center center;
  --op-anim-intensity: 1;
}

.op-box.no-resize-anim { transition: none !important; }

.op-box {
  position: relative;
  overflow: hidden;
  transition-property: width, height, background-color, border-color, border-radius;
  transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
  transition-duration: 520ms;
}

/* Each showing line is its own layer, stacked at the box origin so one can fade
   out while the next fades in and the box animates between their sizes. */
.op-inner {
  position: absolute;
  top: 0;
  left: 0;
  width: max-content;
}

.op-layer {
  opacity: 0;
  --op-line-in: 450ms;
  --op-line-out: 350ms;
  --op-travel: 18px;
  transition:
    opacity var(--op-line-in) ease,
    transform var(--op-line-in) cubic-bezier(0.16, 1, 0.3, 1),
    filter var(--op-line-in) ease;
}
.op-layer[hidden] { display: none !important; }

.op-layer.leaving {
  transition:
    opacity var(--op-line-out) ease,
    transform var(--op-line-out) cubic-bezier(0.7, 0, 0.84, 0),
    filter var(--op-line-out) ease;
}

/* Entry (pre-reveal) states.
   Slide really slides - a whole box-width, clipped by the box, so it reads as a
   slide rather than a nudge. Everything used to move by the same 18px AND fade
   to nothing, which made every style look like the same fade. */
.op-layer[data-t="fade-up"]     { transform: translateY(var(--op-travel)); }
.op-layer[data-t="fade-down"]   { transform: translateY(calc(var(--op-travel) * -1)); }
.op-layer[data-t="slide-left"]  { transform: translateX(110%); opacity: 1; }
.op-layer[data-t="slide-right"] { transform: translateX(-110%); opacity: 1; }
.op-layer[data-t="scale"]       { transform: scale(0.7); }
.op-layer[data-t="blur"]        { filter: blur(18px); }

/* The settled state must out-specify the [data-t] rules above, or a line never
   arrives - it stays offset, or with Blur stays permanently blurred. */
.op-layer.shown,
.op-layer[data-t].shown { opacity: 1; transform: none; filter: none; }

/* Leaving lines continue the way they came in rather than snapping back.
   A slide leaves opaque and travels right out: fading it would turn the slide
   back into a fade, which is the bug this whole block exists to avoid. */
.op-layer.leaving[data-t="fade-up"]     { transform: translateY(calc(var(--op-travel) * -1)); opacity: 0; }
.op-layer.leaving[data-t="fade-down"]   { transform: translateY(var(--op-travel)); opacity: 0; }
.op-layer.leaving[data-t="slide-left"]  { transform: translateX(-110%); opacity: 1; }
.op-layer.leaving[data-t="slide-right"] { transform: translateX(110%); opacity: 1; }
/* Scale shrinks away to nothing. It used to grow slightly and fade, which is
   a fade with a wobble - the same mistake as everywhere else. The opacity is
   held back until the end so what you see is the shrink, not the fade. */
.op-layer.leaving[data-t="scale"] {
  transform: scale(0.05);
  opacity: 0;
  transition:
    transform var(--op-line-out) cubic-bezier(0.7, 0, 0.84, 0),
    opacity calc(var(--op-line-out) * 0.35) ease calc(var(--op-line-out) * 0.65);
}

/* Blur genuinely dissolves, so it does fade - but late, once it has actually
   blurred, rather than fading first and blurring an invisible thing. */
.op-layer.leaving[data-t="blur"] {
  filter: blur(22px);
  opacity: 0;
  transition:
    filter var(--op-line-out) ease,
    opacity calc(var(--op-line-out) * 0.5) ease calc(var(--op-line-out) * 0.5);
}

/* Typewriter reveals with a clip rather than mutating text nodes. */
.op-layer[data-t="typewriter"] {
  opacity: 1;
  clip-path: inset(0 100% 0 0);
  transition: clip-path var(--op-line-in) steps(40, end), opacity var(--op-line-out) ease;
}
.op-layer[data-t="typewriter"].shown { clip-path: inset(0 0 0 0); opacity: 1; }
.op-layer[data-t="typewriter"].leaving { opacity: 0; }

/* A fixed-size Text+: the layer fills the box and centers the words, which are
   scaled to fit by JS. The layer's own transform stays free for the line
   transition - the scale lives on .op-rows, one concern per element. */
.op-box.fit .op-inner {
  box-sizing: border-box;
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}
.op-box.fit .op-rows { flex: 0 0 auto; width: max-content; }

/* Preloaded media: kept decoded so cycling back to a line never flashes. */
.op-preload {
  position: absolute;
  width: 0; height: 0;
  overflow: hidden;
  opacity: 0;
  pointer-events: none;
}

.op-rows {
  display: flex;
  flex-direction: column;
}

/* A line is a horizontal band, so text and a graphic can sit side by side.
   Lines themselves stack, which is what .op-rows does above. */
.op-line {
  display: flex;
  flex-direction: row;
  align-items: center;
  flex-wrap: wrap;
}

.op-row { white-space: pre-wrap; overflow-wrap: anywhere; }
.op-row.uppercase { text-transform: uppercase; }
.op-row.italic { font-style: italic; }

/* Twitch+: an optional title over what it shows - a column of rows, or one
   row with a separator between. */
.op-twitch { display: inline-flex; flex-direction: column; gap: 0.12em; white-space: normal; }
.op-twitch[hidden] { display: none !important; }
.op-tw-title { font-size: 0.6em; opacity: 0.75; letter-spacing: 0.08em; text-transform: uppercase; }
.op-tw-title[hidden] { display: none; }
.op-tw-list.stack { display: flex; flex-direction: column; gap: 0.08em; }
.op-tw-list.inline { display: block; }
.op-tw-item { white-space: pre-wrap; overflow-wrap: anywhere; }
.op-tw-sep { opacity: 0.55; white-space: pre; }
.op-tw-empty { opacity: 0.6; }
.op-tw-hl { font-weight: 800; }

/* Goals+ is drawn by goals.css, which the panel's Goals+ dialog loads too. */

.op-graphic { display: flex; line-height: 0; }
.op-graphic img, .op-graphic video { display: block; max-width: 100%; }

/* Which canvas am I looking at? Two black rectangles floating over OBS are
   otherwise identical. Never shown in an OBS Browser Source - it only appears
   on a popped-out window. */
.op-window-label {
  position: fixed;
  left: 10px;
  top: 10px;
  z-index: 2147483647;
  padding: 4px 10px;
  border-radius: 6px;
  font: 700 11px/1 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  letter-spacing: 0.12em;
  pointer-events: none;
}
.op-window-label.live { background: rgba(255, 90, 106, 0.85); color: #fff; }
.op-window-label.staged { background: rgba(124, 92, 255, 0.85); color: #fff; }

/* A pop-out's own Reload, and its "OBS too?" (reloadEverything). */
.op-window-reload {
  position: fixed;
  right: 10px;
  top: 10px;
  z-index: 2147483647;
  padding: 5px 11px;
  border: 1px solid rgba(255, 255, 255, 0.25);
  border-radius: 6px;
  background: rgba(20, 23, 34, 0.85);
  color: #e8eaf3;
  font: 600 11px/1 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  cursor: pointer;
  opacity: 0.7;
}
.op-window-reload:hover { opacity: 1; border-color: rgba(167, 139, 250, 0.9); }
/* Guides and Snap on a pop-out, left of its Reload. */
.op-window-tools {
  position: fixed;
  right: 78px;
  top: 10px;
  z-index: 2147483647;
  display: flex;
  gap: 10px;
  padding: 4px 10px;
  border: 1px solid rgba(255, 255, 255, 0.25);
  border-radius: 6px;
  background: rgba(20, 23, 34, 0.85);
  color: #e8eaf3;
  font: 600 11px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  opacity: 0.75;
}
.op-window-tools:hover { opacity: 1; }
.op-window-tools label { display: inline-flex; align-items: center; gap: 4px; cursor: pointer; }
.op-window-tools input { margin: 0; }

/* Guides and snapping (guides.js): previews only, drawn in the page's own
   pixels over the stage so a line stays one pixel at any zoom. Under the
   handles, which have to stay grabbable. */
.op-guides { position: fixed; z-index: 2147482000; pointer-events: none; }
.op-guides[hidden], .op-guide-set[hidden], .op-snap[hidden] { display: none !important; }
.op-guide-set { position: absolute; inset: 0; }
.op-guide { position: absolute; }
.op-guide.v { top: 0; bottom: 0; width: 0; border-left: 1px dashed rgba(0, 214, 255, 0.5); }
.op-guide.h { left: 0; right: 0; height: 0; border-top: 1px dashed rgba(0, 214, 255, 0.5); }
.op-guide.middle.v { border-left: 1px solid rgba(0, 214, 255, 0.75); }
.op-guide.middle.h { border-top: 1px solid rgba(0, 214, 255, 0.75); }
.op-guide-safe { position: absolute; border: 1px dotted rgba(255, 196, 0, 0.6); }
/* The line an overlay has snapped to, for as long as it holds. */
.op-snap { position: absolute; }
.op-snap.v { top: 0; bottom: 0; width: 0; border-left: 1px solid #ff3dd2; box-shadow: 0 0 4px #ff3dd2; }
.op-snap.h { left: 0; right: 0; height: 0; border-top: 1px solid #ff3dd2; box-shadow: 0 0 4px #ff3dd2; }
.op-window-ask {
  position: fixed;
  right: 10px;
  top: 40px;
  z-index: 2147483647;
  padding: 10px 12px;
  border: 1px solid rgba(255, 255, 255, 0.2);
  border-radius: 8px;
  background: #1b1f2e;
  color: #e8eaf3;
  font: 500 12px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  box-shadow: 0 10px 28px rgba(0, 0, 0, 0.5);
}
.op-window-ask div { display: flex; gap: 6px; margin-top: 8px; }
.op-window-ask button {
  padding: 5px 10px;
  border: 1px solid rgba(255, 255, 255, 0.25);
  border-radius: 6px;
  background: transparent;
  color: inherit;
  font: inherit;
  cursor: pointer;
}
.op-window-ask button:last-child { background: #7c5cff; border-color: #7c5cff; color: #fff; }

.op-frame { display: block; border: 0; background: transparent; }

.op-placeholder {
  display: grid;
  place-items: center;
  width: 100%; height: 100%;
  font: 600 14px system-ui, sans-serif;
  color: rgba(255, 255, 255, 0.55);
  background: rgba(255, 255, 255, 0.06);
  border: 1px dashed rgba(255, 255, 255, 0.25);
  border-radius: 8px;
}

/* A Text+'s own background picture, under its lines (object.js _paintPicture).
   It carries the box's rounded corners; the crop on op-box clips it with
   everything else. */
.op-bgpic {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
}
.op-bgpic[hidden] { display: none !important; }

/* --------------------------- the overlay helper -------------------------- */

/* A matching border round an object, so a scene reads as one themed set.
   Drawn under the object unless the scene asks for it over (helper.over). */
.op-helper {
  position: absolute;
  border-style: solid;
  pointer-events: none;
}
.op-helper[hidden] { display: none !important; }


/* ------------------------------- identify -------------------------------- */

.op-badge {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  min-width: 64px;
  padding: 10px 18px;
  border-radius: 14px;
  background: rgba(10, 10, 16, 0.82);
  border: 2px solid #7c5cff;
  color: #fff;
  font: 800 44px/1 system-ui, sans-serif;
  text-align: center;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
  z-index: 50;
  animation: op-badge-in 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.op-badge[hidden] { display: none !important; }

@keyframes op-badge-in {
  from { opacity: 0; transform: translate(-50%, -50%) scale(0.7); }
}

/* An object that is off but could be switched on: shown ghosted during identify. */
/* ---------------------------- drag to place ----------------------------- */

/* Only in the panel's preview. An OBS Browser Source must stay untouchable. */
body.draggable #stage { pointer-events: auto; }

/* On op-anim, which is what is shown: the box itself runs on under a crop. The
   selected one is framed by the handles instead (.op-edit, below). */
body.draggable .op-anchor:not(.op-locked) .op-obj.drag-hover:not(.drag-selected) .op-anim {
  outline: 2px solid rgba(124, 92, 255, 0.75);
  outline-offset: 3px;
}
/* Nothing eases while you are dragging it - the overlay has to sit under the
   pointer, not chase it. */
body.draggable .op-obj.dragging,
body.draggable .op-obj.dragging .op-place,
body.draggable .op-obj.dragging .op-motion,
body.draggable .op-obj.dragging .op-box { transition: none !important; }

/* ------------------------ resize and crop handles ------------------------ */

/* Drawn over the stage in the page's own pixels, not inside it: the stage is
   scaled to fit, and a handle inside it would shrink to a speck in the panel.
   Only in a preview, like dragging - handles.js is never attached anywhere
   else, so a Browser Source never has any of this in its page. */
.op-edit {
  position: fixed;
  left: 0;
  top: 0;
  z-index: 2147483000;
  pointer-events: none;
  --op-edit: #7c5cff;
  --op-edit-crop: #ffb020;
}
.op-edit[hidden] { display: none !important; }

.op-edit-frame,
.op-edit-ghost {
  position: fixed;
  box-sizing: border-box;
  pointer-events: none;
}
.op-edit-frame {
  border: 1.5px solid var(--op-edit);
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.55);
}
.op-edit.crop .op-edit-frame { border-color: var(--op-edit-crop); }
.op-edit.locked .op-edit-frame { border-style: dashed; }

/* While cropping: where the whole box would reach, so a crop can be dragged
   back out again. */
.op-edit-ghost {
  border: 1px dashed rgba(255, 176, 32, 0.8);
  background: rgba(255, 176, 32, 0.07);
}
.op-edit-ghost[hidden] { display: none; }

.op-edit-h {
  position: fixed;
  width: 11px;
  height: 11px;
  margin: -5.5px 0 0 -5.5px;
  box-sizing: border-box;
  border: 1.5px solid var(--op-edit);
  border-radius: 2px;
  background: #fff;
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5);
  pointer-events: auto;
  touch-action: none;
}
.op-edit-h[hidden] { display: none; }
.op-edit-h[data-h="n"], .op-edit-h[data-h="s"] { width: 18px; margin-left: -9px; height: 8px; margin-top: -4px; }
.op-edit-h[data-h="e"], .op-edit-h[data-h="w"] { height: 18px; margin-top: -9px; width: 8px; margin-left: -4px; }
.op-edit-h[data-h="nw"], .op-edit-h[data-h="se"] { cursor: nwse-resize; }
.op-edit-h[data-h="ne"], .op-edit-h[data-h="sw"] { cursor: nesw-resize; }
.op-edit-h[data-h="n"], .op-edit-h[data-h="s"] { cursor: ns-resize; }
.op-edit-h[data-h="e"], .op-edit-h[data-h="w"] { cursor: ew-resize; }
.op-edit.crop .op-edit-h { border-color: var(--op-edit-crop); background: #1b1406; cursor: crosshair; }
.op-edit-h:hover { background: var(--op-edit); }
.op-edit.crop .op-edit-h:hover { background: var(--op-edit-crop); }

/* A toolbar that rides along with the selection. */
.op-edit-bar {
  position: fixed;
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 3px;
  border-radius: 7px;
  background: rgba(14, 15, 22, 0.92);
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.45);
  font: 600 11px/1 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  color: #e8e8f0;
  white-space: nowrap;
  pointer-events: auto;
  user-select: none;
}
.op-edit-bar button {
  font: inherit;
  color: inherit;
  padding: 4px 8px;
  border: 0;
  border-radius: 5px;
  background: transparent;
  cursor: pointer;
}
.op-edit-bar button:hover { background: rgba(255, 255, 255, 0.1); }
.op-edit-bar button[aria-pressed="true"] { background: var(--op-edit); color: #fff; }
.op-edit.crop .op-edit-bar button[data-mode="crop"][aria-pressed="true"] { background: var(--op-edit-crop); color: #1b1406; }
.op-edit-bar button[hidden] { display: none; }
/* Shown in this scene, or not: the toolbar's checkmark, drawn like the one in
   the panel's Scenes list (visMark) - a thin green tick in its box, or a red
   box with a red cross. */
.op-edit-bar button[data-mode="show"] { display: grid; place-items: center; padding: 1px 4px; color: #ff4d5e; }
.op-edit-bar button[data-mode="show"][aria-pressed="true"] { background: transparent; color: #35d39a; }
.op-edit-bar button[data-mode="show"]:hover { background: rgba(255, 255, 255, 0.1); }
.op-edit-bar button[data-mode="show"] svg {
  display: block;
  width: 20px; height: 20px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.op-edit-bar button[data-mode="show"] svg rect { fill: currentColor; fill-opacity: 0.18; stroke-width: 1.4; }
/* Picked in a preview but hidden in the scene it draws: a ghost, so its
   checkmark can show it again (ghostId in compositor.js). Previews only. */
.op-anchor.op-hidden-pick { opacity: 0.35; }
.op-anchor.op-hidden-pick .op-anim { outline: 2px dashed rgba(255, 255, 255, 0.6); outline-offset: 4px; }
/* Its lock: bright red when it is locked, like the dotted border it wears. */
.op-edit-bar button[data-mode="lock"] { font-size: 12px; padding-left: 7px; padding-right: 7px; }
.op-edit-bar button[data-mode="lock"][aria-pressed="true"] { background: #ff2b3d; color: #fff; }

/* Overlay+ switched off, in a preview: an empty canvas otherwise reads as broken. */
.op-off-badge {
  position: fixed;
  left: 50%;
  top: 10px;
  transform: translateX(-50%);
  z-index: 2147483647;
  padding: 5px 12px;
  border-radius: 6px;
  background: rgba(255, 43, 61, 0.9);
  color: #fff;
  font: 700 12px/1 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  letter-spacing: 0.04em;
  pointer-events: none;
}
.op-off-badge[hidden] { display: none; }
.op-edit-readout {
  padding: 0 6px 0 4px;
  color: #aeb0c2;
  font-variant-numeric: tabular-nums;
}

/* Over whatever Transparency the object has, which is written inline on the
   same element: while it is being identified, a switched-off one is a ghost. */
.op-obj.identify-ghost { opacity: 0.35 !important; }
.op-obj.identify-ghost .op-badge { border-style: dashed; }

/* --------------------------- object animations --------------------------- */
/* Each keeps the object's layout box, so it holds its space for collision
   purposes even at moments the animation has it off its mark. */

.op-anim[class*="anim-"] {
  animation-fill-mode: both;
  animation-timing-function: ease-in-out;
}

@keyframes op-wiggle {
  0%, 100% { transform: rotate(0deg); }
  25% { transform: rotate(calc(3deg * var(--op-anim-intensity))); }
  75% { transform: rotate(calc(-3deg * var(--op-anim-intensity))); }
}
.anim-wiggle { animation-name: op-wiggle; }

@keyframes op-shake {
  0%, 100% { transform: translateX(0); }
  20% { transform: translateX(calc(-8px * var(--op-anim-intensity))); }
  40% { transform: translateX(calc(8px * var(--op-anim-intensity))); }
  60% { transform: translateX(calc(-5px * var(--op-anim-intensity))); }
  80% { transform: translateX(calc(5px * var(--op-anim-intensity))); }
}
.anim-shake { animation-name: op-shake; animation-timing-function: linear; }

@keyframes op-explode {
  0% { transform: scale(1); filter: brightness(1); }
  35% { transform: scale(calc(1 + 0.35 * var(--op-anim-intensity))); filter: brightness(1.9); }
  55% { transform: scale(calc(1 - 0.06 * var(--op-anim-intensity))); filter: brightness(1); }
  100% { transform: scale(1); filter: brightness(1); }
}
.anim-explode { animation-name: op-explode; }

@keyframes op-pulse {
  0%, 100% { transform: scale(1); }
  50% { transform: scale(calc(1 + 0.08 * var(--op-anim-intensity))); }
}
.anim-pulse { animation-name: op-pulse; }

@keyframes op-bounce {
  0%, 100% { transform: translateY(0); }
  30% { transform: translateY(calc(-22px * var(--op-anim-intensity))); }
  55% { transform: translateY(0); }
  70% { transform: translateY(calc(-9px * var(--op-anim-intensity))); }
  85% { transform: translateY(0); }
}
.anim-bounce { animation-name: op-bounce; }

@keyframes op-float {
  0%, 100% { transform: translateY(calc(-6px * var(--op-anim-intensity))); }
  50% { transform: translateY(calc(6px * var(--op-anim-intensity))); }
}
.anim-float { animation-name: op-float; }

@keyframes op-flicker {
  0%, 100% { opacity: 1; }
  12% { opacity: 0.3; }
  18% { opacity: 1; }
  42% { opacity: 0.55; }
  48% { opacity: 1; }
  70% { opacity: 0.25; }
  76% { opacity: 1; }
}
.anim-flicker { animation-name: op-flicker; animation-timing-function: steps(1, end); }

@keyframes op-pop {
  0% { transform: scale(1); }
  40% { transform: scale(calc(1 + 0.18 * var(--op-anim-intensity))); }
  100% { transform: scale(1); }
}
.anim-pop { animation-name: op-pop; }

@keyframes op-swing-tilt {
  0%, 100% { transform: rotate(0deg); }
  30% { transform: rotate(calc(7deg * var(--op-anim-intensity))); }
  70% { transform: rotate(calc(-5deg * var(--op-anim-intensity))); }
}
.anim-swing-tilt { animation-name: op-swing-tilt; }

@keyframes op-heartbeat {
  0%, 100% { transform: scale(1); }
  14% { transform: scale(calc(1 + 0.12 * var(--op-anim-intensity))); }
  28% { transform: scale(1); }
  42% { transform: scale(calc(1 + 0.09 * var(--op-anim-intensity))); }
  56% { transform: scale(1); }
}
.anim-heartbeat { animation-name: op-heartbeat; }

@keyframes op-jitter {
  0%, 100% { transform: translate(0, 0); }
  25% { transform: translate(calc(2px * var(--op-anim-intensity)), calc(-2px * var(--op-anim-intensity))); }
  50% { transform: translate(calc(-2px * var(--op-anim-intensity)), calc(2px * var(--op-anim-intensity))); }
  75% { transform: translate(calc(2px * var(--op-anim-intensity)), calc(2px * var(--op-anim-intensity))); }
}
.anim-jitter { animation-name: op-jitter; animation-timing-function: steps(2, end); }

/* ------------------------------- offline --------------------------------- */

#offline {
  position: fixed;
  left: 16px;
  bottom: 16px;
  font: 500 13px/1.4 system-ui, sans-serif;
  color: #ffd7d7;
  z-index: 99;
}
#offline[hidden] { display: none; }

.offline-card {
  display: flex;
  gap: 8px;
  align-items: baseline;
  background: rgba(120, 20, 30, 0.82);
  border: 1px solid rgba(255, 120, 130, 0.5);
  border-radius: 10px;
  padding: 8px 12px;
}

/* ------------------------------ countdown -------------------------------- */

.op-countdown { position: relative; font-variant-numeric: tabular-nums; }

/* Numbers, unit words and separators are separate spans so each can take its
   own font, size and color; pre keeps the space in "5 days" from collapsing. */
.op-cd-num, .op-cd-unit, .op-cd-sep { white-space: pre; }

/* An embedded Countdown+ past its switch-off time fades itself away locally -
   there is no single part of a bigger Text+ to switch off in the model. */
.op-countdown.op-countdown-expired { opacity: 0; transition: opacity 900ms ease; }

/* ------------------------------- takeover --------------------------------- */

/* A milestone or finish reveal takes the whole box: every line layer fades
   out and the takeover fades and grows in over them, then the reverse. Like
   every layer here it has one job; the burst animation and the fit scale
   each get their own element inside it, so none of them share a transform. */
.op-box.op-taken > .op-layer { opacity: 0 !important; transition: opacity 320ms ease !important; }

.op-takeover {
  position: absolute;
  box-sizing: border-box;
  opacity: 0;
  transform: scale(0.86);
  pointer-events: none;
  transition: opacity 360ms ease, transform 460ms cubic-bezier(0.22, 1.3, 0.36, 1);
  z-index: 1;
}
.op-takeover.shown { opacity: 1; transform: none; }
/* Resize the box: sits at the box origin at its natural size, like a line
   layer, and the box grows or shrinks to fit it. */
.op-takeover.grow { top: 0; left: 0; width: max-content; }
/* Fit to the box: fills the box as it stands; the content is scaled to fit. */
.op-takeover.box { inset: 0; display: flex; align-items: center; justify-content: center; }

.op-take-anim { display: inline-block; }
.op-take-fit {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  text-align: center;
  transform-origin: center center;
}
.op-take-text { white-space: pre-wrap; overflow-wrap: anywhere; }
.op-take-media { display: block; max-width: 100%; }

/* Where an "in the box" celebration plays: the whole box, clipped to it. */
.op-fx-host {
  position: absolute;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
  border-radius: inherit;
  z-index: 2;
}

/* --------------------------- celebration layer ---------------------------- */

/* One shared, full-canvas layer for `scope: 'screen'` celebrations. A single
   dedicated element with one job, never a shared transform on an object's own
   anchor/obj/place/motion/anim/box stack - see "Rendering layers" in
   ARCHITECTURE.md. Sized to the canvas by #stage; canvas-pixel mask
   coordinates map to it 1:1. */
/* Above every overlay, whatever layer a scene puts one on (layerOf() in
   compositor.js stays below this). */
#op-celebration-layer {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 1000000;
  overflow: hidden;
}

/* ------------------------------ celebrations ------------------------------ */

.op-fx-burst {
  position: absolute;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
}

.op-fx-p {
  position: absolute;
  left: var(--x, 0);
  top: var(--y, 0);
  width: var(--size, 10px);
  height: var(--size, 10px);
  margin: calc(var(--size, 10px) / -2) 0 0 calc(var(--size, 10px) / -2);
  background: var(--color, #fff);
  opacity: 0;
  animation-name: op-fx-fall;
  animation-duration: var(--dur, 2000ms);
  animation-delay: var(--delay, 0ms);
  animation-timing-function: ease-out;
  animation-fill-mode: forwards;
}
.op-fx-p.op-fx-glow { box-shadow: 0 0 calc(var(--size, 10px) * 0.8) var(--color, #fff); }

@keyframes op-fx-fall {
  0% { opacity: 0; transform: translate(0, 0) rotate(0deg) scale(0.4); }
  8% { opacity: 1; }
  80% { opacity: 1; }
  100% {
    opacity: 0;
    transform: translate(var(--dx, 0), var(--dy, 120px)) rotate(var(--rot, 180deg)) scale(1);
  }
}

.op-fx-rect { border-radius: 2px; }
.op-fx-circle, .op-fx-dot { border-radius: 50%; }
.op-fx-star {
  clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%,
    21% 91%, 32% 57%, 2% 35%, 39% 35%);
}
.op-fx-flower {
  border-radius: 50%;
  box-shadow:
    calc(var(--size, 10px) * 0.55) 0 0 0 var(--color, #fff),
    calc(var(--size, 10px) * -0.55) 0 0 0 var(--color, #fff),
    0 calc(var(--size, 10px) * 0.55) 0 0 var(--color, #fff),
    0 calc(var(--size, 10px) * -0.55) 0 0 var(--color, #fff);
}
.op-fx-balloon {
  border-radius: 50% 50% 50% 50% / 62% 62% 38% 38%;
  position: relative;
}
.op-fx-streak {
  border-radius: 999px;
  height: calc(var(--size, 10px) * 3) !important;
  background: linear-gradient(to bottom, transparent, var(--color, #fff));
}

.op-fx-bolt {
  position: absolute;
  left: var(--x, 0);
  top: var(--y, 0);
  width: calc(var(--size, 40px) * 0.5);
  height: var(--size, 40px);
  background: var(--color, #fff);
  clip-path: polygon(40% 0%, 100% 0%, 50% 45%, 80% 45%, 20% 100%, 40% 55%, 0% 55%);
  opacity: 0;
  filter: drop-shadow(0 0 10px var(--color, #fff));
  animation: op-fx-flash 650ms ease-out both;
  animation-delay: var(--delay, 0ms);
}
@keyframes op-fx-flash {
  0%, 100% { opacity: 0; }
  8%, 18% { opacity: 1; }
  12% { opacity: 0.2; }
  30% { opacity: 0; }
}
.offline-card strong { color: #fff; }

/* ------------------------ editing one object ---------------------------- */

/* The panel's preview while an object is being edited on the Objects tab:
   everything else is dimmed, or hidden altogether. Opacity only - it is cheap
   to draw, where a grayscale filter would be re-run every time an animated
   overlay underneath it changed. On the anchor, which nothing else fades. */
.op-anchor.op-dimmed { opacity: 0.28; transition: opacity 180ms ease; }
.op-anchor.op-dimmed-away { visibility: hidden; }
/* Dimmed means hands off - including an interactive Browserlay's own frame,
   which would otherwise still take the click. */
.op-anchor.op-dimmed,
.op-anchor.op-dimmed * { pointer-events: none !important; }
/* Drawn Static: standing where it rests, to line things up against and to
   click to edit. Clear enough to see its edges, and clearer under the
   pointer, which picks it. The stage takes the click (entryAt), not the
   overlay, so its frame stays hands off. */
.op-anchor.op-dimmed.op-still { opacity: 0.5; }
.op-anchor.op-dimmed.op-still:has(.op-obj.drag-hover) { opacity: 0.85; }

/* Locked in the scene this preview shows (op-locked is only ever set in a
   preview): a dotted red border round what is drawn. On op-anim, which is
   exactly the shown box - crop, scale and all. A locked overlay never gets
   the drag-hover outline, which has nothing to offer it. */
.op-anchor.op-locked .op-anim {
  outline: 2px dotted #ff2b3d;
  outline-offset: 4px;
}
