/**
 * PRODUCTS — the product experiences, their schematics and their modals.
 *
 * A page-scoped layer, loaded after motion.css. Every class is prefixed `.prod-` and
 * nothing here contests motion's ownership of [data-hover]'s `transition` shorthand:
 * the rows still hover through the shared vocabulary, this file only adds what the
 * shared vocabulary has no word for.
 *
 * ── THE ONE RULE THE WHOLE FILE OBEYS ───────────────────────────────────────
 *
 * The RESTING state of every schematic is the FINISHED diagram. Animations run *from*
 * an empty state *to* the resting one, never the other way round. Three failure modes
 * disappear at once:
 *
 *   no JavaScript   — html[data-js] is never set, [data-reveal-state] never appears,
 *                     no animation ever runs, and the visitor gets a complete, static,
 *                     correct diagram. An entrance must never be able to leave content
 *                     permanently invisible; that rule applies to drawings too.
 *   reduced motion  — the duration tokens collapse to 0ms at source, so every animation
 *                     resolves in a single frame onto the resting state. The explicit
 *                     @media block at the foot of this file is the belt to that braces.
 *   a CSS mistake   — a typo in a keyframe costs the animation, not the diagram.
 *
 * ── THE TRIGGER ─────────────────────────────────────────────────────────────
 *
 * Scroll triggering reuses site.js's IntersectionObserver rather than adding a second
 * one: each product row is a [data-reveal="rise"] target, so it already gains
 * [data-reveal-state="running"] when it enters the viewport, and every animation below
 * is gated on that attribute. A schematic therefore never draws itself while it is
 * still below the fold.
 *
 * The modal copies of the same schematics have no reveal ancestor — a dialog is not
 * scrolled into view, it is opened — so `.prod-modal[open]` is the second gate. Both
 * live in one :is() so the animation is declared once and the two triggers cannot
 * drift apart.
 *
 * Timings are expressed in --duration-* throughout, including the long POS loop
 * (a multiple of --duration-deliberate). That is what makes reduced motion work at the
 * source rather than as an exception.
 */

/* ════════════════════════════════════════════════════════════════════════════
   1 · THE EXPERIENCE ROW
   ════════════════════════════════════════════════════════════════════════════

   Full-bleed bands, alternating side and ground. Alternation is not decoration: three
   products rendered identically can only assert that they are different kinds of
   system, and the eye crossing the measure between rows is what makes them read as
   three rooms instead of one list. */

.prod-row {
  position: relative;
  padding-block: clamp(var(--space-8), 9vw, var(--space-10));
  border-top: 1px solid var(--border-subtle);
}

.prod-row--flip { background: var(--surface-raised); }

.prod-row__inner {
  display: grid;
  gap: clamp(var(--space-6), 5vw, var(--space-9));
  align-items: center;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 400px), 1fr));
}

/* The side swap is gated behind the two-column breakpoint on purpose. Once the grid
   has collapsed there is only one correct order — the product's name and its status
   before the picture of it — and `order` would put the diagram above the headline. */
@media (min-width: 1001px) {
  .prod-row--flip .prod-panel { order: -1; }
}

.prod-row__copy {
  display: flex;
  flex-direction: column;
  gap: var(--space-5);
  align-items: flex-start;
}

.prod-row__meta {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  flex-wrap: wrap;
}

.prod-row__title {
  font-size: var(--text-2xl);
  letter-spacing: var(--track-2xl);
  line-height: var(--leading-display);
}

.prod-row__prose { color: var(--text-secondary); max-width: 56ch; }
.prod-row__prose p + p { margin-top: var(--space-3); }

.prod-links {
  display: flex;
  gap: var(--space-4) var(--space-5);
  flex-wrap: wrap;
  align-items: center;
}

/* products.js unhides this only where <dialog> genuinely works. `.btn` sets a display,
   which outranks the UA rule for [hidden] — without this the fallback is a button that
   does nothing, which is worse than no button. */
.prod-explore[hidden] { display: none !important; }

/* ── The commercial block ─────────────────────────────────────────────────────
   Same figures as the card version carried, given the room the card could not. The
   price is the only number on this page and it is set at display weight, because a
   price a reader has to hunt for is a price they assume is bad news. */

.prod-price {
  width: 100%;
  padding-top: var(--space-4);
  border-top: 1px solid var(--border-subtle);
}

.prod-price__flag { color: var(--accent-interactive); margin-bottom: var(--space-2); }

.prod-price__line {
  margin: 0;
  display: flex;
  align-items: baseline;
  gap: var(--space-3);
  flex-wrap: wrap;
}

.prod-price__was {
  color: var(--text-muted);
  text-decoration: line-through;
  font-size: var(--text-sm);
}

.prod-price__now {
  font-family: var(--font-display);
  font-weight: var(--weight-displayHeavy);
  font-size: var(--text-2xl);
  letter-spacing: var(--track-2xl);
  line-height: 1;
}

.prod-price__unit { color: var(--text-secondary); font-size: var(--text-sm); }

.prod-price__note {
  margin-top: var(--space-2);
  color: var(--text-secondary);
  font-size: var(--text-sm);
  max-width: 46ch;
}

/* ════════════════════════════════════════════════════════════════════════════
   2 · THE PANEL
   ════════════════════════════════════════════════════════════════════════════

   The frame the schematic sits in. A dot lattice rather than a solid ground, because
   the panel has to read as drawing paper — the moment it reads as a window, the thing
   inside it starts being mistaken for an interface, which is the one thing this page
   is not allowed to imply. */

.prod-panel {
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  min-width: 0;
}

.prod-panel__stage {
  position: relative;
  padding: clamp(var(--space-3), 2.4vw, var(--space-5));
  border: 1px solid var(--border-default);
  border-radius: var(--radius-lg);
  background-color: var(--surface-raised);
  background-image: radial-gradient(circle at 1px 1px, var(--border-subtle) 1px, transparent 0);
  background-size: 18px 18px;
  overflow: hidden;
}

/* On a raised band the panel would otherwise be the same material as its own ground. */
.prod-row--flip .prod-panel__stage { background-color: var(--surface-base); }

.prod-panel__caption {
  font-size: var(--text-xs);
  color: var(--text-muted);
  line-height: var(--leading-snug);
  max-width: 56ch;
}

/* The in-development marker. Deliberately loud enough to survive skimming: a reader who
   takes one of these diagrams for a finished product has been misled by us, not by
   themselves. */
.prod-panel__tag {
  display: inline-block;
  margin-right: var(--space-2);
  padding: 2px 8px;
  border: 1px solid var(--border-accent);
  border-radius: 999px;
  color: var(--accent-interactive);
  font-size: var(--text-2xs);
  letter-spacing: 0.1em;
  text-transform: uppercase;
}

.prod-svg {
  display: block;
  width: 100%;
  height: auto;
  overflow: visible;

  /* One step, every stagger. Retiming a whole diagram is this line. */
  --prod-step: calc(var(--duration-normal) / 5);
}

/* ════════════════════════════════════════════════════════════════════════════
   3 · SCHEMATIC VOCABULARY
   ════════════════════════════════════════════════════════════════════════════

   Shared across all three drawings, so the three read as pages from one technical
   document rather than as three illustrations. Hairline zone frames, mono labels,
   hollow nodes, accent reserved for the thing that is actually happening. */

.prod-zone { fill: none; stroke: var(--border-default); stroke-width: 1; opacity: 0.6; }

.prod-lab {
  fill: var(--text-muted);
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: 0.12em;
}
.prod-lab--node { fill: var(--text-secondary); font-size: 10px; letter-spacing: 0.06em; }
.prod-lab--sm { font-size: 8px; }

.prod-axis { stroke: var(--border-strong); stroke-width: 1; }
.prod-stub { stroke: var(--border-strong); stroke-width: 1; }

.prod-node {
  fill: color-mix(in srgb, var(--text-primary) 4%, transparent);
  stroke: var(--border-strong);
  stroke-width: 1;
}

.prod-box {
  fill: color-mix(in srgb, var(--text-primary) 4%, transparent);
  stroke: var(--border-strong);
  stroke-width: 1;
}

.prod-bus { fill: none; stroke: var(--border-strong); stroke-width: 1; }

/* The overlay that makes a line read as carrying something. Its dashes travel; the
   line underneath does not move, so the effect is current rather than motion. */
.prod-bus-flow {
  fill: none;
  stroke: var(--accent-interactive);
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-dasharray: 5 11;
}

.prod-arrowhead { fill: none; stroke: var(--accent-interactive); stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
.prod-pulse { fill: var(--accent-decorative); }
.prod-mark { fill: var(--surface-raised); stroke: var(--accent-interactive); stroke-width: 1.5; }

/* ── Dental HMS ───────────────────────────────────────────────────────────── */

.prod-tick { fill: color-mix(in srgb, var(--text-muted) 24%, transparent); }
.prod-tick.prod-is-booked { fill: color-mix(in srgb, var(--accent-decorative) 60%, transparent); }
.prod-now { stroke: var(--accent-decorative); stroke-width: 1; opacity: 0.55; }

.prod-tooth { fill: none; stroke: var(--border-strong); stroke-width: 1; }
.prod-tooth.prod-is-charted {
  fill: color-mix(in srgb, var(--accent-decorative) 38%, transparent);
  stroke: var(--accent-decorative);
}

.prod-spark { fill: none; stroke: var(--border-strong); stroke-width: 1.5; stroke-linejoin: round; }
.prod-spark-run {
  fill: none;
  stroke: var(--accent-decorative);
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-dasharray: 6 94;
}

/* ── School ERP ───────────────────────────────────────────────────────────── */

.prod-arc {
  fill: none;
  stroke: var(--accent-interactive);
  stroke-width: 5;
  stroke-linecap: butt;
  stroke-dasharray: 92 208;
  stroke-dashoffset: calc(var(--arc-off, 0) * 1px);
}

.prod-ring-ghost {
  fill: none;
  stroke: var(--border-strong);
  stroke-width: 1;
  stroke-dasharray: 2 6;
  transform-box: view-box;
  transform-origin: 112px 127px;
}

.prod-heat { fill: var(--accent-decorative); fill-opacity: var(--h, 0.4); }
.prod-progress { fill: none; stroke: var(--accent-interactive); stroke-width: 2; stroke-linecap: round; }

/* ── Retail POS ───────────────────────────────────────────────────────────── */

.prod-screen {
  fill: color-mix(in srgb, var(--accent-decorative) 10%, transparent);
  stroke: var(--border-accent);
  stroke-width: 1;
}
.prod-key { fill: color-mix(in srgb, var(--text-primary) 8%, transparent); }
.prod-ledger-line { stroke: var(--border-strong); stroke-width: 2; stroke-linecap: round; }
.prod-ack { fill: none; stroke: var(--success); stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; }

.prod-link { stroke: var(--border-strong); stroke-width: 1; }
.prod-link-live { stroke: var(--accent-interactive); stroke-width: 2; }
.prod-link-dead { stroke: var(--text-muted); stroke-width: 2; stroke-dasharray: 4 7; opacity: 0; }
.prod-link-flow {
  fill: none;
  stroke: var(--accent-decorative);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-dasharray: 4 16;
}
.prod-break { stroke: var(--warning); stroke-width: 2; stroke-linecap: round; opacity: 0; }

.prod-state { font-family: var(--font-mono); font-size: 9px; letter-spacing: 0.14em; }
.prod-state--down { fill: var(--warning); opacity: 0; }
.prod-state--up { fill: var(--accent-interactive); }

.prod-receipt__sheet {
  fill: color-mix(in srgb, var(--text-primary) 6%, transparent);
  stroke: var(--border-strong);
  stroke-width: 1;
}
.prod-receipt__edge { fill: var(--accent-decorative); }

/* ════════════════════════════════════════════════════════════════════════════
   4 · THE ANIMATIONS
   ════════════════════════════════════════════════════════════════════════════

   Every rule below is gated on one of the two triggers. Nothing here changes a resting
   value; the resting values are declared above and these only describe how the diagram
   arrives at them. */

/* One-shot entrances — the diagram assembling itself. ─────────────────────── */

:is(.prod-row[data-reveal-state='running'], .prod-modal[open]) .prod-tick {
  transform-box: fill-box;
  transform-origin: 50% 100%;
  animation: prod-grow var(--duration-slow) var(--ease-entrance) both;
  animation-delay: calc(var(--i, 0) * var(--prod-step));
}

:is(.prod-row[data-reveal-state='running'], .prod-modal[open]) .prod-tooth,
:is(.prod-row[data-reveal-state='running'], .prod-modal[open]) .prod-node,
:is(.prod-row[data-reveal-state='running'], .prod-modal[open]) .prod-mark,
:is(.prod-row[data-reveal-state='running'], .prod-modal[open]) .prod-heat,
:is(.prod-row[data-reveal-state='running'], .prod-modal[open]) .prod-key {
  transform-box: fill-box;
  transform-origin: 50% 50%;
  animation: prod-pop var(--duration-slow) var(--ease-entrance) both;
  animation-delay: calc(var(--i, 0) * var(--prod-step));
}

/* Charted teeth land after the arch exists. Marking a tooth before there is an arch to
   mark reads as noise; a beat later it reads as a clinician working. */
:is(.prod-row[data-reveal-state='running'], .prod-modal[open]) .prod-tooth.prod-is-charted {
  animation-delay: calc(var(--i, 0) * var(--prod-step) + var(--duration-slow));
}

:is(.prod-row[data-reveal-state='running'], .prod-modal[open]) .prod-spark,
:is(.prod-row[data-reveal-state='running'], .prod-modal[open]) .prod-progress {
  animation: prod-draw-100 calc(var(--duration-deliberate) * 1.4) var(--ease-standard) both;
}

:is(.prod-row[data-reveal-state='running'], .prod-modal[open]) .prod-arc {
  animation: prod-arc-in var(--duration-deliberate) var(--ease-standard) both;
  animation-delay: calc(var(--i, 0) * var(--duration-normal));
}

/* Continuous loops — the parts of a system that never stop. ───────────────── */

:is(.prod-row[data-reveal-state='running'], .prod-modal[open]) .prod-now {
  animation: prod-sweep calc(var(--duration-deliberate) * 9) var(--ease-standard) infinite;
}

:is(.prod-row[data-reveal-state='running'], .prod-modal[open]) .prod-bus-flow {
  animation: prod-dash-16 calc(var(--duration-deliberate) * 2) linear infinite;
}

:is(.prod-row[data-reveal-state='running'], .prod-modal[open]) .prod-pulse {
  animation: prod-fall calc(var(--duration-deliberate) * 3) var(--ease-standard) infinite;
}

:is(.prod-row[data-reveal-state='running'], .prod-modal[open]) .prod-spark-run {
  animation: prod-run-100 calc(var(--duration-deliberate) * 5) linear infinite;
}

:is(.prod-row[data-reveal-state='running'], .prod-modal[open]) .prod-ring-ghost {
  animation: prod-spin calc(var(--duration-deliberate) * 40) linear infinite;
}

/* ── The offline-first cycle ──────────────────────────────────────────────────
   The Retail POS panel is the only diagram that is a story rather than a structure,
   because the product's whole argument is a sequence: the link drops, the shop keeps
   selling, the link returns, nothing was lost. One loop, one duration token, one set of
   phase percentages that every element reads from:

     0–13%   trading, synced
     13–17%  the link fails
     17–58%  offline — receipts land in the local queue
     58–63%  reconnect
     63–82%  the queue drains, oldest first
     82–100% synced again

   The FIFO drain is not drawn, it is timed: every receipt shares one keyframe set and
   differs only by delay, so the one that arrived first is necessarily the one that
   leaves first. Getting that ordering out of the timing rather than out of five
   hand-written animations is what keeps it true when the timings are retuned. */

.prod-svg--retail-pos { --loop: calc(var(--duration-deliberate) * 16); }

:is(.prod-row[data-reveal-state='running'], .prod-modal[open]) .prod-link-live,
:is(.prod-row[data-reveal-state='running'], .prod-modal[open]) .prod-state--up {
  animation: prod-online var(--loop) step-end infinite;
}

:is(.prod-row[data-reveal-state='running'], .prod-modal[open]) .prod-link-dead,
:is(.prod-row[data-reveal-state='running'], .prod-modal[open]) .prod-break,
:is(.prod-row[data-reveal-state='running'], .prod-modal[open]) .prod-state--down {
  animation: prod-offline var(--loop) step-end infinite;
}

/* Two animations, two properties, no conflict: the dashes travel continuously while the
   opacity is switched by the cycle. Expressing "current is flowing" and "the link is up"
   as separate animations is what lets the flow keep its phase across an outage. */
:is(.prod-row[data-reveal-state='running'], .prod-modal[open]) .prod-link-flow {
  animation:
    prod-dash-20 calc(var(--duration-deliberate) * 1.6) linear infinite,
    prod-online var(--loop) step-end infinite;
}

:is(.prod-row[data-reveal-state='running'], .prod-modal[open]) .prod-ack {
  animation: prod-ack var(--loop) var(--ease-standard) infinite;
}

:is(.prod-row[data-reveal-state='running'], .prod-modal[open]) .prod-receipt {
  transform-box: fill-box;
  transform-origin: 50% 50%;
  animation: prod-receipt var(--loop) var(--ease-standard) infinite both;
  animation-delay: calc(var(--i, 0) * var(--duration-slow) * 1.1);
}

/* ── Keyframes ────────────────────────────────────────────────────────────── */

@keyframes prod-grow {
  from { opacity: 0; transform: scaleY(0); }
  to   { opacity: 1; transform: none; }
}

@keyframes prod-pop {
  from { opacity: 0; transform: scale(0.6); }
  to   { opacity: 1; transform: none; }
}

/* pathLength="100" on the path makes this arithmetic instead of measurement — the same
   two numbers draw a sparkline and a 144px progress bar. */
@keyframes prod-draw-100 {
  from { stroke-dasharray: 100 100; stroke-dashoffset: 100; }
  to   { stroke-dasharray: 100 100; stroke-dashoffset: 0; }
}

@keyframes prod-run-100 {
  from { stroke-dashoffset: 0; }
  to   { stroke-dashoffset: -100; }
}

@keyframes prod-arc-in {
  from { stroke-dasharray: 0 300; }
  to   { stroke-dasharray: 92 208; }
}

@keyframes prod-dash-16 {
  from { stroke-dashoffset: 0; }
  to   { stroke-dashoffset: -16; }
}

@keyframes prod-dash-20 {
  from { stroke-dashoffset: 0; }
  to   { stroke-dashoffset: -20; }
}

@keyframes prod-sweep {
  0%        { transform: translateX(0); opacity: 0; }
  10%, 90%  { opacity: 0.55; }
  100%      { transform: translateX(390px); opacity: 0; }
}

@keyframes prod-fall {
  0%       { transform: translateY(0); opacity: 0; }
  12%      { opacity: 1; }
  84%      { opacity: 1; }
  100%     { transform: translateY(86px); opacity: 0; }
}

@keyframes prod-spin {
  from { transform: rotate(0deg); }
  to   { transform: rotate(360deg); }
}

@keyframes prod-online {
  0%   { opacity: 1; }
  13%  { opacity: 0; }
  63%  { opacity: 1; }
  100% { opacity: 1; }
}

@keyframes prod-offline {
  0%   { opacity: 0; }
  13%  { opacity: 1; }
  63%  { opacity: 0; }
  100% { opacity: 0; }
}

@keyframes prod-ack {
  0%   { opacity: 0; transform: scale(0.8); }
  13%  { opacity: 0; transform: scale(0.8); }
  70%  { opacity: 0; transform: scale(0.8); }
  76%  { opacity: 1; transform: scale(1); }
  100% { opacity: 1; transform: scale(1); }
}

@keyframes prod-receipt {
  0%   { opacity: 0; transform: translate(0, 0) scale(1); }
  17%  { opacity: 0; transform: translate(0, 0) scale(1); }
  21%  { opacity: 1; transform: translate(0, 0) scale(1); }
  63%  { opacity: 1; transform: translate(0, 0) scale(1); }
  72%  { opacity: 0; transform: translate(268px, -104px) scale(0.5); }
  100% { opacity: 0; transform: translate(268px, -104px) scale(0.5); }
}

/* ════════════════════════════════════════════════════════════════════════════
   5 · THE DETAIL MODAL
   ════════════════════════════════════════════════════════════════════════════

   A native <dialog> opened with showModal(), which brings the top layer, the inert
   background and a real focus trap for free — three things a hand-rolled overlay gets
   wrong in a way nobody notices until somebody is navigating by keyboard. */

.prod-modal {
  width: min(1060px, calc(100vw - var(--space-6)));
  max-width: none;
  max-height: min(92dvh, 920px);
  padding: 0;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-xl);
  background: var(--surface-raised);
  color: var(--text-primary);
  box-shadow: var(--elevation-3);
  overflow: hidden auto;
  overscroll-behavior: contain;
}

.prod-modal::backdrop {
  background: rgb(0 0 0 / 0.7);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
}

.prod-modal[open] { animation: prod-modal-in var(--duration-normal) var(--ease-entrance) both; }
.prod-modal[open]::backdrop { animation: prod-fade var(--duration-normal) var(--ease-standard) both; }

@keyframes prod-modal-in {
  from { opacity: 0; transform: translateY(12px) scale(0.99); }
  to   { opacity: 1; transform: none; }
}
@keyframes prod-fade { from { opacity: 0; } to { opacity: 1; } }

.prod-modal__inner {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--space-6);
  padding: clamp(var(--space-5), 4vw, var(--space-8));
}

.prod-modal__close {
  position: absolute;
  top: var(--space-4);
  right: var(--space-4);
  display: grid;
  place-items: center;
  width: 38px;
  height: 38px;
  padding: 0;
  border: 1px solid var(--border-default);
  border-radius: var(--radius-md);
  background: var(--surface-overlay);
  color: var(--text-secondary);
  cursor: pointer;
}
.prod-modal__close:hover { color: var(--accent-interactive); border-color: var(--border-accent); }
.prod-modal__close svg { width: 16px; height: 16px; }

.prod-modal__head {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  /* Clear of the close control at every width. */
  padding-right: 56px;
}

.prod-modal__title {
  font-size: var(--text-2xl);
  letter-spacing: var(--track-2xl);
  line-height: var(--leading-display);
}

.prod-modal__lede { color: var(--text-secondary); max-width: 62ch; }

.prod-modal__grid {
  display: grid;
  gap: clamp(var(--space-5), 4vw, var(--space-7));
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
  align-items: start;
}

.prod-modal__body { display: flex; flex-direction: column; gap: var(--space-5); }

/* The feature list is a ledger, not bullets: every line is a label and the reason it is
   there, which is the only format that stops a feature list becoming a wish list. */
.prod-feat { list-style: none; margin: 0; padding: 0; }

.prod-feat__item {
  display: grid;
  gap: 2px;
  padding: var(--space-3) 0;
  border-top: 1px solid var(--border-subtle);
}
.prod-feat__item:last-child { border-bottom: 1px solid var(--border-subtle); }

.prod-feat__label {
  font-family: var(--font-display);
  font-weight: var(--weight-display-medium);
  font-size: var(--text-base);
}
.prod-feat__note { color: var(--text-secondary); font-size: var(--text-sm); }

.prod-modal__notice p { font-size: var(--text-sm); }

.prod-modal__cta { display: flex; gap: var(--space-4); flex-wrap: wrap; }

/* ════════════════════════════════════════════════════════════════════════════
   6 · CAPABILITIES — glass panels
   ════════════════════════════════════════════════════════════════════════════

   These three are peers of the products above, not features of them, and the material
   is how that gets said without saying it: a tinted pane with the ground showing
   through reads as a different KIND of thing, where a fourth bordered box would only
   have read as three more products. */

.prod-cap {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  padding: var(--space-6);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-lg);
  background: var(--glass-tint);
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(1.3);
  backdrop-filter: blur(var(--glass-blur)) saturate(1.3);
  overflow: hidden;
}

/* The sheen that makes a pane read as glass rather than as a translucent rectangle. */
.prod-cap::before {
  content: '';
  position: absolute;
  inset: 0 0 auto;
  height: 1px;
  background: linear-gradient(90deg, transparent, var(--border-accent), transparent);
  pointer-events: none;
}

/* Without backdrop-filter the tint alone is nearly invisible on a raised ground, so the
   panel falls back to a solid surface rather than to something half-legible. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .prod-cap { background: var(--surface-overlay); }
}

.prod-cap__icon { background: color-mix(in srgb, var(--accent-decorative) 10%, transparent); }

/* The glyph draws itself. 220 comfortably exceeds the length of every path in the icon
   set, so one number covers all of them; overshooting only means the dash pattern has
   slack, where undershooting would leave a stroke permanently cut short. */
.prod-cap__glyph path { stroke-dasharray: 220; stroke-dashoffset: 0; }

.prod-caps[data-reveal-state='running'] .prod-cap__glyph path {
  /* --phase-delay is set on the card by site.js's stagger and inherits down to the
     path, so the glyph draws in time with the panel it lives in. */
  animation: prod-trace calc(var(--duration-deliberate) * 1.6) var(--ease-standard) both;
  animation-delay: var(--phase-delay, 0ms);
}

/* Declared after the reveal rule deliberately: the two selectors weigh the same, so
   source order is what lets a hover re-draw a glyph that has already arrived. */
.prod-cap:hover .prod-cap__glyph path {
  animation: prod-trace calc(var(--duration-deliberate) * 1.2) var(--ease-standard) both;
}

@keyframes prod-trace {
  from { stroke-dashoffset: 220; }
  to   { stroke-dashoffset: 0; }
}

/* ════════════════════════════════════════════════════════════════════════════
   7 · HOME-PAGE MINIATURES
   ════════════════════════════════════════════════════════════════════════════

   The same three system ideas at thumbnail size, on the home page cards. Static by
   default and deliberately so: a home page with three looping diagrams on it is a
   fairground. One accent element per card moves on hover, which is enough to say
   "there is more of this on the other page" and no more. */

.prod-mini {
  display: block;
  width: 100%;
  height: auto;
  /* Capped rather than left to the aspect ratio: on a 380px card the natural height is
     most of a paragraph, and the diagram is a footnote to the summary, not a rival to
     it. preserveAspectRatio does the letterboxing. */
  max-height: 116px;
  margin-block: calc(var(--space-1) * -1);
  overflow: visible;
}

.prod-mini__quiet { fill: none; stroke: var(--border-strong); stroke-width: 1.5; stroke-linecap: round; }
.prod-mini__solid { fill: color-mix(in srgb, var(--text-muted) 26%, transparent); }

.prod-mini__charted {
  fill: color-mix(in srgb, var(--accent-decorative) 32%, transparent);
  stroke: var(--accent-decorative);
}

.prod-mini__arc {
  fill: none;
  stroke: var(--border-strong);
  stroke-width: 4;
  stroke-linecap: butt;
  stroke-dasharray: 92 208;
  stroke-dashoffset: calc(var(--arc-off, 0) * 1px);
}

/* The one live element. Each card animates a different property because each product's
   idea is a different property: a slot booking, a year turning, a link recovering. */
.prod-mini__live {
  fill: none;
  stroke: var(--accent-interactive);
  stroke-width: 2;
  stroke-linecap: round;
}
.prod-mini__live--fill { fill: var(--accent-decorative); stroke: none; }
.prod-mini__ring { stroke-width: 1.5; stroke-dasharray: 4 6; }
.prod-mini__link { stroke-dasharray: 4 6; }

.card--product:hover .prod-mini__live--dash,
.card--product:focus-visible .prod-mini__live--dash {
  animation: prod-dash-20 calc(var(--duration-deliberate) * 1.4) linear infinite;
}

.card--product:hover .prod-mini__live--pulse,
.card--product:focus-visible .prod-mini__live--pulse {
  transform-box: fill-box;
  transform-origin: 50% 50%;
  animation: prod-mini-pulse calc(var(--duration-deliberate) * 2) var(--ease-standard) infinite;
}

@keyframes prod-mini-pulse {
  0%, 100% { opacity: 1; transform: none; }
  50%      { opacity: 0.45; transform: scale(0.82); }
}

/* ════════════════════════════════════════════════════════════════════════════
   8 · HELIOSYNC SCHOOLOS — the product page
   ════════════════════════════════════════════════════════════════════════════

   Added to this sheet rather than given one of its own on purpose. A new stylesheet
   means a new entry in the $sheets array in functions.php, and that array's order is
   load-bearing — every file added to it is another chance to put something after
   motion.css that should have been before it. Everything below is `.sos-` prefixed,
   uses the schematic vocabulary already declared in §3, and contests nothing.

   ── THE TRIGGER ─────────────────────────────────────────────────────────────

   The product page has no `.prod-row`, so §4's two gates cannot reach it. It gets a
   third: `.sos-fig`, which is the [data-reveal="settle"] <figure> the schematic sits
   in, so site.js's existing IntersectionObserver arms it and no second observer is
   needed. Three gates rather than one shared :is() because the two in §4 are declared
   against elements this page does not have, and widening those selectors would make a
   products-page edit able to break a schematic on a page nobody was looking at.

   The rule from the head of this file still holds without exception: the RESTING state
   of every drawing below is the finished diagram. With JavaScript off, [data-reveal-
   state] never appears, nothing animates, and the visitor gets a complete schematic. */

/* ── The pull line ────────────────────────────────────────────────────────────
   One sentence in the whole page set at display weight. It is the page's thesis —
   "an ERP can manage a school, SchoolOS is designed to connect one" — and a thesis
   inside body copy is a sentence a skimming reader never meets. The accent rule on
   the inline edge does the pointing; the type stays the body face at one step up,
   because a pull quote in the display face would read as a testimonial, and this is
   an argument the company is making rather than one it is quoting. */

.sos-pull {
  margin-block: var(--space-5);
  padding-left: var(--space-5);
  border-left: 2px solid var(--border-accent);
  color: var(--text-primary);
  font-size: var(--text-lg);
  line-height: var(--leading-snug);
  max-width: 34ch;
}

/* ── The core node ────────────────────────────────────────────────────────────
   The single filled object in the ecosystem drawing, because it is the single thing
   the section is arguing exists. color-mix against the accent token rather than a
   literal, so the light theme's bronze substitution carries through it. */

.sos-core {
  fill: color-mix(in srgb, var(--accent-decorative) 12%, transparent);
  stroke: var(--border-accent);
  stroke-width: 1;
}

.sos-core__mark { fill: var(--text-muted); font-size: 8px; letter-spacing: 0.18em; }

.sos-core__name {
  fill: var(--accent-interactive);
  font-family: var(--font-display);
  font-weight: var(--weight-display-medium);
  font-size: 15px;
  letter-spacing: 0.06em;
}

/* The record spine's stop labels. A shade smaller than .prod-lab and with the
   tracking pulled in, because twenty characters at 0.12em runs off a 480-unit
   viewBox on the longest stop and a diagram that clips is a diagram that lies. */
.prod-lab--stop { font-size: 8.5px; letter-spacing: 0.06em; }

/* A 480-unit viewBox in a phone-width column renders at roughly 0.65 scale, which puts
   an 8.5px label at 5.5 real pixels — present, and not readable. Every other label in
   these drawings is boxed by a node it has to fit inside and so cannot grow; the stop
   labels are bounded only by the viewBox edge, so they are the ones with headroom.

   11px is the ceiling, not a preference: the longest stop is "CLASS · SECTION ·
   SUBJECTS", it measures 145.7 units at 8.5px, and 12px would put its left edge past
   x=0. The list of ten stages sits directly under this figure on a narrow viewport and
   carries the same content in body type, so the diagram is not the only copy of it —
   this is legibility, not a load-bearing fix. */
@media (max-width: 700px) {
  .prod-lab--stop { font-size: 11px; }
}

/* ── The connected section's two columns ──────────────────────────────────────
   The diagram is the claim and the ten stages are its evidence, so on a wide
   viewport the diagram stays put while the evidence scrolls past it. Sticky rather
   than a fixed height: the figure is shorter than the list by design, and any
   attempt to match them would either stretch the drawing or truncate the argument. */

.sos-connect {
  display: grid;
  gap: clamp(var(--space-6), 5vw, var(--space-9));
  align-items: start;
  margin-top: var(--space-8);
}

@media (min-width: 1001px) {
  .sos-connect { grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); }
  .sos-connect__fig {
    position: sticky;
    /* Clear of the fixed header, plus a breath, so the caption is never tucked
       under the nav on a mid-scroll pause. */
    top: calc(var(--header-h) + var(--space-5));
  }
}

/* ── The journey ──────────────────────────────────────────────────────────────
   Numbered because the order is the content: these are not ten features, they are
   ten positions on one path, and a bulleted list would lose the only thing being
   claimed. The rail is drawn on the list rather than per item so it is continuous
   through the gaps — the record does not stop between stages either. */

.sos-journey {
  list-style: none;
  margin: 0;
  padding: 0;
  position: relative;
}

.sos-journey__item {
  position: relative;
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  gap: var(--space-4) var(--space-5);
  padding-block: var(--space-5);
  border-top: 1px solid var(--border-subtle);
}
.sos-journey__item:last-child { border-bottom: 1px solid var(--border-subtle); }

.sos-journey__index {
  font-family: var(--font-mono);
  font-size: var(--text-sm);
  color: var(--accent-interactive);
  min-width: 3ch;
  /* Optically aligned with the title's cap height rather than its box. */
  padding-top: 2px;
}

.sos-journey__title {
  font-size: var(--text-lg);
  letter-spacing: var(--track-xl);
  line-height: var(--leading-snug);
}

.sos-journey__body {
  margin-top: var(--space-2);
  color: var(--text-secondary);
  font-size: var(--text-sm);
  max-width: 60ch;
}

/* ── The module lists ─────────────────────────────────────────────────────────
   The screens inside each domain, set in the mono face at label size. Two reasons
   it is mono rather than a bulleted list in body type: sixteen items under
   "Academics" as a bulleted column is a wall, and the mono face is the same one the
   schematic labels use — so a reader crossing from a diagram to a list stays in the
   same document. The dot separators are generated, so the markup is a real <ul> and
   a screen reader still hears a list of sixteen things. */

.sos-modules {
  list-style: none;
  /* `auto` on a flex-column card floors the list, so eleven cards of unequal copy
     length still line their rules up across a row. Without it the hairline sits
     wherever the note happened to end and the grid reads as ragged. */
  margin: auto 0 0;
  padding: var(--space-4) 0 0;
  border-top: 1px solid var(--border-subtle);
  display: flex;
  flex-wrap: wrap;
  gap: 4px var(--space-3);
}

.sos-modules__item {
  position: relative;
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.04em;
  color: var(--text-muted);
  white-space: nowrap;
}

/* The separator sits in the gap, not in the item, so a wrapped line never begins
   with an orphaned dot. */
.sos-modules__item + .sos-modules__item::before {
  content: '';
  position: absolute;
  left: calc(var(--space-3) / -2);
  top: 50%;
  width: 2px;
  height: 2px;
  border-radius: 999px;
  background: var(--border-strong);
}

/* The note is the sentence that says what the domain is FOR, and it has to win against
   sixteen mono module names sitting under it. Body colour and body size are already
   right; what it needs is not to be squeezed when the card stretches to match its row. */
.sos-domain .card__body { flex: 0 0 auto; max-width: 42ch; }

/* ── The portals ledger ───────────────────────────────────────────────────────
   .ledger's rows are built for links, with a meta column on the end. These rows are
   not links — there is nowhere to send a reader that is more specific than the demo —
   so the third column is dropped rather than filled with an arrow that goes nowhere. */

.sos-portals .ledger__row { grid-template-columns: auto minmax(0, 1fr); }
.sos-portals .ledger__title { font-size: var(--text-xl); letter-spacing: var(--track-xl); }

/* ── Roadmap cards ────────────────────────────────────────────────────────────
   The pill is not decoration. A reader who takes one of these three for a shipped
   feature has been misled by us, so the marker sits above the title where a skim
   cannot route around it, and it uses the site's own `planned` status vocabulary
   rather than a new one invented for this page. */

.sos-roadmap__card .status { align-self: flex-start; }

/* ── SCHEMATIC ANIMATION — gate three ─────────────────────────────────────── */

.sos-fig[data-reveal-state='running'] .prod-node,
.sos-fig[data-reveal-state='running'] .prod-mark {
  transform-box: fill-box;
  transform-origin: 50% 50%;
  animation: prod-pop var(--duration-slow) var(--ease-entrance) both;
  animation-delay: calc(var(--i, 0) * var(--prod-step));
}

.sos-fig[data-reveal-state='running'] .prod-bus-flow {
  animation: prod-dash-16 calc(var(--duration-deliberate) * 2) linear infinite;
}

/* The six spokes of the ecosystem drawing carry current at staggered phases rather
   than in lockstep. In lockstep the diagram pulses, which reads as one heartbeat;
   offset, it reads as six conversations happening at once, which is the claim. */
.sos-fig[data-reveal-state='running'] .sos-flow {
  animation-delay: calc(var(--i, 0) * var(--duration-normal) * -0.7);
}

/* The record travelling the spine. 270 units is the exact distance from the first
   stop to the last, so the mark arrives at REPORTS rather than near it — a pulse that
   overshoots the last node is a diagram disagreeing with itself. */
.sos-fig[data-reveal-state='running'] .sos-drop {
  animation: sos-drop calc(var(--duration-deliberate) * 6) var(--ease-standard) infinite;
}

@keyframes sos-drop {
  0%        { transform: translateY(0); opacity: 0; }
  8%        { opacity: 1; }
  88%       { opacity: 1; }
  100%      { transform: translateY(270px); opacity: 0; }
}

/* ════════════════════════════════════════════════════════════════════════════
   9 · REDUCED MOTION
   ════════════════════════════════════════════════════════════════════════════

   The duration tokens already collapse to 0ms at the source, so in principle nothing
   below is reachable. It is here because "in principle" is doing too much work for a
   page that carries an infinite loop: one stylesheet loaded out of order, one token
   overridden by a future component, and a visitor who asked for stillness gets a
   drawing that never stops.

   Only `animation` is switched off, and nothing is forced to a value. Every resting
   state is already the finished diagram, and `transform: none` here would fight the
   rotate() presentation attribute on the academic-year ring — a reset that repaired
   nothing and broke a drawing. */
@media (prefers-reduced-motion: reduce) {
  .prod-svg *,
  .prod-mini *,
  .prod-cap__glyph path,
  .prod-modal[open],
  .prod-modal[open]::backdrop {
    animation: none !important;
  }
}
