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 @@ + + + + + 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 @@ - - - - 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 `
@@ -784,6 +788,16 @@ async function confirmDelete() { + + +