Skip to content

Work / Babul / Wiki / Decisions

2026-10-21-schema-0024

Decisionsupersededverified 2026-10-21

DECISION.2026-10-21.SCHEMA-0024

The schema for components made like Figma (0024)

Written 2026-10-21; approved by the owner 2026-10-21, with the three answers in Status. The one migration is written again (MVP: no data to keep). Tables go from 48 to 50: 2 added. Phase 1 of the build uses it: properties, variant values, one layer tree per component, bindings, the Icon tool.

The rule is the one every primitive already follows: a row holds only what never changes; everything that can change is a change on a draft and a ref. So properties, variant values, bindings and interactions go through drafts, Review, sync, conflicts, merge and tags with no new versioning rules. Comes from 2026-10-21-components-like-figma.

Decision

1. A component's properties: ComponentProperty

  • The row is only the component and the type. A type never changes; a property of another type is a new property.
  • Changes (new target kind COMPONENT_PROPERTY): name, position (its place in the list), default, options (a Variant property's values in order; Interaction states' subset of hover · focus · pressed · disabled), preferred (an Instance swap's preferred icons or components, in order; what a Slot may hold).
  • State and Size are not rows. They stay Variant.state and Variant.size, on every component.
  • Interaction states is a property of its own type, because Button draws Default · Hover · Pressed · Disabled as cells. Get code writes it as CSS.
  • The code name (showTotal) is derived from the name, not stored.
enum PropertyType {
  VARIANT
  BOOLEAN
  TEXT
  INSTANCE_SWAP
  SLOT
  INTERACTION_STATES
}

model ComponentProperty {
  id          String       @id @default(uuid(7)) @db.Uuid
  clientId    String       @db.Uuid
  productId   String       @db.Uuid
  componentId String       @db.Uuid
  type        PropertyType
  createdAt   DateTime     @default(now()) @db.Timestamptz(3)

  product   Product   @relation(fields: [productId, clientId], references: [id, clientId], onDelete: Cascade)
  component Component @relation(fields: [componentId, productId, clientId], references: [id, productId, clientId], onDelete: Cascade)

  @@unique([id, productId, clientId])
  @@index([componentId])
  @@index([productId])
}

2. A variant's values: a change, not a table

  • A variant is State × Size × the author's property values. State and Size stay columns.
  • Its values are one change on the variant: target VARIANT, selector "", property values, a JSON object { <propertyId>: "on" }.
  • The spanning rule: a property missing from values is one the variant spans (Select's Empty spans Open). The app checks, when a draft is saved and when a branch is merged, that no two variants of a component claim the same cell.
  • Variant.name is gone (the owner, 2026-10-21): a variant's name is derived from what it is: its values ("Checked on", after its state when that is not Data), else its state ("Empty"), else, for a captured style with neither, Default for the first and Variant 2, 3… in the order they were made. variantName and readVariantNames in @trunk/render. @@unique([componentId, name]) went with it; capture finds a variant again by its fingerprint, as before.

3. One layer tree for the whole set: DrawnElement.componentId

  • The screens draw one Layers tree per component: a layer is bound once, has "Shown: Expanded · yes", and an interaction "runs in Data · Error". A layer drawn per variant could not say any of that.
  • Add DrawnElement.componentId: a layer of the whole component, drawn in every variant. DrawnElement.variantId is gone (the owner, 2026-10-21): a drawn element lives on one page or in one component. DrawnElement_one_host checks exactly one of pageId and componentId.
  • A variant's own value for such a layer is a DRAWN_ELEMENT change with selector = that variant's id (selector "" is every variant), so Expanded · yes can show chevron-up where the rest show chevron-down.
  • New DRAWN_ELEMENT properties: shown (which variants show it, by State and property values), icon (an ICON's asset), props (a nested instance's property values, JSON).
  • Which edits are a variant's own (the build's rule, Figma's: each variant is its own): in a set of more than one variant, a layer moved, resized, retyped or restyled in one variant is that variant's own value. What the layer is (where it sits in the tree, its name, lock, auto layout, Shown and bindings) is the layer's, in every variant. With one variant, every edit is every variant's. SHARED_IN_SET in constants/component-set.ts.

4. Binding a layer to a property

A change on the layer, naming the property's id:

  • Drawn layer: DRAWN_ELEMENT, property bind.text, bind.visible, bind.instance or bind.content.
  • Captured element: COMPONENT, selector = the element's selector in every variant's markup, the same four properties. A captured Button's label is bound once, not once per variant.
  • An instance on a page sets its property values with props on the PAGE_ELEMENT or the DRAWN_ELEMENT.

5. The Icon tool: DrawnKind gains ICON

An icon or a logo placed from Styles › Icons. Its asset is the change icon (an Asset id), so an Instance swap can change it.

6. Interactions: Interaction

  • The row says where it is, which never changes: on a component or on a page (an instance's Show component in Pages), and on a drawn layer, a captured element (selector), or the whole variant (neither).
  • Changes (new target kind INTERACTION):
Property Value
trigger CLICK · HOVER · PRESS · KEY · DELAY · PART_EVENT
key for KEY: Enter, Escape, ArrowDown, Mod+K
delay for DELAY: milliseconds
event for PART_EVENT: { "part": <drawn element id of the nested instance>, "event": "onChange" }
runsIn the variants it runs in, by State and property values: { "state": ["DATA", "ERROR"], <propertyId>: ["on"] }; unset is all
actions the ordered list, below
[
  { "kind": "CHANGE_TO", "target": "PARENT", "values": { "<propertyId>": "old", "state": "keep" } },
  { "kind": "SET_PROPERTY", "target": "SELF", "property": "<propertyId>", "value": "on" },
  { "kind": "SET_PROPERTY", "target": "SELF", "property": "<propertyId>", "toggle": true },
  { "kind": "SET_PROPERTY", "target": "SELF", "property": "<propertyId>", "step": "next" },
  { "kind": "SHOW_COMPONENT", "component": "<componentId>", "values": { "<propertyId>": "success" } },
  { "kind": "CLOSE_COMPONENT" },
  { "kind": "EMIT", "event": "onChange", "with": "<propertyId>" }
]
  • target is SELF or PARENT (a nested instance changes its group). step cycles a Variant property's options (the Date picker's month, the Table's sort). CLOSE_COMPONENT with no component closes itself. Events are derived from the EMIT actions; Get code writes each as a handler prop.
  • On a page the actions are SHOW_COMPONENT and CLOSE_COMPONENT only. Open page is not an action: it stays the journey link (JourneyEdge, with its LinkAction) on the instance's element.
  • "Radio's own On click is replaced" is derived: an interaction on a nested instance with the same trigger replaces the part's own.
  • Triggers and actions are checked by @trunk/db code when written, not by a database enum, so a trigger the kit needs later (typing, blur, drag) needs no migration.
model Interaction {
  id             String   @id @default(uuid(7)) @db.Uuid
  clientId       String   @db.Uuid
  productId      String   @db.Uuid
  componentId    String?  @db.Uuid
  pageId         String?  @db.Uuid
  drawnElementId String?  @db.Uuid
  selector       String   @default("")
  createdAt      DateTime @default(now()) @db.Timestamptz(3)

  product      Product       @relation(fields: [productId, clientId], references: [id, clientId], onDelete: Cascade)
  component    Component?    @relation(fields: [componentId, productId, clientId], references: [id, productId, clientId], onDelete: Cascade)
  page         Page?         @relation(fields: [pageId, productId, clientId], references: [id, productId, clientId], onDelete: Cascade)
  drawnElement DrawnElement? @relation(fields: [drawnElementId, productId, clientId], references: [id, productId, clientId], onDelete: Cascade)

  @@unique([id, productId, clientId])
  @@index([componentId])
  @@index([pageId])
  @@index([drawnElementId])
  @@index([productId])
}

7. Where a component came from: Component.kitKey, Component.importSystem

  • origin stays PrimitiveOrigin. Taken from the kit is CREATED with kitKey (trunk/select); imported is IMPORTED with importSystem (shadcn, mui, figma-file…); made (Blank, Duplicate) is CREATED with neither; captured is CAPTURED.
  • Changes on COMPONENT: kitVersion (the release it was taken at or last updated to, 1.0.0) and detached (true). Detach, an update and Reset to kit are edits in the draft, so they can be reviewed and undone. Reset to kit sets detached back and writes the kit's values over the component's own.
  • A duplicate of a kit component keeps kitKey and starts with detached set.
  • The kit version in use on the Kit screen is derived: the oldest kitVersion among the attached kit components. The update offer is computed: attached kit components whose kitVersion is behind the release in code.
  • Kit releases are code, not a table: one version number for the whole kit.

8. A kit update can conflict: Conflict.kitVersion

  • A Trunk Kit update writes its changes into the draft. Where the person changed the same property since the kit version they are at, it is a conflict, settled the usual way on Review.
  • Change Conflict.mainCommitId to optional, and add Conflict.kitVersion: a conflict is raised by main's commit or by a kit release, never both.

9. Importing a kit: ImportKind.COMPONENTS, Import.fileKey, Import.tokenMap

  • ImportKind gains COMPONENTS.
  • fileKey: the uploaded file (a Figma library file, a Storybook build). A Figma library is read from the file; no Figma token.
  • tokenMap: their token → the product's token id, as shown before the import. What it painted is in the components' own changes, which everyone reads; the import row is the importer's, like their draft.

10. TargetKind gains COMPONENT_PROPERTY and INTERACTION

The change trigger's target_in_product knows both, so a change can point only at a row of the same product and company.

Impact

Row-level security, checks and triggers

  • RLS: ComponentProperty and Interaction get the company policy and the product-access policy, like every product table.
  • Also fixed: DrawnElement and StyleSection had the company policy but not the product-access one, so a member who may not open a Chosen product could read their rows. Both now have it.
  • Checks:
    • DrawnElement_one_host: exactly one of pageId, componentId.
    • Component_kit_key: kitKey only when CREATED, as trunk/<kebab-case>.
    • Component_import_system: importSystem set exactly when IMPORTED, in kebab case.
    • Interaction_one_host: exactly one of componentId, pageId.
    • Interaction_one_layer: not both a drawn layer and a selector.
    • Conflict_one_cause: exactly one of mainCommitId, kitVersion; Conflict_kit_version: x.y.z.
  • Triggers:
    • ComponentProperty and Interaction are never updated (row_never_changed).
    • Interaction_layer_here: an interaction's drawn layer is drawn on the same component or on the same page.
    • Every new key carries productId and clientId, so no new same-company trigger is needed.
  • Left to the app: at most one Interaction states property per component, no two variants in one cell, unique property names in a component. A row is shared by every ref, and a ref sees only the rows its changes make visible, so the database cannot check these per ref.

How it maps to the screens

Screen Reads
Component sets (13) Variants by state and values; spans; Interaction rows on the canvas as arrows and bolts
Properties, New property ComponentProperty and its changes; bindings in Layers
Interactions Interaction: On (layer), Runs in (runsIn), Trigger, Then (actions)
Tools, Icon in Pages DrawnKind.ICON, icon
Start from › From the kit A CREATE of the component, its variants, properties, layers and interactions, with kitKey and kitVersion
Kit, Kit update kitKey, kitVersion, detached; kit releases in code; Conflict.kitVersion
Import a kit Import (COMPONENTS, fileKey, tokenMap); Component.importSystem
Playground, page Preview Properties, values and interactions, played
Get code props Properties as props; EMIT actions as handlers

What it does not do

  • No table for kit releases, events or interaction actions: releases are code, events are derived, actions are one ordered change.
  • No change to how captured variants are found: State and Size stay what capture and Verify use. Captured variants are not turned into properties.
  • No animated transitions between variants.
  • No interaction, kit or import code in phase 1 of the build: those tables and columns are read from phase 2 on.

Why

Every choice keeps one rule: what can change is a change, so it is versioned like everything else. Against the first sketch in 2026-10-21-components-like-figma, that rejects:

  • VariantPropertyValue as a table (the first sketch): a captured Button's variants get their Type after capture, and a row cannot change on one branch and not another. A change can.
  • Name, order and code name on ComponentProperty: renaming or reordering a property is an edit, so it is a change. The code name is derived.
  • InteractionAction rows: an action's arguments change, so they would be rows that change. One ordered list is one change, and reordering is one change.
  • KIT in PrimitiveOrigin: it would apply to tokens and icons too. kitKey says it.
  • A source enum on Component with four values: origin plus kitKey and importSystem already says each one, once.
  • kitVersion as a column: an update moves it, through the draft.
  • Show component as a page-element property: one Interaction model for components and pages, so Preview plays both the same way.

Status

Approved by the owner, 2026-10-21, with these answers:

  1. The set's layer tree: yes, DrawnElement.variantId goes. One layer tree per component; a variant's own value for a layer is a DRAWN_ELEMENT change keyed by the variant (selector = its id). Done in the migration.
  2. The kit's extra triggers (typing, blur, drag, release, right-click, paste) are in 1.0.0. They need no migration: triggers are checked by @trunk/db code when written (phase 2).
  3. Variant.name stays until the build derives it from the values, then goes. The phase 1 build derives it, and the column is gone.

Commands run

prisma validate, prisma generate, pnpm --filter @trunk/db test:reset (trunk_test rebuilt: 50 tables), pnpm typecheck, pnpm lint, the tests, pnpm check:designs. The dev database was not touched: the owner runs pnpm db:reset.

Related

Sources

  • The owner's approval of the 0024 screens and answers, 2026-10-21.
  • packages/db/prisma/schema.prisma, packages/db/prisma/migrations/20261018120000_init/migration.sql