@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.
- package/LICENSE +96 -0
- package/README.md +46 -2
- package/dist/cli.d.ts +15 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +54 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +53 -0
- package/dist/index.js.map +1 -0
- package/package.json +50 -3
- package/skills/graview-agent-seat/SKILL.md +159 -0
- package/skills/graview-brand/SKILL.md +137 -0
- package/skills/graview-desk/SKILL.md +84 -0
- package/skills/graview-embed/SKILL.md +86 -0
- package/skills/graview-invariant/SKILL.md +112 -0
- package/skills/graview-lens/SKILL.md +187 -0
- package/skills/graview-new-app/SKILL.md +195 -0
- package/skills/graview-node-kind/SKILL.md +159 -0
- package/skills/graview-pages/SKILL.md +206 -0
- package/skills/graview-permissions/SKILL.md +145 -0
- package/skills/graview-port-app/SKILL.md +95 -0
- package/skills/graview-seed/SKILL.md +103 -0
- package/skills/graview-ship/SKILL.md +154 -0
- package/skills/graview-studio/SKILL.md +85 -0
|
@@ -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.
|