footprint-story 0.3.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 — a story written as data
4
+
5
+ - **storyboard** (`footprint-story/storyboard`, a fourth door, pure, loaded only when imported): a story written as plain
6
+ data on a named shape, for a film and a deck to draw the same way. `storyShapes` lists the shapes with a worked outline,
7
+ `storyboardProblems` names every mistake at once, and `readStoryboard` gives frozen scenes, each usable as a scene of a
8
+ film's storyboard as it is. Two shapes: `told-twice` and `what-we-found`. Given a conversation, a claim must cite the
9
+ messages it rests on, and its numbers must be in them. What a model writes must use plain digits: a disguised number is
10
+ refused. Nothing here draws.
11
+ - The architecture test now checks four doors.
12
+
3
13
  ## 0.3.0 — footprint-narration is now footprint-story, and the motion words join it
4
14
 
5
15
  **Renamed.** The package is `footprint-story` from this version: what a film (footprint-storyreel) and a deck
package/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # footprint-story
2
2
 
3
+ For developers building narration and animation tools, or integrating the shared rules used by StoryDeck and StoryReel.
4
+
5
+ [Install and import paths](#install-and-import-paths) · [Text mapping](#one-text-as-shown-and-as-spoken) · [Captions](#captions) · [Motion](#how-things-move-motion) · [Guarantees and limits](#what-is-guaranteed)
6
+
3
7
  **What a film and a deck share.** [StoryDeck](https://github.com/footprintjs/storydeck) (slide decks that narrate
4
8
  themselves) and [footprint-storyreel](https://github.com/footprintjs/storyreel) (drawn films) tell stories the same
5
9
  way: a voice says the words, captions show them, and things move on screen. Each used to keep its own copy of those
@@ -16,22 +20,49 @@ files, YouTube chapters, and — for Node — a voice port with a clip cache key
16
20
  picture may sit still are words both tools use, so they are named here once. Nothing here draws: StoryReel paints
17
21
  them on a canvas, StoryDeck plays them in a page.
18
22
 
23
+ ## Install and import paths
24
+
19
25
  ```sh
20
26
  npm install footprint-story
21
27
  ```
22
28
 
23
- Three doors, nothing else. A door loads only when you import it, so a page that only captions never loads motion:
29
+ Four doors, nothing else. A door loads only when you import it, so a page that only captions never loads motion:
24
30
 
25
31
  | door | what | runs |
26
32
  |---|---|---|
27
33
  | `footprint-story` | the shown↔spoken map, sentences, captions, chapters | anywhere — browser or Node; no dependencies |
28
34
  | `footprint-story/voice` | the voice port, the clip cache, its two adapters: the Chatterbox kit and your own recorded takes | **Node only** (files, child processes) |
29
35
  | `footprint-story/motion` | eases and springs, moves, transitions as data, pace | anywhere — browser or Node; no dependencies |
36
+ | `footprint-story/storyboard` | a story written as data on a named shape, read and checked for both tools to draw | anywhere — browser or Node; no dependencies |
30
37
 
31
38
  What may join: only what both tools use, and only the shared words and maths — each tool keeps its own drawing. A
32
39
  new topic gets its own door; a door becomes a package of its own only if it ever needs a dependency or a release
33
40
  pace of its own.
34
41
 
42
+ ## A story written as data
43
+
44
+ An agent telling a story should not have to write a deck's HTML or a film's recipe. It fills in a **shape**, and every
45
+ mistake is named at once, so it fixes them in one pass. Whoever draws the board treats its words as text, never as code.
46
+
47
+ ```js
48
+ import { storyShapes, storyboardProblems, readStoryboard } from 'footprint-story/storyboard';
49
+
50
+ storyShapes(); // [{ name, about, fills }]: the shapes, and a worked outline of each
51
+ storyboardProblems(fromAgent); // [{ at: 'items[1].turn.callout', message: 'must say both names, …' }]
52
+ const board = readStoryboard(fromAgent, { conversation: [{ id: 'm7', text: 'it waited 41 minutes' }] });
53
+ board.scenes[1]; // { id, part, look, title, narration, beat }
54
+ ```
55
+
56
+ | shape | what it tells |
57
+ |---|---|
58
+ | `told-twice` | a cast of people and everyday things acts a system out, each turns into the real thing where it stood, then the same story runs again for real |
59
+ | `what-we-found` | what was checked, what was found, what is still not known (which must be written, even empty), and what happens next |
60
+
61
+ Given a conversation, a `what-we-found` claim must name the messages it rests on, and every number in it must be in one of
62
+ them; anything that cites none, such as its title, must use numbers from somewhere in the conversation. That catches a
63
+ wrong number. It does not catch a claim that is wrong in words, a number spelled out, or a sign (`-41` reads as 41). `verified` is true only when claims
64
+ really were checked: `told-twice` makes none, and with no conversation nothing is checked.
65
+
35
66
  ## One text, as shown and as spoken
36
67
 
37
68
  A **slot** says one exact place by hand; the **rules** say everything else. Use either or both.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "footprint-story",
3
- "version": "0.3.0",
4
- "description": "What a film and a deck share: what a voice says and what a caption shows (shown-vs-spoken text, sentences and word times, caption cues and files, YouTube chapters); a voice port and a clip cache behind /voice; how things move behind /motion (eases, springs, moves, transitions as data, pace).",
3
+ "version": "0.4.0",
4
+ "description": "Shared JavaScript narration, shown/spoken text, captions and motion primitives for StoryDeck and StoryReel, with a separate Node voice-cache entry.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Sanjay Krishna Anbalagan",
@@ -27,6 +27,7 @@
27
27
  "easing",
28
28
  "spring",
29
29
  "transitions",
30
+ "storyboard",
30
31
  "storyreel",
31
32
  "storydeck",
32
33
  "footprintjs"
@@ -49,6 +50,10 @@
49
50
  "types": "./motion.d.ts",
50
51
  "default": "./motion.js"
51
52
  },
53
+ "./storyboard": {
54
+ "types": "./storyboard.d.ts",
55
+ "default": "./storyboard.js"
56
+ },
52
57
  "./package.json": "./package.json"
53
58
  },
54
59
  "files": [
@@ -58,6 +63,8 @@
58
63
  "voice.d.ts",
59
64
  "motion.js",
60
65
  "motion.d.ts",
66
+ "storyboard.js",
67
+ "storyboard.d.ts",
61
68
  "src",
62
69
  "plugin",
63
70
  "README.md",
@@ -65,7 +72,7 @@
65
72
  "LICENSE"
66
73
  ],
67
74
  "scripts": {
68
- "test": "node --test --experimental-test-coverage --test-coverage-include=src/** --test-coverage-include=index.js --test-coverage-include=voice.js --test-coverage-include=motion.js --test-coverage-lines=95 --test-coverage-branches=85 --test-coverage-functions=90 test/*.test.js"
75
+ "test": "node --test --experimental-test-coverage --test-coverage-include=src/** --test-coverage-include=index.js --test-coverage-include=voice.js --test-coverage-include=motion.js --test-coverage-include=storyboard.js --test-coverage-lines=95 --test-coverage-branches=85 --test-coverage-functions=90 test/*.test.js"
69
76
  },
70
77
  "devDependencies": {
71
78
  "fast-check": "^4.10.2"
@@ -7,13 +7,14 @@ description: What a film and a deck share (footprint-story, formerly footprint-n
7
7
 
8
8
  A voice drops or garbles digits and a forced aligner knows only letters, so a narration is **said in words and
9
9
  shown as written**. Everything here rests on one map from the shown text to the spoken text. And a move, a change of
10
- picture and a pace are named here once, so a film and a deck move alike. Three doors:
10
+ picture and a pace are named here once, so a film and a deck move alike. Four doors:
11
11
 
12
12
  | You need | Import | Runs |
13
13
  |---|---|---|
14
14
  | the shown↔spoken map, sentences, captions, chapters | `footprint-story` | anywhere (browser or Node), no dependencies |
15
15
  | a voice clip cache, the voice port, the Chatterbox kit adapter | `footprint-story/voice` | **Node only** |
16
16
  | eases, springs, moves, transitions as data, pace | `footprint-story/motion` | anywhere, no dependencies; nothing draws |
17
+ | a story as data on a named shape, read and checked | `footprint-story/storyboard` | anywhere, no dependencies; nothing draws |
17
18
 
18
19
  Never import `footprint-story/voice` into browser code. This package was `footprint-narration` until 0.3.0: replace
19
20
  the name in imports and `package.json`; the two old doors export exactly what they did.
@@ -0,0 +1,27 @@
1
+ # storyboard/ — a story written as data, on a named shape
2
+
3
+ **Why:** a model that writes a deck's HTML or a film's recipe by hand spends many tokens, can drift between tools, and can
4
+ put a conversation's planted text into a page as code. Filling in a shape costs little, names every mistake at once,
5
+ and its text is only ever text. Each tool draws the same board its own way.
6
+
7
+ | file | holds |
8
+ |---|---|
9
+ | `read.js` | the door's rule: read a board, name every problem at once, give scenes |
10
+ | `toldTwice.js` | `told-twice`: a cast acts a system out, turns into it, and the story runs again for real |
11
+ | `whatWeFound.js` | `what-we-found`: what was checked, what was found, what is not known, what is next |
12
+ | `conversation.js` | a conversation as sources, and the rule that a claim's numbers are in the messages it cites |
13
+ | `fields.js` | reading data while keeping every problem |
14
+ | `words.js` | the limits on each kind of text, and a part's labels |
15
+
16
+ ```js
17
+ import { readStoryboard } from 'footprint-story/storyboard';
18
+
19
+ const board = readStoryboard(fromAnAgent, { conversation: [{ id: 'm7', text: 'it waited 41 minutes' }] });
20
+ board.scenes[1]; // { id, part, look, title, narration, beat }: title and narration are a film's scene as they are
21
+ ```
22
+
23
+ The number rule covers every word a `what-we-found` says or shows: a claim against the messages it cites, and anything that
24
+ cites none against the whole conversation. What a model writes must use plain digits: a disguised number (hidden characters,
25
+ superscripts, odd separators, direction controls) is refused rather than read. It does not catch a claim that
26
+ is wrong in words, a number spelled out, or a sign (`-41` reads as 41). `verified` is true only when a shape's claims really were checked: `told-twice`
27
+ makes none, and with no conversation given nothing is checked.
@@ -0,0 +1,88 @@
1
+ /**
2
+ * A conversation, as the sources of a storyboard: messages in order, each with an id a claim can cite. This shape is the
3
+ * library's own: an app maps its history onto it. Whoever writes the storyboard may get things wrong, so a claim says which
4
+ * messages it rests on, and every number in it must be in one of them.
5
+ */
6
+ const MOST_MESSAGES = 5000;
7
+
8
+ const THOUSANDS = /^\d{1,3}(?:[,   ]\d{3})+(?:\.\d+)?$/;
9
+ const GROUPING = /[,   ]/g;
10
+ const FULLWIDTH = /[0-9]/g;
11
+ /** Digits of any kind, kept with whatever joins them into one number, so a source's '10²' or '1 250' is not also a 10 or a 250. */
12
+ const NUMBER = /[\p{Nd}\p{No}]+(?:[.,,.   ․﹐﹒][\p{Nd}\p{No}]+)*/gu;
13
+ const BIDI = /[؜‎‏‪-‮⁦-⁩]/;
14
+ /**
15
+ * Two digits with something between them that a reader cannot read as what it is: anything but a visible letter or printable
16
+ * ASCII. One class, with nothing that can match in two ways, so its cost stays linear; invisible letters are marked first.
17
+ */
18
+ const ODDLY_JOINED = /\p{Nd}[^\p{L}\p{Nd}\x20-\x7E]+\p{Nd}/u;
19
+ const INVISIBLE = /\p{Default_Ignorable_Code_Point}/gu;
20
+ /** A control character other than a tab or a line break. */
21
+ const CONTROL = /[^\P{Cc}\t\n\r]/u;
22
+ const plainWidth = (text) => String(text).replace(FULLWIDTH, (d) => String.fromCharCode(d.charCodeAt(0) - 0xFF10 + 48));
23
+ const digitCount = (text) => (text.match(/\p{Nd}/gu) ?? []).length;
24
+
25
+ /**
26
+ * Every number in a text: digits in any script, fullwidth ones read as plain, hidden characters ignored. Grouping goes only
27
+ * when it is real thousands ('1,250.5' → '1250.5', and with a thin space too); anything else stays as written, so '3,5' is
28
+ * not 35 and '10²' is not 10.
29
+ */
30
+ export function numbersIn(text) {
31
+ const seen = plainWidth(text).replace(/[\p{Cf}\p{Default_Ignorable_Code_Point}]/gu, '');
32
+ return [...new Set((seen.match(NUMBER) ?? []).map((n) => (THOUSANDS.test(n) ? n.replace(GROUPING, '') : n)))];
33
+ }
34
+
35
+ /**
36
+ * Whether words write a number in a form that may not show what it reads as: a compatibility digit (10², ½, ①, ㏳), digits
37
+ * joined by anything odd, or direction controls that reorder what is shown. What a model writes must use plain digits, so
38
+ * these are refused rather than read: the rule allows one form instead of chasing each disguise.
39
+ */
40
+ function disguisesNumber(text) {
41
+ const plain = plainWidth(text);
42
+ if (/\p{No}/u.test(plain) || digitCount(plain.normalize('NFKC')) !== digitCount(plain)) return true;
43
+ return digitCount(plain) > 0 && (BIDI.test(plain) || CONTROL.test(plain) || ODDLY_JOINED.test(plain.replace(INVISIBLE, '​')));
44
+ }
45
+ const DISGUISED = 'writes a number in a form that cannot be checked: use plain digits, with no hidden or special characters near them';
46
+
47
+ /**
48
+ * [{id, who?, text}] read and checked: a Map of id → text. Throws, naming the place, on anything it cannot use, as what
49
+ * is passed here comes from the app, not from whoever writes the storyboard.
50
+ */
51
+ export function readConversation(messages) {
52
+ if (!Array.isArray(messages)) throw new TypeError('a conversation is a list of messages: [{id, text}]');
53
+ if (messages.length > MOST_MESSAGES) throw new RangeError(`a conversation of ${messages.length} messages is too long: ${MOST_MESSAGES} is the most`);
54
+ const out = new Map();
55
+ messages.forEach((m, i) => {
56
+ if (m === null || typeof m !== 'object' || typeof m.id !== 'string' || !m.id.trim()) throw new TypeError(`message ${i}: id must be text with something in it`);
57
+ if (typeof m.text !== 'string') throw new TypeError(`message ${i} ("${m.id}"): text must be text`);
58
+ if (out.has(m.id)) throw new TypeError(`message ${i}: two messages have the id "${m.id}"`);
59
+ out.set(m.id, m.text);
60
+ });
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * Checks one claim against the messages it cites, keeping any problem on `fields`: every message it cites is there, and
66
+ * every number it says is in one of them. `claim`: the words it says. With no conversation there is nothing to check
67
+ * against, and a board says so (`verified: false`).
68
+ */
69
+ export function checkClaim(fields, conversation, at, claim, cited) {
70
+ if (!conversation) return;
71
+ if (!cited.length) { fields.say(`${at}.sources`, 'must name the messages this rests on'); return; }
72
+ const missing = cited.filter((id) => !conversation.has(id));
73
+ for (const id of missing) fields.say(`${at}.sources`, `names "${id}", which the conversation does not have`);
74
+ if (missing.length) return;
75
+ if (disguisesNumber(claim)) { fields.say(at, DISGUISED); return; }
76
+ const said = new Set(cited.flatMap((id) => numbersIn(conversation.get(id))));
77
+ for (const n of numbersIn(claim)) {
78
+ if (!said.has(n)) fields.say(at, `says ${n}, which none of its sources (${cited.join(', ')}) says: fix the number, cite the message that says it, or move it to what you are not sure about`);
79
+ }
80
+ }
81
+
82
+ /** Words that cite no message (a title, what is said at the end, what happens next): any number in them must be somewhere in the conversation. */
83
+ export function checkAnywhere(fields, conversation, at, text) {
84
+ if (!conversation) return;
85
+ if (disguisesNumber(text)) { fields.say(at, DISGUISED); return; }
86
+ const said = new Set([...conversation.values()].flatMap((message) => numbersIn(message)));
87
+ for (const n of numbersIn(text)) if (!said.has(n)) fields.say(at, `says ${n}, which the conversation never says: fix the number, or take it out`);
88
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Fields: reading plain data a person or a model wrote, keeping every problem instead of stopping at the first, so one
3
+ * answer can name everything to fix. Reads own properties only. A key it does not know is a problem, naming the keys
4
+ * there are, so a misspelt name is never silently ignored.
5
+ */
6
+ const MOST_NAME = 40; // of an unknown key, as a problem repeats it: what comes back to a model is short
7
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
8
+ const join = (at, key) => (typeof key === 'number' ? `${at}[${key}]` : at ? `${at}.${key}` : key);
9
+
10
+ /** A reader with its own list of problems, each {at, message}: `at` is the place in the data, as it is written. */
11
+ export function fieldReader() {
12
+ const problems = [];
13
+ const say = (at, message) => { problems.push({ at, message }); return null; };
14
+ return {
15
+ problems,
16
+ say,
17
+ /** A copy of `value` holding only its own `keys`, each read once, so nothing inherited counts; null when it is not an object. */
18
+ object(value, at, keys) {
19
+ if (!isObject(value)) return say(at, `must be an object with ${keys.join(', ')}`);
20
+ const own = Object.create(null);
21
+ for (const key of Object.keys(value)) {
22
+ if (keys.includes(key)) own[key] = value[key];
23
+ else say(join(at, key.length > MOST_NAME ? `${key.slice(0, MOST_NAME)}…` : key), `is not a field here (the fields are ${keys.join(', ')})`);
24
+ }
25
+ return own;
26
+ },
27
+ /** A field's text, trimmed; null when it is missing and may be, or wrong. Never longer than `most`. */
28
+ text(parent, key, at, { most, optional = false } = {}) {
29
+ const place = join(at, key), v = parent && Object.hasOwn(parent, key) ? parent[key] : undefined;
30
+ if (v === undefined) return optional ? null : say(place, 'is missing');
31
+ if (typeof v !== 'string' || !v.trim()) return say(place, 'must be text with something in it');
32
+ if (v.trim().length > most) return say(place, `is ${v.trim().length} characters, and ${most} is the most it may be: shorten it`);
33
+ return v.trim();
34
+ },
35
+ /** A field's list, between `least` and `most` long; empty when it is wrong (the problem is kept). */
36
+ list(parent, key, at, { least = 0, most, optional = false } = {}) {
37
+ const place = join(at, key), v = parent && Object.hasOwn(parent, key) ? parent[key] : undefined;
38
+ if (v === undefined) { if (!optional) say(place, 'is missing'); return []; }
39
+ if (!Array.isArray(v)) { say(place, 'must be a list'); return []; }
40
+ if (v.length < least) say(place, `has ${v.length}, and it needs at least ${least}`);
41
+ if (v.length > most) { say(place, `has ${v.length}, and ${most} is the most it may have`); return v.slice(0, most); }
42
+ return v;
43
+ },
44
+ };
45
+ }
46
+
47
+ /** A deep copy's freeze: what a reader hands out is never edited by whoever draws it. */
48
+ export function deepFreeze(value) {
49
+ if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
50
+ for (const v of Object.values(value)) deepFreeze(v);
51
+ Object.freeze(value);
52
+ }
53
+ return value;
54
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The door's one rule: a storyboard is plain data on a named shape, and reading it gives scenes both tools can draw. It
3
+ * names every problem at once, so whoever wrote it fixes them in one pass. No text here is trusted: whoever draws a scene
4
+ * escapes it as text.
5
+ */
6
+ import { fieldReader, deepFreeze } from './fields.js';
7
+ import { MOST } from './words.js';
8
+ import { readConversation } from './conversation.js';
9
+ import { toldTwice } from './toldTwice.js';
10
+ import { whatWeFound } from './whatWeFound.js';
11
+
12
+ const SHAPES = Object.freeze({ [toldTwice.name]: toldTwice, [whatWeFound.name]: whatWeFound });
13
+ const MOST_PROBLEMS = 40;
14
+ const SHARED = ['shape', 'title', 'subtitle', 'lang'];
15
+ const OWN = { 'told-twice': ['analogy', 'intro', 'items', 'flow', 'together', 'remember', 'closing', 'labels'], 'what-we-found': ['intro', 'checked', 'found', 'unsure', 'next', 'closing', 'labels'] };
16
+
17
+ /** The shapes there are, for whoever is about to write one: [{name, about, fills}]. `fills` is a worked outline of its fields. */
18
+ export const storyShapes = () => Object.values(SHAPES).map(({ name, about, fills }) => ({ name, about, fills }));
19
+
20
+
21
+ /** No more problems than whoever wrote this can use in one pass: what comes back to a model stays short. */
22
+ const shortList = (problems) => (problems.length > MOST_PROBLEMS ? [...problems.slice(0, MOST_PROBLEMS), { at: '', message: `and ${problems.length - MOST_PROBLEMS} more: fix these first` }] : problems);
23
+
24
+ /** What reading finds: {board, problems}. `board` only when there are no problems. */
25
+ function readAll(raw, { conversation } = {}) {
26
+ const f = fieldReader();
27
+ let parsed = raw;
28
+ if (typeof raw === 'string') {
29
+ try { parsed = JSON.parse(raw); } catch (e) { return { board: null, problems: [{ at: 'storyboard', message: `is not valid JSON: ${e.message}` }] }; }
30
+ }
31
+ const known = Object.keys(SHAPES), name = parsed !== null && typeof parsed === 'object' && Object.hasOwn(parsed, 'shape') ? parsed.shape : undefined;
32
+ if (typeof name !== 'string' || !Object.hasOwn(SHAPES, name)) return { board: null, problems: [{ at: 'shape', message: `must be one of ${known.join(', ')}` }] };
33
+ const sources = conversation ? readConversation(conversation) : null; // the app's own input: a mistake here is the app's, and throws
34
+ try {
35
+ const input = f.object(parsed, '', [...SHARED, ...OWN[name]]);
36
+ if (!input) return { board: null, problems: f.problems };
37
+ const base = { title: f.text(input, 'title', '', { most: MOST.title }), subtitle: f.text(input, 'subtitle', '', { most: MOST.subtitle, optional: true }), lang: f.text(input, 'lang', '', { most: 20, optional: true }) };
38
+ const drawn = SHAPES[name].read(f, input, base, sources);
39
+ if (f.problems.length) return { board: null, problems: shortList(f.problems) };
40
+ // verified only where a shape's claims really were checked: told-twice tells a system's story, and makes none to check
41
+ return { board: deepFreeze({ shape: name, ...base, verified: Boolean(sources && SHAPES[name].checksClaims), ...drawn }), problems: [] };
42
+ } catch { // not plain data (a getter that throws, a proxy): what it is given to read never stops the reader
43
+ return { board: null, problems: shortList([...f.problems, { at: 'storyboard', message: 'could not be read: it must be plain data' }]) };
44
+ }
45
+ }
46
+
47
+ /** Every problem in a storyboard, [{at, message}]; [] when it can be drawn. Never throws on what it is given to read. `conversation`: the messages its claims cite, [{id, text}]. */
48
+ export const storyboardProblems = (input, options) => readAll(input, options).problems;
49
+
50
+ /**
51
+ * A storyboard read: frozen {shape, title, subtitle, lang, verified, labels, scenes, …}. Each scene is {id, part, look, title,
52
+ * narration, beat}: `title` and `narration` make it a scene of a film's storyboard as they are, and `beat` says what happens.
53
+ * Throws when it cannot be drawn, its `problems` listing all of them. `verified`: whether its claims were checked against a conversation.
54
+ */
55
+ export function readStoryboard(input, options) {
56
+ const { board, problems } = readAll(input, options);
57
+ if (board) return board;
58
+ const list = problems.map((p) => `${p.at ? `${p.at} ` : ''}${p.message}`);
59
+ throw Object.assign(new TypeError(`this storyboard has ${list.length} problem${list.length === 1 ? '' : 's'}:\n- ${list.join('\n- ')}`), { problems });
60
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * told-twice: one picture, told twice. A cast of people and everyday things acts a system out, each turns into the real
3
+ * thing where it stood, then the same story runs again for real. Written once, so the two tellings cannot drift: every
4
+ * real moment is the same moment as a story one, by construction.
5
+ */
6
+ import { MOST, isSafeId, readLabels, saysName } from './words.js';
7
+ import { deepFreeze } from './fields.js';
8
+
9
+ const LABELS = Object.freeze({ story: 'The story', turn: 'The turn', real: 'The real thing', remember: 'To remember' });
10
+ const WHO = { name: 20, line: 36 }; // what fits a piece in a row of five
11
+
12
+ function readWho(f, value, at) {
13
+ const who = f.object(value, at, ['name', 'line', 'icon']);
14
+ if (!who) return { name: null, line: null, icon: null };
15
+ return { name: f.text(who, 'name', at, { most: WHO.name }), line: f.text(who, 'line', at, { most: WHO.line }), icon: f.text(who, 'icon', at, { most: MOST.icon, optional: true }) };
16
+ }
17
+
18
+ function readTold(f, value, at) {
19
+ const told = f.object(value, at, ['headline', 'notes']);
20
+ return told ? { title: f.text(told, 'headline', at, { most: MOST.headline }), narration: f.text(told, 'notes', at, { most: MOST.notes }) } : { title: null, narration: null };
21
+ }
22
+
23
+ function readItem(f, value, i, seen) {
24
+ const at = `items[${i}]`, item = f.object(value, at, ['id', 'cast', 'real', 'turn']);
25
+ if (!item) return null;
26
+ const safe = isSafeId(item.id);
27
+ if (!safe) f.say(`${at}.id`, 'must be lower case letters, digits and dashes');
28
+ else if (seen.has(item.id)) f.say(`${at}.id`, `is "${item.id}" again: each item has an id of its own`);
29
+ else seen.add(item.id);
30
+ const cast = readWho(f, item.cast, `${at}.cast`), real = readWho(f, item.real, `${at}.real`);
31
+ const turn = f.object(item.turn, `${at}.turn`, ['headline', 'callout', 'notes']);
32
+ const callout = turn ? f.text(turn, 'callout', `${at}.turn`, { most: MOST.callout }) : null;
33
+ for (const who of [cast, real]) {
34
+ if (callout && who.name && !saysName(callout, who.name)) f.say(`${at}.turn.callout`, `must say both names, so the reader can tie them together, and it does not say "${who.name}"`);
35
+ }
36
+ return { id: safe ? item.id : null, cast, real, callout, title: turn ? f.text(turn, 'headline', `${at}.turn`, { most: MOST.headline }) : null, narration: turn ? f.text(turn, 'notes', `${at}.turn`, { most: MOST.notes }) : null };
37
+ }
38
+
39
+ /** A moment of the flow: its path along the row of items, what is called on each hop, and its words in each telling. */
40
+ function readMoment(f, value, i, order) {
41
+ const at = `flow[${i}]`, moment = f.object(value, at, ['path', 'calls', 'story', 'real']);
42
+ if (!moment) return null;
43
+ const path = [...f.list(moment, 'path', at, { least: 1, most: 8 })], calls = f.list(moment, 'calls', at, { most: 7, optional: true });
44
+ path.forEach((id, k) => {
45
+ if (!order.includes(id)) f.say(`${at}.path[${k}]`, `is not an item (they are ${order.join(', ')})`);
46
+ else if (k && id === path[k - 1]) f.say(`${at}.path[${k}]`, `goes from "${id}" to itself`);
47
+ else if (k && order.includes(path[k - 1]) && Math.abs(order.indexOf(id) - order.indexOf(path[k - 1])) !== 1) f.say(`${at}.path[${k}]`, `goes from "${path[k - 1]}" to "${id}", which do not stand next to each other: items stand in one row, in the order written`);
48
+ });
49
+ if (calls.length && path.length && calls.length !== path.length - 1) f.say(`${at}.calls`, `has ${calls.length}, and this path has ${path.length - 1} hop${path.length === 2 ? '' : 's'}: one call per hop`);
50
+ const called = calls.map((_, k) => f.text(calls, k, `${at}.calls`, { most: MOST.call }));
51
+ return { path, calls: called, story: readTold(f, moment.story, `${at}.story`), real: readTold(f, moment.real, `${at}.real`) };
52
+ }
53
+
54
+ export const toldTwice = Object.freeze({
55
+ name: 'told-twice',
56
+ about: 'A cast of people and everyday things acts a system out, each turns into the real thing where it stood, then the same story runs again for real. For explaining a system to people who do not work on it.',
57
+ fills: deepFreeze({
58
+ shape: 'told-twice', title: 'One picture, told twice', subtitle: '(optional)', analogy: 'a café stands in for a web service', intro: 'what is said on the title',
59
+ items: [{ id: 'customer', cast: { name: 'Customer', line: 'wants a coffee', icon: 'person-asking' }, real: { name: 'Browser', line: 'asks for a page', icon: 'window' },
60
+ turn: { headline: 'The customer turns into the browser', callout: 'The customer is the browser: it asks, then waits', notes: 'what is said' } }, '… 2 or more, in the row they stand in'],
61
+ flow: [{ path: ['customer', 'cashier'], calls: ['GET /order'], story: { headline: 'They tell the cashier', notes: 'what is said' }, real: { headline: 'It sends a request to the API', notes: 'what is said' } }, '… every item acts'],
62
+ together: { headline: '(optional) a last turn showing them all', notes: 'what is said' }, remember: ['2 to 4 things to remember'], closing: 'what is said at the end',
63
+ labels: { story: 'The café', turn: '…', real: '…', remember: '…' },
64
+ }),
65
+
66
+ read(f, input, base) {
67
+ const seen = new Set(), items = f.list(input, 'items', '', { least: 2, most: 8 }).map((v, i) => readItem(f, v, i, seen)).filter(Boolean);
68
+ const order = items.map((it) => it.id).filter(Boolean), flow = f.list(input, 'flow', '', { least: 1, most: 20 }).map((v, i) => readMoment(f, v, i, order)).filter(Boolean);
69
+ const acting = new Set(flow.flatMap((m) => m.path));
70
+ for (const id of order) if (!acting.has(id)) f.say('flow', `never reaches "${id}": every item acts in the story before it turns`);
71
+ const analogy = f.text(input, 'analogy', '', { most: MOST.analogy }), labels = readLabels(f, input, base.lang, LABELS);
72
+ const together = input.together === undefined ? null : readTold(f, input.together, 'together');
73
+ const points = f.list(input, 'remember', '', { least: 2, most: 4 }).map((_, i, all) => f.text(all, i, 'remember', { most: MOST.point }));
74
+ const intro = f.text(input, 'intro', '', { most: MOST.notes }), closing = f.text(input, 'closing', '', { most: MOST.notes });
75
+ const path = (m, look, k) => ({ id: `${look === 'cast' ? 'story' : 'real'}-${k + 1}`, part: look === 'cast' ? 'story' : 'real', look, ...(look === 'cast' ? m.story : m.real), beat: { kind: 'path', path: m.path, calls: look === 'cast' ? [] : m.calls } });
76
+ return {
77
+ analogy, labels,
78
+ items: items.map(({ id, cast, real }) => ({ id, cast, real })),
79
+ scenes: [
80
+ { id: 'title', part: 'title', look: 'cast', title: base.title, narration: intro, beat: { kind: 'title', subtitle: base.subtitle } },
81
+ ...flow.map((m, k) => path(m, 'cast', k)),
82
+ ...items.map((it) => ({ id: `turn-${it.id}`, part: 'turn', look: 'cast', title: it.title, narration: it.narration, beat: { kind: 'turn', item: it.id, callout: it.callout } })),
83
+ ...(together ? [{ id: 'together', part: 'turn', look: 'cast', ...together, beat: { kind: 'together' } }] : []),
84
+ ...flow.map((m, k) => path(m, 'real', k)),
85
+ { id: 'remember', part: 'remember', look: 'real', title: labels.remember, narration: closing, beat: { kind: 'points', points } },
86
+ ],
87
+ };
88
+ },
89
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * what-we-found: what a piece of work or a conversation came to, in the order a reader asks: what was looked at, what was
3
+ * found, what is still not known, what happens next. A claim names the messages it rests on, and "not sure" has a part of
4
+ * its own, and must be written even when it is empty, so a story cannot sound surer than the work was.
5
+ */
6
+ import { MOST, readLabels } from './words.js';
7
+ import { deepFreeze } from './fields.js';
8
+ import { checkAnywhere, checkClaim } from './conversation.js';
9
+
10
+ const LABELS = Object.freeze({ checked: 'What we checked', found: 'What we found', unsure: 'What we are not sure about', next: 'What happens next' });
11
+
12
+ function readSources(f, value, at) {
13
+ return f.list(value, 'sources', at, { most: 12, optional: true }).map((_, i, all) => f.text(all, i, `${at}.sources`, { most: MOST.source })).filter(Boolean);
14
+ }
15
+
16
+ export const whatWeFound = Object.freeze({
17
+ name: 'what-we-found',
18
+ checksClaims: true,
19
+ about: 'What a piece of work or a conversation came to: what was checked, what was found, what is still not known, what happens next. Claims name their sources, and a number must be in a source.',
20
+ fills: deepFreeze({
21
+ shape: 'what-we-found', title: 'Why the nightly job slowed down', subtitle: '(optional)', intro: 'what is said on the title',
22
+ checked: [{ what: 'Disk space', found: '40% full, so not the cause', notes: '(optional) what is said', sources: ['m3'] }],
23
+ found: [{ headline: 'The job waits on a lock', line: 'It waited 41 minutes on 3 nights', notes: 'what is said', sources: ['m7', 'm9'] }],
24
+ unsure: [{ what: 'Whether Tuesday had the same cause', why: 'there are no logs from that night', notes: '(optional) what is said' }, '… or [] when nothing is, which you must say'],
25
+ next: ['1 to 4 things that happen next'], closing: 'what is said at the end',
26
+ labels: { checked: '…', found: '…', unsure: '…', next: '…' },
27
+ }),
28
+
29
+ read(f, input, base, conversation) {
30
+ const labels = readLabels(f, input, base.lang, LABELS);
31
+ const checked = f.list(input, 'checked', '', { least: 1, most: 6 }).map((v, i) => {
32
+ const at = `checked[${i}]`, c = f.object(v, at, ['what', 'found', 'notes', 'sources']);
33
+ if (!c) return null;
34
+ const what = f.text(c, 'what', at, { most: MOST.line }), found = f.text(c, 'found', at, { most: MOST.headline }), notes = f.text(c, 'notes', at, { most: MOST.notes, optional: true }), sources = readSources(f, c, at);
35
+ checkClaim(f, conversation, at, [what, found, notes].filter(Boolean).join(' '), sources);
36
+ return { id: `checked-${i + 1}`, part: 'checked', look: null, title: found, narration: notes ?? (what && found ? `${what}: ${found}` : null), beat: { kind: 'card', group: 'checked', tone: null, name: what, line: found, sources } };
37
+ });
38
+ const found = f.list(input, 'found', '', { least: 1, most: 3 }).map((v, i) => {
39
+ const at = `found[${i}]`, c = f.object(v, at, ['headline', 'line', 'notes', 'sources']);
40
+ if (!c) return null;
41
+ const title = f.text(c, 'headline', at, { most: MOST.headline }), line = f.text(c, 'line', at, { most: MOST.line }), notes = f.text(c, 'notes', at, { most: MOST.notes }), sources = readSources(f, c, at);
42
+ checkClaim(f, conversation, at, [title, line, notes].filter(Boolean).join(' '), sources);
43
+ return { id: `found-${i + 1}`, part: 'found', look: null, title, narration: notes, beat: { kind: 'card', group: 'found', tone: 'ok', name: line, line: null, sources } };
44
+ });
45
+ const unsure = f.list(input, 'unsure', '', { most: 3 }).map((v, i) => {
46
+ const at = `unsure[${i}]`, c = f.object(v, at, ['what', 'why', 'notes']);
47
+ if (!c) return null;
48
+ const what = f.text(c, 'what', at, { most: MOST.headline }), why = f.text(c, 'why', at, { most: MOST.line }), notes = f.text(c, 'notes', at, { most: MOST.notes, optional: true });
49
+ checkAnywhere(f, conversation, at, [what, why, notes].filter(Boolean).join(' '));
50
+ return { id: `unsure-${i + 1}`, part: 'unsure', look: null, title: what, narration: notes ?? (what && why ? `${what}: ${why}` : null), beat: { kind: 'card', group: 'unsure', tone: 'warn', name: why, line: null, sources: [] } };
51
+ });
52
+ const points = f.list(input, 'next', '', { least: 1, most: 4 }).map((_, i, all) => f.text(all, i, 'next', { most: MOST.point }));
53
+ const intro = f.text(input, 'intro', '', { most: MOST.notes }), closing = f.text(input, 'closing', '', { most: MOST.notes });
54
+ for (const [at, text] of [['title', base.title], ['subtitle', base.subtitle], ['intro', intro], ['closing', closing], ...points.map((p, i) => [`next[${i}]`, p]), ...Object.entries(labels).map(([key, word]) => [`labels.${key}`, word])]) if (text) checkAnywhere(f, conversation, at, text);
55
+ return {
56
+ labels,
57
+ scenes: [
58
+ { id: 'title', part: 'title', look: null, title: base.title, narration: intro, beat: { kind: 'title', subtitle: base.subtitle } },
59
+ ...[...checked, ...found, ...unsure].filter(Boolean),
60
+ { id: 'next', part: 'next', look: null, title: labels.next, narration: closing, beat: { kind: 'points', points } },
61
+ ],
62
+ };
63
+ },
64
+ });
@@ -0,0 +1,29 @@
1
+ /** The words a shape keeps as limits, and the labels of its parts. */
2
+
3
+ /** The most characters each kind of text may be, so what a model writes fits what a film or a deck can show. */
4
+ export const MOST = Object.freeze({ title: 90, subtitle: 120, headline: 120, notes: 1000, name: 40, line: 70, callout: 140, call: 24, point: 90, analogy: 58, label: 40, icon: 40, id: 40, source: 80 });
5
+
6
+ const SAFE_ID = /^[a-z0-9][a-z0-9-]*$/;
7
+ /** An item's id: lower case letters, digits and dashes, so a tool can name a piece or a scene by it. */
8
+ export const isSafeId = (id) => typeof id === 'string' && SAFE_ID.test(id) && id.length <= MOST.id;
9
+
10
+ /** A part's labels: each one given, else the shape's English default. Another language gives its own: English words on a slide of another language would be wrong. */
11
+ export function readLabels(fields, input, lang, defaults) {
12
+ const given = input.labels === undefined ? {} : fields.object(input.labels, 'labels', Object.keys(defaults)) ?? {};
13
+ const english = !lang || /^en([-_]|$)/i.test(lang);
14
+ return Object.fromEntries(Object.entries(defaults).map(([key, word]) => {
15
+ const own = fields.text(given, key, 'labels', { most: MOST.label, optional: true });
16
+ if (!own && !english && !fields.problems.some((p) => p.at === `labels.${key}`)) fields.say(`labels.${key}`, `is needed: this story is in "${lang}", and the label is "${word}" in English`);
17
+ return [key, own ?? word];
18
+ }));
19
+ }
20
+
21
+ /** The scripts written without spaces between words, where a name runs straight on into the next word. */
22
+ const UNSPACED = /[\p{scx=Han}\p{scx=Hira}\p{scx=Kana}\p{scx=Thai}\p{scx=Lao}\p{scx=Khmr}\p{scx=Mymr}]/u;
23
+
24
+ /** Whether a text says a name: as a whole word, in any case, so "API" is not found in "rapid"; in a script with no spaces, anywhere. */
25
+ export function saysName(text, name) {
26
+ const letters = [...name], escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
27
+ const before = UNSPACED.test(letters[0]) ? '' : '(?<![\\p{L}\\p{N}])', after = UNSPACED.test(letters.at(-1)) ? '' : '(?![\\p{L}\\p{N}])';
28
+ return new RegExp(`${before}${escaped}${after}`, 'iu').test(text);
29
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * footprint-story/storyboard — a story written as plain data on a named shape, for a film and a deck to draw the same way.
3
+ * Pure: runs anywhere, imports nothing. Nothing here draws.
4
+ */
5
+
6
+ /** The shapes a storyboard may take. */
7
+ export type StoryShapeName = 'told-twice' | 'what-we-found';
8
+ /** A shape, for whoever is about to write one: what it is for, and a worked outline of its fields. */
9
+ export interface StoryShape { readonly name: StoryShapeName; readonly about: string; readonly fills: object }
10
+ /** One thing to fix: `at` is the place in the data, as it is written. */
11
+ export interface StoryboardProblem { readonly at: string; readonly message: string }
12
+ /** A message a claim can cite. An app maps its history onto this. */
13
+ export interface StoryMessage { readonly id: string; readonly who?: string; readonly text: string }
14
+ export interface StoryboardOptions {
15
+ /** The messages the storyboard's claims rest on: every message cited must be here, and every number in a claim in one it cites. */
16
+ readonly conversation?: readonly StoryMessage[];
17
+ }
18
+ /** What a scene does. */
19
+ export type StoryBeat =
20
+ | { readonly kind: 'title'; readonly subtitle: string | null }
21
+ /** Along the row of items, in order; on a told-twice story's real telling, what is called on each hop. */
22
+ | { readonly kind: 'path'; readonly path: readonly string[]; readonly calls: readonly string[] }
23
+ /** An item turns into the real thing where it stood, and the callout says both names. */
24
+ | { readonly kind: 'turn'; readonly item: string; readonly callout: string }
25
+ | { readonly kind: 'together' }
26
+ | { readonly kind: 'points'; readonly points: readonly string[] }
27
+ /** One card comes in beside the others of its group. */
28
+ | { readonly kind: 'card'; readonly group: string; readonly tone: 'ok' | 'warn' | null; readonly name: string; readonly line: string | null; readonly sources: readonly string[] };
29
+ /** One moment. `title` and `narration` make it a scene of a film's storyboard as they are. */
30
+ export interface StoryScene { readonly id: string; readonly part: string; readonly look: 'cast' | 'real' | null; readonly title: string; readonly narration: string; readonly beat: StoryBeat }
31
+ /** Someone or something in a told-twice row. */
32
+ export interface StoryWho { readonly name: string; readonly line: string; readonly icon: string | null }
33
+ /** A storyboard read: checked and frozen. */
34
+ export interface Storyboard {
35
+ readonly shape: StoryShapeName;
36
+ readonly title: string;
37
+ readonly subtitle: string | null;
38
+ readonly lang: string | null;
39
+ /** Whether its claims really were checked against a conversation: never for told-twice, which makes none. */
40
+ readonly verified: boolean;
41
+ /** Each part's label, by part. */
42
+ readonly labels: Readonly<Record<string, string>>;
43
+ readonly scenes: readonly StoryScene[];
44
+ /** told-twice: the row, in order. */
45
+ readonly items?: readonly { readonly id: string; readonly cast: StoryWho; readonly real: StoryWho }[];
46
+ /** told-twice: says what stands in for what. */
47
+ readonly analogy?: string;
48
+ }
49
+
50
+ /** The shapes there are. */
51
+ export function storyShapes(): readonly StoryShape[];
52
+ /** Every problem in a storyboard (data or JSON text); empty when it can be drawn. Never throws on what it is given to read. */
53
+ export function storyboardProblems(input: unknown, options?: StoryboardOptions): readonly StoryboardProblem[];
54
+ /** A storyboard read. Throws when it cannot be drawn, its `problems` listing all of them. */
55
+ export function readStoryboard(input: unknown, options?: StoryboardOptions): Storyboard;
56
+ /** Every number in a text, with any thousands commas taken out: '1,250.5' → '1250.5'. */
57
+ export function numbersIn(text: string): readonly string[];
package/storyboard.js ADDED
@@ -0,0 +1,10 @@
1
+ // footprint-story/storyboard — a story written as plain data on a named shape, for a film and a deck to draw the same way.
2
+ // Pure: runs anywhere (browser or Node), imports nothing, and loads only when imported. Nothing here draws: StoryDeck makes
3
+ // its pieces and clicks from a board, StoryReel its scenes and recipe.
4
+ //
5
+ // storyShapes() → [{ name, about, fills }]: the shapes there are, and a worked outline of each
6
+ // storyboardProblems(input, { conversation }) → [{ at, message }]: everything to fix, all at once
7
+ // readStoryboard(input, { conversation }) → the board: checked and frozen, with its scenes
8
+
9
+ export { storyShapes, storyboardProblems, readStoryboard } from './src/storyboard/read.js';
10
+ export { numbersIn } from './src/storyboard/conversation.js';