{
  "$schema": "https://design-tokens.org/schema.json",
  "$description": "VOID design system tokens. Dark-only, single-mode. Depth is carried entirely by the boundary of an object, never by its fill: one 1.5px absolute-black hairline per silhouette, with an asymmetric gradient pair straddling it (hard/short inside, soft/long outside). The perceptual mechanism is the Cornsweet illusion — two objects with the same flat fill read as different materials depending only on what happens in the 12px either side of their edge.",
  "meta": {
    "name": "VOID",
    "version": "1.1.0",
    "colorSpace": "srgb",
    "modes": [
      "single"
    ],
    "domain": "Monitoring, infrastructure, finance, instrumentation. A poor fit for content, commerce and marketing surfaces, which need image treatments and a color budget this system deliberately does not have."
  },
  "invariant": {
    "$description": "Not preferences. Violating any one collapses the system into a generic dark theme.",
    "I1": "The hairline is #000000, 1.5px, on every object, at every size. It never scales, never takes a color, never becomes a border-width token.",
    "I2": "The page surface is never black. Absolute black must be darker than its surround or it reads as fill rather than void.",
    "I3": "The gradient pair straddling the hairline is asymmetric. The inner (light) side is hard and short; the outer (dark) side is soft and long. Symmetric decay cancels the illusion.",
    "I4": "Material light is directional and white. It describes what an object is made of.",
    "I5": "State light is omnidirectional and tinted. It describes a property. Because an even, complete ring cannot be produced by any light position, hue can never be misread as lighting.",
    "I6": "Fills are flat. A component has one background value. All variation lives in box-shadow.",
    "I7": "Motion is evidence that something happened. Nothing animates on pointer movement, hover, or scroll.",
    "I8": "Hue is never the sole carrier of meaning. Every status ring is accompanied by a dot, a label, or both."
  },
  "ink": {
    "edge": {
      "$type": "color",
      "$value": "#000000",
      "prop": "--edge",
      "$description": "Absolute. The one value in the system that never varies, never scales and never takes a hue."
    }
  },
  "surface": {
    "$description": "Four values, lightest object on darkest page. Each step is roughly +6 per channel. Do not add intermediate steps; four surfaces is the whole ladder. If a design appears to need a fifth, it needs a different depth preset instead.",
    "s0": {
      "$type": "color",
      "prop": "--s0",
      "role": "page",
      "$value": "#101118"
    },
    "s1": {
      "$type": "color",
      "prop": "--s1",
      "role": "panel, well, nav",
      "$value": "#16181f"
    },
    "s2": {
      "$type": "color",
      "prop": "--s2",
      "role": "component",
      "$value": "#1c1e26"
    },
    "s3": {
      "$type": "color",
      "prop": "--s3",
      "role": "raised part within a component (toggle thumb)",
      "$value": "#23262f"
    }
  },
  "text": {
    "ink": {
      "$type": "color",
      "prop": "--ink",
      "role": "primary",
      "contrastOnS2": "~13:1",
      "$value": "#e7e8ee"
    },
    "ink2": {
      "$type": "color",
      "prop": "--ink-2",
      "role": "secondary",
      "$value": "#9ca0ad"
    },
    "ink3": {
      "$type": "color",
      "prop": "--ink-3",
      "role": "tertiary",
      "contrastOnS2": "~3.6:1",
      "$description": "Acceptable for captions and labels, NOT for body copy.",
      "$value": "#6f7381"
    },
    "inkDisplay": {
      "$type": "color",
      "prop": "--ink-display",
      "role": "display",
      "$description": "Heavy display type blooms on near-black, so it gets its own, dimmer ink.",
      "$value": "#d6d9e2"
    }
  },
  "light": {
    "$description": "One global light vector, unit length, written by ONE rAF spring. Rest is fixed and overhead, biased slightly left. The light never tracks the pointer — an activation (pointerdown on an interactive element, or focusin) injects velocity toward that element's screen position and the spring returns it to rest. When settled and nothing else needs animating the loop is cancelled: zero CPU at rest is a requirement, not an optimisation.",
    "ref": {
      "lx": {
        "$type": "number",
        "prop": "--lx",
        "initial": -0.22,
        "$description": "Registered @property so writes are cheap and can never land invalid. NOT transitioned — the spring is the interpolator."
      },
      "ly": {
        "$type": "number",
        "prop": "--ly",
        "initial": -0.97
      }
    },
    "rest": {
      "x": -0.22,
      "y": 0.97,
      "$description": "In a y-up coordinate space; --ly is written negated."
    },
    "spring": {
      "stiffness": 0.14,
      "damping": 0.76,
      "settleMs": 400,
      "$description": "Settles in ~400ms with one small overshoot. Tunable: 0.05/0.90 heavy and viscous; 0.35/0.55 snappy with a visible bounce."
    },
    "impulse": {
      "gainPointerdown": 0.055,
      "gainFocus": 0.03
    },
    "sleepEpsilon": 0.002
  },
  "depth": {
    "$description": "Three presets over one shadow structure. All three are box-shadow only. --decay and --bleed are RELATIVE and scale with the object; the hairline is ABSOLUTE and does not.",
    "lit": {
      "class": ".lit",
      "lift": 0.19,
      "bleed": "12px",
      "decay": "15px",
      "background": "{surface.s2}",
      "layers": [
        "0 0 0 1.5px {ink.edge} — the hairline",
        "calc(--lx * -7px) calc(--ly * -7px) --bleed rgba(0,0,0,0.7) — outer black bleed, soft and long",
        "inset calc(--lx * 1.5px) calc(--ly * 1.5px) 0 rgba(255,255,255,--lift) — hard inner contact light, the high side of the pair",
        "inset calc(--lx * 9px) calc(--ly * 9px) --decay -11px rgba(255,255,255,--lift) — soft inward decay, the illusion's falloff"
      ]
    },
    "ceramic": {
      "class": ".lit--ceramic",
      "lift": 0.26,
      "bleed": "16px",
      "decay": 0,
      "$description": "Zero interior information: the fill is one value edge to edge and all signal is at the boundary. Reads as glazed ceramic. The purest expression of the thesis, the cheapest to render, and the only preset that cannot band. Mandatory below 80px, where the decay band has no room to resolve, and correct for identity objects at any size — avatars, marks, logos."
    },
    "sunk": {
      "class": ".lit--sunk",
      "background": "{surface.s1}",
      "$description": "The pair inverted: swap which side of the hairline receives light and the object sits below the surface. This single flip supplies the entire input vocabulary — field wells, toggle tracks, progress channels, log panels, pressed buttons."
    },
    "scaleRule": {
      "decay": "≈ 0.07 × min(width, height)",
      "bleed": "≈ 0.06 × min(width, height)",
      "lift": "clamp(0.19 + 0.14 × (1 − size / 200), 0.19, 0.34)",
      "floor": "Below 80px switch to ceramic rather than continuing to compress."
    },
    "banding": {
      "$description": "Gradients from #000 to #14151a cross roughly twenty 8-bit levels over hundreds of pixels; on OLED this produces visible concentric rings. Dither is not optional polish.",
      "webgl": "mandatory ordered or hash dither at ±0.5/255, in-shader",
      "css": "one fixed feTurbulence overlay at ~2.8% opacity (.void-dither), or use .lit--ceramic, which has no gradient to band"
    }
  },
  "status": {
    "$description": "A token rather than a component, so the same signal works at a 6px nav dot and a 116px hero disc with no separate implementation. is-* sets --glow and NOTHING else — never the hairline, the black bleed, or the background.",
    "class": ".lit--status",
    "lift": 0.42,
    "$note": "0.42 against 0.19 for neutral is MATCHED LUMINANCE, not matched alpha. Tinted light reads dimmer than white at equal opacity. Do not 'fix' this by lowering it to match.",
    "ring": "inset 0 0 0 1.5px rgb(--glow / --lift) — no offset, no blur",
    "chromaCeiling": "A 1.5px band at 40% alpha against #1c1e26 is already near the perceptual limit. Saturated hues bloom on near-black displays and destroy the flatness. To make a status clearer raise --lift, never the saturation.",
    "hue": {
      "neutral": {
        "$type": "color",
        "prop": "--hue-neutral",
        "$value": "255 255 255"
      },
      "success": {
        "$type": "color",
        "prop": "--hue-success",
        "label": "healthy",
        "$value": "122 214 160"
      },
      "warning": {
        "$type": "color",
        "prop": "--hue-warning",
        "label": "degraded",
        "$value": "236 194 106"
      },
      "danger": {
        "$type": "color",
        "prop": "--hue-danger",
        "label": "failed",
        "$value": "240 138 128"
      },
      "info": {
        "$type": "color",
        "prop": "--hue-info",
        "label": "running",
        "$value": "138 180 240"
      }
    },
    "idle": {
      "class": ".is-idle",
      "glow": "{status.hue.neutral}",
      "lift": 0.1
    }
  },
  "ring": {
    "$description": "The conformal ring: a WebGL surface where the falloff field is warped by a Möbius disc automorphism w = (z − a)/(1 − conj(a)z), which maps the unit disc to itself and fixes |z| = 1. At most ONE per page — typically a hero loader. Everywhere else CSS is indistinguishable and far cheaper.",
    "consequences": [
      "The silhouette stays a mathematically exact circle while interior level sets are pushed off-centre: asymmetric falloff, undeformed shape.",
      "A constant-width band in w-space becomes a variable-width band on screen — calligraphic line weight, free.",
      "Taking the arc angle in w-space rather than z-space makes a determinate progress head accelerate and decelerate around the ring with NO hand-authored easing."
    ],
    "parameter": {
      "$description": "a is a single complex number. It is the light vector.",
      "constraint": "|a| < 1"
    },
    "modes": {
      "still": {
        "a": "0",
        "arc": 0,
        "$description": "Terminal states. Motion must stop dead so that motion unambiguously means 'in progress'."
      },
      "indeterminate": {
        "a": "0.7·(cos ωt, sin ωt)",
        "arc": 0
      },
      "determinate": {
        "a": "follows the arc head",
        "arc": 1,
        "progress": "0..1"
      }
    },
    "hardening": [
      "Cap the backing store at min(devicePixelRatio, 2), derived from clientWidth.",
      "Handle webglcontextlost: preventDefault() and remount. Mobile browsers drop contexts on backgrounding.",
      "If a context cannot be obtained, replace the canvas with a CSS .void-ring-fallback carrying the same hairline and status ring. Degrade to a static ring, never a blank box.",
      "Debounce a remount on resize at ~180ms."
    ]
  },
  "icon": {
    "$description": "An icon is an incision, not a drawing. The path is absolute black — the same ink as the hairline — and carries the same asymmetric pair: dark line, light edge offset opposite the light vector. That is what makes it read as cut into the surface rather than printed on it.",
    "grid": {
      "viewBox": 24,
      "safeMargin": 2,
      "liveArea": 20
    },
    "stroke": {
      "width": 1.5,
      "scaling": "vector-effect: non-scaling-stroke — the weight holds at 14px and at 40px, exactly as the hairline never scales with its element",
      "caps": "round",
      "joins": "round"
    },
    "angles": "0°, 45°, 90° only; arcs otherwise. Arbitrary diagonals break the family faster than any other rule.",
    "radiusLadder": [
      2,
      2.5,
      3,
      3.75,
      4.25,
      5.75,
      8.5
    ],
    "lift": {
      "$type": "number",
      "$value": 0.24,
      "prop": "--icon-lift"
    },
    "variants": {
      "incised": ".icon",
      "raised": ".icon--raised",
      "mark": ".icon--mark — separators and other non-object marks are dim strokes, not cuts"
    },
    "sizes": {
      "xs": 14,
      "sm": 16,
      "default": 20,
      "lg": 24,
      "xl": 40,
      "count": 32
    },
    "rules": [
      "Icons never take hue. Status is the ring. An icon may sit inside a tinted chip; it never becomes the tint.",
      "Never load-bearing alone. Every icon in a nav or control has a text label beside it — the set is a scanning aid, not a vocabulary.",
      "Stroke only, no fills. A filled shape is a surface, and surfaces get the depth primitive."
    ]
  },
  "component": {
    "$description": "Geometry is normative. Every component is .lit, .lit--ceramic, .lit--sunk or .lit--status plus layout.",
    "disc": {
      "base": ".lit or .lit--status",
      "geometry": "circle; ceramic below 80px",
      "notes": "Primary object. One value plus a caption."
    },
    "card": {
      "base": ".lit",
      "geometry": "radius {radius.md}, 18–20px padding"
    },
    "button": {
      "base": ".lit",
      "geometry": "37px tall, 19px radius, 0 18px padding",
      "press": "inverts the pair — no translate, no scale, so a press can never cause layout shift",
      "sizes": "xs 26 / sm 31 / md 37 / lg 44 / xl 52. Only the box and the type scale; the hairline is 1.5px in every one."
    },
    "field": {
      "base": ".lit--sunk",
      "geometry": "37px tall, 10px radius",
      "focus": "tinted omnidirectional ring"
    },
    "toggle": {
      "base": "track .lit--sunk, thumb raised",
      "geometry": "46×27, thumb 20px, travel 3.5→22.5",
      "on": "thumb takes a success ring"
    },
    "knob": {
      "base": ".lit or .lit--status",
      "geometry": "78px, indicator 2×15px",
      "notes": "The indicator is --edge — same ink as the hairline, so it reads as a cut."
    },
    "badge": {
      "base": ".lit--status",
      "geometry": "25px tall, 13px radius",
      "notes": "Always paired with a 7px dot and a word.",
      "sizes": "sm 21 / md 25 / lg 30."
    },
    "progress": {
      "base": "track .lit--sunk, fill raised",
      "geometry": "10px tall, 5px radius",
      "notes": "The fill carries its own hairline and inherits --glow."
    },
    "avatar": {
      "base": ".lit--ceramic always",
      "geometry": "circle",
      "notes": "Presence uses .lit--status."
    },
    "log": {
      "base": ".lit--sunk",
      "geometry": "radius {radius.sm}, monospace"
    },
    "breadcrumb": {
      "notes": "One trail per screen, top-left, above the title. Ancestors --ink-3, current node a span with aria-current=page in --ink. Item names truncate; ancestors never do. Mobile projects to a back control that NAMES the parent, not a bare chevron."
    },
    "menu": {
      "base": "trigger .lit, panel .lit",
      "class": ".void-menu",
      "geometry": "panel offset 8px below the trigger, radius {radius.md}, items 36px tall",
      "open": "grid-template-rows 0fr → 1fr over --t-open. The panel is the element that carries the depth preset AND the element being sized, so its hairline and black bleed are correct at every intermediate height. Never a fade, never a scale.",
      "keyboard": "Esc closes and returns focus to the trigger; ArrowDown/ArrowUp from the trigger open and land on the first/last item; Home/End; Tab closes. One menu open at a time; a pointerdown outside closes.",
      "aria": "trigger carries aria-haspopup, aria-expanded and aria-controls; single-select items are role=menuitemradio with aria-checked, and the chosen value is reported on the trigger."
    },
    "fold": {
      "base": "head .lit, body .lit--sunk",
      "class": ".void-fold",
      "geometry": "head 46px tall, radius {radius.sm}",
      "notes": "Revealed content sits BELOW the surface that revealed it — the panel is a well, not a raised card. data-fold-group makes a set behave as an accordion. The caret is the one mark permitted to rotate, because its rotation IS the state."
    },
    "rail": {
      "base": "track .lit--sunk, fill raised",
      "class": ".void-rail",
      "notes": "A readout of scroll position, not an animation of it. Takes no hue: I5 reserves tint for state, and position is not one. Writes coalesce to one rAF and are skipped below a 0.4% delta, so a settled page writes nothing."
    },
    "group": {
      "class": ".void-group",
      "notes": "A sticky header inside a .void-scroller. Unpinned it is part of the well; pinned it becomes an object and gains the boundary that says so. Driven by one IntersectionObserver per scroller — no scroll handler, no per-frame work."
    }
  },
  "radius": {
    "sm": {
      "$type": "dimension",
      "$value": "8px",
      "prop": "--r-sm"
    },
    "md": {
      "$type": "dimension",
      "$value": "14px",
      "prop": "--r-md"
    },
    "lg": {
      "$type": "dimension",
      "$value": "20px",
      "prop": "--r-lg"
    }
  },
  "type": {
    "$description": "Four roles. The display face is heavy enough to bloom on near-black, so it is capped in colour (--ink-display) and floored in size.",
    "family": {
      "display": {
        "$value": "Rammetto One",
        "prop": "--font-display",
        "weight": 400,
        "tracking": "-0.01em to -0.02em",
        "floorPx": 16,
        "color": "{text.inkDisplay}",
        "use": "headlines and headline numerals only"
      },
      "ui": {
        "$value": "Cabin",
        "prop": "--font-ui",
        "weights": [
          400,
          500,
          600
        ],
        "use": "anything you click, label, or scan"
      },
      "text": {
        "$value": "Titillium Web",
        "prop": "--font-text",
        "weight": 300,
        "floorPx": 12,
        "use": "supporting prose and captions",
        "$note": "Never on --ink-3 — 300-weight strokes vanish against low-contrast surfaces."
      },
      "mono": {
        "$value": "ui-monospace, SF Mono, Menlo",
        "prop": "--font-mono",
        "use": "machine text only: SHAs, timestamps, log lines"
      }
    },
    "tabularNums": "font-variant-numeric: tabular-nums on every value that updates in place. Proportional digits jitter horizontally, which reads as flicker against these low-contrast surfaces."
  },
  "motion": {
    "$description": "Motion is evidence that something happened (I7). Nothing animates on pointer movement, hover, or scroll. The loader is the ONLY component permitted to animate without a user action, because ongoing work is itself the event. There are exactly three kinds of motion in the system.",
    "kind": {
      "press": "The surface takes the press: the pair flips so the object stops catching light and starts catching shadow, and the black bleed goes because a pressed object casts nothing. Down is instant (0s), the release settles over 170ms — that asymmetry is what makes it a detent rather than a fade. Nothing translates, nothing scales, so a press can never cause layout shift. The incision inside a pressed surface flips sign with it.",
      "disclosure": "An object grows out of nothing. The element carrying the depth preset is the element being sized, so the hairline and the bleed are correct at every intermediate height.",
      "reveal": "--lift-scale 0 → 1: the light finding an object that was already in place. Fires once per element, never on the way back out, and is skipped entirely under reduced motion."
    },
    "thumbMs": 200,
    "fillMs": 260,
    "openMs": 220,
    "pressReleaseMs": 170,
    "revealMs": 520,
    "easing": "cubic-bezier(0.22, 1, 0.36, 1)",
    "scroll": "Scroll produces readouts, never animations: a rail that reports position and a group header that reports it has pinned. Both are discrete state, driven by IntersectionObserver or a coalesced rAF write — neither runs a loop.",
    "reducedMotion": "Suppress the impulse entirely, pin the light to rest, never start the loop, and disable transitions on toggle thumbs, progress fills, carets, panels and pins. Reveals resolve to their lit state immediately. The ceramic preset is fully correct with no motion, so the reduced-motion build is the same design rather than a degraded one."
  },
  "runtime": {
    "$description": "void.js is optional: without it the light sits at its resting vector, menus and folds hold whatever state their markup declares, and every component is still legible. It styles nothing — it moves two numbers and toggles attributes.",
    "file": "/void/void.js",
    "loop": "ONE rAF on the page. Started by an activation, cancelled the moment the light settles and no registered frame wants another, never started at all under prefers-reduced-motion.",
    "markup": {
      "[data-menu]": "> [data-menu-trigger] + .void-menu__panel",
      "[data-fold]": "> .void-fold__head + .void-fold__panel; optional data-fold-group for accordion behaviour",
      "[data-rail]": "value is an optional selector for the scroll container; empty means the document",
      "[data-scroller]": "a scroll region whose .void-group headers report pinning",
      "[data-reveal]": "adds .is-revealed once, on entering the viewport"
    },
    "api": {
      "rest": "rest(deg | {x, y}) — move the light's resting position",
      "wake": "wake() — start the loop if it is not running",
      "nudge": "nudge(el, gain) — inject an impulse toward an element",
      "registerFrame": "registerFrame(fn) — fn(dt) returns truthy to keep the loop alive, which is how a second animated component shares the one clock",
      "onLight": "onLight(fn) — called after every write of --lx / --ly",
      "onWake": "onWake(fn) / onSleep(fn) — loop lifecycle",
      "state": "state() — { running, frames, x, y }"
    }
  },
  "layout": {
    "desktop": {
      "gridTemplateColumns": "218px 1fr",
      "gridTemplateRows": "56px 1fr",
      "$description": "Header spans both columns; nav is column 1; main is column 2."
    },
    "breakpoint": {
      "860": "Multi-column splits collapse to one column.",
      "720": "Single column, three rows. Nav becomes a horizontal scroll rail of chips with group labels hidden and the active chip auto-centred. Touch targets to 44px (buttons) and 40px (nav chips). --decay: 10px and --bleed: 9px; the hairline stays 1.5px.",
      "400": "Reduce container padding only."
    },
    "requirements": [
      "viewport-fit=cover plus env(safe-area-inset-bottom) in main padding.",
      "Guard hover styles in @media (hover: hover) and neutralise them in @media (hover: none) so chips don't stick after a tap.",
      "overscroll-behavior: contain on scrollable regions."
    ]
  },
  "accessibility": {
    "hueAlone": "Never rely on hue alone (I8). The deuteranopic separation between the amber and the red at this luminance is thinner than it appears in swatches.",
    "focus": "Focus indicators must clear 3:1 against adjacent colours — use the tinted ring, not white contact light. White contact light at 0.16 over #1c1e26 cannot clear WCAG 2.4.11.",
    "touchTarget": "44px minimum on coarse pointers.",
    "toggles": "<button> with aria-checked and an aria-label."
  },
  "antiPattern": [
    {
      "dont": "Give the hairline a colour to show status",
      "why": "Breaks I1 and I5. Status goes inside the hairline."
    },
    {
      "dont": "Scale the hairline with element size",
      "why": "It's a boundary marker, not a stroke weight. Scaling turns the language from 'flat with an edge' into 'outlined'."
    },
    {
      "dont": "Make the page background black",
      "why": "Absolute black must be darker than its surround (I2)."
    },
    {
      "dont": "Use symmetric blur on both sides of the edge",
      "why": "Cancels the illusion; looks merely blurry."
    },
    {
      "dont": "Add a gradient to a fill",
      "why": "All variation lives in box-shadow (I6)."
    },
    {
      "dont": "Track the cursor with the light",
      "why": "Ambient motion during reading (I7)."
    },
    {
      "dont": "Translate or scale a button on press",
      "why": "Layout shift. The system changes material instead."
    },
    {
      "dont": "Add a fifth surface value",
      "why": "Use a different depth preset."
    },
    {
      "dont": "Raise status saturation for clarity",
      "why": "Blooms on OLED. Raise --lift instead."
    },
    {
      "dont": "Ship the default preset without dither",
      "why": "Visible banding on OLED."
    },
    {
      "dont": "Mount a WebGL context per component",
      "why": "Context limits and mobile context loss. One per page."
    },
    {
      "dont": "Use emoji or icon fonts for status",
      "why": "The ring and dot are the status vocabulary."
    },
    {
      "dont": "Fade or scale a panel open",
      "why": "A scaled hairline is not 1.5px and a faded one was never there. The panel is an object: grow it."
    },
    {
      "dont": "Animate a wrapper instead of the object",
      "why": "The shadow detaches from the shape. Size the element that carries the depth preset."
    },
    {
      "dont": "Cut an icon into a transparent control",
      "why": "An incision needs a surface. On a ghost control the icon becomes a mark in --ink-2, matching its label."
    }
  ],
  "acceptance": [
    "Two objects with identical fill values, one with the lift pair and one with the hairline alone, are perceptibly different materials.",
    "A colour picker sampling the centre of every disc on a page returns the same value.",
    "Moving the pointer across the page, without clicking, produces no visual change whatsoever.",
    "Clicking a button causes the whole page's lighting to shift and settle within ~400ms.",
    "The rAF loop is cancelled at rest — verify zero scripting activity in a performance profile between interactions.",
    "Every saturated pixel on screen belongs to a status ring or dot.",
    "Screenshotting in greyscale leaves all status still legible.",
    "The determinate loader's head visibly changes speed around the ring, with no easing function in the source.",
    "No banding visible on an OLED display at full brightness.",
    "A pressed control has the same bounding box as a resting one — the material changed and nothing else.",
    "A menu or fold that is opening has a correct 1.5px hairline around its real shape at every intermediate frame.",
    "At 375px width, all interactive targets are ≥ 44px and the nav scrolls horizontally with the active chip centred.",
    "Under prefers-reduced-motion, nothing moves and the design still reads correctly."
  ]
}
