diff --git a/frontend/src/atlas/atlasNounDeclarationFields.json b/frontend/src/atlas/atlasNounDeclarationFields.json new file mode 100644 index 00000000..0cc88673 --- /dev/null +++ b/frontend/src/atlas/atlasNounDeclarationFields.json @@ -0,0 +1,67 @@ +[ + { + "field": "id", + "legalValues": "one of the ids declared in shared/atlasToolIdentity.ts's ATLAS_TOOL_IDENTITIES array", + "meaning": "the noun's stable identity. registerNoun() throws at module-eval time on a duplicate; assertRegistryAgreesWithIdentity() fails the build if an identity has no matching descriptor, or a descriptor has no matching identity." + }, + { + "field": "icon", + "legalValues": "any icon component from @primer/octicons-react, typed as Icon", + "meaning": "the glyph rendered on this noun's tray/palette button." + }, + { + "field": "label", + "legalValues": "a string", + "meaning": "the button/command text. By convention every in-tree noun sources this from identityOf(id).commandLabel rather than restating it, but the field itself accepts any string." + }, + { + "field": "shortcutKey", + "legalValues": "a single-character string, or null", + "meaning": "the bare keypress that arms this tool from the board. null for a tool with no bare-key shortcut." + }, + { + "field": "tray", + "legalValues": "'quick' or 'palette'", + "meaning": "which tray surface renders this tool's button." + }, + { + "field": "interaction", + "legalValues": "'arm-then-click', 'pick-then-place', 'drag-to-draw', 'drag-to-erase', 'ephemeral-drag', or 'paste-or-drop'", + "meaning": "the authoring gesture that places this noun. Must equal the same field on this id's own shared/atlasToolIdentity.ts entry -- the registry's own agreement check cross-validates the two so they can never silently drift apart." + }, + { + "field": "styleDefaults", + "legalValues": "a record of style-field key to value, or omitted entirely", + "meaning": "session-only seed values for a freshly placed instance's style state (colour, size, ...). Never persisted document data -- omit this field for a noun with no style surface rather than declaring an empty object." + }, + { + "field": "styleFields", + "legalValues": "a readonly array of AtlasStyleField entries (atlasStyleVocabulary.ts's closed union: color, color-or-none, stroke-width, or shape-kind) -- REQUIRED, never optional", + "meaning": "this noun's own declared styleable properties. An empty array is the honest answer for a noun with no style surface at all. A non-empty array makes AtlasStylePanel.tsx render this tool's style picker automatically -- no other file needs to name this noun's id." + }, + { + "field": "lockable", + "legalValues": "boolean -- REQUIRED, never optional", + "meaning": "does re-clicking this tool's own already-armed tray button lock it for repeated placement, instead of disarming on the second click? Only meaningful for an arm-then-click tool; every other tool still declares it, always false." + }, + { + "field": "resizable", + "legalValues": "boolean -- REQUIRED, never optional", + "meaning": "can a placed instance be dragged to a new size via the shared NodeResizer? A container that auto-fits its own children, or a tool that never persists a placed instance, both legitimately declare false. The conformance suite checks that a true answer is backed by a real `` in the renderer boardNodeType names." + }, + { + "field": "boardNodeType", + "legalValues": "'atlas-note', 'atlas-sticky', 'atlas-group', 'atlas-object', or null", + "meaning": "which shared React Flow node component renders this noun's placed instance. null for a tool whose gesture never persists a renderable instance (eraser, laser)." + }, + { + "field": "dragBand", + "legalValues": "boolean -- REQUIRED, never optional", + "meaning": "only load-bearing when boardNodeType is 'atlas-object': does this noun's own content capture pointer events (a grid, a vendored pan/zoom viewer), so the shared renderer needs to add its own chrome band as the drag surface? A noun whose whole body already drags declares false, not omitted." + }, + { + "field": "commit", + "legalValues": "a function -- see 'How it loads' for why this is the one member that is runtime code, not declaration", + "meaning": "shapes this noun's own placement input into the artifact CreateBoardObject/CreateCard persists. Each noun's own signature differs; the registry's element type only has to accept every one of them, never call through it generically." + } +] diff --git a/frontend/src/atlas/atlasNounDeclarationFields.test.ts b/frontend/src/atlas/atlasNounDeclarationFields.test.ts new file mode 100644 index 00000000..eefb48e6 --- /dev/null +++ b/frontend/src/atlas/atlasNounDeclarationFields.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import declarationFields from './atlasNounDeclarationFields.json' +import type { AtlasToolShape } from './atlasNounRegistry' + +// This IS goal 0211's chosen freshness mechanism for +// userdocs/reference/extending-the-canvas.md's "What is required" +// table: internal/docsgen has no TypeScript parser, so the table is +// generated (by internal/docsgen's own Go test/gen step) from the +// small committed JSON this file imports -- and THIS test is the other +// half of that freshness chain, verifying the JSON stays exhaustive +// against the real registry type rather than drifting into a stale, +// separately hand-maintained field list. +// +// EXHAUSTIVE_FIELD_KEYS is a literal object, `satisfies`-checked +// against Record -- TypeScript's own +// excess-property + missing-property checks (both only apply to a +// fresh object literal, never to an imported binding) make this +// object fail to COMPILE the moment AtlasToolShape gains, loses, or +// renames a member. `keyof AtlasToolShape` intersects the keys of +// every union member (they all share the same base fields plus id/ +// interaction), so this needs no exported base interface to reach +// into. +const EXHAUSTIVE_FIELD_KEYS = { + id: true, + icon: true, + label: true, + shortcutKey: true, + tray: true, + interaction: true, + styleDefaults: true, + styleFields: true, + lockable: true, + resizable: true, + boardNodeType: true, + dragBand: true, + commit: true, +} satisfies Record + +describe('atlasNounDeclarationFields.json (goal 0211: the extension contract page)', () => { + it('documents exactly the fields AtlasToolShape actually has -- no more, no fewer', () => { + const jsonFields = (declarationFields as { field: string }[]).map((f) => f.field).sort() + const typeFields = Object.keys(EXHAUSTIVE_FIELD_KEYS).sort() + expect(jsonFields).toEqual(typeFields) + }) + + it('gives every field a non-empty legalValues and meaning', () => { + for (const entry of declarationFields as { field: string; legalValues: string; meaning: string }[]) { + expect(entry.legalValues.length, `${entry.field}.legalValues`).toBeGreaterThan(0) + expect(entry.meaning.length, `${entry.field}.meaning`).toBeGreaterThan(0) + } + }) +}) diff --git a/internal/docsgen/docsgen.go b/internal/docsgen/docsgen.go index 953d92b3..4adce1d1 100644 --- a/internal/docsgen/docsgen.go +++ b/internal/docsgen/docsgen.go @@ -160,6 +160,7 @@ func PageIndex() []DocPage { {"concepts/runs-and-review.md", "Runs, review, and debugging", "durable runs, the review queue, breakpoints"}, {"reference/steps.md", "Step reference", "every step's contract, generated from the registry"}, {"reference/settings.md", "Settings", "app preferences: appearance, hotkeys, shortcuts, MCP access, remote access, backups, updates"}, + {"reference/extending-the-canvas.md", "Extending the canvas", "how a canvas noun loads, what its declaration requires, and what platform APIs it may and may not reach"}, {"agents/connect-mcp.md", "Automate with agents", "connecting over MCP and what agents can do"}, {"trust/data-and-safety.md", "Trust, data, and safety", "no phone-home, local data, honest limits"}, } diff --git a/internal/docsgen/docsgen_nouns.go b/internal/docsgen/docsgen_nouns.go new file mode 100644 index 00000000..b69224c2 --- /dev/null +++ b/internal/docsgen/docsgen_nouns.go @@ -0,0 +1,85 @@ +package docsgen + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// declarationField mirrors one entry of +// frontend/src/atlas/atlasNounDeclarationFields.json -- the noun +// registry contract table's own source data (goal 0211). Go has no +// TypeScript parser, so extracting the table straight from +// AtlasToolShape's own .ts declaration was disproportionate; this JSON +// is the generation source instead, and +// atlasNounDeclarationFields.test.ts is the other half of the +// freshness chain, `satisfies`-checking a literal field-key list +// against the real type at TS compile time and comparing this JSON's +// own field set against that list at test time. Neither half alone +// would catch drift on its own language's side. +type declarationField struct { + Field string `json:"field"` + LegalValues string `json:"legalValues"` + Meaning string `json:"meaning"` +} + +// Markers bounding the one generated region inside the otherwise +// hand-authored userdocs/reference/extending-the-canvas.md -- naming +// the exact source keeps the page honest about what freshness is (and +// is not) enforced: only the region between these two lines is +// regenerated by `go generate ./internal/docsgen` and diff-checked by +// TestExtendingCanvasPage_NounFieldTableMatchesCommitted; the prose +// around it carries no such promise. +const ( + NounFieldTableBeginMarker = "" + NounFieldTableEndMarker = "" +) + +// GenerateNounFieldTable renders every AtlasToolShape declaration +// field as a markdown table, read from the committed JSON at +// /atlasNounDeclarationFields.json. `commit` is +// skipped -- it is the one AtlasToolShape member that is runtime code, +// not inert declaration, and the page's "How it loads" section +// documents it separately under the declaration-vs-code split. +func GenerateNounFieldTable(frontendAtlasDir string) (string, error) { + raw, err := os.ReadFile(filepath.Join(frontendAtlasDir, "atlasNounDeclarationFields.json")) // #nosec G304 -- caller-controlled fixed path, never external input + if err != nil { + return "", fmt.Errorf("read atlasNounDeclarationFields.json: %w", err) + } + var fields []declarationField + if err := json.Unmarshal(raw, &fields); err != nil { + return "", fmt.Errorf("parse atlasNounDeclarationFields.json: %w", err) + } + var b strings.Builder + b.WriteString("| Field | Legal values | Meaning |\n") + b.WriteString("|---|---|---|\n") + for _, f := range fields { + if f.Field == "commit" { + continue + } + fmt.Fprintf(&b, "| `%s` | %s | %s |\n", f.Field, f.LegalValues, f.Meaning) + } + return b.String(), nil +} + +// ReplaceMarkedRegion swaps the text strictly between beginMarker and +// endMarker (both kept, byte-identical, in doc) for replacement -- +// gen/main.go uses this to regenerate extending-the-canvas.md's one +// generated table in place without touching the hand-authored prose +// around it; the freshness test below does the same splice to compare +// against the committed file. +func ReplaceMarkedRegion(doc, beginMarker, endMarker, replacement string) (string, error) { + start := strings.Index(doc, beginMarker) + if start == -1 { + return "", fmt.Errorf("begin marker %q not found", beginMarker) + } + contentStart := start + len(beginMarker) + end := strings.Index(doc[contentStart:], endMarker) + if end == -1 { + return "", fmt.Errorf("end marker %q not found after begin marker", endMarker) + } + end += contentStart + return doc[:contentStart] + "\n\n" + replacement + "\n" + doc[end:], nil +} diff --git a/internal/docsgen/docsgen_nouns_test.go b/internal/docsgen/docsgen_nouns_test.go new file mode 100644 index 00000000..60c3de64 --- /dev/null +++ b/internal/docsgen/docsgen_nouns_test.go @@ -0,0 +1,33 @@ +package docsgen + +import ( + "os" + "path/filepath" + "testing" +) + +// Same tfplugindocs shape as TestUserDocs_MatchCommitted, scoped to +// the one generated region inside an otherwise hand-authored page +// (goal 0211's "Extending the canvas" contract page mixes reviewed +// prose with a generated table, unlike steps.md/llms.txt which are +// generated wholesale). Fix a failure with +// `go generate ./internal/docsgen`. +func TestExtendingCanvasPage_NounFieldTableMatchesCommitted(t *testing.T) { + pagePath := filepath.Join("..", "..", "userdocs", "reference", "extending-the-canvas.md") + committed, err := os.ReadFile(pagePath) // #nosec G304 -- fixed path under this repo's own userdocs tree + if err != nil { + t.Fatalf("read extending-the-canvas.md: %v", err) + } + frontendAtlasDir := filepath.Join("..", "..", "frontend", "src", "atlas") + wantTable, err := GenerateNounFieldTable(frontendAtlasDir) + if err != nil { + t.Fatalf("generate noun field table: %v", err) + } + want, err := ReplaceMarkedRegion(string(committed), NounFieldTableBeginMarker, NounFieldTableEndMarker, wantTable) + if err != nil { + t.Fatalf("splice generated table into committed page: %v", err) + } + if string(committed) != want { + t.Errorf("extending-the-canvas.md's generated table is stale -- run `go generate ./internal/docsgen` and commit the result") + } +} diff --git a/internal/docsgen/gen/main.go b/internal/docsgen/gen/main.go index ddbfa9bc..85fa19b9 100644 --- a/internal/docsgen/gen/main.go +++ b/internal/docsgen/gen/main.go @@ -18,6 +18,10 @@ func main() { fmt.Fprintln(os.Stderr, err) os.Exit(1) } + if err := regenerateNounFieldTable(root); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } for name, gen := range map[string]func(string) (string, error){ "llms.txt": docsgen.GenerateLLMSTxt, "llms-full.txt": docsgen.GenerateLLMSFullTxt, @@ -33,3 +37,27 @@ func main() { } } } + +// regenerateNounFieldTable splices the freshly generated noun +// declaration-field table into extending-the-canvas.md's one marked +// region, leaving the rest of the hand-authored page untouched. +func regenerateNounFieldTable(docsRoot string) error { + pagePath := filepath.Join(docsRoot, "reference", "extending-the-canvas.md") + existing, err := os.ReadFile(pagePath) // #nosec G304 -- fixed path under this repo's own userdocs tree + if err != nil { + return fmt.Errorf("read extending-the-canvas.md: %w", err) + } + frontendAtlasDir := filepath.Join("..", "..", "frontend", "src", "atlas") + table, err := docsgen.GenerateNounFieldTable(frontendAtlasDir) + if err != nil { + return fmt.Errorf("generate noun field table: %w", err) + } + updated, err := docsgen.ReplaceMarkedRegion(string(existing), docsgen.NounFieldTableBeginMarker, docsgen.NounFieldTableEndMarker, table) + if err != nil { + return fmt.Errorf("splice noun field table into extending-the-canvas.md: %w", err) + } + if err := os.WriteFile(pagePath, []byte(updated), 0o600); err != nil { // #nosec G703 -- same fixed path as the read above + return fmt.Errorf("write extending-the-canvas.md: %w", err) + } + return nil +} diff --git a/userdocs/llms-full.txt b/userdocs/llms-full.txt index 72cca2da..0dd74daf 100644 --- a/userdocs/llms-full.txt +++ b/userdocs/llms-full.txt @@ -1033,6 +1033,224 @@ tccutil reset Accessibility com.alicoding.mill --- +# Extending the canvas + +Mill's canvas (Atlas) supports nine placeable tools today — card, note, +area, table, image, pencil, eraser, laser, shape — each one a single +self-registered file under `frontend/src/atlas/tools/`. This page is +the contract that file has to satisfy: how it gets discovered, what +its declaration requires, and which platform services its runtime +code may call — and may not. + +Read this as a Mill developer working in this repo, not as a +plugin author installing a package: today, adding a canvas tool means +adding a file to Mill's own tree and rebuilding, the same way adding a +workflow step type does. There is no out-of-tree loading mechanism yet. +See `docs/goals/0211-extension-tiers.md` for why that's a deliberate, +recorded gap rather than an oversight. + +## How it loads + +A canvas tool is one file, `frontend/src/atlas/tools/Tool.ts`, +that: + +1. Builds an object matching the `AtlasToolShape` type + (`frontend/src/atlas/atlasNounRegistry.ts`). +2. Calls `registerNoun(thatObject)` at module scope — not inside a + function, not conditionally. The call has to run the moment the + module loads. + +`frontend/src/atlas/atlasTools.ts` discovers every file matching +`tools/*.ts` via `import.meta.glob(..., { eager: true })` — a glob +over the filesystem, not a hand-maintained list a new tool has to be +appended to. `registerNoun()` throws immediately if two files claim +the same `id`, and a startup check +(`assertRegistryAgreesWithIdentity()`) fails hard if a tool has an +identity (`frontend/src/shared/atlasToolIdentity.ts`) but no +registered descriptor, or a descriptor but no identity — a half-wired +tool cannot exist silently. + +A tool's own translated strings follow the same shape, one level down: +`frontend/src/locales/en/atlas/.json` is merged into the single +`atlas` i18next namespace by `frontend/src/app/atlasLocaleMerge.ts`, +discovered the same glob-and-merge way. The merge refuses two files +declaring the same top-level key, so a new tool's own locale file can +never silently clobber another tool's strings. + +**The declaration-vs-code split.** This is the same split VS Code's +own extension model draws between `contributes` (inert JSON, read +before any extension code runs) and its runtime API (where behavior +actually lives) — worth naming explicitly, because it's what lets the +registry be checked and documented as data. On `AtlasToolShape`: + +- **Inert declaration** — read before any of this tool's own code + runs, and enumerable purely as data: `id`, `icon`, `label`, + `shortcutKey`, `tray`, `interaction`, `styleDefaults`, + `styleFields`, `lockable`, `resizable`, `boardNodeType`, + `dragBand`. The whole "What is required" table below is this list. +- **Runtime code** — the one member that's a function, not data: + `commit`, which shapes this tool's own placement input into the + artifact the board persists. + +## What is required + +Every field on `AtlasToolShape` other than `commit` (documented above +as the one runtime-code member) is REQUIRED — never optional, never +inferred — so a tool that omits one fails to compile rather than +half-existing. `false`, `null`, and an empty array are legitimate, +honest answers for a field that doesn't apply to a given tool; they +are never omissions. + + + +| Field | Legal values | Meaning | +|---|---|---| +| `id` | one of the ids declared in shared/atlasToolIdentity.ts's ATLAS_TOOL_IDENTITIES array | the noun's stable identity. registerNoun() throws at module-eval time on a duplicate; assertRegistryAgreesWithIdentity() fails the build if an identity has no matching descriptor, or a descriptor has no matching identity. | +| `icon` | any icon component from @primer/octicons-react, typed as Icon | the glyph rendered on this noun's tray/palette button. | +| `label` | a string | the button/command text. By convention every in-tree noun sources this from identityOf(id).commandLabel rather than restating it, but the field itself accepts any string. | +| `shortcutKey` | a single-character string, or null | the bare keypress that arms this tool from the board. null for a tool with no bare-key shortcut. | +| `tray` | 'quick' or 'palette' | which tray surface renders this tool's button. | +| `interaction` | 'arm-then-click', 'pick-then-place', 'drag-to-draw', 'drag-to-erase', 'ephemeral-drag', or 'paste-or-drop' | the authoring gesture that places this noun. Must equal the same field on this id's own shared/atlasToolIdentity.ts entry -- the registry's own agreement check cross-validates the two so they can never silently drift apart. | +| `styleDefaults` | a record of style-field key to value, or omitted entirely | session-only seed values for a freshly placed instance's style state (colour, size, ...). Never persisted document data -- omit this field for a noun with no style surface rather than declaring an empty object. | +| `styleFields` | a readonly array of AtlasStyleField entries (atlasStyleVocabulary.ts's closed union: color, color-or-none, stroke-width, or shape-kind) -- REQUIRED, never optional | this noun's own declared styleable properties. An empty array is the honest answer for a noun with no style surface at all. A non-empty array makes AtlasStylePanel.tsx render this tool's style picker automatically -- no other file needs to name this noun's id. | +| `lockable` | boolean -- REQUIRED, never optional | does re-clicking this tool's own already-armed tray button lock it for repeated placement, instead of disarming on the second click? Only meaningful for an arm-then-click tool; every other tool still declares it, always false. | +| `resizable` | boolean -- REQUIRED, never optional | can a placed instance be dragged to a new size via the shared NodeResizer? A container that auto-fits its own children, or a tool that never persists a placed instance, both legitimately declare false. The conformance suite checks that a true answer is backed by a real `` in the renderer boardNodeType names. | +| `boardNodeType` | 'atlas-note', 'atlas-sticky', 'atlas-group', 'atlas-object', or null | which shared React Flow node component renders this noun's placed instance. null for a tool whose gesture never persists a renderable instance (eraser, laser). | +| `dragBand` | boolean -- REQUIRED, never optional | only load-bearing when boardNodeType is 'atlas-object': does this noun's own content capture pointer events (a grid, a vendored pan/zoom viewer), so the shared renderer needs to add its own chrome band as the drag surface? A noun whose whole body already drags declares false, not omitted. | + + + +**Your declaration must pass the conformance suite — it IS the +contract test**, the same way a compiler is the contract test for a +type. A new or changed tool has to keep these files passing: + +- `frontend/src/atlas/atlasNounDeclarationFields.test.ts` — this + page's own table stays exhaustive against the real `AtlasToolShape` + type. +- `frontend/src/atlas/atlasArmConformance.test.ts` — a tool's + `lockable` answer matches its actual arm/disarm behavior. +- `frontend/src/atlas/atlasBoardSurfaceConformance.test.ts` — a + `resizable: true` answer is backed by a real `NodeResizer` in the + renderer its `boardNodeType` names; a `boardNodeType: 'atlas-object'` + tool keeps the shared drag frame band wired; the drag band is gated + on `dragBand`, never rendered unconditionally; and two specific + shared files (`AtlasCreationTray.tsx`, `AtlasStylePanel.tsx`) contain + no tool-id branch. +- `frontend/src/atlas/atlasEditorBoundsConformance.test.ts` and + `atlasSelectionRingConformance.test.ts` — the shared editor-bounds + and selection-ring surfaces reach every registered tool, not a + hand-picked subset. + +None of these render anything — they read source files and the live +registry as data and assert against it (this repo's own +"static source-audit" pattern, documented in each test file's header). +A tool that satisfies the type checker and these tests is, by +definition, correctly wired. + +## What platform APIs exist — and what you may not reach + +A tool's own `commit` function, and any board-rendering code it needs +(shared node renderers, not the tool file itself — see "inherits for +free" below), may call: + +- **Board object CRUD** — `AtlasService.CreateBoardObject`, + `SetBoardObjectSize`, `SetBoardObjectPosition`, `MoveBoardObject`, + `DeleteBoardObject`, `Objects` (generated bindings at + `frontend/bindings/.../internal/services/atlassvc/atlasservice.ts`). +- **Mirroring and captures** — `AtlasService.SaveImageBytes` writes + pasted or drawn bytes to a Mill-owned file and returns its path (used + by both `imageTool.ts` for a pasted clipboard image and + `pencilTool.ts` for a baked stroke SVG); `ObjectMirrorContent` reads + a mirrored file's bytes back for rendering; `RepickObjectMirror` + re-points an existing object at a different local file. +- **The mirror-changed subscription** — + `useAtlasMirrorChanged(id, onChange)` + (`frontend/src/atlas/useAtlasMirrorChanged.ts`) fires whenever a + given object's own mirrored file changes on disk, so a renderer can + refetch instead of polling. +- **The style value store** — `useAtlasNounStyle(nounId)` / + `useAtlasSetStyleValue()` (`frontend/src/atlas/atlasStyleValueStore.ts`) + is the one generic, noun-agnostic store every `styleFields`-declaring + tool reads and writes its session-only style defaults through — never + a bespoke per-tool store. +- **Configure entities** — when a tool's own artifact needs a + reusable "which external thing" reference rather than a one-off + value (`.claude/rules/architecture.md`'s business-vs-integration + test), it goes through `ConfigureService` the way `tableTool.ts` + mints its backing List via `ConfigureService.CreateList` / + `AddListRow`. +- **Selection and resize — inherited, not written.** Declaring + `resizable: true` plus a `boardNodeType` is the entire cost: the + shared renderer that `boardNodeType` names already carries a + `NodeResizer` and selection highlighting for every tool routed + through it. A tool's own `commit` function never calls + `SetBoardObjectSize` or any resize RPC itself — that call lives + entirely in the shared renderer, fired once, for every tool that + opted in by declaring the field. + +**What you may not reach, and what actually stops you.** Compiled-in +TypeScript has no process boundary and no sandbox — nothing here is +enforced the way a browser extension's content-script isolation is. +Each line below names the real mechanism, honestly, rather than +implying a barrier that doesn't exist: + +- **The workflow/composition domain** (`frontend/src/composition/`, + and its Go counterpart `internal/domain/composition`) — enforced by + `frontend/.dependency-cruiser.cjs`'s `atlas-must-not-depend-on-composition` + rule, run by both Lefthook and CI's `boundaries` job. A real import + across that line fails the build. +- **The `configure/`, `views/`, and `app/` bounded-context folders** + your tool file has no reason to import from directly — same + dependency-cruiser config, the `domain-folders-must-not-depend-on-views-or-app` + and related rules. +- **The Go kernel itself** — durable execution, the guardrail engine, + the composition graph engine. There is no TypeScript enforcement here + because none is needed: a tool's runtime code can only call whatever + a generated `*service.ts` bindings file happens to export, and Wails + only generates a binding for a service's own exported Go method. + The kernel is unreachable by construction — no RPC exists to call — + not because a rule blocks it. +- **Other tools' own private implementation modules** — files inside + `frontend/src/atlas/` that are not one of the APIs named above (for + example `atlasBuildBoardObjectNodes.ts`'s internal z-order table, or + the per-Kind branches inside the shared `AtlasBoardObjectNode.tsx` + renderer). **This is enforced by review only.** TypeScript has no + module-private keyword, and nothing stops a `tools/Tool.ts` file + from importing any other file under `frontend/src/atlas/` at all — + the conformance suite above only checks two specific shared files for + one specific bad pattern (a hardcoded tool-id branch), not every + possible reach into implementation detail. Staying inside the surface + documented above, once you're inside the same folder, is a norm this + repo's reviewers hold, not something the compiler holds for you. +- **A duplicate `id`** — enforced by `registerNoun()`'s own runtime + throw and `assertRegistryAgreesWithIdentity()`'s startup check (see + "How it loads"). + +## Stability + +**Stable today:** the declaration fields on `AtlasToolShape` (the +table above) and the conformance suite that checks them. Both have +already absorbed nine tools' worth of real additions without +structural change, and any drift is caught immediately — the suite +breaks the moment a field's meaning or a renderer's contract changes +underneath an existing tool. + +**Not promised:** no semver, no deprecation window, and no +compatibility guarantee on the *runtime* API — `commit`'s own +signature shape, the exact set of `AtlasService` RPCs a tool may call, +or any shared renderer's internal behavior — until there is a second +adopter outside this repo to actually break. Today every "adopter" is +one of Mill's own nine files, changed in the same pull request as any +platform change that affects it, so nothing here has ever needed to +stay backward compatible. This is deliberate, not an oversight: see +`docs/goals/0211-extension-tiers.md` for the research behind stating +that honestly now (a platform that discovers its own contract gaps only +after outside adopters build against them pays for the omission for +years) — and for what actually triggers building the out-of-tree tier +this page does not yet promise. + +--- + # Automate with agents Mill exposes its full surface over MCP (Model Context Protocol), so diff --git a/userdocs/llms.txt b/userdocs/llms.txt index 71350939..4da18e17 100644 --- a/userdocs/llms.txt +++ b/userdocs/llms.txt @@ -14,5 +14,6 @@ - [Runs, review, and debugging](concepts/runs-and-review.md): durable runs, the review queue, breakpoints - [Step reference](reference/steps.md): every step's contract, generated from the registry - [Settings](reference/settings.md): app preferences: appearance, hotkeys, shortcuts, MCP access, remote access, backups, updates +- [Extending the canvas](reference/extending-the-canvas.md): how a canvas noun loads, what its declaration requires, and what platform APIs it may and may not reach - [Automate with agents](agents/connect-mcp.md): connecting over MCP and what agents can do - [Trust, data, and safety](trust/data-and-safety.md): no phone-home, local data, honest limits diff --git a/userdocs/reference/extending-the-canvas.md b/userdocs/reference/extending-the-canvas.md new file mode 100644 index 00000000..381e3249 --- /dev/null +++ b/userdocs/reference/extending-the-canvas.md @@ -0,0 +1,215 @@ +# Extending the canvas + +Mill's canvas (Atlas) supports nine placeable tools today — card, note, +area, table, image, pencil, eraser, laser, shape — each one a single +self-registered file under `frontend/src/atlas/tools/`. This page is +the contract that file has to satisfy: how it gets discovered, what +its declaration requires, and which platform services its runtime +code may call — and may not. + +Read this as a Mill developer working in this repo, not as a +plugin author installing a package: today, adding a canvas tool means +adding a file to Mill's own tree and rebuilding, the same way adding a +workflow step type does. There is no out-of-tree loading mechanism yet. +See `docs/goals/0211-extension-tiers.md` for why that's a deliberate, +recorded gap rather than an oversight. + +## How it loads + +A canvas tool is one file, `frontend/src/atlas/tools/Tool.ts`, +that: + +1. Builds an object matching the `AtlasToolShape` type + (`frontend/src/atlas/atlasNounRegistry.ts`). +2. Calls `registerNoun(thatObject)` at module scope — not inside a + function, not conditionally. The call has to run the moment the + module loads. + +`frontend/src/atlas/atlasTools.ts` discovers every file matching +`tools/*.ts` via `import.meta.glob(..., { eager: true })` — a glob +over the filesystem, not a hand-maintained list a new tool has to be +appended to. `registerNoun()` throws immediately if two files claim +the same `id`, and a startup check +(`assertRegistryAgreesWithIdentity()`) fails hard if a tool has an +identity (`frontend/src/shared/atlasToolIdentity.ts`) but no +registered descriptor, or a descriptor but no identity — a half-wired +tool cannot exist silently. + +A tool's own translated strings follow the same shape, one level down: +`frontend/src/locales/en/atlas/.json` is merged into the single +`atlas` i18next namespace by `frontend/src/app/atlasLocaleMerge.ts`, +discovered the same glob-and-merge way. The merge refuses two files +declaring the same top-level key, so a new tool's own locale file can +never silently clobber another tool's strings. + +**The declaration-vs-code split.** This is the same split VS Code's +own extension model draws between `contributes` (inert JSON, read +before any extension code runs) and its runtime API (where behavior +actually lives) — worth naming explicitly, because it's what lets the +registry be checked and documented as data. On `AtlasToolShape`: + +- **Inert declaration** — read before any of this tool's own code + runs, and enumerable purely as data: `id`, `icon`, `label`, + `shortcutKey`, `tray`, `interaction`, `styleDefaults`, + `styleFields`, `lockable`, `resizable`, `boardNodeType`, + `dragBand`. The whole "What is required" table below is this list. +- **Runtime code** — the one member that's a function, not data: + `commit`, which shapes this tool's own placement input into the + artifact the board persists. + +## What is required + +Every field on `AtlasToolShape` other than `commit` (documented above +as the one runtime-code member) is REQUIRED — never optional, never +inferred — so a tool that omits one fails to compile rather than +half-existing. `false`, `null`, and an empty array are legitimate, +honest answers for a field that doesn't apply to a given tool; they +are never omissions. + + + +| Field | Legal values | Meaning | +|---|---|---| +| `id` | one of the ids declared in shared/atlasToolIdentity.ts's ATLAS_TOOL_IDENTITIES array | the noun's stable identity. registerNoun() throws at module-eval time on a duplicate; assertRegistryAgreesWithIdentity() fails the build if an identity has no matching descriptor, or a descriptor has no matching identity. | +| `icon` | any icon component from @primer/octicons-react, typed as Icon | the glyph rendered on this noun's tray/palette button. | +| `label` | a string | the button/command text. By convention every in-tree noun sources this from identityOf(id).commandLabel rather than restating it, but the field itself accepts any string. | +| `shortcutKey` | a single-character string, or null | the bare keypress that arms this tool from the board. null for a tool with no bare-key shortcut. | +| `tray` | 'quick' or 'palette' | which tray surface renders this tool's button. | +| `interaction` | 'arm-then-click', 'pick-then-place', 'drag-to-draw', 'drag-to-erase', 'ephemeral-drag', or 'paste-or-drop' | the authoring gesture that places this noun. Must equal the same field on this id's own shared/atlasToolIdentity.ts entry -- the registry's own agreement check cross-validates the two so they can never silently drift apart. | +| `styleDefaults` | a record of style-field key to value, or omitted entirely | session-only seed values for a freshly placed instance's style state (colour, size, ...). Never persisted document data -- omit this field for a noun with no style surface rather than declaring an empty object. | +| `styleFields` | a readonly array of AtlasStyleField entries (atlasStyleVocabulary.ts's closed union: color, color-or-none, stroke-width, or shape-kind) -- REQUIRED, never optional | this noun's own declared styleable properties. An empty array is the honest answer for a noun with no style surface at all. A non-empty array makes AtlasStylePanel.tsx render this tool's style picker automatically -- no other file needs to name this noun's id. | +| `lockable` | boolean -- REQUIRED, never optional | does re-clicking this tool's own already-armed tray button lock it for repeated placement, instead of disarming on the second click? Only meaningful for an arm-then-click tool; every other tool still declares it, always false. | +| `resizable` | boolean -- REQUIRED, never optional | can a placed instance be dragged to a new size via the shared NodeResizer? A container that auto-fits its own children, or a tool that never persists a placed instance, both legitimately declare false. The conformance suite checks that a true answer is backed by a real `` in the renderer boardNodeType names. | +| `boardNodeType` | 'atlas-note', 'atlas-sticky', 'atlas-group', 'atlas-object', or null | which shared React Flow node component renders this noun's placed instance. null for a tool whose gesture never persists a renderable instance (eraser, laser). | +| `dragBand` | boolean -- REQUIRED, never optional | only load-bearing when boardNodeType is 'atlas-object': does this noun's own content capture pointer events (a grid, a vendored pan/zoom viewer), so the shared renderer needs to add its own chrome band as the drag surface? A noun whose whole body already drags declares false, not omitted. | + + + +**Your declaration must pass the conformance suite — it IS the +contract test**, the same way a compiler is the contract test for a +type. A new or changed tool has to keep these files passing: + +- `frontend/src/atlas/atlasNounDeclarationFields.test.ts` — this + page's own table stays exhaustive against the real `AtlasToolShape` + type. +- `frontend/src/atlas/atlasArmConformance.test.ts` — a tool's + `lockable` answer matches its actual arm/disarm behavior. +- `frontend/src/atlas/atlasBoardSurfaceConformance.test.ts` — a + `resizable: true` answer is backed by a real `NodeResizer` in the + renderer its `boardNodeType` names; a `boardNodeType: 'atlas-object'` + tool keeps the shared drag frame band wired; the drag band is gated + on `dragBand`, never rendered unconditionally; and two specific + shared files (`AtlasCreationTray.tsx`, `AtlasStylePanel.tsx`) contain + no tool-id branch. +- `frontend/src/atlas/atlasEditorBoundsConformance.test.ts` and + `atlasSelectionRingConformance.test.ts` — the shared editor-bounds + and selection-ring surfaces reach every registered tool, not a + hand-picked subset. + +None of these render anything — they read source files and the live +registry as data and assert against it (this repo's own +"static source-audit" pattern, documented in each test file's header). +A tool that satisfies the type checker and these tests is, by +definition, correctly wired. + +## What platform APIs exist — and what you may not reach + +A tool's own `commit` function, and any board-rendering code it needs +(shared node renderers, not the tool file itself — see "inherits for +free" below), may call: + +- **Board object CRUD** — `AtlasService.CreateBoardObject`, + `SetBoardObjectSize`, `SetBoardObjectPosition`, `MoveBoardObject`, + `DeleteBoardObject`, `Objects` (generated bindings at + `frontend/bindings/.../internal/services/atlassvc/atlasservice.ts`). +- **Mirroring and captures** — `AtlasService.SaveImageBytes` writes + pasted or drawn bytes to a Mill-owned file and returns its path (used + by both `imageTool.ts` for a pasted clipboard image and + `pencilTool.ts` for a baked stroke SVG); `ObjectMirrorContent` reads + a mirrored file's bytes back for rendering; `RepickObjectMirror` + re-points an existing object at a different local file. +- **The mirror-changed subscription** — + `useAtlasMirrorChanged(id, onChange)` + (`frontend/src/atlas/useAtlasMirrorChanged.ts`) fires whenever a + given object's own mirrored file changes on disk, so a renderer can + refetch instead of polling. +- **The style value store** — `useAtlasNounStyle(nounId)` / + `useAtlasSetStyleValue()` (`frontend/src/atlas/atlasStyleValueStore.ts`) + is the one generic, noun-agnostic store every `styleFields`-declaring + tool reads and writes its session-only style defaults through — never + a bespoke per-tool store. +- **Configure entities** — when a tool's own artifact needs a + reusable "which external thing" reference rather than a one-off + value (`.claude/rules/architecture.md`'s business-vs-integration + test), it goes through `ConfigureService` the way `tableTool.ts` + mints its backing List via `ConfigureService.CreateList` / + `AddListRow`. +- **Selection and resize — inherited, not written.** Declaring + `resizable: true` plus a `boardNodeType` is the entire cost: the + shared renderer that `boardNodeType` names already carries a + `NodeResizer` and selection highlighting for every tool routed + through it. A tool's own `commit` function never calls + `SetBoardObjectSize` or any resize RPC itself — that call lives + entirely in the shared renderer, fired once, for every tool that + opted in by declaring the field. + +**What you may not reach, and what actually stops you.** Compiled-in +TypeScript has no process boundary and no sandbox — nothing here is +enforced the way a browser extension's content-script isolation is. +Each line below names the real mechanism, honestly, rather than +implying a barrier that doesn't exist: + +- **The workflow/composition domain** (`frontend/src/composition/`, + and its Go counterpart `internal/domain/composition`) — enforced by + `frontend/.dependency-cruiser.cjs`'s `atlas-must-not-depend-on-composition` + rule, run by both Lefthook and CI's `boundaries` job. A real import + across that line fails the build. +- **The `configure/`, `views/`, and `app/` bounded-context folders** + your tool file has no reason to import from directly — same + dependency-cruiser config, the `domain-folders-must-not-depend-on-views-or-app` + and related rules. +- **The Go kernel itself** — durable execution, the guardrail engine, + the composition graph engine. There is no TypeScript enforcement here + because none is needed: a tool's runtime code can only call whatever + a generated `*service.ts` bindings file happens to export, and Wails + only generates a binding for a service's own exported Go method. + The kernel is unreachable by construction — no RPC exists to call — + not because a rule blocks it. +- **Other tools' own private implementation modules** — files inside + `frontend/src/atlas/` that are not one of the APIs named above (for + example `atlasBuildBoardObjectNodes.ts`'s internal z-order table, or + the per-Kind branches inside the shared `AtlasBoardObjectNode.tsx` + renderer). **This is enforced by review only.** TypeScript has no + module-private keyword, and nothing stops a `tools/Tool.ts` file + from importing any other file under `frontend/src/atlas/` at all — + the conformance suite above only checks two specific shared files for + one specific bad pattern (a hardcoded tool-id branch), not every + possible reach into implementation detail. Staying inside the surface + documented above, once you're inside the same folder, is a norm this + repo's reviewers hold, not something the compiler holds for you. +- **A duplicate `id`** — enforced by `registerNoun()`'s own runtime + throw and `assertRegistryAgreesWithIdentity()`'s startup check (see + "How it loads"). + +## Stability + +**Stable today:** the declaration fields on `AtlasToolShape` (the +table above) and the conformance suite that checks them. Both have +already absorbed nine tools' worth of real additions without +structural change, and any drift is caught immediately — the suite +breaks the moment a field's meaning or a renderer's contract changes +underneath an existing tool. + +**Not promised:** no semver, no deprecation window, and no +compatibility guarantee on the *runtime* API — `commit`'s own +signature shape, the exact set of `AtlasService` RPCs a tool may call, +or any shared renderer's internal behavior — until there is a second +adopter outside this repo to actually break. Today every "adopter" is +one of Mill's own nine files, changed in the same pull request as any +platform change that affects it, so nothing here has ever needed to +stay backward compatible. This is deliberate, not an oversight: see +`docs/goals/0211-extension-tiers.md` for the research behind stating +that honestly now (a platform that discovers its own contract gaps only +after outside adopters build against them pays for the omission for +years) — and for what actually triggers building the out-of-tree tier +this page does not yet promise.