@protoyard/blueprint 0.0.0-stage → 1.1.1

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.
Files changed (168) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1145 -2
  3. package/dist/aggregate.d.ts +83 -0
  4. package/dist/aggregate.js +268 -0
  5. package/dist/app-model.d.ts +76 -0
  6. package/dist/app-model.js +287 -0
  7. package/dist/archetypes/index.d.ts +1 -0
  8. package/dist/archetypes/index.js +1 -0
  9. package/dist/archetypes/schema.d.ts +126 -0
  10. package/dist/archetypes/schema.js +77 -0
  11. package/dist/audit.d.ts +52 -0
  12. package/dist/audit.js +149 -0
  13. package/dist/authoring/deck-lint.d.ts +32 -0
  14. package/dist/authoring/deck-lint.js +567 -0
  15. package/dist/authoring/index.d.ts +1 -0
  16. package/dist/authoring/index.js +1 -0
  17. package/dist/authoring/select-deck-layouts.d.ts +50 -0
  18. package/dist/authoring/select-deck-layouts.js +213 -0
  19. package/dist/authoring-apis.d.ts +13 -0
  20. package/dist/authoring-apis.js +13 -0
  21. package/dist/authoring-validation.d.ts +65 -0
  22. package/dist/authoring-validation.js +267 -0
  23. package/dist/avatar.d.ts +25 -0
  24. package/dist/avatar.js +30 -0
  25. package/dist/brand.d.ts +70 -0
  26. package/dist/brand.js +294 -0
  27. package/dist/brief/archetypes.d.ts +28 -0
  28. package/dist/brief/archetypes.js +798 -0
  29. package/dist/brief/content-synthesize.d.ts +31 -0
  30. package/dist/brief/content-synthesize.js +210 -0
  31. package/dist/brief/deck-outlines.d.ts +96 -0
  32. package/dist/brief/deck-outlines.js +561 -0
  33. package/dist/brief/draft-bodies.d.ts +29 -0
  34. package/dist/brief/draft-bodies.js +224 -0
  35. package/dist/brief/fields.d.ts +19 -0
  36. package/dist/brief/fields.js +229 -0
  37. package/dist/brief/index.d.ts +8 -0
  38. package/dist/brief/index.js +8 -0
  39. package/dist/brief/kind-detection.d.ts +69 -0
  40. package/dist/brief/kind-detection.js +353 -0
  41. package/dist/brief/limits.d.ts +117 -0
  42. package/dist/brief/limits.js +217 -0
  43. package/dist/brief/page-doc-outlines.d.ts +80 -0
  44. package/dist/brief/page-doc-outlines.js +485 -0
  45. package/dist/brief/synthesize.d.ts +59 -0
  46. package/dist/brief/synthesize.js +511 -0
  47. package/dist/brief/text.d.ts +13 -0
  48. package/dist/brief/text.js +59 -0
  49. package/dist/canvas-screen.d.ts +31 -0
  50. package/dist/canvas-screen.js +33 -0
  51. package/dist/canvas-seed.d.ts +58 -0
  52. package/dist/canvas-seed.js +82 -0
  53. package/dist/computed/evaluate.d.ts +11 -0
  54. package/dist/computed/evaluate.js +101 -0
  55. package/dist/computed/index.d.ts +4 -0
  56. package/dist/computed/index.js +3 -0
  57. package/dist/computed/public.d.ts +14 -0
  58. package/dist/computed/public.js +19 -0
  59. package/dist/computed/schema.d.ts +10 -0
  60. package/dist/computed/schema.js +140 -0
  61. package/dist/computed/types.d.ts +70 -0
  62. package/dist/computed/types.js +1 -0
  63. package/dist/computed/validate.d.ts +6 -0
  64. package/dist/computed/validate.js +146 -0
  65. package/dist/csv-guard.d.ts +15 -0
  66. package/dist/csv-guard.js +29 -0
  67. package/dist/csv.d.ts +85 -0
  68. package/dist/csv.js +288 -0
  69. package/dist/files.d.ts +35 -0
  70. package/dist/files.js +246 -0
  71. package/dist/form-schema.d.ts +124 -0
  72. package/dist/form-schema.js +300 -0
  73. package/dist/generator-version.d.ts +11 -0
  74. package/dist/generator-version.js +51 -0
  75. package/dist/generator.d.ts +34 -0
  76. package/dist/generator.js +445 -0
  77. package/dist/identity.d.ts +34 -0
  78. package/dist/identity.js +91 -0
  79. package/dist/import/csv-shape.d.ts +40 -0
  80. package/dist/import/csv-shape.js +265 -0
  81. package/dist/import/json-shape.d.ts +55 -0
  82. package/dist/import/json-shape.js +491 -0
  83. package/dist/import/limits.d.ts +13 -0
  84. package/dist/import/limits.js +14 -0
  85. package/dist/import/sqlite-schema.d.ts +36 -0
  86. package/dist/import/sqlite-schema.js +729 -0
  87. package/dist/index.d.ts +60 -0
  88. package/dist/index.js +58 -0
  89. package/dist/kinds/anchors.d.ts +18 -0
  90. package/dist/kinds/anchors.js +46 -0
  91. package/dist/kinds/bodies.d.ts +6 -0
  92. package/dist/kinds/bodies.js +32 -0
  93. package/dist/kinds/brand-runtime.d.ts +19 -0
  94. package/dist/kinds/brand-runtime.js +35 -0
  95. package/dist/kinds/deck.d.ts +8 -0
  96. package/dist/kinds/deck.js +34 -0
  97. package/dist/kinds/doc.d.ts +19 -0
  98. package/dist/kinds/doc.js +41 -0
  99. package/dist/kinds/embed/index.d.ts +39 -0
  100. package/dist/kinds/embed/index.js +91 -0
  101. package/dist/kinds/envelope.d.ts +159 -0
  102. package/dist/kinds/envelope.js +78 -0
  103. package/dist/kinds/index.d.ts +11 -0
  104. package/dist/kinds/index.js +11 -0
  105. package/dist/kinds/kind.d.ts +36 -0
  106. package/dist/kinds/kind.js +55 -0
  107. package/dist/kinds/limits.d.ts +64 -0
  108. package/dist/kinds/limits.js +190 -0
  109. package/dist/kinds/page-doc-runtime.d.ts +37 -0
  110. package/dist/kinds/page-doc-runtime.js +55 -0
  111. package/dist/kinds/page.d.ts +16 -0
  112. package/dist/kinds/page.js +67 -0
  113. package/dist/kinds/parse.d.ts +18 -0
  114. package/dist/kinds/parse.js +27 -0
  115. package/dist/kinds/runtime.d.ts +128 -0
  116. package/dist/kinds/runtime.js +206 -0
  117. package/dist/kinds/services.d.ts +49 -0
  118. package/dist/kinds/services.js +249 -0
  119. package/dist/kinds/testing.d.ts +9 -0
  120. package/dist/kinds/testing.js +16 -0
  121. package/dist/migrations.d.ts +97 -0
  122. package/dist/migrations.js +351 -0
  123. package/dist/motion.d.ts +50 -0
  124. package/dist/motion.js +52 -0
  125. package/dist/notifications.d.ts +19 -0
  126. package/dist/notifications.js +177 -0
  127. package/dist/patch.d.ts +122 -0
  128. package/dist/patch.js +322 -0
  129. package/dist/policy.d.ts +23 -0
  130. package/dist/policy.js +105 -0
  131. package/dist/runtime-definition.d.ts +39 -0
  132. package/dist/runtime-definition.js +74 -0
  133. package/dist/scenarios.d.ts +9 -0
  134. package/dist/scenarios.js +49 -0
  135. package/dist/schema/blueprint.schema.json +10191 -0
  136. package/dist/schema-json.d.ts +6 -0
  137. package/dist/schema-json.js +36 -0
  138. package/dist/schema.d.ts +6348 -0
  139. package/dist/schema.js +1269 -0
  140. package/dist/search.d.ts +16 -0
  141. package/dist/search.js +96 -0
  142. package/dist/services.d.ts +89 -0
  143. package/dist/services.js +898 -0
  144. package/dist/share.d.ts +78 -0
  145. package/dist/share.js +234 -0
  146. package/dist/sheet-screen.d.ts +49 -0
  147. package/dist/sheet-screen.js +91 -0
  148. package/dist/storage.d.ts +43 -0
  149. package/dist/storage.js +179 -0
  150. package/dist/stream.d.ts +26 -0
  151. package/dist/stream.js +173 -0
  152. package/dist/untrusted.d.ts +63 -0
  153. package/dist/untrusted.js +502 -0
  154. package/dist/validation-tasks.d.ts +178 -0
  155. package/dist/validation-tasks.js +199 -0
  156. package/dist/validation.d.ts +35 -0
  157. package/dist/validation.js +312 -0
  158. package/dist/variants.d.ts +112 -0
  159. package/dist/variants.js +283 -0
  160. package/dist/workflows/index.d.ts +3 -0
  161. package/dist/workflows/index.js +3 -0
  162. package/dist/workflows/schema.d.ts +50 -0
  163. package/dist/workflows/schema.js +34 -0
  164. package/dist/workflows/transition.d.ts +15 -0
  165. package/dist/workflows/transition.js +25 -0
  166. package/dist/workflows/validate.d.ts +17 -0
  167. package/dist/workflows/validate.js +140 -0
  168. package/package.json +120 -3
@@ -0,0 +1,351 @@
1
+ import { entityTable, parseBlueprint, } from "./schema.js";
2
+ import { deriveStores, relationProperty } from "./storage.js";
3
+ export class MigrationError extends Error {
4
+ code;
5
+ constructor(code, message) {
6
+ super(message);
7
+ this.name = 'MigrationError';
8
+ this.code = code;
9
+ }
10
+ }
11
+ /** Row property stored for a field (`customerId`, tags multi-entry → `tags`, …). */
12
+ export function fieldProperty(field) {
13
+ return relationProperty(field) ?? field.name;
14
+ }
15
+ /**
16
+ * Default written when a field is added to an existing table. Prefers the
17
+ * field's declared `default`, otherwise a kind-appropriate empty value so
18
+ * old rows stay readable.
19
+ */
20
+ export function defaultForField(field) {
21
+ if ('default' in field && field.default !== undefined)
22
+ return field.default;
23
+ switch (field.kind) {
24
+ case 'boolean':
25
+ return false;
26
+ case 'tags':
27
+ return [];
28
+ case 'json':
29
+ return null;
30
+ case 'relation':
31
+ return field.cardinality === 'many' ? [] : null;
32
+ case 'persona':
33
+ return null;
34
+ case 'file':
35
+ return field.multiple ? [] : null;
36
+ case 'integer':
37
+ case 'number':
38
+ case 'money':
39
+ return null;
40
+ default:
41
+ return null;
42
+ }
43
+ }
44
+ function tablesOf(blueprint) {
45
+ const map = new Map();
46
+ for (const entity of blueprint.entities) {
47
+ map.set(entityTable(entity), entity);
48
+ }
49
+ return map;
50
+ }
51
+ function fieldByProperty(entity) {
52
+ const map = new Map();
53
+ for (const field of entity.fields) {
54
+ map.set(fieldProperty(field), field);
55
+ }
56
+ return map;
57
+ }
58
+ function sortedKeys(record) {
59
+ return Object.keys(record).sort();
60
+ }
61
+ function dedupeOps(ops) {
62
+ const seen = new Set();
63
+ const out = [];
64
+ for (const op of ops) {
65
+ const key = JSON.stringify(op);
66
+ if (seen.has(key))
67
+ continue;
68
+ seen.add(key);
69
+ out.push(op);
70
+ }
71
+ return out;
72
+ }
73
+ function planFieldChanges(args) {
74
+ const { prevEntity, nextEntity, prevTable, nextTable, fieldRenames, allowDrop, ops } = args;
75
+ const prevByProp = fieldByProperty(prevEntity);
76
+ const nextByProp = fieldByProperty(nextEntity);
77
+ const renameTargets = new Set(Object.keys(fieldRenames)
78
+ .filter((key) => key.startsWith(`${nextTable}.`))
79
+ .map((key) => key.slice(nextTable.length + 1)));
80
+ const renameSources = new Set(Object.entries(fieldRenames)
81
+ .filter(([key]) => key.startsWith(`${nextTable}.`))
82
+ .map(([, from]) => from));
83
+ for (const prevProp of [...prevByProp.keys()].sort()) {
84
+ if (nextByProp.has(prevProp) || renameSources.has(prevProp))
85
+ continue;
86
+ if (!allowDrop) {
87
+ throw new MigrationError('drop-requires-allow', `Dropping field "${prevProp}" from table "${nextTable}" requires allowDrop: true (migrations never delete user data silently)`);
88
+ }
89
+ }
90
+ for (const [key, from] of Object.entries(fieldRenames)) {
91
+ if (!key.startsWith(`${nextTable}.`))
92
+ continue;
93
+ const to = key.slice(nextTable.length + 1);
94
+ if (!prevByProp.has(from)) {
95
+ throw new MigrationError('rename-source-missing', `renamedFrom.fields["${key}"] points at unknown previous property "${from}" on "${prevTable}"`);
96
+ }
97
+ if (!nextByProp.has(to)) {
98
+ throw new MigrationError('rename-source-missing', `renamedFrom.fields["${key}"] but next table "${nextTable}" has no property "${to}"`);
99
+ }
100
+ ops.push({ op: 'renameField', table: nextTable, from, to });
101
+ }
102
+ for (const [property, previous] of prevByProp) {
103
+ const next = nextByProp.get(property);
104
+ if (!next)
105
+ continue;
106
+ const kindChanged = previous.kind !== next.kind;
107
+ const removedEnumValues = previous.kind === 'enum' && next.kind === 'enum'
108
+ ? previous.values.filter((value) => !next.values.includes(value))
109
+ : [];
110
+ if (!kindChanged && removedEnumValues.length === 0)
111
+ continue;
112
+ const change = kindChanged
113
+ ? `Changing field "${property}" from ${previous.kind} to ${next.kind}`
114
+ : `Removing enum values from field "${property}"`;
115
+ if (!allowDrop) {
116
+ throw new MigrationError('drop-requires-allow', `${change} on table "${nextTable}" requires allowDrop: true`);
117
+ }
118
+ const fallback = next.kind === 'enum'
119
+ ? next.default && next.values.includes(next.default)
120
+ ? next.default
121
+ : next.values[0]
122
+ : defaultForField(next);
123
+ ops.push({ op: 'resetField', table: nextTable, field: property, value: fallback });
124
+ }
125
+ for (const nextProp of [...nextByProp.keys()].sort()) {
126
+ if (prevByProp.has(nextProp) || renameTargets.has(nextProp))
127
+ continue;
128
+ const field = nextByProp.get(nextProp);
129
+ if (!field)
130
+ continue;
131
+ ops.push({
132
+ op: 'backfill',
133
+ table: nextTable,
134
+ field: nextProp,
135
+ value: defaultForField(field),
136
+ });
137
+ }
138
+ }
139
+ /**
140
+ * Diff two blueprints into the next `DatabaseVersion` (plus serializable ops).
141
+ * Returns `null` when stores and data shape are unchanged (no version bump).
142
+ *
143
+ * Classifies: add entity, add field (backfill default), add index (store
144
+ * string only), rename via `renamedFrom`, drop (needs `allowDrop` → `null`).
145
+ */
146
+ export function diffBlueprints(prev, next, options = {}) {
147
+ const fromVersion = options.fromVersion ?? 1;
148
+ const allowDrop = options.allowDrop === true;
149
+ const tableRenames = options.renamedFrom?.tables ?? {};
150
+ const fieldRenames = options.renamedFrom?.fields ?? {};
151
+ const prevStores = deriveStores(prev);
152
+ const nextStores = deriveStores(next);
153
+ const prevTables = tablesOf(prev);
154
+ const nextTables = tablesOf(next);
155
+ const stores = {};
156
+ const ops = [];
157
+ const renameSources = new Set(Object.values(tableRenames));
158
+ // Dropped tables (not a rename source)
159
+ for (const prevTable of sortedKeys(prevStores)) {
160
+ if (nextStores[prevTable] !== undefined)
161
+ continue;
162
+ if (renameSources.has(prevTable))
163
+ continue;
164
+ if (!allowDrop) {
165
+ throw new MigrationError('drop-requires-allow', `Dropping table "${prevTable}" requires allowDrop: true (migrations never delete user data silently)`);
166
+ }
167
+ stores[prevTable] = null;
168
+ }
169
+ // Table renames: create next, copy rows, drop previous
170
+ for (const nextTable of sortedKeys(tableRenames)) {
171
+ const prevTable = tableRenames[nextTable];
172
+ if (prevStores[prevTable] === undefined) {
173
+ throw new MigrationError('rename-source-missing', `renamedFrom.tables["${nextTable}"] points at unknown previous table "${prevTable}"`);
174
+ }
175
+ if (nextStores[nextTable] === undefined) {
176
+ throw new MigrationError('rename-source-missing', `renamedFrom.tables["${nextTable}"] but next blueprint has no table "${nextTable}"`);
177
+ }
178
+ stores[nextTable] = nextStores[nextTable];
179
+ stores[prevTable] = null;
180
+ ops.push({ op: 'copyTable', from: prevTable, to: nextTable });
181
+ const prevEntity = prevTables.get(prevTable);
182
+ const nextEntity = nextTables.get(nextTable);
183
+ if (prevEntity && nextEntity) {
184
+ planFieldChanges({
185
+ prevEntity,
186
+ nextEntity,
187
+ prevTable,
188
+ nextTable,
189
+ fieldRenames,
190
+ allowDrop,
191
+ ops,
192
+ });
193
+ }
194
+ }
195
+ // New and changed tables (excluding rename targets already handled)
196
+ for (const nextTable of sortedKeys(nextStores)) {
197
+ if (tableRenames[nextTable] !== undefined)
198
+ continue;
199
+ const nextStore = nextStores[nextTable];
200
+ const prevStore = prevStores[nextTable];
201
+ const prevEntity = prevTables.get(nextTable);
202
+ const nextEntity = nextTables.get(nextTable);
203
+ if (prevStore === undefined) {
204
+ stores[nextTable] = nextStore;
205
+ continue;
206
+ }
207
+ if (prevEntity && nextEntity) {
208
+ planFieldChanges({
209
+ prevEntity,
210
+ nextEntity,
211
+ prevTable: nextTable,
212
+ nextTable,
213
+ fieldRenames,
214
+ allowDrop,
215
+ ops,
216
+ });
217
+ }
218
+ if (prevStore !== nextStore) {
219
+ stores[nextTable] = nextStore;
220
+ }
221
+ }
222
+ // softDelete newly enabled → backfill deletedAt (platform column, not a field)
223
+ for (const nextEntity of next.entities) {
224
+ const nextTable = entityTable(nextEntity);
225
+ const prevTable = tableRenames[nextTable] ?? nextTable;
226
+ const prevEntity = prevTables.get(prevTable);
227
+ if (!prevEntity)
228
+ continue;
229
+ if (nextEntity.features.softDelete && !prevEntity.features.softDelete) {
230
+ ops.push({ op: 'backfill', table: nextTable, field: 'deletedAt', value: null });
231
+ if (stores[nextTable] === undefined && nextStores[nextTable] !== undefined) {
232
+ stores[nextTable] = nextStores[nextTable];
233
+ }
234
+ }
235
+ }
236
+ const uniqueOps = dedupeOps(ops);
237
+ // Ops on an unchanged store string still need a version entry so Dexie runs upgrade.
238
+ for (const op of uniqueOps) {
239
+ if (op.op === 'backfill' || op.op === 'renameField') {
240
+ if (stores[op.table] === undefined && nextStores[op.table] !== undefined) {
241
+ stores[op.table] = nextStores[op.table];
242
+ }
243
+ }
244
+ }
245
+ if (Object.keys(stores).length === 0 && uniqueOps.length === 0) {
246
+ return null;
247
+ }
248
+ const migration = {
249
+ version: fromVersion + 1,
250
+ stores,
251
+ ops: uniqueOps,
252
+ };
253
+ if (uniqueOps.length > 0) {
254
+ migration.upgrade = (tx) => applyUpgradeOps(tx, uniqueOps);
255
+ }
256
+ return migration;
257
+ }
258
+ /**
259
+ * Runs serializable upgrade ops against a Dexie transaction. Safe to call
260
+ * multiple times on the same ops list (backfill only fills `undefined`).
261
+ */
262
+ export async function applyUpgradeOps(tx, ops) {
263
+ const transaction = tx;
264
+ for (const op of ops) {
265
+ if (op.op === 'backfill') {
266
+ await transaction
267
+ .table(op.table)
268
+ .toCollection()
269
+ .modify((row) => {
270
+ if (row[op.field] === undefined)
271
+ row[op.field] = op.value;
272
+ });
273
+ }
274
+ else if (op.op === 'renameField') {
275
+ await transaction
276
+ .table(op.table)
277
+ .toCollection()
278
+ .modify((row) => {
279
+ if (Object.hasOwn(row, op.from)) {
280
+ if (row[op.to] === undefined)
281
+ row[op.to] = row[op.from];
282
+ delete row[op.from];
283
+ }
284
+ });
285
+ }
286
+ else if (op.op === 'copyTable') {
287
+ const rows = await transaction.table(op.from).toArray();
288
+ if (rows.length > 0) {
289
+ await transaction.table(op.to).bulkPut(rows);
290
+ }
291
+ }
292
+ else if (op.op === 'resetField') {
293
+ await transaction
294
+ .table(op.table)
295
+ .toCollection()
296
+ .modify((row) => {
297
+ row[op.field] = op.value;
298
+ });
299
+ }
300
+ }
301
+ }
302
+ /** Core `DatabaseVersion` ready to append to a prototype's `database.versions`. */
303
+ export function toDatabaseVersion(migration) {
304
+ return {
305
+ version: migration.version,
306
+ stores: migration.stores,
307
+ ...(migration.ops.length > 0
308
+ ? { upgrade: (tx) => applyUpgradeOps(tx, migration.ops) }
309
+ : {}),
310
+ };
311
+ }
312
+ /**
313
+ * Builds the full version list: previous versions plus the diff as the tip.
314
+ * When `diffBlueprints` returns null, returns `previous` unchanged.
315
+ */
316
+ export function appendMigration(previous, migration) {
317
+ if (!migration)
318
+ return [...previous];
319
+ return [...previous, toDatabaseVersion(migration)];
320
+ }
321
+ /**
322
+ * Every database version of a blueprint, rebuilt from its `history`: version 1
323
+ * holds the oldest entry's stores, then each step appends the diff against the
324
+ * previous blueprint (steps that change nothing add no version). Deterministic,
325
+ * so generated prototypes and `protoyard validate` agree without running code.
326
+ */
327
+ export function deriveDatabaseVersions(blueprint) {
328
+ const chain = [
329
+ ...blueprint.history.map((entry) => ({
330
+ blueprint: parseBlueprint(entry.blueprint),
331
+ allowDrop: entry.migrateNext?.allowDrop === true,
332
+ })),
333
+ { blueprint, allowDrop: false },
334
+ ];
335
+ const first = chain[0];
336
+ let versions = [{ version: 1, stores: deriveStores(first.blueprint) }];
337
+ for (let i = 1; i < chain.length; i++) {
338
+ const prev = chain[i - 1];
339
+ const migration = diffBlueprints(prev.blueprint, chain[i].blueprint, {
340
+ fromVersion: versions[versions.length - 1].version,
341
+ allowDrop: prev.allowDrop,
342
+ });
343
+ versions = appendMigration(versions, migration);
344
+ }
345
+ return versions;
346
+ }
347
+ /** The latest schema version `deriveDatabaseVersions` produces. */
348
+ export function latestSchemaVersion(blueprint) {
349
+ const versions = deriveDatabaseVersions(blueprint);
350
+ return versions[versions.length - 1].version;
351
+ }
@@ -0,0 +1,50 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * The blueprint `motion` block: a closed, optional vocabulary for how much a
4
+ * prototype animates. It mirrors the envelope motion of the Texuli spec
5
+ * (`{ level }`, spec 0.6) and maps one to one onto the `texuli/motion`
6
+ * `MotionProvider` level, which the player and generated prototypes render
7
+ * with. Agents name a level; nothing here is CSS, keyframes or milliseconds,
8
+ * and unknown keys or values are refused.
9
+ *
10
+ * Optional and additive to formatVersion 1: a blueprint without `motion`
11
+ * parses, hashes (content identity) and generates exactly as before.
12
+ *
13
+ * - `full`: every Texuli animation plays (the default).
14
+ * - `subtle`: shorter animations with no travel or scale.
15
+ * - `none`: no animation; every change is instant.
16
+ *
17
+ * The reader's `prefers-reduced-motion` and the prototype's reduced-motion
18
+ * setting still win: a level is a ceiling, never a way to force motion on.
19
+ */
20
+ /** The motion levels, highest first (the same names as `texuli/motion`'s `MOTION_LEVEL_NAMES`). */
21
+ export declare const BLUEPRINT_MOTION_LEVELS: readonly ["full", "subtle", "none"];
22
+ export type BlueprintMotionLevel = (typeof BLUEPRINT_MOTION_LEVELS)[number];
23
+ /** The level a blueprint without `motion` (or without `motion.level`) plays at. */
24
+ export declare const DEFAULT_BLUEPRINT_MOTION_LEVEL: BlueprintMotionLevel;
25
+ export declare const BlueprintMotionSchema: z.ZodObject<{
26
+ level: z.ZodEnum<{
27
+ none: "none";
28
+ subtle: "subtle";
29
+ full: "full";
30
+ }>;
31
+ }, z.core.$strict>;
32
+ export type BlueprintMotion = z.infer<typeof BlueprintMotionSchema>;
33
+ export declare function isBlueprintMotionLevel(value: unknown): value is BlueprintMotionLevel;
34
+ /** The motion level of any parsed blueprint (`full` when it declares none). */
35
+ export declare function blueprintMotionLevel(blueprint: {
36
+ readonly motion?: BlueprintMotion | undefined;
37
+ }): BlueprintMotionLevel;
38
+ /** The lower of two levels (`none` < `subtle` < `full`): a ceiling only ever lowers. */
39
+ export declare function lowerMotionLevel(a: BlueprintMotionLevel, b: BlueprintMotionLevel): BlueprintMotionLevel;
40
+ /**
41
+ * A deck with the blueprint's motion level as a ceiling on its own
42
+ * `style.motion` (slide transitions and fragments). Returns the same object
43
+ * when nothing changes, so a deck without a lower blueprint level keeps its
44
+ * identity (and Svelte does not re-render it).
45
+ */
46
+ export declare function deckWithMotionCeiling<D extends {
47
+ readonly style?: {
48
+ readonly motion?: BlueprintMotionLevel | undefined;
49
+ } | undefined;
50
+ }>(deck: D, level: BlueprintMotionLevel): D;
package/dist/motion.js ADDED
@@ -0,0 +1,52 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * The blueprint `motion` block: a closed, optional vocabulary for how much a
4
+ * prototype animates. It mirrors the envelope motion of the Texuli spec
5
+ * (`{ level }`, spec 0.6) and maps one to one onto the `texuli/motion`
6
+ * `MotionProvider` level, which the player and generated prototypes render
7
+ * with. Agents name a level; nothing here is CSS, keyframes or milliseconds,
8
+ * and unknown keys or values are refused.
9
+ *
10
+ * Optional and additive to formatVersion 1: a blueprint without `motion`
11
+ * parses, hashes (content identity) and generates exactly as before.
12
+ *
13
+ * - `full`: every Texuli animation plays (the default).
14
+ * - `subtle`: shorter animations with no travel or scale.
15
+ * - `none`: no animation; every change is instant.
16
+ *
17
+ * The reader's `prefers-reduced-motion` and the prototype's reduced-motion
18
+ * setting still win: a level is a ceiling, never a way to force motion on.
19
+ */
20
+ /** The motion levels, highest first (the same names as `texuli/motion`'s `MOTION_LEVEL_NAMES`). */
21
+ export const BLUEPRINT_MOTION_LEVELS = ['full', 'subtle', 'none'];
22
+ /** The level a blueprint without `motion` (or without `motion.level`) plays at. */
23
+ export const DEFAULT_BLUEPRINT_MOTION_LEVEL = 'full';
24
+ export const BlueprintMotionSchema = z.strictObject({
25
+ /** How much the prototype animates: `full` (default), `subtle` or `none`. */
26
+ level: z.enum(BLUEPRINT_MOTION_LEVELS),
27
+ });
28
+ export function isBlueprintMotionLevel(value) {
29
+ return (typeof value === 'string' && BLUEPRINT_MOTION_LEVELS.includes(value));
30
+ }
31
+ /** The motion level of any parsed blueprint (`full` when it declares none). */
32
+ export function blueprintMotionLevel(blueprint) {
33
+ const level = blueprint.motion?.level;
34
+ return isBlueprintMotionLevel(level) ? level : DEFAULT_BLUEPRINT_MOTION_LEVEL;
35
+ }
36
+ /** The lower of two levels (`none` < `subtle` < `full`): a ceiling only ever lowers. */
37
+ export function lowerMotionLevel(a, b) {
38
+ return BLUEPRINT_MOTION_LEVELS.indexOf(a) >= BLUEPRINT_MOTION_LEVELS.indexOf(b) ? a : b;
39
+ }
40
+ /**
41
+ * A deck with the blueprint's motion level as a ceiling on its own
42
+ * `style.motion` (slide transitions and fragments). Returns the same object
43
+ * when nothing changes, so a deck without a lower blueprint level keeps its
44
+ * identity (and Svelte does not re-render it).
45
+ */
46
+ export function deckWithMotionCeiling(deck, level) {
47
+ const own = deck.style?.motion ?? DEFAULT_BLUEPRINT_MOTION_LEVEL;
48
+ const effective = lowerMotionLevel(own, level);
49
+ if (effective === own)
50
+ return deck;
51
+ return { ...deck, style: { ...deck.style, motion: effective } };
52
+ }
@@ -0,0 +1,19 @@
1
+ import { type PrototypeRuntime, type Row } from '@protoyard/core';
2
+ import type { Blueprint, BlueprintEntity } from './schema.ts';
3
+ export interface BlueprintNotificationService {
4
+ list(): Promise<Row[]>;
5
+ unreadCount(): Promise<number>;
6
+ markRead(id: string): Promise<boolean>;
7
+ markUnread(id: string): Promise<boolean>;
8
+ markAllRead(): Promise<number>;
9
+ }
10
+ export interface NotificationEvent {
11
+ readonly on: 'create' | 'update';
12
+ readonly entity: BlueprintEntity;
13
+ readonly row: Row;
14
+ readonly previous?: Row;
15
+ }
16
+ /** Writes rule matches into the caller's existing mutation transaction. */
17
+ export declare function createNotificationWriter(runtime: PrototypeRuntime, blueprint: Blueprint): (event: NotificationEvent) => Promise<number>;
18
+ /** Actor-scoped notification reads and updates. */
19
+ export declare function createNotificationService(runtime: PrototypeRuntime): BlueprintNotificationService;
@@ -0,0 +1,177 @@
1
+ import { createRuntimeId } from '@protoyard/core';
2
+ import { entityTable } from "./schema.js";
3
+ import { FEATURE_TABLES } from "./storage.js";
4
+ import { inputProperty } from "./validation.js";
5
+ function personaProperty(field) {
6
+ return `${field}Id`;
7
+ }
8
+ function text(value) {
9
+ if (value === null || value === undefined)
10
+ return '';
11
+ if (Array.isArray(value))
12
+ return value.map(text).join(', ');
13
+ if (typeof value === 'object')
14
+ return '[object]';
15
+ return String(value);
16
+ }
17
+ async function titleFor(template, entity, row, db, blueprint) {
18
+ const fields = new Map(entity.fields.map((field) => [field.name, field]));
19
+ const matches = [...template.matchAll(/\{([^{}]+)\}/g)];
20
+ let title = template;
21
+ for (const match of matches) {
22
+ const name = match[1] ?? '';
23
+ const field = fields.get(name);
24
+ if (!field)
25
+ continue;
26
+ const value = row[inputProperty(field)];
27
+ let display = value;
28
+ if (field.kind === 'persona' && typeof value === 'string') {
29
+ display = (await db.table('py_personas').get(value))?.name ?? value;
30
+ }
31
+ else if (field.kind === 'relation' && typeof value === 'string') {
32
+ const targetEntity = blueprint.entities.find((item) => item.name === field.to);
33
+ const target = targetEntity
34
+ ? await db.table(entityTable(targetEntity)).get(value)
35
+ : undefined;
36
+ const titleField = targetEntity?.titleField;
37
+ const displayField = targetEntity?.fields.find((candidate) => candidate.name === titleField);
38
+ display =
39
+ target && titleField && !displayField?.sensitive && typeof target[titleField] === 'string'
40
+ ? target[titleField]
41
+ : value;
42
+ }
43
+ title = title.replace(match[0], text(display));
44
+ }
45
+ return title;
46
+ }
47
+ /** Writes rule matches into the caller's existing mutation transaction. */
48
+ export function createNotificationWriter(runtime, blueprint) {
49
+ const db = runtime.db;
50
+ const memberships = db.table('py_memberships');
51
+ const actorId = () => runtime.session.principal()?.persona.id;
52
+ async function recipients(rule, entity, row) {
53
+ const actor = actorId();
54
+ let ids = [];
55
+ if (rule.notify === 'owner' && entity.ownerField) {
56
+ const id = row[personaProperty(entity.ownerField)];
57
+ if (typeof id === 'string')
58
+ ids = [id];
59
+ }
60
+ else if (rule.notify === 'assignee') {
61
+ const id = row[personaProperty('assignee')];
62
+ if (typeof id === 'string')
63
+ ids = [id];
64
+ }
65
+ else if (rule.notify.startsWith('role:')) {
66
+ const role = rule.notify.slice('role:'.length);
67
+ const activeMembership = (await memberships.toArray()).find((item) => item.personaId === actor);
68
+ ids = (await memberships.toArray())
69
+ .filter((item) => item.role === role &&
70
+ (activeMembership?.organizationId === undefined ||
71
+ item.organizationId === activeMembership.organizationId))
72
+ .map((item) => item.personaId)
73
+ .filter((id) => typeof id === 'string');
74
+ }
75
+ return [...new Set(ids)].filter((id) => id !== actor);
76
+ }
77
+ return async (event) => {
78
+ const rules = blueprint.notifications.filter((rule) => rule.entity === event.entity.name && rule.on === event.on);
79
+ const assignmentRules = blueprint.notifications.filter((rule) => rule.entity === event.entity.name && rule.on === 'assign');
80
+ const matchingRules = [...rules, ...assignmentRules];
81
+ if (matchingRules.length === 0)
82
+ return 0;
83
+ let count = 0;
84
+ const notificationTable = db.table(FEATURE_TABLES.notifications);
85
+ for (const rule of matchingRules) {
86
+ if (rule.on === 'assign') {
87
+ if (!rule.field || !event.previous)
88
+ continue;
89
+ const property = personaProperty(rule.field);
90
+ const oldValue = event.previous[property];
91
+ const newValue = event.row[property];
92
+ if (typeof newValue !== 'string' || newValue === oldValue)
93
+ continue;
94
+ }
95
+ const targetIds = await recipients(rule, event.entity, event.row);
96
+ const now = runtime.clock.now().toISOString();
97
+ for (const personaId of targetIds) {
98
+ await notificationTable.put({
99
+ id: createRuntimeId('notification'),
100
+ personaId,
101
+ kind: `${event.entity.name}.${rule.on}`,
102
+ title: await titleFor(rule.title, event.entity, event.row, db, blueprint),
103
+ body: '',
104
+ createdAt: now,
105
+ readAt: null,
106
+ });
107
+ count += 1;
108
+ }
109
+ }
110
+ return count;
111
+ };
112
+ }
113
+ /** Actor-scoped notification reads and updates. */
114
+ export function createNotificationService(runtime) {
115
+ const db = runtime.db;
116
+ const table = db.table(FEATURE_TABLES.notifications);
117
+ const { environment } = runtime;
118
+ const actorId = () => {
119
+ const id = runtime.session.principal()?.persona.id;
120
+ if (!id)
121
+ throw new Error('No active persona. Load a scenario first.');
122
+ return id;
123
+ };
124
+ const ownRow = async (id) => {
125
+ const row = await table.get(id);
126
+ return row?.personaId === actorId() ? row : undefined;
127
+ };
128
+ return {
129
+ async list() {
130
+ return (await table.where('personaId').equals(actorId()).toArray()).sort((a, b) => {
131
+ const byTime = String(b.createdAt).localeCompare(String(a.createdAt));
132
+ return byTime || String(b.id).localeCompare(String(a.id));
133
+ });
134
+ },
135
+ async unreadCount() {
136
+ const personaId = actorId();
137
+ const total = await table.where('personaId').equals(personaId).count();
138
+ const read = await table
139
+ .where('[personaId+readAt]')
140
+ .between([personaId, ''], [personaId, '\uffff'])
141
+ .count();
142
+ return total - read;
143
+ },
144
+ async markRead(id) {
145
+ const row = await ownRow(id);
146
+ if (!row)
147
+ return false;
148
+ if (row.readAt)
149
+ return true;
150
+ await environment.run('notifications.markRead', () => db.transaction('rw', [FEATURE_TABLES.notifications], async () => {
151
+ await table.update(id, { readAt: runtime.clock.now().toISOString() });
152
+ }));
153
+ return true;
154
+ },
155
+ async markUnread(id) {
156
+ const row = await ownRow(id);
157
+ if (!row)
158
+ return false;
159
+ if (!row.readAt)
160
+ return true;
161
+ await environment.run('notifications.markUnread', () => db.transaction('rw', [FEATURE_TABLES.notifications], async () => {
162
+ await table.update(id, { readAt: null });
163
+ }));
164
+ return true;
165
+ },
166
+ async markAllRead() {
167
+ const rows = await table.where('personaId').equals(actorId()).toArray();
168
+ const unread = rows.filter((row) => row.readAt === null || row.readAt === undefined);
169
+ await environment.run('notifications.markAllRead', () => db.transaction('rw', [FEATURE_TABLES.notifications], async () => {
170
+ const readAt = runtime.clock.now().toISOString();
171
+ for (const row of unread)
172
+ await table.update(row.id, { readAt });
173
+ }));
174
+ return unread.length;
175
+ },
176
+ };
177
+ }