/* © 2026 Created with ♥ by Alex Munteanu | alexmunteanu.com */

/*
 * Reset, global behaviour and the five type role classes, shared by the admin tool and the
 * published gallery. Load order is tokens.css, fonts.css, base.css, then strip.css, then the
 * per-surface sheet.
 *
 * Every colour here is a semantic name from tokens.css. No rule in this file reads a palette
 * value directly - see the consumption path at the top of tokens.css.
 */

/*
 * FIRST RULE IN THE FILE, AND IT CARRIES !important ON PURPOSE.
 *
 * The user-agent stylesheet's [hidden] rule is author-origin-losing: any author rule that
 * sets a display - .wv-strip{display:flex}, .wv-label{display:flex},
 * .wv-title{display:-webkit-box} - beats it no matter how specific the UA selector is. The
 * failure looks like a filter that "does nothing": JavaScript sets element.hidden, the count
 * updates, the cards stay on screen. Only a computed-display read in a real browser catches
 * it, which is exactly what AC-040 is.
 *
 * Do not move this rule. Its position is what makes it survive a later addition above it.
 */
[hidden] {
  display: none !important;
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

:root {
  /* Dark only. This is what makes form controls, scrollbars and the canvas render dark
   * without a single extra rule. There is no light counterpart by design. */
  color-scheme: dark;
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;

  /*
   * THE GUTTER IS ALWAYS RESERVED, so nothing in the chrome moves when the scrollbar comes and
   * goes (v0.20.1, operator: "I don't want elements to jump around and actually change the
   * layout when the scrollbar appears").
   *
   * The header is a full-width `1fr auto 1fr` grid, so every change in viewport width moves the
   * lockup by half the scrollbar and Sign out by all of it. Three ordinary things trigger that:
   * app.js is deferred and builds #reel after the first paint, so EVERY page load goes
   * unscrolled -> scrolled in one step; navigating between a long folder and a short one does
   * the same; and player.js puts .is-modal (overflow: hidden) on this element while a clip is
   * open, which removes the scrollbar entirely. Reserving the space once answers all three,
   * because the layout is now permanently the scrolled one.
   *
   * IT MUST BE DECLARED HERE AND NOWHERE ELSE. Unlike `overflow`, this property is explicitly
   * NOT propagated from the body element to the viewport (css-overflow-3), so the same
   * declaration on `body` would reserve nothing at all for the document scrollbar and would
   * read as working.
   *
   * `stable` alone, never `stable both-edges`: both-edges adds a matching phantom gutter on the
   * edge a scrollbar never occupies, which is a second layout change rather than the absence of
   * one.
   *
   * IT IS A NO-OP EXACTLY WHERE THE PROBLEM DOES NOT EXIST. `stable` reserves space for CLASSIC
   * scrollbars only - "in the case of overlay scrollbars, no scrollbar gutter is present" - so
   * on macOS's default setting, on iOS and on Android there is no reserved strip and nothing
   * changes. An engine too old to know the property discards the declaration and keeps today's
   * behaviour; neither case can be worse than the one this replaces.
   *
   * It also holds under `overflow: hidden`, which is what makes the .is-modal case work. The
   * reserved strip stays covered while a clip is open because .player::backdrop paints
   * --player-backdrop across the whole viewport rather than only the dialog's box.
   *
   * No media query gates it, and none may: the jump is not a width-dependent behaviour, and a
   * gate would reintroduce it at whatever widths the gate excluded.
   */
  scrollbar-gutter: stable;
}

body {
  margin: 0;
  background-color: var(--surface-page);
  color: var(--text-primary);
  font-family: var(--font-display);
  font-size: var(--type-title);
  font-weight: var(--weight-regular);
  line-height: var(--leading-title);
  -webkit-font-smoothing: antialiased;
}

h1,
h2,
h3,
h4,
p,
figure,
blockquote {
  margin: 0;
}

ul,
ol {
  margin: 0;
  padding: 0;
}

img,
svg,
video,
canvas {
  display: block;
  max-width: 100%;
}

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

/*
 * Selection is the one piece of chrome a user agent would otherwise colour for us, and its
 * default is a saturated blue. This system's chrome carries zero hue, so it is restated as
 * the primary fill with an inverse label.
 */
::selection {
  background-color: var(--fill-primary);
  color: var(--text-inverse);
}

/*
 * Form controls inherit the type only. Their border and fill are deliberately left alone: an
 * operable control that lost its boundary here and was not restyled downstream would be
 * invisible, and the design's rule is that every operable control carries a
 * --border-operable boundary. Leaving the user-agent border in place makes a missed restyle
 * look wrong rather than disappear.
 */
button,
input,
select,
textarea {
  font: inherit;
  color: inherit;
}

button {
  cursor: pointer;
}

/*
 * One focus treatment for the whole product: a 2px --focus ring at 2px offset on everything
 * operable. It is the only pure white in the system, and it is never text, never a fill and
 * never a border on its own. Never remove this without putting both a border change and a
 * ring back in its place - that still binds for the keyboard path, which is the path this
 * rule now exists for.
 *
 * IT PAINTS ONLY UNDER KEYBOARD MODALITY. shared/focus.js states which input put focus where
 * it is, as data-pointer on <html>, and this rule is gated on the negation of it: the ring is
 * present whenever the attribute is ABSENT, which is the keyboard state AND the state of a
 * page with scripting off. Absence means paint, so the fail direction is the safe one - a
 * product that never loads the script keeps every indicator it had.
 *
 * :where() CONTRIBUTES ZERO SPECIFICITY, and that is the load-bearing part rather than a
 * style preference. This rule keeps exactly the (0,1,0) it had, so everything it competes
 * with resolves as it did before: .admin-row--drag's lift outline, .wv-frame's inside-drawn
 * offset, the focus sinks and the error borders all still win or lose the same contests.
 *
 * ==== THE TWO RULES ARE A PAIR. NEVER SIMPLIFY THEM BACK TO ONE. ====
 *
 * GATING A RULE OFF DOES NOT MEAN NOTHING PAINTS. IT MEANS THE NEXT ORIGIN PAINTS, and for
 * `outline` that origin is the user agent's own :focus-visible rule, which in Chrome is
 * `outline: auto 1px -webkit-focus-ring-color`. An author declaration of any specificity beats
 * the UA origin, so while THIS rule matched it was also, silently, the thing holding the UA
 * ring off the whole product. The first cut of v0.14.1 shipped only the negated rule above and
 * was MEASURED in a real browser: one setAttribute('data-pointer','true'), with focus never
 * touched, flipped the computed outline from `solid 2px rgb(255,255,255) @2px` to
 * `auto 1px rgb(16,16,16) @0` - Chrome's two-tone ring, whose OUTER BAND IS PURE WHITE at
 * 18.10:1 against this surface. The operator would have seen the same white box, and the
 * player would have REGRESSED against v0.14.0, whose .player[data-pointer="true"] rule was a
 * real `outline: none` that beat the UA. No product-wide outline reset exists in any of the
 * seven sheets to fall back on; the companion rule below IS that reset, scoped to the pointer
 * state.
 *
 * THE POSITIVE-FORM RULE MUST KEEP ITS :where() WRAPPER. Written bare, as
 * html[data-pointer="true"] :focus-visible, it is (0,2,1) - and .admin-row--drag's lift
 * outline is (0,1,0), so the suppression would win and the drag affordance would vanish with
 * nothing reporting it. That is the override the plan rejected by name. Inside :where() it is
 * (0,1,0), equal to the drag rule, which lives in admin-ui/admin.css and therefore wins on
 * SOURCE ORDER because that sheet loads after this one. The offset-only rules (.wv-frame,
 * .admin-panel, .admin-row, .admin-chooser__cell) declare no outline at all, so their inset
 * offset simply survives with nothing to paint, and .player__stage's own outline: none is
 * (0,2,0) and untouched.
 *
 * THE BORDER-COLOUR HALVES NEED NO COUNTERPART, and adding one would be wrong. A gated-off
 * border-color falls back to the control's resting --border-operable, which is an AUTHOR
 * value on the element itself. Only `outline` has a UA declaration waiting underneath it.
 *
 * The frame inside a scrolling strip overrides the OFFSET, and only the offset - see the
 * note on .wv-frame:focus-visible in strip.css.
 */
:where(html:not([data-pointer="true"])) :focus-visible {
  outline: 2px solid var(--ring-focus);
  outline-offset: 2px;
}

:where(html[data-pointer="true"]) :focus-visible {
  outline: none;
}

/* ==== Type role classes ==============================================================
 * Ported from the design system's typography.css.
 *
 * EACH ONE CARRIES FAMILY, SIZE, CASE, TRACKING AND FIGURE TREATMENT TOGETHER. Style from
 * these, never from --font-display / --font-mono alone: a rule that sets only the family
 * loses the tracking and the tabular figures, and it loses them silently - the text still
 * renders, in the right face, subtly wrong.
 *
 * How to tell which class a string wants: ask whether the string would change if the studio
 * renamed the thing it describes. A NAME would (Schibsted Grotesk, sentence case); a CODE
 * would not (DM Mono, uppercase, tracked, tabular).
 */

/* Codes. Durations, resolutions, aspects, frame rates, counts, frame indices, status. */
.wv-edge-code {
  font-family: var(--font-mono);
  font-weight: var(--weight-regular);
  font-size: var(--type-edge);
  line-height: var(--leading-edge);
  letter-spacing: var(--tracking-edge);
  text-transform: uppercase;
  color: var(--text-meta);
  font-feature-settings: var(--feature-caps);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* A clip label. Two lines, then clip: a long name must never reflow a row or change a
 * frame's height, so the clamp is a constraint and not a nicety. */
.wv-title {
  font-family: var(--font-display);
  font-weight: var(--weight-medium);
  font-size: var(--type-title);
  line-height: var(--leading-title);
  letter-spacing: var(--tracking-title);
  color: var(--text-primary);
  max-width: var(--measure-title);
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
}

/* A client header. One of exactly three uppercase runs in the product. */
.wv-client {
  font-family: var(--font-display);
  font-weight: var(--weight-medium);
  font-size: var(--type-client);
  line-height: var(--leading-client);
  letter-spacing: var(--tracking-client);
  text-transform: uppercase;
  color: var(--text-primary);
  font-feature-settings: var(--feature-caps), var(--feature-tnum);
}

/*
 * The studio name. The second of the three uppercase runs, and the only place --weight-bold
 * is spent.
 *
 * Both surfaces standardised on .wv-wordmark in v0.4.0, so the bare .wordmark selector this
 * rule used to carry alongside it was dropped - it had no consumer left in any markup. The
 * rule it named never had a second body, which is what made removing one selector safe.
 */
.wv-wordmark {
  font-family: var(--font-display);
  font-weight: var(--weight-bold);
  font-size: var(--type-wordmark);
  line-height: var(--leading-wordmark);
  letter-spacing: var(--tracking-wordmark);
  text-transform: uppercase;
  color: var(--text-primary);
  font-feature-settings: var(--feature-caps);
}

/* The only continuous prose in the product: one line on the login gate, one in an empty
 * state. */
.wv-prose {
  font-family: var(--font-display);
  font-weight: var(--weight-regular);
  font-size: var(--type-title);
  line-height: var(--leading-title);
  color: var(--text-primary);
  max-width: var(--measure-prose);
}

/* ==== Marks and glyphs ================================================================ */

/*
 * The studio mark.
 *
 * AC-061: swapping it is dropping a real .svg at web/assets/logomark.svg. This url() is the
 * only reference to that file anywhere in the project, and it is a background rather than an
 * <img> so an absent file renders as nothing instead of a broken-image glyph.
 *
 * The path is relative to THIS stylesheet, which puts the mark one level up from shared/ in
 * an assets/ folder - the layout the published bundle has. A surface that serves shared/
 * from somewhere else has to serve web/assets/ at ../assets/ from it.
 *
 * It lives in base.css rather than a component sheet because the mark appears on the login
 * page too, and the login page does not load strip.css.
 */
.logomark {
  display: inline-block;
  flex: none;
  width: var(--space-4);
  height: var(--space-4);
  background-image: url("../assets/logomark.svg");
  background-repeat: no-repeat;
  background-position: center;
  background-size: contain;
}

/*
 * The full lockup: the mark stacked over the wordmark, as one square piece of artwork.
 *
 * It is a background rather than an <img> for the same reason the mark is - an absent file
 * renders as nothing instead of a broken-image glyph, and this sits on the login card, which
 * is the one page a client sees before they are signed in.
 *
 * Square by construction (the artwork is a 512 viewBox), so one dimension sets both. It
 * carries the studio name INSIDE the artwork, which is why the element that uses it supplies
 * an accessible name instead of leaving a decorative aria-hidden span.
 */
.brandlogo {
  display: block;
  width: var(--space-16);
  height: var(--space-16);
  margin-inline: auto;
  background-image: url("../assets/logo.svg");
  background-repeat: no-repeat;
  background-position: center;
  background-size: contain;
}

/*
 * The icon box: 24px, geometric, 1.5px stroke. The stroke width, the stroke colour and the
 * absence of a fill are ATTRIBUTES on the inlined <svg> itself, because the published site
 * ships zero third-party JavaScript and therefore no icon library to configure.
 *
 * The rule matches whether the class sits on a wrapper span or on the <svg> directly, so a
 * surface can inline the glyph either way and get the same box.
 *
 * An icon is always decorative and never carries meaning by colour: it is --ink, or
 * --ink-muted when its control is disabled, and it is never any other value.
 */
.wv-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: 24px;
  height: 24px;
}

.wv-icon svg {
  display: block;
  width: 100%;
  height: 100%;
}

/* The one filled glyph in the whole set. Every other icon is stroke-only, which is why this
 * is stated once here rather than left to each surface to remember. */
.wv-icon-play svg,
svg.wv-icon-play {
  fill: currentColor;
}

/*
 * THE ONE INDETERMINATE-BUSY MARK IN THE PRODUCT, and the only thing left in it that animates
 * to say "working".
 *
 * A 270-DEGREE ARC, NOT A FULL CIRCLE. The gap is the whole point: a full circle rotating about
 * its own centre is pixel-identical in every frame, so it shows no motion at all. The arc lives
 * in the glyph's `d` literal rather than in a stroke-dasharray, so the three icon factories that
 * draw it (web/assets/player.js, admin-ui/admin.js, admin-ui/stats.js) need no per-glyph
 * attribute and stay what they are: a list of `d` strings.
 *
 * ONLY A TRANSFORM ANIMATES, so the mark stays on the compositor and no frame repaints. The
 * class lands on the outer <svg>, which in HTML is a replaced element with a border box, so the
 * ordinary CSS default `transform-origin: 50% 50%` is its own centre and the arc turns about
 * itself. An inner <path> or <g> would take the SVG default of `0 0` and swing about the corner.
 *
 * ONE DURATION LITERAL AND ONE FACTOR. Reduced motion multiplies the factor and changes nothing
 * else, so the two states cannot drift into two independent numbers. See the reduced-motion
 * block at the foot of this file for why it is doubled rather than stopped.
 *
 * THE STROKE IS ONE UNIT, which renders 2 CSS px in the player's 48px plate - the weight of the
 * border this replaced and of the focus ring - and one hairline in a 24px box, which is the
 * weight every other boundary in the system is drawn at.
 */
.wv-spinner {
  --wv-spin-base: 900ms;
  --wv-spin-factor: 1;
  animation: wv-spin calc(var(--wv-spin-base) * var(--wv-spin-factor)) linear infinite;
  stroke-width: 1;
}

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

/*
 * Reduced motion. The design's one orchestrated moment is the row settle, and this removes it
 * along with every transition. Durations collapse to 1ms rather than to none so that
 * animationend and transitionend still fire and no script waits forever for an event that was
 * silently deleted.
 *
 * strip.css redefines the wv-settle keyframes to a no-op under the same query rather than
 * setting animation: none, for the same reason.
 *
 * ==== THE BUSY MARK IS EXCLUDED FROM THE RESET, AND THAT IS A DEFECT REPORT, NOT A PREFERENCE
 *
 * `animation-iteration-count: 1` collapses an INFINITE rotation to a single 1ms turn, which
 * leaves the buffering mark frozen mid-animation - a static shape sitting over a clip that is
 * still loading, saying nothing. It was reported from a machine where Windows reports
 * SystemParametersInfo(SPI_GETCLIENTAREAANIMATION) as off, which is the exact parameter Chromium
 * reads for this query and which Windows Server, RDSH and VDI ship off by default. That is a
 * real viewer population, not one machine.
 *
 * SO IT RUNS AT DOUBLE DURATION RATHER THAN STOPPING. A progress indicator that has stopped
 * turning reads as a failure; every source on this converges on slowing indeterminate motion
 * rather than freezing it, and the one thing none of them recommends is stopping it mid-turn,
 * which is exactly what an iteration count of 1 does.
 *
 * IT IS AN EXCLUSION FROM THE RESET, NOT AN OVERRIDE OF IT. The selector below never matches the
 * spinner at all, so .wv-spinner's own rule needs no !important and cannot lose a cascade fight.
 * Adding :not() raises this rule from (0,0,0) to (0,1,0) and it already carries !important, so
 * nothing it wins today can be lost.
 *
 * THE PSEUDO-ELEMENT SELECTORS CARRY THE SAME :not(), and they have to: written bare they would
 * still collapse the spinner's OWN generated content. Nothing draws a ::before on it today, and
 * a selector that is correct only because of what nobody has written yet is not correct.
 *
 * EVERYTHING ELSE IS STILL CAUGHT. The gallery's row settle and per-cell reveal are not the
 * spinner and are not excluded here, so both are still neutralised by this rule AND by
 * strip.css's own keyframe redefinitions - two mechanisms, both unchanged.
 */
@media (prefers-reduced-motion: reduce) {
  *:not(.wv-spinner),
  *:not(.wv-spinner)::before,
  *:not(.wv-spinner)::after {
    animation-duration: 1ms !important;
    animation-delay: 0ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 1ms !important;
    transition-delay: 0ms !important;
  }

  .wv-spinner {
    --wv-spin-factor: 2;
  }

  :root {
    scroll-behavior: auto !important;
  }
}
