From 22f907c44d99cb29383cf35231dde8c74f74862e Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Mon, 3 Aug 2026 11:37:20 -0400
Subject: [PATCH 01/11] feat(design): offer starter token ROLES at creation,
never values
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A literal gets written into a stylesheet when there is no role to reach for.
This codebase demonstrated it: the house style had no "text on a filled colour"
role, so 76 call sites wrote a pure-white literal — not out of defiance, but
because nothing existed to write instead. The correction was not a better ban
list; it was declaring the missing role (#2275, #2349).
So the useful moment is creation. A system whose roles are named on day one
never presents the occasion.
Ten groups, ~40 roles: surface, text, action, semantic, border, accent, radius,
space, motion, state. Operator's call was one flat list, every group
individually skippable — presets keyed to app shape (web / CLI / docs) were
rejected because they need the product to hold opinions about app categories,
and a wrong category is worse than a list someone prunes once.
TWO BOUNDARIES THIS HAS TO HOLD, both rule #115:
- The ROLES ship; the VALUES never do. Every seeded token has an empty
value_by_mode, so a fresh system is a set of named, deliberately-unanswered
questions. A test asserts no hex appears anywhere in the module — not just
that tokens are blank, but that no palette hides in a comment waiting to be
pasted in.
- The PREFIX is the install's. `--fs-` is FabledSword's convention, not the
product's; the default is a neutral `--ds-` and callers pass their own.
Valueless roles are already legible downstream — render_stylesheet emits them
as commented-out declarations and stylesheet_for_system reports them under
`valueless` (#2299) — so "declared but undecided" reads correctly with nothing
new built.
Both surfaces, per rule #33: MCP gains starter_role_groups/token_prefix plus
list_starter_role_groups(); REST gains the same on POST plus
GET /api/design-systems/starter-roles. The parity enumeration is extended
rather than loosened.
Note create_design_system treats None and [] alike (seed nothing), while
starter_tokens treats None as "all". Deliberate: creation must never write 40
rows into a system whose caller never asked, and the everything-checked default
belongs in the UI where the operator can see it.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
---
src/scribe/mcp/tools/design_systems.py | 35 ++++
src/scribe/routes/design_systems.py | 19 ++
src/scribe/services/design_starter_roles.py | 185 ++++++++++++++++++++
src/scribe/services/design_systems.py | 21 +++
tests/test_design_starter_roles.py | 136 ++++++++++++++
tests/test_routes_design_systems.py | 2 +-
6 files changed, 397 insertions(+), 1 deletion(-)
create mode 100644 src/scribe/services/design_starter_roles.py
create mode 100644 tests/test_design_starter_roles.py
diff --git a/src/scribe/mcp/tools/design_systems.py b/src/scribe/mcp/tools/design_systems.py
index c8af5db..63bb7e3 100644
--- a/src/scribe/mcp/tools/design_systems.py
+++ b/src/scribe/mcp/tools/design_systems.py
@@ -22,6 +22,11 @@ from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import design_systems as ds_svc
from scribe.services.design_systems import DesignSystemCycle
+from scribe.services.design_starter_roles import (
+ ALL_GROUPS,
+ DEFAULT_TOKEN_PREFIX,
+ describe_groups,
+)
async def create_design_system(
@@ -29,6 +34,8 @@ async def create_design_system(
description: str = "",
guidance: str = "",
parent_id: int = 0,
+ starter_role_groups: list[str] | None = None,
+ token_prefix: str = "",
) -> dict:
"""Create a design system, optionally inheriting from another.
@@ -41,20 +48,47 @@ async def create_design_system(
parent_id: Inherit from this system — it holds the defaults this one
overrides. Omit (0) for a top-level "family" system, which is what
a first design system usually is.
+ starter_role_groups: Seed the system with named but VALUELESS token
+ roles, so there is something to reach for before a literal gets
+ written instead. Call list_starter_role_groups() for the catalogue.
+ Pass ["all"] for every group. Omit for none — a system with three
+ hand-written tokens is a legitimate design system.
+ token_prefix: Naming convention for the seeded roles, e.g. "--fs-".
+ Defaults to a neutral "--ds-"; pass the install's own if it has one.
+ Ignored when no starter groups are requested.
"""
uid = current_user_id()
+ groups = starter_role_groups
+ if groups and len(groups) == 1 and groups[0] == "all":
+ groups = list(ALL_GROUPS)
system = await ds_svc.create_design_system(
uid,
title=title,
description=description or None,
guidance=guidance or None,
parent_id=parent_id or None,
+ starter_role_groups=groups,
+ token_prefix=token_prefix or DEFAULT_TOKEN_PREFIX,
)
if system is None:
raise ValueError(f"parent design system {parent_id} not found or not writable")
return system.to_dict()
+async def list_starter_role_groups() -> dict:
+ """The starter token ROLES offered at design-system creation.
+
+ Roles, not values. Every group is a set of named questions — "page
+ background, the deepest surface" — that the operator answers with their own
+ palette. Nothing here carries a colour, because a default palette would be
+ one install's taste shipped as product.
+
+ Reach for this before create_design_system so the choice is informed, and
+ pass the group names you want as `starter_role_groups`.
+ """
+ return {"groups": describe_groups(), "default_prefix": DEFAULT_TOKEN_PREFIX}
+
+
async def list_design_systems() -> dict:
"""List your design systems. An empty list is normal — most installs have none."""
uid = current_user_id()
@@ -350,6 +384,7 @@ async def set_project_design_system(project_id: int, design_system_id: int = 0)
def register(mcp) -> None:
for fn in (
create_design_system,
+ list_starter_role_groups,
list_design_systems,
get_design_system,
resolve_design_system,
diff --git a/src/scribe/routes/design_systems.py b/src/scribe/routes/design_systems.py
index 6487ed5..1bb3628 100644
--- a/src/scribe/routes/design_systems.py
+++ b/src/scribe/routes/design_systems.py
@@ -19,6 +19,10 @@ from quart import Blueprint, g, jsonify, request
from scribe.auth import login_required
from scribe.services import design_systems as ds_svc
+from scribe.services.design_starter_roles import (
+ DEFAULT_TOKEN_PREFIX,
+ describe_groups,
+)
from scribe.services.design_systems import DesignSystemCycle
design_systems_bp = Blueprint("design_systems", __name__, url_prefix="/api")
@@ -56,12 +60,27 @@ async def create_design_system():
description=data.get("description") or None,
guidance=data.get("guidance") or None,
parent_id=data.get("parent_id"),
+ starter_role_groups=data.get("starter_role_groups"),
+ token_prefix=data.get("token_prefix") or DEFAULT_TOKEN_PREFIX,
)
if system is None:
return jsonify({"error": "parent design system not found"}), 404
return jsonify(system.to_dict()), 201
+@design_systems_bp.get("/design-systems/starter-roles")
+@login_required
+async def list_starter_role_groups():
+ """The starter role catalogue, for the creation form's checklist.
+
+ Roles and purposes only — no values, ever. See services/design_starter_roles.
+ """
+ return jsonify({
+ "groups": describe_groups(),
+ "default_prefix": DEFAULT_TOKEN_PREFIX,
+ })
+
+
@design_systems_bp.get("/design-systems/")
@login_required
async def get_design_system(design_system_id: int):
diff --git a/src/scribe/services/design_starter_roles.py b/src/scribe/services/design_starter_roles.py
new file mode 100644
index 0000000..ab9c456
--- /dev/null
+++ b/src/scribe/services/design_starter_roles.py
@@ -0,0 +1,185 @@
+"""A starter set of token ROLES, offered when a design system is created.
+
+WHY THIS EXISTS
+---------------
+A literal gets written into a stylesheet when there is no role to reach for.
+That is the mechanism, and this codebase produced a clean demonstration of it:
+the house style had no "text on a filled colour" role, so 76 call sites wrote
+a pure-white literal — not out of defiance, but because nothing existed to write
+instead (#2275). The correction was not a better ban list. It was declaring the
+missing role.
+
+So the useful moment is CREATION. A system whose roles are named on day one
+never presents the occasion for a literal, and never needs a list of values it
+forbids.
+
+WHAT SHIPS AND WHAT DOES NOT (rule #115)
+----------------------------------------
+The ROLES ship: `surface-page`, `text-primary`, `action-destructive` are
+generic CSS-design vocabulary, not one operator's kit. Every install that has a
+page has a page background.
+
+The VALUES never ship. Each token is created with an empty `value_by_mode`, so
+a fresh system is a set of named, deliberately-unanswered questions. No hex
+appears anywhere in this file, and none should ever be added to it — a default
+palette would be this operator's palette wearing product clothes.
+
+A valueless token is already legible downstream: `render_stylesheet` emits it as
+a commented-out declaration in its group (#2299), and `stylesheet_for_system`
+reports it under `valueless`. So a blank role reads as "to be decided" rather
+than as breakage, without anything new.
+
+THE PREFIX IS THE INSTALL'S
+---------------------------
+`--fs-` is FabledSword's convention, not the product's. The prefix is a
+parameter with a neutral default; a caller that has a house convention passes
+it. Baking `--fs-` in would put one family's naming into every install.
+
+FLAT, NOT PRESET
+----------------
+One list, every group individually skippable, all on by default (operator's
+call, 2026-08-03). Presets keyed to app shape — web / CLI / docs — were
+considered and rejected: they would require the product to hold opinions about
+app categories, and a wrong category is worse than a generic list someone
+prunes once.
+"""
+from __future__ import annotations
+
+DEFAULT_TOKEN_PREFIX = "--ds-"
+
+# group -> (what the group is for, ((role suffix, purpose), ...))
+#
+# Purposes are written as the QUESTION the operator is answering, because that
+# is what an unfilled role is. "Page background, the deepest surface" tells you
+# what to put there; "Colour 1" does not.
+STARTER_ROLE_GROUPS: dict[str, tuple[str, tuple[tuple[str, str], ...]]] = {
+ "surface": (
+ "Backgrounds, by elevation",
+ (
+ ("surface-page", "Page background, the deepest surface"),
+ ("surface-raised", "Cards and raised elements"),
+ ("surface-hover", "Hovered surfaces, secondary elevation"),
+ ),
+ ),
+ "text": (
+ "Foreground colours, by emphasis",
+ (
+ ("text-primary", "Primary text on a page or raised surface"),
+ ("text-secondary", "Secondary text and captions"),
+ ("text-tertiary", "Hints and metadata"),
+ # The role whose absence caused 76 literals. It is in the starter
+ # set deliberately: text on a filled colour is NOT the page text
+ # colour, because the surface under it does not change with the
+ # mode while the page does.
+ ("text-on-action", "Text on a filled colour — buttons, badges"),
+ ),
+ ),
+ "action": (
+ "What the user can do — kept separate from the accent, which is identity",
+ (
+ ("action-primary", "The confirming action: Save, Submit"),
+ ("action-secondary", "Non-destructive alternates"),
+ ("action-destructive", "Irreversible actions — delete, revoke"),
+ ),
+ ),
+ "semantic": (
+ "What the system is telling you",
+ (
+ ("success", "Something worked"),
+ ("warning", "Something needs attention"),
+ ("error", "Something failed — distinct from destructive"),
+ ("info", "Neutral information"),
+ ),
+ ),
+ "border": (
+ "Boundaries and dividers",
+ (
+ ("border-color", "The line colour itself"),
+ ("border", "The default structural border, as a shorthand"),
+ ("border-hover", "Border on hover or emphasis"),
+ ("border-active", "Selected or current — the one border that may carry the accent"),
+ ),
+ ),
+ "accent": (
+ "This install's identity — not its actions",
+ (
+ ("accent", "The single signature colour"),
+ ("accent-soft", "Tinted backgrounds — pills, tags"),
+ ("accent-faint", "The faintest wash"),
+ ),
+ ),
+ "radius": (
+ "Corner rounding",
+ (
+ ("radius-sm", "Pills, tags, code spans"),
+ ("radius-md", "Buttons, inputs, small cards"),
+ ("radius-lg", "Cards, panels, modals"),
+ ),
+ ),
+ "space": (
+ "The spacing scale — a gap not on the scale is a decision to justify",
+ tuple((f"space-{i}", f"Spacing step {i}") for i in range(1, 11)),
+ ),
+ "motion": (
+ "Transition timing — motion supports the interaction, never performs",
+ (
+ ("ease", "The one easing curve, used by every transition"),
+ ("dur-fast", "Hovers, colour and border changes"),
+ ("dur-base", "Most state changes"),
+ ("dur-slow", "Larger surface or layout shifts"),
+ ),
+ ),
+ "state": (
+ "Cross-cutting states that are otherwise improvised per view",
+ (
+ ("disabled-opacity", "Opacity for disabled controls"),
+ ("overlay", "Scrim behind modals and dialogs"),
+ ),
+ ),
+}
+
+ALL_GROUPS: tuple[str, ...] = tuple(STARTER_ROLE_GROUPS)
+
+
+def starter_tokens(
+ groups: list[str] | tuple[str, ...] | None = None,
+ prefix: str = DEFAULT_TOKEN_PREFIX,
+) -> list[dict]:
+ """Token rows for the chosen groups — names and purposes only, no values.
+
+ `groups` of None means every group; an empty list means none, which is a
+ real answer and not the same as None. An operator who wants three tokens
+ should be able to get three.
+
+ Unknown group names are ignored rather than raising: this feeds a
+ checkbox list, and a stale name from an older client should not fail a
+ creation that is otherwise fine.
+ """
+ chosen = ALL_GROUPS if groups is None else [g for g in groups if g in STARTER_ROLE_GROUPS]
+ rows: list[dict] = []
+ for group in chosen:
+ _, roles = STARTER_ROLE_GROUPS[group]
+ for index, (suffix, purpose) in enumerate(roles, start=1):
+ rows.append({
+ "name": f"{prefix}{suffix}",
+ "group_name": group,
+ "purpose": purpose,
+ # Empty, not absent: the column is NOT NULL with a {} default,
+ # so absence has exactly one spelling here as it does there.
+ "value_by_mode": {},
+ "order_index": index,
+ })
+ return rows
+
+
+def describe_groups() -> list[dict]:
+ """The catalogue, for a UI to render as a checklist."""
+ return [
+ {
+ "group": group,
+ "description": description,
+ "token_count": len(roles),
+ "names": [suffix for suffix, _ in roles],
+ }
+ for group, (description, roles) in STARTER_ROLE_GROUPS.items()
+ ]
diff --git a/src/scribe/services/design_systems.py b/src/scribe/services/design_systems.py
index e4bb63b..522829d 100644
--- a/src/scribe/services/design_systems.py
+++ b/src/scribe/services/design_systems.py
@@ -27,6 +27,10 @@ from scribe.services.design_stylesheet import (
duplicate_values,
render_stylesheet,
)
+from scribe.services.design_starter_roles import (
+ DEFAULT_TOKEN_PREFIX,
+ starter_tokens,
+)
from scribe.services.design_cascade import (
ResolvedToken,
ancestry,
@@ -79,11 +83,23 @@ async def create_design_system(
description: str | None = None,
guidance: str | None = None,
parent_id: int | None = None,
+ starter_role_groups: list[str] | None = None,
+ token_prefix: str = DEFAULT_TOKEN_PREFIX,
) -> DesignSystem | None:
"""Create a system, with or without a parent.
Returns None when `parent_id` names a system the caller may not write —
which, per the ACL, means one they do not own.
+
+ `starter_role_groups` seeds the system with named, VALUELESS token roles
+ (#2349) — the moment a role is missing is the moment a literal gets written
+ instead, so the cheapest time to name them is now. Pass a list of group
+ names to choose, `[]` for none, or None for none.
+
+ None and `[]` deliberately mean the same thing here, unlike in
+ `starter_tokens` where None means "all": creation must not seed 40 rows
+ into a system whose caller never asked. Opting in is the caller's job, and
+ the UI's default of everything-checked lives in the UI.
"""
if parent_id is not None and not await access.can_write_design_system(
user_id, parent_id
@@ -100,6 +116,11 @@ async def create_design_system(
session.add(system)
await session.commit()
await session.refresh(system)
+
+ if starter_role_groups:
+ for row in starter_tokens(starter_role_groups, prefix=token_prefix):
+ session.add(DesignToken(design_system_id=system.id, **row))
+ await session.commit()
return system
diff --git a/tests/test_design_starter_roles.py b/tests/test_design_starter_roles.py
new file mode 100644
index 0000000..7f9e79c
--- /dev/null
+++ b/tests/test_design_starter_roles.py
@@ -0,0 +1,136 @@
+"""The starter role set (#2349).
+
+The premise: a literal gets written when there is no role to reach for. This
+codebase demonstrated it — the house style had no "text on a filled colour"
+role, so 76 call sites wrote `color: #fff` (#2275). Naming the roles at
+creation removes the occasion.
+
+The tests that matter here are about the BOUNDARY, not the content. Roles ship
+with the product; values never do. A default palette would be one operator's
+taste shipped as product code (rule #115), and it would be very easy to add by
+accident while "being helpful".
+"""
+import re
+
+import pytest
+
+from scribe.services import design_starter_roles as roles
+
+
+def test_no_role_carries_a_VALUE():
+ """THE rule-115 guard. Every seeded token is a named question with no
+ answer. The moment one ships a hex, the product is prescribing an install's
+ palette."""
+ for row in roles.starter_tokens():
+ assert row["value_by_mode"] == {}, f"{row['name']} shipped a value"
+
+
+def _colour_literals(text: str) -> list[str]:
+ """Hex colours in `text`, NOT counting issue references.
+
+ `#2275` is four hex-valid digits and also how this codebase cites an issue —
+ so the naive pattern flags its own documentation, which is the third time
+ that has happened here (#2353). A real colour either contains a letter
+ a–f or is a full 6/8-digit value; an all-decimal 3- or 4-digit match is an
+ issue number.
+ """
+ out = []
+ for m in re.findall(r"#[0-9a-fA-F]{3,8}\b", text):
+ digits = m[1:]
+ if len(digits) not in (3, 4, 6, 8):
+ continue
+ if len(digits) in (6, 8) or any(c in "abcdefABCDEF" for c in digits):
+ out.append(m)
+ return out
+
+
+def test_the_module_contains_no_colour_literals_at_all():
+ """Belt and braces on the above, and the stronger claim: not just that
+ tokens are blank, but that no palette hides in a comment or a docstring
+ waiting to be pasted in. Checks the SOURCE, not the output."""
+ import pathlib
+ src = pathlib.Path(roles.__file__).read_text()
+ hexes = _colour_literals(src)
+ assert not hexes, f"colour literals in product code: {hexes}"
+
+
+def test_the_colour_check_does_not_flag_issue_references():
+ """Pins the exclusion above, because without it this file fails on its own
+ citations and the obvious 'fix' is to delete the check."""
+ assert _colour_literals("see #2275 and #2349") == []
+ assert _colour_literals("color: #fff") == ["#fff"]
+ assert _colour_literals("#E8E4D8 on #14171A") == ["#E8E4D8", "#14171A"]
+ assert _colour_literals("#000000") == ["#000000"]
+
+
+def test_every_group_is_individually_selectable():
+ """Operator's call: one flat list, all skippable. An install that wants
+ three tokens must be able to get three."""
+ only_text = roles.starter_tokens(["text"])
+ assert {r["group_name"] for r in only_text} == {"text"}
+ assert len(only_text) == 4
+
+
+def test_empty_selection_yields_nothing_and_is_not_the_same_as_None():
+ """`[]` is a real answer — "none of them" — and must not be read as
+ "unspecified, so give me everything". Getting this backwards would seed 40
+ rows into a system whose creator explicitly declined."""
+ assert roles.starter_tokens([]) == []
+ assert len(roles.starter_tokens(None)) > 30
+
+
+def test_unknown_group_names_are_ignored_not_fatal():
+ """This feeds a checkbox list. A stale name from an older client should not
+ fail an otherwise-fine creation."""
+ out = roles.starter_tokens(["text", "not-a-real-group"])
+ assert {r["group_name"] for r in out} == {"text"}
+
+
+def test_the_prefix_is_the_installs_choice():
+ """`--fs-` is FabledSword's convention, not the product's. Baking it in
+ would put one family's naming into every install."""
+ assert all(r["name"].startswith("--ds-") for r in roles.starter_tokens(["text"]))
+ custom = roles.starter_tokens(["text"], prefix="--acme-")
+ assert all(r["name"].startswith("--acme-") for r in custom)
+ assert "--acme-text-primary" in {r["name"] for r in custom}
+
+
+def test_text_on_action_is_in_the_starter_set():
+ """The specific role whose absence produced 76 literals. It is separate
+ from text-primary on purpose: the surfaces it sits on do not change with
+ the mode, while the page does — so reusing text-primary there passes in
+ dark and fails contrast in light (#2275)."""
+ names = {r["name"] for r in roles.starter_tokens(["text"])}
+ assert "--ds-text-on-action" in names
+ assert "--ds-text-primary" in names
+
+
+def test_names_are_valid_custom_properties():
+ """They go straight into a stylesheet; an invalid name is a silent no-op
+ rather than an error, which is the worst failure mode available."""
+ valid = re.compile(r"^--[A-Za-z0-9_-]+$")
+ for row in roles.starter_tokens():
+ assert valid.match(row["name"]), row["name"]
+
+
+def test_no_duplicate_names_across_the_whole_set():
+ """A design system has a partial-unique index on (system, name); a
+ duplicate in the starter set would make creation fail at the DB with a
+ constraint error rather than anything legible."""
+ names = [r["name"] for r in roles.starter_tokens()]
+ assert len(names) == len(set(names))
+
+
+def test_every_role_states_a_purpose():
+ """An unfilled role is only useful if it says what belongs there. "Colour
+ 1" is a blank with extra steps."""
+ for row in roles.starter_tokens():
+ assert row["purpose"].strip(), row["name"]
+
+
+def test_describe_groups_matches_what_starter_tokens_produces():
+ """The catalogue a UI renders and the rows creation writes must not drift —
+ a checklist offering a group that seeds nothing is a lie in the UI."""
+ described = {g["group"]: g["token_count"] for g in roles.describe_groups()}
+ for group, count in described.items():
+ assert len(roles.starter_tokens([group])) == count
diff --git a/tests/test_routes_design_systems.py b/tests/test_routes_design_systems.py
index 8b0b54a..eca7396 100644
--- a/tests/test_routes_design_systems.py
+++ b/tests/test_routes_design_systems.py
@@ -85,7 +85,7 @@ def test_agent_and_web_surfaces_stay_at_parity():
"resolve_design_system", "update_design_system", "delete_design_system",
"create_design_token", "list_design_tokens", "update_design_token",
"delete_design_token", "set_project_design_system",
- "get_design_system_stylesheet",
+ "get_design_system_stylesheet", "list_starter_role_groups",
):
assert callable(getattr(tools, name)), f"MCP tool missing: {name}"
assert callable(getattr(routes, name)), f"REST route missing: {name}"
From 4852b0d3dfdadf032de01521445bac78da7f2c54 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Mon, 3 Aug 2026 11:42:11 -0400
Subject: [PATCH 02/11] test(design): add the starter-roles endpoint to the URL
enumeration
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
CI caught it: tests/test_routes_design_systems.py enumerates every routed rule,
and I added a handler without adding its rule. The guard doing exactly what its
docstring says it is for.
Two enumerations govern this blueprint and I had only extended one — the
parity list (handlers exist on both surfaces) but not the URL list (handlers
are actually routed). They catch different failures, which is why both exist.
Noted in place: /api/design-systems/starter-roles is a static segment sharing a
prefix with /api/design-systems/. That pairing is where a
silently-shadowed route hides, so it is worth being explicit that the int
converter cannot match "starter-roles" — verified rather than assumed.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
---
tests/test_routes_design_systems.py | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/tests/test_routes_design_systems.py b/tests/test_routes_design_systems.py
index eca7396..8ad2389 100644
--- a/tests/test_routes_design_systems.py
+++ b/tests/test_routes_design_systems.py
@@ -44,6 +44,11 @@ def test_every_endpoint_is_reachable_on_the_app():
}
assert rules == {
"/api/design-systems",
+ # Static segment, declared before the rule reads it — Quart's
+ # int converter will not match "starter-roles", so the two cannot
+ # collide. Worth stating: a static-vs-dynamic sibling on the same prefix
+ # is exactly where a silently-shadowed route hides.
+ "/api/design-systems/starter-roles",
"/api/design-systems/",
"/api/design-systems//resolved",
"/api/design-systems//stylesheet",
From 174ec8af46291c6311c32c1784b552bf0760be4b Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Mon, 3 Aug 2026 11:45:48 -0400
Subject: [PATCH 03/11] feat(design): the starter-role checklist, in the
creation UI
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Rule #27 — the backend half shipped without a surface an operator can touch,
so this is the other half of #2349.
StarterRolePicker is a component rather than inline markup because
DesignSystemsView has TWO creation forms: the empty state is a sibling branch
of the body, not a parent, so a form written into one is unreachable from the
other. Inlining the checklist would have made it the next thing in this
codebase defined twice and free to drift — which is what the button migration
spent nine commits undoing.
What it offers is names and purposes, never values. "Named now, valued later":
a role you haven't filled shows as to-be-decided, while a role that doesn't
exist is what gets written as a literal instead. Every group unchecks
individually, and the prefix is editable because `--fs-` is one family's
convention, not the product's.
Three deliberate details:
- All groups checked by DEFAULT, and that default lives in the UI, not the
service. create_design_system treats None and [] alike (seed nothing) so it
can never write 40 rows into a system whose caller never asked; a UI default
is visible and reversible before the click. Different layers, different
safe answers.
- A failed catalogue fetch is NOT fatal and does not read as an error. Starter
roles are an accelerator, not a prerequisite — the form still creates, and
the operator adds tokens by hand.
- The refs are not cleared after a successful create. The picker owns them and
re-seeds on mount; resetting here would race that and silently create the
next system with no roles.
props + defineEmits rather than defineModel, matching TagInput and the rest of
components/. defineModel is available (Vue 3.5) and would be shorter, but being
the only file in the codebase using a different binding idiom costs more than
the lines it saves.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
---
frontend/src/api/designSystems.ts | 17 ++
frontend/src/components/StarterRolePicker.vue | 212 ++++++++++++++++++
frontend/src/views/DesignSystemsView.vue | 18 ++
3 files changed, 247 insertions(+)
create mode 100644 frontend/src/components/StarterRolePicker.vue
diff --git a/frontend/src/api/designSystems.ts b/frontend/src/api/designSystems.ts
index 89cb9ba..e3d7f21 100644
--- a/frontend/src/api/designSystems.ts
+++ b/frontend/src/api/designSystems.ts
@@ -74,11 +74,28 @@ export const fetchDesignSystems = () =>
export const fetchDesignSystem = (id: number) =>
apiGet(`/api/design-systems/${id}`);
+export interface StarterRoleGroup {
+ group: string;
+ description: string;
+ token_count: number;
+ names: string[];
+}
+
+/** The starter token ROLES offered at creation — names and purposes, never
+ * values. A default palette would be one install's taste shipped as product
+ * (rule #115), so the values are always the operator's to fill. */
+export const listStarterRoleGroups = () =>
+ apiGet<{ groups: StarterRoleGroup[]; default_prefix: string }>(
+ "/api/design-systems/starter-roles",
+ );
+
export const createDesignSystem = (body: {
title: string;
description?: string;
guidance?: string;
parent_id?: number | null;
+ starter_role_groups?: string[];
+ token_prefix?: string;
}) => apiPost("/api/design-systems", body);
/** Omit `parent_id` to leave it alone; send `null` to make the system a family. */
diff --git a/frontend/src/components/StarterRolePicker.vue b/frontend/src/components/StarterRolePicker.vue
new file mode 100644
index 0000000..0011fdb
--- /dev/null
+++ b/frontend/src/components/StarterRolePicker.vue
@@ -0,0 +1,212 @@
+
+
+
+
Loading starter roles…
+
+
+
+ Starter roles unavailable — you can add tokens by hand after creating.
+
+
+
+
+
+
diff --git a/frontend/src/views/DesignSystemsView.vue b/frontend/src/views/DesignSystemsView.vue
index 805f859..ff50efd 100644
--- a/frontend/src/views/DesignSystemsView.vue
+++ b/frontend/src/views/DesignSystemsView.vue
@@ -42,6 +42,7 @@ import {
import DesignTabs from "@/components/DesignTabs.vue";
import { ApiError } from "@/api/client";
import { useToastStore } from "@/stores/toast";
+import StarterRolePicker from "@/components/StarterRolePicker.vue";
const toast = useToastStore();
@@ -148,6 +149,10 @@ const newTitle = ref("");
const newDescription = ref("");
const newParentId = ref(null);
const creating = ref(false);
+// Starter roles (#2349). The picker fills these on mount; empty means the
+// operator unchecked everything, which is a real answer.
+const starterGroups = ref([]);
+const tokenPrefix = ref("");
async function submitCreate() {
const title = newTitle.value.trim();
@@ -158,11 +163,16 @@ async function submitCreate() {
title,
description: newDescription.value.trim() || undefined,
parent_id: newParentId.value,
+ starter_role_groups: starterGroups.value.length ? starterGroups.value : undefined,
+ token_prefix: tokenPrefix.value.trim() || undefined,
});
newTitle.value = "";
newDescription.value = "";
newParentId.value = null;
showCreate.value = false;
+ // NOT reset: the picker owns these and re-seeds on mount. Clearing them
+ // here would race the next mount and silently create the following system
+ // with no roles at all.
await loadSystems();
selectedId.value = created.id;
toast.show(`Created ${created.title}`);
@@ -540,6 +550,10 @@ function isColourish(value: string): boolean {
placeholder="What it covers"
/>
+
+
From bd60d679d90477f670a6e1a7791ef0128039751f Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Mon, 3 Aug 2026 11:52:04 -0400
Subject: [PATCH 04/11] =?UTF-8?q?refactor(theme):=20remove=20184=20var()?=
=?UTF-8?q?=20fallbacks=20=E2=80=94=20every=20one=20was=20unreachable?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
#2277 counted ~150 "raw colour literals bypassing the tokens". Measuring them
told a different story: 184 sat in `var(--token, #fallback)` position, and a
check against theme.css shows every one of those tokens IS declared. So the
fallbacks could not render. Not drift — vestigial.
They were also not this palette. The most common were Tailwind and Flat-UI
defaults — #6366f1 indigo, #22c55e green, #f59e0b amber, #3b82f6 blue,
#e74c3c and #27ae60 — a second, unsanctioned colour scheme sitting in the
codebase looking like the app's colours to anyone reading it.
Removing them is not tidying. #2319's lesson is that a fallback is WORSE than a
missing token: a missing token renders as nothing and someone eventually
notices, while a fallback renders something plausible forever. These 184 were
one token rename away from silently repainting the app in Tailwind. The design
token check would catch the rename — but the fallback is precisely the thing
that would make it invisible if the check were ever bypassed.
Literal count 152 -> 45, which matters beyond the number: a report that is
mostly unreachable noise is one people stop reading, and then it stops working
while still passing. What remains should be genuinely worth looking at.
Done with a paren-aware transform, not a regex — `var(--x, rgba(0,0,0,.5))`
nests parens and `[^)]+` would cut at the first one and leave `))` behind.
Verified after: every changed line is a fallback strip and nothing else, and
every var() reference still resolves.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
---
frontend/src/App.vue | 6 ++--
frontend/src/assets/editor-shared.css | 4 +--
frontend/src/assets/prose.css | 4 +--
frontend/src/components/DiffView.vue | 12 +++----
frontend/src/components/HistoryPanel.vue | 26 +++++++-------
frontend/src/components/InlineAssistPanel.vue | 20 +++++------
frontend/src/components/NotificationBell.vue | 2 +-
frontend/src/components/RecurrenceEditor.vue | 2 +-
frontend/src/components/SystemsSection.vue | 4 +--
frontend/src/components/TagInput.vue | 2 +-
frontend/src/components/TaskCard.vue | 12 +++----
.../src/components/WorkspaceNoteEditor.vue | 2 +-
.../src/components/WorkspaceTaskPanel.vue | 12 +++----
.../src/components/rules/PlanRulesPanel.vue | 4 +--
.../src/components/rules/ProjectRulesTab.vue | 34 +++++++++----------
.../components/rules/RuleEditorSlideOver.vue | 10 +++---
.../src/components/rules/RuleListPane.vue | 6 ++--
.../components/rules/RulebookDetailPane.vue | 12 +++----
.../src/components/rules/RulebookListPane.vue | 14 ++++----
frontend/src/views/GraphView.vue | 4 +--
frontend/src/views/KnowledgeView.vue | 26 +++++++-------
frontend/src/views/NoteEditorView.vue | 6 ++--
frontend/src/views/ProjectListView.vue | 2 +-
frontend/src/views/ProjectView.vue | 24 ++++++-------
frontend/src/views/RulesView.vue | 4 +--
frontend/src/views/SettingsView.vue | 20 +++++------
frontend/src/views/SharedWithMeView.vue | 2 +-
frontend/src/views/SnippetDetailView.vue | 6 ++--
frontend/src/views/SnippetEditorView.vue | 4 +--
frontend/src/views/SnippetListView.vue | 20 +++++------
frontend/src/views/TaskEditorView.vue | 24 ++++++-------
frontend/src/views/TaskViewerView.vue | 20 +++++------
frontend/src/views/TrashView.vue | 2 +-
33 files changed, 176 insertions(+), 176 deletions(-)
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 25b887f..044e3d6 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -300,7 +300,7 @@ onUnmounted(() => {
.shortcuts-overlay {
position: fixed;
inset: 0;
- background: var(--color-overlay, rgba(0, 0, 0, 0.45));
+ background: var(--color-overlay);
z-index: 9000;
display: flex;
align-items: center;
@@ -309,8 +309,8 @@ onUnmounted(() => {
.shortcuts-panel {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
- border-radius: var(--radius-md, 8px);
- box-shadow: 0 8px 32px var(--color-shadow, rgba(0,0,0,0.2));
+ border-radius: var(--radius-md);
+ box-shadow: 0 8px 32px var(--color-shadow);
width: min(420px, 92vw);
overflow: hidden;
}
diff --git a/frontend/src/assets/editor-shared.css b/frontend/src/assets/editor-shared.css
index eaecc14..fa0af46 100644
--- a/frontend/src/assets/editor-shared.css
+++ b/frontend/src/assets/editor-shared.css
@@ -98,8 +98,8 @@
color: var(--fs-text-on-action);
}
.tag-pill.applied {
- background: var(--color-success, #2ecc71);
- border-color: var(--color-success, #2ecc71);
+ background: var(--color-success);
+ border-color: var(--color-success);
color: var(--fs-text-on-action);
cursor: default;
}
diff --git a/frontend/src/assets/prose.css b/frontend/src/assets/prose.css
index b3cf455..6056d48 100644
--- a/frontend/src/assets/prose.css
+++ b/frontend/src/assets/prose.css
@@ -219,7 +219,7 @@
}
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
- color: var(--color-text-muted, var(--color-text-secondary));
+ color: var(--color-text-muted);
content: attr(data-placeholder);
float: left;
height: 0;
@@ -234,5 +234,5 @@
}
.tiptap-wrapper:focus-within {
- box-shadow: var(--focus-ring, 0 0 0 2px var(--color-primary));
+ box-shadow: var(--focus-ring);
}
diff --git a/frontend/src/components/DiffView.vue b/frontend/src/components/DiffView.vue
index 95531c0..01fb67f 100644
--- a/frontend/src/components/DiffView.vue
+++ b/frontend/src/components/DiffView.vue
@@ -110,8 +110,8 @@ function markerFor(type: DiffLine['type']): string {
font-weight: 600;
}
-.diff-summary-ins { color: var(--color-success, #2ecc71); }
-.diff-summary-del { color: var(--color-danger, #e74c3c); }
+.diff-summary-ins { color: var(--color-success); }
+.diff-summary-del { color: var(--color-danger); }
.diff-scroll {
flex: 1;
@@ -136,13 +136,13 @@ function markerFor(type: DiffLine['type']): string {
}
.diff-delete {
- background: color-mix(in srgb, var(--color-danger, #e74c3c) 12%, transparent);
- color: var(--color-danger, #e74c3c);
+ background: color-mix(in srgb, var(--color-danger) 12%, transparent);
+ color: var(--color-danger);
}
.diff-insert {
- background: color-mix(in srgb, var(--color-success, #2ecc71) 12%, transparent);
- color: var(--color-success, #2ecc71);
+ background: color-mix(in srgb, var(--color-success) 12%, transparent);
+ color: var(--color-success);
}
.diff-equal {
diff --git a/frontend/src/components/HistoryPanel.vue b/frontend/src/components/HistoryPanel.vue
index a4ddd89..4b2211f 100644
--- a/frontend/src/components/HistoryPanel.vue
+++ b/frontend/src/components/HistoryPanel.vue
@@ -403,12 +403,12 @@ onMounted(loadVersions);
font-size: 0.85em;
line-height: 1;
}
-.pin-badge-manual { color: var(--color-primary, #6366f1); }
-.pin-badge-auto { color: var(--color-text-muted, rgba(255, 255, 255, 0.5)); }
+.pin-badge-manual { color: var(--color-primary); }
+.pin-badge-auto { color: var(--color-text-muted); }
.history-item-label {
font-size: 0.72rem;
- color: var(--color-primary, #6366f1);
+ color: var(--color-primary);
font-style: italic;
margin-top: 0.15rem;
overflow: hidden;
@@ -430,7 +430,7 @@ onMounted(loadVersions);
}
.pin-state {
font-style: italic;
- color: var(--color-text-muted, rgba(255, 255, 255, 0.6));
+ color: var(--color-text-muted);
flex: 1;
min-width: 0;
overflow: hidden;
@@ -442,13 +442,13 @@ onMounted(loadVersions);
font-size: 0.78rem;
background: transparent;
color: inherit;
- border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
+ border: 1px solid var(--color-border);
border-radius: 999px;
cursor: pointer;
}
.btn-pin:hover:not(:disabled), .btn-pin-edit:hover:not(:disabled) {
background: rgba(99, 102, 241, 0.12);
- border-color: var(--color-primary, #6366f1);
+ border-color: var(--color-primary);
}
.btn-unpin:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.10);
@@ -463,27 +463,27 @@ onMounted(loadVersions);
flex: 1;
padding: 0.3rem 0.5rem;
font-size: 0.85rem;
- background: var(--color-input-bg, rgba(255, 255, 255, 0.03));
- border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
- border-radius: var(--radius-sm, 4px);
+ background: var(--color-input-bg);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
color: inherit;
}
.pin-label-input:focus {
outline: none;
- border-color: var(--color-primary, #6366f1);
+ border-color: var(--color-primary);
}
.btn-pin-save, .btn-pin-cancel {
padding: 0.3rem 0.7rem;
font-size: 0.78rem;
background: transparent;
color: inherit;
- border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
- border-radius: var(--radius-sm, 4px);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
cursor: pointer;
}
.btn-pin-save:hover:not(:disabled) {
background: rgba(99, 102, 241, 0.12);
- border-color: var(--color-primary, #6366f1);
+ border-color: var(--color-primary);
}
.btn-pin-save:disabled, .btn-pin-cancel:disabled,
.btn-pin:disabled, .btn-pin-edit:disabled, .btn-unpin:disabled {
diff --git a/frontend/src/components/InlineAssistPanel.vue b/frontend/src/components/InlineAssistPanel.vue
index f43dceb..741a5e0 100644
--- a/frontend/src/components/InlineAssistPanel.vue
+++ b/frontend/src/components/InlineAssistPanel.vue
@@ -135,8 +135,8 @@ const markers: Record = {
flex-shrink: 0;
}
.iap-btn-cancel:hover {
- border-color: var(--color-danger, #e74c3c);
- color: var(--color-danger, #e74c3c);
+ border-color: var(--color-danger);
+ color: var(--color-danger);
}
.iap-stream-preview {
@@ -191,19 +191,19 @@ const markers: Record = {
font-weight: var(--fs-weight-medium);
}
.iap-btn-accept {
- background: var(--color-success, #22c55e);
+ background: var(--color-success);
color: var(--fs-text-on-action);
}
.iap-btn-accept:hover { opacity: 0.85; }
.iap-btn-reject {
- background: var(--color-bg-card, var(--color-bg));
+ background: var(--color-bg-card);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.iap-btn-reject:hover {
- border-color: var(--color-danger, #e74c3c);
- color: var(--color-danger, #e74c3c);
+ border-color: var(--color-danger);
+ color: var(--color-danger);
}
/* ── Diff ── */
@@ -226,12 +226,12 @@ const markers: Record = {
.iap-diff-equal { color: var(--color-text-muted); }
.iap-diff-delete {
- background: color-mix(in srgb, var(--color-danger, #e74c3c) 10%, transparent);
- color: var(--color-danger, #e74c3c);
+ background: color-mix(in srgb, var(--color-danger) 10%, transparent);
+ color: var(--color-danger);
}
.iap-diff-insert {
- background: color-mix(in srgb, var(--color-success, #22c55e) 10%, transparent);
- color: var(--color-success, #22c55e);
+ background: color-mix(in srgb, var(--color-success) 10%, transparent);
+ color: var(--color-success);
}
.iap-diff-marker {
diff --git a/frontend/src/components/NotificationBell.vue b/frontend/src/components/NotificationBell.vue
index d24c659..d3318d5 100644
--- a/frontend/src/components/NotificationBell.vue
+++ b/frontend/src/components/NotificationBell.vue
@@ -81,7 +81,7 @@ onUnmounted(() => {
position: absolute;
top: -5px;
right: -5px;
- background: var(--color-danger, #ef4444);
+ background: var(--color-danger);
color: var(--fs-text-on-action);
font-size: 0.6rem;
font-weight: 700;
diff --git a/frontend/src/components/RecurrenceEditor.vue b/frontend/src/components/RecurrenceEditor.vue
index 092426f..865e054 100644
--- a/frontend/src/components/RecurrenceEditor.vue
+++ b/frontend/src/components/RecurrenceEditor.vue
@@ -159,7 +159,7 @@ const calendarDayMax = computed(() =>
.rec-num-input {
width: 4rem;
padding: 0.25rem 0.4rem;
- border: 1px solid var(--color-input-border, var(--color-border));
+ border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
diff --git a/frontend/src/components/SystemsSection.vue b/frontend/src/components/SystemsSection.vue
index 1564790..b7b963d 100644
--- a/frontend/src/components/SystemsSection.vue
+++ b/frontend/src/components/SystemsSection.vue
@@ -445,7 +445,7 @@ async function confirmDelete() {
}
.action-btn:hover { background: var(--color-bg-secondary); color: var(--color-text); }
.action-btn:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 1px; opacity: 1; }
-.action-delete:hover { color: var(--color-danger, #e74c3c); }
+.action-delete:hover { color: var(--color-danger); }
/* ── Empty ────────────────────────────────────────────────────── */
.systems-empty {
@@ -483,7 +483,7 @@ async function confirmDelete() {
/* ── Modal ────────────────────────────────────────────────────── */
.modal-overlay {
position: fixed; inset: 0;
- background: var(--color-overlay, rgba(0,0,0,0.45));
+ background: var(--color-overlay);
display: flex; align-items: center; justify-content: center;
z-index: 200;
}
diff --git a/frontend/src/components/TagInput.vue b/frontend/src/components/TagInput.vue
index 7fc8a1f..833f4e4 100644
--- a/frontend/src/components/TagInput.vue
+++ b/frontend/src/components/TagInput.vue
@@ -222,7 +222,7 @@ function focusInput() {
}
.tag-autocomplete-item:hover,
.tag-autocomplete-item.selected {
- background: var(--color-bg-hover, color-mix(in srgb, var(--color-primary) 8%, transparent));
+ background: var(--color-bg-hover);
color: var(--color-primary);
}
diff --git a/frontend/src/components/TaskCard.vue b/frontend/src/components/TaskCard.vue
index d933760..9bb9179 100644
--- a/frontend/src/components/TaskCard.vue
+++ b/frontend/src/components/TaskCard.vue
@@ -144,19 +144,19 @@ function isOverdue(): boolean {
opacity: 0.8;
}
.dot-todo {
- background: var(--color-status-todo, #94a3b8);
- border: 2px solid var(--color-status-todo, #94a3b8);
+ background: var(--color-status-todo);
+ border: 2px solid var(--color-status-todo);
background: transparent;
border: 2px solid var(--color-text-muted);
}
.dot-in-progress {
- background: var(--color-status-in-progress, #3b82f6);
+ background: var(--color-status-in-progress);
}
.dot-done {
- background: var(--color-status-done, #22c55e);
+ background: var(--color-status-done);
}
.dot-cancelled {
- background: var(--color-status-cancelled, #6b7280);
+ background: var(--color-status-cancelled);
}
.task-title-compact {
@@ -190,7 +190,7 @@ function isOverdue(): boolean {
flex-shrink: 0;
}
.due-compact.overdue {
- color: var(--color-danger, #e74c3c);
+ color: var(--color-danger);
font-weight: 600;
}
/* Full layout */
diff --git a/frontend/src/components/WorkspaceNoteEditor.vue b/frontend/src/components/WorkspaceNoteEditor.vue
index f44855f..83fe67f 100644
--- a/frontend/src/components/WorkspaceNoteEditor.vue
+++ b/frontend/src/components/WorkspaceNoteEditor.vue
@@ -463,7 +463,7 @@ defineExpose({ reload: loadProjectNotes });
display: flex;
flex-direction: column;
overflow: hidden;
- background: var(--color-bg-card, var(--color-bg-secondary));
+ background: var(--color-bg-card);
}
.rail-header {
diff --git a/frontend/src/components/WorkspaceTaskPanel.vue b/frontend/src/components/WorkspaceTaskPanel.vue
index 4f6d305..bc32830 100644
--- a/frontend/src/components/WorkspaceTaskPanel.vue
+++ b/frontend/src/components/WorkspaceTaskPanel.vue
@@ -387,7 +387,7 @@ defineExpose({ reload: loadAll });
.task-add-input {
flex: 1;
- background: var(--color-input-bg, var(--color-bg));
+ background: var(--color-input-bg);
border: 1px solid var(--color-border);
border-radius: 5px;
padding: 0.28rem 0.5rem;
@@ -413,7 +413,7 @@ defineExpose({ reload: loadAll });
gap: 0.4rem;
width: 100%;
padding: 0.4rem 0.65rem;
- background: var(--color-surface-raised, color-mix(in srgb, var(--color-surface) 92%, var(--color-text)));
+ background: var(--color-surface-raised);
border: none;
cursor: pointer;
text-align: left;
@@ -433,7 +433,7 @@ defineExpose({ reload: loadAll });
text-transform: capitalize;
}
.ms-status-active { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: var(--color-primary); }
-.ms-status-completed { background: color-mix(in srgb, var(--color-success, #27ae60) 15%, transparent); color: var(--color-success, #27ae60); }
+.ms-status-completed { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
.task-items {
list-style: none;
@@ -466,7 +466,7 @@ defineExpose({ reload: loadAll });
justify-content: center;
}
.status-dot.status-in_progress { border-color: var(--color-primary); color: var(--color-primary); }
-.status-dot.status-done { border-color: var(--color-success, #27ae60); color: var(--color-success, #27ae60); }
+.status-dot.status-done { border-color: var(--color-success); color: var(--color-success); }
.task-title {
flex: 1;
@@ -522,7 +522,7 @@ defineExpose({ reload: loadAll });
margin-left: auto;
}
.status-badge.status-in_progress { border-color: var(--color-primary); color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 10%, transparent); }
-.status-badge.status-done { border-color: var(--color-success, #27ae60); color: var(--color-success, #27ae60); background: color-mix(in srgb, var(--color-success, #27ae60) 10%, transparent); }
+.status-badge.status-done { border-color: var(--color-success); color: var(--color-success); background: color-mix(in srgb, var(--color-success) 10%, transparent); }
.btn-edit-task { margin-left: 0.25rem; }
.btn-edit-task:hover { text-decoration: underline; }
@@ -614,7 +614,7 @@ defineExpose({ reload: loadAll });
flex-shrink: 0;
}
.task-due.overdue {
- color: var(--color-danger, #e74c3c);
+ color: var(--color-danger);
font-weight: 500;
}
diff --git a/frontend/src/components/rules/PlanRulesPanel.vue b/frontend/src/components/rules/PlanRulesPanel.vue
index 4cab350..1796b87 100644
--- a/frontend/src/components/rules/PlanRulesPanel.vue
+++ b/frontend/src/components/rules/PlanRulesPanel.vue
@@ -46,7 +46,7 @@ watch(() => props.projectId, load);
diff --git a/frontend/src/components/rules/RuleEditorSlideOver.vue b/frontend/src/components/rules/RuleEditorSlideOver.vue
index 72e1c6c..fee2105 100644
--- a/frontend/src/components/rules/RuleEditorSlideOver.vue
+++ b/frontend/src/components/rules/RuleEditorSlideOver.vue
@@ -98,8 +98,8 @@ watch(() => props.ruleId, load);
.slide-over {
position: fixed; top: 0; right: 0; bottom: 0;
width: min(520px, 90vw);
- background: var(--color-surface, #18181b);
- border-left: 2px solid var(--color-primary, #6366f1);
+ background: var(--color-surface);
+ border-left: 2px solid var(--color-primary);
padding: 1.5rem;
overflow-y: auto;
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.3);
@@ -110,11 +110,11 @@ header h2 {
font-family: Fraunces, serif; font-style: italic;
}
label { display: block; margin-bottom: 1rem; }
-.required { color: var(--color-primary, #6366f1); }
+.required { color: var(--color-primary); }
input, textarea {
width: 100%; margin-top: 0.25rem;
- background: var(--color-bg, #111113); color: inherit;
- border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
+ background: var(--color-bg); color: inherit;
+ border: 1px solid var(--color-border); border-radius: 6px;
padding: 0.5rem; font: inherit;
font-family: inherit;
}
diff --git a/frontend/src/components/rules/RuleListPane.vue b/frontend/src/components/rules/RuleListPane.vue
index 65ecac4..b33bd6a 100644
--- a/frontend/src/components/rules/RuleListPane.vue
+++ b/frontend/src/components/rules/RuleListPane.vue
@@ -22,18 +22,18 @@ const emit = defineEmits<{
\ No newline at end of file
diff --git a/frontend/src/views/TaskViewerView.vue b/frontend/src/views/TaskViewerView.vue
index 2cc007d..a05e60f 100644
--- a/frontend/src/views/TaskViewerView.vue
+++ b/frontend/src/views/TaskViewerView.vue
@@ -561,7 +561,7 @@ const subTaskProgress = computed(() => {
}
.subtasks-fill {
height: 100%;
- background: var(--color-status-done, #22c55e);
+ background: var(--color-status-done);
border-radius: 2px;
transition: width 0.3s ease;
}
@@ -602,13 +602,13 @@ const subTaskProgress = computed(() => {
border: 2px solid var(--color-text-muted);
}
.dot-in-progress {
- background: var(--color-status-in-progress, #3b82f6);
+ background: var(--color-status-in-progress);
}
.dot-done {
- background: var(--color-status-done, #22c55e);
+ background: var(--color-status-done);
}
.dot-cancelled {
- background: var(--color-text-muted, #6b7280);
+ background: var(--color-text-muted);
}
.sub-title {
flex: 1;
@@ -749,26 +749,26 @@ const subTaskProgress = computed(() => {
/* ── Goal block + auto-summary banner ─────────────────────────────────────── */
.task-goal-display {
- border-left: 2px solid var(--color-border, rgba(255, 255, 255, 0.12));
+ border-left: 2px solid var(--color-border);
padding: 0.4rem 0 0.4rem 0.9rem;
margin: 0.75rem 0 1.25rem;
background: rgba(255, 255, 255, 0.02);
}
.goal-label {
- font-family: var(--font-display, "Fraunces", serif);
+ font-family: var(--font-display);
font-style: italic;
font-size: 0.78rem;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
- color: var(--color-text-muted, rgba(255, 255, 255, 0.5));
+ color: var(--color-text-muted);
margin: 0 0 0.25rem;
}
.goal-text {
margin: 0;
font-size: 0.95rem;
line-height: 1.45;
- color: var(--color-text, inherit);
+ color: var(--color-text);
white-space: pre-wrap;
}
.auto-summary-banner {
@@ -777,11 +777,11 @@ const subTaskProgress = computed(() => {
gap: 0.5rem;
font-size: 0.78rem;
font-style: italic;
- color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
+ color: var(--color-text-muted);
margin: 0 0 0.75rem;
}
.auto-summary-icon {
- color: var(--color-primary, #6366f1);
+ color: var(--color-primary);
font-size: 0.85rem;
}
diff --git a/frontend/src/views/TrashView.vue b/frontend/src/views/TrashView.vue
index 8c087e3..e954f60 100644
--- a/frontend/src/views/TrashView.vue
+++ b/frontend/src/views/TrashView.vue
@@ -68,7 +68,7 @@ onMounted(() => store.fetchTrash());
.batch-count { opacity: 0.6; font-weight: 400; font-size: 0.9em; margin-left: 0.35rem; }
.batch-meta { font-size: 0.82em; opacity: 0.6; margin-top: 0.25rem; }
.batch-actions { display: flex; gap: 0.5rem; flex-shrink: 0; }
-.batch-actions button { border-radius: 6px; padding: 0.35rem 0.7rem; cursor: pointer; border: 1px solid var(--color-border, #2a2a2e); background: none; color: inherit; }
+.batch-actions button { border-radius: 6px; padding: 0.35rem 0.7rem; cursor: pointer; border: 1px solid var(--color-border); background: none; color: inherit; }
.btn-restore:hover { border-color: var(--color-action-primary); color: var(--color-action-primary); }
.btn-purge:hover { border-color: var(--color-action-destructive); color: var(--color-action-destructive); }
From c34454b8406a513e1da240d9b66150128c04df52 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Mon, 3 Aug 2026 11:53:54 -0400
Subject: [PATCH 05/11] =?UTF-8?q?refactor(theme):=20the=20accent=20was=20h?=
=?UTF-8?q?and-written=2016=20times=20=E2=80=94=20now=20derived?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`rgba(91, 74, 138, …)` is Scribe's accent in decimal. It appears sixteen times
across five files at ten different opacities, plus once as #5B4A8A. Change the
accent in the design system and none of them would have moved — which is the
precise failure the token system exists to prevent, hiding in a notation that
does not look like a colour constant.
Now `color-mix(in srgb, var(--color-primary) N%, transparent)`, so every one
follows the accent. The design system already uses this form for its own tints
(--fs-accent-soft, -faint, -wash), so this is the established idiom rather than
a new one.
The CI literal count barely moves (45 -> 44) because its regex matches #hex and
fifteen of these were rgba(). Worth stating plainly: **the count was never the
goal, and the check is blind to this whole class.** An rgba triple is a colour
literal in every sense that matters and the report does not see it.
Not touched: the badge palette in KnowledgeView (#7A6DA8, #fbbf24, #818cf8 for
note/task/plan) and the remaining greys. Those are genuine unmade decisions —
what colour IS a plan badge — not drift, and inventing tokens for them would be
deciding by implementation.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
---
frontend/src/components/AppHeader.vue | 6 +++---
frontend/src/components/NoteCard.vue | 6 +++---
frontend/src/components/TaskCard.vue | 4 ++--
frontend/src/views/KnowledgeView.vue | 14 +++++++-------
frontend/src/views/SettingsView.vue | 2 +-
5 files changed, 16 insertions(+), 16 deletions(-)
diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue
index ac3ef6e..fab666f 100644
--- a/frontend/src/components/AppHeader.vue
+++ b/frontend/src/components/AppHeader.vue
@@ -129,7 +129,7 @@ router.afterEach(() => {
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index 5e9bf81..0d590f7 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -110,15 +110,11 @@ const router = createRouter({
component: () => import("@/views/RulesView.vue"),
},
{
- // Meta-surface, same family as /rules: it describes the app rather than
- // holding the operator's records.
- path: "/design",
- name: "design",
- component: () => import("@/views/DesignView.vue"),
- },
- {
- // The editable half of the same surface: /design is what the browser
- // renders, /design-systems is the record that ought to decide it.
+ // The design systems this install RECORDS — for the projects it tracks,
+ // not for the install itself. There was a sibling `/design` that read the
+ // running app's own stylesheet out of the browser; it could only ever
+ // inspect the instance it was served from, which made it a mirror rather
+ // than a tool (#274).
path: "/design-systems",
name: "design-systems",
component: () => import("@/views/DesignSystemsView.vue"),
diff --git a/frontend/src/utils/designDrift.ts b/frontend/src/utils/designDrift.ts
deleted file mode 100644
index 5bd1332..0000000
--- a/frontend/src/utils/designDrift.ts
+++ /dev/null
@@ -1,235 +0,0 @@
-/**
- * Agreement — does the running app match the design system it was built from?
- *
- * This panel used to compare a design RULEBOOK's prose claims against the live
- * tokens. That rulebook was retired into the design system on 2026-08-01, and
- * the feature spent two days rendering a reassuring empty state instead of
- * failing (#2419). Its replacement is not the same question re-aimed: comparing
- * a design system against a stylesheet generated from that same design system
- * would be a tautology.
- *
- * The question that survives is the one no server can answer. A sheet still has
- * to be LOADED and APPLIED, and until now nothing checked that it was. Three
- * failures live in that gap:
- *
- * absent the app has no such token at all — the sheet was never
- * regenerated after the record changed, or never loaded
- * differs the app has the token with another value — a stale copy of the
- * sheet, or a later rule that overrode it
- * unrecorded the app declares a token in the record's own family that the
- * record has never heard of — hand-editing that outlived its reason
- *
- * SCOPE, and it is a real limit rather than an omission. This compares the
- * record against the TOKENS. A literal hardcoded in a component where a token
- * should be referenced is invisible here, because the drift isn't in the tokens
- * at all — that check has the component sources and belongs in CI (#2277).
- * Saying so in the panel matters: a report that silently omits a category
- * invites the reader to conclude the category is clean.
- */
-import type { DesignToken } from "@/utils/designTokens";
-
-/** The base mode's key in a token's `value_by_mode`, mirroring services/design_stylesheet. */
-export const BASE_MODE = "base";
-
-/** One token as the RECORD has it, already narrowed to the mode being checked. */
-export interface RecordedToken {
- name: string;
- /** Declared value for this mode, or "" when the role is named but unvalued. */
- value: string;
- groupName: string | null;
-}
-
-export type AgreementStatus = "ok" | "absent" | "differs" | "unrecorded";
-
-export interface Agreement {
- name: string;
- groupName: string | null;
- /** What the record declares, resolved. Empty for an `unrecorded` row. */
- recorded: string;
- /** What the browser resolved. Empty for an `absent` row. */
- live: string;
- status: AgreementStatus;
-}
-
-/**
- * Which declared value applies when the page is in `mode`.
- *
- * Falls back to base, which is the storage model rather than a convenience: a
- * mode block is an OVERRIDE layer, so a token with no entry for the current
- * mode is not missing — it is inheriting, exactly as the sheet has it.
- */
-export function valueForMode(
- valueByMode: Record,
- mode: string,
-): string {
- const own = valueByMode[mode];
- if (own !== undefined && own !== "") return own;
- return valueByMode[BASE_MODE] ?? "";
-}
-
-/**
- * Normalise a colour for comparison.
- *
- * A record writes `#FFFFFF`, a sheet writes `#fff`, and
- * getComputedStyle can hand back `rgb(255, 255, 255)` — three spellings of one
- * colour, and a comparison that misses any of them over-reports drift, which is
- * the failure that gets a panel ignored. The rgb() case is browser-specific and
- * therefore has no server-side counterpart, which is exactly why it is here.
- */
-export function normalizeColour(value: string): string | null {
- const raw = value.trim().toLowerCase();
-
- const hex = /^#([0-9a-f]{3,8})$/.exec(raw);
- if (hex) {
- let digits = hex[1];
- if (digits.length === 3 || digits.length === 4) {
- digits = digits.split("").map((c) => c + c).join("");
- }
- return digits.length === 6 || digits.length === 8 ? `#${digits}` : null;
- }
-
- // getComputedStyle reports real colour properties as rgb()/rgba(), never as
- // authored. Custom properties are token streams and usually come back as
- // written, so this arm is insurance rather than the common path.
- const rgb = /^rgba?\(([^)]+)\)$/.exec(raw);
- if (rgb) {
- const parts = rgb[1].split(/[,\s/]+/).filter(Boolean);
- if (parts.length < 3) return null;
- const channels = parts.slice(0, 3).map((p) => Number(p));
- if (channels.some((n) => !Number.isFinite(n))) return null;
- const hexOf = (n: number) => Math.round(n).toString(16).padStart(2, "0");
- const base = `#${channels.map(hexOf).join("")}`;
- if (parts.length === 3) return base;
- const alpha = Number(parts[3]);
- if (!Number.isFinite(alpha) || alpha >= 1) return base;
- return `${base}${hexOf(alpha * 255)}`;
- }
-
- return null;
-}
-
-/**
- * Compare two CSS values for sameness, not for identical text.
- *
- * Whitespace inside a compound value is not meaningful — `0 2px 10px` and
- * `0 2px 10px` are one shadow — and neither is case, since a custom property
- * carries no font names or content strings that would be changed by folding it.
- * Colours go through the normaliser first so spelling differences don't read as
- * drift.
- */
-export function sameValue(a: string, b: string): boolean {
- const canon = (v: string) => {
- const trimmed = v.trim();
- return normalizeColour(trimmed) ?? trimmed.replace(/\s+/g, " ").toLowerCase();
- };
- return canon(a) === canon(b);
-}
-
-/**
- * The families the record claims, as name prefixes.
- *
- * Used to decide which live tokens count as `unrecorded`. An app's stylesheet
- * legitimately carries names the record never owned — Scribe's own sheet keeps
- * a `--color-*` alias layer over the design system's `--fs-*` block — and
- * reporting those as drift would bury the real findings under a compatibility
- * shim. So the record is treated as owning a FAMILY, identified by the prefix
- * up to the first separator, and nothing outside it is judged.
- *
- * Derived from the data rather than configured, because the prefix is the
- * install's choice (see design_starter_roles) and hardcoding one would put a
- * single operator's naming into every install (rule #115).
- */
-export function recordedFamilies(names: Iterable): string[] {
- const families = new Set();
- for (const name of names) {
- const match = /^(--[A-Za-z0-9]+-)/.exec(name);
- if (match) families.add(match[1]);
- }
- return [...families];
-}
-
-/**
- * Compare the record against the running app.
- *
- * `resolved` is the record's declared values after the browser has substituted
- * `var()` in them (see `resolveDeclared`) — the same treatment the live values
- * already received, which is what makes the two comparable.
- *
- * Tokens the record names but has no value for are SKIPPED, not reported. A
- * valueless token is a role awaiting a decision, and the stylesheet already
- * reports those under `valueless`; counting them as drift would mean a system
- * created with starter roles opens this panel red on day one.
- */
-export function compareToApp(
- recorded: RecordedToken[],
- resolved: Map,
- live: DesignToken[],
-): Agreement[] {
- const liveByName = new Map();
- for (const token of live) liveByName.set(token.name, token.value);
- const out: Agreement[] = [];
-
- for (const token of recorded) {
- if (!token.value) continue;
- const declared = resolved.get(token.name) ?? token.value;
- const actual = liveByName.get(token.name) ?? "";
- out.push({
- name: token.name,
- groupName: token.groupName,
- recorded: declared,
- live: actual,
- status: !actual ? "absent" : sameValue(declared, actual) ? "ok" : "differs",
- });
- }
-
- const known = new Set(recorded.map((t) => t.name));
- const families = recordedFamilies(known);
- for (const token of live) {
- if (known.has(token.name)) continue;
- if (!families.some((prefix) => token.name.startsWith(prefix))) continue;
- out.push({
- name: token.name,
- groupName: null,
- recorded: "",
- live: token.value,
- status: "unrecorded",
- });
- }
-
- return out;
-}
-
-export interface AgreementSummary {
- ok: number;
- absent: number;
- differs: number;
- unrecorded: number;
- total: number;
-}
-
-export function summarise(agreements: Agreement[]): AgreementSummary {
- const summary: AgreementSummary = {
- ok: 0, absent: 0, differs: 0, unrecorded: 0, total: agreements.length,
- };
- for (const a of agreements) summary[a.status] += 1;
- return summary;
-}
-
-/**
- * Findings worth leading with.
- *
- * A panel that opens with every row gets closed and never reopened. `absent`
- * leads because it is the one status that can mean the whole sheet is missing;
- * `differs` next, because a wrong value is being rendered right now;
- * `unrecorded` last, since it is a bookkeeping gap rather than a visible fault.
- * `ok` rows are not findings at all and belong behind an expansion.
- */
-export function rankAgreements(agreements: Agreement[]): Agreement[] {
- const order: Record = {
- absent: 0, differs: 1, unrecorded: 2, ok: 3,
- };
- return [...agreements].sort((a, b) => {
- const byStatus = order[a.status] - order[b.status];
- return byStatus !== 0 ? byStatus : a.name.localeCompare(b.name);
- });
-}
diff --git a/frontend/src/utils/designTokens.ts b/frontend/src/utils/designTokens.ts
deleted file mode 100644
index 802ccc8..0000000
--- a/frontend/src/utils/designTokens.ts
+++ /dev/null
@@ -1,243 +0,0 @@
-/**
- * Design-token inventory — what tokens exist, and what they actually resolve to.
- *
- * Foundation for the design explorer (milestone #251): the gallery renders
- * against these, and the agreement panel compares them to the design system the
- * install says its UI is built from (#2419).
- *
- * DESIGN NOTE — why this parses NAMES but never VALUES.
- * Extracting `--foo` from a stylesheet is a trivial, robust regex. Extracting
- * its VALUE is not: values contain nested parens, commas inside rgba(),
- * `var()` references to other tokens, multi-part shadows, and gradients — and
- * `theme.css` has all of those today. So we take the names from the source and
- * ask the BROWSER for every value.
- *
- * That is not just easier, it is more correct. getComputedStyle reports what
- * actually won the cascade, resolves `var()` chains, and — critically for this
- * milestone — reflects live overrides set on a container, which is exactly what
- * the preview surface needs (see #2261). Parsing the source would report what
- * the file says rather than what the user is looking at.
- *
- * It also means this module needs no unit tests to be trustworthy: the only
- * logic here is a name regex and a group lookup. The frontend has no test
- * runner today (`vue-tsc --noEmit` is the whole check), so keeping the
- * error-prone half in the browser rather than in our code is deliberate.
- */
-import themeCss from "@/assets/theme.css?raw";
-
-export type TokenGroup =
- | "color"
- | "radius"
- | "gradient"
- | "glow"
- | "focus"
- | "layout"
- | "other";
-
-export type ThemeMode = "light" | "dark";
-
-export interface DesignToken {
- /** Full custom-property name, including the leading `--`. */
- name: string;
- /** Coarse family, derived from the name prefix. */
- group: TokenGroup;
- /** Resolved value in the requested context, straight from the browser. */
- value: string;
- /** True when the token is re-declared under a mode selector in source. */
- modeAware: boolean;
-}
-
-/**
- * Matches a custom-property DECLARATION, and never a `var(--name)` use.
- *
- * The discriminator is the COLON, not the preceding character. A declaration is
- * `--name:`; a reference is `var(--name)` or `var(--name, fallback)` — followed
- * by `)` or `,`, never by `:`. So no anchor is needed, and adding one is
- * actively wrong: an earlier version required the match to follow `{` or `;`,
- * which silently dropped every declaration that came after a comment —
- * including `--color-bg`, the first and most-used token in the file.
- */
-const DECLARATION = /(--[A-Za-z0-9_-]+)\s*:/g;
-
-/** Comments are stripped first so a commented-out declaration isn't counted. */
-const COMMENT = /\/\*[\s\S]*?\*\//g;
-
-/**
- * Any mode-override block, whichever mode it names.
- *
- * This used to hardcode `[data-theme="dark"]`, and that stopped being true the
- * day the sheet went dark-first: `:root` now carries dark and
- * `[data-theme="light"]` overrides it. The hardcoded selector matched nothing,
- * `overriddenInDark` was false for all 186 tokens, and the "mode-aware" flag
- * silently vanished from the gallery — a UI that kept rendering, wrongly.
- *
- * Matching the SHAPE rather than one mode name is what makes that unrepeatable,
- * and it is also the only version that holds for an install whose modes aren't
- * light and dark (rule #115). `selector_for_mode` in services/design_stylesheet
- * emits exactly this shape, so the two ends agree by construction.
- */
-const MODE_SELECTOR = /\[data-theme=["']?[\w-]+["']?\]/g;
-
-const GROUP_PREFIXES: ReadonlyArray<[string, TokenGroup]> = [
- ["--color-", "color"],
- ["--radius-", "radius"],
- ["--gradient-", "gradient"],
- ["--glow-", "glow"],
- ["--focus-", "focus"],
- ["--page-", "layout"],
- ["--sidebar-", "layout"],
- ["--chat-", "layout"],
-];
-
-export function groupFor(name: string): TokenGroup {
- for (const [prefix, group] of GROUP_PREFIXES) {
- if (name.startsWith(prefix)) return group;
- }
- return "other";
-}
-
-/** Every custom property declared anywhere in the stylesheet, in source order, deduped. */
-export function tokenNames(css: string = themeCss): string[] {
- const seen = new Set();
- const out: string[] = [];
- for (const match of css.replace(COMMENT, "").matchAll(DECLARATION)) {
- const name = match[1];
- if (!seen.has(name)) {
- seen.add(name);
- out.push(name);
- }
- }
- return out;
-}
-
-/** The subset re-declared under a mode selector — i.e. tokens that change with mode. */
-export function modeOverriddenNames(css: string = themeCss): Set {
- const bare = css.replace(COMMENT, "");
- const names = new Set();
- for (const match of bare.matchAll(MODE_SELECTOR)) {
- if (match.index === undefined) continue;
- const open = bare.indexOf("{", match.index + match[0].length);
- if (open === -1) continue;
- // A custom-property block is flat, so the first `}` closes it. Anything
- // nested would be a rule, not a declaration, and has no tokens to find.
- const close = bare.indexOf("}", open);
- if (close === -1) continue;
- for (const name of tokenNames(bare.slice(open, close))) names.add(name);
- }
- return names;
-}
-
-/**
- * Read the resolved value of every token in `host`'s context.
- *
- * Pass a container to read the tokens as they apply INSIDE it — which is how
- * the preview surface reads a scoped override without disturbing the page.
- * Defaults to the document root, i.e. the app-wide values.
- */
-export function readTokens(host: Element = document.documentElement): DesignToken[] {
- const computed = getComputedStyle(host);
- const modal = modeOverriddenNames();
- return tokenNames().map((name) => ({
- name,
- group: groupFor(name),
- value: computed.getPropertyValue(name).trim(),
- modeAware: modal.has(name),
- }));
-}
-
-/**
- * Read a set of DECLARED values as the browser would resolve them.
- *
- * The point is to compare like with like. A design system records
- * `color-mix(in srgb, var(--fs-accent) 15%, transparent)`; the browser reports
- * the same token with `var()` already substituted. Comparing those two strings
- * marks every derived token as drift, which is a report nobody can read.
- *
- * So both sides go through the same engine: set the declarations on an
- * offscreen probe, read them back, and the substitution is done by the
- * implementation that will do it for real rather than by a parser of ours.
- * Undeclared references fall through to the page's own values, which is what
- * the cascade would do anyway.
- */
-export function resolveDeclared(declared: Map): Map {
- const probe = document.createElement("div");
- probe.style.display = "none";
- for (const [name, value] of declared) probe.style.setProperty(name, value);
- document.body.appendChild(probe);
- try {
- const computed = getComputedStyle(probe);
- const out = new Map();
- for (const name of declared.keys()) {
- out.set(name, computed.getPropertyValue(name).trim());
- }
- return out;
- } finally {
- probe.remove();
- }
-}
-
-/**
- * Read tokens as they would resolve in a given mode, without touching the page.
- *
- * Uses an offscreen probe carrying the mode attribute, so the live UI is never
- * mutated to take a reading.
- *
- * KNOWN LIMITATION, and it is a property of the stylesheet rather than of this
- * function: mode scoping is one-way. Whichever mode the sheet treats as its
- * BASE lives on `:root` and has no attribute selector of its own, so a probe
- * can add an overriding mode to a subtree but can never add the base mode back.
- *
- * The sheet is dark-first today — `:root` carries dark, `[data-theme="light"]`
- * overrides it — so light-inside-dark previews work and dark-inside-light ones
- * return the light values. That direction flipped when the sheet did, which is
- * why this says "the base mode" rather than naming one: callers should treat a
- * cross-mode read as best-effort either way.
- */
-export function readTokensForMode(mode: ThemeMode): DesignToken[] {
- const probe = document.createElement("div");
- probe.setAttribute("data-theme", mode);
- probe.style.display = "none";
- document.body.appendChild(probe);
- try {
- return readTokens(probe);
- } finally {
- probe.remove();
- }
-}
-
-/**
- * Tokens grouped by family, preserving source order within each group.
- *
- * `overrides` maps a token name to the group it should sit under, and exists
- * because the prefix table above can only know the families that shipped with
- * the product. An install's own design system knows the groups it authored, so
- * a caller holding the record passes them here rather than the taxonomy growing
- * one operator's prefixes (rule #115).
- */
-export function groupTokens(
- tokens: DesignToken[],
- overrides: Map = new Map(),
-): Map {
- const out = new Map();
- for (const token of tokens) {
- const group = overrides.get(token.name) ?? token.group;
- const bucket = out.get(group);
- if (bucket) bucket.push(token);
- else out.set(group, [token]);
- }
- return out;
-}
-
-/**
- * Tokens declared in the stylesheet that nothing references with `var()`.
- *
- * Dead tokens are drift too: `--chat-reading-width` and
- * `--chat-context-sidebar-width` outlived the chat subsystem that was deleted
- * in the MCP-first pivot, and nothing has referenced them since. Takes the
- * corpus of source files to search as an argument so the caller decides what
- * "used" means — this module has no opinion about the project layout.
- */
-export function unreferencedTokens(tokens: DesignToken[], sources: string[]): DesignToken[] {
- const haystack = sources.join("\n");
- return tokens.filter((token) => !haystack.includes(`var(${token.name}`));
-}
diff --git a/frontend/src/utils/designValues.ts b/frontend/src/utils/designValues.ts
new file mode 100644
index 0000000..6745bb0
--- /dev/null
+++ b/frontend/src/utils/designValues.ts
@@ -0,0 +1,88 @@
+/**
+ * Turning a design system's RECORDED values into ones you can look at.
+ *
+ * Replaces `designTokens.ts` and `designDrift.ts`, which between them read the
+ * running app's own stylesheet — names out of a bundled `theme.css`, values out
+ * of `getComputedStyle(document.documentElement)`. That could only ever describe
+ * the install serving the page, and the design surface is for the projects an
+ * install TRACKS (#274). What is left here works on any system's record,
+ * including one for an app this browser has never loaded.
+ *
+ * Nothing in this module reads the document's own tokens or mutates the page.
+ */
+
+/** The base mode's key in `value_by_mode`, mirroring services/design_stylesheet. */
+export const BASE_MODE = "base";
+
+/**
+ * Which declared value applies in `mode`.
+ *
+ * Falls back to base, which is the storage model rather than a convenience: a
+ * mode block is an OVERRIDE layer, so a token with no entry for the current
+ * mode is not missing — it is inheriting, exactly as the generated sheet has it.
+ */
+export function valueForMode(
+ valueByMode: Record,
+ mode: string,
+): string {
+ const own = valueByMode[mode];
+ if (own !== undefined && own !== "") return own;
+ return valueByMode[BASE_MODE] ?? "";
+}
+
+/** Every mode any token in the set declares, base first then the rest by name. */
+export function modesPresent(
+ tokens: { value_by_mode: Record }[],
+): string[] {
+ const modes = new Set();
+ for (const token of tokens) {
+ for (const [mode, value] of Object.entries(token.value_by_mode)) {
+ if (value) modes.add(mode);
+ }
+ }
+ const rest = [...modes].filter((m) => m !== BASE_MODE).sort();
+ return modes.has(BASE_MODE) ? [BASE_MODE, ...rest] : rest;
+}
+
+/**
+ * Resolve declared values the way a browser would, without applying them.
+ *
+ * A record holds `color-mix(in srgb, var(--fs-accent) 15%, transparent)`. Shown
+ * as text that is a string; shown as a swatch it needs `var()` substituted and
+ * the mix evaluated. Rather than write a CSS parser, set the declarations on an
+ * offscreen probe and read them back — the substitution is done by the
+ * implementation that would do it for real.
+ *
+ * Custom properties INHERIT, and `all: initial` does not reset them — so a probe
+ * sitting in this page would resolve any reference the record leaves undeclared
+ * against the surrounding app's own tokens. Previewing another project's system
+ * would then quietly borrow this one's palette wherever that system was
+ * incomplete, and a token the record already knows is broken (it shows up under
+ * `unknown_refs`) would render as though it were fine.
+ *
+ * So every name referenced but not declared is blanked on the probe first. It
+ * resolves to nothing, which is what the record says it is.
+ */
+const VAR_REFERENCE = /var\(\s*(--[A-Za-z0-9_-]+)/g;
+
+export function resolveDeclared(declared: Map): Map {
+ const probe = document.createElement("div");
+ probe.style.display = "none";
+ for (const value of declared.values()) {
+ for (const match of value.matchAll(VAR_REFERENCE)) {
+ if (!declared.has(match[1])) probe.style.setProperty(match[1], " ");
+ }
+ }
+ for (const [name, value] of declared) probe.style.setProperty(name, value);
+ document.body.appendChild(probe);
+ try {
+ const computed = getComputedStyle(probe);
+ const out = new Map();
+ for (const name of declared.keys()) {
+ out.set(name, computed.getPropertyValue(name).trim());
+ }
+ return out;
+ } finally {
+ probe.remove();
+ }
+}
diff --git a/frontend/src/views/DesignSystemsView.vue b/frontend/src/views/DesignSystemsView.vue
index ff50efd..856957a 100644
--- a/frontend/src/views/DesignSystemsView.vue
+++ b/frontend/src/views/DesignSystemsView.vue
@@ -1,9 +1,13 @@
-
-
-
-
-
-
-
Live tokens
-
- The system as it actually is. Token values are read from the browser at
- runtime, so this page reflects the live cascade rather than what the
- stylesheet says. Components shown are the real ones — where a piece of
- the system has no shared implementation, it is marked missing rather
- than mocked up.
-
-
-
-
-
-
Agreement with the record
-
-
- Checking the running app against its design system…
-
-
-
- The design system couldn't be read.
-
- The gallery below is still live — it comes from the browser, not the
- server — but nothing is being compared against the record right now.
- This is a failure, not an empty result.
-
-
-
-
- No design system designated for this UI.
-
- This install hasn't said which design system its own interface is
- built from, so there is nothing to check the running app against.
- Designate one in Settings
- and this panel will report every token the record declares that the
- app doesn't have, renders differently, or has never heard of.
-
-
-
-
- Design system #{{ systemId }} could not be read.
-
- It is designated in Settings,
- but it has since been deleted or is no longer shared with you. Nothing
- is being checked — this is a misconfiguration, not a clean result.
-
-
-
-
-
- {{ summary.absent }} not in the app ·
- {{ summary.differs }} rendering another value ·
- {{ summary.unrecorded }} not in the record ·
- {{ summary.ok }} agreeing, across {{ summary.total }} tokens checked
- against {{ systemTitle }} in
- {{ theme }} mode.
-
- {{ unvaluedCount }} recorded {{ unvaluedCount === 1 ? "role has" : "roles have" }}
- no value yet and {{ unvaluedCount === 1 ? "was" : "were" }} not checked.
-
-
-
-
- This compares the record against the TOKENS only.
-
- A value hardcoded in a component — where a token should have been
- referenced — is invisible here, because the drift isn't in the
- tokens at all. Reading it would mean bundling every component's
- source into the app. That check belongs in CI and is tracked
- separately, so treat a clean panel as "the tokens agree", not "the
- app agrees".
-
-
-
-
- The record declares no valued tokens, so there is nothing to compare
- yet. Give its roles values and this panel starts reporting.
-
Imported from the app. What you see is what ships.
-
-
- Status badge
-
-
-
-
-
-
-
-
-
- Priority badge
-
-
-
-
- (none renders nothing, by design)
-
-
-
-
- Tag pill
-
-
-
-
-
-
-
-
-
-
Buttons
-
-
-
-
-
-
-
-
- Three sizes, because the app has three kinds of button: a page action, a
- row action, and an affordance that sits inside a card without disturbing
- its rhythm.
-
-
-
-
-
-
-
-
- {{ b.name }}
- {{ b.spec }}
- shared
-
-
-
-
-
-
-
Type scale
-
- The scale has no tokens.
-
- Nothing in the stylesheet declares a size token, so sizes are being set
- ad hoc per component. There is nothing live to show here.
-
-
-
-
diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue
index a291711..fd8615f 100644
--- a/frontend/src/views/SettingsView.vue
+++ b/frontend/src/views/SettingsView.vue
@@ -4,7 +4,6 @@ import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile } from "@/api/client";
-import { fetchDesignSystems } from "@/api/designSystems";
import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
import TagInput from "@/components/TagInput.vue";
@@ -32,12 +31,6 @@ const kbWritePathThreshold = ref("0.68");
// gate: that one BLOCKS a create and must be unforgiving of noise, this one only
// suggests a merge the operator reviews (services/dedup.py).
const kbDuplicateThreshold = ref("0.82");
-// Which design system this install's own UI is built from, for the /design
-// agreement panel. Empty = none designated, which is the normal state for a
-// fresh install rather than a misconfiguration — the panel explains itself when
-// unset. Replaced design_rulebook_id when the rulebook was retired (#2419).
-const uiDesignSystemId = ref("");
-const designSystems = ref<{ id: number; title: string }[]>([]);
const savingKbInject = ref(false);
const kbInjectSaved = ref(false);
@@ -107,9 +100,6 @@ async function saveKbInject() {
kb_writepath_enabled: kbWritePathEnabled.value ? 'true' : 'false',
kb_writepath_threshold: String(wpT),
kb_duplicate_threshold: String(dupT),
- // Empty string DELETES the setting (see routes/settings.py), which is
- // exactly right for "no design system" — absent rather than zero.
- ui_design_system_id: uiDesignSystemId.value,
});
kbInjectSaved.value = true;
setTimeout(() => (kbInjectSaved.value = false), 2000);
@@ -500,16 +490,6 @@ onMounted(async () => {
if (allSettings.kb_duplicate_threshold !== undefined) {
kbDuplicateThreshold.value = allSettings.kb_duplicate_threshold;
}
- uiDesignSystemId.value = allSettings.ui_design_system_id ?? "";
- // Best-effort: the picker degrades to "none available" rather than blocking
- // the whole settings page if design systems can't be listed.
- try {
- designSystems.value = (await fetchDesignSystems()).design_systems.map(
- (s) => ({ id: s.id, title: s.title }),
- );
- } catch {
- designSystems.value = [];
- }
if (allSettings.notify_task_reminders !== undefined) {
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
}
@@ -1280,25 +1260,12 @@ function formatUserDate(iso: string): string {
location, not by resemblance.
-
-
-
-
- Which design system this interface is supposed to be built from. Once
- set, the Design page compares
- every token the system declares against what the browser has actually
- resolved, and reports the ones the app is missing, renders differently,
- or has never heard of. That catches a stylesheet that was regenerated
- but never shipped — which the record alone cannot tell you, since the
- sheet is generated from it. Leave it as None if this install's
- interface isn't described by one of your design systems.
-
-
+
+
Quart:
app.register_blueprint(knowledge_bp)
app.register_blueprint(rulebooks_bp)
app.register_blueprint(plugin_bp)
- app.register_blueprint(design_bp)
app.register_blueprint(design_systems_bp)
app.register_blueprint(trash_bp)
app.register_blueprint(dashboard_bp)
diff --git a/src/scribe/routes/design.py b/src/scribe/routes/design.py
deleted file mode 100644
index e54dc15..0000000
--- a/src/scribe/routes/design.py
+++ /dev/null
@@ -1,45 +0,0 @@
-"""This install's UI surface — which design system it claims to be built from.
-
-Kept separate from the design-systems CRUD blueprint on purpose. That one is
-the RECORD: create a system, move a token, read the cascade. This one answers a
-question about the RUNNING APP, and it exists because those are not the same
-question. A design system can be a perfect record of a stylesheet the app never
-loaded.
-
-The client owns the other half. `utils/designTokens.ts` reads what the browser
-actually resolved, which is the one thing no server can report, and compares it
-to what this endpoint's system declares. So the comparison is
-"does the app agree with its own sheet?" rather than "is the record
-self-consistent?", which would be a tautology — the sheet is generated from the
-record (#2419).
-"""
-from quart import Blueprint, jsonify
-
-from scribe.auth import get_current_user_id, login_required
-from scribe.services import design_systems as ds_svc
-
-design_bp = Blueprint("design", __name__, url_prefix="/api/design")
-
-
-@design_bp.get("/ui-system")
-@login_required
-async def get_ui_system():
- """The design system this install designated as the source of its own UI.
-
- Returns `{"design_system_id": int|null, "title": str|null}`.
-
- Both nulls is the NORMAL case, not an error — an install that has not
- designated one has nothing to check the running app against, and the client
- shows an explanatory empty state (rule #115).
-
- An id with a null title is the third case and the reason the id is returned
- separately: designated, but deleted or not readable by this caller. Folding
- that into "none designated" is precisely how a feature comes to render a
- reassuring empty state forever.
- """
- uid = get_current_user_id()
- system_id, system = await ds_svc.ui_design_system(uid)
- return jsonify({
- "design_system_id": system_id,
- "title": system.title if system else None,
- })
diff --git a/src/scribe/services/design_systems.py b/src/scribe/services/design_systems.py
index 8e2a4ae..522829d 100644
--- a/src/scribe/services/design_systems.py
+++ b/src/scribe/services/design_systems.py
@@ -37,7 +37,6 @@ from scribe.services.design_cascade import (
resolve_tokens,
would_cycle,
)
-from scribe.services.settings import get_setting
logger = logging.getLogger(__name__)
@@ -135,40 +134,6 @@ async def get_design_system(user_id: int, design_system_id: int) -> DesignSystem
return system
-# Which design system this install's own UI is built from. A plain setting
-# rather than a column: no migration, discoverable in the Settings UI (rule
-# #25), and honest about being a per-install claim rather than a property of the
-# system — the same system can be the record for an app that never loads it.
-UI_DESIGN_SYSTEM_SETTING = "ui_design_system_id"
-
-
-async def ui_design_system(user_id: int) -> tuple[int | None, DesignSystem | None]:
- """The design system this install says its UI is built from.
-
- Returns `(id, system)`. Three outcomes, deliberately distinguishable:
-
- - `(None, None)` — nothing designated. The NORMAL state for any install but
- the one that set it up (rule #115), not an error.
- - `(id, None)` — designated, but gone or not readable by this caller. A
- misconfiguration worth naming rather than silently degrading to "none",
- which is exactly the failure that orphaned the panel this feeds (#2419).
- - `(id, system)` — designated and readable.
-
- A non-numeric setting value reads as nothing designated: the value is only
- ever written by a `