@brunosouzas/design-system 0.3.2 → 0.5.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/README.md CHANGED
@@ -25,7 +25,7 @@ The visual identity of [brunosouzas.com](https://brunosouzas.com): design tokens
25
25
 
26
26
  ## Status
27
27
 
28
- Early. The first change of the [roadmap](docs/roadmap.md) is implemented: the token source and its CSS, TypeScript and JSON exports, the motion components, and the circuit-breaker state machine as the first catalogue scene (16:9 and 4:5, Portuguese and English), with commands to render its stills and videos. The first variety scene, `publish-subscribe`, is implemented with independent message copies, Publish acceptance, two ACKs and a local invoice NACK. Its owner stills/video approval is pending; implementation is not visual approval. The illustration pieces (roadmap item 3), the metro and diagram pieces (item 6) stackledger's map markers, zones and icons (AB#167, 0.3.0) and the body diagram pieces (AB#141, 0.3.2) are implemented: the static drawing library that covers, banners, diagrams and scenes share, with a command that builds a blog cover from a JSON spec. The published covers are not ported to it yet (item 5).
28
+ Early. The first change of the [roadmap](docs/roadmap.md) is implemented: the token source and its CSS, TypeScript and JSON exports, the motion components, and the circuit-breaker state machine as the first catalogue scene (16:9 and 4:5, Portuguese and English), with commands to render its stills and videos. The first variety scene, `publish-subscribe`, is implemented with independent message copies, Publish acceptance, two ACKs and a local invoice NACK. Its owner stills/video approval is pending; implementation is not visual approval. The illustration pieces (roadmap item 3), the metro and diagram pieces (item 6) stackledger's map markers, zones and icons (AB#167, 0.3.0) and the body diagram pieces (AB#141, 0.3.2) are implemented: the static drawing library that covers, banners, diagrams and scenes share, with a command that builds a blog cover from a JSON spec. The published covers are not ported to it yet (item 5). The cast of mascots and the emotes (AB#152, 0.5.0) are pieces too, with the approved cast motion test in the Studio; new videos are language-neutral by default ([ADR 0005](docs/adr/0005-neutral-videos-and-the-cast.md)).
29
29
 
30
30
  ## What it is
31
31
 
@@ -34,7 +34,8 @@ The blog's articles explain how systems behave: a circuit breaker opening, a pip
34
34
  - **Design tokens, one source.** Colours with fixed meanings (green healthy, orange blocked, yellow transitional, red the failure path), the Lato type scale, the thick ink outline, the hard offset shadow, radii, and motion timings. Exported as CSS variables for the site, TypeScript for the animations and JSON for anything else (the SVG generators of the static diagrams).
35
35
  - **Motion components.** React components for [Remotion](https://www.remotion.dev): an ink block with a hard shadow, a pill label, an arrow that draws itself, a token travelling along a transition, call-result squares, a countdown ring. Flow primitives in `src/brand/flow-components.tsx` add a white hexagonal `Exchange`, rail-ended `Queue`, yellow-tab `TaggedContainer` and persistent `PositionedToken` (stationary or path-positioned). Copy identities, splits and ACK/NACK rules belong to scenes, not to a generic message engine.
36
36
  - **Illustration pieces.** The objects the blog's covers and diagrams draw again and again (pill label, sheet, code panel, robot, server stack, gear, road, the 26 EIP icons and more), each a pure TypeScript function that returns an SVG node tree from the tokens. The same tree becomes an SVG file for a cover and React elements for a scene, and a scene animates a piece by recomputing its parameters every frame. See [Illustration pieces and covers](#illustration-pieces-and-covers).
37
- - **A catalogue of scenes.** Each scene animates something a published article already says, in 16:9 (article) and 4:5 (LinkedIn), in Portuguese and English from one text table. The first is the [circuit-breaker state machine](docs/spikes/2026-10-22-motion-pilot/README.md).
37
+ - **A cast and emotes.** Seven mascots that are the technical objects themselves (Dev, AI agent, Architect, DevOps, Data, Requester, Message) and eight reaction symbols with a fixed meaning, so a video can tell its story without words. See [The cast and the emotes](#the-cast-and-the-emotes).
38
+ - **A catalogue of scenes.** Each scene animates something a published article already says. The first two are in 16:9 (article) and 4:5 (LinkedIn), in Portuguese and English from one text table; new scenes are one language-neutral 16:9 video ([ADR 0005](docs/adr/0005-neutral-videos-and-the-cast.md)). The first is the [circuit-breaker state machine](docs/spikes/2026-10-22-motion-pilot/README.md).
38
39
 
39
40
  The style follows the illustration covers of the [Spotify Engineering blog](https://engineering.atspotify.com/): flat shapes, a dark ink outline, a hard shadow with no blur, saturated colours on a calm page.
40
41
 
@@ -90,7 +91,7 @@ npm run stills -- publish-subscribe # 24 phase/final PNGs (six per
90
91
  npm run stills -- publish-subscribe --dry-run # print 24 commands, no effects
91
92
  npm run render -- publish-subscribe --dry-run # print eight commands; real video waits for owner stills approval
92
93
  npm run build # compile the package into dist/ (the package test needs it)
93
- npm run cover -- cover.json --out card.svg # build a blog cover (or a banner) from its spec
94
+ npm run cover -- cover.json --out card.svg # build a blog cover (or a banner, diagram or icon) from its spec
94
95
  npm run pieces:sheet -- --out out/pieces.svg # every piece once, labelled, for review
95
96
  npm run studio # Remotion Studio, for frame-by-frame review
96
97
  ```
@@ -131,7 +132,7 @@ The components take every colour and size from the tokens, and what is visible a
131
132
  - `toSvg(node)` writes a tree as SVG text; `toReact(node)` turns the same tree into React elements. Both describe the same elements, attributes and text, which the tests check.
132
133
  - `pose(node, { x, y, tilt, squash, hop, scale, shake, opacity })` animates any piece around its base point; a scene recomputes the numbers every frame.
133
134
  - Every colour parameter is a token path (`"color.healthy"`, `"illustration.accent"`), never a hex value, except the three data fills of the map pieces below. Each piece checks its parameters before drawing and names the first wrong one. Positions and points take any finite number, because a map has no edge; sizes stay within 10000. A drawing that would hold a non-finite number throws `<piece>: the drawing overflows`.
134
- - The 51 pieces, with their anchors, parameters and defaults, are listed in [design §4](openspec/changes/archive/2026-10-23-illustration-pieces/design.md) (the 26 cover pieces), in [design §3](openspec/changes/archive/2026-10-23-metro-and-diagram-pieces/design.md) of `metro-and-diagram-pieces` (the nine map and diagram pieces) and in [design §3, §5 and §6](openspec/changes/archive/2026-10-23-stackledger-map-and-link-pieces/design.md) of `stackledger-map-and-link-pieces` (the six markers, zones and icons) and in [design §3 to §5](openspec/changes/archive/2026-10-23-body-diagram-pieces/design.md) of `body-diagram-pieces` (the ten body diagram pieces); the rules are in [`openspec/specs/illustration-pieces/`](openspec/specs/illustration-pieces/spec.md). `npm run pieces:sheet -- --out <file.svg>` draws them all.
135
+ - The 52 pieces, with their anchors, parameters and defaults, are listed in [design §4](openspec/changes/archive/2026-10-23-illustration-pieces/design.md) (the 26 cover pieces), in [design §3](openspec/changes/archive/2026-10-23-metro-and-diagram-pieces/design.md) of `metro-and-diagram-pieces` (the nine map and diagram pieces) and in [design §3, §5 and §6](openspec/changes/archive/2026-10-23-stackledger-map-and-link-pieces/design.md) of `stackledger-map-and-link-pieces` (the six markers, zones and icons) and in [design §3 to §5](openspec/changes/archive/2026-10-23-body-diagram-pieces/design.md) of `body-diagram-pieces` (the ten body diagram pieces), plus `stateStart` ([design §4](openspec/changes/archive/2026-10-24-designer-formats/design.md) of `designer-formats`); the rules are in [`openspec/specs/illustration-pieces/`](openspec/specs/illustration-pieces/spec.md). `npm run pieces:sheet -- --out <file.svg>` draws them all.
135
136
 
136
137
  ### Maps and diagrams
137
138
 
@@ -160,7 +161,43 @@ Ten pieces draw what the blog's body diagrams need beyond the map pieces: `diagr
160
161
  - The shapes take one to three centred lines (`text`), an optional `subtitle` and `textSize`. Without `fill` the face is the surface's panel; with a fill, every word is ink.
161
162
  - Backticks mark code: `` "adopts a `correlationId`" `` draws `correlationId` in the monospace stack, as a `tspan`. An odd number of backticks is drawn as is.
162
163
  - Widths the piece estimates (`tagWidth`, `conditionWidth`, a label's `width`) can be passed by a caller that measures its text.
163
- - A body diagram is 800 wide. Lay it out in 560 units and show it 800 wide: the pieces then come out at the sizes of the published diagrams (text about 18, outline about 4); only the hard shadow is shallower (5 instead of 8).
164
+ - A body diagram is 800 wide. Lay it out in 560 units and show it 800 wide: the pieces then come out at the sizes of the published diagrams (text about 18, outline about 4); only the hard shadow is shallower (5 instead of 8). The cover command does this for you with `"format": "diagram"` (below).
165
+ - `stateStart({ cx, cy, r, surface })` draws the filled start dot of a state machine (7.7 units, 11 at 800), in the surface's text colour, with no outline and no shadow.
166
+
167
+ ### The cast and the emotes
168
+
169
+ `mascot({ who, x, y, face, action, scale, look, headTilt, prop })` draws one character standing with the centre of its ground line at `(x, y)`. Each one carries its identity in three layers, so no video has to introduce it: a body shape, a mark always visible, and a prop when its hands are free.
170
+
171
+ | `who` | Body | Mark | Prop | Colour |
172
+ |---|---|---|---|---|
173
+ | `dev` | modern all-in-one computer, face and code on the screen | `</>` on the chin | | `cast.dev.*` |
174
+ | `agent` | retro lamp all-in-one: dome, chrome neck, the screen is the head | AI spark | | white, glowing face (`cast.glow`) |
175
+ | `architect` | drafting set square with its hole and a pencil | ruler ticks | diagram board | `cast.architect.*` |
176
+ | `devops` | gear | ∞ | wrench | `cast.devops.*` (magenta, never the blocked orange) |
177
+ | `data` | cylinder with a lid | two bands | magnifier | `cast.data.*` |
178
+ | `request` | speech bubble | the request line | | `color.caller` |
179
+ | `message` | envelope (0.7 of the others) | flap and seal | | white |
180
+
181
+ - `face`: `normal`, `blink`, `happy`, `wink`, `surprised`, `worried`, `thinking`, `focused`. One drawing for everyone; the agent's face glows on its dark screen.
182
+ - `action`: `none` (the default: an expression has no hands or feet), `rest`, `wave`, `hold`, `reach`, `up`, `shrug`, `type`. Arms and legs start at the body's sides; hands are mittens.
183
+ - `look` (-1 to 1) moves the gaze, `headTilt` (-30 to 30) turns the agent's head on its neck, `prop: false` hides the prop. Every part is a group named by `data-part` (`body`, `face`, `eyes`, `brows`, `mouth`, `arm-left`, `arm-right`, `legs`, `prop`, `mark`, …), so a scene can animate it.
184
+
185
+ `emote({ kind, x, y, scale, burst })` draws a reaction beside a head, never over the face. Each kind keeps one meaning in every video, like the colours:
186
+
187
+ | `kind` | Shape | Meaning |
188
+ |---|---|---|
189
+ | `idea` | bulb | an idea |
190
+ | `doubt` | ? | doubt, not understood |
191
+ | `problem` | ! in a red triangle | a problem appears |
192
+ | `worked` | green check | it worked |
193
+ | `refused` | red cross | refused |
194
+ | `pressure` | sweat drops | under pressure |
195
+ | `liked` | heart | liked |
196
+ | `waiting` | Z Z Z | waiting |
197
+
198
+ In a scene, an emote pops in on the frame the face changes (`emotePop` in `src/brand/cast-motion.ts`, with `leave`, `blinking`, `breathe`, `squashAfter` and `hop`), one at a time, and the body acts through `pose`. A mascot's colour is its identity and never changes with state. The approved motion test is the Studio composition `cast-motion-test` (folder `cast`): `npx remotion render src/index.ts cast-motion-test`. It is a proof of the cast, not an article scene, so `npm run stills` and `npm run render` do not list it.
199
+
200
+ The cast draws the same on any background; a dark-surface variant is planned (AB#187).
164
201
 
165
202
  ### A cover
166
203
 
@@ -180,10 +217,10 @@ A cover is a JSON spec kept with its article, outside this repository: drafts ar
180
217
  }
181
218
  ```
182
219
 
183
- - `background` names one of the 21 pairs in `coverBackground` (a background and its ring colour, measured from the published covers). Each pair is used by one cover only.
220
+ - `background` names one of the 21 pairs in `coverBackground` (a background and its ring colour, measured from the published covers). Which covers may share a pair is the `designer` skill's rule, not the package's.
184
221
  - `pieces` are drawn in order; their words go on top.
185
222
  - The `rings` centre stays within ±10000 in a cover spec, and a piece that overflows is reported at its entry (`pieces[1]: the drawing overflows`).
186
- - `custom` (optional) is the cover's one free-form object, a JSON node tree limited to drawing tags and a fixed list of attributes, with no external references. An object that repeats becomes a piece instead.
223
+ - `custom` (optional) is the cover's one free-form object, a JSON node tree limited to drawing tags and a fixed list of attributes, with no external references. An object that repeats becomes a piece instead. It is painted after every piece's shapes; `customAt: <i>` paints it before `pieces[i]` instead, so a pill can sit on top of it (words always stay on top).
187
224
  - The three published covers that proved the pieces are the test fixtures in `test/fixtures/covers/`.
188
225
 
189
226
  ### Banners
@@ -195,6 +232,19 @@ A spec with `"format": "banner"` (the default is `"cover"`) builds a home banner
195
232
 
196
233
  The banner is one image that scales down as a whole. Words in it become small on a phone (a 19px label is about 6px at 375px wide), so keep them few or none. `test/fixtures/covers/home-banner.json` is the brunosouzas.com home banner redrawn from the pieces.
197
234
 
235
+ ### Body diagrams and icons from a spec
236
+
237
+ The same command draws the blog's body diagrams and icons, so a consumer needs no script and no local install:
238
+
239
+ ```sh
240
+ npx --yes --package @brunosouzas/design-system@<version> brunosouzas-cover design/<topic>.json --lang pt --out images/<topic>-pt.svg
241
+ ```
242
+
243
+ - `"format": "diagram"` takes a `height` in layout units (1 to 10000) and no `background`: the spec is laid out 560 wide and written 800 wide, on the `color.panel` panel with rounded corners (`bodyDiagram.radius`, 14 at 800) and rings in `color.ring` that show at step 28 and width 2, out to the farthest corner. Pieces get the map outline (3) and label size (13), which show at 4.3 and 18.6. `test/fixtures/covers/circuit-breaker-states.json` is the circuit breaker article's state diagram.
244
+ - `"format": "icon"` holds one `eipIcon`, `uiIcon` or `linkIcon` at 0,0 and writes it in a box of its own size (64x40 for an EIP icon). An EIP icon takes its title and description, with the CC BY 4.0 credit, from the icon's data, so its spec has neither; a UI or link icon spec needs both. No `background`, `rings` or `custom`.
245
+ - **Text tables.** `"text": { "pt": { "open": ["taxa de falha", "> limite"] }, "en": { ... } }` holds one table per language with the same keys; any string `"@open"` elsewhere in the spec (pieces, `title`, `description`, even `id`) is replaced by that key's value for the language passed with `--lang <code>`. Every language must define every key, and the ids in the output gain the language (`<id>-pt`). Without `text`, strings are drawn as written, so `"@home"` stays a label.
246
+ - Like a cover, a diagram or icon is a standalone image: `uiIcon` `colour: "currentColor"` is refused in every format.
247
+
198
248
  ### A new piece
199
249
 
200
250
  Add it to `src/pieces/library.ts` (or `src/pieces/metro.ts` for a map or diagram piece) with `definePiece(name, schema, draw)`, take its colours and sizes from the tokens (a new colour goes to `tokens/source.json` first), register it in `PIECES`, and give it a sample in `scripts/pieces-sheet.ts`. The catalogue test then checks that it draws with only its required parameters and uses token colours only.
@@ -222,6 +272,7 @@ Project-wide decisions are recorded as ADRs in [`docs/adr/`](docs/adr/README.md)
222
272
  - **One token source.** Colours, type, outline, shadow and motion timings live in one file; the site, the animations and the SVG scripts read generated exports instead of keeping their own copies. [ADR 0002](docs/adr/0002-one-token-source.md)
223
273
  - **One visual language, fixed colour meanings.** Green is always healthy, orange always blocked, red always the failure path, so a reader learns the code once. Rotated text is allowed since the 2026-10-23 amendment. [ADR 0003](docs/adr/0003-visual-language-and-colour-meaning.md)
224
274
  - **Scenes illustrate published articles.** Every string on screen comes from the article, so an animation never says more than the reviewed text. [ADR 0004](docs/adr/0004-scenes-illustrate-published-articles.md)
275
+ - **Neutral videos, told by a fixed cast.** One video for both languages, no words on screen, a caption only when the motion cannot explain itself, and every gesture tied to an article line. [ADR 0005](docs/adr/0005-neutral-videos-and-the-cast.md)
225
276
 
226
277
  ## How this project is built
227
278
 
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- export declare const COVER_USAGE = "usage: npm run cover -- <spec.json> --out <file.svg>";
2
+ export declare const COVER_USAGE = "usage: npm run cover -- <spec.json> --out <file.svg> [--lang <code>]";
3
3
  export type CoverIo = {
4
4
  cwd: string;
5
5
  log: (line: string) => void;
@@ -10,12 +10,13 @@ export declare const parseCoverArgs: (args: string[], cwd: string) => {
10
10
  ok: true;
11
11
  spec: string;
12
12
  out: string;
13
+ lang?: string;
13
14
  } | {
14
15
  ok: false;
15
16
  problem: string;
16
17
  };
17
18
  /** Builds the cover text for a spec file's contents, or the problem lines. */
18
- export declare const buildCover: (text: string, label: string) => {
19
+ export declare const buildCover: (text: string, label: string, lang?: string) => {
19
20
  ok: true;
20
21
  svg: string;
21
22
  } | {
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // `npm run cover -- <spec.json> --out <file.svg>`, installed as the bin
2
+ // `npm run cover -- <spec.json> --out <file.svg> [--lang <code>]`, installed as the bin
3
3
  // `brunosouzas-cover` (spec cover-cli; design §6):
4
4
  // builds one blog cover from a cover spec kept with its article in the vault.
5
5
  //
@@ -14,23 +14,29 @@ import { closeSync, existsSync, openSync, readFileSync, realpathSync, renameSync
14
14
  import { randomUUID } from "node:crypto";
15
15
  import { dirname, resolve } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
- import { coverTree, validateCoverSpec } from "../src/pieces/cover.js";
17
+ import { coverTree, LANGUAGE, validateCoverSpec } from "../src/pieces/cover.js";
18
18
  import { toSvg } from "../src/pieces/render.js";
19
- export const COVER_USAGE = "usage: npm run cover -- <spec.json> --out <file.svg>";
19
+ export const COVER_USAGE = "usage: npm run cover -- <spec.json> --out <file.svg> [--lang <code>]";
20
20
  /** The first invocation problem, in the order of the spec, or the two paths. */
21
21
  export const parseCoverArgs = (args, cwd) => {
22
22
  const specs = [];
23
23
  const outs = [];
24
+ const langs = [];
24
25
  let unknown;
25
26
  let outWithoutValue = false;
27
+ let langWithoutValue = false;
26
28
  for (let i = 0; i < args.length; i++) {
27
29
  const arg = args[i];
28
- if (arg === "--out") {
30
+ if (arg === "--out" || arg === "--lang") {
29
31
  const value = args[i + 1];
30
- if (value === undefined || value.startsWith("--"))
31
- outWithoutValue = true;
32
+ if (value === undefined || value.startsWith("--")) {
33
+ if (arg === "--out")
34
+ outWithoutValue = true;
35
+ else
36
+ langWithoutValue = true;
37
+ }
32
38
  else {
33
- outs.push(value);
39
+ (arg === "--out" ? outs : langs).push(value);
34
40
  i++;
35
41
  }
36
42
  }
@@ -56,11 +62,17 @@ export const parseCoverArgs = (args, cwd) => {
56
62
  const target = resolve(cwd, out);
57
63
  if (existsSync(target) && statSync(target).isDirectory())
58
64
  return { ok: false, problem: `the output is a folder: ${out}` };
65
+ if (langWithoutValue)
66
+ return { ok: false, problem: "--lang needs a value: <code>" };
67
+ if (langs.length > 1)
68
+ return { ok: false, problem: "--lang given more than once" };
69
+ if (langs.length === 1 && !LANGUAGE.test(langs[0]))
70
+ return { ok: false, problem: `--lang expects a language code such as pt or en, found ${langs[0]}` };
59
71
  if (specs.length === 0)
60
72
  return { ok: false, problem: "missing the cover spec: <spec.json>" };
61
73
  if (specs.length > 1)
62
74
  return { ok: false, problem: `one cover spec at a time, found ${specs.length}` };
63
- return { ok: true, spec: specs[0], out };
75
+ return { ok: true, spec: specs[0], out, ...(langs.length === 1 ? { lang: langs[0] } : {}) };
64
76
  };
65
77
  /** Parses the spec text; a syntax error names the line and column. */
66
78
  const parseJson = (text, label) => {
@@ -82,14 +94,14 @@ const parseJson = (text, label) => {
82
94
  }
83
95
  };
84
96
  /** Builds the cover text for a spec file's contents, or the problem lines. */
85
- export const buildCover = (text, label) => {
97
+ export const buildCover = (text, label, lang) => {
86
98
  const parsed = parseJson(text, label);
87
99
  if ("problem" in parsed)
88
100
  return { ok: false, lines: [parsed.problem] };
89
- const problems = validateCoverSpec(parsed.value);
101
+ const problems = validateCoverSpec(parsed.value, { lang });
90
102
  if (problems.length > 0)
91
103
  return { ok: false, lines: problems.map((p) => `${p.path}: ${p.message}`) };
92
- return { ok: true, svg: `${toSvg(coverTree(parsed.value))}\n` };
104
+ return { ok: true, svg: `${toSvg(coverTree(parsed.value, { lang }))}\n` };
93
105
  };
94
106
  export const runCover = (args, io) => {
95
107
  const invocation = parseCoverArgs(args, io.cwd);
@@ -108,7 +120,7 @@ export const runCover = (args, io) => {
108
120
  io.error(COVER_USAGE);
109
121
  return 1;
110
122
  }
111
- const result = buildCover(text, invocation.spec);
123
+ const result = buildCover(text, invocation.spec, invocation.lang);
112
124
  if (!result.ok) {
113
125
  for (const line of result.lines)
114
126
  io.error(line);
@@ -0,0 +1,32 @@
1
+ import { type SvgNode } from "./node.ts";
2
+ export declare const MASCOTS: readonly ["dev", "agent", "architect", "devops", "data", "request", "message"];
3
+ export declare const MASCOT_FACES: readonly ["normal", "blink", "happy", "wink", "surprised", "worried", "thinking", "focused"];
4
+ export declare const MASCOT_ACTIONS: readonly ["none", "rest", "wave", "hold", "reach", "up", "shrug", "type"];
5
+ export declare const EMOTE_KINDS: readonly ["idea", "doubt", "problem", "worked", "refused", "pressure", "liked", "waiting"];
6
+ export type MascotName = (typeof MASCOTS)[number];
7
+ export type MascotFace = (typeof MASCOT_FACES)[number];
8
+ export type MascotAction = (typeof MASCOT_ACTIONS)[number];
9
+ export type EmoteKind = (typeof EMOTE_KINDS)[number];
10
+ export declare const face: (cx: number, cy: number, f: MascotFace, col: string, mouthFill: string) => SvgNode;
11
+ export type MascotParams = {
12
+ who: MascotName;
13
+ x: number;
14
+ y: number;
15
+ face?: MascotFace;
16
+ action?: MascotAction;
17
+ scale?: number;
18
+ look?: number;
19
+ headTilt?: number;
20
+ prop?: boolean;
21
+ };
22
+ export type EmoteParams = {
23
+ kind: EmoteKind;
24
+ x: number;
25
+ y: number;
26
+ scale?: number;
27
+ burst?: boolean;
28
+ };
29
+ /** One character standing with the centre of its ground line at (x, y). */
30
+ export declare const mascot: import("./piece.ts").Piece<MascotParams>;
31
+ /** One reaction symbol with a fixed meaning, centred on (x, y), beside a head. */
32
+ export declare const emote: import("./piece.ts").Piece<EmoteParams>;
@@ -0,0 +1,280 @@
1
+ // The cast (spec illustration-pieces, change cast-and-emotes, card 152):
2
+ // `mascot`, seven characters with a shared face, actions and named parts, and
3
+ // `emote`, eight reaction symbols with a fixed meaning (ADR 0005).
4
+ //
5
+ // Why here: covers, diagrams and scenes draw the same characters from one
6
+ // source; a scene animates them by recomputing parameters per frame and
7
+ // wrapping the piece in `pose`.
8
+ //
9
+ // The geometry is the approved spike's, ported unchanged
10
+ // (~/.hermes/spikes/design-system/cast-and-emotes/cast.mts). The frozen fixture
11
+ // test/fixtures/cast-0.5.0.json pins every call's output, so a refactor here
12
+ // must keep every hash.
13
+ import { drawn, el, g } from "./node.js";
14
+ import { colour, pos } from "./params.js";
15
+ import { definePiece } from "./piece.js";
16
+ const c = (path) => colour(path);
17
+ export const MASCOTS = ["dev", "agent", "architect", "devops", "data", "request", "message"];
18
+ export const MASCOT_FACES = ["normal", "blink", "happy", "wink", "surprised", "worried", "thinking", "focused"];
19
+ export const MASCOT_ACTIONS = ["none", "rest", "wave", "hold", "reach", "up", "shrug", "type"];
20
+ export const EMOTE_KINDS = ["idea", "doubt", "problem", "worked", "refused", "pressure", "liked", "waiting"];
21
+ const ink = () => c("color.ink");
22
+ const O = () => ({ stroke: ink(), "stroke-width": 4, "stroke-linejoin": "round" });
23
+ const shade = (d, dx = 8) => el("path", { d, fill: ink(), transform: `translate(${dx} ${dx})` });
24
+ const solid = (d, fill, dx = 8) => [shade(d, dx), el("path", { d, fill, ...O() })];
25
+ const shine = (d, col) => el("path", { d, fill: "none", stroke: col, "stroke-width": 9, "stroke-linecap": "round" });
26
+ const rr = (x, y, w, h, r) => `M${x + r},${y} H${x + w - r} Q${x + w},${y} ${x + w},${y + r} V${y + h - r} Q${x + w},${y + h} ${x + w - r},${y + h} H${x + r} Q${x},${y + h} ${x},${y + h - r} V${y + r} Q${x},${y} ${x + r},${y} Z`;
27
+ // ---------------------------------------------------------------- face (macs.ts)
28
+ export const face = (cx, cy, f, col, mouthFill) => {
29
+ const k = 1, dx = 24 * k;
30
+ const eye = (x, right) => {
31
+ if (f === "blink")
32
+ return el("path", { d: `M${x - 9 * k},${cy} H${x + 9 * k}`, stroke: col, "stroke-width": 5 * k, "stroke-linecap": "round" });
33
+ if (f === "happy" || (f === "wink" && right))
34
+ return el("path", { d: `M${x - 10 * k},${cy + 3 * k} Q${x},${cy - 10 * k} ${x + 10 * k},${cy + 3 * k}`, fill: "none", stroke: col, "stroke-width": 5 * k, "stroke-linecap": "round" });
35
+ if (f === "focused")
36
+ return el("path", { d: right ? `M${x + 10 * k},${cy - 6 * k} L${x - 8 * k},${cy} L${x + 10 * k},${cy + 6 * k}` : `M${x - 10 * k},${cy - 6 * k} L${x + 8 * k},${cy} L${x - 10 * k},${cy + 6 * k}`, fill: "none", stroke: col, "stroke-width": 5 * k, "stroke-linecap": "round", "stroke-linejoin": "round" });
37
+ const s = f === "surprised" ? 1.35 : 1, o = f === "thinking" ? 5 * k : 0;
38
+ return g({}, el("ellipse", { cx: x + o, cy: cy - o, rx: 7 * k * s, ry: 10 * k * s, fill: col }), el("circle", { cx: x + o + 2.5 * k, cy: cy - o - 4 * k, r: 2.6 * k, fill: c("color.white") }));
39
+ };
40
+ const by = cy - 22 * k, brow = (x, a, b) => el("path", { d: `M${x - 9 * k},${by + a * k} L${x + 9 * k},${by + b * k}`, stroke: col, "stroke-width": 4.5 * k, "stroke-linecap": "round" });
41
+ const brows = f === "worried" ? [brow(cx - dx, 4, -3), brow(cx + dx, -3, 4)]
42
+ : f === "surprised" ? [brow(cx - dx, -6, -8), brow(cx + dx, -8, -6)]
43
+ : f === "thinking" ? [brow(cx + dx, 0, -7)] : [];
44
+ const my = cy + 26 * k, ln = (d, fill = "none") => el("path", { d, fill, stroke: col, "stroke-width": 5 * k, "stroke-linecap": "round", "stroke-linejoin": "round" });
45
+ const smile = ln(`M${cx - 12 * k},${my - 2 * k} Q${cx},${my + 11 * k} ${cx + 12 * k},${my - 2 * k}`);
46
+ const mouth = {
47
+ blink: smile,
48
+ normal: smile,
49
+ happy: ln(`M${cx - 15 * k},${my - 4 * k} Q${cx},${my + 16 * k} ${cx + 15 * k},${my - 4 * k} Z`, mouthFill),
50
+ wink: ln(`M${cx - 12 * k},${my - 2 * k} Q${cx + 2 * k},${my + 12 * k} ${cx + 14 * k},${my - 6 * k}`),
51
+ surprised: el("ellipse", { cx, cy: my + 4 * k, rx: 7 * k, ry: 9 * k, fill: mouthFill, stroke: col, "stroke-width": 4 * k }),
52
+ worried: ln(`M${cx - 12 * k},${my + 5 * k} Q${cx},${my - 6 * k} ${cx + 12 * k},${my + 5 * k}`),
53
+ thinking: ln(`M${cx - 7 * k},${my + 2 * k} L${cx + 10 * k},${my - 2 * k}`),
54
+ focused: ln(`M${cx - 8 * k},${my} L${cx + 8 * k},${my}`),
55
+ }[f];
56
+ return g({ "data-part": "face" }, g({ "data-part": "eyes" }, eye(cx - dx, false), eye(cx + dx, true)), brows.length ? g({ "data-part": "brows" }, ...brows) : null, g({ "data-part": "mouth" }, mouth));
57
+ };
58
+ // Arm targets as offsets from the shoulders (left, right). "computer" = macs.ts, "object" = mascots4.ts.
59
+ const COMPUTER = {
60
+ armWidth: 15, legWidth: 15, bend: 8,
61
+ offsets: { rest: [[-20, 40], [20, 40]], wave: [[-20, 40], [30, -86]], hold: [[30, 50], [-30, 50]], reach: [[-20, 40], [70, -4]], up: [[-30, -90], [30, -90]], shrug: [[-44, -26], [44, -26]], type: [[26, 58], [-26, 58]] },
62
+ };
63
+ const OBJECT = {
64
+ armWidth: 16, legWidth: 16, bend: 8,
65
+ offsets: { rest: [[-22, 34], [22, 34]], wave: [[-22, 34], [34, -92]], hold: [[0, 0], [0, 0]], reach: [[-22, 34], [70, -4]], up: [[-34, -96], [34, -96]], shrug: [[-46, -30], [46, -30]], type: [[26, 58], [-26, 58]] },
66
+ };
67
+ const arm = (from, to, side, w) => [
68
+ el("path", { d: `M${from[0]},${from[1]} Q${(from[0] + to[0]) / 2 + side * 14},${Math.max(from[1], to[1]) + 8} ${to[0]},${to[1]}`, fill: "none", stroke: ink(), "stroke-width": w, "stroke-linecap": "round" }),
69
+ el("ellipse", { cx: to[0], cy: to[1], rx: 15, ry: 12, fill: ink(), transform: `rotate(${side * 20} ${to[0]} ${to[1]})` }),
70
+ ];
71
+ // ---------------------------------------------------------------- props (mascots4.ts)
72
+ const boardProp = (x, y) => [
73
+ shade(rr(x, y, 104, 78, 10), 6), el("path", { d: rr(x, y, 104, 78, 10), fill: c("color.white"), ...O() }),
74
+ el("path", { d: `M${x + 30},${y + 28} H${x + 52} V${y + 52} H${x + 74}`, fill: "none", stroke: ink(), "stroke-width": 3 }),
75
+ el("rect", { x: x + 12, y: y + 18, width: 22, height: 20, rx: 4, fill: c("color.caller"), stroke: ink(), "stroke-width": 3 }),
76
+ el("rect", { x: x + 70, y: y + 42, width: 22, height: 20, rx: 4, fill: c("color.healthy"), stroke: ink(), "stroke-width": 3 }),
77
+ el("rect", { x: x + 42, y: y + 8, width: 20, height: 18, rx: 4, fill: c("color.transitional"), stroke: ink(), "stroke-width": 3 }),
78
+ ];
79
+ const wrenchProp = (x, y) => {
80
+ const d = `M${x},${y} L${x + 50},${y - 50} M${x + 50},${y - 50}`;
81
+ return [
82
+ el("path", { d, stroke: ink(), "stroke-width": 18, "stroke-linecap": "round" }),
83
+ el("path", { d, stroke: c("cast.metal"), "stroke-width": 10, "stroke-linecap": "round" }),
84
+ el("path", { d: `M${x + 40},${y - 74} A24,24 0 1 0 ${x + 76},${y - 40} L${x + 62},${y - 46} L${x + 56},${y - 60} Z`, fill: c("cast.metal"), ...O() }),
85
+ ];
86
+ };
87
+ const magnifierProp = (x, y) => [
88
+ el("path", { d: `M${x},${y} L${x + 24},${y - 24}`, stroke: ink(), "stroke-width": 16, "stroke-linecap": "round" }),
89
+ el("circle", { cx: x + 44, cy: y - 44, r: 30, fill: c("cast.data.lens"), stroke: ink(), "stroke-width": 9 }),
90
+ el("circle", { cx: x + 44, cy: y - 44, r: 30, fill: "none", stroke: c("color.caller"), "stroke-width": 4 }),
91
+ el("path", { d: `M${x + 30},${y - 56} Q${x + 36},${y - 66} ${x + 48},${y - 66}`, fill: "none", stroke: c("color.white"), "stroke-width": 5, "stroke-linecap": "round" }),
92
+ ];
93
+ const spark = (x, y) => el("path", { d: `M${x},${y - 14} L${x + 4},${y - 4} L${x + 14},${y} L${x + 4},${y + 4} L${x},${y + 14} L${x - 4},${y + 4} L${x - 14},${y} L${x - 4},${y - 4} Z`, fill: c("color.transitional"), ...O(), "stroke-width": 3 });
94
+ const CAST = () => ({
95
+ // DEV: modern all-in-one. Screen frame = body, code on the screen, face on the screen, </> on the chin, stand.
96
+ dev: {
97
+ family: COMPUTER, anchor: [110, 230], ground: 80, shoulders: [[22, 118], [198, 118]], hips: [[86, 200], [134, 200]], foot: 226,
98
+ faceAt: [110, 88], faceColour: ink(), mouthFill: c("cast.mouth"),
99
+ parts: () => [
100
+ g({ "data-part": "stand" }, ...solid("M88,184 H132 L140,212 H80 Z", c("cast.metal"), 6), ...solid(rr(62, 208, 96, 12, 6), c("cast.metal"), 5)),
101
+ g({ "data-part": "body" }, ...solid(rr(4, 24, 212, 168, 18), c("cast.dev.fill")), el("path", { d: "M8,152 H212 V174 Q212,188 198,188 H22 Q8,188 8,174 Z", fill: c("cast.dev.chin"), stroke: "none" }), el("path", { d: "M4,152 H216", stroke: ink(), "stroke-width": 4 }), el("path", { d: rr(18, 36, 184, 108, 10), fill: c("cast.dev.screen"), stroke: ink(), "stroke-width": 3.5 }), g({ "data-part": "screen-code", stroke: "none" }, el("rect", { x: 30, y: 48, width: 34, height: 7, rx: 3.5, fill: c("color.healthy") }), el("rect", { x: 36, y: 61, width: 22, height: 7, rx: 3.5, fill: c("cast.dev.fill") }), el("rect", { x: 156, y: 48, width: 34, height: 7, rx: 3.5, fill: c("color.healthy") }), el("rect", { x: 162, y: 61, width: 22, height: 7, rx: 3.5, fill: c("cast.dev.fill") }))),
102
+ g({ "data-part": "mark" }, el("path", { d: "M98,163 L90,170 L98,177 M122,163 L130,170 L122,177 M114,160 L106,180", fill: "none", stroke: c("color.white"), "stroke-width": 4.5, "stroke-linecap": "round", "stroke-linejoin": "round" })),
103
+ ],
104
+ },
105
+ // AI AGENT: retro lamp all-in-one. Dome = body, chrome neck, the screen is the head (tilts on the neck).
106
+ agent: {
107
+ family: COMPUTER, anchor: [110, 250], ground: 82, shoulders: [[44, 196], [176, 196]], hips: [[84, 236], [136, 236]], foot: 248,
108
+ faceAt: [110, 58], faceColour: c("cast.glow"), mouthFill: c("cast.glow"), faceInHead: true,
109
+ parts: (tilt) => {
110
+ const top = [110 + tilt * 1.4, 110];
111
+ const neck = `M110,176 Q${108 + tilt},150 ${top[0]},${top[1]}`;
112
+ return [
113
+ g({ "data-part": "neck" }, el("path", { d: neck, fill: "none", stroke: ink(), "stroke-width": 16, "stroke-linecap": "round" }), el("path", { d: neck, fill: "none", stroke: c("cast.agent.neck"), "stroke-width": 8, "stroke-linecap": "round" })),
114
+ g({ "data-part": "body" }, ...solid("M30,240 Q30,166 110,164 Q190,166 190,240 Z", c("color.white")), el("path", { d: "M44,226 H176", stroke: c("cast.metal"), "stroke-width": 4, "stroke-dasharray": "3 6", "stroke-linecap": "round" }), el("path", { d: "M58,196 Q80,180 104,178", fill: "none", stroke: c("cast.agent.shine"), "stroke-width": 8, "stroke-linecap": "round" })),
115
+ g({ "data-part": "mark" }, spark(110, 206)),
116
+ ];
117
+ },
118
+ },
119
+ // ARCHITECT: drafting set square with its hole, ruler ticks (mark), pencil; board as prop.
120
+ architect: {
121
+ family: OBJECT, anchor: [105, 216], ground: 76, shoulders: [[46, 152], [174, 152]], hips: [[78, 190], [146, 190]], foot: 212, hold: [64, 168],
122
+ faceAt: [110, 132], faceColour: ink(), mouthFill: c("cast.mouth"),
123
+ parts: () => [
124
+ g({ "data-part": "pencil", transform: "rotate(28 128 40)" }, el("path", { d: "M120,6 H136 V60 L128,74 L120,60 Z", fill: c("color.transitional"), ...O(), "stroke-width": 3.5 }), el("path", { d: "M120,6 H136 V16 H120 Z", fill: c("color.backend"), ...O(), "stroke-width": 3.5 })),
125
+ g({ "data-part": "body" }, ...solid("M99,30 Q110,13 121,30 L198,158 Q210,184 177,193 H35 Q6,187 21,160 Z", c("cast.architect.fill")), el("path", { d: "M110,58 L130,94 H90 Z", fill: c("color.panel"), stroke: ink(), "stroke-width": 3.5, "stroke-linejoin": "round" }), shine("M70,110 Q78,92 88,80", c("cast.architect.shine"))),
126
+ g({ "data-part": "mark", stroke: ink(), "stroke-width": 3, "stroke-linecap": "round" }, ...Array.from({ length: 9 }, (_, i) => { const x = 42 + i * 16; const h = i % 2 ? 9 : 15; return el("path", { d: `M${x},193 V${193 - h}` }); })),
127
+ ],
128
+ prop: () => boardProp(118, 152),
129
+ },
130
+ // DEVOPS: gear with the infinity loop (mark); wrench as prop.
131
+ devops: {
132
+ family: OBJECT, anchor: [105, 216], ground: 76, shoulders: [[34, 138], [184, 138]], hips: [[68, 176], [146, 176]], foot: 212, hold: [72, 160],
133
+ faceAt: [110, 100], faceColour: ink(), mouthFill: c("cast.mouth"),
134
+ parts: () => {
135
+ const loop = "M110,160 C98,146 76,146 76,160 C76,174 98,174 110,160 C122,146 144,146 144,160 C144,174 122,174 110,160 Z";
136
+ return [
137
+ g({ "data-part": "body" }, ...solid("M86,22H128L137,42L161,36L183,58L175,82L196,94V128L174,141L182,163L158,187L136,178L124,197H87L77,180L54,186L28,163L37,138L16,125V93L39,81L31,57L53,35L76,42Z", c("cast.devops.fill")), shine("M60,60 Q80,46 104,46", c("cast.devops.shine"))),
138
+ g({ "data-part": "mark" }, el("path", { d: loop, fill: "none", stroke: c("color.white"), "stroke-width": 11, "stroke-linejoin": "round" }), el("path", { d: loop, fill: "none", stroke: ink(), "stroke-width": 4, "stroke-linejoin": "round" })),
139
+ ];
140
+ },
141
+ prop: () => wrenchProp(168, 178),
142
+ },
143
+ // DATA: cylinder with lid and two bands (mark); magnifier as prop.
144
+ data: {
145
+ family: OBJECT, anchor: [105, 216], ground: 76, shoulders: [[38, 140], [172, 140]], hips: [[68, 196], [142, 196]], foot: 212, hold: [70, 170],
146
+ faceAt: [105, 104], faceColour: ink(), mouthFill: c("cast.mouth"),
147
+ parts: () => [
148
+ g({ "data-part": "body" }, ...solid("M35,60 V174 Q35,205 105,205 Q175,205 175,174 V60 Z", c("cast.data.fill"))),
149
+ g({ "data-part": "mark", fill: "none", stroke: ink(), "stroke-width": 4 }, el("path", { d: "M35,128 Q105,158 175,128" }), el("path", { d: "M35,166 Q105,196 175,166" })),
150
+ g({ "data-part": "lid" }, el("ellipse", { cx: 105, cy: 61, rx: 70, ry: 25, fill: c("cast.data.lid"), ...O() }), shine("M50,84 V112", c("cast.data.shine"))),
151
+ ],
152
+ prop: () => magnifierProp(164, 186),
153
+ },
154
+ // REQUESTER: speech bubble in caller blue, the request line (mark).
155
+ request: {
156
+ family: OBJECT, anchor: [105, 216], ground: 76, shoulders: [[30, 120], [180, 120]], hips: [[78, 176], [134, 176]], foot: 212, hold: [70, 150],
157
+ faceAt: [105, 82], faceColour: ink(), mouthFill: c("cast.mouth"),
158
+ parts: () => [
159
+ g({ "data-part": "body" }, ...solid("M54,30 H156 Q186,30 186,60 V130 Q186,160 156,160 H96 L66,190 L72,160 H54 Q24,160 24,130 V60 Q24,30 54,30 Z", c("color.caller")), shine("M44,62 Q56,46 78,46", c("cast.request.shine"))),
160
+ g({ "data-part": "mark" }, el("rect", { x: 62, y: 134, width: 86, height: 12, rx: 6, fill: c("color.white"), stroke: ink(), "stroke-width": 3 })),
161
+ ],
162
+ },
163
+ // MESSAGE: envelope (0.7 of the others), seal (mark).
164
+ message: {
165
+ family: OBJECT, anchor: [105, 216], scale: 0.7, ground: 76, shoulders: [[40, 140], [170, 140]], hips: [[78, 190], [132, 190]], foot: 212, hold: [70, 170],
166
+ faceAt: [105, 136], faceColour: ink(), mouthFill: c("cast.mouth"),
167
+ parts: () => [
168
+ g({ "data-part": "body" }, ...solid(rr(32, 88, 146, 106, 16), c("color.white"))),
169
+ g({ "data-part": "mark" }, el("path", { d: "M36,94 L105,140 L174,94", fill: "none", stroke: ink(), "stroke-width": 4, "stroke-linejoin": "round" }), el("circle", { cx: 105, cy: 140, r: 9, fill: c("color.transitional"), stroke: ink(), "stroke-width": 3 })),
170
+ ],
171
+ },
172
+ });
173
+ const drawMascot = (p) => {
174
+ const ch = CAST()[p.who];
175
+ const f = p.face ?? "normal", act = p.action ?? "none", look = p.look ?? 0, tilt = p.headTilt ?? 0;
176
+ const k = (p.scale ?? 1) * (ch.scale ?? 1);
177
+ const fam = ch.family;
178
+ const [L, R] = ch.shoulders;
179
+ let arms = [];
180
+ let legs = null;
181
+ const front = act === "hold" || act === "type";
182
+ if (act !== "none") {
183
+ const [ol, or] = fam.offsets[act];
184
+ let targetL = [L[0] + ol[0], L[1] + ol[1]], targetR = [R[0] + or[0], R[1] + or[1]];
185
+ // mascots4: "hold" puts both mittens at the hold point, mirrored round x 105.
186
+ if (act === "hold" && ch.hold) {
187
+ targetL = ch.hold;
188
+ targetR = [210 - ch.hold[0], ch.hold[1]];
189
+ }
190
+ arms = [g({ "data-part": "arm-left" }, ...arm(L, targetL, -1, fam.armWidth)), g({ "data-part": "arm-right" }, ...arm(R, targetR, 1, fam.armWidth))];
191
+ legs = g({ "data-part": "legs" }, ...ch.hips.flatMap(([hx, hy], i) => {
192
+ const s = i ? 1 : -1;
193
+ return fam === COMPUTER
194
+ ? [el("path", { d: `M${hx},${hy} L${hx + s * 4},${ch.foot - 6}`, stroke: ink(), "stroke-width": fam.legWidth, "stroke-linecap": "round" }), el("ellipse", { cx: hx + s * 11, cy: ch.foot, rx: 19, ry: 11, fill: ink() })]
195
+ : [el("path", { d: `M${hx},${hy} L${hx + s * 6},${ch.foot - 6}`, stroke: ink(), "stroke-width": fam.legWidth, "stroke-linecap": "round" }), el("ellipse", { cx: hx + s * 13, cy: ch.foot, rx: 19, ry: 11, fill: ink() })];
196
+ }));
197
+ }
198
+ let faceWrapped = face(ch.faceAt[0] + (ch.faceInHead ? tilt * 1.4 : 0), ch.faceAt[1], f, ch.faceColour, ch.mouthFill);
199
+ if (look !== 0)
200
+ faceWrapped = { ...faceWrapped, attrs: { ...faceWrapped.attrs, transform: `translate(${look * 6} 0)` } };
201
+ const showProp = (p.prop ?? true) && ch.prop && (act === "rest" || act === "hold");
202
+ const [ax, ay] = ch.anchor;
203
+ let parts = ch.parts(tilt);
204
+ if (ch.faceInHead) {
205
+ const top = [110 + tilt * 1.4, 110];
206
+ parts = [...parts, g({ "data-part": "head", transform: `rotate(${tilt} ${top[0]} ${top[1]})` }, ...solid(rr(top[0] - 96, 0, 192, 132, 22), c("color.white")), el("path", { d: rr(top[0] - 80, 14, 160, 104, 12), fill: c("illustration.codePanel"), stroke: ink(), "stroke-width": 4 }), faceWrapped)];
207
+ }
208
+ const root = g({ "data-mascot": p.who, transform: `translate(${p.x} ${p.y}) scale(${k}) translate(${-ax} ${-ay})`, stroke: "none" }, g({ "data-part": "ground" }, el("ellipse", { cx: ax, cy: ay, rx: ch.ground, ry: 9, fill: c("cast.ground") })), ...(front ? [] : arms), legs, ...parts, ch.faceInHead ? null : faceWrapped, showProp ? g({ "data-part": "prop" }, ...ch.prop()) : null, ...(front ? arms : []));
209
+ return drawn([root]);
210
+ };
211
+ // ---------------------------------------------------------------- emotes (emotes.ts)
212
+ const rays = (cx, cy, r1, r2, n, from = -150, to = -30) => Array.from({ length: n }, (_, i) => {
213
+ const a = ((from + (i * (to - from)) / (n - 1)) * Math.PI) / 180;
214
+ const r = (v) => Math.round(v * 100) / 100;
215
+ return el("path", { d: `M${r(cx + r1 * Math.cos(a))},${r(cy + r1 * Math.sin(a))} L${r(cx + r2 * Math.cos(a))},${r(cy + r2 * Math.sin(a))}`, stroke: ink(), "stroke-width": 6, "stroke-linecap": "round" });
216
+ });
217
+ const EO = () => ({ stroke: ink(), "stroke-width": 5, "stroke-linejoin": "round", "stroke-linecap": "round" });
218
+ const disc = (cx, cy, r, fill) => [el("circle", { cx: cx + 6, cy: cy + 6, r, fill: ink() }), el("circle", { cx, cy, r, fill, ...EO() })];
219
+ const zed = (x, y, s) => {
220
+ // A Z of height s with its top-left at (x, y - s): ink casing, caller fill.
221
+ const w = s * 0.72, d = `M${x},${y - s} H${x + w} L${x},${y} H${x + w}`;
222
+ return [el("path", { d, fill: "none", stroke: ink(), "stroke-width": s * 0.34, "stroke-linecap": "round", "stroke-linejoin": "round" }),
223
+ el("path", { d, fill: "none", stroke: c("color.caller"), "stroke-width": s * 0.16, "stroke-linecap": "round", "stroke-linejoin": "round" })];
224
+ };
225
+ const drawEmote = (p) => {
226
+ const { kind, x: cx, y: cy } = p, burst = p.burst ?? true, s = p.scale ?? 1;
227
+ const parts = [];
228
+ switch (kind) {
229
+ case "idea": {
230
+ if (burst)
231
+ parts.push(el("circle", { cx, cy: cy - 6, r: 62, fill: c("color.transitional"), opacity: 0.35 }), ...rays(cx, cy - 6, 50, 72, 7, -180, 0));
232
+ const bulb = `M${cx - 30},${cy - 10} A34,34 0 1 1 ${cx + 30},${cy - 10} Q${cx + 22},${cy + 6} ${cx + 18},${cy + 22} H${cx - 18} Q${cx - 22},${cy + 6} ${cx - 30},${cy - 10} Z`;
233
+ parts.push(el("path", { d: bulb, fill: ink(), transform: "translate(6 6)" }), el("path", { d: bulb, fill: c("color.transitional"), ...EO() }), el("path", { d: `M${cx - 16},${cy - 22} Q${cx - 12},${cy - 32} ${cx - 2},${cy - 34}`, fill: "none", stroke: c("color.white"), "stroke-width": 6, "stroke-linecap": "round" }), el("path", { d: `M${cx - 8},${cy + 22} V${cy} L${cx},${cy + 8} L${cx + 8},${cy} V${cy + 22}`, fill: "none", stroke: ink(), "stroke-width": 4, "stroke-linejoin": "round" }), el("rect", { x: cx - 18, y: cy + 22, width: 36, height: 12, rx: 3, fill: c("cast.metal"), ...EO(), "stroke-width": 4 }), el("rect", { x: cx - 14, y: cy + 34, width: 28, height: 10, rx: 5, fill: c("cast.metal"), ...EO(), "stroke-width": 4 }));
234
+ break;
235
+ }
236
+ case "doubt":
237
+ parts.push(...disc(cx, cy, 40, c("color.white")), el("path", { d: `M${cx - 14},${cy - 12} Q${cx - 14},${cy - 28} ${cx},${cy - 28} Q${cx + 16},${cy - 28} ${cx + 16},${cy - 13} Q${cx + 16},${cy - 2} ${cx + 2},${cy + 4} V${cy + 10}`, fill: "none", stroke: ink(), "stroke-width": 9, "stroke-linecap": "round", "stroke-linejoin": "round" }), el("circle", { cx: cx + 2, cy: cy + 24, r: 6, fill: ink() }));
238
+ break;
239
+ case "problem":
240
+ if (burst)
241
+ parts.push(...rays(cx, cy, 48, 70, 5));
242
+ parts.push(el("path", { d: `M${cx},${cy - 44} L${cx + 46},${cy + 34} H${cx - 46} Z`, fill: ink(), transform: "translate(6 6)" }), el("path", { d: `M${cx},${cy - 44} L${cx + 46},${cy + 34} H${cx - 46} Z`, fill: c("color.failure"), ...EO() }), el("path", { d: `M${cx},${cy - 18} V${cy + 8}`, stroke: ink(), "stroke-width": 9, "stroke-linecap": "round" }), el("circle", { cx, cy: cy + 22, r: 5.5, fill: ink() }));
243
+ break;
244
+ case "pressure":
245
+ for (const [dx, dy, k] of [[0, 0, 1], [30, 22, 0.6]])
246
+ parts.push(el("path", { d: `M${cx + dx},${cy + dy - 34 * k} Q${cx + dx + 24 * k},${cy + dy} ${cx + dx},${cy + dy + 12 * k} Q${cx + dx - 24 * k},${cy + dy} ${cx + dx},${cy + dy - 34 * k} Z`, fill: c("cast.glow"), ...EO(), "stroke-width": 4 }), el("path", { d: `M${cx + dx - 6 * k},${cy + dy - 2 * k} Q${cx + dx - 8 * k},${cy + dy + 6 * k} ${cx + dx - 2 * k},${cy + dy + 8 * k}`, fill: "none", stroke: c("color.white"), "stroke-width": 3, "stroke-linecap": "round" }));
247
+ break;
248
+ case "liked":
249
+ parts.push(el("path", { d: `M${cx},${cy + 30} C${cx - 50},${cy} ${cx - 34},${cy - 40} ${cx},${cy - 16} C${cx + 34},${cy - 40} ${cx + 50},${cy} ${cx},${cy + 30} Z`, fill: c("color.backend"), ...EO() }));
250
+ break;
251
+ case "worked":
252
+ if (burst)
253
+ parts.push(...rays(cx, cy, 48, 68, 5));
254
+ parts.push(...disc(cx, cy, 38, c("color.healthy")), el("path", { d: `M${cx - 16},${cy} L${cx - 4},${cy + 12} L${cx + 18},${cy - 12}`, fill: "none", stroke: ink(), "stroke-width": 8, "stroke-linecap": "round", "stroke-linejoin": "round" }));
255
+ break;
256
+ case "refused":
257
+ parts.push(...disc(cx, cy, 38, c("color.failure")), el("path", { d: `M${cx - 13},${cy - 13} L${cx + 13},${cy + 13} M${cx + 13},${cy - 13} L${cx - 13},${cy + 13}`, stroke: ink(), "stroke-width": 8, "stroke-linecap": "round" }));
258
+ break;
259
+ case "waiting":
260
+ parts.push(...zed(cx - 12, cy + 12, 26), ...zed(cx + 20, cy - 20, 20), ...zed(cx + 44, cy - 46, 14));
261
+ break;
262
+ }
263
+ const attrs = s === 1 ? { "data-emote": kind, stroke: "none" } : { "data-emote": kind, stroke: "none", transform: `translate(${cx} ${cy}) scale(${s}) translate(${-cx} ${-cy})` };
264
+ return drawn([g(attrs, ...parts)]);
265
+ };
266
+ const unit = { kind: "number", min: 0, exclusiveMin: true, max: 10 };
267
+ /** One character standing with the centre of its ground line at (x, y). */
268
+ export const mascot = definePiece("mascot", {
269
+ who: { kind: "enum", required: true, values: MASCOTS },
270
+ x: pos(),
271
+ y: pos(),
272
+ face: { kind: "enum", values: MASCOT_FACES },
273
+ action: { kind: "enum", values: MASCOT_ACTIONS },
274
+ scale: unit,
275
+ look: { kind: "number", min: -1, max: 1 },
276
+ headTilt: { kind: "number", min: -30, max: 30 },
277
+ prop: { kind: "boolean" },
278
+ }, drawMascot, ({ who, headTilt }) => (headTilt !== undefined && headTilt !== 0 && who !== "agent" ? [{ path: "headTilt", message: "applies only to the agent" }] : []));
279
+ /** One reaction symbol with a fixed meaning, centred on (x, y), beside a head. */
280
+ export const emote = definePiece("emote", { kind: { kind: "enum", required: true, values: EMOTE_KINDS }, x: pos(), y: pos(), scale: unit, burst: { kind: "boolean" } }, drawEmote);