Skip to content

Work / Trunk / Wiki / Decisions

2026-10-17-drawn-elements

Decisioncanonicalverified 2026-10-18

DECISION.2026-10-17.DRAWN-ELEMENTS

Drawn elements: a table of their own, changed like everything else

Approved by the owner on 2026-10-17 (T032).

Decision

  • A drawn element is one row in a new table, DrawnElement. The row holds only what never changes: which product, what kind it is, and where it lives (one page or one variant). Like an imported token or icon, it is CREATED: no ref sees it until a CREATE change for it is on that ref.
  • Everything about it that can change is a change row, the same as any other edit: its parent, its place among its siblings, x, y, width, height, its text, its path, the variant it uses, and its CSS. So drafts, Review, sync, conflicts, merge, history and Verify work for it with no new rules.
  • A new page is a page, not an element: a Page row with origin DESIGNED, made visible by a CREATE change, as the schema already allows. New pages are made on Journey, from the right-click menu or with N (2026-10-18-editor-redesign). A Frame drawn on a page is an element of kind FRAME.
  • Two people can design the same path. The unique index on a page's path holds for captured pages only; a designed page's path is checked when a branch is merged, as one of the branch page's checks.

Why

  • The owner chose a table of its own over JSON in DraftChange (0017, decided 2026-10-17): an element has many properties, and they change one at a time.
  • Keeping the properties as change rows, rather than columns, means two people who move the same rectangle conflict only on what both changed, property by property, which is how every other conflict works (branch-verify-merge). Columns would make a drawn element the one thing in Trunk that cannot be synced or merged property by property.
  • A row is never updated, so it can be shared by every ref: a branch that never saw the CREATE simply does not see it.

Impact

Schema

One new enum, one new table, one new target kind, and a partial unique index.

// What a drawn element is. FRAME is a box that holds others; COMPONENT_USE
// places a use of a variant.
enum DrawnKind {
  FRAME
  RECTANGLE
  TEXT
  PATH
  COMPONENT_USE
}

// An element someone drew. Only what never changes is here; its place, size,
// text, path, variant and styles are changes (targetKind DRAWN_ELEMENT), so a
// ref sees it, and sees it as it is, only through its changes. It lives on one
// page or in one variant.
// SQL check: exactly one of pageId and variantId.
model DrawnElement {
  id          String     @id @default(uuid(7)) @db.Uuid
  clientId    String     @db.Uuid
  productId   String     @db.Uuid
  kind        DrawnKind
  pageId      String?    @db.Uuid
  variantId   String?    @db.Uuid
  createdById String?    @db.Uuid
  createdAt   DateTime   @default(now()) @db.Timestamptz(3)

  product   Product  @relation(fields: [productId, clientId], references: [id, clientId], onDelete: Cascade)
  page      Page?    @relation(fields: [pageId, productId, clientId], references: [id, productId, clientId], onDelete: Cascade)
  variant   Variant? @relation(fields: [variantId, productId, clientId], references: [id, productId, clientId], onDelete: Cascade)
  createdBy User?    @relation(fields: [createdById], references: [id], onDelete: SetNull)

  @@unique([id, productId, clientId])
  @@index([pageId])
  @@index([variantId])
  @@index([productId])
  @@index([createdById])
}
  • TargetKind gains DRAWN_ELEMENT. Its properties, with selector "":

    property value
    parent the selector of the element it sits in, in its page or variant, or the id of a drawn FRAME; "" for the top
    after the selector or drawn id it follows among its siblings; "" for first
    x, y, width, height pixels from the parent's top left, at the size it was drawn
    text a TEXT element's words
    path a PATH element's SVG path data
    variant a COMPONENT_USE element's variant id
    any CSS property in EDITABLE_PROPERTIES as for a page's element
  • Page, Variant and User gain the back-relation drawnElements.

  • Migration SQL, at the bottom with the other checks:

    • DrawnElement: exactly one of pageId, variantId; row-level security like every company table; no UPDATE (a trigger, as for Commit).
    • The change-target trigger learns DRAWN_ELEMENT: the target is a DrawnElement of the same product.
    • Page_productId_path_key becomes a partial unique index, WHERE origin = 'CAPTURED'.

Rules the app keeps

  • A CREATE for a drawn element is always written with its parent, after, x, y, width and height in the same save.
  • A DELETE of a drawn FRAME deletes what is inside it, in the same save.
  • When a capture changes a page so a parent selector no longer matches, the element is drawn at the top of the page, and Analysis lists it as moved.
  • Merging a branch with a designed page whose path main already has is stopped by a check on the branch page, with a rename.

Code

  • packages/render draws a page's or a variant's drawn elements into its HTML, absolutely placed inside their parent, after the changes are laid over it.
  • packages/versioning and packages/history need no new rules: a drawn element is one more target kind.
  • Verify counts a drawn element as a difference, as it does any edit.

Not changed

  • No DraftChange, CommitChange or RefValue column changes.
  • The dev and test databases are dropped and reseeded once, as for every schema change before launch (CLAUDE.md, "MVP rule").

Status

Active. Approved 2026-10-17; the migration and the database rules are in place.

Sources

  • features/0017-canvas-tools.md, Decided, 2026-10-17.
  • packages/db/prisma/schema.prisma: PrimitiveOrigin, TargetKind, ChangeOp.