feat(design-systems): the model, the parent chain, and the guard on it
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 20s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 27s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 20s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 27s
Milestone #254 step 1 (#2286). A design system becomes a record Scribe holds rather than prose in a rulebook: a named set of tokens with an OPTIONAL parent, so a family system carries the house style and an app system carries only what it changes. Answering "what does this app alter?" is then `list its tokens` — nothing to compute. `parent_id` is the whole model. It replaces both an `always_on` flag (a family system is one with no parent) and a subscription join table (a project points at ONE system; the chain supplies the rest) — less schema than the rulebook shape it mirrors. Two decisions the task left open, settled here: - **Token values are JSONB keyed by mode**, not `value_light`/`value_dark` columns. The deciding argument was not flexibility, it was ambiguity: in a child system an unset mode means "inherit", in a root it means "not mode-dependent", and as columns both are NULL and the resolver cannot tell them apart. As a map, resolution is `{**parent, **child}` at every level with no special case for roots. Against it: queryability — but nothing filters tokens by value in SQL, so that buys a query no caller makes. - **`group_name` is free text, no CHECK enum.** Groupings are each design system's own vocabulary; a whitelist would bake one install's kit into the schema. No CHECK is introduced anywhere, so rule #36 does not fire. The cascade lives in `services/design_cascade.py` as pure functions over a `{id: parent_id}` map, importing nothing — which is what lets both the service and `access.py` use it without a cycle, and lets a test state a whole hierarchy in one literal. Cycles are refused on WRITE by walking up from the proposed parent (the cheap direction), and survived on READ by a visited-set, because a loop from a direct DB edit must truncate rather than hang. ACL (rule #78) is deliberately asymmetric: owning a system grants write, reaching one through a project you can see grants READ ONLY. An editor on a shared project must not be able to rewrite the family system every other project in that family resolves through. Also renames `services/design_system.py` -> `design_rulebook_import.py`. It is the #251 prose extractor, whose role is already scheduled to become a one-shot importer (#2288), and leaving it one character away from the new `design_systems.py` was a trap for every later session. Rule #115 throughout: nothing seeds a system or implies a default. An install with zero design systems is ordinary, not degraded.
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
"""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
|
||||
|
||||
# The mode key that applies when no more specific one does. Emitted CSS puts it
|
||||
# on the base selector and every other key on a mode selector, mirroring how a
|
||||
# stylesheet is actually written: light on `:root`, dark layered over it.
|
||||
BASE_MODE = "base"
|
||||
|
||||
|
||||
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)
|
||||
# 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 "",
|
||||
"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)
|
||||
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,
|
||||
"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,
|
||||
}
|
||||
Reference in New Issue
Block a user