@graview/skills 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,137 @@
1
+ ---
2
+ name: graview-brand
3
+ description: Put an installation's own name, mark, typeface and palette on a Graview app without forking a package, and let graview check measure the contrast rather than trusting it.
4
+ ---
5
+
6
+ # Brand an installation
7
+
8
+ Every primitive reads a custom property and `themeCss(scheme, brand)` emits
9
+ them, so a brand is a DECLARATION rather than a fork. The part worth getting
10
+ right is that a custom palette can be wrong in ways nobody notices until
11
+ somebody with a bright office files a bug — so the framework measures it.
12
+
13
+ ## Do this
14
+
15
+ 1. **Declare the brand in your domain layer**, so `graview check` reads it:
16
+
17
+ ```ts
18
+ import { brandFromAccent, DARK, LIGHT, type Brand } from "@graview/core";
19
+
20
+ const derived = brandFromAccent({ accent: "#7a4bd0", base: { dark: DARK, light: LIGHT } });
21
+ if (!derived.ok) {
22
+ throw new Error(`Needs ${derived.missing.join(", ")} — ${derived.why}`);
23
+ }
24
+
25
+ export const brand: Brand = {
26
+ name: "Acme Bids",
27
+ // Inline SVG using currentColor: one file works in both schemes.
28
+ logo: '<svg viewBox="0 0 24 24" ... stroke="currentColor">...</svg>',
29
+ typography: { body: '"Inter", ui-sans-serif, system-ui, sans-serif' },
30
+ schemes: derived.schemes,
31
+ };
32
+ ```
33
+
34
+ 2. **Expect the refusal path to be real.** No single colour can be accent TEXT
35
+ in both schemes — 4.5:1 on white needs a lightness under about 0.18 and
36
+ 4.5:1 on a dark panel needs one over about 0.24, and those do not overlap.
37
+ So `brandFromAccent` keeps the hue and saturation, which are the brand's,
38
+ and moves the lightness the smallest distance that clears AA. When that
39
+ distance is far enough that the colour has stopped being theirs, it refuses
40
+ and names what to supply. **Do not catch that and carry on with a guess** —
41
+ shipping a colour they did not choose under their own name is worse than
42
+ asking.
43
+
44
+ 3. **Hand it to `themeCss` and to the provider.**
45
+ `sheet.replaceSync(themeCss(scheme, brand))` for the tokens;
46
+ `<GraviewProvider brand={brand}>` so `Wordmark` and anything else that asks
47
+ can read the name and the mark.
48
+
49
+ 4. **Declare it on the app too:** `defineApp({ ..., brand })`.
50
+
51
+ 5. **Colour the kinds themselves.** Every surface that colours by kind —
52
+ chips, districts, calendar spans, rosters — reads `brand.accents`, a hue
53
+ in degrees per kind, before falling back to the stable hash:
54
+
55
+ ```ts
56
+ accents: { gardener: 28, plot: 42, planting: 122, rule: 210 },
57
+ ```
58
+
59
+ `graview check` refuses an accent naming a kind nobody declared (a typo
60
+ would otherwise silently hash) and a value that is not a number.
61
+
62
+ 6. **Dress the lines: the kit.** Everything the scene draws that is not a
63
+ view is declared on `brand.kit` — any part, the rest as shipped:
64
+
65
+ ```ts
66
+ kit: {
67
+ connectors: {
68
+ all: { route: "orthogonal" }, // curve | straight | orthogonal
69
+ byEdge: { "tended-by": { colour: "#1d3f8a", pattern: "dashed" }, "grows-in": { visible: false } },
70
+ },
71
+ captions: { visible: true }, grid: { visible: false }, lattice: { size: 46 },
72
+ tags: { visible: true }, emphasis: { dim: 0.34 }, marks: { flag: "⚠" },
73
+ },
74
+ ```
75
+
76
+ A route or a pattern is a named strategy, one case in one file
77
+ (`@graview/react` `routes.ts`, `@graview/render` `connectors.ts`), so
78
+ the next one is one more case. `graview check` holds an explicit line
79
+ colour to 3:1 against both grounds in both schemes
80
+ (`kit-contrast-below-aa`); a kind kept quiet is still on the inspector.
81
+ An embed's `handle.setBrand({ ...brand, kit })` re-dresses it live.
82
+
83
+ ## Styling by conversation
84
+
85
+ This skill is built to be DRIVEN IN NATURAL LANGUAGE — "warmer", "more
86
+ editorial", "our green is #1B4332", "make people amber and money green" —
87
+ because the whole look is one serialisable declaration:
88
+
89
+ - **palette** → change `accent` (or supply explicit scheme tokens) and let
90
+ `brandFromAccent` move lightness the minimum distance that clears AA;
91
+ - **lines** → `kit.connectors` ("right-angled lines", "hide the grows-in
92
+ lines", "make tended-by dashed and navy") and the ground's `kit.grid`;
93
+ - **feel** → `shape.radius` (square = formal) and `shape.density` (tight =
94
+ dense) — one number each;
95
+ - **voice** → `typography.body/display/mono` with real fallback stacks;
96
+ - **kind colours** → `accents` hues per kind;
97
+ - **per-kind layout** → register a view over the registry cell, the same
98
+ authoring move as everything else (see graview-node-kind).
99
+
100
+ The loop for each request: edit ONLY the declaration file, run `pnpm check`
101
+ (the framework measures contrast rather than trusting either of you), then
102
+ `pnpm survey` and show the before/after screenshots from `docs/survey/`
103
+ side by side. Never edit a component to achieve a look a token can carry —
104
+ if a look genuinely needs one, that is a missing token to raise, not a fork
105
+ to make.
106
+
107
+ ## Worked example
108
+
109
+ - `apps/todo/src/domain/brand.ts` — "Things" from one accent, one mark and one
110
+ typeface, with the refusal path left live
111
+
112
+ ## Then find out whether it worked
113
+
114
+ ```sh
115
+ pnpm build && npx graview check ./dist/domain/app.js
116
+ ```
117
+
118
+ `theme-contrast-below-aa` names the exact token pair, the scheme and where it
119
+ is drawn — "a field name", "text on a filled accent" — because "your theme has
120
+ a contrast problem" is not something anyone can act on.
121
+ `theme-token-unreadable` reports a value the checker could not parse rather
122
+ than passing it silently, which is the failure mode a contrast check exists to
123
+ prevent.
124
+
125
+ **And look at it, in both schemes.** The checker measures contrast and nothing
126
+ else. Run the app with `?theme=light` and `?theme=dark`, and run axe against
127
+ both — the framework's harnesses do this and report zero violations, which is
128
+ the bar a branded installation should also clear.
129
+
130
+ ## What the check cannot see
131
+
132
+ - Whether it looks like the brand. Contrast is measurable; taste is not.
133
+ - Whether the logo reads at 18 pixels. Most do not.
134
+ - Whether the typeface is loaded. A declared face with no `@font-face` and no
135
+ web font silently falls back, and the fallback is usually fine — which is why
136
+ nobody notices for months.
137
+ - Anything about a value it cannot parse. It says so; believe it.
@@ -0,0 +1,84 @@
1
+ ---
2
+ name: graview-desk
3
+ description: Put a model INSIDE a Graview product — a surface that takes photographs or words, reaches the model through a declared door, and turns the answer into a plan somebody reviews before it touches the graph.
4
+ ---
5
+
6
+ # A desk a model works at
7
+
8
+ `graview-agent-seat` is the model BESIDE the product: a seat in the bar that
9
+ answers questions and proposes a change. A desk is the model INSIDE it — the
10
+ surface a person brings something to. Photographs of a property, a paragraph
11
+ about a season, a spreadsheet somebody was sent. It is often the feature the
12
+ product is for, and it is four seams, all of which the framework ships.
13
+
14
+ ## The four seams
15
+
16
+ 1. **Intake** — getting the something in. `<Intake onPhotos={...} />` hands
17
+ back data URLs.
18
+ 2. **A door** — reaching the model. Declared, not hard-wired:
19
+ ```ts
20
+ intelligence: [{
21
+ name: "surveyor",
22
+ kind: "llm",
23
+ reach: ["paste", "local"],
24
+ bridge: "/__graview/local",
25
+ keyStorage: "in this browser only, never in the repository",
26
+ may: ["stake-out", "place-feature"],
27
+ }]
28
+ ```
29
+ `<Door provider="surveyor" prompt={...} photos={...} onProposals={...} />`
30
+ draws exactly the doors declared. **Paste is the floor** — a prompt out, an
31
+ answer back, no key and no network — and a product reachable that way is a
32
+ product anybody can run. `local` is the machine under the dev server
33
+ (`localIntelligence()` from `@graview/ship/dev`), offered only when
34
+ something is actually answering there.
35
+ 3. **A plan** — what came back, as an object rather than a list: ordered by
36
+ the chain, judged against the policy and the provider's `may`, reviewable.
37
+ `planFrom(store, proposals, { app, principal })`.
38
+ 4. **A review** — `<PlanReview plan={plan} onApplied={...} />`. Applied as one
39
+ turn, so `store.undo(batch)` takes the whole thing back.
40
+
41
+ ## Do this
42
+
43
+ 1. **Write the prompt from the graph.** What kinds exist, what acts exist and
44
+ what they take — `generateLlmsTxt(app)` writes that — plus what is already
45
+ there. A model that cannot see the graph invents a second Back Lawn.
46
+
47
+ 2. **Ask for proposals, not prose.** One JSON object with a `proposals` array
48
+ of `{ mutation, args, as?, why? }`. `firstJsonObject` finds it inside
49
+ whatever the model wrapped it in; `validateProposals` drops what this app
50
+ does not have and what the provider may not do.
51
+
52
+ 3. **Declare the allowlist and mean it.** `may` is enforced by the store now,
53
+ on every path including your own code. A surveyor that may describe the
54
+ ground and may not touch the record is a promise the runtime keeps.
55
+
56
+ 4. **Never a second path to the store.** The desk's answer becomes ordinary
57
+ mutations, applied by the same store, judged by the same policy, in the
58
+ same log, with the same undo. "Add AI" is an entry in the declaration,
59
+ never a way around it.
60
+
61
+ 5. **Say what it did in the product's words.** `why` on each proposal is the
62
+ sentence the review shows; a plan of eleven calls with no reasons is a
63
+ thing nobody can approve.
64
+
65
+ ## Then find out whether it worked
66
+
67
+ ```sh
68
+ pnpm build:domain && npx graview check ./dist/domain/app.js
69
+ ```
70
+
71
+ The checker verifies every act in `may` exists, warns on a `local` reach with
72
+ no bridge and a `key` reach with no storage story, and `graview describe`
73
+ lists the doors. Then drive it: a test that feeds a canned answer through
74
+ `planFrom` and asserts the plan's order, its refusals and what the graph
75
+ holds afterwards costs ten lines and catches the whole class.
76
+
77
+ ## What the check cannot see
78
+
79
+ - Whether the model can actually see what you sent. A photograph a model was
80
+ never handed produces a confident description of nothing.
81
+ - Whether a refusal is legible. The store refuses in a sentence; if the desk
82
+ swallows it, a person meets a button that does nothing.
83
+ - Whether the answer is TRUE. Everything here makes a proposal safe, ordered
84
+ and reversible. None of it makes it right, which is why a person reads it.
@@ -0,0 +1,86 @@
1
+ ---
2
+ name: graview-embed
3
+ description: Put a Graview app on somebody else's page — a picture in an article, a chapter in the docs, a live demo in a landing page — with its own theme scoped to one element, and nothing on the host touched.
4
+ ---
5
+
6
+ # The embed: an app on somebody else's page
7
+
8
+ `@graview/embed` mounts an app into any element on any page. It brings its
9
+ own theme scoped to that element, the brand's fonts, and a strip with the
10
+ faces and the places — no `Shell`, no router, nothing of the host's styled
11
+ or listened to. It is the same declaration, the same store and the same acts
12
+ as the app itself; only the frame is the host's.
13
+
14
+ ```ts
15
+ import { mount } from "@graview/embed";
16
+
17
+ const handle = mount(el, {
18
+ app, store, // or `seed`, and one is made
19
+ views, // the app's own registry, in its own schema
20
+ stop: "#focus=agg:plot", // the fragment the app would put in its address bar
21
+ scheme: "auto", // the host's data-theme, else the system's
22
+ label: "Chapter 13", // names every landmark inside
23
+ });
24
+ handle.setFace("pages"); handle.setStop("#focus=plot-2"); handle.unmount();
25
+ ```
26
+
27
+ `graview create` writes this as `src/embed.tsx` and an `embed.html` host, so
28
+ the project's own `pnpm typecheck` covers the embed surface from day one.
29
+
30
+ ## The moves
31
+
32
+ 1. **Say where it opens with the stop, not with code.** `stop` is exactly
33
+ the fragment the app writes — `#overview=1`, `#focus=agg:plot`,
34
+ `#view=the-week` — so a link you copied from the app is an embed's
35
+ starting point. `face` follows the stop unless you name one.
36
+ 2. **Pick the face for the page.** `"scene"` and `"graview"` are the app;
37
+ `"pages"` is the routed face, opened at `path`; `"picture"` is ONE named
38
+ lens alone (`stop: "#view=the-week"`), no bar and no rail — a page
39
+ about a lens shows the lens. `toggle: false` drops the strip too.
40
+ 3. **Name it.** Two embeds on one page carry the same landmarks — the
41
+ relation key, the inspector, the pages' navigation — and a landmark must
42
+ be unique by role and name. `label` names every one of them after the
43
+ embed; leave it off and two embeds are one confusing region twice.
44
+ 4. **Let the host decide the look.** `scheme: "auto"` follows the host's
45
+ `data-theme` stamp; `setScheme` follows a host toggle; `fonts: false`
46
+ when the host already loads them; `brand` / `setBrand` to dress it.
47
+ 5. **Make the policy felt, if the page is about it.** `seats` lists the
48
+ principals a reader may take — each a label and a `Principal` — on the
49
+ strip; the acts, the pages and the strip narrow the moment one sits
50
+ down, and `setSeat` does it from the host. See `graview-permissions`.
51
+ 6. **Many on one page: mount when near.** `mountWhenNear(elements,
52
+ mountOne)` mounts each as the reader scrolls toward it, so a page of
53
+ sixteen chapters costs one at a time. Share a `store` between embeds
54
+ only when they are meant to be one app seen twice.
55
+ 7. **Presence is opt-in.** An embed broadcasts nothing and draws nobody
56
+ unless it is handed a `presence` channel: putting a graph on a page does
57
+ not tell its readers about each other.
58
+
59
+ ## Worked examples
60
+
61
+ - `apps/seedbed/src/site-embed.ts` — the docs site's chapters, many to a
62
+ page, mounted as the reader nears them, the rota's seats on the strip
63
+ - `apps/rota/src/embed.ts` — two embeds of one app on one host page, each
64
+ named for what it shows
65
+ - `packages/core/src/scaffold/ui.ts` — what `graview create` writes
66
+
67
+ ## Then find out whether it worked
68
+
69
+ ```sh
70
+ pnpm build && npx graview check ./dist/domain/app.js # the declaration is still whole
71
+ pnpm typecheck # the embed takes the app's own views, no casts
72
+ ```
73
+
74
+ Then open the host page at a phone's width and with two embeds on it: each
75
+ names its own landmarks, neither pushes the page sideways, and pressing a
76
+ place in one moves only that one. `packages/embed/tests/unit/embed.test.ts`
77
+ holds the contracts; copy its shape for a host of your own.
78
+
79
+ ## What the check cannot see
80
+
81
+ - Whether the host's CSS reaches in. The embed scopes its own theme; a host
82
+ rule like `button { … }` on the whole page still applies, and only looking
83
+ at the page finds it.
84
+ - Whether the stop still lands. A stop names ids and places; rename a place
85
+ or seed different ids and an embed opens somewhere it resolves to rather
86
+ than where the article says it does.
@@ -0,0 +1,112 @@
1
+ ---
2
+ name: graview-invariant
3
+ description: Write a Graview invariant that judges the graph and NAMES the mutations that would repair it, then verify it with graview check and by making it actually fire.
4
+ ---
5
+
6
+ # Write a rule that names its own repairs
7
+
8
+ An invariant is not a validator. A validator says no; an invariant says what is
9
+ wrong, which nodes are implicated, and **which mutations would fix it** — and
10
+ that last part is the seam the whole affordance system rides on. A repair
11
+ naming a mutation is why selecting an out-of-balance set of duties surfaces
12
+ "rebalance" without anyone writing a rule to produce that suggestion.
13
+
14
+ ## Do this
15
+
16
+ 1. **Scope it to a kind, or to the graph.** `scope: { kind: "session" }` runs it
17
+ once per session; `scope: "graph"` runs it once. Use `match` to narrow
18
+ further — the worked examples scope to a `rule` kind and match on the rule
19
+ node's own `spec.type`, so the rules a domain enforces are *data* rather
20
+ than code, and a coach can be told which rule fired by name.
21
+
22
+ 2. **Declare `repairs` statically.** The array of mutation names this rule may
23
+ propose. `graview check` verifies every one exists, so a repair pointing at
24
+ a renamed mutation is a build failure rather than a surprise the first time
25
+ the rule fires.
26
+
27
+ 3. **Return violations that carry their implications.**
28
+
29
+ ```ts
30
+ export const sessionFits = defineInvariant("session-fits", {
31
+ scope: { kind: "rule", match: (node) => node.spec.type === "session-fits" },
32
+ repairs: ["cut-drill"],
33
+ evaluate({ graph, subject }) {
34
+ // ... find the session and its drills ...
35
+ if (planned <= budget) return [];
36
+ return [{
37
+ invariant: "session-fits",
38
+ subjectId: subject.id,
39
+ label: subject.label,
40
+ // In the app's own words. This is shown in the strip verbatim.
41
+ message: `${session.label} plans ${planned} minutes into ${budget} — ${planned - budget} over`,
42
+ // Every node this is ABOUT. Views light these wherever they are
43
+ // drawn, and selecting the rule lights them — which is the only
44
+ // reason selecting a rule changes the picture at all.
45
+ nodeIds: [session.id, ...drills.map((d) => d.id)],
46
+ repairs: drills.map((drill) => ({
47
+ mutation: "cut-drill",
48
+ args: { sessionId: session.id, drillId: drill.id },
49
+ label: `Cut "${drill.label}" — ${drill.minutes} minutes back`,
50
+ })),
51
+ }];
52
+ },
53
+ });
54
+ ```
55
+
56
+ 4. **Keep `evaluate` pure.** Same graph and context in, same violations out. No
57
+ clock, no random source, no DOM. That purity is what lets the whole tier run
58
+ headlessly, and what lets `preview` say what a change *would* break before
59
+ it happens.
60
+
61
+ 5. **Fill in `missing` for a repair that cannot be complete.** A repair still
62
+ needing an argument lists it, and the framework offers real candidates for
63
+ it — the interface never wires up a picker per mutation.
64
+
65
+ ## The horizon
66
+
67
+ A scoped invariant judges only CURRENT subjects — nodes retired under their
68
+ kind's declared `lifecycle` are skipped, because a rule about last term's
69
+ agreement is noise, not a violation. An invariant that genuinely audits
70
+ history says so with `judgesPast: true`.
71
+
72
+ So a rule about the past and the present — "nothing closed may still depend
73
+ on something open" — is written from the side that is still current: the
74
+ subject is the OPEN thing, the violation names what closed against it, and
75
+ the repair acts on the subject, because that is the node a person can still
76
+ act on. Judging the closed one would put the violation behind the horizon
77
+ and the repair on a thing that has already left the picture.
78
+
79
+ ## Then find out whether it worked
80
+
81
+ ```sh
82
+ pnpm build && npx graview check ./dist/domain/app.js
83
+ ```
84
+
85
+ The checker catches `repair-unknown-mutation`, `invariant-scope-undeclared` and
86
+ `required-invariant-unregistered`. Report the real output, warnings included.
87
+
88
+ **And make it fire.** A rule that never returns a violation passes every check
89
+ and does nothing. Write a test that builds a graph which breaks it:
90
+
91
+ ```ts
92
+ it("catches an over-long session and offers a way back", () => {
93
+ const store = createStore({ snapshot: overbooked });
94
+ const violation = store.violations().find((v) => v.invariant === "session-fits")!;
95
+ expect(violation.nodeIds).toContain("s-tue");
96
+ // And the repair actually resolves it, which is the claim that matters.
97
+ const repair = violation.repairs[0]!;
98
+ store.apply({ name: repair.mutation, args: repair.args! });
99
+ expect(store.violations().some((v) => v.invariant === "session-fits")).toBe(false);
100
+ });
101
+ ```
102
+
103
+ ## What the check cannot see
104
+
105
+ Say so rather than implying otherwise:
106
+
107
+ - Whether the message reads as something a person would say.
108
+ - Whether `nodeIds` names everything the rule is genuinely about. Too few and
109
+ the picture does not change when you select the rule; too many and it lights
110
+ the whole screen.
111
+ - Whether the repair is one a person would actually want. A technically
112
+ resolving repair nobody would choose is worse than no repair at all.
@@ -0,0 +1,187 @@
1
+ ---
2
+ name: graview-lens
3
+ description: Build a Graview lens that binds ROLES rather than field names, so a second domain can reuse it unchanged — and prove the reuse rather than asserting it.
4
+ ---
5
+
6
+ # Build a lens
7
+
8
+ A lens is a picture that knows nothing about your domain. The timeline does not
9
+ know what a duty is; it knows there is a `start`, an `end` and a column. The
10
+ coverage matrix does not know what a requirement is; it knows there are rows,
11
+ columns and an edge that fills a cell. That indirection is the entire point: a
12
+ lens written for one app is used unchanged by another.
13
+
14
+ The framework ships five, each built from the public primitives — which makes
15
+ them the worked example of the authoring API rather than privileged insiders.
16
+ Read one before writing your own:
17
+
18
+ - `packages/primitives/src/lens/timeline.tsx` — intervals in columns
19
+ - `packages/primitives/src/lens/calendar.tsx` — the same intervals, by date
20
+ - `packages/primitives/src/lens/coverage.tsx` — a bipartite mapping
21
+ - `packages/primitives/src/lens/board.tsx` — position given by the domain
22
+ (discs for codes of three characters, tokens for words; `arrange: "shelf"`
23
+ when the x and y are categories rather than coordinates)
24
+ - `packages/primitives/src/lens/plan.tsx` — an outline, with points inside it
25
+
26
+ (`reach.tsx` sits beside them and is not one: no factory, no roles to
27
+ rebind. A view that ships is still a view.)
28
+
29
+ The plan is the one to read if yours draws anything, because it is the one
30
+ that learned what drawing costs. A name is fitted to the shape it names —
31
+ `fitLabel` and `spanAt` from `@graview/layout`, which give the width of a
32
+ shape AT A HEIGHT rather than its bounding box, because the shapes that
33
+ overflow are the long thin ones. Names are painted after everything else,
34
+ because a marker dot took the first letter off two of them. Whatever will
35
+ not fit goes in a key underneath. And `useDrawnSize` answers the question a
36
+ scalable drawing hides: at THIS size, can a person read it, or press it? A
37
+ one-pixel target with a keyboard stop and an ARIA label is not an accessible
38
+ control; it is an inaccessible one that has been described.
39
+
40
+ Start it with the scaffolder — `graview lens grounds-map --roles regions,markers
41
+ --binds entities` — which writes all eight rules below already in place, and
42
+ the reuse test beside it, red on purpose.
43
+
44
+ ## Do this
45
+
46
+ 1. **Name the roles, not the fields.** A lens declares
47
+ `requiredRoles: ["start", "end"]`; an app says which of ITS fields fill them.
48
+ If your lens names `dutyId` anywhere, it is not a lens.
49
+
50
+ 2. **Choose which shape of binding you need.** There are two, and assuming
51
+ there was one is how the first version of this got it wrong:
52
+ - **fields** — a kind's own field names onto roles
53
+ (`{ session: { start: "start", end: "end" } }`). The timeline.
54
+ - **entities** — roles onto whole kinds and edges
55
+ (`{ rows: { kind: "skill" }, link: { edge: "develops" } }`). The matrix.
56
+ A relationship that IS a node — a concern addressed by a practice,
57
+ applied by a routine, covering a ground — binds
58
+ `link: { path: ["covers", "applies", "addresses"] }`, column end first;
59
+ each cell then carries the nodes it walked through, which is what you
60
+ actually want to press.
61
+
62
+ 3. **Fail loudly on a bad binding.** Throw a named error saying which role and
63
+ what was missing. A lens that renders empty when misbound costs an hour.
64
+ Loud means THE PANEL SAYS SO, not the application is gone: every view host
65
+ sits behind an error boundary, so a throw draws the error's own message in
66
+ the view's place — named with the kind and the view — and leaves the bar,
67
+ the districts and every other view standing. Give the error a `hint`
68
+ property and the panel prints it under the message, which is where a
69
+ sentence about what to bind instead belongs.
70
+
71
+ 4. **Mark every real thing as a target.** Anything standing for a node gets
72
+ `data-graview-pick={id}`. That one attribute is the whole contract: the host
73
+ routes a click on it to that node, gives it a tab stop and a role, and a
74
+ single click selects it in place while a double click travels. A span you
75
+ cannot click is the bug this prevents.
76
+
77
+ **Drawing in SVG? The framework sets the role; you set the `aria-label`.**
78
+ A `<g>`, `<polygon>`, `<circle>`, `<rect>`, `<path>`, `<ellipse>`,
79
+ `<polyline>`, `<line>` or `<use>` carrying the mark becomes a button like
80
+ any `<span>` — but a shape has no text inside it to be named by, so
81
+ without a label it is a stop that announces nothing, which is worse for a
82
+ keyboard than not being reachable at all. Name it after the thing it
83
+ stands for.
84
+
85
+ 5. **Read `implicated` and `flagged`.** Empty means "no emphasis", NOT "nothing
86
+ is related". Expose what you decide as `data-graview-emphasis` so it can be
87
+ checked — a claim about a picture that exists only as a colour cannot be
88
+ checked by anything, not a test and not a person reading the tree.
89
+
90
+ 6. **Render at three fidelities.** `glyph` is a chip; `summary` is denser
91
+ content, not the same content scaled down; `full` is the picture.
92
+
93
+ 7. **`nodes` is THE GROUP'S MEMBERS, and nothing else.** A lens mounted over
94
+ a kind's `many` cell receives that kind's members in `ViewProps.nodes` —
95
+ not the graph. Every interesting lens draws more than one kind (a board
96
+ has slots and occupants, a matrix has rows and columns, a map has regions
97
+ and markers), and every other kind comes from `store.graph`, narrowed back
98
+ to `nodes` for the group's own kind so the scene's horizon still applies.
99
+ Built from `nodes` alone, a map drew every piece of ground with nothing
100
+ standing in it: no error, no empty state, a complete, tidy, wrong picture,
101
+ which is the worst failure a lens has. The framework's own board lens had
102
+ this exact bug.
103
+
104
+ **`budget` is the most to draw.** A drive-in thumbnail hands a lens its 12
105
+ most relevant members with `budget` and `total`: hold what you read from
106
+ the store to it too, and say "+N more" (`withMore` does). A real catalogue
107
+ is thousands; a lens that draws them all at 6% stalls the city.
108
+
109
+ 8. **Take an arrangement, and say what you have no place for.** Every
110
+ picture over a kind can be sorted, filtered and grouped from the
111
+ declaration alone — `arrangeable(schema, kind)` offers the fields by
112
+ type, the edges by far end, the lifecycle and the standing — and the
113
+ choice travels in the stop as `in.sort`, `in.filter`, `in.group` and
114
+ `in.q`, beside the calendar's own `in.at`. In your lens's component:
115
+
116
+ ```tsx
117
+ const { nodes, arranged, bar } = useArranging(props, {
118
+ lensAllows: { group: false }, // a board has nowhere to group
119
+ allow: options.arranging, // the app's say: false, or per part
120
+ arrangedBy: options.arrangedBy, // how the picture opens
121
+ });
122
+ return <>{bar}<Picture nodes={nodes} /></>;
123
+ ```
124
+
125
+ `bar` is the framework's own row (`ArrangeBar`), drawn at full fidelity
126
+ unless declined; `nodes` are the members as arranged; `arranged.groups`
127
+ are there when the picture has a place for headings, as the calendar's
128
+ agenda does. Declare `arrangedBy` on the app's `lenses` entry too, in the
129
+ grammar (`{ group: "held-at" }`), so `graview check` holds it to the
130
+ bound kind and `graview describe` can say it. A person who arranged the
131
+ list can ask your picture the same thing in the same words.
132
+
133
+ 9. **Give it a name when you register it.** A lens mounted over a group is
134
+ registered on that kind's `many` cells, and the fourth argument names it:
135
+ `registry.register("gardener", { cardinality: "many", fidelity: "full" },
136
+ TendingView, { title: "Who tends what" })`. A titled group view is a
137
+ PLACE — the bar and an embed's strip list it by name, press it from
138
+ anywhere, and show it pressed while you are there. Without the title the
139
+ lens is reachable only by focusing the group, and once someone clicks
140
+ into a member nothing on screen says it exists.
141
+
142
+ ## Roles live in two places, and they do different jobs
143
+
144
+ `defineNode("shift", { fieldRoles: { start: "from" } })` is what everything
145
+ that is NOT a lens reads: the graph's own responder answering "when is it",
146
+ the generated docs. A lens reads `bindings` and only `bindings`. Neither
147
+ overrides the other, because neither is looking at the other — so bind the
148
+ lens, and declare `fieldRoles` because the rest of the app wants them too.
149
+
150
+ `graview check` NOTES it when the two disagree about one role. Often that is
151
+ a mistake; sometimes it is right, because a role name belongs to a lens and
152
+ `fieldRoles` has one namespace for all of them: a rota means the hour a shift
153
+ starts by `start`, and its calendar means the day. Look once, then decide.
154
+
155
+ ## Then find out whether it worked
156
+
157
+ ```sh
158
+ pnpm build && npx graview check ./dist/domain/app.js
159
+ ```
160
+
161
+ The checker reads `lenses` in `defineApp` and reports `lens-role-unbound`,
162
+ `lens-binding-missing-field` and `lens-binding-undeclared-kind`. Report the output.
163
+
164
+ **And prove the reuse.** This is the claim a lens exists to support, and the one
165
+ the checker cannot make for you — it NOTES every lens in `lenses` that the
166
+ framework did not ship (`lens-authored-here`) so the question gets asked out
167
+ loud, and the answer is yours. Write a test that builds it against a domain it
168
+ was not designed for, then point at it with `provenBy: "tests/lens-reuse.test.ts"`
169
+ and the note stands down:
170
+
171
+ ```ts
172
+ it("works in a domain nothing here is about", () => {
173
+ // The board lens, pointed at a seating plan.
174
+ const built = buildBoard(nodes, edges, { slots: { kind: "seat" } }, schema);
175
+ expect(built.empty).toEqual(["seat-4"]);
176
+ });
177
+ ```
178
+
179
+ If you cannot write that test, say so plainly: you have written a view rather
180
+ than a lens, and that is a legitimate thing to have written.
181
+
182
+ ## What the check cannot see
183
+
184
+ - Whether the picture is legible. Run it and look, in both schemes.
185
+ - Whether it survives being drawn small. The Graview renders the focused view
186
+ at natural size and scales it; `pnpm shrunk` measures whether it clips.
187
+ - Whether the roles you chose generalise, or merely rename your own fields.