Commit 6815967f by PLN (Algolia)

fleet color language + triangle UX redesign (impeccable pass)

ontology in models (SSOT) → generated tokens:
- models.py: OntologyTerm + ColorFamily; ROLE/LIFECYCLE/AGREE terms (color+glyph+
  label) and an authored 12-family SAMPLE-WORLD model (hue-anchored). Variation =
  OKLCH lightness ladder + ±8° hue-fan → 8 shades/family = 96 sample slots. Sample
  axis (conceptual) kept separate from the measured-register axis (ROLE_FAMILIES).
- tools/gen_tokens.py: OKLCH→sRGB (no deps) → tokens.css (armada/ui) + tokens.json
  (triangle fetches at runtime, overrides :root). Validated vs DesignTokens. New
  tide.py 'tokens' step. Never hue alone (glyph+label per term).

triangle UX (low cognitive load, clear hierarchy):
- header calm at rest: one title line, one quiet coverage line, and the agreement
  bar is now the interactive sea-state instrument (overview + legend + level filter
  in one) — replaces the duplicated chips+stack+pills.
- faceted full-text search: free terms + scopes (sample:/gig:/phrase:/take:/style:/
  name:), covers the pattern registry, with 'matched-in' hints so results never feel
  mysterious; '/' focuses, clear button, teaching empty state.
- sound chips colored by sample family (shared lexical classifier from tokens),
  glyph-paired; agreement painted from the ontology (unparsed no longer red).
- tests: 51 green (OKLCH math, shade ladder greyscale-distinctness, ontology
  integrity, unparsed≠conflict, DRY tokens contract, classifier examples).
parent aa0de028
...@@ -417,3 +417,149 @@ class PatternRegistry(BaseModel): ...@@ -417,3 +417,149 @@ class PatternRegistry(BaseModel):
n_repeated: int n_repeated: int
patterns: list[PatternEntry] = Field(default_factory=list) patterns: list[PatternEntry] = Field(default_factory=list)
tracks: list[TrackPatternSig] = Field(default_factory=list) tracks: list[TrackPatternSig] = Field(default_factory=list)
# ── THE ONTOLOGY: the fleet's shared visual vocabulary (concept → color + glyph) ─
# Principle 3 (DESIGN.md): "One language across the fleet." Every L'Armada surface
# (triangle.html, armada/ui, future tools) must paint the SAME concept the SAME way.
# So the vocabulary lives HERE, beside the data models — one machine-readable source
# of truth. tools/gen_tokens.py derives tokens.json + tokens.css from it; the design
# RATIONALE stays human-readable in armada/DESIGN.md (the two must agree).
#
# Never-Hue-Alone Rule: every term carries a glyph + label, so meaning survives in
# greyscale and is colour-blind safe. Colours are the DESIGN.md fleet palette.
class Lifecycle(str, Enum):
idea = "idea"
wip = "wip"
ready = "ready"
released = "released"
blocked = "blocked"
archived = "archived"
class OntologyTerm(BaseModel):
"""One named concept in the fleet vocabulary, with its canonical paint."""
key: str
label: str
color: str # hex — DESIGN.md fleet palette
glyph: str # greyscale-safe reinforcement (never hue alone)
description: str = ""
# Role families — an orbit's measured register group (DESIGN.md §2 Secondary).
# The MAP is per-track and derived from analysis (Measured-Register Rule); these are
# only the family identities + paint, shared by every tool that shows an orbit.
ROLE_FAMILIES = [
OntologyTerm(key="percs", label="Percs", color="#ff8c00", glyph="▰", description="kicks, snares, hats, percussion — transient, warm"),
OntologyTerm(key="bass", label="Bass", color="#7c5cff", glyph="▂", description="sub, acid, reese/wobble, bass-spine — low, deep"),
OntologyTerm(key="melodic", label="Melodic", color="#36c5f0", glyph="♪", description="leads, keys, riffs, high plucks — bright"),
OntologyTerm(key="tops", label="Tops", color="#2dd4bf", glyph="≈", description="breaks, chops, texture loops — busy, mid-high"),
OntologyTerm(key="atmos", label="Atmos", color="#8a93a6", glyph="◌", description="pads, drones, ambient, fx — diffuse, background"),
OntologyTerm(key="vox", label="Vox", color="#ff3d7b", glyph="◍", description="vocal samples and chops — human, forward"),
]
# Lifecycle status — catalog/triage/release state (DESIGN.md §2 Tertiary).
LIFECYCLE_TERMS = [
OntologyTerm(key="idea", label="Idea", color="#737373", glyph="·", description="raw / sketch"),
OntologyTerm(key="wip", label="WIP", color="#e0a82e", glyph="◐", description="in progress"),
OntologyTerm(key="ready", label="Ready", color="#5bc091", glyph="●", description="mastered, ship-ready"),
OntologyTerm(key="released", label="Released", color="#d900ff", glyph="★", description="out in the world"),
OntologyTerm(key="blocked", label="Blocked", color="#ff5252", glyph="✕", description="needs a fix before it moves"),
OntologyTerm(key="archived", label="Archived", color="#4a4a4a", glyph="▢", description="parked / superseded"),
]
# A↔C agreement (the triangle) — REUSES the lifecycle paint so a green/amber/red means
# the same thing fleet-wide. unparsed is FAINT GREY, never red: a parser miss is not a
# conflict (the cardinal rule). divergent gets a bespoke violet (wrong file link).
AGREE_TERMS = [
OntologyTerm(key="agree", label="Agree", color="#5bc091", glyph="●", description="≥60% of claimed sounds are in the score"),
OntologyTerm(key="partial", label="Partial", color="#e0a82e", glyph="◐", description="30–60% — some claims unmatched"),
OntologyTerm(key="conflict", label="Conflict", color="#ff5252", glyph="✕", description="0–30% — real partial disagreement"),
OntologyTerm(key="divergent", label="Divergent", color="#b06cff", glyph="⤬", description="zero overlap, both rich → wrong file link"),
OntologyTerm(key="no-claim", label="No-claim", color="#737373", glyph="·", description="metadata lists no parseable sound"),
OntologyTerm(key="unparsed", label="Unparsed", color="#6a6a70", glyph="?", description="WE couldn't read the score — not a conflict"),
OntologyTerm(key="empty", label="Empty", color="#4a4a4a", glyph="∅", description="neither side has parseable sounds"),
]
# Neutral surfaces + brand (DESIGN.md §2 Neutral / Primary) — the rest of the palette.
NEUTRALS = {
"surface": "#0a0a0a", "raised": "#111111", "overlay": "#171717",
"hairline": "#ffffff1f", "ink": "#e8e8ea", "mute": "#9a9aa0", "faint": "#6a6a70",
}
BRAND = {"magenta": "#d900ff", "magenta-low": "#a700d1", "magenta-deep": "#8900b3"}
# ── the scalable color LANGUAGE: families (hue) × variations (shades) ──────────
# To paint 50-100 concepts coherently we need families with systematic variations,
# not 100 hand-picked hexes. A family anchors an OKLCH hue; gen_tokens derives N
# distinguishable shades by walking lightness DOWN while fanning the hue ±SHADE_FAN
# degrees (PLN's choice) — so members read as one family yet stay separable, and the
# lightness ladder keeps them greyscale-safe. Each concept = (family, shade index)
# + its glyph + label (never hue alone). This is the SAMPLE-WORLD axis: a coherent
# AUTHORED model of sample kinds — deliberately SEPARATE from the analysis/measured
# register axis (ROLE_FAMILIES), which carries its own labels and colors.
SHADE_COUNT = 8 # shades generated per family (≥ members we expect in one)
SHADE_L = (0.80, 0.52) # OKLCH lightness ladder: lightest → darkest
SHADE_CHROMA = 0.15 # base chroma (slightly eased at the ends by gen_tokens)
SHADE_FAN = 8.0 # ± degrees of hue fan across the ladder
class ColorFamily(BaseModel):
"""A hue-anchored family; gen_tokens expands it into SHADE_COUNT shades."""
key: str
label: str
glyph: str
hue: float # OKLCH hue anchor (degrees)
chroma: float = SHADE_CHROMA
match: list[str] = Field(default_factory=list) # lexical cues → classify a sample name
description: str = ""
# Sample-world families (authored coherent model). `match` keywords let any surface
# map a sample NAME to its family lexically — legitimate here: this is the sample's
# conceptual identity, NOT a measured register claim (that's ROLE_FAMILIES, by audio).
SAMPLE_FAMILIES = [
ColorFamily(key="kick", label="Kick", glyph="●", hue=25, match=["kick", "kik", "bd", "808bd", "909", "bassdrum"]),
ColorFamily(key="snare", label="Snare", glyph="◆", hue=50, match=["snare", "sn", "sd", "clap", "cp", "rim", "rs"]),
ColorFamily(key="perc", label="Perc", glyph="▴", hue=80, match=["perc", "conga", "bongo", "tom", "clave", "shaker", "tabla", "cowbell"]),
ColorFamily(key="hat", label="Hat", glyph="✦", hue=110, match=["hat", "hh", "oh", "ch", "hihat", "cymbal", "cym", "ride", "crash"]),
ColorFamily(key="break", label="Break", glyph="≈", hue=150, match=["break", "amen", "loop", "jungle", "dnb", "jazz"]),
ColorFamily(key="pad", label="Pad", glyph="◌", hue=180, match=["pad", "drone", "choir", "string", "ambient", "atmos"]),
ColorFamily(key="keys", label="Keys", glyph="♬", hue=205, match=["key", "keys", "piano", "rhodes", "epiano", "organ", "fpiano", "qstab", "nujazz"]),
ColorFamily(key="lead", label="Lead", glyph="♪", hue=230, match=["lead", "arp", "pluck", "stab", "blip", "saw", "square"]),
ColorFamily(key="synth", label="Synth", glyph="◈", hue=260, match=["synth", "commodore", "chip", "fm", "moog", "reese"]),
ColorFamily(key="bass", label="Bass", glyph="▂", hue=290, match=["bass", "sub", "808", "acid", "wobble", "meth_bass", "ramplem", "bassline"]),
ColorFamily(key="fx", label="FX", glyph="✺", hue=320, match=["fx", "riser", "sweep", "noise", "impact", "downlifter", "uplifter", "glitch", "vinyl", "foley"]),
ColorFamily(key="vox", label="Vox", glyph="◍", hue=355, match=["vox", "vocal", "voc", "acap", "speech", "voice"]),
]
# The whole vocabulary, for gen_tokens (and anyone who needs the fleet paint).
ONTOLOGY = {
"role": ROLE_FAMILIES,
"lifecycle": LIFECYCLE_TERMS,
"agree": AGREE_TERMS,
}
# ── design tokens artifact (generated by tools/gen_tokens.py) ──────────────────
# The machine-readable fleet palette every surface loads. tokens.css for CSS-var
# consumers (armada/ui), tokens.json for runtime consumers (triangle.html fetches
# it and overrides its :root). Validated against this model on emit (DRY contract).
class TokenFamily(BaseModel):
key: str
label: str
glyph: str
base: str # the family's representative hex
shades: list[str] = Field(default_factory=list) # light → dark
match: list[str] = Field(default_factory=list)
class DesignTokens(BaseModel):
model_config = ConfigDict(populate_by_name=True)
schema_: str = Field(alias="schema")
as_of: str
neutrals: dict[str, str]
brand: dict[str, str]
role: list[OntologyTerm] = Field(default_factory=list)
lifecycle: list[OntologyTerm] = Field(default_factory=list)
agree: list[OntologyTerm] = Field(default_factory=list)
sample: list[TokenFamily] = Field(default_factory=list)
"""Fleet color language — the generated tokens must be valid, distinguishable, and
greyscale-safe (parsers-over-copy: ontology in models → tokens, tested mechanically).
gen_tokens lives in tools/, so add it to the path like the ts-types generator."""
import re
import sys
from pathlib import Path
import models as M
ROOT = Path(__file__).resolve().parent.parent.parent.parent # …/Sound/Tidal
sys.path.insert(0, str(ROOT / "tools"))
import gen_tokens as G # noqa: E402
HEX = re.compile(r"^#[0-9a-f]{6}$")
def _rellum(hexs):
"""WCAG relative luminance, for greyscale-distinctness checks."""
def lin(c):
c /= 255
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
r, g, b = (int(hexs[i:i + 2], 16) for i in (1, 3, 5))
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
# ── UT: the OKLCH→hex math + shade ladder ────────────────────────────────────
def test_oklch_hex_is_valid_and_clamped():
for L in (0.0, 0.5, 1.0):
for H in (0, 90, 200, 359):
assert HEX.match(G.oklch_hex(L, 0.15, H))
def test_shades_are_valid_and_monotonically_darker():
for fam in M.SAMPLE_FAMILIES:
base, sh = G.shades(fam)
assert HEX.match(base)
assert len(sh) == M.SHADE_COUNT
assert all(HEX.match(s) for s in sh)
lums = [_rellum(s) for s in sh]
assert lums == sorted(lums, reverse=True), f"{fam.key} ladder not light→dark"
# light and dark ends are clearly separated (greyscale-safe)
assert lums[0] - lums[-1] > 0.15, f"{fam.key} ladder too flat in greyscale"
# ── ontology integrity ───────────────────────────────────────────────────────
def test_every_ontology_term_has_glyph_and_hex():
for axis in (M.ROLE_FAMILIES, M.LIFECYCLE_TERMS, M.AGREE_TERMS):
for t in axis:
assert t.glyph, t.key
assert HEX.match(t.color), (t.key, t.color)
def test_agree_terms_cover_the_enum():
keys = {t.key for t in M.AGREE_TERMS}
assert keys == {lv.value for lv in M.AgreeLevel}
def test_unparsed_is_not_painted_like_conflict():
"""Cardinal rule: a parser miss must never look like a data conflict (red)."""
by = {t.key: t.color for t in M.AGREE_TERMS}
assert by["unparsed"] != by["conflict"]
assert by["empty"] != by["conflict"]
def test_sample_families_have_distinct_hues_and_match_cues():
hues = [f.hue for f in M.SAMPLE_FAMILIES]
assert len(set(hues)) == len(hues) # no two families share a hue
for f in M.SAMPLE_FAMILIES:
assert f.match, f"{f.key} has no lexical cues"
assert f.glyph
# ── DRY contract: emitted tokens validate against the model ──────────────────
def test_tokens_validate_against_model():
from models import DesignTokens
tok = G.build()
dt = DesignTokens.model_validate(tok)
assert len(dt.sample) == len(M.SAMPLE_FAMILIES)
# every sample slot is a valid hex
for f in tok["sample"]:
assert HEX.match(f["base"]) and all(HEX.match(s) for s in f["shades"])
def test_sample_classifier_examples():
"""The lexical sample-family rules should land obvious cases (coherent model)."""
fam = {f.key: f for f in M.SAMPLE_FAMILIES}
def classify(name):
s = name.lower()
for f in M.SAMPLE_FAMILIES:
if any(s == m or s.startswith(m) for m in f.match):
return f.key
return None
assert classify("kick") == "kick"
assert classify("808bd") == "kick"
assert classify("hats") == "hat"
assert classify("meth_bass") == "bass"
assert classify("jazz") == "break"
assert classify("superpiano") is None or classify("piano") == "keys"
...@@ -49,6 +49,13 @@ def _patterns(): ...@@ -49,6 +49,13 @@ def _patterns():
return m.OUT return m.OUT
def _tokens():
sys.path.insert(0, str(ROOT / "tools"))
import gen_tokens as t
t.main() # validates against DesignTokens on emit
return t.OUT
def _ts_types(): def _ts_types():
sys.path.insert(0, str(ROOT / "tools")) sys.path.insert(0, str(ROOT / "tools"))
import gen_ts_types as g import gen_ts_types as g
...@@ -62,6 +69,7 @@ STEPS = [ ...@@ -62,6 +69,7 @@ STEPS = [
("catalog_view", "triangle · A=score ⋈ C=metadata ⋈ B=recording (validated)", _catalog_view), ("catalog_view", "triangle · A=score ⋈ C=metadata ⋈ B=recording (validated)", _catalog_view),
("catalog", "GENERATED catalog · catalog_view ⊕ authored overlay", _catalog), ("catalog", "GENERATED catalog · catalog_view ⊕ authored overlay", _catalog),
("patterns", "pattern registry · phrases-as-things + n-gram clustering", _patterns), ("patterns", "pattern registry · phrases-as-things + n-gram clustering", _patterns),
("tokens", "fleet color language · ontology → tokens.css + tokens.json", _tokens),
("ts_types", "pydantic models → generated TypeScript (DRY)", _ts_types), ("ts_types", "pydantic models → generated TypeScript (DRY)", _ts_types),
] ]
NAMES = [n for n, _, _ in STEPS] NAMES = [n for n, _, _ in STEPS]
......
{
"schema": "design-tokens v1 (fleet color language: ontology → families × shades)",
"as_of": "2026-06-06",
"neutrals": {
"surface": "#0a0a0a",
"raised": "#111111",
"overlay": "#171717",
"hairline": "#ffffff1f",
"ink": "#e8e8ea",
"mute": "#9a9aa0",
"faint": "#6a6a70"
},
"brand": {
"magenta": "#d900ff",
"magenta-low": "#a700d1",
"magenta-deep": "#8900b3"
},
"role": [
{
"key": "percs",
"label": "Percs",
"color": "#ff8c00",
"glyph": "▰",
"description": "kicks, snares, hats, percussion — transient, warm"
},
{
"key": "bass",
"label": "Bass",
"color": "#7c5cff",
"glyph": "▂",
"description": "sub, acid, reese/wobble, bass-spine — low, deep"
},
{
"key": "melodic",
"label": "Melodic",
"color": "#36c5f0",
"glyph": "♪",
"description": "leads, keys, riffs, high plucks — bright"
},
{
"key": "tops",
"label": "Tops",
"color": "#2dd4bf",
"glyph": "≈",
"description": "breaks, chops, texture loops — busy, mid-high"
},
{
"key": "atmos",
"label": "Atmos",
"color": "#8a93a6",
"glyph": "◌",
"description": "pads, drones, ambient, fx — diffuse, background"
},
{
"key": "vox",
"label": "Vox",
"color": "#ff3d7b",
"glyph": "◍",
"description": "vocal samples and chops — human, forward"
}
],
"lifecycle": [
{
"key": "idea",
"label": "Idea",
"color": "#737373",
"glyph": "·",
"description": "raw / sketch"
},
{
"key": "wip",
"label": "WIP",
"color": "#e0a82e",
"glyph": "◐",
"description": "in progress"
},
{
"key": "ready",
"label": "Ready",
"color": "#5bc091",
"glyph": "●",
"description": "mastered, ship-ready"
},
{
"key": "released",
"label": "Released",
"color": "#d900ff",
"glyph": "★",
"description": "out in the world"
},
{
"key": "blocked",
"label": "Blocked",
"color": "#ff5252",
"glyph": "✕",
"description": "needs a fix before it moves"
},
{
"key": "archived",
"label": "Archived",
"color": "#4a4a4a",
"glyph": "▢",
"description": "parked / superseded"
}
],
"agree": [
{
"key": "agree",
"label": "Agree",
"color": "#5bc091",
"glyph": "●",
"description": "≥60% of claimed sounds are in the score"
},
{
"key": "partial",
"label": "Partial",
"color": "#e0a82e",
"glyph": "◐",
"description": "30–60% — some claims unmatched"
},
{
"key": "conflict",
"label": "Conflict",
"color": "#ff5252",
"glyph": "✕",
"description": "0–30% — real partial disagreement"
},
{
"key": "divergent",
"label": "Divergent",
"color": "#b06cff",
"glyph": "⤬",
"description": "zero overlap, both rich → wrong file link"
},
{
"key": "no-claim",
"label": "No-claim",
"color": "#737373",
"glyph": "·",
"description": "metadata lists no parseable sound"
},
{
"key": "unparsed",
"label": "Unparsed",
"color": "#6a6a70",
"glyph": "?",
"description": "WE couldn't read the score — not a conflict"
},
{
"key": "empty",
"label": "Empty",
"color": "#4a4a4a",
"glyph": "∅",
"description": "neither side has parseable sounds"
}
],
"sample": [
{
"key": "kick",
"label": "Kick",
"glyph": "●",
"base": "#df6862",
"shades": [
"#f6a4a7",
"#f59091",
"#f07e7d",
"#e66f6a",
"#d8635a",
"#c65a4e",
"#b15546",
"#995243"
],
"match": [
"kick",
"kik",
"bd",
"808bd",
"909",
"bassdrum"
]
},
{
"key": "snare",
"label": "Snare",
"glyph": "◆",
"base": "#d97230",
"shades": [
"#f4a98c",
"#f2966e",
"#eb8653",
"#e0783a",
"#d16d26",
"#bf641b",
"#aa5e1e",
"#92592a"
],
"match": [
"snare",
"sn",
"sd",
"clap",
"cp",
"rim",
"rs"
]
},
{
"key": "perc",
"label": "Perc",
"glyph": "▴",
"base": "#c18500",
"shades": [
"#e5b476",
"#dfa54f",
"#d69725",
"#c98b00",
"#b98000",
"#a87600",
"#956d00",
"#806517"
],
"match": [
"perc",
"conga",
"bongo",
"tom",
"clave",
"shaker",
"tabla",
"cowbell"
]
},
{
"key": "hat",
"label": "Hat",
"glyph": "✦",
"base": "#999900",
"shades": [
"#cac075",
"#beb550",
"#b0aa2b",
"#a19e00",
"#919300",
"#818800",
"#727c0e",
"#647028"
],
"match": [
"hat",
"hh",
"oh",
"ch",
"hihat",
"cymbal",
"cym",
"ride",
"crash"
]
},
{
"key": "break",
"label": "Break",
"glyph": "≈",
"base": "#3eab5e",
"shades": [
"#9ace94",
"#7ec67e",
"#63bc6e",
"#4ab162",
"#33a55b",
"#239857",
"#228954",
"#2d7a53"
],
"match": [
"break",
"amen",
"loop",
"jungle",
"dnb",
"jazz"
]
},
{
"key": "pad",
"label": "Pad",
"glyph": "◌",
"base": "#00af96",
"shades": [
"#77d2b7",
"#46caac",
"#00c0a2",
"#00b59a",
"#00a892",
"#009a89",
"#008b7f",
"#007a73"
],
"match": [
"pad",
"drone",
"choir",
"string",
"ambient",
"atmos"
]
},
{
"key": "keys",
"label": "Keys",
"glyph": "♬",
"base": "#00abbd",
"shades": [
"#68d1d3",
"#26c8cf",
"#00bdc9",
"#00b1c2",
"#00a4b8",
"#0095ac",
"#00869d",
"#00778a"
],
"match": [
"key",
"keys",
"piano",
"rhodes",
"epiano",
"organ",
"fpiano",
"qstab",
"nujazz"
]
},
{
"key": "lead",
"label": "Lead",
"glyph": "♪",
"base": "#00a1db",
"shades": [
"#70cceb",
"#43c1eb",
"#00b5e8",
"#00a8e1",
"#009ad5",
"#008cc6",
"#007eb2",
"#26709a"
],
"match": [
"lead",
"arp",
"pluck",
"stab",
"blip",
"saw",
"square"
]
},
{
"key": "synth",
"label": "Synth",
"glyph": "◈",
"base": "#5991ed",
"shades": [
"#8fc2fb",
"#79b4fe",
"#68a6fb",
"#5d98f3",
"#568ae6",
"#537dd3",
"#5271bd",
"#5166a1"
],
"match": [
"synth",
"commodore",
"chip",
"fm",
"moog",
"reese"
]
},
{
"key": "bass",
"label": "Bass",
"glyph": "▂",
"base": "#927fe7",
"shades": [
"#b3b6fb",
"#a8a6fc",
"#9f96f8",
"#9686ee",
"#8e79df",
"#856dcb",
"#7b63b4",
"#705c9a"
],
"match": [
"bass",
"sub",
"808",
"acid",
"wobble",
"meth_bass",
"ramplem",
"bassline"
]
},
{
"key": "fx",
"label": "FX",
"glyph": "✺",
"base": "#ba71cb",
"shades": [
"#d3acec",
"#ce99e8",
"#c787df",
"#bf78d2",
"#b46bc2",
"#a860b0",
"#99599b",
"#875485"
],
"match": [
"fx",
"riser",
"sweep",
"noise",
"impact",
"downlifter",
"uplifter",
"glitch",
"vinyl",
"foley"
]
},
{
"key": "vox",
"label": "Vox",
"glyph": "◍",
"base": "#d76797",
"shades": [
"#eda4ca",
"#eb90bd",
"#e67dae",
"#dd6d9f",
"#d0618f",
"#c0587f",
"#ad5270",
"#974f63"
],
"match": [
"vox",
"vocal",
"voc",
"acap",
"speech",
"voice"
]
}
]
}
\ No newline at end of file
...@@ -2,12 +2,18 @@ ...@@ -2,12 +2,18 @@
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>L'Armada · Triangle · Catalog Confirmation</title> <title>L'Armada · Triangle · Catalog Confirmation</title>
<style> <style>
/* Ship's Bridge tokens (armada/DESIGN.md) — magenta reserved for "the now" */ /* Ship's Bridge tokens — the FLEET vocabulary. Canonical source = armada/DESIGN.md;
these defaults are kept in sync by tools/gen_tokens.py and OVERRIDDEN at runtime
from tokens.json (fetched on load) so every L'Armada surface shares one palette.
Magenta reserved (One Voice Rule): the brand mark + the single active filter. */
:root{ :root{
--surface:#0a0a0a;--raised:#111;--overlay:#171717;--hairline:#ffffff1f; --surface:#0a0a0a;--raised:#111111;--overlay:#171717;--hairline:#ffffff1f;
--ink:#e8e8ea;--mute:#9a9aa0;--faint:#6a6a70;--magenta:#d900ff; --ink:#e8e8ea;--mute:#9a9aa0;--faint:#6a6a70;--magenta:#d900ff;
/* role families (orbit register groups) — paired with a glyph everywhere (never hue alone) */
--percs:#ff8c00;--bass:#7c5cff;--melodic:#36c5f0;--tops:#2dd4bf;--atmos:#8a93a6;--vox:#ff3d7b; --percs:#ff8c00;--bass:#7c5cff;--melodic:#36c5f0;--tops:#2dd4bf;--atmos:#8a93a6;--vox:#ff3d7b;
--agree:#5bc091;--partial:#e0a82e;--conflict:#ff5252;--divergent:#b06cff;--idle:#4a4a4a; /* A↔C agreement reuses the lifecycle-status scale so colors mean the same fleet-wide:
agree=status-ready · partial=status-wip · conflict=status-blocked · idle=status-idea */
--agree:#5bc091;--partial:#e0a82e;--conflict:#ff5252;--divergent:#b06cff;--idle:#737373;
} }
*{box-sizing:border-box} *{box-sizing:border-box}
body{margin:0;background:var(--surface);color:var(--ink);height:100vh;overflow:hidden; body{margin:0;background:var(--surface);color:var(--ink);height:100vh;overflow:hidden;
...@@ -15,30 +21,53 @@ body{margin:0;background:var(--surface);color:var(--ink);height:100vh;overflow:h ...@@ -15,30 +21,53 @@ body{margin:0;background:var(--surface);color:var(--ink);height:100vh;overflow:h
.mono{font-family:"Geist Mono",ui-monospace,SFMono-Regular,monospace} .mono{font-family:"Geist Mono",ui-monospace,SFMono-Regular,monospace}
.app{display:grid;grid-template-rows:auto auto 1fr;height:100vh} .app{display:grid;grid-template-rows:auto auto 1fr;height:100vh}
/* header */ /* header — calm at rest: one title line, one quiet coverage line, one sea-state bar */
header{padding:14px 20px 10px;border-bottom:1px solid var(--hairline)} header{padding:13px 20px 11px;border-bottom:1px solid var(--hairline)}
h1{margin:0;font-size:15px;letter-spacing:.14em;font-weight:600} .hbar{display:flex;align-items:baseline;justify-content:space-between;gap:16px}
h1 b{color:var(--magenta)} h1{margin:0;font-size:14px;font-weight:600;letter-spacing:-.01em}
.sub{color:var(--mute);font-size:12px;margin-top:2px} h1 .mk{color:var(--magenta)} /* the one magenta mark (brand) */
.chips{display:flex;gap:18px;flex-wrap:wrap;margin-top:10px;align-items:flex-end} h1 .hsub{color:var(--mute);font-weight:400;font-size:13px;margin-left:6px}
.chip{display:flex;flex-direction:column;gap:1px} .prov{color:var(--faint);font-size:11px;white-space:nowrap}
.chip .n{font-size:22px;font-weight:600;line-height:1} /* sea-state: interactive agreement bar (overview + legend + filter in one instrument) */
.chip .l{font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--faint)} .sea{margin-top:11px;display:flex;align-items:center;gap:18px;flex-wrap:wrap}
.stack{display:flex;height:9px;border-radius:5px;overflow:hidden;width:340px;margin-top:5px;border:1px solid var(--hairline)} .agbar{display:flex;height:10px;border-radius:5px;overflow:hidden;width:min(420px,52vw);
.stack i{display:block;height:100%} border:1px solid var(--hairline);cursor:default}
.agbar i{display:block;height:100%;cursor:pointer;transition:opacity .15s,flex .25s ease}
.agbar i:hover{opacity:.82}
.agbar.filtered i{opacity:.28}.agbar.filtered i.sel{opacity:1}
.aglegend{display:flex;gap:5px;flex-wrap:wrap}
.lg{display:flex;align-items:center;gap:6px;border:1px solid var(--hairline);border-radius:99px;
padding:3px 10px 3px 8px;cursor:pointer;font-size:12px;color:var(--mute);background:transparent;
transition:border-color .15s,color .15s}
.lg:hover{color:var(--ink)}
.lg.on{color:var(--ink);border-color:currentColor}
.lg .sw{width:8px;height:8px;border-radius:2px;flex:none}
.lg .gl{font-size:11px;line-height:1}
.lg b{font-variant-numeric:tabular-nums;color:var(--ink)}
.cov{margin-left:auto;color:var(--mute);font-size:12px;display:flex;gap:14px;align-items:baseline}
.cov b{color:var(--ink);font-variant-numeric:tabular-nums}
.cov .lo{color:var(--conflict)} /* the EDA gap is the red number we don't hide */
/* toolbar */ /* toolbar */
.bar{padding:10px 20px;display:flex;gap:10px;align-items:center;border-bottom:1px solid var(--hairline);flex-wrap:wrap} .bar{padding:9px 20px;display:flex;gap:10px;align-items:center;border-bottom:1px solid var(--hairline);flex-wrap:wrap}
.search{position:relative;flex:1 1 360px;max-width:620px;display:flex;align-items:center} .search{position:relative;flex:1 1 360px;max-width:640px;display:flex;align-items:center}
.search::before{content:"⌕";position:absolute;left:12px;color:var(--faint);font-size:16px;pointer-events:none} .search .ic{position:absolute;left:11px;color:var(--faint);font-size:15px;pointer-events:none}
input#q{background:var(--overlay);border:1px solid var(--hairline);color:var(--ink);border-radius:9px; input#q{background:var(--overlay);border:1px solid var(--hairline);color:var(--ink);border-radius:9px;
padding:9px 12px 9px 32px;font:inherit;font-size:14px;width:100%} padding:9px 30px 9px 32px;font:inherit;font-size:14px;width:100%}
input#q:focus{outline:none;border-color:var(--melodic)} input#q::placeholder{color:var(--faint)}
.f{background:transparent;border:1px solid var(--hairline);color:var(--mute);border-radius:99px; input#q:focus{outline:none;border-color:var(--melodic);box-shadow:0 0 0 3px #36c5f022}
padding:4px 11px;cursor:pointer;font:inherit;font-size:12px;display:flex;gap:6px;align-items:center} .qx{position:absolute;right:7px;border:none;background:transparent;color:var(--faint);cursor:pointer;
.f:hover{color:var(--ink)}.f.on{color:#000;font-weight:600} font-size:14px;padding:3px 5px;border-radius:5px;display:none}
.f .d{width:8px;height:8px;border-radius:50%} .qx:hover{color:var(--ink);background:#ffffff14}.search.has .qx{display:block}
.count{color:var(--faint);font-size:12px;white-space:nowrap} .slash{position:absolute;right:9px;color:var(--faint);border:1px solid var(--hairline);border-radius:4px;
font:500 10px/1 "Geist Mono",monospace;padding:3px 5px;pointer-events:none}
.search.has .slash{display:none}
.tg{background:transparent;border:1px solid var(--hairline);color:var(--mute);border-radius:99px;
padding:5px 11px;cursor:pointer;font:inherit;font-size:12px;display:flex;gap:6px;align-items:center;white-space:nowrap}
.tg:hover{color:var(--ink)}.tg.on{color:var(--ink);border-color:var(--melodic);background:#36c5f015}
.tg .gl{font-size:11px}
.count{color:var(--faint);font-size:12px;white-space:nowrap;display:flex;align-items:center;gap:8px}
.count b{color:var(--ink);font-variant-numeric:tabular-nums}
/* data-load banner */ /* data-load banner */
.banner{margin:16px 20px;padding:14px 16px;border:1px solid var(--conflict);border-radius:10px; .banner{margin:16px 20px;padding:14px 16px;border:1px solid var(--conflict);border-radius:10px;
...@@ -125,16 +154,42 @@ audio{width:100%;height:30px;margin-top:5px} ...@@ -125,16 +154,42 @@ audio{width:100%;height:30px;margin-top:5px}
.famh b{color:var(--melodic)} .famh b{color:var(--melodic)}
.pfilter{color:var(--magenta);border:1px solid var(--magenta);border-radius:99px;padding:1px 9px;cursor:pointer;font-size:11px;margin-right:8px} .pfilter{color:var(--magenta);border:1px solid var(--magenta);border-radius:99px;padding:1px 9px;cursor:pointer;font-size:11px;margin-right:8px}
.solofam{color:var(--faint);font-size:12px;margin-top:12px;border-top:1px solid var(--hairline);padding-top:10px} .solofam{color:var(--faint);font-size:12px;margin-top:12px;border-top:1px solid var(--hairline);padding-top:10px}
/* sample-family sound chip — color from the fleet language, glyph = never-hue-alone */
.sf{display:inline-flex;align-items:center;gap:4px;border-radius:5px;padding:1px 6px;margin:1px 3px 1px 0;
font-size:11px;border:1px solid #ffffff14;line-height:1.5;white-space:nowrap}
.sf .g{font-size:10px;opacity:.9}
.sfwrap{display:flex;flex-wrap:wrap;gap:0}
/* matched-in hint (why a row is in the results) */
.hint{font-size:10px;color:var(--faint);border:1px solid var(--hairline);border-radius:4px;
padding:0 5px;margin-left:6px;vertical-align:middle;white-space:nowrap}
mark{background:#36c5f033;color:var(--ink);border-radius:2px;padding:0 1px}
/* empty result state that teaches */
.noresult{padding:36px 20px;text-align:center;color:var(--mute)}
.noresult b{color:var(--ink);display:block;font-size:14px;margin-bottom:4px}
.noresult code{background:var(--overlay);border:1px solid var(--hairline);border-radius:5px;
padding:1px 6px;font-family:"Geist Mono",monospace;font-size:12px;color:var(--melodic);cursor:pointer}
.legendrow{font-size:11px;color:var(--faint)}
</style></head><body> </style></head><body>
<div class="app"> <div class="app">
<header> <header>
<h1>🔺 <b>TRIANGLE</b> · CATALOG CONFIRMATION</h1> <div class="hbar">
<div class="sub" id="sub">loading…</div> <h1>🔺 <span class="mk">Triangle</span><span class="hsub">catalog confirmation · score ⋈ metadata ⋈ recording</span></h1>
<div class="chips" id="chips"></div> <div class="prov mono" id="prov">loading…</div>
</div>
<div class="sea">
<div class="agbar" id="agbar" title="A↔C agreement — click a band to filter"></div>
<div class="aglegend" id="aglegend"></div>
<div class="cov mono" id="cov"></div>
</div>
</header> </header>
<div class="bar"> <div class="bar">
<span class="search"><input id="q" placeholder="search track, sound, sample, gig…" oninput="render()"></span> <span class="search">
<span id="filters"></span> <span class="ic"></span>
<input id="q" placeholder="search… try sample:kick, gig:montreuil, phrase:&quot;t . &lt;f t&gt;&quot;" oninput="render()">
<span class="slash">/</span>
<button class="qx" id="qx" title="clear search" onclick="clearSearch()"></button>
</span>
<span class="views" id="toggles"></span>
<span class="views" id="views"></span> <span class="views" id="views"></span>
<span class="count" id="count"></span> <span class="count" id="count"></span>
</div> </div>
...@@ -156,16 +211,44 @@ audio{width:100%;height:30px;margin-top:5px} ...@@ -156,16 +211,44 @@ audio{width:100%;height:30px;margin-top:5px}
<div class="drawer" id="drawer"></div> <div class="drawer" id="drawer"></div>
<script> <script>
const C={agree:'--agree',partial:'--partial',conflict:'--conflict',divergent:'--divergent',
'no-claim':'--idle',unparsed:'--conflict',empty:'--idle'};
const css=v=>getComputedStyle(document.documentElement).getPropertyValue(v).trim(); const css=v=>getComputedStyle(document.documentElement).getPropertyValue(v).trim();
const ROLE={percs:'--percs',bass:'--bass',melodic:'--melodic',tops:'--tops',atmos:'--atmos',vox:'--vox'}; let DATA=null, PAT=null, TOK=null, SEL=null, VIEW='table', PFILTER=null;
let DATA=null, PAT=null, FILTER='all', SEL=null, VIEW='table', PFILTER=null;
let PSIG={}, PMAP={}; // track→pattern-sig, pid→pattern entry (lookup) let PSIG={}, PMAP={}; // track→pattern-sig, pid→pattern entry (lookup)
const FILTERS=[['all','All'],['agree','Agree'],['partial','Partial'],['conflict','Conflict'], let AGREE=null, RECF=false, EDAF=false; // agreement-level filter + orthogonal toggles
['divergent','Divergent'],['no-claim','No-claim'],['unrecorded','Unrecorded'],['eda','EDA-grounded']]; let ONT={agree:{}, sample:[]}; // fleet color language, from tokens.json
const AGREE_ORDER=['agree','partial','conflict','divergent','no-claim','unparsed','empty'];
// pattern registry is best-effort — the triangle still works without it // ── fleet color language: load tokens.json, override :root, build lookups ──────
// (best-effort — the triangle still works on its built-in :root defaults)
fetch('tokens.json').then(r=>r.ok?r.json():null).then(t=>{
if(!t)return; TOK=t;
const root=document.documentElement.style;
for(const[k,v]of Object.entries(t.neutrals||{}))root.setProperty('--'+k,v);
if(t.brand&&t.brand.magenta)root.setProperty('--magenta',t.brand.magenta);
(t.agree||[]).forEach(x=>ONT.agree[x.key]=x);
ONT.sample=t.sample||[];
if(DATA)boot(); // re-render with the real palette once it lands
}).catch(()=>{});
const AG_FALLBACK={agree:'--agree',partial:'--partial',conflict:'--conflict',
divergent:'--divergent','no-claim':'--idle',unparsed:'--faint',empty:'--idle'};
function agColor(k){return (ONT.agree[k]&&ONT.agree[k].color)||css(AG_FALLBACK[k]||'--idle');}
function agGlyph(k){return (ONT.agree[k]&&ONT.agree[k].glyph)||'';}
// sample-family classifier — shared lexical rules from the ontology (sample-world
// axis; this is a sample's conceptual identity, NOT a measured-register claim)
function sampleFamily(name){
const s=String(name).toLowerCase();
for(const f of ONT.sample){if((f.match||[]).some(m=>s===m||s.startsWith(m)))return f;}
return null;
}
function soundChip(name){
const f=sampleFamily(name);
if(!f)return `<span class="sf" style="color:var(--mute)">${esc(name)}</span>`;
return `<span class="sf" title="${esc(f.label)} family" style="color:${f.base};border-color:${f.base}44;background:${f.base}14"><span class="g">${f.glyph}</span>${esc(name)}</span>`;
}
function chips4(arr){return arr.slice(0,4).map(soundChip).join('')+(arr.length>4?`<span class="muted" style="font-size:11px">+${arr.length-4}</span>`:'');}
// pattern registry is best-effort too
fetch('pattern_registry.json').then(r=>r.ok?r.json():null).then(p=>{ fetch('pattern_registry.json').then(r=>r.ok?r.json():null).then(p=>{
if(p){PAT=p; p.tracks.forEach(t=>PSIG[t.track]=t); p.patterns.forEach(e=>PMAP[e.id]=e);} if(p){PAT=p; p.tracks.forEach(t=>PSIG[t.track]=t); p.patterns.forEach(e=>PMAP[e.id]=e);}
}).catch(()=>{}); }).catch(()=>{});
...@@ -173,7 +256,7 @@ fetch('pattern_registry.json').then(r=>r.ok?r.json():null).then(p=>{ ...@@ -173,7 +256,7 @@ fetch('pattern_registry.json').then(r=>r.ok?r.json():null).then(p=>{
fetch('catalog_view.json').then(r=>{if(!r.ok)throw new Error(r.status);return r.json()}) fetch('catalog_view.json').then(r=>{if(!r.ok)throw new Error(r.status);return r.json()})
.then(d=>{DATA=d;boot()}) .then(d=>{DATA=d;boot()})
.catch(e=>{ .catch(e=>{
document.getElementById('sub').textContent='data not loaded'; document.getElementById('prov').textContent='data not loaded';
document.querySelector('.wrap').innerHTML=`<div class="banner"> document.querySelector('.wrap').innerHTML=`<div class="banner">
<b>⚠ catalog_view.json didn't load</b> (${esc(String(e.message||e))}).<br> <b>⚠ catalog_view.json didn't load</b> (${esc(String(e.message||e))}).<br>
This page needs a server (file:// blocks fetch). Run it load-once from the repo:<br> This page needs a server (file:// blocks fetch). Run it load-once from the repo:<br>
...@@ -185,49 +268,94 @@ fetch('catalog_view.json').then(r=>{if(!r.ok)throw new Error(r.status);return r. ...@@ -185,49 +268,94 @@ fetch('catalog_view.json').then(r=>{if(!r.ok)throw new Error(r.status);return r.
function boot(){ function boot(){
const s=DATA.stats; const s=DATA.stats;
document.getElementById('sub').textContent= const prov=document.getElementById('prov');
`${DATA.schema} · as of ${DATA.as_of}`; prov.textContent=`as of ${DATA.as_of}`; prov.title=DATA.schema;
// coverage chips // quiet coverage line (the EDA gap is the one red number we don't hide)
const chips=[ document.getElementById('cov').innerHTML=
['Tracks',s.tracks_total],['Score parsed',`${s.score_parsed}/${s.tracks_total}`], `<span><b>${s.tracks_total}</b> tracks</span>`+
['With metadata',s.with_metadata],['Recorded',`${s.recorded}/${s.tracks_total}`], `<span><b>${s.recorded}</b>/${s.tracks_total} recorded</span>`+
['EDA coverage',`${s.takes_with_eda}/${s.takes_total} takes`], `<span class="lo"><b>${s.takes_with_eda}</b>/${s.takes_total} takes EDA'd</span>`;
]; // interactive sea-state: the agreement bar IS the level filter (overview+legend+control)
let h=chips.map(([l,n])=>`<div class="chip"><div class="n mono">${n}</div><div class="l">${l}</div></div>`).join(''); const lvls=AGREE_ORDER.map(k=>[k, s['ac_'+k.replace('-','_')]||0]).filter(([,n])=>n>0);
// agreement stacked bar document.getElementById('agbar').className='agbar'+(AGREE?' filtered':'');
const order=[['agree','agree'],['partial','partial'],['conflict','conflict'], document.getElementById('agbar').innerHTML=lvls.map(([k,n])=>
['divergent','divergent'],['no-claim','no-claim']]; `<i class="${k===AGREE?'sel':''}" style="flex:${n};background:${agColor(k)}" title="${k}: ${n} — click to filter" data-k="${k}"></i>`).join('');
const seg=order.map(([k,lv])=>{const n=s['ac_'+k.replace('-','_')]||s['ac_'+k]||0; document.getElementById('aglegend').innerHTML=lvls.map(([k,n])=>
return n?`<i style="flex:${n};background:${css(C[lv])}" title="${lv}: ${n}"></i>`:''}).join(''); `<button class="lg${k===AGREE?' on':''}" data-k="${k}" style="${k===AGREE?'color:'+agColor(k):''}"><span class="sw" style="background:${agColor(k)}"></span><span class="gl">${agGlyph(k)}</span>${k} <b>${n}</b></button>`).join('');
h+=`<div class="chip"><div class="stack">${seg}</div> const setAg=k=>{AGREE=(AGREE===k?null:k);boot();};
<div class="l">A↔C agreement · ${s.ac_agree} agree · ${s.ac_conflict} conflict · ${s.ac_divergent} divergent</div></div>`; document.querySelectorAll('#agbar i').forEach(b=>b.onclick=()=>setAg(b.dataset.k));
document.getElementById('chips').innerHTML=h; document.querySelectorAll('.lg').forEach(b=>b.onclick=()=>setAg(b.dataset.k));
// filters // orthogonal toggles
document.getElementById('filters').innerHTML=FILTERS.map(([k,l])=>{ document.getElementById('toggles').innerHTML=
const c=C[k]?css(C[k]):''; `<button class="tg${RECF?' on':''}" data-t="rec" title="show only tracks with no candidate take">○ Unrecorded</button>`+
const dot=c?`<span class="d" style="background:${c}"></span>`:''; `<button class="tg${EDAF?' on':''}" data-t="eda" title="show only audio-grounded tracks">◉ EDA</button>`;
return `<button class="f${k===FILTER?' on':''}" data-k="${k}" document.querySelector('[data-t="rec"]').onclick=()=>{RECF=!RECF;boot();};
style="${k===FILTER&&c?`background:${c};border-color:${c}`:''}">${dot}${l}</button>`}).join(''); document.querySelector('[data-t="eda"]').onclick=()=>{EDAF=!EDAF;boot();};
document.querySelectorAll('.f').forEach(b=>b.onclick=()=>{FILTER=b.dataset.k;boot()}); // view toggle (table / clusters)
// view toggle (table / clusters) — clusters needs the pattern registry
document.getElementById('views').innerHTML= document.getElementById('views').innerHTML=
[['table','▤ Tracks'],['clusters','🧬 Clusters']].map(([k,l])=> [['table','▤ Tracks'],['clusters','🧬 Clusters']].map(([k,l])=>
`<button class="vb${k===VIEW?' on':''}" data-v="${k}">${l}</button>`).join(''); `<button class="tg${k===VIEW?' on':''}" data-v="${k}">${l}</button>`).join('');
document.querySelectorAll('.vb').forEach(b=>b.onclick=()=>{VIEW=b.dataset.v;boot()}); document.querySelectorAll('[data-v]').forEach(b=>b.onclick=()=>{VIEW=b.dataset.v;boot();});
render(); render();
} }
function match(r){ // ── faceted full-text search: free terms + field scopes (sound:/gig:/phrase:/…) ─
if(PFILTER){const e=PMAP[PFILTER]; if(!e||!e.occurrences.some(o=>o.track===r.track))return false} const FIELD_ALIASES={sample:'sound',samples:'sound',sounds:'sound',gigs:'gig',
const q=document.getElementById('q').value.toLowerCase().trim(); takes:'take',styles:'style',phrases:'phrase',title:'name',path:'track',ingredients:'ingredient'};
if(q){const ing=(r.ingredients||[]).map(g=>g.code+' '+g.description).join(' '); const ALL_FIELDS=['name','track','sound','gig','style','take','phrase','ingredient'];
const hay=(r.name+' '+r.track+' '+r.score_sounds.join(' ')+' '+r.claimed_sounds.join(' ')+' '+r.gigs.join(' ')+' '+ing).toLowerCase(); const FIELD_LABEL={sound:'sample',gig:'gig',phrase:'phrase',take:'take',style:'style',
if(!hay.includes(q))return false} track:'path',ingredient:'ingredient',name:'name'};
if(FILTER==='all')return true; function parseQuery(raw){
if(FILTER==='unrecorded')return !r.recorded; const terms=[]; raw=raw.trim(); if(!raw)return terms;
if(FILTER==='eda')return r.n_takes_eda>0; const re=/(\w+):"([^"]*)"|(\w+):(\S+)|"([^"]*)"|(\S+)/g; let m;
return r.ac.level===FILTER; while((m=re.exec(raw))){
if(m[1]!==undefined)terms.push({f:m[1].toLowerCase(),v:m[2].toLowerCase()});
else if(m[3]!==undefined)terms.push({f:m[3].toLowerCase(),v:m[4].toLowerCase()});
else terms.push({f:null,v:(m[5]!==undefined?m[5]:m[6]).toLowerCase()});
}
return terms;
} }
function fieldText(r,f){
switch(f){
case 'name':return r.names.concat([r.name]).join(' ');
case 'track':return r.track;
case 'sound':return r.score_sounds.concat(r.claimed_sounds).join(' ');
case 'gig':return r.gigs.join(' ');
case 'style':return (r.metas||[]).map(x=>x.style||'').join(' ');
case 'take':return (r.takes||[]).map(x=>x.take).join(' ');
case 'phrase':return trackPhrases(r.track).map(x=>x.p.norm).join(' ');
case 'ingredient':return (r.ingredients||[]).map(g=>g.code+' '+g.description).join(' ');
default:return '';
}
}
function searchMatch(r,terms){
const where=new Set();
for(const t of terms){
const f=t.f?(FIELD_ALIASES[t.f]||t.f):null;
if(f&&ALL_FIELDS.includes(f)){
if(!fieldText(r,f).toLowerCase().includes(t.v))return{ok:false};
where.add(f);
}else{
const needle=f?`${t.f}:${t.v}`:t.v; // unknown scope → literal free text
let hit=null;
for(const ff of ALL_FIELDS){if(fieldText(r,ff).toLowerCase().includes(needle)){hit=ff;break;}}
if(!hit)return{ok:false};
where.add(hit);
}
}
return{ok:true,where};
}
function filterOk(r){
if(PFILTER){const e=PMAP[PFILTER]; if(!e||!e.occurrences.some(o=>o.track===r.track))return false;}
if(AGREE&&r.ac.level!==AGREE)return false;
if(RECF&&r.recorded)return false;
if(EDAF&&!(r.n_takes_eda>0))return false;
return true;
}
function clearAgree(){AGREE=null;boot();}
function clearSearch(){document.getElementById('q').value='';render();document.getElementById('q').focus();}
function setQ(v){document.getElementById('q').value=v;render();}
function resetAll(){AGREE=null;RECF=false;EDAF=false;PFILTER=null;document.getElementById('q').value='';boot();}
function render(){ function render(){
const tbl=VIEW==='table'; const tbl=VIEW==='table';
...@@ -237,31 +365,48 @@ function render(){ ...@@ -237,31 +365,48 @@ function render(){
} }
function renderTable(){ function renderTable(){
const rows=DATA.tracks.filter(match); const qval=document.getElementById('q').value;
const pf=PFILTER&&PMAP[PFILTER]?`<span class="pfilter" onclick="clearPhrase()" title="click to clear">🧬 ${esc(PMAP[PFILTER].norm.slice(0,30))} ✕</span>`:''; document.querySelector('.search').classList.toggle('has',qval.trim().length>0);
document.getElementById('count').innerHTML=pf+`${rows.length} / ${DATA.tracks.length} tracks`; const terms=parseQuery(qval);
document.getElementById('rows').innerHTML=rows.map((r,i)=>{ const out=[];
const lv=r.ac.level, c=css(C[lv]); for(const r of DATA.tracks){if(!filterOk(r))continue; const m=searchMatch(r,terms); if(m.ok)out.push([r,m.where]);}
const fc=[];
if(AGREE)fc.push(`<span class="pfilter" onclick="clearAgree()" title="clear">${agGlyph(AGREE)} ${AGREE} ✕</span>`);
if(PFILTER&&PMAP[PFILTER])fc.push(`<span class="pfilter" onclick="clearPhrase()" title="clear">🧬 ${esc(PMAP[PFILTER].norm.slice(0,22))} ✕</span>`);
document.getElementById('count').innerHTML=fc.join('')+`<span><b>${out.length}</b> / ${DATA.tracks.length}</span>`;
if(!out.length){
document.getElementById('rows').innerHTML=`<tr><td colspan="6"><div class="noresult">
<b>No tracks match</b>Try a scope like <code onclick="setQ('sample:kick')">sample:kick</code>,
<code onclick="setQ('gig:montreuil')">gig:montreuil</code>, or
<code onclick="resetAll()">clear all filters</code>.</div></td></tr>`;
return;
}
document.getElementById('rows').innerHTML=out.map(([r,where])=>{
const lv=r.ac.level, c=agColor(lv);
const idx=DATA.tracks.indexOf(r); const idx=DATA.tracks.indexOf(r);
const prec=r.ac.precision!=null?` ${Math.round(r.ac.precision*100)}%`:''; const prec=r.ac.precision!=null?` ${Math.round(r.ac.precision*100)}%`:'';
const sib=r.alias_siblings.length?` <span class="warn" title="${r.alias_siblings.join('\\n')}">⚠${r.alias_siblings.length}</span>`:''; const sib=r.alias_siblings.length?` <span class="warn" title="${esc(r.alias_siblings.join(', '))}">⚠${r.alias_siblings.length}</span>`:'';
const hidden=[...where].filter(f=>['phrase','gig','take','ingredient','track'].includes(f));
const hint=qval.trim()&&hidden.length?`<span class="hint" title="matched on">↳ ${hidden.map(f=>FIELD_LABEL[f]||f).join(', ')}</span>`:'';
const meta=r.metas[0]||{}; const meta=r.metas[0]||{};
const metaCell=r.claimed_sounds.length const scoreCell=r.score_sounds.length
? `<span class="snd">${r.claimed_sounds.slice(0,4).join(' · ')}${r.claimed_sounds.length>4?'…':''}</span>` ? `<span class="mono" style="color:var(--faint)">${r.n_orbits}d</span> ${chips4(r.score_sounds)}`
: `<span class="muted" title="this track's gig tracklist lists no ingredient sounds — a corner-C metadata gap, not a disagreement (A↔C = no-claim)">— none listed —</span>`; : `<span class="muted">— unparsed —</span>`;
const bpm=meta.bpm?`<span class="mono">${meta.bpm}</span> `:''; const metaCell=r.claimed_sounds.length?chips4(r.claimed_sounds)
: `<span class="muted" title="this gig's tracklist lists no ingredient sounds — a corner-C gap, not a disagreement">— none listed —</span>`;
const bpm=meta.bpm?`<span class="mono" style="color:var(--mute)">${meta.bpm}</span> `:'';
const recCell=r.recorded const recCell=r.recorded
? `${r.n_takes} take${r.n_takes>1?'s':''} <span class="${r.n_takes_eda?'eda-yes':'eda-no'}" title="EDA-grounded takes">${r.n_takes_eda?'◉ EDA':'○ no EDA'}</span>` ? `${r.n_takes} take${r.n_takes>1?'s':''} <span class="${r.n_takes_eda?'eda-yes':'eda-no'}" title="EDA-grounded">${r.n_takes_eda?'◉ EDA':'○'}</span>`
: `<span class="muted" title="no Ardour take date-joins to this track's gig(s) yet — corner B unlinked">— unrecorded —</span>`; : `<span class="muted" title="no Ardour take date-joins yet — corner B unlinked">— unrecorded —</span>`;
return `<tr data-i="${idx}" class="${idx===SEL?'sel':''}"> return `<tr data-i="${idx}" class="${idx===SEL?'sel':''}">
<td><div class="tname">${esc(r.name)}${sib}</div><div class="path mono">${esc(r.track)}</div></td> <td><div class="tname">${esc(r.name)}${sib}${hint}</div><div class="path mono">${esc(r.track)}</div></td>
<td><span class="mono">${r.n_orbits}d</span> <span class="snd">${r.score_sounds.slice(0,4).join(' · ')}${r.score_sounds.length>4?'…':''}</span></td> <td><div class="sfwrap">${scoreCell}</div></td>
<td>${bpm}${metaCell}</td> <td>${bpm}<div class="sfwrap">${metaCell}</div></td>
<td><span class="tag lvl" style="background:${c}">${lv}${prec}</span></td> <td><span class="tag lvl" style="background:${c}">${agGlyph(lv)} ${lv}${prec}</span></td>
<td>${recCell}</td> <td>${recCell}</td>
<td class="num mono">${r.gigs.length}</td> <td class="num mono">${r.gigs.length}</td>
</tr>`}).join(''); </tr>`}).join('');
document.querySelectorAll('#rows tr').forEach(tr=>tr.onclick=()=>open(+tr.dataset.i)); document.querySelectorAll('#rows tr[data-i]').forEach(tr=>tr.onclick=()=>open(+tr.dataset.i));
} }
// safe, line-based Tidal highlighter: split comment in RAW, escape, then color // safe, line-based Tidal highlighter: split comment in RAW, escape, then color
...@@ -355,8 +500,8 @@ function renderClusters(){ ...@@ -355,8 +500,8 @@ function renderClusters(){
el.innerHTML=cards+soloHtml; el.innerHTML=cards+soloHtml;
} }
function open(i){SEL=i;render();const r=DATA.tracks[i];const lv=r.ac.level,c=css(C[lv]); function open(i){SEL=i;render();const r=DATA.tracks[i];const lv=r.ac.level,c=agColor(lv);
const orb=Object.entries(r.score).map(([d,snd])=>`<div class="o mono">${d}</div><div class="mono">${esc(snd)}</div>`).join(''); const orb=Object.entries(r.score).map(([d,snd])=>`<div class="o mono">${d}</div><div>${soundChip(snd)}</div>`).join('');
const diff=g=>(r.ac[g]||[]).map(s=>`<span class="d-${g==='metadata_only'?'meta':g==='score_only'?'score':'shared'}">${esc(s)}</span>`).join('')||'<span class="empty">none</span>'; const diff=g=>(r.ac[g]||[]).map(s=>`<span class="d-${g==='metadata_only'?'meta':g==='score_only'?'score':'shared'}">${esc(s)}</span>`).join('')||'<span class="empty">none</span>';
// B · recordings — render an <audio> ONLY when a proxy actually exists on disk // B · recordings — render an <audio> ONLY when a proxy actually exists on disk
// (t.audio, resolved at build time) so there are no broken/empty players // (t.audio, resolved at build time) so there are no broken/empty players
...@@ -379,7 +524,7 @@ function open(i){SEL=i;render();const r=DATA.tracks[i];const lv=r.ac.level,c=css ...@@ -379,7 +524,7 @@ function open(i){SEL=i;render();const r=DATA.tracks[i];const lv=r.ac.level,c=css
<div class="empty">versions of one track, or a wrong link — ear-verify</div></div>`:''; <div class="empty">versions of one track, or a wrong link — ear-verify</div></div>`:'';
document.getElementById('drawer').innerHTML=`<span class="x" onclick="close_()">✕</span> document.getElementById('drawer').innerHTML=`<span class="x" onclick="close_()">✕</span>
<h2>${esc(r.name)}</h2><div class="path mono">${esc(r.track)}</div> <h2>${esc(r.name)}</h2><div class="path mono">${esc(r.track)}</div>
<div style="margin-top:9px"><span class="tag lvl" style="background:${c}">${lv}${r.ac.precision!=null?' · precision '+Math.round(r.ac.precision*100)+'%':''}</span></div> <div style="margin-top:9px"><span class="tag lvl" style="background:${c}">${agGlyph(lv)} ${lv}${r.ac.precision!=null?' · precision '+Math.round(r.ac.precision*100)+'%':''}</span></div>
<div class="sec"><h3><span class="corner cA">A</span>score — ${r.n_orbits} orbits <div class="sec"><h3><span class="corner cA">A</span>score — ${r.n_orbits} orbits
&nbsp;<span class="toggle" onclick="toggleSrc(${i})">▾ view .tidal source</span></h3> &nbsp;<span class="toggle" onclick="toggleSrc(${i})">▾ view .tidal source</span></h3>
...@@ -401,5 +546,13 @@ function open(i){SEL=i;render();const r=DATA.tracks[i];const lv=r.ac.level,c=css ...@@ -401,5 +546,13 @@ function open(i){SEL=i;render();const r=DATA.tracks[i];const lv=r.ac.level,c=css
} }
function close_(){document.getElementById('drawer').classList.remove('open');SEL=null;render()} function close_(){document.getElementById('drawer').classList.remove('open');SEL=null;render()}
function esc(s){return String(s).replace(/[&<>"]/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m]))} function esc(s){return String(s).replace(/[&<>"]/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m]))}
document.addEventListener('keydown',e=>{if(e.key==='Escape')close_()}); const Q=()=>document.getElementById('q');
document.addEventListener('keydown',e=>{
if(e.key==='/'&&document.activeElement!==Q()){e.preventDefault();Q().focus();return;}
if(e.key==='Escape'){
if(document.getElementById('drawer').classList.contains('open'))return close_();
if(document.activeElement===Q()&&Q().value){clearSearch();}
else if(Q().value){clearSearch();}
}
});
</script></body></html> </script></body></html>
/* AUTO-GENERATED from armada/tide-table/models.py ONTOLOGY by
tools/gen_tokens.py — DO NOT EDIT. The fleet color language. */
:root{
--surface:#0a0a0a;
--raised:#111111;
--overlay:#171717;
--hairline:#ffffff1f;
--ink:#e8e8ea;
--mute:#9a9aa0;
--faint:#6a6a70;
--brand-magenta:#d900ff;
--brand-magenta-low:#a700d1;
--brand-magenta-deep:#8900b3;
--role-percs:#ff8c00;
--role-bass:#7c5cff;
--role-melodic:#36c5f0;
--role-tops:#2dd4bf;
--role-atmos:#8a93a6;
--role-vox:#ff3d7b;
--lifecycle-idea:#737373;
--lifecycle-wip:#e0a82e;
--lifecycle-ready:#5bc091;
--lifecycle-released:#d900ff;
--lifecycle-blocked:#ff5252;
--lifecycle-archived:#4a4a4a;
--agree-agree:#5bc091;
--agree-partial:#e0a82e;
--agree-conflict:#ff5252;
--agree-divergent:#b06cff;
--agree-no_claim:#737373;
--agree-unparsed:#6a6a70;
--agree-empty:#4a4a4a;
--sample-kick:#df6862;
--sample-kick-0:#f6a4a7;
--sample-kick-1:#f59091;
--sample-kick-2:#f07e7d;
--sample-kick-3:#e66f6a;
--sample-kick-4:#d8635a;
--sample-kick-5:#c65a4e;
--sample-kick-6:#b15546;
--sample-kick-7:#995243;
--sample-snare:#d97230;
--sample-snare-0:#f4a98c;
--sample-snare-1:#f2966e;
--sample-snare-2:#eb8653;
--sample-snare-3:#e0783a;
--sample-snare-4:#d16d26;
--sample-snare-5:#bf641b;
--sample-snare-6:#aa5e1e;
--sample-snare-7:#92592a;
--sample-perc:#c18500;
--sample-perc-0:#e5b476;
--sample-perc-1:#dfa54f;
--sample-perc-2:#d69725;
--sample-perc-3:#c98b00;
--sample-perc-4:#b98000;
--sample-perc-5:#a87600;
--sample-perc-6:#956d00;
--sample-perc-7:#806517;
--sample-hat:#999900;
--sample-hat-0:#cac075;
--sample-hat-1:#beb550;
--sample-hat-2:#b0aa2b;
--sample-hat-3:#a19e00;
--sample-hat-4:#919300;
--sample-hat-5:#818800;
--sample-hat-6:#727c0e;
--sample-hat-7:#647028;
--sample-break:#3eab5e;
--sample-break-0:#9ace94;
--sample-break-1:#7ec67e;
--sample-break-2:#63bc6e;
--sample-break-3:#4ab162;
--sample-break-4:#33a55b;
--sample-break-5:#239857;
--sample-break-6:#228954;
--sample-break-7:#2d7a53;
--sample-pad:#00af96;
--sample-pad-0:#77d2b7;
--sample-pad-1:#46caac;
--sample-pad-2:#00c0a2;
--sample-pad-3:#00b59a;
--sample-pad-4:#00a892;
--sample-pad-5:#009a89;
--sample-pad-6:#008b7f;
--sample-pad-7:#007a73;
--sample-keys:#00abbd;
--sample-keys-0:#68d1d3;
--sample-keys-1:#26c8cf;
--sample-keys-2:#00bdc9;
--sample-keys-3:#00b1c2;
--sample-keys-4:#00a4b8;
--sample-keys-5:#0095ac;
--sample-keys-6:#00869d;
--sample-keys-7:#00778a;
--sample-lead:#00a1db;
--sample-lead-0:#70cceb;
--sample-lead-1:#43c1eb;
--sample-lead-2:#00b5e8;
--sample-lead-3:#00a8e1;
--sample-lead-4:#009ad5;
--sample-lead-5:#008cc6;
--sample-lead-6:#007eb2;
--sample-lead-7:#26709a;
--sample-synth:#5991ed;
--sample-synth-0:#8fc2fb;
--sample-synth-1:#79b4fe;
--sample-synth-2:#68a6fb;
--sample-synth-3:#5d98f3;
--sample-synth-4:#568ae6;
--sample-synth-5:#537dd3;
--sample-synth-6:#5271bd;
--sample-synth-7:#5166a1;
--sample-bass:#927fe7;
--sample-bass-0:#b3b6fb;
--sample-bass-1:#a8a6fc;
--sample-bass-2:#9f96f8;
--sample-bass-3:#9686ee;
--sample-bass-4:#8e79df;
--sample-bass-5:#856dcb;
--sample-bass-6:#7b63b4;
--sample-bass-7:#705c9a;
--sample-fx:#ba71cb;
--sample-fx-0:#d3acec;
--sample-fx-1:#ce99e8;
--sample-fx-2:#c787df;
--sample-fx-3:#bf78d2;
--sample-fx-4:#b46bc2;
--sample-fx-5:#a860b0;
--sample-fx-6:#99599b;
--sample-fx-7:#875485;
--sample-vox:#d76797;
--sample-vox-0:#eda4ca;
--sample-vox-1:#eb90bd;
--sample-vox-2:#e67dae;
--sample-vox-3:#dd6d9f;
--sample-vox-4:#d0618f;
--sample-vox-5:#c0587f;
--sample-vox-6:#ad5270;
--sample-vox-7:#974f63;
}
{
"schema": "design-tokens v1 (fleet color language: ontology → families × shades)",
"as_of": "2026-06-06",
"neutrals": {
"surface": "#0a0a0a",
"raised": "#111111",
"overlay": "#171717",
"hairline": "#ffffff1f",
"ink": "#e8e8ea",
"mute": "#9a9aa0",
"faint": "#6a6a70"
},
"brand": {
"magenta": "#d900ff",
"magenta-low": "#a700d1",
"magenta-deep": "#8900b3"
},
"role": [
{
"key": "percs",
"label": "Percs",
"color": "#ff8c00",
"glyph": "▰",
"description": "kicks, snares, hats, percussion — transient, warm"
},
{
"key": "bass",
"label": "Bass",
"color": "#7c5cff",
"glyph": "▂",
"description": "sub, acid, reese/wobble, bass-spine — low, deep"
},
{
"key": "melodic",
"label": "Melodic",
"color": "#36c5f0",
"glyph": "♪",
"description": "leads, keys, riffs, high plucks — bright"
},
{
"key": "tops",
"label": "Tops",
"color": "#2dd4bf",
"glyph": "≈",
"description": "breaks, chops, texture loops — busy, mid-high"
},
{
"key": "atmos",
"label": "Atmos",
"color": "#8a93a6",
"glyph": "◌",
"description": "pads, drones, ambient, fx — diffuse, background"
},
{
"key": "vox",
"label": "Vox",
"color": "#ff3d7b",
"glyph": "◍",
"description": "vocal samples and chops — human, forward"
}
],
"lifecycle": [
{
"key": "idea",
"label": "Idea",
"color": "#737373",
"glyph": "·",
"description": "raw / sketch"
},
{
"key": "wip",
"label": "WIP",
"color": "#e0a82e",
"glyph": "◐",
"description": "in progress"
},
{
"key": "ready",
"label": "Ready",
"color": "#5bc091",
"glyph": "●",
"description": "mastered, ship-ready"
},
{
"key": "released",
"label": "Released",
"color": "#d900ff",
"glyph": "★",
"description": "out in the world"
},
{
"key": "blocked",
"label": "Blocked",
"color": "#ff5252",
"glyph": "✕",
"description": "needs a fix before it moves"
},
{
"key": "archived",
"label": "Archived",
"color": "#4a4a4a",
"glyph": "▢",
"description": "parked / superseded"
}
],
"agree": [
{
"key": "agree",
"label": "Agree",
"color": "#5bc091",
"glyph": "●",
"description": "≥60% of claimed sounds are in the score"
},
{
"key": "partial",
"label": "Partial",
"color": "#e0a82e",
"glyph": "◐",
"description": "30–60% — some claims unmatched"
},
{
"key": "conflict",
"label": "Conflict",
"color": "#ff5252",
"glyph": "✕",
"description": "0–30% — real partial disagreement"
},
{
"key": "divergent",
"label": "Divergent",
"color": "#b06cff",
"glyph": "⤬",
"description": "zero overlap, both rich → wrong file link"
},
{
"key": "no-claim",
"label": "No-claim",
"color": "#737373",
"glyph": "·",
"description": "metadata lists no parseable sound"
},
{
"key": "unparsed",
"label": "Unparsed",
"color": "#6a6a70",
"glyph": "?",
"description": "WE couldn't read the score — not a conflict"
},
{
"key": "empty",
"label": "Empty",
"color": "#4a4a4a",
"glyph": "∅",
"description": "neither side has parseable sounds"
}
],
"sample": [
{
"key": "kick",
"label": "Kick",
"glyph": "●",
"base": "#df6862",
"shades": [
"#f6a4a7",
"#f59091",
"#f07e7d",
"#e66f6a",
"#d8635a",
"#c65a4e",
"#b15546",
"#995243"
],
"match": [
"kick",
"kik",
"bd",
"808bd",
"909",
"bassdrum"
]
},
{
"key": "snare",
"label": "Snare",
"glyph": "◆",
"base": "#d97230",
"shades": [
"#f4a98c",
"#f2966e",
"#eb8653",
"#e0783a",
"#d16d26",
"#bf641b",
"#aa5e1e",
"#92592a"
],
"match": [
"snare",
"sn",
"sd",
"clap",
"cp",
"rim",
"rs"
]
},
{
"key": "perc",
"label": "Perc",
"glyph": "▴",
"base": "#c18500",
"shades": [
"#e5b476",
"#dfa54f",
"#d69725",
"#c98b00",
"#b98000",
"#a87600",
"#956d00",
"#806517"
],
"match": [
"perc",
"conga",
"bongo",
"tom",
"clave",
"shaker",
"tabla",
"cowbell"
]
},
{
"key": "hat",
"label": "Hat",
"glyph": "✦",
"base": "#999900",
"shades": [
"#cac075",
"#beb550",
"#b0aa2b",
"#a19e00",
"#919300",
"#818800",
"#727c0e",
"#647028"
],
"match": [
"hat",
"hh",
"oh",
"ch",
"hihat",
"cymbal",
"cym",
"ride",
"crash"
]
},
{
"key": "break",
"label": "Break",
"glyph": "≈",
"base": "#3eab5e",
"shades": [
"#9ace94",
"#7ec67e",
"#63bc6e",
"#4ab162",
"#33a55b",
"#239857",
"#228954",
"#2d7a53"
],
"match": [
"break",
"amen",
"loop",
"jungle",
"dnb",
"jazz"
]
},
{
"key": "pad",
"label": "Pad",
"glyph": "◌",
"base": "#00af96",
"shades": [
"#77d2b7",
"#46caac",
"#00c0a2",
"#00b59a",
"#00a892",
"#009a89",
"#008b7f",
"#007a73"
],
"match": [
"pad",
"drone",
"choir",
"string",
"ambient",
"atmos"
]
},
{
"key": "keys",
"label": "Keys",
"glyph": "♬",
"base": "#00abbd",
"shades": [
"#68d1d3",
"#26c8cf",
"#00bdc9",
"#00b1c2",
"#00a4b8",
"#0095ac",
"#00869d",
"#00778a"
],
"match": [
"key",
"keys",
"piano",
"rhodes",
"epiano",
"organ",
"fpiano",
"qstab",
"nujazz"
]
},
{
"key": "lead",
"label": "Lead",
"glyph": "♪",
"base": "#00a1db",
"shades": [
"#70cceb",
"#43c1eb",
"#00b5e8",
"#00a8e1",
"#009ad5",
"#008cc6",
"#007eb2",
"#26709a"
],
"match": [
"lead",
"arp",
"pluck",
"stab",
"blip",
"saw",
"square"
]
},
{
"key": "synth",
"label": "Synth",
"glyph": "◈",
"base": "#5991ed",
"shades": [
"#8fc2fb",
"#79b4fe",
"#68a6fb",
"#5d98f3",
"#568ae6",
"#537dd3",
"#5271bd",
"#5166a1"
],
"match": [
"synth",
"commodore",
"chip",
"fm",
"moog",
"reese"
]
},
{
"key": "bass",
"label": "Bass",
"glyph": "▂",
"base": "#927fe7",
"shades": [
"#b3b6fb",
"#a8a6fc",
"#9f96f8",
"#9686ee",
"#8e79df",
"#856dcb",
"#7b63b4",
"#705c9a"
],
"match": [
"bass",
"sub",
"808",
"acid",
"wobble",
"meth_bass",
"ramplem",
"bassline"
]
},
{
"key": "fx",
"label": "FX",
"glyph": "✺",
"base": "#ba71cb",
"shades": [
"#d3acec",
"#ce99e8",
"#c787df",
"#bf78d2",
"#b46bc2",
"#a860b0",
"#99599b",
"#875485"
],
"match": [
"fx",
"riser",
"sweep",
"noise",
"impact",
"downlifter",
"uplifter",
"glitch",
"vinyl",
"foley"
]
},
{
"key": "vox",
"label": "Vox",
"glyph": "◍",
"base": "#d76797",
"shades": [
"#eda4ca",
"#eb90bd",
"#e67dae",
"#dd6d9f",
"#d0618f",
"#c0587f",
"#ad5270",
"#974f63"
],
"match": [
"vox",
"vocal",
"voc",
"acap",
"speech",
"voice"
]
}
]
}
\ No newline at end of file
#!/usr/bin/env python3
"""gen_tokens — the fleet color language, generated from the ontology (DRY).
armada/DESIGN.md is the design RATIONALE; armada/tide-table/models.py is the
machine-readable ontology (concepts → color + glyph + label, and the hue-anchored
SAMPLE_FAMILIES). This derives the actual paint every L'Armada surface uses:
• families × variations — each ColorFamily is expanded into SHADE_COUNT shades by
walking OKLCH lightness DOWN while fanning the hue ±SHADE_FAN° (PLN's scheme),
so ~50-100 concepts get stable, distinguishable, greyscale-safe colors.
• two artifacts —
tokens.css (:root custom properties) for CSS-var consumers (armada/ui)
tokens.json (the structured palette) for runtime consumers (triangle.html
fetches it and overrides its :root, so it can never drift)
OKLCH→sRGB is implemented inline (Björn Ottosson's matrices) — no dependencies, so
this runs under the same system python3 as the rest of the pipeline. Validated
against models.DesignTokens on emit.
python3 tools/gen_tokens.py # writes tokens.css (ui) + tokens.json (×2)
"""
from __future__ import annotations
import json
import math
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "armada" / "tide-table"))
import models as M # noqa: E402
AS_OF = "2026-06-06"
CSS_OUT = ROOT / "armada" / "ui" / "src" / "tokens.css"
JSON_OUT = ROOT / "armada" / "ui" / "src" / "tokens.json"
# a served copy so the standalone triangle can fetch it from its own dir
TRIANGLE_OUT = ROOT / "armada" / "tide-table" / "tokens.json"
# ── OKLCH → sRGB hex (Ottosson) ───────────────────────────────────────────────
def _lin_to_srgb(c: float) -> float:
c = max(0.0, min(1.0, c))
return 1.055 * (c ** (1 / 2.4)) - 0.055 if c > 0.0031308 else 12.92 * c
def oklch_hex(L: float, C: float, H_deg: float) -> str:
h = math.radians(H_deg)
a, b = C * math.cos(h), C * math.sin(h)
l_ = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3
m_ = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3
s_ = (L - 0.0894841775 * a - 1.2914855480 * b) ** 3
r = 4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_
g = -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_
bb = -0.0041960863 * l_ - 0.7034186147 * m_ + 1.7076147010 * s_
return "#" + "".join(f"{round(_lin_to_srgb(x) * 255):02x}" for x in (r, g, bb))
def shades(fam) -> tuple[str, list[str]]:
"""Family base + SHADE_COUNT shades: lightness ladder + ±SHADE_FAN° hue fan.
Chroma eases slightly at the light/dark ends so extremes don't look muddy."""
n = M.SHADE_COUNT
lo, hi = M.SHADE_L[1], M.SHADE_L[0]
out = []
for i in range(n):
t = i / (n - 1) # 0 (light) … 1 (dark)
L = hi + (lo - hi) * t
H = fam.hue + (t - 0.5) * 2 * M.SHADE_FAN
C = fam.chroma * (1 - 0.35 * (2 * t - 1) ** 2) # bell: full in the middle
out.append(oklch_hex(L, C, H))
base = oklch_hex(0.66, fam.chroma, fam.hue) # representative swatch
return base, out
def build() -> dict:
def terms(ts):
return [{"key": t.key, "label": t.label, "color": t.color,
"glyph": t.glyph, "description": t.description} for t in ts]
sample = []
for fam in M.SAMPLE_FAMILIES:
base, sh = shades(fam)
sample.append({"key": fam.key, "label": fam.label, "glyph": fam.glyph,
"base": base, "shades": sh, "match": fam.match})
return {
"schema": "design-tokens v1 (fleet color language: ontology → families × shades)",
"as_of": AS_OF,
"neutrals": dict(M.NEUTRALS),
"brand": dict(M.BRAND),
"role": terms(M.ROLE_FAMILIES),
"lifecycle": terms(M.LIFECYCLE_TERMS),
"agree": terms(M.AGREE_TERMS),
"sample": sample,
}
def to_css(tok: dict) -> str:
out = ["/* AUTO-GENERATED from armada/tide-table/models.py ONTOLOGY by",
" tools/gen_tokens.py — DO NOT EDIT. The fleet color language. */",
":root{"]
for k, v in tok["neutrals"].items():
out.append(f" --{k}:{v};")
for k, v in tok["brand"].items():
out.append(f" --brand-{k}:{v};")
for axis in ("role", "lifecycle", "agree"):
for t in tok[axis]:
out.append(f" --{axis}-{t['key'].replace('-', '_')}:{t['color']};")
for f in tok["sample"]:
out.append(f" --sample-{f['key']}:{f['base']};")
for i, s in enumerate(f["shades"]):
out.append(f" --sample-{f['key']}-{i}:{s};")
out.append("}")
return "\n".join(out) + "\n"
def main():
tok = build()
from models import DesignTokens # DRY contract: validate before writing
DesignTokens.model_validate(tok)
js = json.dumps(tok, ensure_ascii=False, indent=1)
JSON_OUT.write_text(js)
TRIANGLE_OUT.write_text(js)
CSS_OUT.write_text(to_css(tok))
n_sh = sum(len(f["shades"]) for f in tok["sample"])
print(f"✓ {CSS_OUT.relative_to(ROOT)} + {JSON_OUT.name} (×2: ui + tide-table)")
print(f" {len(tok['sample'])} sample families × {M.SHADE_COUNT} shades = {n_sh} sample slots")
print(f" + role {len(tok['role'])} · lifecycle {len(tok['lifecycle'])} · agree {len(tok['agree'])}")
OUT = CSS_OUT
if __name__ == "__main__":
main()
...@@ -14,9 +14,9 @@ from pathlib import Path ...@@ -14,9 +14,9 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "armada" / "tide-table")) sys.path.insert(0, str(ROOT / "armada" / "tide-table"))
from models import CatalogView, Note, PlayerData # noqa: E402 from models import CatalogView, Note, PatternRegistry, PlayerData # noqa: E402
ROOTS = [PlayerData, Note, CatalogView] # top-level UI-facing models ROOTS = [PlayerData, Note, CatalogView, PatternRegistry] # top-level UI-facing models
OUT = ROOT / "armada" / "ui" / "src" / "types.gen.ts" OUT = ROOT / "armada" / "ui" / "src" / "types.gen.ts"
SCALARS = {"string": "string", "number": "number", "integer": "number", SCALARS = {"string": "string", "number": "number", "integer": "number",
"boolean": "boolean", "null": "null"} "boolean": "boolean", "null": "null"}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment