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.
159 lines
7.9 KiB
Python
159 lines
7.9 KiB
Python
"""Design systems — a stylesheet held as records, inherited family -> app.
|
|
|
|
A DesignSystem is a named set of design tokens with an OPTIONAL parent, and that
|
|
single self-FK is the whole model. A system with no parent is a family system; a
|
|
system WITH one holds only what it changes. "What does this app alter?" is
|
|
therefore `list its tokens` — nothing to compute, nothing to diff — which is why
|
|
inheritance won over a flat family-plus-loose-overrides shape.
|
|
|
|
Resolution walks the chain and lets the deepest system win by token name. That
|
|
is the CSS cascade rather than an analogy to it, which is why the storage model
|
|
and the stylesheet model come out the same shape.
|
|
|
|
`parent_id` also replaces two things the rulebook model needs to express the same
|
|
idea: an `always_on` flag (a family system is simply one with no parent) and a
|
|
subscription join table (a project points at ONE system, and the chain supplies
|
|
the rest). Less schema for more structure.
|
|
"""
|
|
from sqlalchemy import BigInteger, ForeignKey, Index, Integer, Text, text
|
|
from sqlalchemy.dialects.postgresql import JSONB
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from scribe.models import Base
|
|
from scribe.models.base import SoftDeleteMixin, TimestampMixin
|
|
|
|
|
|
class DesignSystem(Base, TimestampMixin, SoftDeleteMixin):
|
|
__tablename__ = "design_systems"
|
|
|
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
|
owner_user_id: Mapped[int] = mapped_column(
|
|
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), index=True
|
|
)
|
|
title: Mapped[str] = mapped_column(Text)
|
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
# The narrative a token table cannot hold: aesthetic, voice and tone, what
|
|
# is deliberately out of scope. Free-form markdown rather than a column per
|
|
# category — a schema with `voice`/`aesthetic`/`scope` columns would bake one
|
|
# rulebook's table of contents into every install.
|
|
guidance: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
# SET NULL, not CASCADE: deleting a family system must not delete every app
|
|
# system that inherited from it. Orphaning turns each child into a root that
|
|
# still holds its own overrides — recoverable. A cascade would destroy data
|
|
# the operator never asked to touch.
|
|
parent_id: Mapped[int | None] = mapped_column(
|
|
BigInteger,
|
|
ForeignKey("design_systems.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
index=True,
|
|
)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"owner_user_id": self.owner_user_id,
|
|
"title": self.title,
|
|
"description": self.description or "",
|
|
"guidance": self.guidance or "",
|
|
"parent_id": self.parent_id,
|
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
|
}
|
|
|
|
|
|
class DesignToken(Base, TimestampMixin, SoftDeleteMixin):
|
|
"""One custom property in one system: its name, and its value per mode.
|
|
|
|
`value_by_mode` is a JSONB map of mode -> value, e.g.
|
|
`{"base": "#f7f5ef", "dark": "#14171a"}`. Three reasons it beats a pair of
|
|
`value_light` / `value_dark` columns here:
|
|
|
|
- **Absence means one thing.** In a child system an unset mode means
|
|
"inherit"; in a root it would have to mean "not mode-dependent". With
|
|
columns those are both NULL and the resolver cannot tell them apart. With
|
|
a map, resolution is `{**parent_map, **child_map}` at every level —
|
|
one rule, no special case for roots.
|
|
- **Per-mode overrides are already real.** A palette rule in this operator's
|
|
own kit deepens one accent on light backgrounds for contrast while
|
|
leaving the dark value alone. Mode is a second override axis, not a
|
|
second column.
|
|
- **Nothing filters tokens by value in SQL.** Drift comparison resolves the
|
|
set first and compares in the client; the importer diffs in Python. The
|
|
queryability columns would buy is for a query no caller makes.
|
|
|
|
The cost is real — a third mode is data rather than schema, so the DB will
|
|
not reject a typo'd mode key. That is the trade taken.
|
|
|
|
NOT NULL with a `{}` default deliberately: a JSONB column otherwise has two
|
|
empty states (SQL NULL and JSON null) and code has to test for both.
|
|
"""
|
|
__tablename__ = "design_tokens"
|
|
__table_args__ = (
|
|
# Partial unique: a name is unique among LIVE tokens in a system, so a
|
|
# trashed token doesn't block recreating the same name. Two live rows
|
|
# named `--fs-obsidian` in one system is a duplicate definition, and the
|
|
# cascade would pick between them arbitrarily.
|
|
Index(
|
|
"uq_token_per_design_system", "design_system_id", "name",
|
|
unique=True, postgresql_where=text("deleted_at IS NULL"),
|
|
),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
|
design_system_id: Mapped[int] = mapped_column(
|
|
BigInteger, ForeignKey("design_systems.id", ondelete="CASCADE"), index=True
|
|
)
|
|
name: Mapped[str] = mapped_column(Text)
|
|
# Named for what it holds rather than the bare word `values`, which is
|
|
# reserved in SQL — the same reason `group_name` is not `group`.
|
|
value_by_mode: Mapped[dict] = mapped_column(
|
|
JSONB, nullable=False, default=dict, server_default=text("'{}'::jsonb")
|
|
)
|
|
# `group` is a reserved word in SQL; `group_name` throughout — model, column
|
|
# and payload — so no layer has to remember which spelling it is on.
|
|
# Free text, not a CHECK enum: groupings are the design system's own
|
|
# vocabulary, and a whitelist would bake one install's kit into the schema.
|
|
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. "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 — 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 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.
|
|
supersedes: Mapped[list] = mapped_column(
|
|
JSONB, nullable=False, default=list, server_default=text("'[]'::jsonb")
|
|
)
|
|
order_index: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"design_system_id": self.design_system_id,
|
|
"name": self.name,
|
|
"value_by_mode": self.value_by_mode or {},
|
|
"group_name": self.group_name,
|
|
"purpose": self.purpose,
|
|
"rationale": self.rationale,
|
|
"supersedes": self.supersedes or [],
|
|
"order_index": self.order_index,
|
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
|
}
|