Compare commits

...
3 Commits
Author SHA1 Message Date
bvandeusen 716f227bc7 theme.css generated from the design system (#92)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 21s
2026-07-31 12:45:06 -04:00
bvandeusen 67a529a38e feat(theme): repoint theme.css at the design system, and find what wasn't captured
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 32s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 42s
theme.css is now generated from design system 2 (Scribe, inheriting FabledSword)
plus a compatibility alias layer, so the record decides the styling rather than
describing it after the fact.

**Dark is now the base layer.** The kit is dark-mode-first, so `:root` carries
the dark palette and `[data-theme="light"]` overrides it — the inverse of how
this file read before. `useTheme` already sets the attribute explicitly to
"light" or "dark", so the flip needed no JS change. It also closes the one-way
scoping gap #251 recorded: there IS a `[data-theme="light"]` block now, so a
container can add light as well as dark.

**60 dark overrides became 12.** The other 48 were restating relationships the
derivations now express: `--color-bg` follows `--fs-surface-page` because an
alias resolves at use time, so it needs stating once rather than per mode.

## What the audit found, which is the actual deliverable

**12 dead tokens, removed.** Declared in both modes, referenced by nothing:
seven from the removed chat subsystem (bubbles, input bar), plus `--glow-soft`,
`--color-action-ghost-border`, `--radius-pill` and two chat widths. 17% of the
file was styling a feature that no longer exists.

**16 names referenced but NEVER declared** — not by this change, not by the file
before it. Fourteen carried hardcoded fallbacks, so pages rendered and nothing
ever failed, but the fallback was what rendered, every time. Several were off
the palette entirely:

  --color-primary-bg      fell back to rgba(99,102,241,0.15) — an indigo
  --color-destructive     fell back to #b85a4a — not the oxblood
  --color-status-cancelled fell back to #6b7280 — a grey from no palette here
  --color-muted           fell back to #888

All 16 now resolve to real tokens. Expect small visual shifts exactly where a
fallback had drifted; the shift is the fix.

**Two tokens the app needed and never had**: `--fs-status-cancelled` (Scribe has
had a cancelled task status since the lifecycle was built and never had a colour
for it) and `--fs-layout-header` (referenced with a 52px fallback, so 52px was
always the real value — just not one anybody could look up).

## The system grew to cover what the app improvised

Per the operator: the kit wasn't growing with the app, and this is the result.
Recorded as tokens with GAP RECORDED FROM PRACTICE in their rationale — action
hover states, disabled opacity, the modal scrim, both code backgrounds, the
table stripe, the CTA gradient and glows, the accent-deep and accent-wash tints,
and the layout dimensions.

Scribe's own system gained its domain semantics — task status, priority, overdue,
wikilink — all DERIVED from family colours, so twelve rows of duplicated hex
became twelve formulas and zero new values. Priority maps onto the semantic
ladder deliberately: low is info, medium is warning, high is error.

95 tokens resolved, none valueless, 34 derived, no broken references, no cycles.
2026-07-31 12:42:15 -04:00
bvandeusen 473280e690 chore(design-systems): stop teaching one install's kit in product copy
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Successful in 41s
The operator's check: this must be a system for managing design systems, not one
with the FabledSword family built into it.

No LOGIC was coupled — the audit found zero behavioural dependencies. But every
docstring example, every UI placeholder and several comments named this install's
palette, so a stranger creating their first design system was shown
"FabledSword" as the expected shape and `--fs-obsidian` as the expected token.
Examples teach, and these taught the wrong thing.

Placeholders now describe the SHAPE ("Your house style", "--surface-page")
rather than naming one instance's contents, and the token-name placeholder now
says the thing worth saying: name it for its purpose, because `--obsidian` and
`--button-bg` both stop being true the moment the value or the element changes.

Not fixed here, and it is the one real coupling left: DesignView.vue hardcodes
rule 65's button variants and rule 60's type scale as literal arrays, so a
stranger's Design page would display this family's specs. Those arrays exist
because there was no design system to read from — which there now is. They go
when the panel is repointed (#2295), not before.

Scribe's own stylesheet comments ("Moss action-primary per Hybrid") are left
alone: that is the app CONSUMING the family style, which is what dogfooding
looks like, not the tool assuming it.
2026-07-31 10:18:53 -04:00
6 changed files with 351 additions and 184 deletions
+319 -161
View File
@@ -1,149 +1,307 @@
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=Inter:ital,wght@0,400;0,500;1,400&family=JetBrains+Mono:ital,wght@0,400;1,400&display=swap');
/* ==========================================================================
GENERATED FROM THE DESIGN SYSTEM — Scribe (design system 2), which inherits
the FabledSword house style (design system 1).
Do not hand-edit the --fs-* block below. Edit the design system in the app
and regenerate: /design-systems -> Master stylesheet -> Copy.
DARK IS THE BASE LAYER. The kit is dark-mode-first, so :root carries the dark
palette and [data-theme="light"] overrides it. That is the inverse of how this
file used to read, and it is deliberate: the light palette was never specified
by any rule, so it is recorded as a departure rather than as the default.
Only 12 tokens differ between modes. Everything else — spacing, type, motion,
radius, and every derived colour — is stated once, because a value built with
var() resolves where it is USED, not where it is written.
========================================================================== */
:root {
/* Light mode — warm parchment palette */
--color-bg: #F5F1E8;
--color-bg-secondary: #FBF8F0;
--color-bg-card: #FBF8F0;
--color-surface: #EFEAE0;
--color-text: #14171A;
--color-text-secondary: #5A5852;
--color-text-muted: #9A9890;
--color-border: #D9D6CE;
--color-input-border: #D9D6CE;
--color-primary: #5B4A8A;
--color-danger: #C04A1F;
--color-tag-bg: rgba(91, 74, 138, 0.12);
--color-tag-text: #5B4A8A;
--color-shadow: rgba(0, 0, 0, 0.08);
--color-toast-success: #4A5D3F;
--color-toast-error: #C04A1F;
--color-status-todo: #3F4651;
--color-status-todo-bg: rgba(63, 70, 81, 0.10);
--color-status-in-progress: #5B4A8A;
--color-status-in-progress-bg: rgba(91, 74, 138, 0.12);
--color-status-done: #4A5D3F;
--color-status-done-bg: rgba(74, 93, 63, 0.12);
--color-priority-low: #3D5A6E;
--color-priority-low-bg: rgba(61, 90, 110, 0.12);
--color-priority-medium: #8B6F1E;
--color-priority-medium-bg: rgba(139, 111, 30, 0.12);
--color-priority-high: #C04A1F;
--color-priority-high-bg: rgba(192, 74, 31, 0.12);
--color-wikilink: #5B4A8A;
--color-wikilink-bg: rgba(91, 74, 138, 0.12);
--color-overdue: #C04A1F;
--color-code-bg: #EBEDF0;
--color-code-inline-bg: #EBEDF0;
--color-table-stripe: rgba(20, 23, 26, 0.025);
--color-success: #4A5D3F;
--color-warning: #8B6F1E;
--color-input-bar-bg: #EFEAE0;
--color-input-bar-text: #14171A;
--color-input-bar-placeholder: rgba(20, 23, 26, 0.4);
--color-overlay: rgba(0, 0, 0, 0.45);
--color-bubble-user-bg: transparent;
--color-bubble-user-border: #D9D6CE;
--color-bubble-user-text: #5A5852;
--color-bubble-asst-shadow: 0 2px 14px rgba(91, 74, 138, 0.06), 0 1px 4px rgba(0, 0, 0, 0.05);
--color-primary-solid: #5B4A8A;
--color-primary-deep: #3F3560;
--gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep));
--glow-cta: 0 2px 10px rgba(91, 74, 138, 0.35);
--glow-cta-hover: 0 4px 20px rgba(91, 74, 138, 0.55);
--glow-soft: 0 0 16px rgba(91, 74, 138, 0.35);
--color-primary-faint: rgba(91, 74, 138, 0.08);
--color-primary-tint: rgba(91, 74, 138, 0.12);
--color-primary-wash: rgba(91, 74, 138, 0.20);
/* accent */
--fs-accent: #5B4A8A; /* Scribe's signature colour */
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 15%, transparent); /* Pill, tag and badge backgrounds */
--fs-accent-faint: color-mix(in srgb, var(--fs-accent) 8%, transparent); /* The faintest accent wash */
--fs-accent-deep: color-mix(in srgb, var(--fs-accent) 70%, black); /* The accent, darkened */
--fs-accent-wash: color-mix(in srgb, var(--fs-accent) 22%, transparent); /* Heaviest accent tint */
--fs-gradient-cta: linear-gradient(135deg, var(--fs-accent), var(--fs-accent-deep));
--fs-glow-cta: 0 2px 10px color-mix(in srgb, var(--fs-accent) 35%, transparent);
--fs-glow-cta-hover: 0 4px 24px color-mix(in srgb, var(--fs-accent) 65%, transparent);
/* Action color set — Hybrid rule: action buttons use these, accent reserved for brand moments */
--color-action-primary: #4A5D3F;
--color-action-primary-hover: #5A6F4D;
--color-action-secondary: #8B7355;
--color-action-secondary-hover: #A0876A;
--color-action-destructive: #6B2118;
--color-action-destructive-hover: #7E2A1F;
--color-action-ghost-border: #3F4651;
/* action */
--fs-action-primary: #4A5D3F; /* Save, Submit, Confirm */
--fs-action-secondary: #8B7355; /* Non-destructive alternates */
--fs-action-tertiary: var(--fs-border-color); /* Ghost / outline actions */
--fs-action-destructive: var(--fs-destructive);
--fs-action-primary-hover: color-mix(in srgb, var(--fs-action-primary) 88%, white);
--fs-action-secondary-hover: color-mix(in srgb, var(--fs-action-secondary) 88%, white);
--fs-action-destructive-hover: color-mix(in srgb, var(--fs-action-destructive) 88%, white);
--radius-sm: 6px;
--radius-md: 12px;
--radius-lg: 18px;
--radius-pill: 9999px;
--focus-ring: 0 0 0 2px rgba(91, 74, 138, 0.5);
/* Layout */
--page-max-width: 1200px;
--page-padding-x: 1rem;
--sidebar-width: 260px;
--chat-reading-width: min(1200px, 100%);
--chat-context-sidebar-width: 220px;
/* border */
--fs-border-color: #3F4651; /* Borders, dividers and ghost outlines */
--fs-border: 0.5px solid var(--fs-border-color);
--fs-border-hover: 0.5px solid color-mix(in srgb, var(--fs-text-secondary) 30%, transparent);
--fs-border-active: 2px solid var(--fs-accent); /* Selected card or active tab only */
/* editor */
--fs-wikilink: var(--fs-accent);
/* elevation */
--fs-shadow-1: 0 1px 0 rgba(0,0,0,0.4); /* Hairline lift */
--fs-shadow-2: 0 4px 12px rgba(0,0,0,0.35); /* Dropdowns, popovers */
--fs-shadow-3: 0 16px 40px rgba(0,0,0,0.5); /* Modals */
/* focus */
--fs-focus-ring: 0 0 0 2px var(--fs-accent);
/* font */
--fs-font-display: 'Fraunces', Georgia, serif;
--fs-font-body: 'Inter', system-ui, sans-serif;
--fs-font-mono: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
/* icon */
--fs-icon-stroke: 1.5px; /* at 24px */
--fs-icon-stroke-sm: 1px; /* at 16px */
/* layout */
--fs-layout-page-max: 1200px;
--fs-layout-page-pad: 1rem;
--fs-layout-sidebar: 260px;
--fs-layout-header: 52px;
/* motion */
--fs-ease: cubic-bezier(0.2, 0.6, 0.2, 1); /* the one curve */
--fs-dur-fast: 120ms;
--fs-dur-base: 180ms;
--fs-dur-slow: 280ms;
/* priority */
--fs-priority-low: var(--fs-info);
--fs-priority-low-bg: color-mix(in srgb, var(--fs-priority-low) 12%, transparent);
--fs-priority-medium: var(--fs-warning);
--fs-priority-medium-bg: color-mix(in srgb, var(--fs-priority-medium) 12%, transparent);
--fs-priority-high: var(--fs-error);
--fs-priority-high-bg: color-mix(in srgb, var(--fs-priority-high) 12%, transparent);
/* radius */
--fs-radius-sm: 4px; /* pills, tags, code spans */
--fs-radius-md: 8px; /* buttons, inputs, small cards */
--fs-radius-lg: 12px; /* cards, panels, modals */
--fs-radius-xl: 16px; /* hero containers */
--fs-radius-pill: 9999px;
/* semantic */
--fs-success: var(--fs-action-primary);
--fs-warning: #8B6F1E;
--fs-error: #C04A1F;
--fs-info: #3D5A6E;
--fs-destructive: #6B2118; /* irreversible — deliberately not the error colour */
/* space */
--fs-space-1: 4px;
--fs-space-2: 8px;
--fs-space-3: 12px;
--fs-space-4: 16px;
--fs-space-5: 20px;
--fs-space-6: 24px;
--fs-space-7: 32px;
--fs-space-8: 48px;
--fs-space-9: 64px;
--fs-space-10: 96px;
/* state */
--fs-disabled-opacity: 0.5;
--fs-overlay: rgba(0, 0, 0, 0.65);
/* status */
--fs-status-todo: var(--fs-border-color);
--fs-status-todo-bg: color-mix(in srgb, var(--fs-status-todo) 12%, transparent);
--fs-status-in-progress: var(--fs-accent);
--fs-status-in-progress-bg: color-mix(in srgb, var(--fs-status-in-progress) 12%, transparent);
--fs-status-done: var(--fs-success);
--fs-status-done-bg: color-mix(in srgb, var(--fs-status-done) 12%, transparent);
--fs-overdue: var(--fs-error);
--fs-status-cancelled: var(--fs-text-tertiary); /* set aside, not failed */
/* surface */
--fs-surface-page: #14171A; /* page bg, deepest surface */
--fs-surface-raised: #1E2228; /* cards, raised elements */
--fs-surface-hover: #2C313A; /* hovered surfaces */
--fs-surface-code: var(--fs-surface-page);
--fs-surface-code-inline: var(--fs-surface-raised);
--fs-table-stripe: color-mix(in srgb, var(--fs-text-primary) 3%, transparent);
/* text */
--fs-text-primary: #E8E4D8; /* also the text colour on filled action buttons */
--fs-text-secondary: #C2BFB4;
--fs-text-tertiary: #9C9A92;
/* type */
--fs-size-display: 40px;
--fs-size-h1: 32px;
--fs-size-h2: 24px;
--fs-size-h3: 18px;
--fs-size-body: 15px;
--fs-size-body-sm: 13px;
--fs-size-label: 12px;
--fs-size-code: 13px;
--fs-size-tiny: 11px; /* the only ALL CAPS, the only non-default tracking */
--fs-weight-regular: 400;
--fs-weight-medium: 500; /* the heaviest the system goes */
--fs-leading-heading: 1.3;
--fs-leading-body: 1.5;
--fs-leading-longform: 1.7;
--fs-tracking-tiny: 0.08em;
}
[data-theme="dark"] {
/* Dark mode — Obsidian / Iron / Pewter */
--color-bg: #14171A;
--color-bg-secondary: #1E2228;
--color-bg-card: #1E2228;
--color-surface: #2C313A;
--color-text: #E8E4D8;
--color-text-secondary: #C2BFB4;
--color-text-muted: #9C9A92;
--color-border: #3F4651;
--color-input-border: #3F4651;
--color-primary: #5B4A8A;
--color-danger: #C04A1F;
--color-tag-bg: rgba(91, 74, 138, 0.15);
--color-tag-text: #5B4A8A;
[data-theme="light"] {
/* accent */
--fs-accent-wash: color-mix(in srgb, var(--fs-accent) 20%, transparent);
--fs-glow-cta-hover: 0 4px 20px color-mix(in srgb, var(--fs-accent) 55%, transparent);
/* border */
--fs-border-color: #D9D6CE;
/* state */
--fs-overlay: rgba(0, 0, 0, 0.45);
/* surface */
--fs-surface-page: #F5F1E8;
--fs-surface-raised: #FBF8F0;
--fs-surface-hover: #EFEAE0;
--fs-surface-code: #EBEDF0;
--fs-surface-code-inline: #EBEDF0;
/* text */
--fs-text-primary: #14171A;
--fs-text-secondary: #5A5852;
--fs-text-tertiary: #9A9890;
}
/* ==========================================================================
COMPATIBILITY ALIASES — the app's historical names, pointing at the system.
These exist so ~55 components keep working while they migrate to --fs-*
one at a time. Every one is a plain var() reference, which is what lets this
block be declared ONCE: when [data-theme="light"] moves --fs-surface-page,
--color-bg follows, because the alias resolves at use time.
That is why this file lost 48 of its 60 dark-mode overrides — they were all
restating relationships the aliases now express directly.
Removing this block is a rename sweep across the components, tracked
separately. Nothing new should reference a --color-* name.
========================================================================== */
:root {
/* surfaces */
--color-bg: var(--fs-surface-page);
--color-bg-secondary: var(--fs-surface-raised);
--color-bg-card: var(--fs-surface-raised);
--color-surface: var(--fs-surface-hover);
--color-code-bg: var(--fs-surface-code);
--color-code-inline-bg: var(--fs-surface-code-inline);
--color-table-stripe: var(--fs-table-stripe);
--color-overlay: var(--fs-overlay);
/* text */
--color-text: var(--fs-text-primary);
--color-text-secondary: var(--fs-text-secondary);
--color-text-muted: var(--fs-text-tertiary);
/* lines */
--color-border: var(--fs-border-color);
--color-input-border: var(--fs-border-color);
--focus-ring: var(--fs-focus-ring);
/* brand */
--color-primary: var(--fs-accent);
--color-primary-solid: var(--fs-accent);
--color-primary-deep: var(--fs-accent-deep);
--color-primary-faint: var(--fs-accent-faint);
--color-primary-tint: var(--fs-accent-soft);
--color-primary-wash: var(--fs-accent-wash);
--color-tag-bg: var(--fs-accent-soft);
--color-tag-text: var(--fs-accent);
--color-wikilink: var(--fs-wikilink);
--color-wikilink-bg: var(--fs-accent-soft);
--gradient-cta: var(--fs-gradient-cta);
--glow-cta: var(--fs-glow-cta);
--glow-cta-hover: var(--fs-glow-cta-hover);
/* actions */
--color-action-primary: var(--fs-action-primary);
--color-action-primary-hover: var(--fs-action-primary-hover);
--color-action-secondary: var(--fs-action-secondary);
--color-action-secondary-hover: var(--fs-action-secondary-hover);
--color-action-destructive: var(--fs-action-destructive);
--color-action-destructive-hover: var(--fs-action-destructive-hover);
/* semantic */
--color-success: var(--fs-success);
--color-warning: var(--fs-warning);
--color-danger: var(--fs-error);
--color-overdue: var(--fs-overdue);
--color-toast-success: var(--fs-success);
--color-toast-error: var(--fs-error);
--color-shadow: rgba(0, 0, 0, 0.4);
--color-toast-success: #4A5D3F;
--color-toast-error: #C04A1F;
--color-status-todo: #3F4651;
--color-status-todo-bg: rgba(63, 70, 81, 0.18);
--color-status-in-progress: #5B4A8A;
--color-status-in-progress-bg: rgba(91, 74, 138, 0.18);
--color-status-done: #4A5D3F;
--color-status-done-bg: rgba(74, 93, 63, 0.18);
--color-priority-low: #3D5A6E;
--color-priority-low-bg: rgba(61, 90, 110, 0.18);
--color-priority-medium: #8B6F1E;
--color-priority-medium-bg: rgba(139, 111, 30, 0.18);
--color-priority-high: #C04A1F;
--color-priority-high-bg: rgba(192, 74, 31, 0.18);
--color-wikilink: #5B4A8A;
--color-wikilink-bg: rgba(91, 74, 138, 0.18);
--color-overdue: #C04A1F;
--color-code-bg: #14171A;
--color-code-inline-bg: #1E2228;
--color-table-stripe: rgba(255, 255, 255, 0.025);
--color-success: #4A5D3F;
--color-warning: #8B6F1E;
--color-input-bar-bg: #1E2228;
--color-input-bar-text: #E8E4D8;
--color-input-bar-placeholder: rgba(232, 228, 216, 0.35);
--color-overlay: rgba(0, 0, 0, 0.65);
--color-bubble-user-bg: transparent;
--color-bubble-user-border: #3F4651;
--color-bubble-user-text: #C2BFB4;
--color-bubble-asst-shadow: 0 4px 28px rgba(91, 74, 138, 0.14), 0 2px 8px rgba(0, 0, 0, 0.4);
--color-primary-solid: #5B4A8A;
--color-primary-deep: #3F3560;
--gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep));
--glow-cta: 0 2px 12px rgba(91, 74, 138, 0.45);
--glow-cta-hover: 0 4px 24px rgba(91, 74, 138, 0.65);
--glow-soft: 0 0 18px rgba(91, 74, 138, 0.4);
--color-primary-faint: rgba(91, 74, 138, 0.10);
--color-primary-tint: rgba(91, 74, 138, 0.14);
--color-primary-wash: rgba(91, 74, 138, 0.22);
/* Action color set — identical across themes */
--color-action-primary: #4A5D3F;
--color-action-primary-hover: #5A6F4D;
--color-action-secondary: #8B7355;
--color-action-secondary-hover: #A0876A;
--color-action-destructive: #6B2118;
--color-action-destructive-hover: #7E2A1F;
--color-action-ghost-border: #3F4651;
/* task status + priority */
--color-status-todo: var(--fs-status-todo);
--color-status-todo-bg: var(--fs-status-todo-bg);
--color-status-in-progress: var(--fs-status-in-progress);
--color-status-in-progress-bg: var(--fs-status-in-progress-bg);
--color-status-done: var(--fs-status-done);
--color-status-done-bg: var(--fs-status-done-bg);
--color-priority-low: var(--fs-priority-low);
--color-priority-low-bg: var(--fs-priority-low-bg);
--color-priority-medium: var(--fs-priority-medium);
--color-priority-medium-bg: var(--fs-priority-medium-bg);
--color-priority-high: var(--fs-priority-high);
--color-priority-high-bg: var(--fs-priority-high-bg);
/* geometry */
--radius-sm: var(--fs-radius-sm);
--radius-md: var(--fs-radius-lg); /* NB: the app's "md" is the system's LARGE */
--radius-lg: var(--fs-radius-xl); /* and the app's "lg" is the system's XL */
--page-max-width: var(--fs-layout-page-max);
--page-padding-x: var(--fs-layout-page-pad);
--sidebar-width: var(--fs-layout-sidebar);
--header-height: var(--fs-layout-header);
/* ------------------------------------------------------------------
Names components reference that were NEVER declared anywhere.
Each of these was reached for with a hardcoded fallback, so the page
rendered — but the fallback was what rendered, always, and several were
off-palette: --color-primary-bg fell back to an indigo, --color-destructive
to a brick that is not the oxblood, --color-status-cancelled to a grey from
no palette in this system.
Wiring them to real tokens is the whole point of the exercise. Expect small
visual shifts exactly where a fallback had drifted; that shift IS the fix.
------------------------------------------------------------------ */
--color-accent: var(--fs-accent);
--color-accent-fg: var(--fs-text-primary);
--color-hover: var(--fs-surface-hover);
--color-bg-hover: var(--fs-surface-hover);
--color-bg-tertiary: var(--fs-surface-hover);
--color-surface-2: var(--fs-surface-hover);
--color-surface-alt: var(--fs-surface-hover);
--color-surface-raised: var(--fs-surface-raised);
--color-input-bg: var(--fs-surface-page);
--color-muted: var(--fs-text-tertiary);
--color-destructive: var(--fs-destructive);
--color-primary-bg: var(--fs-accent-soft);
--color-status-cancelled: var(--fs-status-cancelled);
--font-display: var(--fs-font-display);
--font-mono: var(--fs-font-mono);
}
/* ==========================================================================
Base element styles
========================================================================== */
*,
*::before,
*::after {
@@ -152,36 +310,36 @@
body {
margin: 0;
background: var(--color-bg);
color: var(--color-text);
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", Roboto, sans-serif;
background: var(--fs-surface-page);
color: var(--fs-text-primary);
font-family: var(--fs-font-body);
font-feature-settings: "cv11";
line-height: 1.5;
transition: background-color 0.2s, color 0.2s;
line-height: var(--fs-leading-body);
transition: background-color var(--fs-dur-base) var(--fs-ease),
color var(--fs-dur-base) var(--fs-ease);
}
h1, h2 {
font-family: 'Fraunces', Georgia, serif;
font-family: var(--fs-font-display);
font-optical-sizing: auto;
font-weight: 500;
line-height: 1.3;
font-weight: var(--fs-weight-medium);
line-height: var(--fs-leading-heading);
}
h3 {
font-family: 'Inter', system-ui, sans-serif;
font-weight: 500;
line-height: 1.3;
font-family: var(--fs-font-body);
font-weight: var(--fs-weight-medium);
line-height: var(--fs-leading-heading);
}
code, pre, kbd, samp {
font-family: 'JetBrains Mono', ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-family: var(--fs-font-mono);
font-feature-settings: "liga", "calt";
}
::selection {
background: rgba(91, 74, 138, 0.3);
color: var(--color-text);
background: var(--fs-accent-wash);
color: var(--fs-text-primary);
}
input:focus-visible,
@@ -190,15 +348,15 @@ select:focus-visible,
button:focus-visible,
a:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
border-radius: var(--radius-sm);
box-shadow: var(--fs-focus-ring);
border-radius: var(--fs-radius-sm);
}
button:not(:disabled):active,
.btn:not(:disabled):active,
[role="button"]:not(:disabled):active {
transform: scale(0.97);
transition: transform 0.08s ease;
transition: transform 0.08s var(--fs-ease);
}
/* Responsive breakpoints: 480px (phone), 768px (tablet), 1024px (desktop) */
@@ -228,11 +386,11 @@ button:not(:disabled):active,
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 9999px;
background: var(--fs-border-color);
border-radius: var(--fs-radius-pill);
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
background: var(--fs-text-tertiary);
}
/* Floating inline assist button (teleported to body, cannot be scoped) */
@@ -240,14 +398,14 @@ button:not(:disabled):active,
position: fixed;
z-index: 150;
transform: translateX(-50%);
background: var(--color-primary);
color: #fff;
background: var(--fs-accent);
color: var(--fs-text-primary);
border: none;
border-radius: 999px;
border-radius: var(--fs-radius-pill);
padding: 0.3rem 0.8rem;
font-size: 0.8rem;
font-size: var(--fs-size-body-sm);
cursor: pointer;
box-shadow: 0 2px 8px var(--color-shadow);
box-shadow: var(--fs-shadow-2);
white-space: nowrap;
}
.inline-assist-btn:hover {
+5 -5
View File
@@ -530,7 +530,7 @@ function isColourish(value: string): boolean {
<label class="field-label" for="first-title">Title</label>
<input
id="first-title" v-model="newTitle" class="input" type="text"
placeholder="FabledSword" @keyup.enter="submitCreate"
placeholder="Your house style" @keyup.enter="submitCreate"
/>
</div>
<div class="field">
@@ -581,7 +581,7 @@ function isColourish(value: string): boolean {
<label class="field-label" for="new-title">Title</label>
<input
id="new-title" v-model="newTitle" class="input" type="text"
placeholder="FabledSword, or Scribe" @keyup.enter="submitCreate"
placeholder="A house style, or one app in it" @keyup.enter="submitCreate"
/>
</div>
<div class="field">
@@ -852,7 +852,7 @@ function isColourish(value: string): boolean {
<label class="field-label" for="token-name">Name</label>
<input
id="token-name" v-model="tokenName" class="input mono" type="text"
placeholder="--fs-obsidian"
placeholder="--surface-page"
/>
</div>
<div class="field-row">
@@ -864,7 +864,7 @@ function isColourish(value: string): boolean {
<label class="field-label" for="token-purpose">Purpose</label>
<input
id="token-purpose" v-model="tokenPurpose" class="input" type="text"
placeholder="page bg, deepest surface"
placeholder="page background, deepest surface"
/>
</div>
</div>
@@ -873,7 +873,7 @@ function isColourish(value: string): boolean {
<label class="field-label" for="token-rationale">Why this value</label>
<input
id="token-rationale" v-model="tokenRationale" class="input" type="text"
placeholder="Success equals Moss, aligned by design"
placeholder="Matches the primary action colour, deliberately"
/>
<p class="field-hint">
Distinct from purpose: purpose is what the token is FOR, this is
+11 -6
View File
@@ -33,7 +33,8 @@ async def create_design_system(
"""Create a design system, optionally inheriting from another.
Args:
title: What this system is, e.g. "FabledSword" or "Scribe" (required).
title: What this system is — a house style, or one app within it
(required).
description: What it covers and when it applies.
guidance: The narrative a token table cannot hold — aesthetic, voice and
tone, what is deliberately out of scope. Markdown, free-form.
@@ -226,18 +227,22 @@ async def create_design_token(
Args:
design_system_id: The system that owns this token.
name: The custom-property name, e.g. "--fs-obsidian" (required).
name: The custom-property name, e.g. "--surface-page" (required).
Name it for its PURPOSE, not its value: a name like "--obsidian"
or "--button-bg" stops being true the moment the value or the
element changes.
value_by_mode: Values keyed by mode, e.g.
{"base": "#f7f5ef", "dark": "#14171a"}. Use "base" for the value
{"base": "#14171a", "light": "#f7f5ef"}. Use "base" for the value
that applies when no mode is more specific; a token that is not
mode-dependent needs only "base". In a system WITH a parent, an
omitted mode is inherited rather than blanked.
group_name: Free-text grouping — "surface", "text", "radius", whatever
this system's own vocabulary is.
purpose: What the token is for, e.g. "page bg, deepest surface".
purpose: What the token is for, e.g. "page background, deepest
surface".
rationale: WHY it is this value — a different question from purpose.
"Success equals Moss, aligned by design" is a rationale; "page bg,
deepest surface" is a purpose.
"Deliberately the same value as the primary action colour" is a
rationale; "page background, deepest surface" is a purpose.
supersedes: Literal values this token should be used INSTEAD OF, e.g.
["#fff", "#ffffff"]. This is how a design system records what a
prohibition was trying to say — not "white is banned" but "write
+3 -2
View File
@@ -45,7 +45,8 @@ async def create_rulebook(title: str, description: str = "") -> dict:
Two ways a rulebook reaches projects, set by its always_on flag (toggle via
update_rulebook):
- always_on = true -> binds EVERY one of your projects automatically.
Use for universal cross-project norms (e.g. "FabledSword family").
Use for universal cross-project norms that apply across every
project, not just one.
- always_on = false -> binds only projects that subscribe
(subscribe_project_to_rulebook). Use for a THEMED body of rules a
category of projects shares (e.g. a design system that visual apps
@@ -54,7 +55,7 @@ async def create_rulebook(title: str, description: str = "") -> dict:
to any single project. Project-specific rules go in create_project_rule.
Args:
title: Rulebook name (e.g. "FabledSword family").
title: Rulebook name.
description: Optional short description of what this rulebook covers.
"""
uid = current_user_id()
+9 -7
View File
@@ -116,22 +116,24 @@ class DesignToken(Base, TimestampMixin, SoftDeleteMixin):
group_name: Mapped[str | None] = mapped_column(Text, nullable=True)
purpose: Mapped[str | None] = mapped_column(Text, nullable=True)
# WHY this token is this value — a different question from `purpose`, which
# is what it is FOR. "Success equals Moss, aligned by design" is a rationale;
# "page bg, deepest surface" is a purpose. Rules carry the first routinely
# and a token row had nowhere to put it.
# is what it is FOR. "Deliberately the same value as the primary action
# colour" is a rationale; "page background, deepest surface" is a purpose.
# Design guidance carries the first routinely and a token row had nowhere to
# put it.
rationale: Mapped[str | None] = mapped_column(Text, nullable=True)
# Literal values this token should be used INSTEAD OF, e.g. ["#fff",
# "#ffffff"] on a text-on-action token.
#
# This is how a design system records the thing a prohibition was trying to
# say. "Pure white is never text" is the shadow of a positive fact — text is
# Parchment — and a system that stores what things ARE has no row for a ban.
# say. "Pure white is never text" is the shadow of a positive fact — some
# other colour IS the text colour — and a system that stores what things ARE
# has no row for a ban.
# Recording the replacement keeps the check and makes it actionable: a
# finding can name what to write instead of merely objecting.
#
# It has to be DECLARED rather than inferred, because the superseded literal
# and the token's own value are usually different colours (#fff is not
# #E8E4D8). No value-matching rule could ever connect them.
# and the token's own value are usually different colours entirely. No
# value-matching rule could ever connect them.
#
# Consumed by the source lint (#2277), not by the drift panel: these
# literals live in component CSS, which the panel cannot see and says so.
+4 -3
View File
@@ -18,8 +18,8 @@ has recall, locations, drift checks and merge.
So the division is: this sheet says what the values MEAN; snippets say what
things LOOK LIKE, in terms of those values. A token named after an element
(`--fs-button-bg`) is the smell that the two have been mixed — it multiplies
with every new element, where a purpose name (`--fs-action-primary`) is reused.
(`--button-bg`) is the smell that the two have been mixed — it multiplies
with every new element, where a purpose name (`--action-primary`) is reused.
SAFETY
------
@@ -203,7 +203,8 @@ def duplicate_values(tokens: Sequence) -> dict[str, list[str]]:
Two names for one value are either a deliberate alias or the same idea
recorded twice — the token-level form of the duplicated-definition shape.
Reported rather than refused: a design system legitimately aligns colours on
purpose ("Success = Moss, by design"), and a generator that rejected that
purpose (one palette entry defined as equal to another), and a generator
that rejected that
would be wrong about the operator's intent.
Compares the BASE value only. Two tokens agreeing in one mode and diverging