@brunosouzas/design-system 0.1.1 → 0.3.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) and the metro and diagram pieces (item 6) 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) and stackledger's map markers, zones and icons (AB#167, 0.3.0) 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).
29
29
 
30
30
  ## What it is
31
31
 
@@ -90,7 +90,7 @@ npm run stills -- publish-subscribe # 24 phase/final PNGs (six per
90
90
  npm run stills -- publish-subscribe --dry-run # print 24 commands, no effects
91
91
  npm run render -- publish-subscribe --dry-run # print eight commands; real video waits for owner stills approval
92
92
  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 from its spec
93
+ npm run cover -- cover.json --out card.svg # build a blog cover (or a banner) from its spec
94
94
  npm run pieces:sheet -- --out out/pieces.svg # every piece once, labelled, for review
95
95
  npm run studio # Remotion Studio, for frame-by-frame review
96
96
  ```
@@ -130,17 +130,20 @@ The components take every colour and size from the tokens, and what is visible a
130
130
 
131
131
  - `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
132
  - `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
- - Every colour parameter is a token path (`"color.healthy"`, `"illustration.accent"`), never a hex value, except the two line 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 35 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) and in [design §3](openspec/changes/archive/2026-10-23-metro-and-diagram-pieces/design.md) of `metro-and-diagram-pieces` (the nine below); the rules are in [`openspec/specs/illustration-pieces/`](openspec/specs/illustration-pieces/spec.md). `npm run pieces:sheet -- --out <file.svg>` draws them all.
133
+ - 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 41 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); 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
135
 
136
136
  ### Maps and diagrams
137
137
 
138
- Nine pieces draw a metro map and its page diagrams, for [stackledger](https://github.com/brunosouzas/stackledger) and any other map in this style: `lineBadge`, `stationMarker` (stop, interchange as a capsule, deprecated), `pipe`, `countBadge`, `nodeBlock`, `pillNode`, `hexNode` (normal, focus, placeholder), `messageArrow` (curved or straight, with a head and an optional label) and `ledgerMark` (stackledger's mark). They paint what the caller lays out: positions, text measurement and truncation stay with the map engine.
138
+ Thirteen pieces draw a metro map and its page diagrams, for [stackledger](https://github.com/brunosouzas/stackledger) and any other map in this style: `lineBadge`, `stationMarker` (stop, interchange as a capsule, deprecated), `pipe`, `countBadge`, `nodeBlock`, `pillNode`, `hexNode` (normal, focus, placeholder), `messageArrow` (curved or straight, with a head and an optional label) `ledgerMark` (stackledger's mark; `surface: "dark"` lines it with a light rim), `renewalRing` (orange bands round a station), `suggestedHalo` (a dashed muted circle), `licenceMark` (a 10-unit disc, green when held, white to buy) and `zone` (the outline the caller gives, widened by a round 32-unit stroke at the surface's zone opacity, with an optional title). They paint what the caller lays out: positions, hulls, text measurement and truncation stay with the map engine.
139
139
 
140
- - `surface: "light" | "dark"` picks the background they sit on (`surface.*` tokens); every map and diagram piece takes it except `pipe` and `ledgerMark`. On dark the hard shadow takes `surface.dark.shadow` and the outline gets a light rim ([ADR 0003](docs/adr/0003-visual-language-and-colour-meaning.md), amendment of AB#157).
140
+ - A `placeholder` node may have a `subtitle` (drawn like a normal node's, all muted), and `lineBadge` may have no `text` (the pill alone, 30 wide by default).
141
+ - The licence's disc is small, so it has its own room rule: `size` at least three outlines (9 at ink scale 1).
142
+
143
+ - `surface: "light" | "dark"` picks the background they sit on (`surface.*` tokens); every map and diagram piece takes it except `pipe`; `ledgerMark` takes only `surface`. On dark the hard shadow takes `surface.dark.shadow` and the outline gets a light rim ([ADR 0003](docs/adr/0003-visual-language-and-colour-meaning.md), amendment of AB#157).
141
144
  - `inkScale` (0.25 to 8) thickens the outline, rim and shadow without changing the geometry, on `lineBadge`, `stationMarker`, `countBadge` and the three nodes; the map uses 1, the page diagrams 1.6. A shape's height (a marker's diameter) must be at least four outlines; a placeholder node, which draws no ink, is exempt.
142
- - A line's colour comes from the map's data, so `pipe.fill` and `lineBadge.fill` also accept `#RRGGBB`. Every other colour is a token path, and a cover spec rejects any hex.
143
- - The `map.*` tokens hold the measures (outline 3, shadow 3.5, line 12, casing 6, badge 20, node 46…), and `site.*` the page palette, fonts and type scale of brunosouzas.com in light and dark.
145
+ - A line's colour comes from the map's data, so `pipe.fill`, `lineBadge.fill` and `zone.fill` also accept `#RRGGBB`. Every other colour is a token path, and a cover spec rejects any hex.
146
+ - The `map.*` tokens hold the measures (outline 3, shadow 3.5, line 12, casing 6, badge 20, node 46, ring 3, halo 1.5, licence 10, zone padding 32…), `surface.*.zoneOpacity` the zone's opacity (0.12, dark 0.22), and `site.*` the page palette, fonts and type scale of brunosouzas.com in light and dark.
144
147
 
145
148
  ```ts
146
149
  import { lineBadge, pipe, stationMarker, toSvg } from "@brunosouzas/design-system";
@@ -151,7 +154,7 @@ const badge = lineBadge({ x: 250, y: 40, text: "SH", fill: "#7B3FE4", surface: "
151
154
 
152
155
  ### A cover
153
156
 
154
- A cover is a JSON spec kept with its article, outside this repository: drafts are private, and only the pieces are public. `npm run cover -- <spec.json> --out <file.svg>` validates it, reports every problem with its JSON path, and writes an 800x420 SVG; the same spec always gives the same bytes.
157
+ A cover is a JSON spec kept with its article, outside this repository: drafts are private, and only the pieces are public. `npm run cover -- <spec.json> --out <file.svg>` validates it, reports every problem with its JSON path, and writes an 800x420 SVG; the same spec always gives the same bytes. With `"format": "banner"` the same command writes a site banner instead (see below).
155
158
 
156
159
  ```json
157
160
  {
@@ -173,10 +176,30 @@ A cover is a JSON spec kept with its article, outside this repository: drafts ar
173
176
  - `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.
174
177
  - The three published covers that proved the pieces are the test fixtures in `test/fixtures/covers/`.
175
178
 
179
+ ### Banners
180
+
181
+ A spec with `"format": "banner"` (the default is `"cover"`) builds a home banner for a site: `banner.width` x `banner.height` (1200x420) with corners rounded at `banner.radius` (20), the measures of brunosouzas.com's published banner. Same pieces, validation and command. Two things differ from a cover:
182
+
183
+ - the rings go on until they reach the frame's farthest corner (22 rings for a centre at 600, 225 with radius 60), where a cover draws 14;
184
+ - pieces and labels are clipped to the rounded frame too, so nothing pokes past a corner.
185
+
186
+ 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.
187
+
176
188
  ### A new piece
177
189
 
178
190
  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.
179
191
 
192
+ ### UI and link icons
193
+
194
+ - `uiIcon({ name, x, y, size, colour })` draws one of seven glyphs of the sites' chrome in a 24-unit box and one token colour: `sun`, `moon`, `zoomIn`, `zoomOut`, `reset`, `calendar`, `download` (data in `UI_ICONS`).
195
+ - `linkIcon({ icon, x, y, size, surface })` draws a link sticker as brunosouzas.com draws its social icons (46-unit box, light or dark frame): `github`, `linkedin`, `email`, `website` (data in `LINK_ICONS`). The GitHub mark is from [Font Awesome](https://fontawesome.com/), licensed [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).
196
+ - Neither holds a link, a style, a script or a URL, so a consumer can write one with `toSvg` inside its own `<svg xmlns=…>` and ship it as a static file.
197
+
198
+ ```ts
199
+ import { el, linkIcon, toSvg } from "@brunosouzas/design-system";
200
+ const github = toSvg(el("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 46 46", width: 28, height: 28 }, ...linkIcon({ icon: "github", x: 0, y: 0, surface: "dark" }).body));
201
+ ```
202
+
180
203
  ### EIP icons
181
204
 
182
205
  `src/pieces/eip-icons.ts` holds the 26 Enterprise Integration Patterns icons as data. They are redrawn from the icons by Gregor Hohpe and Bobby Woolf ([enterpriseintegrationpatterns.com](https://www.enterpriseintegrationpatterns.com/)), licensed [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/); each icon keeps that credit in its description.
@@ -2,6 +2,9 @@ import { type PieceName } from "./library.ts";
2
2
  import { type SvgNode } from "./node.ts";
3
3
  import { type Problem } from "./params.ts";
4
4
  export declare const SAFE_ID: RegExp;
5
+ /** Cover (800x420, the default) or banner (1200x420, rounded corners): spec cover-cli, "Banner output". */
6
+ export declare const FORMATS: readonly ["cover", "banner"];
7
+ export type CoverFormat = (typeof FORMATS)[number];
5
8
  export type CoverPiece = {
6
9
  piece: PieceName;
7
10
  } & Record<string, unknown>;
@@ -14,6 +17,7 @@ export type CoverSpec = {
14
17
  id: string;
15
18
  title: string;
16
19
  description: string;
20
+ format?: CoverFormat;
17
21
  background: string;
18
22
  rings: {
19
23
  cx: number;
@@ -28,5 +32,7 @@ export declare const backgroundNames: () => string[];
28
32
  export declare const comparePaths: (a: string, b: string) => number;
29
33
  /** Every problem of a parsed spec, sorted by path; empty when the spec is valid. */
30
34
  export declare const validateCoverSpec: (spec: unknown) => Problem[];
31
- /** The cover's node tree. The spec must be valid (validateCoverSpec returned no problem). */
35
+ /** Rings a banner draws: out to the frame's farthest corner (spec cover-cli, "Banner rings reach the corners"). */
36
+ export declare const bannerRingCount: (cx: number, cy: number, radius?: number) => number;
37
+ /** The cover's (or banner's) node tree. The spec must be valid (validateCoverSpec returned no problem). */
32
38
  export declare const coverTree: (spec: CoverSpec) => SvgNode;
@@ -12,7 +12,9 @@ import { PIECE_NAMES, PIECES, rings } from "./library.js";
12
12
  import { el, g } from "./node.js";
13
13
  import { COLOUR_TOKENS, checkParams, isColourLiteral, MAX_SIZE, PATH_DATA } from "./params.js";
14
14
  export const SAFE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
15
- const TOP_LEVEL = ["id", "title", "description", "background", "rings", "pieces", "custom"];
15
+ const TOP_LEVEL = ["id", "title", "description", "format", "background", "rings", "pieces", "custom"];
16
+ /** Cover (800x420, the default) or banner (1200x420, rounded corners): spec cover-cli, "Banner output". */
17
+ export const FORMATS = ["cover", "banner"];
16
18
  const CUSTOM_TAGS = ["g", "rect", "circle", "ellipse", "line", "polyline", "polygon", "path", "text"];
17
19
  // Attributes a free-form object may use (design §6): geometry, paint, text and transform.
18
20
  const CUSTOM_ATTRS = new Set([
@@ -154,7 +156,7 @@ export const validateCoverSpec = (spec) => {
154
156
  if (!isPlainObject(spec.rings))
155
157
  out.push({ path: "rings", message: "missing or not an object (expected cx, cy and an optional radius)" });
156
158
  else {
157
- const ringProblems = checkParams(rings.schema, spec.rings, "rings").filter((problem) => problem.path !== "rings.color");
159
+ const ringProblems = checkParams(rings.schema, spec.rings, "rings").filter((problem) => problem.path !== "rings.color" && problem.path !== "rings.count");
158
160
  out.push(...ringProblems);
159
161
  // The piece takes any finite centre; a cover spec keeps 0.1.0's ±10000, which bounds a banner's ring count.
160
162
  for (const axis of ["cx", "cy"]) {
@@ -169,6 +171,11 @@ export const validateCoverSpec = (spec) => {
169
171
  }
170
172
  if (isPlainObject(spec.rings) && Object.hasOwn(spec.rings, "color"))
171
173
  out.push({ path: "rings.color", message: "unknown parameter: the ring colour comes from the background" });
174
+ if (isPlainObject(spec.rings) && Object.hasOwn(spec.rings, "count"))
175
+ out.push({ path: "rings.count", message: "unknown parameter: the ring count comes from the format" });
176
+ if (spec.format !== undefined && (typeof spec.format !== "string" || !FORMATS.includes(spec.format))) {
177
+ out.push({ path: "format", message: `expected one of ${FORMATS.join(", ")}` });
178
+ }
172
179
  if (!Array.isArray(spec.pieces))
173
180
  out.push({ path: "pieces", message: "missing or not an array" });
174
181
  else
@@ -254,12 +261,25 @@ const resolveCustom = (node) => {
254
261
  attrs.stroke = "none";
255
262
  return el(node.tag, attrs, ...(node.children ?? []).map((child) => (typeof child === "string" ? child : resolveCustom(child))));
256
263
  };
257
- /** The cover's node tree. The spec must be valid (validateCoverSpec returned no problem). */
264
+ /** Rings a banner draws: out to the frame's farthest corner (spec cover-cli, "Banner rings reach the corners"). */
265
+ export const bannerRingCount = (cx, cy, radius = tokens.cover.ringRadius) => {
266
+ const { width, height } = tokens.banner;
267
+ const far = Math.max(...[[0, 0], [width, 0], [0, height], [width, height]].map(([x, y]) => Math.hypot(x - cx, y - cy)));
268
+ return Math.max(1, Math.ceil((far - radius) / tokens.cover.ringStep) + 1);
269
+ };
270
+ /** The cover's (or banner's) node tree. The spec must be valid (validateCoverSpec returned no problem). */
258
271
  export const coverTree = (spec) => {
259
- const { width, height } = tokens.cover;
272
+ const banner = spec.format === "banner";
273
+ const { width, height } = banner ? tokens.banner : tokens.cover;
274
+ // A banner's corners are rounded; a cover keeps today's square frame, attribute for attribute.
275
+ const corner = banner ? { rx: tokens.banner.radius } : {};
260
276
  const pair = tokens.coverBackground[spec.background];
261
277
  const drawnPieces = spec.pieces.map(({ piece, ...params }) => PIECES[piece](params));
262
278
  const ringPath = `coverBackground.${spec.background}.ring`;
279
+ const ringCount = banner ? { count: bannerRingCount(spec.rings.cx, spec.rings.cy, spec.rings.radius) } : {};
280
+ const frame = `url(#${spec.id}-frame)`;
281
+ const outlined = g({ stroke: tokens.color.ink, "stroke-width": tokens.outline.block, "stroke-linejoin": "round" }, ...drawnPieces.flatMap((d) => d.body), spec.custom ? resolveCustom(spec.custom) : null);
282
+ const labels = g({ "font-size": tokens.cover.labelSize, "font-weight": tokens.cover.labelWeight, fill: tokens.color.ink, "text-anchor": "middle" }, ...drawnPieces.flatMap((d) => d.text));
263
283
  return el("svg", {
264
284
  xmlns: "http://www.w3.org/2000/svg",
265
285
  width,
@@ -268,5 +288,7 @@ export const coverTree = (spec) => {
268
288
  role: "img",
269
289
  "aria-labelledby": `${spec.id}-title ${spec.id}-desc`,
270
290
  "font-family": tokens.font.sans,
271
- }, el("title", { id: `${spec.id}-title` }, spec.title), el("desc", { id: `${spec.id}-desc` }, spec.description), el("defs", {}, el("clipPath", { id: `${spec.id}-frame` }, el("rect", { width, height }))), el("rect", { width, height, fill: pair.fill }), g({ "clip-path": `url(#${spec.id}-frame)` }, ...rings({ ...spec.rings, color: ringPath }).body), g({ stroke: tokens.color.ink, "stroke-width": tokens.outline.block, "stroke-linejoin": "round" }, ...drawnPieces.flatMap((d) => d.body), spec.custom ? resolveCustom(spec.custom) : null), g({ "font-size": tokens.cover.labelSize, "font-weight": tokens.cover.labelWeight, fill: tokens.color.ink, "text-anchor": "middle" }, ...drawnPieces.flatMap((d) => d.text)));
291
+ }, el("title", { id: `${spec.id}-title` }, spec.title), el("desc", { id: `${spec.id}-desc` }, spec.description), el("defs", {}, el("clipPath", { id: `${spec.id}-frame` }, el("rect", { width, height, ...corner }))), el("rect", { width, height, ...corner, fill: pair.fill }), g({ "clip-path": frame }, ...rings({ ...spec.rings, color: ringPath, ...ringCount }).body),
292
+ // On a banner, pieces and labels are clipped too, so nothing pokes past a rounded corner.
293
+ ...(banner ? [g({ "clip-path": frame }, outlined, labels)] : [outlined, labels]));
272
294
  };
@@ -5,5 +5,5 @@ export { COLOUR_TOKENS, DATA_COLOUR, MAX_ITEMS, paint, MAX_SIZE, checkParams, co
5
5
  export { definePiece, type Piece } from "./piece.ts";
6
6
  export * from "./library.ts";
7
7
  export { EIP_ICONS, EIP_ICON_NAMES, EIP_ICON_HEIGHT, EIP_ICON_WIDTH, type EipIcon } from "./eip-icons.ts";
8
- export { SAFE_ID, backgroundNames, comparePaths, coverTree, validateCoverSpec, type CoverPiece, type CoverSpec, type CustomNode } from "./cover.ts";
8
+ export { FORMATS, SAFE_ID, backgroundNames, bannerRingCount, comparePaths, coverTree, validateCoverSpec, type CoverFormat, type CoverPiece, type CoverSpec, type CustomNode } from "./cover.ts";
9
9
  export { tokens, type Tokens } from "../../tokens/generated/tokens.ts";
@@ -10,5 +10,5 @@ export { COLOUR_TOKENS, DATA_COLOUR, MAX_ITEMS, paint, MAX_SIZE, checkParams, co
10
10
  export { definePiece } from "./piece.js";
11
11
  export * from "./library.js";
12
12
  export { EIP_ICONS, EIP_ICON_NAMES, EIP_ICON_HEIGHT, EIP_ICON_WIDTH } from "./eip-icons.js";
13
- export { SAFE_ID, backgroundNames, comparePaths, coverTree, validateCoverSpec } from "./cover.js";
13
+ export { FORMATS, SAFE_ID, backgroundNames, bannerRingCount, comparePaths, coverTree, validateCoverSpec } from "./cover.js";
14
14
  export { tokens } from "../../tokens/generated/tokens.js";
@@ -1,4 +1,6 @@
1
1
  export * from "./metro.ts";
2
+ export * from "./ui-icons.ts";
3
+ export * from "./link-icons.ts";
2
4
  type Point = [number, number];
3
5
  export type PillParams = {
4
6
  x: number;
@@ -10,13 +12,16 @@ export type PillParams = {
10
12
  /** Pill label centred on (x, y), usually overlapping the top edge of what it names. */
11
13
  export declare const pill: import("./piece.ts").Piece<PillParams>;
12
14
  export declare const RING_COUNT = 14;
15
+ /** Most rings one call draws: enough for a banner's farthest corner from any centre a cover spec accepts (548). */
16
+ export declare const MAX_RINGS = 1000;
13
17
  export type RingsParams = {
14
18
  cx: number;
15
19
  cy: number;
16
20
  radius?: number;
17
21
  color?: string;
22
+ count?: number;
18
23
  };
19
- /** Concentric rings around (cx, cy): 14 rings, step and width from the cover tokens. */
24
+ /** Concentric rings around (cx, cy): `count` rings (14 by default), step and width from the cover tokens. */
20
25
  export declare const rings: import("./piece.ts").Piece<RingsParams>;
21
26
  export type SheetRow = {
22
27
  lead?: "none" | "checkbox" | "checked" | "question";
@@ -266,6 +271,10 @@ export declare const PIECES: {
266
271
  readonly hexNode: import("./piece.ts").Piece<import("./metro.ts").NodeParams>;
267
272
  readonly messageArrow: import("./piece.ts").Piece<import("./metro.ts").MessageArrowParams>;
268
273
  readonly ledgerMark: import("./piece.ts").Piece<import("./metro.ts").LedgerMarkParams>;
274
+ readonly renewalRing: import("./piece.ts").Piece<import("./metro.ts").RenewalRingParams>;
275
+ readonly suggestedHalo: import("./piece.ts").Piece<import("./metro.ts").SuggestedHaloParams>;
276
+ readonly licenceMark: import("./piece.ts").Piece<import("./metro.ts").LicenceMarkParams>;
277
+ readonly zone: import("./piece.ts").Piece<import("./metro.ts").ZoneParams>;
269
278
  readonly pill: import("./piece.ts").Piece<PillParams>;
270
279
  readonly rings: import("./piece.ts").Piece<RingsParams>;
271
280
  readonly sheet: import("./piece.ts").Piece<SheetParams>;
@@ -292,6 +301,8 @@ export declare const PIECES: {
292
301
  readonly book: import("./piece.ts").Piece<BookParams>;
293
302
  readonly muleFlow: import("./piece.ts").Piece<MuleFlowParams>;
294
303
  readonly eipIcon: import("./piece.ts").Piece<EipIconParams>;
304
+ readonly uiIcon: import("./piece.ts").Piece<import("./ui-icons.ts").UiIconParams>;
305
+ readonly linkIcon: import("./piece.ts").Piece<import("./link-icons.ts").LinkIconParams>;
295
306
  };
296
307
  export type PieceName = keyof typeof PIECES;
297
308
  export declare const PIECE_NAMES: PieceName[];
@@ -12,7 +12,11 @@ import { drawn, el, g, r2, shadowed } from "./node.js";
12
12
  import { col, colour, count, label, MAX_SIZE, PATH_DATA, pos, size } from "./params.js";
13
13
  import { definePiece } from "./piece.js";
14
14
  import { METRO_PIECES } from "./metro.js";
15
+ import { uiIcon } from "./ui-icons.js";
16
+ import { linkIcon } from "./link-icons.js";
15
17
  export * from "./metro.js";
18
+ export * from "./ui-icons.js";
19
+ export * from "./link-icons.js";
16
20
  const ink = tokens.color.ink;
17
21
  const mono = tokens.font.mono;
18
22
  /** Pill label centred on (x, y), usually overlapping the top edge of what it names. */
@@ -23,9 +27,11 @@ export const pill = definePiece("pill", { x: pos(), y: pos(), text: label(), fil
23
27
  });
24
28
  // ---------- background ----------
25
29
  export const RING_COUNT = 14;
26
- /** Concentric rings around (cx, cy): 14 rings, step and width from the cover tokens. */
27
- export const rings = definePiece("rings", { cx: pos(), cy: pos(), radius: size(), color: col() }, ({ cx, cy, radius = tokens.cover.ringRadius, color = "color.ring" }) => drawn([
28
- g({ fill: "none", stroke: colour(color, "color"), "stroke-width": tokens.cover.ringWidth }, ...Array.from({ length: RING_COUNT }, (_, i) => el("circle", { cx, cy, r: radius + i * tokens.cover.ringStep }))),
30
+ /** Most rings one call draws: enough for a banner's farthest corner from any centre a cover spec accepts (548). */
31
+ export const MAX_RINGS = 1000;
32
+ /** Concentric rings around (cx, cy): `count` rings (14 by default), step and width from the cover tokens. */
33
+ export const rings = definePiece("rings", { cx: pos(), cy: pos(), radius: size(), color: col(), count: count(1, MAX_RINGS) }, ({ cx, cy, radius = tokens.cover.ringRadius, color = "color.ring", count: rings = RING_COUNT }) => drawn([
34
+ g({ fill: "none", stroke: colour(color, "color"), "stroke-width": tokens.cover.ringWidth }, ...Array.from({ length: rings }, (_, i) => el("circle", { cx, cy, r: radius + i * tokens.cover.ringStep }))),
29
35
  ]));
30
36
  const rowSchema = {
31
37
  lead: { kind: "enum", values: ["none", "checkbox", "checked", "question"] },
@@ -373,5 +379,7 @@ export const PIECES = {
373
379
  muleFlow,
374
380
  eipIcon,
375
381
  ...METRO_PIECES,
382
+ uiIcon,
383
+ linkIcon,
376
384
  };
377
385
  export const PIECE_NAMES = Object.keys(PIECES);
@@ -0,0 +1,22 @@
1
+ import { type SvgNode } from "./node.ts";
2
+ import { type SurfaceName } from "./metro.ts";
3
+ export type LinkIcon = {
4
+ readonly name: string;
5
+ readonly title: string;
6
+ readonly description: string; /** A colour token path. */
7
+ readonly face: string;
8
+ readonly glyph: readonly SvgNode[];
9
+ };
10
+ /** Every link icon draws in a 46x46 box. */
11
+ export declare const LINK_ICON_SIZE = 46;
12
+ export declare const LINK_ICONS: readonly LinkIcon[];
13
+ export declare const LINK_ICON_NAMES: string[];
14
+ export type LinkIconParams = {
15
+ icon: string;
16
+ x: number;
17
+ y: number;
18
+ size?: number;
19
+ surface?: SurfaceName;
20
+ };
21
+ /** One link sticker at (x, y), `size` wide (46 by default), on the light or the dark page. */
22
+ export declare const linkIcon: import("./piece.ts").Piece<LinkIconParams>;
@@ -0,0 +1,80 @@
1
+ // Link icons as data (spec illustration-pieces, "Link icons as data"; design §6
2
+ // of stackledger-map-and-link-pieces): the social stickers of brunosouzas.com
3
+ // (public/images/home/social-*.svg at e29b3dd), with every colour replaced by
4
+ // its token, plus a `website` globe in the same style. `linkIcon` draws the
5
+ // site's frame (light, or dark with its rim) around the face and the glyph, at
6
+ // the site's measures (46 box, face 38 at 2, radius 9, outline 2.5).
7
+ //
8
+ // Credit: the GitHub mark is Font Awesome's (fontawesome.com), licensed
9
+ // CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/); its description
10
+ // keeps the credit.
11
+ //
12
+ // What can go wrong: an unknown icon is rejected by the schema. No node holds
13
+ // a link, a style, a script or a URL, so the output can ship as a static file.
14
+ import { tokens } from "../../tokens/generated/tokens.js";
15
+ import { drawn, el, g, r2 } from "./node.js";
16
+ import { colour, pos, size } from "./params.js";
17
+ import { definePiece } from "./piece.js";
18
+ import { SURFACES } from "./metro.js";
19
+ const C = tokens.color;
20
+ const INK = C.ink;
21
+ /** Every link icon draws in a 46x46 box. */
22
+ export const LINK_ICON_SIZE = 46;
23
+ export const LINK_ICONS = [
24
+ {
25
+ name: "github",
26
+ title: "GitHub",
27
+ description: "GitHub icon. The GitHub mark is from Font Awesome (fontawesome.com), CC BY 4.0.",
28
+ face: "color.white",
29
+ glyph: [g({ transform: "translate(9.5 9) scale(0.0455)" }, el("path", { d: "M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z", fill: INK, stroke: "none" }))],
30
+ },
31
+ {
32
+ name: "linkedin",
33
+ title: "LinkedIn",
34
+ description: "LinkedIn icon.",
35
+ face: "color.caller",
36
+ glyph: [
37
+ el("rect", { x: 12, y: 18.5, width: 4, height: 11.5, rx: 1, fill: INK, stroke: "none" }),
38
+ el("circle", { cx: 14, cy: 13.8, r: 2.4, fill: INK, stroke: "none" }),
39
+ el("path", { d: "M19.5,30 V18.5 H23.5 V20.3 C24.5,19 26.2,18.1 28,18.1 C31.2,18.1 32.5,20.2 32.5,23.6 V30 H28.5 V24.3 C28.5,22.7 27.8,21.9 26.6,21.9 C25.2,21.9 23.5,22.8 23.5,24.7 V30 Z", fill: INK, stroke: "none" }),
40
+ ],
41
+ },
42
+ {
43
+ name: "email",
44
+ title: "Email",
45
+ description: "Email icon.",
46
+ face: "color.transitional",
47
+ glyph: [
48
+ el("rect", { x: 9, y: 13, width: 24, height: 17, rx: 3, fill: C.white, stroke: INK, "stroke-width": 2.5 }),
49
+ el("path", { d: "M10,15 L21,23 L32,15", fill: "none", stroke: INK, "stroke-width": 2.5, "stroke-linejoin": "round" }),
50
+ ],
51
+ },
52
+ {
53
+ name: "website",
54
+ title: "Website",
55
+ description: "Website icon: a globe.",
56
+ face: "color.healthy",
57
+ glyph: [
58
+ el("circle", { cx: 21, cy: 21, r: 10.5, fill: C.white, stroke: INK, "stroke-width": 2.5 }),
59
+ el("ellipse", { cx: 21, cy: 21, rx: 4.6, ry: 10.5, fill: "none", stroke: INK, "stroke-width": 2 }),
60
+ el("path", { d: "M10.5 21H31.5M12.5 15.5H29.5M12.5 26.5H29.5", fill: "none", stroke: INK, "stroke-width": 2, "stroke-linecap": "round" }),
61
+ ],
62
+ },
63
+ ];
64
+ export const LINK_ICON_NAMES = LINK_ICONS.map((icon) => icon.name);
65
+ /** The site's frame: the shadow (and on dark the rim) behind the face. */
66
+ const frame = (surface) => {
67
+ const s = tokens.surface[surface];
68
+ return s.rimWidth > 0
69
+ ? [
70
+ el("rect", { x: 4, y: 4, width: 41, height: 41, rx: 11, fill: s.shadow, stroke: "none" }),
71
+ el("rect", { x: 0.5, y: 0.5, width: 41, height: 41, rx: 11, fill: s.rim, stroke: "none" }),
72
+ ]
73
+ : [el("rect", { x: 5, y: 5, width: 38, height: 38, rx: 9, fill: s.shadow, stroke: "none" })];
74
+ };
75
+ /** One link sticker at (x, y), `size` wide (46 by default), on the light or the dark page. */
76
+ export const linkIcon = definePiece("linkIcon", { icon: { kind: "enum", required: true, values: LINK_ICON_NAMES }, x: pos(), y: pos(), size: size(), surface: { kind: "enum", values: SURFACES } }, ({ icon, x, y, size: side = LINK_ICON_SIZE, surface = "light" }) => {
77
+ const data = LINK_ICONS.find((candidate) => candidate.name === icon);
78
+ const face = el("rect", { x: 2, y: 2, width: 38, height: 38, rx: 9, fill: colour(data.face, "face"), stroke: INK, "stroke-width": 2.5 });
79
+ return drawn([g({ transform: `translate(${r2(x)} ${r2(y)}) scale(${side / LINK_ICON_SIZE})`, stroke: "none" }, ...frame(surface), face, ...data.glyph)]);
80
+ });
@@ -8,13 +8,13 @@ export type Ink = {
8
8
  export type LineBadgeParams = Ink & {
9
9
  x: number;
10
10
  y: number;
11
- text: string;
11
+ text?: string;
12
12
  fill: string;
13
13
  width?: number;
14
14
  height?: number;
15
15
  flat?: boolean;
16
16
  };
17
- /** A line's terminal badge: a pill in the line colour with its code in white, centred on (x, y). */
17
+ /** A line's terminal badge: a pill in the line colour with its code in white, centred on (x, y); without `text`, the pill alone. */
18
18
  export declare const lineBadge: import("./piece.ts").Piece<LineBadgeParams>;
19
19
  export type StationMarkerParams = Ink & {
20
20
  cx: number;
@@ -76,9 +76,44 @@ export type LedgerMarkParams = {
76
76
  y: number;
77
77
  size?: number;
78
78
  line?: string;
79
+ surface?: SurfaceName;
79
80
  };
80
- /** stackledger's mark (favicon, header): an ink square with a bent line and two stations, 32 units scaled to `size`. */
81
+ /** stackledger's mark (favicon, header): an ink square with a bent line and two stations, 32 units scaled to `size`.
82
+ * On a surface with a rim (dark), a light rim of 2 × rimWidth lines the square inside its 32 box (card 167). */
81
83
  export declare const ledgerMark: import("./piece.ts").Piece<LedgerMarkParams>;
84
+ export type RenewalRingParams = Ink & {
85
+ cx: number;
86
+ cy: number;
87
+ r: number;
88
+ width?: number;
89
+ };
90
+ /** The renewal ring: bands like a bent pipe round a station (rim on dark, ink, then color.blocked), no shadow. */
91
+ export declare const renewalRing: import("./piece.ts").Piece<RenewalRingParams>;
92
+ export type SuggestedHaloParams = {
93
+ cx: number;
94
+ cy: number;
95
+ r: number;
96
+ surface?: SurfaceName;
97
+ };
98
+ /** The suggested-position halo: a dashed muted circle, no ink. */
99
+ export declare const suggestedHalo: import("./piece.ts").Piece<SuggestedHaloParams>;
100
+ export type LicenceMarkParams = Ink & {
101
+ cx: number;
102
+ cy: number;
103
+ state?: "owned" | "toBuy";
104
+ size?: number;
105
+ };
106
+ /** A licence: a small disc sticker, green when held, white to buy. Its own room rule: size ≥ 3 × outline. */
107
+ export declare const licenceMark: import("./piece.ts").Piece<LicenceMarkParams>;
108
+ export type ZoneParams = {
109
+ points: Point[];
110
+ fill: string;
111
+ title?: string;
112
+ titleAt?: Point;
113
+ surface?: SurfaceName;
114
+ };
115
+ /** A zone: the outline the caller gives (stackledger's hull), widened by a round stroke, at the surface's zone opacity; an optional title. */
116
+ export declare const zone: import("./piece.ts").Piece<ZoneParams>;
82
117
  export declare const METRO_PIECES: {
83
118
  readonly lineBadge: import("./piece.ts").Piece<LineBadgeParams>;
84
119
  readonly stationMarker: import("./piece.ts").Piece<StationMarkerParams>;
@@ -89,5 +124,9 @@ export declare const METRO_PIECES: {
89
124
  readonly hexNode: import("./piece.ts").Piece<NodeParams>;
90
125
  readonly messageArrow: import("./piece.ts").Piece<MessageArrowParams>;
91
126
  readonly ledgerMark: import("./piece.ts").Piece<LedgerMarkParams>;
127
+ readonly renewalRing: import("./piece.ts").Piece<RenewalRingParams>;
128
+ readonly suggestedHalo: import("./piece.ts").Piece<SuggestedHaloParams>;
129
+ readonly licenceMark: import("./piece.ts").Piece<LicenceMarkParams>;
130
+ readonly zone: import("./piece.ts").Piece<ZoneParams>;
92
131
  };
93
132
  export {};
@@ -50,9 +50,10 @@ const sticker = (shape, face, inkParams) => {
50
50
  // Every text sets its own anchor, size and weight, so a surrounding group (a cover's label style) does not leak in.
51
51
  const words = (x, y, value, attrs) => el("text", { x: r2(x), y: r2(y), "font-family": font, ...attrs }, value);
52
52
  const pill = (x, y, w, h) => (dx, dy, attrs) => el("rect", { x: r2(x + dx), y: r2(y + dy), width: r2(w), height: r2(h), rx: r2(h / 2), ...attrs });
53
- const badgeWidth = ({ width, text }) => width ?? Math.max(30, 7.6 * Array.from(text).length + 18);
54
- /** A line's terminal badge: a pill in the line colour with its code in white, centred on (x, y). */
55
- export const lineBadge = definePiece("lineBadge", { x: pos(), y: pos(), text: label(), fill: dataColour(), width: size(), height: size(), flat: { kind: "boolean" }, ...inkSchema }, (params) => {
53
+ // Without a text, the default width is the one-character width (card 167).
54
+ const badgeWidth = ({ width, text }) => width ?? Math.max(30, 7.6 * (text === undefined ? 1 : Array.from(text).length) + 18);
55
+ /** A line's terminal badge: a pill in the line colour with its code in white, centred on (x, y); without `text`, the pill alone. */
56
+ export const lineBadge = definePiece("lineBadge", { x: pos(), y: pos(), text: label(false), fill: dataColour(), width: size(), height: size(), flat: { kind: "boolean" }, ...inkSchema }, (params) => {
56
57
  const { x, y, text, fill, height = M.badgeHeight, flat = false } = params;
57
58
  const width = badgeWidth(params);
58
59
  const face = paint(fill);
@@ -60,7 +61,7 @@ export const lineBadge = definePiece("lineBadge", { x: pos(), y: pos(), text: la
60
61
  const inset = flat ? outline / 2 : 0;
61
62
  const shape = pill(x - width / 2 + inset, y - height / 2 + inset, width - 2 * inset, height - 2 * inset);
62
63
  const body = flat ? [shape(0, 0, { fill: face, stroke: ink, "stroke-width": r2(outline), "stroke-linejoin": "round" })] : sticker(shape, face, params);
63
- return drawn(body, [words(x, y + 4.5, text, { fill: white, "font-size": M.badgeTextSize, "font-weight": bold, "text-anchor": "middle" })]);
64
+ return drawn(body, text === undefined ? [] : [words(x, y + 4.5, text, { fill: white, "font-size": M.badgeTextSize, "font-weight": bold, "text-anchor": "middle" })]);
64
65
  }, (params) => {
65
66
  const height = params.height ?? M.badgeHeight;
66
67
  return limits(atLeast("width", badgeWidth(params), height, "the height"), roomFor("height", height, params));
@@ -125,10 +126,11 @@ const nodeSchema = {
125
126
  const nodeLimits = (kind) => (params) => {
126
127
  const { width, height = M.nodeHeight, subtitle, state = "normal" } = params;
127
128
  const radius = params.radius ?? M.nodeRadius;
128
- return limits(atLeast("width", width, 2 * height, "twice the height"), kind === "box" && radius > height / 2 && { path: "radius", message: `must be at most half the height (${r2(height / 2)}), found ${r2(radius)}` }, state === "placeholder" && subtitle !== undefined && { path: "subtitle", message: "a placeholder has no subtitle" }, state !== "placeholder" && roomFor("height", height, params));
129
+ return limits(atLeast("width", width, 2 * height, "twice the height"), kind === "box" && radius > height / 2 && { path: "radius", message: `must be at most half the height (${r2(height / 2)}), found ${r2(radius)}` }, state !== "placeholder" && roomFor("height", height, params));
129
130
  };
130
131
  const drawNode = (kind, params) => {
131
132
  const { x, y, width, height = M.nodeHeight, title, subtitle, state = "normal" } = params;
133
+ const inset = kind === "box" ? 12 : height / 2;
132
134
  const radius = kind === "pill" ? height / 2 : (params.radius ?? M.nodeRadius);
133
135
  const shape = kind === "hex"
134
136
  ? (dx, dy, attrs) => {
@@ -140,10 +142,16 @@ const drawNode = (kind, params) => {
140
142
  const { s } = inkOf(params);
141
143
  const middle = y + height / 2;
142
144
  if (state === "placeholder") {
143
- return drawn([shape(0, 0, { fill: "none", stroke: s.muted, "stroke-width": 1.5, "stroke-dasharray": "5 4", "stroke-linejoin": "round" })], [words(x + width / 2, middle + 4.5, title, { fill: s.muted, "font-size": M.labelSize, "font-weight": 400, "text-anchor": "middle" })]);
145
+ const dashed = [shape(0, 0, { fill: "none", stroke: s.muted, "stroke-width": 1.5, "stroke-dasharray": "5 4", "stroke-linejoin": "round" })];
146
+ // With a subtitle, a normal node's text layout, every word muted (stackledger's unlinked box; card 167).
147
+ return drawn(dashed, subtitle === undefined
148
+ ? [words(x + width / 2, middle + 4.5, title, { fill: s.muted, "font-size": M.labelSize, "font-weight": 400, "text-anchor": "middle" })]
149
+ : [
150
+ words(x + inset, middle - 3, title, { fill: s.muted, "font-size": M.labelSize, "font-weight": bold, "text-anchor": "start" }),
151
+ words(x + inset, middle + 14, subtitle, { fill: s.muted, "font-size": 11, "font-weight": 400, "text-anchor": "start" }),
152
+ ]);
144
153
  }
145
154
  const focus = state === "focus";
146
- const inset = kind === "box" ? 12 : height / 2;
147
155
  const titleFill = focus ? ink : s.text;
148
156
  const text = subtitle === undefined
149
157
  ? [words(x + inset, middle + 4.5, title, { fill: titleFill, "font-size": M.labelSize, "font-weight": bold, "text-anchor": "start" })]
@@ -198,8 +206,61 @@ export const messageArrow = definePiece("messageArrow", {
198
206
  const distance = Math.hypot(to[0] - from[0], to[1] - from[1]);
199
207
  return limits(distance === 0 && { path: "to", message: "must differ from from" }, distance > 0 && head && distance < 10 * width && { path: "to", message: `is too close to from for the head: needs at least ${r2(10 * width)}, found ${r2(distance)}` });
200
208
  });
201
- /** stackledger's mark (favicon, header): an ink square with a bent line and two stations, 32 units scaled to `size`. */
202
- export const ledgerMark = definePiece("ledgerMark", { x: pos(), y: pos(), size: { kind: "number", min: 12, max: 512 }, line: col() }, ({ x, y, size: side = 32, line = "illustration.accent" }) => drawn([
203
- g({ transform: `translate(${r2(x)} ${r2(y)}) scale(${side / 32})` }, el("rect", { x: 0, y: 0, width: 32, height: 32, rx: 7, fill: ink, stroke: "none" }), el("path", { d: "M7 23 L14 16 L25 16", fill: "none", stroke: colour(line, "line"), "stroke-width": 4.5, "stroke-linecap": "round", "stroke-linejoin": "round" }), el("circle", { cx: 14, cy: 16, r: 4, fill: white, stroke: ink, "stroke-width": 2 }), el("circle", { cx: 25, cy: 16, r: 2.6, fill: white, stroke: ink, "stroke-width": 1.6 })),
204
- ]));
205
- export const METRO_PIECES = { lineBadge, stationMarker, pipe, countBadge, nodeBlock, pillNode, hexNode, messageArrow, ledgerMark };
209
+ /** stackledger's mark (favicon, header): an ink square with a bent line and two stations, 32 units scaled to `size`.
210
+ * On a surface with a rim (dark), a light rim of 2 × rimWidth lines the square inside its 32 box (card 167). */
211
+ export const ledgerMark = definePiece("ledgerMark", { x: pos(), y: pos(), size: { kind: "number", min: 12, max: 512 }, line: col(), surface: surfaceParam }, ({ x, y, size: side = 32, line = "illustration.accent", surface = "light" }) => {
212
+ const { rimWidth, rim } = tokens.surface[surface];
213
+ const band = 2 * rimWidth;
214
+ const square = rimWidth > 0
215
+ ? [
216
+ el("rect", { x: 0, y: 0, width: 32, height: 32, rx: 7, fill: rim, stroke: "none" }),
217
+ el("rect", { x: band, y: band, width: 32 - 2 * band, height: 32 - 2 * band, rx: 7 - band, fill: ink, stroke: "none" }),
218
+ ]
219
+ : [el("rect", { x: 0, y: 0, width: 32, height: 32, rx: 7, fill: ink, stroke: "none" })];
220
+ return drawn([
221
+ g({ transform: `translate(${r2(x)} ${r2(y)}) scale(${side / 32})` }, ...square, el("path", { d: "M7 23 L14 16 L25 16", fill: "none", stroke: colour(line, "line"), "stroke-width": 4.5, "stroke-linecap": "round", "stroke-linejoin": "round" }), el("circle", { cx: 14, cy: 16, r: 4, fill: white, stroke: ink, "stroke-width": 2 }), el("circle", { cx: 25, cy: 16, r: 2.6, fill: white, stroke: ink, "stroke-width": 1.6 })),
222
+ ]);
223
+ });
224
+ /** The renewal ring: bands like a bent pipe round a station (rim on dark, ink, then color.blocked), no shadow. */
225
+ export const renewalRing = definePiece("renewalRing", { cx: pos(), cy: pos(), r: size(true), width: { kind: "number", min: 0, exclusiveMin: true, max: 50 }, ...inkSchema }, (params) => {
226
+ const { cx, cy, r, width = M.ringWidth } = params;
227
+ const { s, outline, rim } = inkOf(params);
228
+ // Radius and band widths as given, like pipe's widths: a valid hairline must not round to zero.
229
+ const band = (stroke, strokeWidth) => el("circle", { cx: r2(cx), cy: r2(cy), r, fill: "none", stroke, "stroke-width": strokeWidth });
230
+ return drawn([...(s.rimWidth > 0 ? [band(s.rim, width + outline + 2 * rim)] : []), band(ink, width + outline), band(tokens.color.blocked, width)]);
231
+ }, (params) => {
232
+ const { outline, rim } = inkOf(params);
233
+ const need = ((params.width ?? M.ringWidth) + outline + 2 * rim) / 2;
234
+ return limits(params.r < need && { path: "r", message: `leaves no room for the ring's bands: needs at least ${r2(need)}` });
235
+ });
236
+ /** The suggested-position halo: a dashed muted circle, no ink. */
237
+ export const suggestedHalo = definePiece("suggestedHalo", { cx: pos(), cy: pos(), r: size(true), surface: surfaceParam }, ({ cx, cy, r, surface = "light" }) => drawn([el("circle", { cx: r2(cx), cy: r2(cy), r, fill: "none", stroke: tokens.surface[surface].muted, "stroke-width": M.haloWidth, "stroke-dasharray": "4 3" })]));
238
+ /** A licence: a small disc sticker, green when held, white to buy. Its own room rule: size ≥ 3 × outline. */
239
+ export const licenceMark = definePiece("licenceMark", { cx: pos(), cy: pos(), state: { kind: "enum", values: ["owned", "toBuy"] }, size: size(), ...inkSchema }, (params) => {
240
+ const { cx, cy, state = "owned", size: side = M.licenceSize } = params;
241
+ const shape = (dx, dy, attrs) => el("circle", { cx: r2(cx + dx), cy: r2(cy + dy), r: r2(side / 2), ...attrs });
242
+ return drawn(sticker(shape, state === "owned" ? tokens.color.healthy : white, params));
243
+ }, (params) => {
244
+ const need = 3 * inkOf(params).outline;
245
+ const side = params.size ?? M.licenceSize;
246
+ return limits(side < need && { path: "size", message: `leaves no room for the licence's face: needs at least ${r2(need)} at this ink scale` });
247
+ });
248
+ /** Mixes a #RRGGBB colour with white by `amount` (stackledger's zone title tint). */
249
+ const tint = (hex, amount) => "#" + [1, 3, 5].map((i) => Math.round(parseInt(hex.slice(i, i + 2), 16) * (1 - amount) + 255 * amount).toString(16).padStart(2, "0").toUpperCase()).join("");
250
+ /** A zone: the outline the caller gives (stackledger's hull), widened by a round stroke, at the surface's zone opacity; an optional title. */
251
+ export const zone = definePiece("zone", { points: { kind: "array", required: true, minItems: 1, maxItems: 10000, of: point() }, fill: dataColour(), title: label(false), titleAt: point(false), surface: surfaceParam }, ({ points, fill, title, titleAt, surface = "light" }) => {
252
+ const colourOf = paint(fill);
253
+ const pad = M.zonePadding;
254
+ const at = (p) => `${r2(p[0])},${r2(p[1])}`;
255
+ const shape = points.length === 1
256
+ ? el("circle", { cx: r2(points[0][0]), cy: r2(points[0][1]), r: pad, fill: colourOf })
257
+ : points.length === 2
258
+ ? el("line", { x1: r2(points[0][0]), y1: r2(points[0][1]), x2: r2(points[1][0]), y2: r2(points[1][1]), stroke: colourOf, "stroke-width": 2 * pad, "stroke-linecap": "round" })
259
+ : el("polygon", { points: points.map(at).join(" "), fill: colourOf, stroke: colourOf, "stroke-width": 2 * pad, "stroke-linejoin": "round" });
260
+ const titleFill = surface === "dark" ? tint(colourOf, M.zoneTitleTint) : colourOf;
261
+ const text = title !== undefined && titleAt !== undefined
262
+ ? [words(titleAt[0], titleAt[1] + 13, title, { fill: titleFill, "font-size": M.labelSize, "font-weight": bold, "text-anchor": "start" })]
263
+ : [];
264
+ return drawn([g({ opacity: tokens.surface[surface].zoneOpacity, stroke: "none" }, shape)], text);
265
+ }, ({ title, titleAt }) => limits(title !== undefined && titleAt === undefined && { path: "titleAt", message: "is required with a title" }, titleAt !== undefined && title === undefined && { path: "title", message: "is required with titleAt" }));
266
+ export const METRO_PIECES = { lineBadge, stationMarker, pipe, countBadge, nodeBlock, pillNode, hexNode, messageArrow, ledgerMark, renewalRing, suggestedHalo, licenceMark, zone };
@@ -0,0 +1,20 @@
1
+ import type { SvgNode } from "./node.ts";
2
+ export type UiIcon = {
3
+ readonly name: string;
4
+ readonly title: string;
5
+ readonly description: string;
6
+ readonly nodes: readonly SvgNode[];
7
+ };
8
+ /** Every UI icon draws in a 24x24 box. */
9
+ export declare const UI_ICON_SIZE = 24;
10
+ export declare const UI_ICONS: readonly UiIcon[];
11
+ export declare const UI_ICON_NAMES: string[];
12
+ export type UiIconParams = {
13
+ name: string;
14
+ x: number;
15
+ y: number;
16
+ size?: number;
17
+ colour?: string;
18
+ };
19
+ /** One UI icon at (x, y), `size` wide (24 by default), in one token colour (`color.ink` by default). */
20
+ export declare const uiIcon: import("./piece.ts").Piece<UiIconParams>;
@@ -0,0 +1,34 @@
1
+ import { el, g, drawn, r2 } from "./node.js";
2
+ import { col, pos, size } from "./params.js";
3
+ import { definePiece } from "./piece.js";
4
+ import { colour } from "./params.js";
5
+ /** Every UI icon draws in a 24x24 box. */
6
+ export const UI_ICON_SIZE = 24;
7
+ const stroked = (d) => el("path", { d, fill: "none", "stroke-width": 2, "stroke-linecap": "round", "stroke-linejoin": "round" });
8
+ const filled = (d) => el("path", { d, stroke: "none" });
9
+ export const UI_ICONS = [
10
+ {
11
+ name: "sun",
12
+ title: "Sun",
13
+ description: "Light theme: a disc with eight rays.",
14
+ nodes: [el("circle", { cx: 12, cy: 12, r: 4.5, stroke: "none" }), stroked("M12 2.5v2.5M12 19v2.5M2.5 12H5M19 12h2.5M5.3 5.3l1.8 1.8M16.9 16.9l1.8 1.8M5.3 18.7l1.8-1.8M16.9 7.1l1.8-1.8")],
15
+ },
16
+ { name: "moon", title: "Moon", description: "Dark theme: a crescent moon.", nodes: [filled("M20.5 14.6A8.6 8.6 0 0 1 9.4 3.5a8.6 8.6 0 1 0 11.1 11.1Z")] },
17
+ { name: "zoomIn", title: "Zoom in", description: "A magnifier with a plus.", nodes: [stroked("M10.5 4a6.5 6.5 0 1 0 0 13a6.5 6.5 0 1 0 0-13ZM15.3 15.3l5.2 5.2M7.5 10.5h6M10.5 7.5v6")] },
18
+ { name: "zoomOut", title: "Zoom out", description: "A magnifier with a minus.", nodes: [stroked("M10.5 4a6.5 6.5 0 1 0 0 13a6.5 6.5 0 1 0 0-13ZM15.3 15.3l5.2 5.2M7.5 10.5h6")] },
19
+ { name: "reset", title: "Reset", description: "A circular arrow: back to the start.", nodes: [stroked("M4.5 12A7.5 7.5 0 1 0 6.7 6.7M4 3.5v4.5h4.5")] },
20
+ {
21
+ name: "calendar",
22
+ title: "Calendar",
23
+ description: "A calendar page with one day marked.",
24
+ nodes: [stroked("M5.5 5h13a2 2 0 0 1 2 2v11.5a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2ZM3.5 10h17M8 3v4M16 3v4"), el("rect", { x: 7, y: 13, width: 3, height: 3, rx: 0.5, stroke: "none" })],
25
+ },
26
+ { name: "download", title: "Download", description: "An arrow down into a tray.", nodes: [stroked("M12 3.5v11M7.5 10l4.5 4.5 4.5-4.5M4.5 16.5v3h15v-3")] },
27
+ ];
28
+ export const UI_ICON_NAMES = UI_ICONS.map((icon) => icon.name);
29
+ /** One UI icon at (x, y), `size` wide (24 by default), in one token colour (`color.ink` by default). */
30
+ export const uiIcon = definePiece("uiIcon", { name: { kind: "enum", required: true, values: UI_ICON_NAMES }, x: pos(), y: pos(), size: size(), colour: col() }, ({ name, x, y, size: side = UI_ICON_SIZE, colour: path = "color.ink" }) => {
31
+ const icon = UI_ICONS.find((candidate) => candidate.name === name);
32
+ const paint = colour(path, "colour");
33
+ return drawn([g({ transform: `translate(${r2(x)} ${r2(y)}) scale(${side / UI_ICON_SIZE})`, fill: paint, stroke: paint }, ...icon.nodes)]);
34
+ });
@@ -1,4 +1,9 @@
1
1
  export declare const tokens: {
2
+ readonly banner: {
3
+ readonly height: 420;
4
+ readonly radius: 20;
5
+ readonly width: 1200;
6
+ };
2
7
  readonly color: {
3
8
  readonly backend: "#FF6B6B";
4
9
  readonly blocked: "#FF8A3D";
@@ -131,14 +136,19 @@ export declare const tokens: {
131
136
  readonly countHeight: 16;
132
137
  readonly countTextSize: 11;
133
138
  readonly edgeWidth: 2.5;
139
+ readonly haloWidth: 1.5;
134
140
  readonly interchangeExtra: 2;
135
141
  readonly labelSize: 13;
142
+ readonly licenceSize: 10;
136
143
  readonly lineWidth: 12;
137
144
  readonly nodeHeight: 46;
138
145
  readonly nodeRadius: 10;
139
146
  readonly outline: 3;
147
+ readonly ringWidth: 3;
140
148
  readonly shadow: 3.5;
141
149
  readonly stopRadius: 12;
150
+ readonly zonePadding: 32;
151
+ readonly zoneTitleTint: 0.6;
142
152
  };
143
153
  readonly motion: {
144
154
  readonly dimOpacity: 0.42;
@@ -228,6 +238,7 @@ export declare const tokens: {
228
238
  readonly rimWidth: 1;
229
239
  readonly shadow: "#4C5CB8";
230
240
  readonly text: "#EEF2FF";
241
+ readonly zoneOpacity: 0.22;
231
242
  };
232
243
  readonly light: {
233
244
  readonly background: "#EEF2FF";
@@ -238,6 +249,7 @@ export declare const tokens: {
238
249
  readonly rimWidth: 0;
239
250
  readonly shadow: "#0A1033";
240
251
  readonly text: "#0A1033";
252
+ readonly zoneOpacity: 0.12;
241
253
  };
242
254
  };
243
255
  };
@@ -1,5 +1,10 @@
1
1
  // Generated from tokens/source.json by scripts/tokens.ts (npm run tokens). Do not edit by hand.
2
2
  export const tokens = {
3
+ "banner": {
4
+ "height": 420,
5
+ "radius": 20,
6
+ "width": 1200
7
+ },
3
8
  "color": {
4
9
  "backend": "#FF6B6B",
5
10
  "blocked": "#FF8A3D",
@@ -132,14 +137,19 @@ export const tokens = {
132
137
  "countHeight": 16,
133
138
  "countTextSize": 11,
134
139
  "edgeWidth": 2.5,
140
+ "haloWidth": 1.5,
135
141
  "interchangeExtra": 2,
136
142
  "labelSize": 13,
143
+ "licenceSize": 10,
137
144
  "lineWidth": 12,
138
145
  "nodeHeight": 46,
139
146
  "nodeRadius": 10,
140
147
  "outline": 3,
148
+ "ringWidth": 3,
141
149
  "shadow": 3.5,
142
- "stopRadius": 12
150
+ "stopRadius": 12,
151
+ "zonePadding": 32,
152
+ "zoneTitleTint": 0.6
143
153
  },
144
154
  "motion": {
145
155
  "dimOpacity": 0.42,
@@ -228,7 +238,8 @@ export const tokens = {
228
238
  "rim": "#EEF2FF",
229
239
  "rimWidth": 1,
230
240
  "shadow": "#4C5CB8",
231
- "text": "#EEF2FF"
241
+ "text": "#EEF2FF",
242
+ "zoneOpacity": 0.22
232
243
  },
233
244
  "light": {
234
245
  "background": "#EEF2FF",
@@ -238,7 +249,8 @@ export const tokens = {
238
249
  "rim": "#EEF2FF",
239
250
  "rimWidth": 0,
240
251
  "shadow": "#0A1033",
241
- "text": "#0A1033"
252
+ "text": "#0A1033",
253
+ "zoneOpacity": 0.12
242
254
  }
243
255
  }
244
256
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brunosouzas/design-system",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Design tokens and illustration pieces of brunosouzas.com: tokens as CSS, JSON and TypeScript, pieces that draw SVG (and React), and a cover command.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -1,5 +1,8 @@
1
1
  /* Generated from tokens/source.json by scripts/tokens.ts (npm run tokens). Do not edit by hand. */
2
2
  :root {
3
+ --ds-banner-height: 420px;
4
+ --ds-banner-radius: 20px;
5
+ --ds-banner-width: 1200px;
3
6
  --ds-color-backend: #FF6B6B;
4
7
  --ds-color-blocked: #FF8A3D;
5
8
  --ds-color-caller: #8FB2FF;
@@ -79,14 +82,19 @@
79
82
  --ds-map-count-height: 16px;
80
83
  --ds-map-count-text-size: 11px;
81
84
  --ds-map-edge-width: 2.5px;
85
+ --ds-map-halo-width: 1.5px;
82
86
  --ds-map-interchange-extra: 2px;
83
87
  --ds-map-label-size: 13px;
88
+ --ds-map-licence-size: 10px;
84
89
  --ds-map-line-width: 12px;
85
90
  --ds-map-node-height: 46px;
86
91
  --ds-map-node-radius: 10px;
87
92
  --ds-map-outline: 3px;
93
+ --ds-map-ring-width: 3px;
88
94
  --ds-map-shadow: 3.5px;
89
95
  --ds-map-stop-radius: 12px;
96
+ --ds-map-zone-padding: 32px;
97
+ --ds-map-zone-title-tint: 0.6;
90
98
  --ds-motion-dim-opacity: 0.42;
91
99
  --ds-motion-draw-arrow: 20;
92
100
  --ds-motion-fade: 10;
@@ -149,6 +157,7 @@
149
157
  --ds-surface-dark-rim-width: 1px;
150
158
  --ds-surface-dark-shadow: #4C5CB8;
151
159
  --ds-surface-dark-text: #EEF2FF;
160
+ --ds-surface-dark-zone-opacity: 0.22;
152
161
  --ds-surface-light-background: #EEF2FF;
153
162
  --ds-surface-light-grid: #E0E7FB;
154
163
  --ds-surface-light-muted: #5B6694;
@@ -157,4 +166,5 @@
157
166
  --ds-surface-light-rim-width: 0px;
158
167
  --ds-surface-light-shadow: #0A1033;
159
168
  --ds-surface-light-text: #0A1033;
169
+ --ds-surface-light-zone-opacity: 0.12;
160
170
  }
@@ -1,4 +1,9 @@
1
1
  {
2
+ "banner": {
3
+ "height": 420,
4
+ "radius": 20,
5
+ "width": 1200
6
+ },
2
7
  "color": {
3
8
  "backend": "#FF6B6B",
4
9
  "blocked": "#FF8A3D",
@@ -131,14 +136,19 @@
131
136
  "countHeight": 16,
132
137
  "countTextSize": 11,
133
138
  "edgeWidth": 2.5,
139
+ "haloWidth": 1.5,
134
140
  "interchangeExtra": 2,
135
141
  "labelSize": 13,
142
+ "licenceSize": 10,
136
143
  "lineWidth": 12,
137
144
  "nodeHeight": 46,
138
145
  "nodeRadius": 10,
139
146
  "outline": 3,
147
+ "ringWidth": 3,
140
148
  "shadow": 3.5,
141
- "stopRadius": 12
149
+ "stopRadius": 12,
150
+ "zonePadding": 32,
151
+ "zoneTitleTint": 0.6
142
152
  },
143
153
  "motion": {
144
154
  "dimOpacity": 0.42,
@@ -227,7 +237,8 @@
227
237
  "rim": "#EEF2FF",
228
238
  "rimWidth": 1,
229
239
  "shadow": "#4C5CB8",
230
- "text": "#EEF2FF"
240
+ "text": "#EEF2FF",
241
+ "zoneOpacity": 0.22
231
242
  },
232
243
  "light": {
233
244
  "background": "#EEF2FF",
@@ -237,7 +248,8 @@
237
248
  "rim": "#EEF2FF",
238
249
  "rimWidth": 0,
239
250
  "shadow": "#0A1033",
240
- "text": "#0A1033"
251
+ "text": "#0A1033",
252
+ "zoneOpacity": 0.12
241
253
  }
242
254
  }
243
255
  }
@@ -1,6 +1,11 @@
1
1
  // Generated from tokens/source.json by scripts/tokens.ts (npm run tokens). Do not edit by hand.
2
2
 
3
3
  export const tokens = {
4
+ "banner": {
5
+ "height": 420,
6
+ "radius": 20,
7
+ "width": 1200
8
+ },
4
9
  "color": {
5
10
  "backend": "#FF6B6B",
6
11
  "blocked": "#FF8A3D",
@@ -133,14 +138,19 @@ export const tokens = {
133
138
  "countHeight": 16,
134
139
  "countTextSize": 11,
135
140
  "edgeWidth": 2.5,
141
+ "haloWidth": 1.5,
136
142
  "interchangeExtra": 2,
137
143
  "labelSize": 13,
144
+ "licenceSize": 10,
138
145
  "lineWidth": 12,
139
146
  "nodeHeight": 46,
140
147
  "nodeRadius": 10,
141
148
  "outline": 3,
149
+ "ringWidth": 3,
142
150
  "shadow": 3.5,
143
- "stopRadius": 12
151
+ "stopRadius": 12,
152
+ "zonePadding": 32,
153
+ "zoneTitleTint": 0.6
144
154
  },
145
155
  "motion": {
146
156
  "dimOpacity": 0.42,
@@ -229,7 +239,8 @@ export const tokens = {
229
239
  "rim": "#EEF2FF",
230
240
  "rimWidth": 1,
231
241
  "shadow": "#4C5CB8",
232
- "text": "#EEF2FF"
242
+ "text": "#EEF2FF",
243
+ "zoneOpacity": 0.22
233
244
  },
234
245
  "light": {
235
246
  "background": "#EEF2FF",
@@ -239,7 +250,8 @@ export const tokens = {
239
250
  "rim": "#EEF2FF",
240
251
  "rimWidth": 0,
241
252
  "shadow": "#0A1033",
242
- "text": "#0A1033"
253
+ "text": "#0A1033",
254
+ "zoneOpacity": 0.12
243
255
  }
244
256
  }
245
257
  } as const;