CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Failing after 20s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Skipped
Last piece of the architecture in #2296. The operator: "the prose doesn't have to live as one offs, there's a central system for managing it." Two fields, both free-form: design_systems.guidance the narrative a token table cannot hold — aesthetic, voice and tone, what is deliberately out of scope. design_tokens.rationale WHY a token is this value, which is a different question from `purpose` (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. Free-form rather than a column per category, deliberately. A schema with `voice`, `aesthetic` and `scope` columns would bake one rulebook's table of contents into every install (rule #115), leaving the next install three empty columns and nowhere for what it actually cares about. Both nullable: a design system with no prose at all is complete, not a draft. `rationale` cascades like `purpose` — deepest non-empty wins — so an app overriding a colour keeps the family's reasoning rather than blanking it. Same argument as `supersedes`: the override was about the value, not the meaning. In the generated sheet the inline comment prefers `purpose` and falls back to `rationale`, so a token carrying only the why still says something instead of rendering bare.
157 lines
7.8 KiB
Python
157 lines
7.8 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. "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.
|
|
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.
|
|
# 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.
|
|
#
|
|
# 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,
|
|
}
|