Files
FabledScribe/src/scribe/models/design_system.py
T
bvandeusenandClaude Fable 5 b0eda32575
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 26s
refactor(models): one iso() for every to_dict timestamp; mixins replace hand-rolled created_at/updated_at (#2827, milestone 296 area 3)
Reading all 28 models against each other: 54 `x.isoformat() if x else None` /
`x.isoformat()` expressions in 23 to_dict methods, in two guarded/unguarded
wordings, become iso() from models/base.py — uniform, and a row read before
flush serialises as null instead of raising. Rulebook / RulebookTopic / Rule
carried byte-identical copies of TimestampMixin's two columns;
InvitationToken / PasswordResetToken / NoteUsageEvent carried CreatedAtMixin's —
all six now use the mixin. AppLog and RetrievalLog keep their explicit
created_at, commented: their composite index orders on `created_at.desc()`,
which needs the column object in the class body. Schema-neutral (same column
definitions) — no migration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:17:42 -04:00

159 lines
7.7 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, iso
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": iso(self.created_at),
"updated_at": iso(self.updated_at),
}
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": iso(self.created_at),
"updated_at": iso(self.updated_at),
}