@ideare/kit 0.1.0-next.0 → 0.1.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,10 @@
1
1
  import { ensureContrast, oklabDistance } from "../color/contrast.js";
2
2
  import { CONTRAST_PAIRS } from "../color/palette.js";
3
3
  import { contrast, formatRatio } from "../color/wcag.js";
4
- import { BANNERS, avatarScene, bannerScene, cardBackScene, catalogScene, ogScene, tileScene, } from "../files/templates.js";
4
+ import { BANNERS, avatarScene, bannerScene, cardBackScene, catalogScene, logoScene, ogScene, tileScene, } from "../files/templates.js";
5
5
  import { parseMark } from "../marks/svg.js";
6
6
  import { engineFor, renderScene } from "../scene/render.js";
7
- import { fontFor } from "../scene/brand-art.js";
7
+ import { fontFor, linePxAt16, smallMark } from "../scene/brand-art.js";
8
8
  import { checkCopy } from "./copy.js";
9
9
  const GROUND_WORDS = {
10
10
  ground: "the page",
@@ -166,10 +166,9 @@ function markChecks(brand) {
166
166
  });
167
167
  }
168
168
  }
169
- // Thin lines vanish at 16 px: the thinnest stroke must stay at 1 px or more.
170
- const small = parseMark(brand.mark.small ?? brand.mark.svg);
171
- const drawn = 16 * (1 - Math.min(brand.mark.tile.padding, 0.12));
172
- const thinnest = (small.thinnestStroke * drawn) / Math.max(small.viewBox.w, small.viewBox.h);
169
+ // Thin lines vanish at 16 px: the thinnest stroke of the small drawing must stay at 1 px or
170
+ // more. Measured on what the kit draws there, a curated icon's heavier lines included.
171
+ const thinnest = Math.round(linePxAt16(smallMark(brand), brand.mark.tile.padding) * 100) / 100;
173
172
  if (Number.isFinite(thinnest)) {
174
173
  results.push(thinnest >= 1
175
174
  ? {
@@ -177,7 +176,7 @@ function markChecks(brand) {
177
176
  area: "logo",
178
177
  level: "pass",
179
178
  title: "The logo reads at 16 px",
180
- details: { thinnestLinePx: Math.round(thinnest * 100) / 100 },
179
+ details: { thinnestLinePx: thinnest },
181
180
  }
182
181
  : {
183
182
  id: "logo.legibility.16",
@@ -185,7 +184,7 @@ function markChecks(brand) {
185
184
  level: "warning",
186
185
  title: "Thin lines in the logo fade out at 16 px. Add a simpler small version",
187
186
  message: "Browser tabs show the logo at 16 px.",
188
- details: { thinnestLinePx: Math.round(thinnest * 100) / 100, needed: 1 },
187
+ details: { thinnestLinePx: thinnest, needed: 1 },
189
188
  });
190
189
  }
191
190
  // Colors in the mark that are not in either palette.
@@ -435,9 +434,86 @@ async function textChecks(brand, ctx) {
435
434
  : "The iPhone icon has see-through pixels, which iOS turns black",
436
435
  details: { transparentPixels: transparent },
437
436
  });
437
+ const kept = await oneColorKept(brand, ctx.fonts, ctx.rasterize);
438
+ results.push(kept >= ONE_COLOR_KEPT
439
+ ? {
440
+ id: "logo.oneColor",
441
+ area: "logo",
442
+ level: "pass",
443
+ title: "The one-color logo keeps every part",
444
+ details: { edgesKept: formatShare(kept) },
445
+ }
446
+ : {
447
+ id: "logo.oneColor",
448
+ area: "logo",
449
+ level: "warning",
450
+ title: "Parts of the logo merge in the one-color version",
451
+ message: "Stamps and embroidery print the logo in one color. Parts in different colors that touch become one shape. Leave a small gap between them.",
452
+ details: {
453
+ edgesKept: formatShare(kept),
454
+ needed: formatShare(ONE_COLOR_KEPT),
455
+ },
456
+ });
438
457
  }
439
458
  return results;
440
459
  }
460
+ /** The share of the color logo's edges the one-color logo must keep. */
461
+ const ONE_COLOR_KEPT = 0.8;
462
+ function formatShare(share) {
463
+ return `${Math.round(share * 100)}%`;
464
+ }
465
+ /**
466
+ * The share of the color mark's edges that its one-color (ink) version keeps, measured at 64 px.
467
+ * A glyph that merges into its tile keeps only the tile's outline: about half.
468
+ */
469
+ async function oneColorKept(brand, fonts, rasterize) {
470
+ const size = 64;
471
+ const draw = async (tone) => {
472
+ const { svg } = await renderScene(logoScene("mark", tone, size, size), brand, { fonts });
473
+ const raster = await rasterize(svg, { width: size });
474
+ return { pixels: raster.pixels, width: raster.width, height: raster.height };
475
+ };
476
+ const color = await draw("color");
477
+ const ink = await draw("ink");
478
+ const { width, height } = color;
479
+ const at = (x, y) => (y * width + x) * 4;
480
+ // The color mark on white, so a see-through spot reads as the ground.
481
+ const shade = (i, channel) => {
482
+ const alpha = (color.pixels[i + 3] ?? 0) / 255;
483
+ return (color.pixels[i + channel] ?? 0) * alpha + 255 * (1 - alpha);
484
+ };
485
+ const colorEdge = (i, j) => Math.abs(shade(i, 0) - shade(j, 0)) +
486
+ Math.abs(shade(i, 1) - shade(j, 1)) +
487
+ Math.abs(shade(i, 2) - shade(j, 2)) >
488
+ 96;
489
+ const alpha = (x, y) => ink.pixels[at(x, y) + 3] ?? 0;
490
+ const inkEdgeNear = (x, y) => {
491
+ for (let dy = -1; dy <= 1; dy++)
492
+ for (let dx = -1; dx <= 1; dx++) {
493
+ const px = x + dx;
494
+ const py = y + dy;
495
+ if (px < 0 || py < 0 || px + 1 >= ink.width || py + 1 >= ink.height)
496
+ continue;
497
+ const here = alpha(px, py);
498
+ if (Math.abs(here - alpha(px + 1, py)) > 64 ||
499
+ Math.abs(here - alpha(px, py + 1)) > 64)
500
+ return true;
501
+ }
502
+ return false;
503
+ };
504
+ let edges = 0;
505
+ let kept = 0;
506
+ for (let y = 0; y + 1 < height; y++)
507
+ for (let x = 0; x + 1 < width; x++) {
508
+ const i = at(x, y);
509
+ if (!colorEdge(i, at(x + 1, y)) && !colorEdge(i, at(x, y + 1)))
510
+ continue;
511
+ edges++;
512
+ if (inkEdgeNear(x, y))
513
+ kept++;
514
+ }
515
+ return edges ? kept / edges : 1;
516
+ }
441
517
  /** Upload limits per network, in bytes. */
442
518
  const SIZE_LIMITS = [
443
519
  { match: /^og\//, max: 5 * 1024 * 1024, where: "X share cards (5 MB)" },
@@ -0,0 +1,45 @@
1
+ import { type MarkElement, type ParsedMark } from "./svg.ts";
2
+ /**
3
+ * One-color versions (stamps, embroidery, a logo over a photo) keep the symbol readable the way a
4
+ * printer would: a glyph on a tile is cut out of it. The tile prints in the one color and the
5
+ * glyph shows the ground through it, so the version works on any ground. Painting every part in
6
+ * the one color leaves a plain disc.
7
+ */
8
+ /** A box in the mark's own units. */
9
+ type Box = {
10
+ x0: number;
11
+ y0: number;
12
+ x1: number;
13
+ y1: number;
14
+ };
15
+ /** A drawn shape with the paints it inherits ("none", "currentColor" or a hex) and its box. */
16
+ type MarkLeaf = {
17
+ element: MarkElement;
18
+ fill: string;
19
+ stroke: string;
20
+ box: Box;
21
+ };
22
+ /** A shape in a one-color version: whether its fill and its stroke are cut out. */
23
+ type OneColorPart = {
24
+ leaf: MarkLeaf;
25
+ fillHole: boolean;
26
+ strokeHole: boolean;
27
+ };
28
+ /** Every point a path reaches, control points included, in its own units. Relative commands,
29
+ * H and V, and arcs with packed flags (svgo writes "a1 1 0 011 1") all read. */
30
+ export declare function pathPoints(d: string): [number, number][];
31
+ /**
32
+ * Which paints become holes in a one-color version. Each part is read against what it sits on
33
+ * (the topmost filled part before it whose box holds it): in another color it takes the other
34
+ * state, so a glyph on a tile is cut out of it and a dot on that glyph prints again; in the same
35
+ * color it takes the same state. A part on the ground prints, unless it is white or close to it,
36
+ * which reads as the paper. A drawing that is all white (made for dark grounds) keeps every
37
+ * shape, since there is nothing to cut it from.
38
+ */
39
+ export declare function oneColorParts(mark: ParsedMark): OneColorPart[];
40
+ /**
41
+ * The mark as a mask for its one-color version: white where the one color prints, black where a
42
+ * part is cut out. Undefined when nothing is cut out, so the mark can simply be recolored.
43
+ */
44
+ export declare function knockoutMask(mark: ParsedMark): ParsedMark | undefined;
45
+ export {};
@@ -0,0 +1,304 @@
1
+ import { contrast } from "../color/wcag.js";
2
+ import { IDENTITY, apply, withTransform, } from "./svg.js";
3
+ /** Under this contrast with white, a color reads as the paper: a hole or a highlight. */
4
+ const PAPER_CONTRAST = 1.3;
5
+ /** How far a part may reach past the tile's box and still sit on it, as a share of the mark. */
6
+ const SLACK = 0.02;
7
+ const NUMBER = /[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/iy;
8
+ const COMMAND = /[MmLlHhVvCcSsQqTtAaZz]/;
9
+ /** Points along an arc (SVG's endpoint form), sampled from its center form. */
10
+ function arcPoints(x0, y0, radiusX, radiusY, degrees, large, sweep, x, y) {
11
+ let rx = Math.abs(radiusX);
12
+ let ry = Math.abs(radiusY);
13
+ if (!rx || !ry || (x0 === x && y0 === y))
14
+ return [[x, y]];
15
+ const phi = (degrees * Math.PI) / 180;
16
+ const cos = Math.cos(phi);
17
+ const sin = Math.sin(phi);
18
+ const dx = (x0 - x) / 2;
19
+ const dy = (y0 - y) / 2;
20
+ const x1 = cos * dx + sin * dy;
21
+ const y1 = -sin * dx + cos * dy;
22
+ const lambda = (x1 * x1) / (rx * rx) + (y1 * y1) / (ry * ry);
23
+ if (lambda > 1) {
24
+ rx *= Math.sqrt(lambda);
25
+ ry *= Math.sqrt(lambda);
26
+ }
27
+ const num = rx * rx * ry * ry - rx * rx * y1 * y1 - ry * ry * x1 * x1;
28
+ const den = rx * rx * y1 * y1 + ry * ry * x1 * x1;
29
+ const coef = (large === sweep ? -1 : 1) * Math.sqrt(Math.max(0, num / den));
30
+ const cxp = (coef * rx * y1) / ry;
31
+ const cyp = (-coef * ry * x1) / rx;
32
+ const cx = cos * cxp - sin * cyp + (x0 + x) / 2;
33
+ const cy = sin * cxp + cos * cyp + (y0 + y) / 2;
34
+ const start = Math.atan2((y1 - cyp) / ry, (x1 - cxp) / rx);
35
+ let delta = Math.atan2((-y1 - cyp) / ry, (-x1 - cxp) / rx) - start;
36
+ if (sweep && delta < 0)
37
+ delta += 2 * Math.PI;
38
+ else if (!sweep && delta > 0)
39
+ delta -= 2 * Math.PI;
40
+ const points = [];
41
+ for (let step = 1; step <= 16; step++) {
42
+ const t = start + (delta * step) / 16;
43
+ const ex = rx * Math.cos(t);
44
+ const ey = ry * Math.sin(t);
45
+ points.push([cos * ex - sin * ey + cx, sin * ex + cos * ey + cy]);
46
+ }
47
+ return points;
48
+ }
49
+ /** Every point a path reaches, control points included, in its own units. Relative commands,
50
+ * H and V, and arcs with packed flags (svgo writes "a1 1 0 011 1") all read. */
51
+ export function pathPoints(d) {
52
+ const points = [];
53
+ let i = 0;
54
+ const skip = () => {
55
+ while (i < d.length && /[\s,]/.test(d[i] ?? ""))
56
+ i++;
57
+ };
58
+ const number = () => {
59
+ skip();
60
+ NUMBER.lastIndex = i;
61
+ const match = NUMBER.exec(d);
62
+ if (!match)
63
+ return undefined;
64
+ i += match[0].length;
65
+ return Number(match[0]);
66
+ };
67
+ const flag = () => {
68
+ skip();
69
+ const char = d[i];
70
+ if (char !== "0" && char !== "1")
71
+ return undefined;
72
+ i++;
73
+ return Number(char);
74
+ };
75
+ let x = 0;
76
+ let y = 0;
77
+ let startX = 0;
78
+ let startY = 0;
79
+ let command = "";
80
+ for (;;) {
81
+ skip();
82
+ if (i >= d.length)
83
+ return points;
84
+ const char = d[i] ?? "";
85
+ if (COMMAND.test(char)) {
86
+ command = char;
87
+ i++;
88
+ if (command === "Z" || command === "z") {
89
+ x = startX;
90
+ y = startY;
91
+ continue;
92
+ }
93
+ }
94
+ const relative = command === command.toLowerCase();
95
+ const ox = relative ? x : 0;
96
+ const oy = relative ? y : 0;
97
+ const pairs = (count) => {
98
+ const out = [];
99
+ for (let n = 0; n < count; n++) {
100
+ const px = number();
101
+ const py = number();
102
+ if (px === undefined || py === undefined)
103
+ return undefined;
104
+ out.push([ox + px, oy + py]);
105
+ }
106
+ return out;
107
+ };
108
+ let drawn;
109
+ switch (command.toLowerCase()) {
110
+ case "m":
111
+ case "l":
112
+ case "t":
113
+ drawn = pairs(1);
114
+ break;
115
+ case "s":
116
+ case "q":
117
+ drawn = pairs(2);
118
+ break;
119
+ case "c":
120
+ drawn = pairs(3);
121
+ break;
122
+ case "h": {
123
+ const value = number();
124
+ drawn = value === undefined ? undefined : [[ox + value, y]];
125
+ break;
126
+ }
127
+ case "v": {
128
+ const value = number();
129
+ drawn = value === undefined ? undefined : [[x, oy + value]];
130
+ break;
131
+ }
132
+ case "a": {
133
+ const [rx, ry, rotation] = [number(), number(), number()];
134
+ const [large, sweep] = [flag(), flag()];
135
+ const end = pairs(1)?.[0];
136
+ drawn =
137
+ rx === undefined ||
138
+ ry === undefined ||
139
+ rotation === undefined ||
140
+ large === undefined ||
141
+ sweep === undefined ||
142
+ !end
143
+ ? undefined
144
+ : arcPoints(x, y, rx, ry, rotation, large, sweep, end[0], end[1]);
145
+ break;
146
+ }
147
+ default:
148
+ // A number with no command before it, or after a close: nothing more reads.
149
+ return points;
150
+ }
151
+ const last = drawn?.[drawn.length - 1];
152
+ if (!drawn || !last)
153
+ return points;
154
+ points.push(...drawn);
155
+ if (command === "M" || command === "m") {
156
+ startX = last[0];
157
+ startY = last[1];
158
+ // More pairs after a moveto are lines.
159
+ command = command === "M" ? "L" : "l";
160
+ }
161
+ [x, y] = last;
162
+ }
163
+ }
164
+ function corners(x0, y0, x1, y1) {
165
+ return [
166
+ [x0, y0],
167
+ [x1, y0],
168
+ [x0, y1],
169
+ [x1, y1],
170
+ ];
171
+ }
172
+ function shapePoints(element) {
173
+ const attrs = element.attrs;
174
+ const n = (name) => Number(attrs[name] ?? 0) || 0;
175
+ switch (element.tag) {
176
+ case "circle":
177
+ return corners(n("cx") - n("r"), n("cy") - n("r"), n("cx") + n("r"), n("cy") + n("r"));
178
+ case "ellipse":
179
+ return corners(n("cx") - n("rx"), n("cy") - n("ry"), n("cx") + n("rx"), n("cy") + n("ry"));
180
+ case "rect":
181
+ return corners(n("x"), n("y"), n("x") + n("width"), n("y") + n("height"));
182
+ case "line":
183
+ return [
184
+ [n("x1"), n("y1")],
185
+ [n("x2"), n("y2")],
186
+ ];
187
+ case "polyline":
188
+ case "polygon": {
189
+ const values = (attrs.points ?? "")
190
+ .trim()
191
+ .split(/[\s,]+/)
192
+ .map(Number);
193
+ const points = [];
194
+ for (let k = 0; k + 1 < values.length; k += 2)
195
+ points.push([values[k] ?? 0, values[k + 1] ?? 0]);
196
+ return points;
197
+ }
198
+ case "path":
199
+ return pathPoints(attrs.d ?? "");
200
+ case "g":
201
+ return [];
202
+ }
203
+ }
204
+ /** Every drawn shape of a mark, bottom first, with its paints and its box in the mark's units. */
205
+ function markLeaves(mark) {
206
+ const leaves = [];
207
+ const walk = (element, fill, stroke, matrix) => {
208
+ const nextFill = element.attrs.fill ?? fill;
209
+ const nextStroke = element.attrs.stroke ?? stroke;
210
+ const nextMatrix = withTransform(matrix, element.attrs.transform);
211
+ for (const child of element.children)
212
+ walk(child, nextFill, nextStroke, nextMatrix);
213
+ if (element.tag === "g")
214
+ return;
215
+ const points = shapePoints(element).map(([x, y]) => apply(nextMatrix, x, y));
216
+ if (!points.length)
217
+ return;
218
+ const xs = points.map(([x]) => x);
219
+ const ys = points.map(([, y]) => y);
220
+ leaves.push({
221
+ element,
222
+ fill: nextFill,
223
+ stroke: nextStroke,
224
+ box: {
225
+ x0: Math.min(...xs),
226
+ y0: Math.min(...ys),
227
+ x1: Math.max(...xs),
228
+ y1: Math.max(...ys),
229
+ },
230
+ });
231
+ };
232
+ // An unset fill on the root paints in currentColor (markBody writes it so).
233
+ walk(mark.root, "currentColor", "none", IDENTITY);
234
+ return leaves;
235
+ }
236
+ function inside(inner, outer, slack) {
237
+ return (inner.x0 >= outer.x0 - slack &&
238
+ inner.y0 >= outer.y0 - slack &&
239
+ inner.x1 <= outer.x1 + slack &&
240
+ inner.y1 <= outer.y1 + slack);
241
+ }
242
+ /** Whether the paint reads as the paper: white, or close to it. */
243
+ function isPaper(paint) {
244
+ return paint.startsWith("#") && contrast(paint, "#ffffff") < PAPER_CONTRAST;
245
+ }
246
+ /**
247
+ * Which paints become holes in a one-color version. Each part is read against what it sits on
248
+ * (the topmost filled part before it whose box holds it): in another color it takes the other
249
+ * state, so a glyph on a tile is cut out of it and a dot on that glyph prints again; in the same
250
+ * color it takes the same state. A part on the ground prints, unless it is white or close to it,
251
+ * which reads as the paper. A drawing that is all white (made for dark grounds) keeps every
252
+ * shape, since there is nothing to cut it from.
253
+ */
254
+ export function oneColorParts(mark) {
255
+ const slack = Math.max(mark.viewBox.w, mark.viewBox.h) * SLACK;
256
+ const parts = [];
257
+ for (const leaf of markLeaves(mark)) {
258
+ let below;
259
+ for (let k = parts.length - 1; k >= 0 && !below; k--) {
260
+ const part = parts[k];
261
+ if (part && part.leaf.fill !== "none" && inside(leaf.box, part.leaf.box, slack))
262
+ below = part;
263
+ }
264
+ const isHole = (paint) => paint !== "none" &&
265
+ (below
266
+ ? paint === below.leaf.fill
267
+ ? below.fillHole
268
+ : !below.fillHole
269
+ : isPaper(paint));
270
+ parts.push({ leaf, fillHole: isHole(leaf.fill), strokeHole: isHole(leaf.stroke) });
271
+ }
272
+ const keepsInk = parts.some((part) => (part.leaf.fill !== "none" && !part.fillHole) ||
273
+ (part.leaf.stroke !== "none" && !part.strokeHole));
274
+ return keepsInk
275
+ ? parts
276
+ : parts.map((part) => ({ ...part, fillHole: false, strokeHole: false }));
277
+ }
278
+ /**
279
+ * The mark as a mask for its one-color version: white where the one color prints, black where a
280
+ * part is cut out. Undefined when nothing is cut out, so the mark can simply be recolored.
281
+ */
282
+ export function knockoutMask(mark) {
283
+ const parts = oneColorParts(mark);
284
+ if (!parts.some((part) => part.fillHole || part.strokeHole))
285
+ return undefined;
286
+ const byElement = new Map(parts.map((part) => [part.leaf.element, part]));
287
+ const paint = (value, hole) => value === "none" ? "none" : hole ? "#000000" : "#ffffff";
288
+ const copy = (element) => {
289
+ const { fill: _fill, stroke: _stroke, ...attrs } = element.attrs;
290
+ const part = byElement.get(element);
291
+ return {
292
+ ...element,
293
+ attrs: part
294
+ ? {
295
+ ...attrs,
296
+ fill: paint(part.leaf.fill, part.fillHole),
297
+ stroke: paint(part.leaf.stroke, part.strokeHole),
298
+ }
299
+ : attrs,
300
+ children: element.children.map(copy),
301
+ };
302
+ };
303
+ return { ...mark, root: copy(mark.root), colors: ["#000000", "#ffffff"] };
304
+ }
@@ -25,6 +25,13 @@ export type ParsedMark = {
25
25
  /** What was left out, in plain words. Empty for a clean mark. */
26
26
  unsupported: string[];
27
27
  };
28
+ /** An affine transform as SVG's matrix(a b c d e f). */
29
+ export type Matrix = readonly [number, number, number, number, number, number];
30
+ export declare const IDENTITY: Matrix;
31
+ /** `matrix` followed by a transform attribute parseMark kept (so it is well formed). */
32
+ export declare function withTransform(matrix: Matrix, transform: string | undefined): Matrix;
33
+ /** A point through the matrix. */
34
+ export declare function apply([a, b, c, d, e, f]: Matrix, x: number, y: number): [number, number];
28
35
  /** Read a mark master into the kit's subset. Throws only when there is no usable <svg> root. */
29
36
  export declare function parseMark(svg: string): ParsedMark;
30
37
  export type MarkPaint = {
package/dist/marks/svg.js CHANGED
@@ -79,6 +79,56 @@ const NAMED = {
79
79
  };
80
80
  const NUMBER = /^-?(\d+\.?\d*|\.\d+)(e-?\d+)?$/i;
81
81
  const TRANSFORM = /^(\s*(matrix|translate|scale|rotate|skewX|skewY)\s*\([-\d.,\se]*\)\s*)+$/i;
82
+ export const IDENTITY = [1, 0, 0, 1, 0, 0];
83
+ function multiply(m, n) {
84
+ return [
85
+ m[0] * n[0] + m[2] * n[1],
86
+ m[1] * n[0] + m[3] * n[1],
87
+ m[0] * n[2] + m[2] * n[3],
88
+ m[1] * n[2] + m[3] * n[3],
89
+ m[0] * n[4] + m[2] * n[5] + m[4],
90
+ m[1] * n[4] + m[3] * n[5] + m[5],
91
+ ];
92
+ }
93
+ /** `matrix` followed by a transform attribute parseMark kept (so it is well formed). */
94
+ export function withTransform(matrix, transform) {
95
+ let out = matrix;
96
+ for (const [, name = "", args = ""] of (transform ?? "").matchAll(/(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)/gi)) {
97
+ const n = args
98
+ .trim()
99
+ .split(/[\s,]+/)
100
+ .filter(Boolean)
101
+ .map(Number);
102
+ const radians = ((n[0] ?? 0) * Math.PI) / 180;
103
+ const cos = Math.cos(radians);
104
+ const sin = Math.sin(radians);
105
+ const [cx = 0, cy = 0] = n.slice(1);
106
+ const kind = name.toLowerCase();
107
+ const step = kind === "matrix"
108
+ ? [n[0] ?? 1, n[1] ?? 0, n[2] ?? 0, n[3] ?? 1, n[4] ?? 0, n[5] ?? 0]
109
+ : kind === "translate"
110
+ ? [1, 0, 0, 1, n[0] ?? 0, n[1] ?? 0]
111
+ : kind === "scale"
112
+ ? [n[0] ?? 1, 0, 0, n[1] ?? n[0] ?? 1, 0, 0]
113
+ : kind === "rotate"
114
+ ? [cos, sin, -sin, cos, cx - cos * cx + sin * cy, cy - sin * cx - cos * cy]
115
+ : kind === "skewx"
116
+ ? [1, 0, Math.tan(radians), 1, 0, 0]
117
+ : [1, Math.tan(radians), 0, 1, 0, 0];
118
+ out = multiply(out, step);
119
+ }
120
+ return out;
121
+ }
122
+ /** A point through the matrix. */
123
+ export function apply([a, b, c, d, e, f], x, y) {
124
+ return [a * x + c * y + e, b * x + d * y + f];
125
+ }
126
+ /** How much the matrix scales a line at its thinnest: its smaller singular value. */
127
+ function thinnestScale([a, b, c, d]) {
128
+ const sum = a * a + b * b + c * c + d * d;
129
+ const det = a * d - b * c;
130
+ return Math.sqrt(Math.max(0, (sum - Math.sqrt(Math.max(0, sum * sum - 4 * det * det))) / 2));
131
+ }
82
132
  /** A paint value as the kit stores it: lowercase hex, "none" or "currentColor". */
83
133
  function normalizePaint(value) {
84
134
  const trimmed = value.trim().toLowerCase();
@@ -266,19 +316,21 @@ export function parseMark(svg) {
266
316
  }
267
317
  if (!root || !viewBox)
268
318
  throw new TypeError("The mark has no <svg> root with a viewBox or a size.");
269
- // Stroke widths, inherited down the tree: the legibility check needs the thinnest one.
270
- const walk = (element, stroke, width) => {
319
+ // Stroke widths, inherited down the tree, as drawn: a 2-unit line inside scale(2) is 4 units
320
+ // wide in the viewBox. The legibility check needs the thinnest one.
321
+ const walk = (element, stroke, width, matrix) => {
271
322
  const nextStroke = element.attrs.stroke ?? stroke;
272
323
  const nextWidth = element.attrs["stroke-width"]
273
324
  ? Number(element.attrs["stroke-width"])
274
325
  : width;
326
+ const nextMatrix = withTransform(matrix, element.attrs.transform);
275
327
  if (element.tag !== "g" && nextStroke && nextStroke !== "none") {
276
- thinnestStroke = Math.min(thinnestStroke, nextWidth);
328
+ thinnestStroke = Math.min(thinnestStroke, nextWidth * thinnestScale(nextMatrix));
277
329
  }
278
330
  for (const child of element.children)
279
- walk(child, nextStroke, nextWidth);
331
+ walk(child, nextStroke, nextWidth, nextMatrix);
280
332
  };
281
- walk(root, undefined, 1);
333
+ walk(root, undefined, 1, IDENTITY);
282
334
  return {
283
335
  viewBox,
284
336
  root,
@@ -4,6 +4,16 @@ import type { LoadedFont, TextEngine } from "../text/engine.ts";
4
4
  /** The versions an owner may bring as their own drawing (brand.mark.lockups). */
5
5
  declare const LOCKUPS: readonly ["horizontal", "stacked", "wordmark"];
6
6
  type Lockup = (typeof LOCKUPS)[number];
7
+ /** The thinnest line of a mark in a 16 px favicon, in pixels: the favicon draws the mark inside
8
+ * the tile's padding, of at most 12%. */
9
+ export declare function linePxAt16(mark: ParsedMark, padding: number): number;
10
+ /**
11
+ * The drawing for 16 to 32 px (favicons, small.svg, small avatars): the owner's own small version
12
+ * when there is one. A curated icon (`source.kind: "icon"`) is drawn with heavier lines, so its
13
+ * thinnest line holds a whole pixel at 16 px, where its 2-unit lines fade, up to twice its weight.
14
+ * Any other mark is drawn as it is, and check() asks for a simpler small version when it needs one.
15
+ */
16
+ export declare function smallMark(brand: Brand, mark?: ParsedMark): ParsedMark;
7
17
  /**
8
18
  * Everything the brand contributes to a drawing, prepared once per brand: the parsed marks, the
9
19
  * fonts for both roles and the outlined wordmark. Lockups are composed from these, in natural
@@ -43,7 +53,9 @@ export type LogoFragment = {
43
53
  };
44
54
  /**
45
55
  * One logo version. `ground` is what it sits on (it picks the dark mark and the wordmark color
46
- * for "color"); ink and white are one-color versions for stamps, embroidery and photos.
56
+ * for "color"); ink and white are one-color versions for stamps, embroidery and photos. `id`
57
+ * names the masks a one-color version cuts with: pass the page's own when several drawings share
58
+ * one document. Without it, the fragment numbers its own.
47
59
  */
48
- export declare function logoFragment(art: BrandArt, brand: Brand, variant: LogoVariant, tone: LogoTone, ground: string): LogoFragment;
60
+ export declare function logoFragment(art: BrandArt, brand: Brand, variant: LogoVariant, tone: LogoTone, ground: string, id?: () => string): LogoFragment;
49
61
  export {};
@@ -1,11 +1,53 @@
1
1
  import { contrast } from "../color/wcag.js";
2
2
  import { isLight } from "../color/contrast.js";
3
+ import { knockoutMask } from "../marks/one-color.js";
3
4
  import { markBody, parseMark } from "../marks/svg.js";
4
5
  import { pathBounds } from "../marks/draw.js";
5
6
  import { nearestWeight } from "../fonts/pick.js";
6
7
  import { fmt } from "../text/engine.js";
7
8
  /** The versions an owner may bring as their own drawing (brand.mark.lockups). */
8
9
  const LOCKUPS = ["horizontal", "stacked", "wordmark"];
10
+ /** The heaviest a curated icon's lines get in the small version: past twice, details clog. */
11
+ const MAX_SMALL_WEIGHT = 2;
12
+ /** The thinnest line of a mark in a 16 px favicon, in pixels: the favicon draws the mark inside
13
+ * the tile's padding, of at most 12%. */
14
+ export function linePxAt16(mark, padding) {
15
+ const drawn = 16 * (1 - Math.min(padding, 0.12));
16
+ return (mark.thinnestStroke * drawn) / Math.max(mark.viewBox.w, mark.viewBox.h);
17
+ }
18
+ /**
19
+ * The drawing for 16 to 32 px (favicons, small.svg, small avatars): the owner's own small version
20
+ * when there is one. A curated icon (`source.kind: "icon"`) is drawn with heavier lines, so its
21
+ * thinnest line holds a whole pixel at 16 px, where its 2-unit lines fade, up to twice its weight.
22
+ * Any other mark is drawn as it is, and check() asks for a simpler small version when it needs one.
23
+ */
24
+ export function smallMark(brand, mark = parseMark(brand.mark.svg)) {
25
+ if (brand.mark.small)
26
+ return parseMark(brand.mark.small);
27
+ if (brand.mark.source?.kind !== "icon")
28
+ return mark;
29
+ const px = linePxAt16(mark, brand.mark.tile.padding);
30
+ if (!Number.isFinite(px) || px <= 0 || px >= 1)
31
+ return mark;
32
+ const weight = Math.min(MAX_SMALL_WEIGHT, Math.ceil(100 / px) / 100);
33
+ const heavier = (element, isRoot) => {
34
+ const width = element.attrs["stroke-width"];
35
+ // An unset width is 1, inherited from the root.
36
+ const scaled = width === undefined ? (isRoot ? weight : undefined) : Number(width) * weight;
37
+ return {
38
+ ...element,
39
+ attrs: scaled === undefined
40
+ ? element.attrs
41
+ : { ...element.attrs, "stroke-width": fmt(scaled) },
42
+ children: element.children.map((child) => heavier(child, false)),
43
+ };
44
+ };
45
+ return {
46
+ ...mark,
47
+ root: heavier(mark.root, true),
48
+ thinnestStroke: mark.thinnestStroke * weight,
49
+ };
50
+ }
9
51
  const cache = new WeakMap();
10
52
  /** The font file a role draws with: the brand's family at the nearest weight it has. */
11
53
  export function fontFor(brand, role, weight) {
@@ -38,7 +80,7 @@ export function brandArt(brand, engine) {
38
80
  }
39
81
  return {
40
82
  mark,
41
- small: brand.mark.small ? parseMark(brand.mark.small) : mark,
83
+ small: smallMark(brand, mark),
42
84
  dark: brand.mark.dark ? parseMark(brand.mark.dark) : undefined,
43
85
  lockups,
44
86
  display,
@@ -81,15 +123,30 @@ function lockupColorsOn(brand, ground) {
81
123
  const light = brand.colors.dark.ink;
82
124
  return (hex) => (contrast(hex, ground) < 3 ? light : hex);
83
125
  }
84
- function placeMark(mark, color, current, height, x, y) {
126
+ function placeMark(mark, body, height, x, y) {
85
127
  const { viewBox } = mark;
86
128
  const scale = height / viewBox.h;
87
- const body = markBody(mark, { color, current });
88
129
  return {
89
130
  body: `<g transform="translate(${fmt(x - viewBox.x * scale)} ${fmt(y - viewBox.y * scale)}) scale(${fmt(scale)})">${body}</g>`,
90
131
  w: viewBox.w * scale,
91
132
  };
92
133
  }
134
+ /**
135
+ * A drawing in one color. What has to stay visible inside it (a glyph on its tile) is cut out
136
+ * with a mask instead of painted, so the version works on any ground. A drawing with nothing to
137
+ * cut out is simply recolored.
138
+ */
139
+ function oneColorBody(mark, one, id) {
140
+ const mask = knockoutMask(mark);
141
+ if (!mask)
142
+ return markBody(mark, { color: () => one, current: one });
143
+ const { x, y, w, h } = mark.viewBox;
144
+ // Strokes and a viewBox that hugs the drawing can reach past the box.
145
+ const pad = Math.max(w, h) / 4;
146
+ const area = `x="${fmt(x - pad)}" y="${fmt(y - pad)}" width="${fmt(w + 2 * pad)}" height="${fmt(h + 2 * pad)}"`;
147
+ const maskId = id();
148
+ return `<mask id="${maskId}" maskUnits="userSpaceOnUse" ${area}>${markBody(mask)}</mask><rect ${area} fill="${one}" mask="url(#${maskId})"/>`;
149
+ }
93
150
  function placeWordmark(art, fill, height, x, y) {
94
151
  const { box, d } = art.wordmark;
95
152
  const scale = box.h > 0 ? height / box.h : 1;
@@ -100,25 +157,31 @@ function placeWordmark(art, fill, height, x, y) {
100
157
  }
101
158
  /**
102
159
  * One logo version. `ground` is what it sits on (it picks the dark mark and the wordmark color
103
- * for "color"); ink and white are one-color versions for stamps, embroidery and photos.
160
+ * for "color"); ink and white are one-color versions for stamps, embroidery and photos. `id`
161
+ * names the masks a one-color version cuts with: pass the page's own when several drawings share
162
+ * one document. Without it, the fragment numbers its own.
104
163
  */
105
- export function logoFragment(art, brand, variant, tone, ground) {
164
+ export function logoFragment(art, brand, variant, tone, ground, id) {
106
165
  const palette = isLight(ground) ? brand.colors.light : brand.colors.dark;
107
166
  const one = tone === "ink" ? brand.colors.light.ink : tone === "white" ? "#ffffff" : undefined;
108
167
  const wordFill = one ?? palette.ink;
168
+ let next = 0;
169
+ const nextId = id ?? (() => `ideare-cut-${next++}`);
109
170
  const mark = (which, height, x, y) => {
110
171
  if (one) {
111
172
  const base = which === "small" ? art.small : art.mark;
112
- return placeMark(base, () => one, one, height, x, y);
173
+ return placeMark(base, oneColorBody(base, one, nextId), height, x, y);
113
174
  }
114
175
  const chosen = markOn(art, brand, ground, which);
115
- return placeMark(chosen.mark, chosen.color, palette.ink, height, x, y);
176
+ const body = markBody(chosen.mark, { color: chosen.color, current: palette.ink });
177
+ return placeMark(chosen.mark, body, height, x, y);
116
178
  };
117
179
  const own = variant === "mark" || variant === "small" ? undefined : art.lockups[variant];
118
180
  if (own) {
119
- const placed = one
120
- ? placeMark(own, () => one, one, 100, 0, 0)
121
- : placeMark(own, lockupColorsOn(brand, ground), palette.ink, 100, 0, 0);
181
+ const body = one
182
+ ? oneColorBody(own, one, nextId)
183
+ : markBody(own, { color: lockupColorsOn(brand, ground), current: palette.ink });
184
+ const placed = placeMark(own, body, 100, 0, 0);
122
185
  return { body: placed.body, w: placed.w, h: 100 };
123
186
  }
124
187
  switch (variant) {
@@ -16,7 +16,7 @@ import type { AssetRef, Frame, Scene } from "./schema.ts";
16
16
  * one transform instead of every glyph path.
17
17
  */
18
18
  /** Bump when the output for the same scene and brand changes, so revisions change too. */
19
- export declare const RENDERER_VERSION = "2";
19
+ export declare const RENDERER_VERSION = "3";
20
20
  /** Filter region pixels allowed per page: about two large shadows on a share card. */
21
21
  export declare const FILTER_BUDGET_PX = 500000;
22
22
  export type AssetResolver = (asset: AssetRef, drawn: {
@@ -22,7 +22,7 @@ import { parseScene } from "./schema.js";
22
22
  * one transform instead of every glyph path.
23
23
  */
24
24
  /** Bump when the output for the same scene and brand changes, so revisions change too. */
25
- export const RENDERER_VERSION = "2";
25
+ export const RENDERER_VERSION = "3";
26
26
  /** Filter region pixels allowed per page: about two large shadows on a share card. */
27
27
  export const FILTER_BUDGET_PX = 500_000;
28
28
  const engines = new WeakMap();
@@ -373,7 +373,7 @@ export async function renderScene(input, brand, ctx) {
373
373
  break;
374
374
  case "logo": {
375
375
  const tone = layer.tone === "auto" ? "color" : layer.tone;
376
- const fragment = logoFragment(art, brand, layer.variant, tone, ground ?? palette.ground);
376
+ const fragment = logoFragment(art, brand, layer.variant, tone, ground ?? palette.ground, sink.id);
377
377
  const scale = Math.min(box.w / fragment.w, box.h / fragment.h);
378
378
  const spare = box.w - fragment.w * scale;
379
379
  const x = layer.align === "start" ? 0 : layer.align === "end" ? spare : spare / 2;
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** The kit's version, kept equal to package.json's (src/brand/schema.test.ts checks it). */
2
- export declare const VERSION = "0.1.0-next.0";
2
+ export declare const VERSION = "0.1.0-next.1";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** The kit's version, kept equal to package.json's (src/brand/schema.test.ts checks it). */
2
- export const VERSION = "0.1.0-next.0";
2
+ export const VERSION = "0.1.0-next.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ideare/kit",
3
- "version": "0.1.0-next.0",
3
+ "version": "0.1.0-next.1",
4
4
  "description": "The brand kit engine behind ideare: brand.json, file generation, checks and the scene renderer.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://ideare.app",