@protoyard/blueprint 0.0.0-stage → 1.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 +21 -0
- package/README.md +1149 -2
- package/dist/aggregate.d.ts +83 -0
- package/dist/aggregate.js +268 -0
- package/dist/app-model.d.ts +76 -0
- package/dist/app-model.js +287 -0
- package/dist/archetypes/index.d.ts +1 -0
- package/dist/archetypes/index.js +1 -0
- package/dist/archetypes/schema.d.ts +126 -0
- package/dist/archetypes/schema.js +77 -0
- package/dist/audit.d.ts +52 -0
- package/dist/audit.js +149 -0
- package/dist/authoring/deck-lint.d.ts +32 -0
- package/dist/authoring/deck-lint.js +567 -0
- package/dist/authoring/index.d.ts +1 -0
- package/dist/authoring/index.js +1 -0
- package/dist/authoring/select-deck-layouts.d.ts +50 -0
- package/dist/authoring/select-deck-layouts.js +213 -0
- package/dist/authoring-apis.d.ts +13 -0
- package/dist/authoring-apis.js +13 -0
- package/dist/authoring-validation.d.ts +65 -0
- package/dist/authoring-validation.js +267 -0
- package/dist/avatar.d.ts +25 -0
- package/dist/avatar.js +30 -0
- package/dist/brand.d.ts +70 -0
- package/dist/brand.js +294 -0
- package/dist/brief/archetypes.d.ts +28 -0
- package/dist/brief/archetypes.js +798 -0
- package/dist/brief/content-synthesize.d.ts +31 -0
- package/dist/brief/content-synthesize.js +210 -0
- package/dist/brief/deck-outlines.d.ts +96 -0
- package/dist/brief/deck-outlines.js +561 -0
- package/dist/brief/draft-bodies.d.ts +29 -0
- package/dist/brief/draft-bodies.js +224 -0
- package/dist/brief/fields.d.ts +19 -0
- package/dist/brief/fields.js +229 -0
- package/dist/brief/index.d.ts +8 -0
- package/dist/brief/index.js +8 -0
- package/dist/brief/kind-detection.d.ts +69 -0
- package/dist/brief/kind-detection.js +353 -0
- package/dist/brief/limits.d.ts +117 -0
- package/dist/brief/limits.js +217 -0
- package/dist/brief/page-doc-outlines.d.ts +80 -0
- package/dist/brief/page-doc-outlines.js +485 -0
- package/dist/brief/synthesize.d.ts +59 -0
- package/dist/brief/synthesize.js +511 -0
- package/dist/brief/text.d.ts +13 -0
- package/dist/brief/text.js +59 -0
- package/dist/canvas-screen.d.ts +31 -0
- package/dist/canvas-screen.js +33 -0
- package/dist/canvas-seed.d.ts +58 -0
- package/dist/canvas-seed.js +82 -0
- package/dist/computed/evaluate.d.ts +11 -0
- package/dist/computed/evaluate.js +101 -0
- package/dist/computed/index.d.ts +4 -0
- package/dist/computed/index.js +3 -0
- package/dist/computed/public.d.ts +14 -0
- package/dist/computed/public.js +19 -0
- package/dist/computed/schema.d.ts +10 -0
- package/dist/computed/schema.js +140 -0
- package/dist/computed/types.d.ts +70 -0
- package/dist/computed/types.js +1 -0
- package/dist/computed/validate.d.ts +6 -0
- package/dist/computed/validate.js +146 -0
- package/dist/csv-guard.d.ts +15 -0
- package/dist/csv-guard.js +29 -0
- package/dist/csv.d.ts +85 -0
- package/dist/csv.js +288 -0
- package/dist/files.d.ts +35 -0
- package/dist/files.js +246 -0
- package/dist/form-schema.d.ts +124 -0
- package/dist/form-schema.js +300 -0
- package/dist/generator-version.d.ts +11 -0
- package/dist/generator-version.js +51 -0
- package/dist/generator.d.ts +34 -0
- package/dist/generator.js +445 -0
- package/dist/identity.d.ts +34 -0
- package/dist/identity.js +91 -0
- package/dist/import/csv-shape.d.ts +40 -0
- package/dist/import/csv-shape.js +265 -0
- package/dist/import/json-shape.d.ts +55 -0
- package/dist/import/json-shape.js +491 -0
- package/dist/import/limits.d.ts +13 -0
- package/dist/import/limits.js +14 -0
- package/dist/import/sqlite-schema.d.ts +36 -0
- package/dist/import/sqlite-schema.js +729 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.js +58 -0
- package/dist/kinds/anchors.d.ts +18 -0
- package/dist/kinds/anchors.js +46 -0
- package/dist/kinds/bodies.d.ts +6 -0
- package/dist/kinds/bodies.js +32 -0
- package/dist/kinds/brand-runtime.d.ts +19 -0
- package/dist/kinds/brand-runtime.js +35 -0
- package/dist/kinds/deck.d.ts +8 -0
- package/dist/kinds/deck.js +34 -0
- package/dist/kinds/doc.d.ts +19 -0
- package/dist/kinds/doc.js +41 -0
- package/dist/kinds/embed/index.d.ts +39 -0
- package/dist/kinds/embed/index.js +91 -0
- package/dist/kinds/envelope.d.ts +159 -0
- package/dist/kinds/envelope.js +78 -0
- package/dist/kinds/index.d.ts +11 -0
- package/dist/kinds/index.js +11 -0
- package/dist/kinds/kind.d.ts +36 -0
- package/dist/kinds/kind.js +55 -0
- package/dist/kinds/limits.d.ts +64 -0
- package/dist/kinds/limits.js +190 -0
- package/dist/kinds/page-doc-runtime.d.ts +37 -0
- package/dist/kinds/page-doc-runtime.js +55 -0
- package/dist/kinds/page.d.ts +16 -0
- package/dist/kinds/page.js +67 -0
- package/dist/kinds/parse.d.ts +18 -0
- package/dist/kinds/parse.js +27 -0
- package/dist/kinds/runtime.d.ts +128 -0
- package/dist/kinds/runtime.js +206 -0
- package/dist/kinds/services.d.ts +49 -0
- package/dist/kinds/services.js +249 -0
- package/dist/kinds/testing.d.ts +9 -0
- package/dist/kinds/testing.js +16 -0
- package/dist/migrations.d.ts +97 -0
- package/dist/migrations.js +351 -0
- package/dist/motion.d.ts +50 -0
- package/dist/motion.js +52 -0
- package/dist/notifications.d.ts +19 -0
- package/dist/notifications.js +177 -0
- package/dist/patch.d.ts +122 -0
- package/dist/patch.js +322 -0
- package/dist/policy.d.ts +23 -0
- package/dist/policy.js +105 -0
- package/dist/runtime-definition.d.ts +39 -0
- package/dist/runtime-definition.js +74 -0
- package/dist/scenarios.d.ts +9 -0
- package/dist/scenarios.js +49 -0
- package/dist/schema/blueprint.schema.json +10191 -0
- package/dist/schema-json.d.ts +6 -0
- package/dist/schema-json.js +36 -0
- package/dist/schema.d.ts +6348 -0
- package/dist/schema.js +1269 -0
- package/dist/search.d.ts +16 -0
- package/dist/search.js +96 -0
- package/dist/services.d.ts +89 -0
- package/dist/services.js +898 -0
- package/dist/share.d.ts +78 -0
- package/dist/share.js +234 -0
- package/dist/sheet-screen.d.ts +49 -0
- package/dist/sheet-screen.js +91 -0
- package/dist/storage.d.ts +43 -0
- package/dist/storage.js +179 -0
- package/dist/stream.d.ts +26 -0
- package/dist/stream.js +173 -0
- package/dist/untrusted.d.ts +63 -0
- package/dist/untrusted.js +502 -0
- package/dist/validation-tasks.d.ts +178 -0
- package/dist/validation-tasks.js +199 -0
- package/dist/validation.d.ts +35 -0
- package/dist/validation.js +312 -0
- package/dist/variants.d.ts +112 -0
- package/dist/variants.js +283 -0
- package/dist/workflows/index.d.ts +3 -0
- package/dist/workflows/index.js +3 -0
- package/dist/workflows/schema.d.ts +50 -0
- package/dist/workflows/schema.js +34 -0
- package/dist/workflows/transition.d.ts +15 -0
- package/dist/workflows/transition.js +25 -0
- package/dist/workflows/validate.d.ts +17 -0
- package/dist/workflows/validate.js +140 -0
- package/package.json +120 -3
package/README.md
CHANGED
|
@@ -1,3 +1,1150 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @protoyard/blueprint
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Data-only blueprints for Protoyard ([ADR-017](../../docs/DECISIONS.md)). A `blueprint.json`
|
|
4
|
+
describes a product (entities, fields, relations, roles, screens) without any executable
|
|
5
|
+
code. This package validates it and derives what a working prototype needs from it.
|
|
6
|
+
|
|
7
|
+
Framework-agnostic: it uses the shared core, scenarios, themes, blocks, pages and deck schemas,
|
|
8
|
+
plus zod, and never imports Svelte (ADR-002). The Svelte CRUD screens live in
|
|
9
|
+
[`@protoyard/crud`](../crud/README.md). How a blueprint becomes a prototype (`protoyard generate`,
|
|
10
|
+
regeneration, drift checks) is in [docs/GENERATOR.md](../../docs/GENERATOR.md).
|
|
11
|
+
|
|
12
|
+
## Modules
|
|
13
|
+
|
|
14
|
+
| Module | Derives | Entry |
|
|
15
|
+
| --- | --- | --- |
|
|
16
|
+
| schema | the zod schema, JSON Schema and cross-reference checks | `.` |
|
|
17
|
+
| computed | a bounded data-only expression grammar, type/reference validation and read-time evaluation | `./computed` (opt-in) |
|
|
18
|
+
| workflows | data-only states, field guards, role/capability references and deterministic transition checks | `./workflows` (opt-in) |
|
|
19
|
+
| archetypes | strict, portable kit archetype declarations and supported entry points | `.` |
|
|
20
|
+
| storage | Dexie store strings and integrity rules | `.` |
|
|
21
|
+
| migrations | appended database versions; `deriveDatabaseVersions` rebuilds them from `history` | `.` |
|
|
22
|
+
| validation | create and update zod input schemas per entity | `.` |
|
|
23
|
+
| policy | role capabilities (`<entity>:<action>`) and `:own` record rules | `.` |
|
|
24
|
+
| notifications | validated create/update/assignment rules, atomic writes and actor-scoped read state | `.` |
|
|
25
|
+
| audit | per-mutation `auditEntries` rows (diff + sensitive redaction) and history reads | `.` |
|
|
26
|
+
| files | size/type/quota checked Blob uploads, metadata listing, on-demand reads and removal | `.` |
|
|
27
|
+
| brand | a WCAG AA theme from `brand` (`resolveBlueprintBrand`) | `.` |
|
|
28
|
+
| services | entity services: `session.assert` → zod → `environment.run` → `db.transaction` | `.` |
|
|
29
|
+
| aggregate | count/sum/avg/min/max with groups and UTC date buckets for dashboards and analytics | `.` |
|
|
30
|
+
| app-model | routes, navigation rows with icon ids, navigation groups and layout (`deriveAppModel`) | `.` |
|
|
31
|
+
| runtime-definition | a complete `definePrototype`-valid definition plus the app model (`definePrototypeFromBlueprint`) | `.` (the generator stays lazy) |
|
|
32
|
+
| kinds | content-kind runtime: `defineKindRuntime` (resolves the brand, then calls `defineKindPrototype`, which generated kind prototypes call with the brand `generate` resolved, so they never load the palette generator) (deck/page/doc definition: block-state and form-response stores, `default` scenario, presenter/audience/viewer personas, no generator) and `createKindServices` (persistent `BlockStateHost`, form responses) | `./kinds` (never in the app graph); `./kinds/parse` (synchronous `parseDeckBlueprint`, `parsePageBlueprint`, `parseDocBlueprint`, for generated kind prototypes: no top-level await, see [PUBLISHING](../../docs/PUBLISHING.md)) |
|
|
33
|
+
| csv | RFC 4180 parse/serialize + row validation | `./csv` (lazy) |
|
|
34
|
+
| csv-shape | bounded CSV header/sample inference into a reviewable entity draft | `./import/csv-shape` (lazy authoring) |
|
|
35
|
+
| json-shape | bounded JSON object/array shape inference into reviewable entity drafts | `./import/json-shape` (lazy authoring) |
|
|
36
|
+
| sqlite-schema | bounded SQLite `CREATE TABLE` DDL inference into reviewable entity drafts | `./import/sqlite-schema` (lazy authoring) |
|
|
37
|
+
| generator | deterministic worlds (`ctx.rng`, `ctx.faker`, `ctx.ids`, `ctx.time`) | `./generator` (lazy) |
|
|
38
|
+
| search | ranked, policy-filtered record search across `searchable` fields (the CRUD search palette) | `./search` |
|
|
39
|
+
| identity | content sha256 and `bp-…` storage namespace (`blueprintIdentity`); every shipped blueprint's identity is frozen in `tests/fixtures/identity-golden.json` | `.` |
|
|
40
|
+
| deck authoring lint | structural diagnostics for slide ids, layout props, outline order, fragments and internal links | `.` |
|
|
41
|
+
|
|
42
|
+
## The schema
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import {
|
|
46
|
+
parseBlueprint,
|
|
47
|
+
safeParseBlueprint,
|
|
48
|
+
validateBlueprintForAuthoring,
|
|
49
|
+
entityTable,
|
|
50
|
+
} from '@protoyard/blueprint';
|
|
51
|
+
|
|
52
|
+
const blueprint = parseBlueprint(JSON.parse(text)); // throws a ZodError with paths
|
|
53
|
+
entityTable(blueprint.entities[0]); // `support-tickets` → `supportTickets` unless `table` is set
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Agent authoring tools can use `validateBlueprintForAuthoring(input)` for stable JSON paths and
|
|
57
|
+
repair hints. Its `valid` flag comes from the complete blueprint parser, including cross-reference
|
|
58
|
+
checks; passing the JSON Schema shape alone does not make a blueprint valid:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
const result = await validateBlueprintForAuthoring(untrustedJson);
|
|
62
|
+
if (!result.valid) {
|
|
63
|
+
for (const diagnostic of result.diagnostics) {
|
|
64
|
+
console.log(diagnostic.jsonPath, diagnostic.message, diagnostic.hint);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Each diagnostic includes a machine-readable `code`, path segments in `path`, an unambiguous
|
|
70
|
+
`jsonPath`, the original validation `message` and a concise next step in `hint`. Block and asset
|
|
71
|
+
failures map to stable codes such as `unknown-block-type`, `invalid-chart-spec`,
|
|
72
|
+
`missing-alt-text`, `unsupported-url-scheme`, `invalid-step-or-fragment-target` and
|
|
73
|
+
`duplicate-block-id`. Use `diagnosticsFromBlockIssues(issues, pathPrefix)` to lift
|
|
74
|
+
`@protoyard/blocks` parse issues into the same shape under a deck/page/doc path. Validation never
|
|
75
|
+
fetches remote assets and never renders untrusted markdown as HTML. `parseBlueprint`
|
|
76
|
+
and `safeParseBlueprint` keep their existing behavior. The helper is in the package entry module
|
|
77
|
+
and is tree-shakeable for consumers that do not import it.
|
|
78
|
+
|
|
79
|
+
The helper validates every kind through the shared kind union (`safeParseAnyBlueprint`) and returns
|
|
80
|
+
the `kind` it used. Any-kind parsing and authoring validation are asynchronous because deck, page and
|
|
81
|
+
doc body schemas load dynamically by kind. An input with no `kind` is an app (the compatibility rule),
|
|
82
|
+
so existing app blueprints and their diagnostics are unchanged; an app never imports those body
|
|
83
|
+
schemas. A deck, page or doc runs through its strict envelope and body schema: its diagnostics point
|
|
84
|
+
into the body, under `$["<kind>"]`, and keys that belong only to apps get a hint naming the keys that
|
|
85
|
+
kind accepts. Problems with `kind` itself are reported on `$["kind"]`: `invalid-kind` (not a string)
|
|
86
|
+
and `unknown-kind` (needs a newer Protoyard) return `kind: null`, and `kind-unavailable` names a known
|
|
87
|
+
kind whose engine this build does not ship yet.
|
|
88
|
+
|
|
89
|
+
### Page and doc bodies
|
|
90
|
+
|
|
91
|
+
This build ships the `deck`, `page` and `doc` bodies. Each body schema is loaded only when its
|
|
92
|
+
content kind is parsed.
|
|
93
|
+
|
|
94
|
+
**`kind: "page"` is a single-scroll page.** It is `{ kind: "page", page: { id, title, sections, nav?, forms? } }`.
|
|
95
|
+
The body is `@protoyard/pages`' `PageDocumentSchema` plus `kinds/page.ts`, which checks:
|
|
96
|
+
|
|
97
|
+
- section ids and anchors are unique, and nav links and `forms[].formId` resolve (from the pages schema);
|
|
98
|
+
- block ids, headings included, are unique across **all** sections, because one page is one DOM and
|
|
99
|
+
one form-response store;
|
|
100
|
+
- a block id is never also a section id or anchor;
|
|
101
|
+
- `forms[].sectionId`, when set, is the section that holds that form, containers included;
|
|
102
|
+
- every `#fragment` link in a block (`cta` actions, `cards` items) points at a section anchor or a
|
|
103
|
+
block id on the page.
|
|
104
|
+
|
|
105
|
+
**`kind: "doc"` is a continuous block flow.** It is `{ kind: "doc", doc: { id, title, blocks, toc?, print? } }`.
|
|
106
|
+
The body is `DocDocumentSchema` (block ids, and so heading ids, are unique) plus `kinds/doc.ts`, which
|
|
107
|
+
checks:
|
|
108
|
+
|
|
109
|
+
- a `toc` lists at least one heading from level 2 to `toc.depth` (3 by default). A heading without
|
|
110
|
+
`level` counts as level 2.
|
|
111
|
+
- every `#fragment` link points at a block id in the doc.
|
|
112
|
+
|
|
113
|
+
Fixtures: `tests/fixtures/kinds/landing.page.json` and `tests/fixtures/kinds/quarterly-report.doc.json`
|
|
114
|
+
(`pnpm vitest run packages/blueprint/tests/kinds/page-doc.test.ts`).
|
|
115
|
+
|
|
116
|
+
Deck bodies validate unique slide ids, outline references, known layout ids, catalog props and
|
|
117
|
+
bounded step markers. The layout catalog validates props after temporarily removing animation
|
|
118
|
+
metadata; the parsed props keep `{ step, fragment }` and `{ step, reveal }` so the motion engine can
|
|
119
|
+
resolve them later.
|
|
120
|
+
|
|
121
|
+
A blueprint has `formatVersion: 1`, a `name`, one or more `entities`, one or more `roles` and
|
|
122
|
+
optional `screens`, `brand` and `archetype`. Every object is strict, so unknown keys fail.
|
|
123
|
+
|
|
124
|
+
## Deck structural lint
|
|
125
|
+
|
|
126
|
+
`lintDeckStructure(input)` checks an untrusted **deck body** (the object under
|
|
127
|
+
`blueprint.deck`, or a bare deck document) without rendering it or mutating the
|
|
128
|
+
input. It returns `{ valid, diagnostics }`. Each diagnostic has a stable
|
|
129
|
+
`code`, `severity: 'error'`, path segments, `jsonPath` and `message`.
|
|
130
|
+
|
|
131
|
+
What it checks (against `@protoyard/decks/schema` and the layout catalog):
|
|
132
|
+
|
|
133
|
+
| Code | Meaning |
|
|
134
|
+
| --- | --- |
|
|
135
|
+
| `missing-slide-id` / `duplicate-slide-id` | Every slide needs a unique slug id |
|
|
136
|
+
| `unknown-layout` | `layout` is not in `@protoyard/decks/layouts` |
|
|
137
|
+
| `invalid-layout-props` | Catalog slide `props` fail that layout's zod schema |
|
|
138
|
+
| `invalid-fragment-order` | Fragment / block `step` values are out of range or out of order |
|
|
139
|
+
| `duplicate-outline-slide` / `invalid-slide-order` | Outline `slideIds` must exist once and follow deck order |
|
|
140
|
+
| `invalid-deck-structure` | Other schema failures |
|
|
141
|
+
|
|
142
|
+
Deep links of the form `#/slide-id` or `#/slide-id/step` must resolve to a
|
|
143
|
+
**visible** (non-hidden) slide. Visual and content quality (notes length,
|
|
144
|
+
layout repetition, WCAG) live in `@protoyard/decks` quality rules, not here.
|
|
145
|
+
|
|
146
|
+
`lintDeckContent(deck)` adds image alt requirements, chart descriptions, CTA
|
|
147
|
+
labels, word density and speaker-note guidance. `lintDeckAssets(deck)` separately
|
|
148
|
+
checks media URL syntax and protocol allowlists on raw deck data, including
|
|
149
|
+
media nested in embedded-app data; it never fetches. All return
|
|
150
|
+
`{ valid, diagnostics }` with stable codes, severity, path segments and
|
|
151
|
+
JSONPath. Layout capacity violations are errors. Three repeated visible layouts
|
|
152
|
+
in a row produce a warning when the catalog offers alternatives. Missing
|
|
153
|
+
accessible content is an error; short chart descriptions, dense copy and missing
|
|
154
|
+
or brief optional presenter notes are warnings. Density is an estimate: rendered
|
|
155
|
+
text must still meet the 24 px body and 18 px label minimums on the 1280×720
|
|
156
|
+
canvas. Final contrast, text size and fit need rendered visual QA.
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
import { lintDeckContent, lintDeckStructure } from '@protoyard/blueprint';
|
|
160
|
+
|
|
161
|
+
const structure = lintDeckStructure(deckJson);
|
|
162
|
+
const content = lintDeckContent(deckJson);
|
|
163
|
+
for (const issue of [...structure.diagnostics, ...content.diagnostics]) {
|
|
164
|
+
console.log(issue.code, issue.jsonPath, issue.message);
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
CLI packaging of `deck lint` is owned by the CLI / KINDS lane; agents can call
|
|
169
|
+
`lintDeckStructure` and `lintDeckContent` in-process today.
|
|
170
|
+
|
|
171
|
+
## Agent authoring sequence
|
|
172
|
+
|
|
173
|
+
**Primary path for agents is validated JSON**, not free-form prose at runtime.
|
|
174
|
+
Prose is only the offline seed that produces a draft blueprint file.
|
|
175
|
+
|
|
176
|
+
### Content kinds (deck, page, doc)
|
|
177
|
+
|
|
178
|
+
1. **Detect kind** with `detectBriefKind(brief)` or pass `{ kind: 'deck' | 'page' | 'doc' | 'app' }`
|
|
179
|
+
into `blueprintFromBrief` (see [Kind detection](#kind-detection)).
|
|
180
|
+
2. **Synthesize a draft** with `blueprintFromBrief(brief)` (or
|
|
181
|
+
`synthesizeDeckFromBrief` / page / doc helpers). The result is
|
|
182
|
+
`ContentBriefResult`: `{ kind, outline, json, rationale, kindDetection }`.
|
|
183
|
+
**Generated copy is draft scaffolding only** — placeholders marked "Draft:",
|
|
184
|
+
metrics as "—", no invented facts, quotes or real people (see
|
|
185
|
+
[Deck outline templates](#deck-outline-templates)).
|
|
186
|
+
3. **Write the JSON** to disk (`pnpm protoyard blueprint new <file> --from-brief …`
|
|
187
|
+
or your own writer). Prefer the full blueprint envelope:
|
|
188
|
+
`{ formatVersion, name, kind, deck|page|doc, … }`.
|
|
189
|
+
4. **Validate** with `validateBlueprintForAuthoring(json)` and/or
|
|
190
|
+
`pnpm protoyard blueprint validate <file>`. Diagnostics carry stable `code`,
|
|
191
|
+
`path`, `jsonPath`, `message` and `hint`. Revalidate after every edit.
|
|
192
|
+
5. **Deck-only lint:** `lintDeckStructure(json.deck)` checks layout structure
|
|
193
|
+
and capacity; `lintDeckAssets(json.deck)` checks source URL syntax without
|
|
194
|
+
network access.
|
|
195
|
+
6. **Generate / play** with `pnpm protoyard generate` or the player when the
|
|
196
|
+
draft is valid. See [the generator guide](../../docs/GENERATOR.md) and
|
|
197
|
+
[BRIEF_TO_PROTOTYPE](../../docs/BRIEF_TO_PROTOTYPE.md).
|
|
198
|
+
|
|
199
|
+
### Apps
|
|
200
|
+
|
|
201
|
+
For an offline end-to-end app path, start from a [quick blueprint](../../blueprints/quick/README.md)
|
|
202
|
+
or synthesize one with `pnpm protoyard blueprint new <file> --from-brief <brief-file>` (use `-`
|
|
203
|
+
for stdin). Read the rationale, edit the JSON, and run `pnpm protoyard blueprint validate <file>`.
|
|
204
|
+
For machine-actionable repairs, pass the parsed input to `validateBlueprintForAuthoring`; each
|
|
205
|
+
diagnostic provides `jsonPath`, `message` and a suggested `hint`. Revalidate after every repair.
|
|
206
|
+
The JSON Schema is useful for editor completion, but only the package parser checks
|
|
207
|
+
cross-references such as relation targets, screen fields and archetype entity slots.
|
|
208
|
+
|
|
209
|
+
If data shape is the starting point, use the lazy import modules documented below to infer entities
|
|
210
|
+
from CSV headers and samples, JSON objects, or SQLite `CREATE TABLE` statements. These are schema
|
|
211
|
+
imports: drafts and notes do not retain sample values or database rows. Review ambiguous kinds and
|
|
212
|
+
relation candidates, combine the entities with roles, scenario defaults, routes and any selected
|
|
213
|
+
`archetype`, then validate the assembled blueprint. CSV, JSON and SQLite imports currently expose
|
|
214
|
+
package APIs rather than a `protoyard` CLI import command.
|
|
215
|
+
|
|
216
|
+
Select `assistant`, `team-chat`, `saas-onboarding` or `analytics` in the optional portable
|
|
217
|
+
`archetype` block when a matching kit adapter should be available at runtime. The player and
|
|
218
|
+
generator consume this data and load declared kit entry points lazily. Standard entity
|
|
219
|
+
`screenDefaults` and top-level screens cover common routes; specialized workflows still need
|
|
220
|
+
owned Svelte screens and services. The separate `./workflows` module validates state-machine data,
|
|
221
|
+
but formatVersion 1 and generated services do not yet persist or execute those workflows. See
|
|
222
|
+
[the generator guide](../../docs/GENERATOR.md) for the complete author review, generation and
|
|
223
|
+
regeneration loop.
|
|
224
|
+
|
|
225
|
+
- **Brand (optional):** `seed` (`#rrggbb`) and/or built-in `preset` (`violet`, `ocean`, …),
|
|
226
|
+
optional `secondary`, `styleProfile` (`soft` | `crisp` | `flat` | `rounded` | `editorial` |
|
|
227
|
+
`mono`), `font` (catalog id), `radius`, `density`, `id` and `name`.
|
|
228
|
+
`pnpm protoyard generate` turns brand into a WCAG AA theme (`resolveBlueprintBrand`), writes
|
|
229
|
+
hash-headed `src/generated/brand-theme.ts`, registers it on the prototype, and sets
|
|
230
|
+
`defaultTheme`. `libraryTheme` (a theme library id, exclusive with every other theme field)
|
|
231
|
+
skips the module: `defaultTheme` becomes the library id, checked by `brandLibraryThemeId`.
|
|
232
|
+
Without `brand`, the template default theme is kept.
|
|
233
|
+
- **Catalog (optional):** the same `catalog` block as a prototype manifest, plus optional
|
|
234
|
+
`tags`, `icon` and `category`. Files under `blueprints/<slug>.json` that set **both**
|
|
235
|
+
`brand` and `catalog` are indexed by `protoyard index` as `kind: blueprint` (see
|
|
236
|
+
[blueprints/README.md](../../blueprints/README.md)).
|
|
237
|
+
- **Kit archetype (optional):** portable runtime data for `assistant`, `team-chat`,
|
|
238
|
+
`saas-onboarding` or `analytics`. It maps stable entity slots to entities in this blueprint,
|
|
239
|
+
declares a kit screen and route, records scenario defaults, and lists only allow-listed public
|
|
240
|
+
kit entry points. These strings are validated metadata; parsing never imports or executes them.
|
|
241
|
+
`BlueprintSchema` checks entity references and route collisions. Omitting the block keeps
|
|
242
|
+
ordinary CRUD blueprints unchanged. For example:
|
|
243
|
+
|
|
244
|
+
```json
|
|
245
|
+
{
|
|
246
|
+
"archetype": {
|
|
247
|
+
"id": "assistant",
|
|
248
|
+
"kits": ["@protoyard/ai", "@protoyard/ai-ui"],
|
|
249
|
+
"entities": { "conversations": "conversation", "messages": "message" },
|
|
250
|
+
"screens": [{ "kind": "assistant-workspace", "path": "/assistant" }],
|
|
251
|
+
"scenarioDefaults": { "density": "normal", "activity": "normal" }
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
The integration contract is checked against each kit's public package export map in
|
|
257
|
+
`tests/archetypes/kit-contract.test.ts`: AI archetypes target the AI engine and workspace,
|
|
258
|
+
SaaS onboarding uses `saasRoutes`' lazy loader, and analytics selects only the grid and
|
|
259
|
+
date-range UI subpaths it declares. The test also checks their callback and simulation
|
|
260
|
+
contracts. The blueprint package keeps kit names as validated data and does not depend on
|
|
261
|
+
or import the kits. The player's route registry is responsible for loading the selected
|
|
262
|
+
screen lazily. `@protoyard/ai-ui` currently exports an assistant workspace but no dedicated
|
|
263
|
+
team-chat screen, so the player still needs an adapter or a future UI entry for that screen.
|
|
264
|
+
|
|
265
|
+
- **Entities:** kebab-case `name` (singular) and `plural`, optional `table`, `fields`,
|
|
266
|
+
`titleField`, `searchable`, `ownerField` (a `persona` field, enables `:own` actions),
|
|
267
|
+
`indexes`, `defaultSort`, optional `screenDefaults`, and `features` (`softDelete`, `audit`,
|
|
268
|
+
`notifications`, `files`, `csv`, all off by default). `screenDefaults.views` can enable
|
|
269
|
+
`list`, `detail` and `form` for that entity; `list.columns` selects visible fields and
|
|
270
|
+
`detail.tabs` selects overview, related and feature-backed sections. When the block is absent,
|
|
271
|
+
the legacy list and detail routes remain. Entity defaults materialize as ordinary screens;
|
|
272
|
+
an explicit top-level screen of the same entity and type takes precedence and is not replaced.
|
|
273
|
+
- **Field kinds:** `text`, `longText`, `number`, `integer`, `money` (integer cents plus an
|
|
274
|
+
ISO 4217 `currency`, default `USD`), `boolean`, `date`, `datetime`, `enum` (`values`),
|
|
275
|
+
`email`, `url`, `phone`, `tags`, `relation` (`to`, `cardinality` `one|many`, `onDelete`
|
|
276
|
+
`restrict|cascade|nullify`), `persona` (a `py_personas` row), `file` (`accept`,
|
|
277
|
+
`maxSizeBytes`, `multiple`), `json`, `geo` (`region`; stored as `{ lat, lng }` with 5
|
|
278
|
+
decimals, accepted as an object or `"lat, lng"` text, never indexed, searched or sorted)
|
|
279
|
+
and `avatar` (a face from `@protoyard/avatars` stored as its id, `av-001` … `av-175`;
|
|
280
|
+
only library ids validate; never indexed, searched or sorted). `id`, `createdAt`, `updatedAt`, `deletedAt` and
|
|
281
|
+
`deletedBy` are managed by the platform and cannot be declared.
|
|
282
|
+
- **Roles:** `permissions` maps an entity name (or `*`) to actions: `read`, `create`,
|
|
283
|
+
`update`, `update:own`, `delete`, `delete:own`, `import`, `export` or `*`.
|
|
284
|
+
- **Screens:** `list` (`columns`), `detail` (optional `tabs`: `overview`, `related:<entity>`,
|
|
285
|
+
`activity`, `files`, `audit`), `form`, `board` (`groupBy` an enum, `cardFields`, `sumField`),
|
|
286
|
+
`calendar` (`dateField`, `endField`, `colorBy`, `view`), `timeline` (`startField`,
|
|
287
|
+
`endField`, `groupBy`, `zoom`), `map` (`geoField`, `colorBy`), `analytics` (`dateField`,
|
|
288
|
+
`measures`, `dimensions`), `sheet` (records as a spreadsheet: `columns` of text, number,
|
|
289
|
+
money, boolean, date, enum and contact fields, plus read-only datetime, relation, persona and
|
|
290
|
+
tags; `totals` sums number, integer or money columns; at most 26 columns; schema in
|
|
291
|
+
`src/sheet-screen.ts`), `canvas` (one infinite canvas per record, stored in the `json`
|
|
292
|
+
`field` it names; schema in `src/canvas-screen.ts`), `dashboard` (`widgets`: `kpi`, `chart` line/area/bar/donut,
|
|
293
|
+
`list`, `board-summary`, each with `aggregate` count/sum/avg/min/max, optional `field`,
|
|
294
|
+
`groupBy`, date `bucket` and `filter`; the formatVersion 1 `metrics` still parse) and
|
|
295
|
+
`settings` (`sections`). Every screen takes `path`, `title` and `nav` (`label`, a Lucide
|
|
296
|
+
`icon` from `SCREEN_NAV_ICONS`, `order`, `hidden`). `screenPath(screen, entities)` derives
|
|
297
|
+
default routes (`/<plural>`, `/<plural>/:id`, `/<plural>/new`, `/<plural>/<kind>`,
|
|
298
|
+
`/dashboard`, `/settings`); `dashboardWidgets(screen)` returns legacy metrics as `kpi`
|
|
299
|
+
widgets followed by `widgets`. All keys are additive: `formatVersion` stays `1`.
|
|
300
|
+
- **Notifications:** optional top-level rules use `on: 'create' | 'update' | 'assign'`, an
|
|
301
|
+
entity, `notify: 'owner' | 'assignee' | 'role:<name>'`, and a plain-text title with `{field}`
|
|
302
|
+
substitutions. Assignment rules name a persona field. Writes share the entity mutation
|
|
303
|
+
transaction, exclude the actor, and store an empty body for future UI copy.
|
|
304
|
+
|
|
305
|
+
After the shape is valid, cross-references are checked with path-specific messages, for
|
|
306
|
+
example `entities.1.fields.3.to: relation "support-ticket.customer" targets unknown entity
|
|
307
|
+
"client"`: relation targets, title and owner fields, enum values and defaults, the reserved
|
|
308
|
+
`py_` table prefix, duplicate names, `:own` actions without an owner field, and screens: every
|
|
309
|
+
referenced field exists and has the right kind (enum `groupBy`, date fields, numeric
|
|
310
|
+
aggregates, `geo` for maps), filter values and ops, chart requirements (donut needs `groupBy`,
|
|
311
|
+
line and area need a date bucket), related tabs need a relation back, feature-gated tabs and
|
|
312
|
+
settings sections, archetype entity slots, and duplicate or reserved (`/members`) routes.
|
|
313
|
+
Entity screen defaults receive the same field, feature, related-entity and route-collision checks
|
|
314
|
+
as top-level screens; errors point back to `entities.<n>.screenDefaults`.
|
|
315
|
+
|
|
316
|
+
Relations keep their stored direction explicit: the entity declaring a relation owns its
|
|
317
|
+
`<field>Id` or `<field>Ids` foreign-key property. `required` means a one relation must contain an
|
|
318
|
+
id and a many relation must contain at least one id; when omitted, a one relation is optional
|
|
319
|
+
and a many relation may be empty. Related-detail tabs name the target entity; the generated UI finds
|
|
320
|
+
the inverse relation field(s) on that entity and uses their declared labels (or field names).
|
|
321
|
+
This avoids a second, unsynchronized foreign-key declaration. Relation fields retain the v1
|
|
322
|
+
shape and defaults, so existing blueprints and derived stores need no migration.
|
|
323
|
+
|
|
324
|
+
## JSON Schema
|
|
325
|
+
|
|
326
|
+
`schema/blueprint.schema.json` (draft 2020-12) gives editors autocompletion: point a
|
|
327
|
+
blueprint's `$schema` at it. It describes the shape only; the cross-reference checks above
|
|
328
|
+
run in `parseBlueprint`. The package exports it as `@protoyard/blueprint/schema.json` (the
|
|
329
|
+
packed package ships it as `dist/schema/blueprint.schema.json`). `protoyard blueprint new`
|
|
330
|
+
finds it through that export, from this repository or an installed package, and writes a
|
|
331
|
+
relative `$schema`. A test fails when the file is out of date. Regenerate it with:
|
|
332
|
+
|
|
333
|
+
```bash
|
|
334
|
+
pnpm exec tsx -e "import { blueprintJsonSchema } from './packages/blueprint/src/index.ts'; import { writeFileSync } from 'node:fs'; void blueprintJsonSchema().then(schema => writeFileSync('packages/blueprint/schema/blueprint.schema.json', JSON.stringify(schema, null, 2) + '\n'));" && npx biome format --write packages/blueprint/schema
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
**Size:** measured with esbuild (minified, zod and `@protoyard/core` external) and gzip -9:
|
|
338
|
+
about 6.1 KB for parsing (`parseBlueprint`, `entityTable`) with the screen kinds (3.9 KB
|
|
339
|
+
before them). `blueprintJsonSchema` tree-shakes away when unused.
|
|
340
|
+
|
|
341
|
+
## Storage derivation
|
|
342
|
+
|
|
343
|
+
```ts
|
|
344
|
+
import { deriveStores, deriveIntegrity, parseBlueprint } from '@protoyard/blueprint';
|
|
345
|
+
|
|
346
|
+
const blueprint = parseBlueprint(JSON.parse(text));
|
|
347
|
+
const stores = deriveStores(blueprint);
|
|
348
|
+
// { customers: 'id, *tags, name, createdAt, updatedAt', supportTickets: 'id, customerId, …', … }
|
|
349
|
+
const integrity = deriveIntegrity(blueprint);
|
|
350
|
+
// { relations: { supportTickets: { customerId: 'customers', assigneeId: 'py_personas' } },
|
|
351
|
+
// timestamps: { supportTickets: ['createdAt', 'updatedAt', 'dueOn', 'deletedAt'] } }
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
`deriveStores` is deterministic (same blueprint → byte-identical JSON) and matches what
|
|
355
|
+
`PrototypeDatabase` accepts as Dexie store strings:
|
|
356
|
+
|
|
357
|
+
- every table starts with `id`
|
|
358
|
+
- relation `one` → indexed `<field>Id`; `many` → multi-entry `*<field>Ids`
|
|
359
|
+
- persona → indexed `<field>Id` (integrity target `py_personas`)
|
|
360
|
+
- tags → multi-entry `*<field>`; file fields → `<field>Id` / `*<field>Ids`
|
|
361
|
+
- `indexes`, `titleField` and `defaultSort` add plain indexes when needed
|
|
362
|
+
- `createdAt` / `updatedAt` always; `deletedAt` when `features.softDelete`
|
|
363
|
+
- when any entity enables `audit` / `notifications` / `files`, shared domain tables
|
|
364
|
+
`auditEntries`, `notifications` and `files` are appended (not `py_*`)
|
|
365
|
+
|
|
366
|
+
`deriveIntegrity` feeds `validateWorld` from `@protoyard/scenarios`: dangling foreign keys
|
|
367
|
+
and out-of-range timestamps fail the scenario load.
|
|
368
|
+
|
|
369
|
+
## Migrations
|
|
370
|
+
|
|
371
|
+
```ts
|
|
372
|
+
import {
|
|
373
|
+
deriveStores,
|
|
374
|
+
diffBlueprints,
|
|
375
|
+
appendMigration,
|
|
376
|
+
toDatabaseVersion,
|
|
377
|
+
} from '@protoyard/blueprint';
|
|
378
|
+
|
|
379
|
+
const v1 = { version: 1, stores: deriveStores(prev) };
|
|
380
|
+
const migration = diffBlueprints(prev, next, {
|
|
381
|
+
fromVersion: 1,
|
|
382
|
+
// allowDrop: true, // required to drop a table or field
|
|
383
|
+
// renamedFrom: { fields: { 'notes.headline': 'title' }, tables: { clients: 'customers' } },
|
|
384
|
+
});
|
|
385
|
+
// null when nothing changed; otherwise version 2 + stores + serializable ops
|
|
386
|
+
const versions = appendMigration([v1], migration);
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
`diffBlueprints` classifies add entity, add field (backfill default), add index, rename via
|
|
390
|
+
`renamedFrom`, and drop (`allowDrop` → Dexie store `null`). Upgrade steps are **data**
|
|
391
|
+
(`backfill`, `renameField`, `copyTable`) so blueprint history stays JSON; `toDatabaseVersion`
|
|
392
|
+
turns them into a Dexie `upgrade` callback. Drops without `allowDrop` throw `MigrationError`
|
|
393
|
+
— migrations never delete user data silently.
|
|
394
|
+
|
|
395
|
+
`protoyard generate` records every storage change in the blueprint's `history` (earlier
|
|
396
|
+
blueprints with their schema version and `migrateNext.allowDrop`). `deriveDatabaseVersions`
|
|
397
|
+
rebuilds the full version list from that data alone, and `latestSchemaVersion` returns its
|
|
398
|
+
tip, which is what generated `src/generated/schema.ts` and the `validate` drift check use:
|
|
399
|
+
|
|
400
|
+
```ts
|
|
401
|
+
import { deriveDatabaseVersions, latestSchemaVersion } from '@protoyard/blueprint';
|
|
402
|
+
|
|
403
|
+
const versions = deriveDatabaseVersions(blueprint); // v1 from the oldest entry, then each diff
|
|
404
|
+
latestSchemaVersion(blueprint); // must equal protoyard.json storage.schemaVersion
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
## Policy derivation
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
import { CAPABILITIES, derivePolicy, parseBlueprint } from '@protoyard/blueprint';
|
|
411
|
+
import { createPermissionPolicy } from '@protoyard/core';
|
|
412
|
+
|
|
413
|
+
const blueprint = parseBlueprint(input);
|
|
414
|
+
const policy = createPermissionPolicy(derivePolicy(blueprint));
|
|
415
|
+
const caps = CAPABILITIES(blueprint);
|
|
416
|
+
// UI and services use caps['support-ticket'].read / .deleteOwn / .updateOwn.
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Role actions become `<entity>:<action>` capabilities. `*` grants are expanded or preserved for the runtime policy, and explicit `-capability` entries remain denials. `update:own` and `delete:own` use the persona id column derived from the entity's `ownerField`. Owner/admin roles receive the platform `members:*` capability for team-management services. These permissions are simulated UI behavior, not security.
|
|
420
|
+
|
|
421
|
+
## Input validation
|
|
422
|
+
|
|
423
|
+
```ts
|
|
424
|
+
import { parseInput } from '@protoyard/core';
|
|
425
|
+
import { deriveInputSchemas } from '@protoyard/blueprint';
|
|
426
|
+
|
|
427
|
+
const { create, update } = deriveInputSchemas(entity); // built on first use, cached per entity
|
|
428
|
+
const row = parseInput(create, formValues); // throws ValidationError { issues: { subject: 'Subject is required' } }
|
|
429
|
+
const patch = parseInput(update, { status: 'solved' }); // only keys present are validated and returned
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
- **Keys** are row properties: relation, persona and file fields use `customerId`,
|
|
433
|
+
`relatedIds`, `assigneeId`, `attachmentsIds` (see `inputProperty`); other fields use
|
|
434
|
+
their name. Unknown keys are dropped.
|
|
435
|
+
- **Coercion** of form strings: numbers and integers, money (`'12.34'`, `'$1,234.50'` →
|
|
436
|
+
integer cents; plain numbers are already cents and must be integers), booleans (`on`,
|
|
437
|
+
`true`, `1`, `yes`), dates (`YYYY-MM-DD`, real calendar dates), datetimes (ISO with an
|
|
438
|
+
offset, normalized to UTC), comma-separated tags (trimmed, de-duplicated) and JSON text.
|
|
439
|
+
- **Checks:** text `minLength`/`maxLength`, number/integer/money `min`/`max`, enum values,
|
|
440
|
+
email, http(s) URLs, phone numbers, tag count and length, library ids for avatars, and
|
|
441
|
+
non-empty ids for relations, personas and files.
|
|
442
|
+
- **Blanks:** on create, a blank or absent optional field becomes its `default`, `false`
|
|
443
|
+
for booleans, `[]` for lists, otherwise `null`. A blank required field fails with
|
|
444
|
+
"<Label> is required" unless it has a default. On update, blanks clear optional fields
|
|
445
|
+
(`null` or `[]`) and fail for required ones.
|
|
446
|
+
- **Messages** use the field `label`, or the name in sentence case (`dueOn` → "Due on").
|
|
447
|
+
|
|
448
|
+
## CSV codec
|
|
449
|
+
|
|
450
|
+
Lazy entry point — load only when the user opens import/export:
|
|
451
|
+
|
|
452
|
+
```ts
|
|
453
|
+
import {
|
|
454
|
+
parseCsv,
|
|
455
|
+
serializeCsv,
|
|
456
|
+
serializeEntityCsv,
|
|
457
|
+
validateCsv,
|
|
458
|
+
CSV_MAX_BYTES,
|
|
459
|
+
CSV_MAX_ROWS,
|
|
460
|
+
} from '@protoyard/blueprint/csv';
|
|
461
|
+
|
|
462
|
+
const matrix = parseCsv(text); // RFC 4180: quotes, "", CRLF, BOM
|
|
463
|
+
const csv = serializeEntityCsv(entity, rows); // headers = labels; formulas neutralized
|
|
464
|
+
const { valid, errors } = validateCsv(entity, text);
|
|
465
|
+
// errors: [{ row: 2, field: 'amount', message: '…' }] (1-based data rows)
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
- Headers match field **name**, input **property** (`customerId`) or **label** (case-insensitive).
|
|
469
|
+
- A `geo` field is two columns, `<name>Lat` and `<name>Lng` (also matched as `<label> lat` /
|
|
470
|
+
`<label> lng`); import needs both halves or neither.
|
|
471
|
+
- An `avatar` field exports its id (`av-007`) and imports only library ids.
|
|
472
|
+
- Money and dates go through `deriveInputSchemas` create coercion (`12.50` → 1250 cents).
|
|
473
|
+
- Export prefixes with `'` cells starting with `= + - @` (also after leading spaces or full-width), a tab or a carriage return, unless the whole cell is a plain number such as `-5` (spreadsheet formula guard, `guardCsvFormula`). Import strips only a prefix the export would have added, so values round-trip.
|
|
474
|
+
- Hard limits: `CSV_MAX_BYTES` (2 MiB) and `CSV_MAX_ROWS` (10,000 data rows); override per call.
|
|
475
|
+
|
|
476
|
+
### CSV shape authoring
|
|
477
|
+
|
|
478
|
+
Shape inference is a separate lazy authoring entry, so player consumers that only
|
|
479
|
+
need the row codec do not load it:
|
|
480
|
+
|
|
481
|
+
```ts
|
|
482
|
+
import { inferCsvEntity } from '@protoyard/blueprint/import/csv-shape';
|
|
483
|
+
|
|
484
|
+
const { entity, notes } = inferCsvEntity(csvText, { entityName: 'invoice' });
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
It normalizes headers into deterministic field names, infers conservative kinds from
|
|
488
|
+
at most 100 non-empty rows, and returns a validated entity draft with review notes.
|
|
489
|
+
The result never includes imported cell values, enum values, sample-derived limits or
|
|
490
|
+
row data. The existing 2 MiB / 10,000-row CSV limits still apply; shape inference also
|
|
491
|
+
caps the header at 100 columns, header and sampled cells at 16 KiB each, and sample
|
|
492
|
+
inspection at 100 non-empty rows. Caller limits can lower these caps only. Ambiguous columns
|
|
493
|
+
remain `text` and are called out in `notes` for author review. Formula-like sample
|
|
494
|
+
cells are unguarded only while classifying them and are never returned.
|
|
495
|
+
|
|
496
|
+
### JSON shape authoring
|
|
497
|
+
|
|
498
|
+
JSON shape inference is also a separate lazy authoring entry. It accepts one
|
|
499
|
+
representative object or an array of objects:
|
|
500
|
+
|
|
501
|
+
```ts
|
|
502
|
+
import { inferJsonShape } from '@protoyard/blueprint/import/json-shape';
|
|
503
|
+
|
|
504
|
+
const { entities, notes, relationCandidates } = inferJsonShape(jsonText, {
|
|
505
|
+
entityName: 'support-ticket',
|
|
506
|
+
});
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
The result is a validated, value-free entity draft. Scalar fields infer
|
|
510
|
+
conservative text, number, integer, boolean, email, URL, date or datetime
|
|
511
|
+
kinds. Missing and null samples make a field optional; mixed shapes and
|
|
512
|
+
unrecognized nested values stay `json` with a review note. A single sample
|
|
513
|
+
never marks a field required; multiple samples mark it required only when every
|
|
514
|
+
sample has a non-null value. Nested objects are
|
|
515
|
+
kept as JSON. Arrays of objects produce related entity drafts and candidate
|
|
516
|
+
one-to-many fields, which should be reviewed before generation. Arrays of
|
|
517
|
+
strings can become `tags`; enum values and sample limits are never copied.
|
|
518
|
+
|
|
519
|
+
Input is capped at 2 MiB, 100 sampled objects per entity, 8 levels of nesting,
|
|
520
|
+
2,000 object/array nodes, 20,000 total value nodes, 100 fields per object and
|
|
521
|
+
16 KiB per string value or key. Prototype-related keys and keys with control or
|
|
522
|
+
bidirectional formatting characters are rejected. Higher caller limits are
|
|
523
|
+
clamped to these hard caps. Output entity and field
|
|
524
|
+
ordering is stable regardless of JSON key insertion order, and notes/errors do
|
|
525
|
+
not echo source values.
|
|
526
|
+
|
|
527
|
+
### SQLite schema authoring
|
|
528
|
+
|
|
529
|
+
SQLite schema import accepts SQL text and never opens a database, runs a
|
|
530
|
+
statement or reads table rows. Import it only while authoring a draft:
|
|
531
|
+
|
|
532
|
+
```ts
|
|
533
|
+
import { importSqliteSchema } from '@protoyard/blueprint/import/sqlite-schema';
|
|
534
|
+
|
|
535
|
+
const { entities, primaryKeys, notes } = importSqliteSchema(`
|
|
536
|
+
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
|
|
537
|
+
CREATE TABLE orders (
|
|
538
|
+
id INTEGER PRIMARY KEY,
|
|
539
|
+
customer_id INTEGER REFERENCES customers(id) ON DELETE CASCADE
|
|
540
|
+
);
|
|
541
|
+
`);
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
The supported grammar is one or more `CREATE [TEMP|TEMPORARY] TABLE [IF NOT
|
|
545
|
+
EXISTS]` declarations with plain or quoted identifiers, comma-separated
|
|
546
|
+
columns, optional declared types and column or table `PRIMARY KEY`, `NOT NULL`,
|
|
547
|
+
`NULL`, `UNIQUE`, `CHECK`, `DEFAULT`, `COLLATE`, `REFERENCES` and `FOREIGN KEY`
|
|
548
|
+
constraints. Foreign keys must reference one local column and one declared
|
|
549
|
+
table. `ON DELETE CASCADE`, `SET NULL`, `RESTRICT` and `NO ACTION` map to the
|
|
550
|
+
blueprint's delete behavior. Table and target names match case-insensitively.
|
|
551
|
+
|
|
552
|
+
`INTEGER PRIMARY KEY` maps to Protoyard's generated `id`; other primary-key
|
|
553
|
+
fields become required and are listed in `primaryKeys`, since blueprint fields
|
|
554
|
+
do not enforce uniqueness. The returned `notes` call out that limitation.
|
|
555
|
+
`INT` maps to `integer`; `REAL`, `FLOAT`, `DOUBLE`, `NUMERIC` and `DECIMAL` map
|
|
556
|
+
to `number`; character, text and CLOB types map to `text`; `BOOLEAN`, `DATE`,
|
|
557
|
+
`DATETIME` and `TIMESTAMP` map to their corresponding blueprint kinds. BLOB
|
|
558
|
+
and absent types use conservative reviewable representations. Defaults, checks,
|
|
559
|
+
unique constraints and collations do not become behavior; default literals are
|
|
560
|
+
discarded. Composite foreign keys, unsupported types and non-CREATE statements
|
|
561
|
+
are rejected with a path-aware `SqliteSchemaError`. Input is limited to 256 KiB,
|
|
562
|
+
50 tables, 100 columns per table, 40,000 tokens and 8 levels of parenthesis
|
|
563
|
+
nesting. Caller limits can lower these caps only. SQL literal values and
|
|
564
|
+
database rows never appear in the output or parser diagnostics.
|
|
565
|
+
|
|
566
|
+
## World generator
|
|
567
|
+
|
|
568
|
+
Lazy entry so Faker stays out of the initial chunk:
|
|
569
|
+
|
|
570
|
+
```ts
|
|
571
|
+
import { createBlueprintGenerator } from '@protoyard/blueprint/generator';
|
|
572
|
+
import { createGenerateContext } from '@protoyard/scenarios';
|
|
573
|
+
|
|
574
|
+
const { generate, generatorVersion } = createBlueprintGenerator(blueprint);
|
|
575
|
+
const world = generate(createGenerateContext(scenario));
|
|
576
|
+
// team + entity rows × ctx.scale(), relations in topological order, optional
|
|
577
|
+
// auditEntries / notifications history when features are on
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
- Field values use `ctx.faker` / `ctx.rng` / `ctx.time` / `ctx.ids` only (no `Math.random` / `Date.now`).
|
|
581
|
+
- Optional `field.fake` must be an allow-listed path such as `company.name` or `person.fullName`.
|
|
582
|
+
- Emails always use `example.com` / `example.org`.
|
|
583
|
+
- `generatorVersion` mixes a blueprint content hash with the generator SDK and scenarios SDK versions.
|
|
584
|
+
`blueprintGeneratorVersion` lives in a small module (`src/generator-version.ts`, re-exported here)
|
|
585
|
+
so the runtime definition reads it without loading the generator.
|
|
586
|
+
- `geo` fields get points from `geoPoints()` inside their `region` (default `metro-grid`), one
|
|
587
|
+
point set per entity field, so the same seed gives the same points (generator SDK 2).
|
|
588
|
+
- Team personas come from `generateTeam`, so each one carries a seeded library `avatar`
|
|
589
|
+
(`av-NNN`, no repeats until all 175 are used; generator SDK 4).
|
|
590
|
+
- `avatar` fields get one seeded face per row from `createAvatarPool` on a fork keyed by
|
|
591
|
+
entity and field, dealing the team's faces last: no repeats within the field until all
|
|
592
|
+
175 are used, more rows keep the earlier faces, and other values do not shift
|
|
593
|
+
(generator SDK 5).
|
|
594
|
+
|
|
595
|
+
## Audit log
|
|
596
|
+
|
|
597
|
+
When an entity sets `features.audit`, every successful mutation writes one row to the shared
|
|
598
|
+
`auditEntries` table **inside the same Dexie transaction** as the row change:
|
|
599
|
+
|
|
600
|
+
```ts
|
|
601
|
+
import { createEntityServices, createAuditService } from '@protoyard/blueprint';
|
|
602
|
+
|
|
603
|
+
const services = createEntityServices(runtime, blueprint);
|
|
604
|
+
const audit = createAuditService(runtime, blueprint);
|
|
605
|
+
await services.ticket.update(id, { status: 'solved' });
|
|
606
|
+
const history = await audit.listForRecord('ticket', id);
|
|
607
|
+
// [{ action: 'update', actorId, at, changes: { status: { before: 'open', after: 'solved' } } }, …]
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
- Diffs list **only changed** input properties; `sensitive` fields store `[redacted]`.
|
|
611
|
+
- Failed mutations (validation, permission denied, offline) write **no** audit row.
|
|
612
|
+
- Soft delete / restore / purge / bulk ops emit `delete` / `restore` / `purge` / `update` actions.
|
|
613
|
+
- `listForRecord` and `listRecent` require `<entity>:read` on an audited entity.
|
|
614
|
+
|
|
615
|
+
## Entity services
|
|
616
|
+
|
|
617
|
+
```ts
|
|
618
|
+
import { createEntityServices } from '@protoyard/blueprint';
|
|
619
|
+
|
|
620
|
+
const services = createEntityServices(runtime, blueprint); // one service per entity name
|
|
621
|
+
const ticket = await services.ticket.create({ subject: 'Printer jam', customerId });
|
|
622
|
+
await services.ticket.update(ticket.id, { status: 'solved' });
|
|
623
|
+
const undoToken = await services.customer.remove(customerId); // token only when softDelete is enabled
|
|
624
|
+
if (undoToken) await services.customer.restore(undoToken); // restore within the simulated undo window
|
|
625
|
+
await services.customer.purge(customerId); // permanently remove trashed rows
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
Each entity gets `list`, `trash`, `get`, `create`, `update`, `remove`, `restore`, `purge`,
|
|
629
|
+
`shuffleAvatar`, `bulkUpdate` and `bulkRemove`. `shuffleAvatar(id, field?)` saves the next face
|
|
630
|
+
for an avatar field (default: the first) with the same checks as `update` (operation
|
|
631
|
+
`<entity>.shuffleAvatar`); the face comes from `nextAvatar(current, '<entity>:<id>:<field>')`,
|
|
632
|
+
a deterministic walk with no clock or random source that never repeats the current face and
|
|
633
|
+
visits all 175 before any repeat. Entities without an avatar field reject it with a
|
|
634
|
+
`ValidationError`. `list` hides soft-deleted rows; `trash` reads the indexed
|
|
635
|
+
`deletedAt` values for review. When `features.softDelete` is enabled, `remove` marks the record
|
|
636
|
+
and its cascade set with `deletedAt` / `deletedBy` and returns an undo token that works for 30
|
|
637
|
+
seconds of simulated time, including after a reload. Restoring also reverses nullified relation
|
|
638
|
+
fields. `purge` requires the full `<entity>:delete` capability and permanently removes the trashed
|
|
639
|
+
record and its cascade dependents. Entities without soft delete keep hard-delete `remove`; the
|
|
640
|
+
explicit `bulkRemove` operation is also permanent and returns the number of removed rows.
|
|
641
|
+
|
|
642
|
+
Every mutation runs:
|
|
643
|
+
|
|
644
|
+
1. `session.assert('<entity>:<action>')`. For update and delete, a role with only
|
|
645
|
+
`update:own` / `delete:own` passes when the record's `ownerField` is the actor. A
|
|
646
|
+
denial names the full capability (`ticket:update`). This is simulated policy, not
|
|
647
|
+
security.
|
|
648
|
+
2. zod through `deriveInputSchemas` and `parseInput` (field-keyed `ValidationError`).
|
|
649
|
+
3. `environment.run('<entity>.<create|update|remove|bulkUpdate|bulkRemove>')`, so
|
|
650
|
+
DevTools latency, offline mode and scoped failures (`failureScope: ['ticket.']`) apply.
|
|
651
|
+
4. One `db.transaction('rw', …)` that checks referenced rows exist (relations,
|
|
652
|
+
`py_personas`, files; a missing one fails as `customerId: 'Customer not found'`),
|
|
653
|
+
writes the row with `createdAt`/`updatedAt` from the simulated clock, and applies every
|
|
654
|
+
`onDelete` rule: `cascade` removes dependents recursively (softly for an undoable remove),
|
|
655
|
+
`nullify` clears single references or removes the id from many-relations, and `restrict`
|
|
656
|
+
refuses the whole delete with `EntityInUseError`. A failure at any step writes nothing.
|
|
657
|
+
|
|
658
|
+
New rows get `createRuntimeId` ids (`support_ticket_u3f9…`) and the actor as owner when
|
|
659
|
+
the entity has an `ownerField` and none was given. Updating or deleting a missing id
|
|
660
|
+
throws `EntityNotFoundError`. Bulk writes go through `bulkPut` in chunks of `BULK_CHUNK`
|
|
661
|
+
(1,000) rows inside the same transaction. Reads (`list`, `get`) need `<entity>:read`, hide
|
|
662
|
+
rows with `deletedAt`, sort by `defaultSort` (else `createdAt`), and read IndexedDB
|
|
663
|
+
directly without the simulated network, like `liveQuery` reads.
|
|
664
|
+
|
|
665
|
+
When notification rules are present, matching create and update mutations append notification
|
|
666
|
+
rows in that same transaction. Assignment rules fire when the named persona field changes to a
|
|
667
|
+
new persona. Rule titles are plain text and interpolate entity fields; the actor never receives
|
|
668
|
+
their own notification. `createNotificationService(runtime)` scopes list, unread count and read
|
|
669
|
+
state changes to whichever persona is active, so switching persona changes the visible inbox.
|
|
670
|
+
|
|
671
|
+
Entities with `features.files` can use `createFileServices(runtime, blueprint)`. Uploads validate
|
|
672
|
+
the file field's `accept` and `maxSizeBytes`, check the 50 MiB per-prototype default quota and the
|
|
673
|
+
runtime storage estimate, then attach the file id and persist binary bytes in the same simulated
|
|
674
|
+
service transaction. `list()` returns metadata without a `blob`; `get()` reconstructs a Blob on
|
|
675
|
+
demand. JSON snapshots do not preserve file bytes, so users should not rely on snapshots to move
|
|
676
|
+
attachments between prototypes. A visible note in the snapshot UI still requires the runtime
|
|
677
|
+
snapshot surface to opt in.
|
|
678
|
+
|
|
679
|
+
CSV import/export live on the same service object (capabilities `<entity>:export` /
|
|
680
|
+
`<entity>:import`):
|
|
681
|
+
|
|
682
|
+
```ts
|
|
683
|
+
const csv = await services.ticket.exportCsv(); // visible rows, formula-safe
|
|
684
|
+
const preview = await services.ticket.importCsv(text); // mode defaults to preview
|
|
685
|
+
const committed = await services.ticket.importCsv(text, {
|
|
686
|
+
mode: 'commit',
|
|
687
|
+
skipInvalid: true, // otherwise any invalid row aborts and writes nothing
|
|
688
|
+
});
|
|
689
|
+
// Relation cells accept a target id or its title. Commit is one transaction
|
|
690
|
+
// (bulk chunks of 1,000). Audit rows are written when features.audit is on.
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
## Brief to blueprint
|
|
694
|
+
|
|
695
|
+
Kind synthesis fixtures live in `tests/fixtures/briefs/kinds.json` and are exercised by
|
|
696
|
+
`tests/brief-kinds.test.ts` (all four kinds, cue conflicts, explicit overrides, empty and
|
|
697
|
+
maximum-length input, content-only requests). The historical app set in
|
|
698
|
+
`tests/fixtures/briefs/briefs.json` stays the regression baseline
|
|
699
|
+
(`pnpm vitest run packages/blueprint/tests/brief-kinds.test.ts packages/blueprint/tests/brief.test.ts`).
|
|
700
|
+
|
|
701
|
+
Generated kind JSON Schema (from the live Zod unions via `z.toJSONSchema`) is checked against
|
|
702
|
+
catalog fixtures in `tests/kind-json-schema.test.ts`. That suite also locks synthesizer drafts:
|
|
703
|
+
every bench brief (`scripts/bench-brief-kinds.ts`) must detect the expected kind and pass
|
|
704
|
+
`safeParseAnyBlueprint` (page/doc bodies include `id` + `title`; deck body is `style` + `slides`
|
|
705
|
+
only). Cross-reference and lint rules still require the package validator — JSON Schema alone is
|
|
706
|
+
not enough.
|
|
707
|
+
|
|
708
|
+
`blueprintFromBrief(brief, { archetype?, name?, kind? })` turns a short product brief into a
|
|
709
|
+
valid blueprint (or content draft), offline and deterministically (same brief, same JSON; no
|
|
710
|
+
network or model). Kind dispatch runs first (ADR-036):
|
|
711
|
+
|
|
712
|
+
1. An explicit `kind` (`app` | `deck` | `page` | `doc`) overrides every cue; unknown kinds throw a
|
|
713
|
+
stable `RangeError`.
|
|
714
|
+
2. Strong deck / page / doc cues route to kind-specific synthesizers **before** app archetype
|
|
715
|
+
scoring:
|
|
716
|
+
- **Deck** — eight outline templates (pitch, QBR, launch, status, training, lesson, report,
|
|
717
|
+
talk) via `synthesizeDeckOutline`, then catalog layout selection via `selectDeckLayouts`
|
|
718
|
+
(see below). Bodies are draft placeholders, not research.
|
|
719
|
+
- **Page** — landing / pricing / waitlist / generic section outlines
|
|
720
|
+
(`buildPageOutline`).
|
|
721
|
+
- **Doc** — report / handbook / one-pager / proposal / generic outlines
|
|
722
|
+
(`buildDocOutline`). Bounded block vocabulary, stable section ids, explicit interactive
|
|
723
|
+
omissions in the rationale.
|
|
724
|
+
3. App briefs keep the historical result fields and byte-compatible JSON.
|
|
725
|
+
|
|
726
|
+
### Brief and outline bounds (`src/brief/limits.ts`)
|
|
727
|
+
|
|
728
|
+
| Cap | Value | Notes |
|
|
729
|
+
| --- | --- | --- |
|
|
730
|
+
| `MAX_BRIEF_LENGTH` / `MAX_CONTENT_BRIEF_LENGTH` | 5,000 characters | Longer briefs are truncated; synthesizers record a stable notice in `rationale`. |
|
|
731
|
+
| `OUTLINE_LIMITS.maxSlides` | 64 | Deck outlines only. |
|
|
732
|
+
| `OUTLINE_LIMITS.maxSections` | 64 | Page and doc outlines. |
|
|
733
|
+
| `OUTLINE_LIMITS.maxBlocksPerUnit` | 12 | Per slide/section (matches block column capacity). |
|
|
734
|
+
| `OUTLINE_LIMITS.maxTitleLength` | 80 | Envelope and unit titles. |
|
|
735
|
+
| `OUTLINE_LIMITS.maxBodyLength` | 600 | Short body copy (block shortMarkdown). |
|
|
736
|
+
| `OUTLINE_LIMITS.maxTextLength` | 4,000 | Long text (block markdown). |
|
|
737
|
+
|
|
738
|
+
Use `boundBriefInput` / `prepareBriefForSynthesis` before custom synthesis, `checkOutlineBounds` for hard failures, and `applyOutlineBounds` to clip deterministically with notices. Text ceilings agree with `@protoyard/blocks` content limits so drafts stay within untrusted validators.
|
|
739
|
+
|
|
740
|
+
```ts
|
|
741
|
+
import { blueprintFromBrief, isAppBriefResult } from '@protoyard/blueprint';
|
|
742
|
+
|
|
743
|
+
const result = blueprintFromBrief(
|
|
744
|
+
'A dog grooming salon that books appointments, tracks groomers and invoices with amount and due date.'
|
|
745
|
+
);
|
|
746
|
+
if (isAppBriefResult(result)) {
|
|
747
|
+
// archetype 'booking'; entities appointment, client, groomer, invoice
|
|
748
|
+
const { blueprint, json, archetype, layout, rationale, kindDetection } = result;
|
|
749
|
+
}
|
|
750
|
+
```
|
|
751
|
+
|
|
752
|
+
- **Archetype:** keyword scoring over `ARCHETYPES` (help desk, CRM, booking, logistics,
|
|
753
|
+
assistant, team chat, SaaS onboarding, analytics, projects, LMS, clinic, finance, wiki, IoT,
|
|
754
|
+
social and marketplace). The assistant, team-chat, SaaS-onboarding and analytics seeds recognize
|
|
755
|
+
high-signal cues, add their portable config to `blueprint.archetype`, and expose every matched
|
|
756
|
+
cue in the rationale. When equally strong kit cues conflict, synthesis falls back to the generic
|
|
757
|
+
`tracker`; `blueprintFromBrief(..., { archetype })` explicitly overrides scoring. Other
|
|
758
|
+
archetypes retain their earlier-order tie break. A lone stray keyword is not
|
|
759
|
+
enough unless it names an archetype entity; otherwise the brief's main noun becomes the tracker
|
|
760
|
+
entity.
|
|
761
|
+
- **Entities:** nouns after verbs such as *track*, *manage*, *log* or *schedule* ("track
|
|
762
|
+
groomers and invoices") become entities linked to the main one; attribute words
|
|
763
|
+
("locations", "prices") do not.
|
|
764
|
+
- **Fields:** "… with amount, due date and status" infers kinds from cue words
|
|
765
|
+
(`fieldFromPhrase`): dates, datetimes, money, numbers, enums, geo, personas, email, phone,
|
|
766
|
+
URL, tags, long text and booleans. Brief-wide cues (a location, a price, a deadline, an
|
|
767
|
+
owner) add a field to the main entity when it lacks that kind.
|
|
768
|
+
- **Screens** follow the data: list for every entity, board for a status enum, calendar for
|
|
769
|
+
upcoming dates, timeline for start/end pairs, map for geo, a dashboard at `/`, analytics for
|
|
770
|
+
amounts, a tabbed detail and settings. `protoyard generate` emits lazy route wrappers for
|
|
771
|
+
declared screens; wrappers read the matching config from the runtime blueprint.
|
|
772
|
+
- **Faces:** people-like entities (`PEOPLE_ENTITIES`: customer, contact, lead, client,
|
|
773
|
+
patient, student, member, driver, agent, volunteer, follower and similar) get an optional
|
|
774
|
+
`avatar` field labeled Photo (named `photo` when `avatar` is taken).
|
|
775
|
+
- Always the roles `owner`, `admin`, `member`, `viewer`; a brand seed picked by name hash from
|
|
776
|
+
the catalog's AA-checked seeds; `layout` is `mobile-tabs` for phone briefs, else `sidebar`.
|
|
777
|
+
This is a suggested layout in the `blueprintFromBrief()` result and can be included as the
|
|
778
|
+
blueprint's top-level `{ "id": "mobile-tabs" }` layout configuration.
|
|
779
|
+
|
|
780
|
+
It is a heuristic starting point: read the `rationale`, then refine `blueprint.json`.
|
|
781
|
+
|
|
782
|
+
### Kind detection
|
|
783
|
+
|
|
784
|
+
`detectBriefKind(brief, { kind? })` in `src/brief/kind-detection.ts` decides whether a brief asks
|
|
785
|
+
for an `app`, a `deck`, a `page` or a `doc` (ADR-036). It runs before archetype scoring. It is
|
|
786
|
+
deterministic and offline, and it matches whole words, longest phrase first. It returns
|
|
787
|
+
`{ kind, confidence, ambiguous, reason, cues, suggestion }`. `reason` is one sentence a CLI or UI
|
|
788
|
+
can show as is.
|
|
789
|
+
|
|
790
|
+
- **Strong cues** switch the kind: `slides`, `slide deck`, `pitch deck`, `keynote`,
|
|
791
|
+
`presentation` (deck); `landing page`, `marketing site`, `pricing page`, `microsite` (page);
|
|
792
|
+
`a report`, `quarterly report`, `one-pager`, `whitepaper`, `handbook`, `design doc` (doc). The
|
|
793
|
+
full lists are `KIND_CUES`.
|
|
794
|
+
- **Weak cues** (`talk`, `website`, a bare `report`, which is often a verb) only fill
|
|
795
|
+
`suggestion`. The kind stays `app`.
|
|
796
|
+
- **App fallback.** The kind stays `app`, with `confidence: 'fallback'` and a reason, in four
|
|
797
|
+
cases: there is no cue; a cue names a tool that makes the content (`slide deck builder`,
|
|
798
|
+
`report generator`); an app noun from `APP_CUES` (`app`, `dashboard`, `platform`, `wiki`…)
|
|
799
|
+
comes before the first strong cue; or two content kinds both have strong cues. The last two
|
|
800
|
+
cases set `ambiguous: true`.
|
|
801
|
+
- An explicit `kind` overrides every cue (`confidence: 'explicit'`). An unknown explicit kind
|
|
802
|
+
throws a `RangeError`.
|
|
803
|
+
|
|
804
|
+
`detectBriefKind` and the content synthesizers (`synthesizeDeckFromBrief`,
|
|
805
|
+
`synthesizePageFromBrief`, `synthesizeDocFromBrief`) are exported from `@protoyard/blueprint`.
|
|
806
|
+
Page/doc section templates live in `buildPageOutline` / `buildDocOutline`
|
|
807
|
+
(`packages/blueprint/src/brief/page-doc-outlines.ts`): ordered sections with draft blocks
|
|
808
|
+
(heading, text, list, cta, …) and rationale lines for omitted interactive features (forms,
|
|
809
|
+
payments, auth). No app entities or roles.
|
|
810
|
+
`blueprintFromBrief` returns `AppBriefResult | ContentBriefResult`; narrow with
|
|
811
|
+
`isAppBriefResult` / `isContentBriefResult`. Content drafts carry `kind`, `outline`, `json` and
|
|
812
|
+
`rationale` (no app `entities` / `roles`); engine body schemas validate them once wired.
|
|
813
|
+
|
|
814
|
+
### Deck outline templates
|
|
815
|
+
|
|
816
|
+
`src/brief/deck-outlines.ts` has eight deterministic deck outlines: pitch, QBR, launch, status,
|
|
817
|
+
training, lesson, report and talk (`DECK_OUTLINE_IDS`).
|
|
818
|
+
|
|
819
|
+
`synthesizeDeckOutline(brief, { outline?, slides?, title? })` returns:
|
|
820
|
+
|
|
821
|
+
- `deck`: a deck body (`{ title, aspect, slides }`) of free slides
|
|
822
|
+
(`{ id, title, layout: "free", blocks, notes }`) built from `@protoyard/blocks` blocks;
|
|
823
|
+
- `plan`: one `{ id, role, pacing, purpose, core }` per slide, read by layout selection;
|
|
824
|
+
- `rationale`: why this outline and size were chosen.
|
|
825
|
+
|
|
826
|
+
- **Choosing a template.** `chooseDeckOutline` matches whole-word cues, and multi-word cues
|
|
827
|
+
count more ("status update" beats "update", "pitch deck" beats "deck"). With no cue it uses
|
|
828
|
+
the general talk outline and says so. An explicit `outline` wins.
|
|
829
|
+
- **Size.** `requestedSlideCount` reads "12 slides", "a ten-slide pitch" or "a 15-minute talk"
|
|
830
|
+
(one slide a minute); `slides` overrides it.
|
|
831
|
+
- Each template has core slides, which are always present, and optional slides, which fill
|
|
832
|
+
a larger count in template order (`deckOutlineRange`). Templates range from 6 to 12
|
|
833
|
+
slides (pitch 7–12, QBR 7–11, launch 6–9, status 6–8, training 8–11, lesson 6–9, report
|
|
834
|
+
6–10, talk 6–11), under a cap of 16.
|
|
835
|
+
- A count outside the range is clamped, and the rationale says so.
|
|
836
|
+
- Slide ids (`slide-<key>`) are stable at every size.
|
|
837
|
+
- **Pacing.** Every slide is tagged punch (one line, about 8 s), idea (one concept, about 25 s),
|
|
838
|
+
diagram (a structure to walk through) or story (a sequence). Speaker notes repeat the purpose
|
|
839
|
+
and the pacing guidance.
|
|
840
|
+
- **Honest scaffolding.** Every slide body is a placeholder marked "Draft:" that says what to
|
|
841
|
+
write. Metrics show "—", charts are a note asking for real data, people are "Person 1…", and
|
|
842
|
+
nothing links anywhere. No facts, numbers, quotes or names are invented.
|
|
843
|
+
- Lessons and training always get a placeholder quiz. A warm-up poll (lessons) or an
|
|
844
|
+
audience poll (talks) is optional and appears when the slide count leaves room.
|
|
845
|
+
- The deck title is the brief's topic (after about/on/for), with control and bidi
|
|
846
|
+
characters stripped, at most 80 characters.
|
|
847
|
+
- **Deterministic.** The same brief and options always give the same slide ids, order, blocks
|
|
848
|
+
and rationale. The module never reads the clock or a random source.
|
|
849
|
+
|
|
850
|
+
`synthesizeDeckFromBrief` calls `synthesizeDeckOutline`, then `selectDeckLayouts` (see below),
|
|
851
|
+
and exports the outline helpers from `@protoyard/blueprint` / `./brief`.
|
|
852
|
+
|
|
853
|
+
### Deck layout selection
|
|
854
|
+
|
|
855
|
+
`src/authoring/select-deck-layouts.ts` maps each slide's `role`, `pacing` and optional
|
|
856
|
+
`blockTypes` to a catalog layout id from `@protoyard/decks/layouts`. It is pure (no clock or
|
|
857
|
+
random source):
|
|
858
|
+
|
|
859
|
+
```ts
|
|
860
|
+
import { selectDeckLayouts, synthesizeDeckOutline } from '@protoyard/blueprint';
|
|
861
|
+
|
|
862
|
+
const outline = synthesizeDeckOutline('A pitch deck about Example Harbor.');
|
|
863
|
+
const layouts = selectDeckLayouts(
|
|
864
|
+
outline.plan.map((slide, i) => ({
|
|
865
|
+
id: slide.id,
|
|
866
|
+
role: slide.role,
|
|
867
|
+
pacing: slide.pacing,
|
|
868
|
+
blockTypes: outline.deck.slides[i]?.blocks.map((b) => b.type),
|
|
869
|
+
})),
|
|
870
|
+
{ aspect: outline.deck.aspect }
|
|
871
|
+
);
|
|
872
|
+
// layouts[i].layout — e.g. title-hero, process-steps, closing-cta
|
|
873
|
+
// layouts[i].rationale — stable explanation for agents
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
Rules: explicit `layout` overrides win; block types (chart, steps, …) rank above a generic
|
|
877
|
+
role; pacing must fit the catalog entry when possible; three identical layouts in a row are
|
|
878
|
+
avoided when another compatible candidate exists. `propsForSelectedLayout` seeds catalog
|
|
879
|
+
`props` from the layout sample and slide title when a free outline slide is rewritten.
|
|
880
|
+
|
|
881
|
+
### Authoring diagnostics (JSON path + hint)
|
|
882
|
+
|
|
883
|
+
`validateBlueprintForAuthoring(input)` is the primary machine interface for agents editing
|
|
884
|
+
blueprint JSON. It never fetches remote assets and never renders markdown as HTML.
|
|
885
|
+
|
|
886
|
+
| Surface | API | Notes |
|
|
887
|
+
| --- | --- | --- |
|
|
888
|
+
| Full blueprint (any kind) | `validateBlueprintForAuthoring` | `code`, `path`, `jsonPath`, `message`, `hint` |
|
|
889
|
+
| Block list under a path | `diagnosticsFromBlockIssues(issues, pathPrefix)` | Lifts `@protoyard/blocks` parse issues |
|
|
890
|
+
| Deck body structure | `lintDeckStructure` | Slide ids, layouts, capacities, fragments, outline links |
|
|
891
|
+
| Deck asset references | `lintDeckAssets` | URL syntax and protocol allowlist; never fetches |
|
|
892
|
+
|
|
893
|
+
Stable block/asset codes include `unknown-block-type`, `invalid-chart-spec`,
|
|
894
|
+
`missing-alt-text`, `unsupported-url-scheme`, `invalid-step-or-fragment-target` and
|
|
895
|
+
`duplicate-block-id`. App codes still cover `unknown-entity`, `unknown-field`,
|
|
896
|
+
`invalid-reference`, and so on. After each repair, re-run validation until `valid: true`.
|
|
897
|
+
|
|
898
|
+
```ts
|
|
899
|
+
import {
|
|
900
|
+
validateBlueprintForAuthoring,
|
|
901
|
+
lintDeckStructure,
|
|
902
|
+
diagnosticsFromBlockIssues,
|
|
903
|
+
} from '@protoyard/blueprint';
|
|
904
|
+
import { safeParseBlocks } from '@protoyard/blocks';
|
|
905
|
+
|
|
906
|
+
const authoring = validateBlueprintForAuthoring(blueprintJson);
|
|
907
|
+
if (!authoring.valid) {
|
|
908
|
+
for (const d of authoring.diagnostics) {
|
|
909
|
+
console.log(d.jsonPath, d.code, d.hint);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
if (blueprintJson.kind === 'deck' && blueprintJson.deck) {
|
|
914
|
+
const lint = lintDeckStructure(blueprintJson.deck);
|
|
915
|
+
// …
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const blocks = safeParseBlocks(someBlocks);
|
|
919
|
+
if (!blocks.success) {
|
|
920
|
+
const lifted = diagnosticsFromBlockIssues(blocks.issues, ['deck', 'slides', 0, 'blocks']);
|
|
921
|
+
}
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
## Authoring exports
|
|
925
|
+
|
|
926
|
+
Authoring APIs ship from the package root (`@protoyard/blueprint`); there is no separate
|
|
927
|
+
subpath. They are plain named exports of a `sideEffects: false` package, so a prototype that
|
|
928
|
+
only parses or generates never bundles them:
|
|
929
|
+
|
|
930
|
+
| Need | Exports |
|
|
931
|
+
| --- | --- |
|
|
932
|
+
| Brief to blueprint, by kind | `blueprintFromBrief`, `synthesizeAppFromBrief`, `synthesizeContentFromBrief`, `synthesizeDeckFromBrief`, `synthesizePageFromBrief`, `synthesizeDocFromBrief`, `isAppBriefResult`, `isContentBriefResult` |
|
|
933
|
+
| Kind detection | `detectBriefKind`, `KIND_CUES`, `APP_CUES`; blueprint kinds: `detectBlueprintKind`, `BLUEPRINT_KINDS`, `CONTENT_KINDS` |
|
|
934
|
+
| Outlines and bounds | `synthesizeDeckOutline`, `chooseDeckOutline`, `DECK_OUTLINES`, `buildPageOutline`, `buildDocOutline`, `selectPageOutline`, `selectDocOutline`, `boundBriefInput`, `prepareBriefForSynthesis`, `checkOutlineBounds`, `applyOutlineBounds`, `OUTLINE_LIMITS` |
|
|
935
|
+
| Deck layouts and lint | `selectDeckLayouts`, `propsForSelectedLayout`, `lintDeckStructure`, `lintDeckContent`, `lintDeckAssets`; catalog types `DeckLayoutEntry`, `DeckLayoutId`, `LayoutPacing` (type-only) |
|
|
936
|
+
| Validation | `validateBlueprintForAuthoring`, `parseAnyBlueprint`, `safeParseAnyBlueprint` |
|
|
937
|
+
| Entity forms on `@texuli/forms` | `entityFormModel` (a one-page Texuli `FormSchema` as plain data, with value conversion both ways), `HOSTED_FORM_KINDS` (relation, datetime, geo, avatar, file: painted by Protoyard controls), `UNASSIGNED_OPTION` |
|
|
938
|
+
| Streamed blueprints | `paintStreamedBlueprint`, `truncatedJsonCandidates`, `MAX_STREAM_CANDIDATES`: the valid part of a truncated deck, page or doc, checked like a complete one (`quick serve` drafts) |
|
|
939
|
+
| Lazy, for browsers | `loadAuthoringApis()` |
|
|
940
|
+
|
|
941
|
+
`loadAuthoringApis()` resolves to brief synthesis, kind detection, outlines, deck lint, layout
|
|
942
|
+
selection, authoring validation and the deck layout catalog (`deckLayouts`, `getDeckLayout`,
|
|
943
|
+
`describeDeckLayouts`). It loads them as one chunk, so a browser tool can parse blueprints first
|
|
944
|
+
and fetch authoring only when the author opens it. The functions are the same ones the root
|
|
945
|
+
exports. Server tools (CLI, MCP) can simply import the named exports.
|
|
946
|
+
|
|
947
|
+
Measured on 2026-10-22 with a real Vite build of the linked package (minified, gzip level 9; asserted in
|
|
948
|
+
`tests/authoring-exports.test.ts`):
|
|
949
|
+
|
|
950
|
+
| Entry imports | Initial JS |
|
|
951
|
+
| --- | --- |
|
|
952
|
+
| `parseBlueprint` only (a generated app) | 39.3 KB |
|
|
953
|
+
| `parseAnyBlueprint` (the kind union, as the player uses it) | 41.6 KB |
|
|
954
|
+
| The kind union plus `loadAuthoringApis` | 42.3 KB (+0.6 KB); authoring loads lazily |
|
|
955
|
+
| Static authoring imports (`blueprintFromBrief`, `detectBriefKind`, `lintDeckStructure`, `selectDeckLayouts`, `validateBlueprintForAuthoring`) | 83.0 KB (+41.3 KB) |
|
|
956
|
+
|
|
957
|
+
The test also checks that the app-only and kind-union bundles contain no authoring module and
|
|
958
|
+
no deck layout catalog.
|
|
959
|
+
|
|
960
|
+
## Workflow state models
|
|
961
|
+
|
|
962
|
+
`@protoyard/blueprint/workflows` validates a workflow separately from the formatVersion 1
|
|
963
|
+
blueprint schema. This keeps the first state model opt-in while the serialized blueprint versioning
|
|
964
|
+
and migration path are decided. A workflow binds to an existing enum field; its state ids must be
|
|
965
|
+
that enum's values. Transitions name explicit endpoints and can add plain equality guards and a
|
|
966
|
+
role/capability authorization reference:
|
|
967
|
+
|
|
968
|
+
```ts
|
|
969
|
+
import {
|
|
970
|
+
checkWorkflowTransition,
|
|
971
|
+
validateWorkflowDefinition,
|
|
972
|
+
} from '@protoyard/blueprint/workflows';
|
|
973
|
+
|
|
974
|
+
const validation = validateWorkflowDefinition(workflowData, parsedBlueprint);
|
|
975
|
+
if (validation.valid) {
|
|
976
|
+
const decision = checkWorkflowTransition(validation.workflow, 'open', 'resolved', {
|
|
977
|
+
role: 'agent',
|
|
978
|
+
capabilities: ['ticket:update'],
|
|
979
|
+
record: { priority: 1 },
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
```
|
|
983
|
+
|
|
984
|
+
Validation checks the initial state, enum values, transition endpoints, guard fields and types,
|
|
985
|
+
role references, and that each referenced role has the declared capability. The transition check
|
|
986
|
+
is a deterministic, pure decision helper; it does not authorize a database write. Services must
|
|
987
|
+
still enforce their normal session policy. Workflows are never JavaScript and are not yet embedded
|
|
988
|
+
in blueprint formatVersion 1 or generated services.
|
|
989
|
+
|
|
990
|
+
Measured workflow subpath: 4.2 KB minified (1.76 KB gzip) with zod external, using esbuild on
|
|
991
|
+
`src/workflows/index.ts`. Consumers that do not import `@protoyard/blueprint/workflows` do not load
|
|
992
|
+
the transition engine.
|
|
993
|
+
|
|
994
|
+
## Computed expressions
|
|
995
|
+
|
|
996
|
+
Computed values use a small JSON AST and are evaluated at read time. They are never stored in a
|
|
997
|
+
row, generated as JavaScript or included in storage migrations. Import the opt-in module only
|
|
998
|
+
where the consumer needs computed values. The `./computed` package subpath is opt-in:
|
|
999
|
+
|
|
1000
|
+
```ts
|
|
1001
|
+
import {
|
|
1002
|
+
evaluateComputedExpression,
|
|
1003
|
+
validateComputedExpression,
|
|
1004
|
+
} from '@protoyard/blueprint/computed';
|
|
1005
|
+
|
|
1006
|
+
const expression = {
|
|
1007
|
+
op: 'dateDiff',
|
|
1008
|
+
start: { op: 'field', field: 'startedAt' },
|
|
1009
|
+
end: { op: 'field', field: 'dueAt' },
|
|
1010
|
+
unit: 'days',
|
|
1011
|
+
};
|
|
1012
|
+
const check = validateComputedExpression(expression, { sourceEntity, entities });
|
|
1013
|
+
const result = evaluateComputedExpression(expression, {
|
|
1014
|
+
sourceEntity, entities, row, relations,
|
|
1015
|
+
});
|
|
1016
|
+
```
|
|
1017
|
+
|
|
1018
|
+
The grammar allows scalar literals, scalar field references, numeric `add` / `subtract` /
|
|
1019
|
+
`multiply` / `divide`, string `concat`, date `dateDiff` and relation `aggregate` (`count`,
|
|
1020
|
+
`sum`, `avg`, `min`, `max`). Arithmetic accepts number, integer and money fields; `concat`
|
|
1021
|
+
accepts text-like fields; date differences accept date/datetime fields; aggregates validate the
|
|
1022
|
+
relation target and the aggregate field kind. `validateComputedExpression` reports the inferred
|
|
1023
|
+
result type and whether it may be null. Missing field values, empty non-count aggregates,
|
|
1024
|
+
invalid runtime values and division by zero return `null`; count over an absent relation is zero.
|
|
1025
|
+
Dates are input as ISO strings or epoch milliseconds, and differences use the requested unit.
|
|
1026
|
+
|
|
1027
|
+
`safeParseComputedExpression` and `parseComputedExpression` enforce strict object shapes, a
|
|
1028
|
+
maximum depth of 32 and at most 256 object/array nodes before recursive parsing. The evaluator
|
|
1029
|
+
walks only these fixed operators; strings remain literal data and cannot invoke code. This is a
|
|
1030
|
+
standalone authoring/runtime primitive: the blueprint storage schema and generated prototypes do
|
|
1031
|
+
not yet declare computed fields.
|
|
1032
|
+
|
|
1033
|
+
## Aggregates
|
|
1034
|
+
|
|
1035
|
+
`aggregate(rows, spec, entity?)` computes one aggregate in a single pass;
|
|
1036
|
+
`createAggregateQuery(services, { entity, ...spec })` returns a `liveQuery` querier that reads
|
|
1037
|
+
through the entity service, so `session.assert('<entity>:read')` applies, and resolves to
|
|
1038
|
+
`null` when the persona cannot read (like the generated Home counts).
|
|
1039
|
+
|
|
1040
|
+
```ts
|
|
1041
|
+
import { createAggregateQuery } from '@protoyard/blueprint';
|
|
1042
|
+
|
|
1043
|
+
const revenue = liveQuery(
|
|
1044
|
+
createAggregateQuery(services, {
|
|
1045
|
+
entity: 'invoice',
|
|
1046
|
+
aggregate: 'sum',
|
|
1047
|
+
field: 'amount',
|
|
1048
|
+
groupBy: 'status',
|
|
1049
|
+
bucket: { field: 'createdAt', unit: 'week' },
|
|
1050
|
+
filter: { field: 'status', op: 'in', value: ['paid', 'sent'] },
|
|
1051
|
+
}),
|
|
1052
|
+
{ initial: null }
|
|
1053
|
+
);
|
|
1054
|
+
// revenue.current?.series → [{ key: '2026-05-04T00:00:00.000Z|paid', bucket, group, count, value }, …]
|
|
1055
|
+
```
|
|
1056
|
+
|
|
1057
|
+
- `aggregate`: `count` (default), `sum`, `avg`, `min`, `max`. Money sums stay integer cents;
|
|
1058
|
+
money and integer averages round to integers; `min`/`max` over dates return epoch ms
|
|
1059
|
+
(`valueKind` says `money`, `date` or `number`). `avg`/`min`/`max` over no values are `null`.
|
|
1060
|
+
- `groupBy`: relations and personas group by id, tags and many-relations count once per
|
|
1061
|
+
item, booleans as `'true'`/`'false'`. Enum groups keep the declared order and every value
|
|
1062
|
+
appears, even at zero; rows without a value go to `NO_GROUP`, last.
|
|
1063
|
+
- `bucket`: UTC days, weeks starting Monday, months starting on the 1st, over a date or
|
|
1064
|
+
datetime field or `createdAt`/`updatedAt`. Buckets are contiguous between the data's first
|
|
1065
|
+
and last bucket, or over `range: { from, to }`; more than `MAX_BUCKETS` (5,000) throws.
|
|
1066
|
+
- `filter`: `eq`, `in`, `gte`, `lte`; dates compare as instants, array fields match any item.
|
|
1067
|
+
- Soft-deleted rows are always skipped. Keys (`<bucket|*>|<group|*>`) are stable.
|
|
1068
|
+
|
|
1069
|
+
Measured: 10,000 rows grouped by status and bucketed by day in 10.7 ms in Vitest while the
|
|
1070
|
+
machine was under heavy load (`pnpm vitest run packages/blueprint/tests/aggregate.test.ts`).
|
|
1071
|
+
|
|
1072
|
+
## App model
|
|
1073
|
+
|
|
1074
|
+
`deriveAppModel(blueprint)` returns the app a blueprint describes as plain data
|
|
1075
|
+
([ADR-029](../../docs/DECISIONS.md)), with no Svelte or Lucide imports:
|
|
1076
|
+
|
|
1077
|
+
- `routes`, in router order. Each has a `name`, `path`, `title` and `description`,
|
|
1078
|
+
plus a `view` that says what it renders:
|
|
1079
|
+
- `home`: the overview with a count per entity;
|
|
1080
|
+
- `members`: workspace people and their simulated roles;
|
|
1081
|
+
- `entity`: the entity page (list, create, edit), with the `screenIndex` and
|
|
1082
|
+
`screenType` of the `list`, `form` or tab-less `detail` screen that claims it;
|
|
1083
|
+
- `screen`: a screen with its own view (board, calendar, timeline, map,
|
|
1084
|
+
analytics, dashboard, settings, tabbed detail), with its `screenIndex`,
|
|
1085
|
+
`screenType` and a unique `component` id such as `Dashboard1Screen`.
|
|
1086
|
+
- `nav`: sidebar rows. Home comes first (`exact: true`), then entities without a
|
|
1087
|
+
list screen, then visible screens by `nav.order` and blueprint order, then
|
|
1088
|
+
Members. Icons are `SCREEN_NAV_ICONS` ids.
|
|
1089
|
+
- `navGroups`: Overview, Records, Views and Workspace, with empty groups dropped.
|
|
1090
|
+
- `layout`: `{ id, mobile? }`, `sidebar` by default.
|
|
1091
|
+
- `icons`: every icon id the navigation may reference.
|
|
1092
|
+
|
|
1093
|
+
A later route on the same path replaces an earlier one in place, so a screen can
|
|
1094
|
+
claim an entity's default list or detail route. `screenNavIcon`, `screenHasOwnView`
|
|
1095
|
+
and `entityLabel` expose the per-screen rules. Generated prototypes and the player
|
|
1096
|
+
both render this model through `BlueprintApp` (`@protoyard/crud/app`), so the
|
|
1097
|
+
generator emits no route or layout files. `tests/app-model.test.ts` pins the model of
|
|
1098
|
+
every catalog blueprint in `tests/__snapshots__/app-model.catalog.json`. The snapshot
|
|
1099
|
+
was taken when the model was proven byte-identical to the generator's former
|
|
1100
|
+
`routes.ts` and `layout.ts`, so a change is always a reviewed diff.
|
|
1101
|
+
|
|
1102
|
+
`blueprintGeneratorVersion(blueprint)` (synchronous, no Faker) is the world
|
|
1103
|
+
generator's version. Generated prototypes declare it as `generatorVersion`, and so
|
|
1104
|
+
does the runtime definition, so a stored world reseeds when the blueprint's
|
|
1105
|
+
generative surface or the generator SDK changes.
|
|
1106
|
+
|
|
1107
|
+
## Runtime definition
|
|
1108
|
+
|
|
1109
|
+
`definePrototypeFromBlueprint(blueprint, { namespace, slug?, version? })` builds in
|
|
1110
|
+
memory what `protoyard generate` writes as files ([ADR-029](../../docs/DECISIONS.md)),
|
|
1111
|
+
so the player can run a blueprint with no build. It returns
|
|
1112
|
+
`{ definition, appModel }`; the definition passes `definePrototype`:
|
|
1113
|
+
|
|
1114
|
+
- `manifest`: `id` `local.<slug>`, the slug (default: the namespace), the blueprint's
|
|
1115
|
+
name, description, category, tags and icon, `defaultScenario: "active"`,
|
|
1116
|
+
`defaultTheme` from `brandDefaultThemeId` (`violet` without a brand),
|
|
1117
|
+
`preview.layout.id` from the app model, `storage.schemaVersion` from
|
|
1118
|
+
`latestSchemaVersion`, and the scenario ids;
|
|
1119
|
+
- `database.versions` from `deriveDatabaseVersions`, `policy`, `integrity` and the
|
|
1120
|
+
default `blueprintScenarios`;
|
|
1121
|
+
- `themes`: the theme a seed or preset brand builds (a library brand ships by id);
|
|
1122
|
+
- `generate` loads `./generator` on first use, and `generatorVersion` is
|
|
1123
|
+
`blueprintGeneratorVersion`.
|
|
1124
|
+
|
|
1125
|
+
`brandDefaultThemeId` is the same function codegen uses for `protoyard.json`.
|
|
1126
|
+
Measured with Vite 8 (minified, gzip -9) on an entry that imports
|
|
1127
|
+
`definePrototypeFromBlueprint` from the package entry: about 20.0 KB entry plus a
|
|
1128
|
+
40.5 KB shared chunk at start, and the generator as a separate 5.5 KB lazy chunk.
|
|
1129
|
+
`tests/runtime-definition.test.ts` boots the definition on the real runtime
|
|
1130
|
+
(fake IndexedDB): it seeds every default scenario, keeps created rows across a
|
|
1131
|
+
reload, and seeds the same world twice for the same seed.
|
|
1132
|
+
|
|
1133
|
+
## Tests
|
|
1134
|
+
|
|
1135
|
+
Services run on the real runtime with the help desk fixture in
|
|
1136
|
+
`tests/fixtures/blueprint-prototype.ts`, a prototype whose stores, policy and integrity are
|
|
1137
|
+
all derived from its blueprint.
|
|
1138
|
+
|
|
1139
|
+
```bash
|
|
1140
|
+
pnpm vitest run packages/blueprint
|
|
1141
|
+
pnpm vitest run packages/blueprint/tests/authoring-docs.test.ts
|
|
1142
|
+
pnpm vitest run packages/blueprint/tests/brief-kinds.test.ts
|
|
1143
|
+
pnpm vitest run packages/blueprint/tests/deck-lint.test.ts
|
|
1144
|
+
pnpm vitest run packages/blueprint/tests/select-deck-layouts.test.ts
|
|
1145
|
+
pnpm --filter @protoyard/blueprint typecheck
|
|
1146
|
+
```
|
|
1147
|
+
|
|
1148
|
+
## Package output
|
|
1149
|
+
|
|
1150
|
+
The manifest keeps TypeScript source exports for workspace development. `pnpm build:packages --filter engine` creates the ESM and declaration files under `dist/` that the packed manifest exposes.
|