Skip to content

Commit ccf7d4e

Browse files
alicodingclaude
andauthored
docs: the Extending-the-canvas contract — how it loads, what's required, what you may reach (goal 0211) (#433)
One reference page, userdocs/reference/extending-the-canvas.md: how a canvas noun's tools/<id>Tool.ts loads (glob discovery + the declaration-vs-code split, VS Code's contributes/API precedent named directly), the AtlasToolShape field table (generated), the conformance suite that IS the contract test, and which platform APIs a noun's runtime code may use vs. what it may not reach — with the actual enforcement (dependency-cruiser boundary, review-only, or "no RPC exists to call") named per line rather than implying a sandbox compiled-in TypeScript doesn't have. Generation mechanism: Go docsgen has no TypeScript parser, so extracting the field table straight from AtlasToolShape's own .ts type was disproportionate. Instead: a small committed JSON (atlasNounDeclarationFields.json) is the generation source docsgen reads to render the table into one marked region of the page (go generate ./internal/docsgen, freshness-checked by TestExtendingCanvasPage_NounFieldTableMatchesCommitted); a `satisfies Record<keyof AtlasToolShape, true>`-checked literal inside atlasNounDeclarationFields.test.ts enforces on the TypeScript side that the JSON's field set can never drift from the real registry type (TypeScript's own excess/missing-property checks only apply to a fresh literal, so the check lives in the test file, not on the JSON import itself). The rest of the page is hand-authored prose, honestly not claimed as generated. Named-tax audit (goal 0211's own teeth): five places today's board-object rendering path already reaches past the documented surface, all clustered around AtlasBoardObjectNode.tsx/ atlasBuildBoardObjectNodes.ts/locales/en/atlas/shared.json -- diagram's missing registry entry, the unregistered object.Kind if/else content dispatch, per-Kind aria-labels leaking into the shared locale namespace, a hand-maintained z-order table with a silent default, and diagram's own dragBand/resizable gap. Full detail and the deferral (0211 slice 2, triggered by the first diagram-shaped noun or v1 planning) recorded in docs/goals/0211-extension-tiers.md's new "Named tax" section; SPEC.md §9.5 now points at the page. Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 92febd8 commit ccf7d4e

9 files changed

Lines changed: 700 additions & 0 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
[
2+
{
3+
"field": "id",
4+
"legalValues": "one of the ids declared in shared/atlasToolIdentity.ts's ATLAS_TOOL_IDENTITIES array",
5+
"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."
6+
},
7+
{
8+
"field": "icon",
9+
"legalValues": "any icon component from @primer/octicons-react, typed as Icon",
10+
"meaning": "the glyph rendered on this noun's tray/palette button."
11+
},
12+
{
13+
"field": "label",
14+
"legalValues": "a string",
15+
"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."
16+
},
17+
{
18+
"field": "shortcutKey",
19+
"legalValues": "a single-character string, or null",
20+
"meaning": "the bare keypress that arms this tool from the board. null for a tool with no bare-key shortcut."
21+
},
22+
{
23+
"field": "tray",
24+
"legalValues": "'quick' or 'palette'",
25+
"meaning": "which tray surface renders this tool's button."
26+
},
27+
{
28+
"field": "interaction",
29+
"legalValues": "'arm-then-click', 'pick-then-place', 'drag-to-draw', 'drag-to-erase', 'ephemeral-drag', or 'paste-or-drop'",
30+
"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."
31+
},
32+
{
33+
"field": "styleDefaults",
34+
"legalValues": "a record of style-field key to value, or omitted entirely",
35+
"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."
36+
},
37+
{
38+
"field": "styleFields",
39+
"legalValues": "a readonly array of AtlasStyleField entries (atlasStyleVocabulary.ts's closed union: color, color-or-none, stroke-width, or shape-kind) -- REQUIRED, never optional",
40+
"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."
41+
},
42+
{
43+
"field": "lockable",
44+
"legalValues": "boolean -- REQUIRED, never optional",
45+
"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."
46+
},
47+
{
48+
"field": "resizable",
49+
"legalValues": "boolean -- REQUIRED, never optional",
50+
"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 `<NodeResizer>` in the renderer boardNodeType names."
51+
},
52+
{
53+
"field": "boardNodeType",
54+
"legalValues": "'atlas-note', 'atlas-sticky', 'atlas-group', 'atlas-object', or null",
55+
"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)."
56+
},
57+
{
58+
"field": "dragBand",
59+
"legalValues": "boolean -- REQUIRED, never optional",
60+
"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."
61+
},
62+
{
63+
"field": "commit",
64+
"legalValues": "a function -- see 'How it loads' for why this is the one member that is runtime code, not declaration",
65+
"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."
66+
}
67+
]
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { describe, expect, it } from 'vitest'
2+
import declarationFields from './atlasNounDeclarationFields.json'
3+
import type { AtlasToolShape } from './atlasNounRegistry'
4+
5+
// This IS goal 0211's chosen freshness mechanism for
6+
// userdocs/reference/extending-the-canvas.md's "What is required"
7+
// table: internal/docsgen has no TypeScript parser, so the table is
8+
// generated (by internal/docsgen's own Go test/gen step) from the
9+
// small committed JSON this file imports -- and THIS test is the other
10+
// half of that freshness chain, verifying the JSON stays exhaustive
11+
// against the real registry type rather than drifting into a stale,
12+
// separately hand-maintained field list.
13+
//
14+
// EXHAUSTIVE_FIELD_KEYS is a literal object, `satisfies`-checked
15+
// against Record<keyof AtlasToolShape, true> -- TypeScript's own
16+
// excess-property + missing-property checks (both only apply to a
17+
// fresh object literal, never to an imported binding) make this
18+
// object fail to COMPILE the moment AtlasToolShape gains, loses, or
19+
// renames a member. `keyof AtlasToolShape` intersects the keys of
20+
// every union member (they all share the same base fields plus id/
21+
// interaction), so this needs no exported base interface to reach
22+
// into.
23+
const EXHAUSTIVE_FIELD_KEYS = {
24+
id: true,
25+
icon: true,
26+
label: true,
27+
shortcutKey: true,
28+
tray: true,
29+
interaction: true,
30+
styleDefaults: true,
31+
styleFields: true,
32+
lockable: true,
33+
resizable: true,
34+
boardNodeType: true,
35+
dragBand: true,
36+
commit: true,
37+
} satisfies Record<keyof AtlasToolShape, true>
38+
39+
describe('atlasNounDeclarationFields.json (goal 0211: the extension contract page)', () => {
40+
it('documents exactly the fields AtlasToolShape actually has -- no more, no fewer', () => {
41+
const jsonFields = (declarationFields as { field: string }[]).map((f) => f.field).sort()
42+
const typeFields = Object.keys(EXHAUSTIVE_FIELD_KEYS).sort()
43+
expect(jsonFields).toEqual(typeFields)
44+
})
45+
46+
it('gives every field a non-empty legalValues and meaning', () => {
47+
for (const entry of declarationFields as { field: string; legalValues: string; meaning: string }[]) {
48+
expect(entry.legalValues.length, `${entry.field}.legalValues`).toBeGreaterThan(0)
49+
expect(entry.meaning.length, `${entry.field}.meaning`).toBeGreaterThan(0)
50+
}
51+
})
52+
})

internal/docsgen/docsgen.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ func PageIndex() []DocPage {
160160
{"concepts/runs-and-review.md", "Runs, review, and debugging", "durable runs, the review queue, breakpoints"},
161161
{"reference/steps.md", "Step reference", "every step's contract, generated from the registry"},
162162
{"reference/settings.md", "Settings", "app preferences: appearance, hotkeys, shortcuts, MCP access, remote access, backups, updates"},
163+
{"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"},
163164
{"agents/connect-mcp.md", "Automate with agents", "connecting over MCP and what agents can do"},
164165
{"trust/data-and-safety.md", "Trust, data, and safety", "no phone-home, local data, honest limits"},
165166
}

internal/docsgen/docsgen_nouns.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package docsgen
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
)
10+
11+
// declarationField mirrors one entry of
12+
// frontend/src/atlas/atlasNounDeclarationFields.json -- the noun
13+
// registry contract table's own source data (goal 0211). Go has no
14+
// TypeScript parser, so extracting the table straight from
15+
// AtlasToolShape's own .ts declaration was disproportionate; this JSON
16+
// is the generation source instead, and
17+
// atlasNounDeclarationFields.test.ts is the other half of the
18+
// freshness chain, `satisfies`-checking a literal field-key list
19+
// against the real type at TS compile time and comparing this JSON's
20+
// own field set against that list at test time. Neither half alone
21+
// would catch drift on its own language's side.
22+
type declarationField struct {
23+
Field string `json:"field"`
24+
LegalValues string `json:"legalValues"`
25+
Meaning string `json:"meaning"`
26+
}
27+
28+
// Markers bounding the one generated region inside the otherwise
29+
// hand-authored userdocs/reference/extending-the-canvas.md -- naming
30+
// the exact source keeps the page honest about what freshness is (and
31+
// is not) enforced: only the region between these two lines is
32+
// regenerated by `go generate ./internal/docsgen` and diff-checked by
33+
// TestExtendingCanvasPage_NounFieldTableMatchesCommitted; the prose
34+
// around it carries no such promise.
35+
const (
36+
NounFieldTableBeginMarker = "<!-- BEGIN GENERATED: noun declaration fields (source: frontend/src/atlas/atlasNounDeclarationFields.json) -->"
37+
NounFieldTableEndMarker = "<!-- END GENERATED -->"
38+
)
39+
40+
// GenerateNounFieldTable renders every AtlasToolShape declaration
41+
// field as a markdown table, read from the committed JSON at
42+
// <frontendAtlasDir>/atlasNounDeclarationFields.json. `commit` is
43+
// skipped -- it is the one AtlasToolShape member that is runtime code,
44+
// not inert declaration, and the page's "How it loads" section
45+
// documents it separately under the declaration-vs-code split.
46+
func GenerateNounFieldTable(frontendAtlasDir string) (string, error) {
47+
raw, err := os.ReadFile(filepath.Join(frontendAtlasDir, "atlasNounDeclarationFields.json")) // #nosec G304 -- caller-controlled fixed path, never external input
48+
if err != nil {
49+
return "", fmt.Errorf("read atlasNounDeclarationFields.json: %w", err)
50+
}
51+
var fields []declarationField
52+
if err := json.Unmarshal(raw, &fields); err != nil {
53+
return "", fmt.Errorf("parse atlasNounDeclarationFields.json: %w", err)
54+
}
55+
var b strings.Builder
56+
b.WriteString("| Field | Legal values | Meaning |\n")
57+
b.WriteString("|---|---|---|\n")
58+
for _, f := range fields {
59+
if f.Field == "commit" {
60+
continue
61+
}
62+
fmt.Fprintf(&b, "| `%s` | %s | %s |\n", f.Field, f.LegalValues, f.Meaning)
63+
}
64+
return b.String(), nil
65+
}
66+
67+
// ReplaceMarkedRegion swaps the text strictly between beginMarker and
68+
// endMarker (both kept, byte-identical, in doc) for replacement --
69+
// gen/main.go uses this to regenerate extending-the-canvas.md's one
70+
// generated table in place without touching the hand-authored prose
71+
// around it; the freshness test below does the same splice to compare
72+
// against the committed file.
73+
func ReplaceMarkedRegion(doc, beginMarker, endMarker, replacement string) (string, error) {
74+
start := strings.Index(doc, beginMarker)
75+
if start == -1 {
76+
return "", fmt.Errorf("begin marker %q not found", beginMarker)
77+
}
78+
contentStart := start + len(beginMarker)
79+
end := strings.Index(doc[contentStart:], endMarker)
80+
if end == -1 {
81+
return "", fmt.Errorf("end marker %q not found after begin marker", endMarker)
82+
}
83+
end += contentStart
84+
return doc[:contentStart] + "\n\n" + replacement + "\n" + doc[end:], nil
85+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package docsgen
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
// Same tfplugindocs shape as TestUserDocs_MatchCommitted, scoped to
10+
// the one generated region inside an otherwise hand-authored page
11+
// (goal 0211's "Extending the canvas" contract page mixes reviewed
12+
// prose with a generated table, unlike steps.md/llms.txt which are
13+
// generated wholesale). Fix a failure with
14+
// `go generate ./internal/docsgen`.
15+
func TestExtendingCanvasPage_NounFieldTableMatchesCommitted(t *testing.T) {
16+
pagePath := filepath.Join("..", "..", "userdocs", "reference", "extending-the-canvas.md")
17+
committed, err := os.ReadFile(pagePath) // #nosec G304 -- fixed path under this repo's own userdocs tree
18+
if err != nil {
19+
t.Fatalf("read extending-the-canvas.md: %v", err)
20+
}
21+
frontendAtlasDir := filepath.Join("..", "..", "frontend", "src", "atlas")
22+
wantTable, err := GenerateNounFieldTable(frontendAtlasDir)
23+
if err != nil {
24+
t.Fatalf("generate noun field table: %v", err)
25+
}
26+
want, err := ReplaceMarkedRegion(string(committed), NounFieldTableBeginMarker, NounFieldTableEndMarker, wantTable)
27+
if err != nil {
28+
t.Fatalf("splice generated table into committed page: %v", err)
29+
}
30+
if string(committed) != want {
31+
t.Errorf("extending-the-canvas.md's generated table is stale -- run `go generate ./internal/docsgen` and commit the result")
32+
}
33+
}

internal/docsgen/gen/main.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ func main() {
1818
fmt.Fprintln(os.Stderr, err)
1919
os.Exit(1)
2020
}
21+
if err := regenerateNounFieldTable(root); err != nil {
22+
fmt.Fprintln(os.Stderr, err)
23+
os.Exit(1)
24+
}
2125
for name, gen := range map[string]func(string) (string, error){
2226
"llms.txt": docsgen.GenerateLLMSTxt,
2327
"llms-full.txt": docsgen.GenerateLLMSFullTxt,
@@ -33,3 +37,27 @@ func main() {
3337
}
3438
}
3539
}
40+
41+
// regenerateNounFieldTable splices the freshly generated noun
42+
// declaration-field table into extending-the-canvas.md's one marked
43+
// region, leaving the rest of the hand-authored page untouched.
44+
func regenerateNounFieldTable(docsRoot string) error {
45+
pagePath := filepath.Join(docsRoot, "reference", "extending-the-canvas.md")
46+
existing, err := os.ReadFile(pagePath) // #nosec G304 -- fixed path under this repo's own userdocs tree
47+
if err != nil {
48+
return fmt.Errorf("read extending-the-canvas.md: %w", err)
49+
}
50+
frontendAtlasDir := filepath.Join("..", "..", "frontend", "src", "atlas")
51+
table, err := docsgen.GenerateNounFieldTable(frontendAtlasDir)
52+
if err != nil {
53+
return fmt.Errorf("generate noun field table: %w", err)
54+
}
55+
updated, err := docsgen.ReplaceMarkedRegion(string(existing), docsgen.NounFieldTableBeginMarker, docsgen.NounFieldTableEndMarker, table)
56+
if err != nil {
57+
return fmt.Errorf("splice noun field table into extending-the-canvas.md: %w", err)
58+
}
59+
if err := os.WriteFile(pagePath, []byte(updated), 0o600); err != nil { // #nosec G703 -- same fixed path as the read above
60+
return fmt.Errorf("write extending-the-canvas.md: %w", err)
61+
}
62+
return nil
63+
}

0 commit comments

Comments
 (0)