"""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, }