@brunosouzas/design-system 0.1.1 → 0.2.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
@@ -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
  ```
@@ -151,7 +151,7 @@ const badge = lineBadge({ x: 250, y: 40, text: "SH", fill: "#7B3FE4", surface: "
151
151
 
152
152
  ### A cover
153
153
 
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.
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. With `"format": "banner"` the same command writes a site banner instead (see below).
155
155
 
156
156
  ```json
157
157
  {
@@ -173,6 +173,15 @@ A cover is a JSON spec kept with its article, outside this repository: drafts ar
173
173
  - `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
174
  - The three published covers that proved the pieces are the test fixtures in `test/fixtures/covers/`.
175
175
 
176
+ ### Banners
177
+
178
+ 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:
179
+
180
+ - 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;
181
+ - pieces and labels are clipped to the rounded frame too, so nothing pokes past a corner.
182
+
183
+ 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.
184
+
176
185
  ### A new piece
177
186
 
178
187
  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.
@@ -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";
@@ -10,13 +10,16 @@ export type PillParams = {
10
10
  /** Pill label centred on (x, y), usually overlapping the top edge of what it names. */
11
11
  export declare const pill: import("./piece.ts").Piece<PillParams>;
12
12
  export declare const RING_COUNT = 14;
13
+ /** Most rings one call draws: enough for a banner's farthest corner from any centre a cover spec accepts (548). */
14
+ export declare const MAX_RINGS = 1000;
13
15
  export type RingsParams = {
14
16
  cx: number;
15
17
  cy: number;
16
18
  radius?: number;
17
19
  color?: string;
20
+ count?: number;
18
21
  };
19
- /** Concentric rings around (cx, cy): 14 rings, step and width from the cover tokens. */
22
+ /** Concentric rings around (cx, cy): `count` rings (14 by default), step and width from the cover tokens. */
20
23
  export declare const rings: import("./piece.ts").Piece<RingsParams>;
21
24
  export type SheetRow = {
22
25
  lead?: "none" | "checkbox" | "checked" | "question";
@@ -23,9 +23,11 @@ export const pill = definePiece("pill", { x: pos(), y: pos(), text: label(), fil
23
23
  });
24
24
  // ---------- background ----------
25
25
  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 }))),
26
+ /** Most rings one call draws: enough for a banner's farthest corner from any centre a cover spec accepts (548). */
27
+ export const MAX_RINGS = 1000;
28
+ /** Concentric rings around (cx, cy): `count` rings (14 by default), step and width from the cover tokens. */
29
+ 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([
30
+ 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
31
  ]));
30
32
  const rowSchema = {
31
33
  lead: { kind: "enum", values: ["none", "checkbox", "checked", "question"] },
@@ -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";
@@ -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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brunosouzas/design-system",
3
- "version": "0.1.1",
3
+ "version": "0.2.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;
@@ -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",
@@ -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",