@ideare/kit 0.0.0-stage → 0.1.0-next.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/LICENSE +21 -0
- package/README.md +148 -2
- package/THIRD_PARTY.md +52 -0
- package/dist/brand/schema.d.ts +265 -0
- package/dist/brand/schema.js +271 -0
- package/dist/check/copy.d.ts +27 -0
- package/dist/check/copy.js +139 -0
- package/dist/check/index.d.ts +54 -0
- package/dist/check/index.js +492 -0
- package/dist/color/contrast.d.ts +41 -0
- package/dist/color/contrast.js +84 -0
- package/dist/color/palette.d.ts +27 -0
- package/dist/color/palette.js +114 -0
- package/dist/color/wcag.d.ts +22 -0
- package/dist/color/wcag.js +50 -0
- package/dist/draft/draft.d.ts +61 -0
- package/dist/draft/draft.js +383 -0
- package/dist/editor/history.d.ts +78 -0
- package/dist/editor/history.js +221 -0
- package/dist/editor/operations.d.ts +112 -0
- package/dist/editor/operations.js +621 -0
- package/dist/files/docs.d.ts +49 -0
- package/dist/files/docs.js +392 -0
- package/dist/files/generate.d.ts +47 -0
- package/dist/files/generate.js +254 -0
- package/dist/files/png.d.ts +40 -0
- package/dist/files/png.js +168 -0
- package/dist/files/raster.d.ts +14 -0
- package/dist/files/raster.js +0 -0
- package/dist/files/templates.d.ts +162 -0
- package/dist/files/templates.js +390 -0
- package/dist/files/tokens.d.ts +22 -0
- package/dist/files/tokens.js +204 -0
- package/dist/fonts/catalog.d.ts +3 -0
- package/dist/fonts/catalog.js +345 -0
- package/dist/fonts/ofl.d.ts +5 -0
- package/dist/fonts/ofl.js +5 -0
- package/dist/fonts/pick.d.ts +33 -0
- package/dist/fonts/pick.js +73 -0
- package/dist/imports/folder.d.ts +80 -0
- package/dist/imports/folder.js +124 -0
- package/dist/imports/unzip.d.ts +32 -0
- package/dist/imports/unzip.js +128 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.js +34 -0
- package/dist/marks/draw.d.ts +48 -0
- package/dist/marks/draw.js +149 -0
- package/dist/marks/icons.data.d.ts +5 -0
- package/dist/marks/icons.data.js +600 -0
- package/dist/marks/svg.d.ts +46 -0
- package/dist/marks/svg.js +341 -0
- package/dist/node/index.d.ts +13 -0
- package/dist/node/index.js +41 -0
- package/dist/posts/pix.d.ts +19 -0
- package/dist/posts/pix.js +49 -0
- package/dist/posts/templates.d.ts +45 -0
- package/dist/posts/templates.js +868 -0
- package/dist/resvg/index.d.ts +2 -0
- package/dist/resvg/index.js +22 -0
- package/dist/scene/brand-art.d.ts +49 -0
- package/dist/scene/brand-art.js +154 -0
- package/dist/scene/frame.d.ts +89 -0
- package/dist/scene/frame.js +213 -0
- package/dist/scene/grain.d.ts +7 -0
- package/dist/scene/grain.js +7 -0
- package/dist/scene/qr.d.ts +10 -0
- package/dist/scene/qr.js +359 -0
- package/dist/scene/render.d.ts +76 -0
- package/dist/scene/render.js +557 -0
- package/dist/scene/schema.d.ts +271 -0
- package/dist/scene/schema.js +257 -0
- package/dist/sign.d.ts +47 -0
- package/dist/sign.js +88 -0
- package/dist/text/engine.d.ts +128 -0
- package/dist/text/engine.js +245 -0
- package/dist/text/fonts.d.ts +56 -0
- package/dist/text/fonts.js +65 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/dist/wasm/index.d.ts +15 -0
- package/dist/wasm/index.js +46 -0
- package/package.json +78 -5
- package/schema/brand.v1.json +442 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { ensureContrast, fromOklch, onColor } from "./contrast.js";
|
|
2
|
+
/**
|
|
3
|
+
* Every pair of roles a brand promises to keep readable, with its WCAG 2 floor: 4.5 for text,
|
|
4
|
+
* 3 for lines, fills and focus (non-text). check() measures these on every brand, and
|
|
5
|
+
* generatePalette() is built to pass them.
|
|
6
|
+
*/
|
|
7
|
+
export const CONTRAST_PAIRS = [
|
|
8
|
+
{ fg: "ink", bg: "ground", min: 4.5, what: "text" },
|
|
9
|
+
{ fg: "ink", bg: "surface", min: 4.5, what: "text on cards" },
|
|
10
|
+
{ fg: "muted", bg: "ground", min: 4.5, what: "quiet text" },
|
|
11
|
+
{ fg: "muted", bg: "surface", min: 4.5, what: "quiet text on cards" },
|
|
12
|
+
{ fg: "line", bg: "ground", min: 3, what: "field borders" },
|
|
13
|
+
{ fg: "line", bg: "surface", min: 3, what: "field borders on cards" },
|
|
14
|
+
{ fg: "primary", bg: "ground", min: 3, what: "the brand color as a button" },
|
|
15
|
+
{ fg: "onPrimary", bg: "primary", min: 4.5, what: "text on the brand color" },
|
|
16
|
+
{ fg: "accent", bg: "ground", min: 3, what: "the accent as a fill" },
|
|
17
|
+
{ fg: "onAccent", bg: "accent", min: 4.5, what: "text on the accent" },
|
|
18
|
+
{ fg: "focus", bg: "ground", min: 3, what: "the focus ring" },
|
|
19
|
+
{ fg: "danger", bg: "ground", min: 4.5, what: "error text" },
|
|
20
|
+
{ fg: "success", bg: "ground", min: 4.5, what: "success text" },
|
|
21
|
+
{ fg: "warning", bg: "ground", min: 4.5, what: "warning text" },
|
|
22
|
+
];
|
|
23
|
+
export const MOODS = ["calm", "bold", "warm", "fresh", "classic", "playful"];
|
|
24
|
+
const MOOD = {
|
|
25
|
+
calm: { chroma: 0.09, accentShift: 150, accentChroma: 0.07, tint: 0.006 },
|
|
26
|
+
bold: { chroma: 0.2, accentShift: 180, accentChroma: 0.17, tint: 0.004 },
|
|
27
|
+
warm: { chroma: 0.14, accentShift: 40, accentChroma: 0.12, tint: 0.012 },
|
|
28
|
+
fresh: { chroma: 0.15, accentShift: 120, accentChroma: 0.13, tint: 0.008 },
|
|
29
|
+
classic: { chroma: 0.07, accentShift: 30, accentChroma: 0.05, tint: 0.004 },
|
|
30
|
+
playful: { chroma: 0.19, accentShift: 210, accentChroma: 0.16, tint: 0.01 },
|
|
31
|
+
};
|
|
32
|
+
const STATUS_HUE = { danger: 27, success: 150, warning: 70, focus: 255 };
|
|
33
|
+
/**
|
|
34
|
+
* A light and a dark role set from one hue and a mood. Lightness and chroma come from the mood;
|
|
35
|
+
* every role is then pushed (in OKLCH lightness only) until each of CONTRAST_PAIRS passes, so the
|
|
36
|
+
* result is readable by construction and keeps the hue the owner picked.
|
|
37
|
+
*/
|
|
38
|
+
export function generatePalette({ hue, mood }) {
|
|
39
|
+
const spec = MOOD[mood];
|
|
40
|
+
const h = ((hue % 360) + 360) % 360;
|
|
41
|
+
const light = buildTheme("light", h, spec);
|
|
42
|
+
const dark = buildTheme("dark", h, spec);
|
|
43
|
+
// The fleet's retune rule: the brand color is never the same in both themes.
|
|
44
|
+
if (dark.primary === light.primary)
|
|
45
|
+
dark.primary = fromOklch({ l: 0.8, c: spec.chroma, h });
|
|
46
|
+
return { light, dark };
|
|
47
|
+
}
|
|
48
|
+
function buildTheme(theme, h, spec) {
|
|
49
|
+
const isLight = theme === "light";
|
|
50
|
+
const ground = isLight
|
|
51
|
+
? fromOklch({ l: 0.993, c: spec.tint * 0.5, h })
|
|
52
|
+
: fromOklch({ l: 0.17, c: spec.tint * 1.5, h });
|
|
53
|
+
const surface = isLight
|
|
54
|
+
? fromOklch({ l: 0.965, c: spec.tint * 1.5, h })
|
|
55
|
+
: fromOklch({ l: 0.22, c: spec.tint * 2, h });
|
|
56
|
+
const grounds = [ground, surface];
|
|
57
|
+
const ink = readable(fromOklch({ l: isLight ? 0.22 : 0.95, c: 0.02, h }), grounds, 4.5);
|
|
58
|
+
const muted = readable(fromOklch({ l: isLight ? 0.48 : 0.74, c: 0.025, h }), grounds, 4.5);
|
|
59
|
+
const line = readable(fromOklch({ l: isLight ? 0.64 : 0.52, c: 0.02, h }), grounds, 3);
|
|
60
|
+
// Text on a fill: ink or white in light themes; the dark ground or the ink in dark ones.
|
|
61
|
+
const darkText = isLight ? ink : ground;
|
|
62
|
+
const lightText = isLight ? "#ffffff" : ink;
|
|
63
|
+
const primaryPair = fill(fromOklch({ l: isLight ? 0.52 : 0.74, c: spec.chroma, h }), ground, {
|
|
64
|
+
dark: darkText,
|
|
65
|
+
light: lightText,
|
|
66
|
+
prefer: isLight ? lightText : darkText,
|
|
67
|
+
});
|
|
68
|
+
const accentPair = fill(fromOklch({
|
|
69
|
+
l: isLight ? 0.58 : 0.76,
|
|
70
|
+
c: spec.accentChroma,
|
|
71
|
+
h: (h + spec.accentShift) % 360,
|
|
72
|
+
}), ground, { dark: darkText, light: lightText, prefer: isLight ? lightText : darkText });
|
|
73
|
+
const status = (hueOf, l, c, min) => readable(fromOklch({ l, c, h: hueOf }), grounds, min);
|
|
74
|
+
return {
|
|
75
|
+
ground,
|
|
76
|
+
surface,
|
|
77
|
+
ink,
|
|
78
|
+
muted,
|
|
79
|
+
line,
|
|
80
|
+
primary: primaryPair.fill,
|
|
81
|
+
onPrimary: primaryPair.on,
|
|
82
|
+
accent: accentPair.fill,
|
|
83
|
+
onAccent: accentPair.on,
|
|
84
|
+
focus: status(STATUS_HUE.focus, isLight ? 0.55 : 0.72, 0.17, 3),
|
|
85
|
+
danger: status(STATUS_HUE.danger, isLight ? 0.52 : 0.72, 0.19, 4.5),
|
|
86
|
+
success: status(STATUS_HUE.success, isLight ? 0.5 : 0.75, 0.13, 4.5),
|
|
87
|
+
warning: status(STATUS_HUE.warning, isLight ? 0.52 : 0.8, 0.12, 4.5),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** Push `color` until it reaches `min` against every background in `grounds`. */
|
|
91
|
+
function readable(color, grounds, min) {
|
|
92
|
+
let hex = color;
|
|
93
|
+
// Two passes: fixing it against one ground can, rarely, bring it back under on another.
|
|
94
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
95
|
+
for (const ground of grounds)
|
|
96
|
+
hex = ensureContrast(hex, ground, min).hex;
|
|
97
|
+
}
|
|
98
|
+
return hex;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* A fill that reads as a control on the ground (3:1) and carries 4.5:1 text. The text is the
|
|
102
|
+
* dark or the light candidate, whichever reads; when neither does, the fill moves away from the
|
|
103
|
+
* preferred one (darker under white in light themes, lighter over the dark ground in dark ones).
|
|
104
|
+
*/
|
|
105
|
+
function fill(color, ground, text) {
|
|
106
|
+
let hex = ensureContrast(color, ground, 3).hex;
|
|
107
|
+
let on = onColor(hex, text.dark, text.light);
|
|
108
|
+
if (on.ratio < 4.5) {
|
|
109
|
+
hex = ensureContrast(hex, text.prefer, 4.5).hex;
|
|
110
|
+
hex = ensureContrast(hex, ground, 3).hex;
|
|
111
|
+
on = onColor(hex, text.dark, text.light);
|
|
112
|
+
}
|
|
113
|
+
return { fill: hex, on: on.hex };
|
|
114
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WCAG 2 contrast on hex colors. The fleet's contrast gates measure the same way, so a brand
|
|
3
|
+
* that passes here passes there.
|
|
4
|
+
*/
|
|
5
|
+
export type Rgb = {
|
|
6
|
+
r: number;
|
|
7
|
+
g: number;
|
|
8
|
+
b: number;
|
|
9
|
+
};
|
|
10
|
+
/** `#rrggbb` (or `#rgb`) to 0..255 channels. Throws on anything else. */
|
|
11
|
+
export declare function parseHex(hex: string): Rgb;
|
|
12
|
+
export declare function toHex({ r, g, b }: Rgb): string;
|
|
13
|
+
/** Lowercase `#rrggbb`. */
|
|
14
|
+
export declare function normalizeHex(hex: string): string;
|
|
15
|
+
/** WCAG 2 relative luminance, 0 (black) to 1 (white). */
|
|
16
|
+
export declare function luminance(hex: string): number;
|
|
17
|
+
/** WCAG 2 contrast ratio, 1 to 21. Order does not matter. */
|
|
18
|
+
export declare function contrast(a: string, b: string): number;
|
|
19
|
+
/** The ratio rounded down to 2 decimals, the way people should see it: 4.499 is not 4.5. */
|
|
20
|
+
export declare function formatRatio(ratio: number): string;
|
|
21
|
+
/** Composite a color with alpha over an opaque ground, for contrast on translucent paint. */
|
|
22
|
+
export declare function over(hex: string, alpha: number, ground: string): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WCAG 2 contrast on hex colors. The fleet's contrast gates measure the same way, so a brand
|
|
3
|
+
* that passes here passes there.
|
|
4
|
+
*/
|
|
5
|
+
/** `#rrggbb` (or `#rgb`) to 0..255 channels. Throws on anything else. */
|
|
6
|
+
export function parseHex(hex) {
|
|
7
|
+
const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
|
|
8
|
+
if (!match?.[1])
|
|
9
|
+
throw new TypeError(`Not a hex color: ${hex}`);
|
|
10
|
+
const digits = match[1].length === 3 ? [...match[1]].map((digit) => digit + digit).join("") : match[1];
|
|
11
|
+
const value = Number.parseInt(digits, 16);
|
|
12
|
+
return { r: (value >> 16) & 255, g: (value >> 8) & 255, b: value & 255 };
|
|
13
|
+
}
|
|
14
|
+
export function toHex({ r, g, b }) {
|
|
15
|
+
const channel = (value) => Math.max(0, Math.min(255, Math.round(value)))
|
|
16
|
+
.toString(16)
|
|
17
|
+
.padStart(2, "0");
|
|
18
|
+
return `#${channel(r)}${channel(g)}${channel(b)}`;
|
|
19
|
+
}
|
|
20
|
+
/** Lowercase `#rrggbb`. */
|
|
21
|
+
export function normalizeHex(hex) {
|
|
22
|
+
return toHex(parseHex(hex));
|
|
23
|
+
}
|
|
24
|
+
function linear(channel) {
|
|
25
|
+
const value = channel / 255;
|
|
26
|
+
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
27
|
+
}
|
|
28
|
+
/** WCAG 2 relative luminance, 0 (black) to 1 (white). */
|
|
29
|
+
export function luminance(hex) {
|
|
30
|
+
const { r, g, b } = parseHex(hex);
|
|
31
|
+
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
|
|
32
|
+
}
|
|
33
|
+
/** WCAG 2 contrast ratio, 1 to 21. Order does not matter. */
|
|
34
|
+
export function contrast(a, b) {
|
|
35
|
+
const first = luminance(a);
|
|
36
|
+
const second = luminance(b);
|
|
37
|
+
const [light, dark] = first > second ? [first, second] : [second, first];
|
|
38
|
+
return (light + 0.05) / (dark + 0.05);
|
|
39
|
+
}
|
|
40
|
+
/** The ratio rounded down to 2 decimals, the way people should see it: 4.499 is not 4.5. */
|
|
41
|
+
export function formatRatio(ratio) {
|
|
42
|
+
return `${(Math.floor(ratio * 100) / 100).toFixed(2)}:1`;
|
|
43
|
+
}
|
|
44
|
+
/** Composite a color with alpha over an opaque ground, for contrast on translucent paint. */
|
|
45
|
+
export function over(hex, alpha, ground) {
|
|
46
|
+
const top = parseHex(hex);
|
|
47
|
+
const bottom = parseHex(ground);
|
|
48
|
+
const mix = (a, b) => a * alpha + b * (1 - alpha);
|
|
49
|
+
return toHex({ r: mix(top.r, bottom.r), g: mix(top.g, bottom.g), b: mix(top.b, bottom.b) });
|
|
50
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Brand, Locale } from "../brand/schema.ts";
|
|
2
|
+
import { type Mood } from "../color/palette.ts";
|
|
3
|
+
import type { FontResolver } from "../text/fonts.ts";
|
|
4
|
+
/**
|
|
5
|
+
* A draft brand from a business's name and one line about it, drawn by code alone. The same
|
|
6
|
+
* input always draws the same brand, so a link that carries the input (`draftParams`) redraws it
|
|
7
|
+
* anywhere: the site's try-it makes one in the browser and the studio picks it up from the link.
|
|
8
|
+
*
|
|
9
|
+
* `seed` keeps the look steady while the name is typed: colors, fonts and the kind of logo come
|
|
10
|
+
* from the seed, and change only when a word says something about the business ("padaria",
|
|
11
|
+
* "salon"), so mostly the letters change as you type.
|
|
12
|
+
* `picks` count each "Try another" press, per part.
|
|
13
|
+
*/
|
|
14
|
+
export type DraftPicks = {
|
|
15
|
+
colors: number;
|
|
16
|
+
fonts: number;
|
|
17
|
+
logo: number;
|
|
18
|
+
};
|
|
19
|
+
export type DraftInput = {
|
|
20
|
+
name: string;
|
|
21
|
+
/** What the business does, in the owner's words. It becomes the tagline. */
|
|
22
|
+
idea: string;
|
|
23
|
+
locale: Locale;
|
|
24
|
+
seed: number;
|
|
25
|
+
picks: DraftPicks;
|
|
26
|
+
};
|
|
27
|
+
/** The longest name (the wordmark's limit) and line (the copy check's tagline limit) a draft
|
|
28
|
+
* takes: every file the kit makes has room for them. */
|
|
29
|
+
export declare const DRAFT_LIMITS: {
|
|
30
|
+
readonly name: 40;
|
|
31
|
+
readonly idea: 60;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* The font pairs a draft picks from. Few on purpose: a browser carries every file
|
|
35
|
+
* (`draftFontFiles`), so the try-it draws with no font server. Each display face has one weight,
|
|
36
|
+
* and the text faces are sans or serif opposites of their display.
|
|
37
|
+
*/
|
|
38
|
+
export declare const DRAFT_FONT_PAIRS: readonly {
|
|
39
|
+
display: string;
|
|
40
|
+
weight: number;
|
|
41
|
+
text: string;
|
|
42
|
+
moods: readonly Mood[];
|
|
43
|
+
}[];
|
|
44
|
+
/** Every font file a draft can ask for, so a page can serve them from its own origin. */
|
|
45
|
+
export declare function draftFontFiles(): {
|
|
46
|
+
id: string;
|
|
47
|
+
weight: number;
|
|
48
|
+
}[];
|
|
49
|
+
/** A fresh seed for a new draft. */
|
|
50
|
+
export declare function newDraftSeed(): number;
|
|
51
|
+
/**
|
|
52
|
+
* Draw the draft. The name and the line are trimmed and cut to DRAFT_LIMITS; an empty line makes
|
|
53
|
+
* the name the tagline. Loads the display font (for a monogram) through `fonts`.
|
|
54
|
+
*/
|
|
55
|
+
export declare function draftBrand(input: DraftInput, ctx: {
|
|
56
|
+
fonts: FontResolver;
|
|
57
|
+
}): Promise<Brand>;
|
|
58
|
+
/** The draft as link parameters: name, idea, locale, seed, colors, fonts and logo. */
|
|
59
|
+
export declare function draftParams(input: DraftInput): URLSearchParams;
|
|
60
|
+
/** A draft read back from link parameters, or null when any part is missing or out of range. */
|
|
61
|
+
export declare function readDraftParams(params: URLSearchParams): DraftInput | null;
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { BRAND_SCHEMA_URL, LOCALES, parseBrand } from "../brand/schema.js";
|
|
2
|
+
import { MOODS, generatePalette } from "../color/palette.js";
|
|
3
|
+
import { catalogFont } from "../fonts/pick.js";
|
|
4
|
+
import { iconMark, initials, matchIcons, monogramMark } from "../marks/draw.js";
|
|
5
|
+
import { engineFor } from "../scene/render.js";
|
|
6
|
+
/** The longest name (the wordmark's limit) and line (the copy check's tagline limit) a draft
|
|
7
|
+
* takes: every file the kit makes has room for them. */
|
|
8
|
+
export const DRAFT_LIMITS = { name: 40, idea: 60 };
|
|
9
|
+
/**
|
|
10
|
+
* The font pairs a draft picks from. Few on purpose: a browser carries every file
|
|
11
|
+
* (`draftFontFiles`), so the try-it draws with no font server. Each display face has one weight,
|
|
12
|
+
* and the text faces are sans or serif opposites of their display.
|
|
13
|
+
*/
|
|
14
|
+
export const DRAFT_FONT_PAIRS = [
|
|
15
|
+
{ display: "calistoga", weight: 400, text: "instrument-sans", moods: ["warm", "playful"] },
|
|
16
|
+
{ display: "fraunces", weight: 600, text: "instrument-sans", moods: ["warm", "classic"] },
|
|
17
|
+
{ display: "bodoni-moda", weight: 600, text: "karla", moods: ["classic", "calm"] },
|
|
18
|
+
{ display: "pacifico", weight: 400, text: "karla", moods: ["playful", "warm"] },
|
|
19
|
+
{ display: "syne", weight: 700, text: "instrument-sans", moods: ["bold", "fresh"] },
|
|
20
|
+
{ display: "bebas-neue", weight: 400, text: "karla", moods: ["bold"] },
|
|
21
|
+
{ display: "fredoka", weight: 600, text: "karla", moods: ["playful", "fresh"] },
|
|
22
|
+
{ display: "eb-garamond", weight: 500, text: "instrument-sans", moods: ["classic", "calm"] },
|
|
23
|
+
{ display: "outfit", weight: 700, text: "literata", moods: ["fresh", "calm"] },
|
|
24
|
+
{ display: "caprasimo", weight: 400, text: "instrument-sans", moods: ["warm", "bold"] },
|
|
25
|
+
];
|
|
26
|
+
const TEXT_WEIGHT = 400;
|
|
27
|
+
/** Every font file a draft can ask for, so a page can serve them from its own origin. */
|
|
28
|
+
export function draftFontFiles() {
|
|
29
|
+
const files = new Map();
|
|
30
|
+
for (const pair of DRAFT_FONT_PAIRS) {
|
|
31
|
+
files.set(`${pair.display}-${pair.weight}`, { id: pair.display, weight: pair.weight });
|
|
32
|
+
files.set(`${pair.text}-${TEXT_WEIGHT}`, { id: pair.text, weight: TEXT_WEIGHT });
|
|
33
|
+
}
|
|
34
|
+
return [...files.values()];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* What a line about the business says about its look: a mood and the hues that suit it. Words are
|
|
38
|
+
* matched without accents at the start of a word, in pt-BR, en and es. The first row that matches
|
|
39
|
+
* wins; a line with none of them leaves the mood to the seed.
|
|
40
|
+
*/
|
|
41
|
+
const MOOD_WORDS = [
|
|
42
|
+
{
|
|
43
|
+
mood: "warm",
|
|
44
|
+
hues: [15, 60],
|
|
45
|
+
words: [
|
|
46
|
+
"pao",
|
|
47
|
+
"paes",
|
|
48
|
+
"padar",
|
|
49
|
+
"bolo",
|
|
50
|
+
"doce",
|
|
51
|
+
"confeit",
|
|
52
|
+
"cafe",
|
|
53
|
+
"bread",
|
|
54
|
+
"bak",
|
|
55
|
+
"cake",
|
|
56
|
+
"pastr",
|
|
57
|
+
"coffee",
|
|
58
|
+
"sweet",
|
|
59
|
+
"pan ",
|
|
60
|
+
"panad",
|
|
61
|
+
"pastel",
|
|
62
|
+
"dulce",
|
|
63
|
+
"comida",
|
|
64
|
+
"food",
|
|
65
|
+
"restaur",
|
|
66
|
+
"pizza",
|
|
67
|
+
"lanche",
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
mood: "fresh",
|
|
72
|
+
hues: [95, 175],
|
|
73
|
+
words: [
|
|
74
|
+
"planta",
|
|
75
|
+
"flor",
|
|
76
|
+
"jardi",
|
|
77
|
+
"horta",
|
|
78
|
+
"verde",
|
|
79
|
+
"organic",
|
|
80
|
+
"plant",
|
|
81
|
+
"flower",
|
|
82
|
+
"garden",
|
|
83
|
+
"green",
|
|
84
|
+
"vegan",
|
|
85
|
+
"natural",
|
|
86
|
+
"suco",
|
|
87
|
+
"juice",
|
|
88
|
+
"salad",
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
mood: "calm",
|
|
93
|
+
hues: [185, 330],
|
|
94
|
+
words: [
|
|
95
|
+
"salao",
|
|
96
|
+
"salon",
|
|
97
|
+
"beleza",
|
|
98
|
+
"beauty",
|
|
99
|
+
"belleza",
|
|
100
|
+
"estetic",
|
|
101
|
+
"spa",
|
|
102
|
+
"massag",
|
|
103
|
+
"yoga",
|
|
104
|
+
"unha",
|
|
105
|
+
"nail",
|
|
106
|
+
"cabel",
|
|
107
|
+
"hair",
|
|
108
|
+
"pelo",
|
|
109
|
+
"cor ",
|
|
110
|
+
"escova",
|
|
111
|
+
"terapia",
|
|
112
|
+
"therap",
|
|
113
|
+
"saude",
|
|
114
|
+
"health",
|
|
115
|
+
"salud",
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
mood: "bold",
|
|
120
|
+
hues: [0, 360],
|
|
121
|
+
words: [
|
|
122
|
+
"barb",
|
|
123
|
+
"academia",
|
|
124
|
+
"gym",
|
|
125
|
+
"gimnas",
|
|
126
|
+
"treino",
|
|
127
|
+
"fitness",
|
|
128
|
+
"oficina",
|
|
129
|
+
"reform",
|
|
130
|
+
"repair",
|
|
131
|
+
"repar",
|
|
132
|
+
"arreglo",
|
|
133
|
+
"conserto",
|
|
134
|
+
"pintura",
|
|
135
|
+
"paint",
|
|
136
|
+
"obra",
|
|
137
|
+
"constru",
|
|
138
|
+
"tattoo",
|
|
139
|
+
"tatu",
|
|
140
|
+
],
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
mood: "classic",
|
|
144
|
+
hues: [200, 260],
|
|
145
|
+
words: [
|
|
146
|
+
"advoga",
|
|
147
|
+
"law ",
|
|
148
|
+
"lawyer",
|
|
149
|
+
"abogad",
|
|
150
|
+
"contab",
|
|
151
|
+
"account",
|
|
152
|
+
"contad",
|
|
153
|
+
"consult",
|
|
154
|
+
"foto",
|
|
155
|
+
"photo",
|
|
156
|
+
"imobili",
|
|
157
|
+
"real estate",
|
|
158
|
+
"inmobili",
|
|
159
|
+
"arquitet",
|
|
160
|
+
"architect",
|
|
161
|
+
"arquitect",
|
|
162
|
+
],
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
mood: "playful",
|
|
166
|
+
hues: [0, 360],
|
|
167
|
+
words: [
|
|
168
|
+
"crianca",
|
|
169
|
+
"infant",
|
|
170
|
+
"kid",
|
|
171
|
+
"nino",
|
|
172
|
+
"pet ",
|
|
173
|
+
"cachorr",
|
|
174
|
+
"dog",
|
|
175
|
+
"perro",
|
|
176
|
+
"gato",
|
|
177
|
+
"cat ",
|
|
178
|
+
"festa",
|
|
179
|
+
"party",
|
|
180
|
+
"fiesta",
|
|
181
|
+
"brinqu",
|
|
182
|
+
"toy",
|
|
183
|
+
"juguet",
|
|
184
|
+
"brecho",
|
|
185
|
+
"thrift",
|
|
186
|
+
"bazar",
|
|
187
|
+
],
|
|
188
|
+
},
|
|
189
|
+
];
|
|
190
|
+
/** Lowercase, without accents, padded with spaces, so words match at their starts. */
|
|
191
|
+
function plain(text) {
|
|
192
|
+
return ` ${text
|
|
193
|
+
.normalize("NFD")
|
|
194
|
+
.replace(/\p{M}/gu, "")
|
|
195
|
+
.toLowerCase()
|
|
196
|
+
.replace(/[^a-z0-9]+/g, " ")} `;
|
|
197
|
+
}
|
|
198
|
+
function lineMood(idea) {
|
|
199
|
+
const haystack = plain(idea);
|
|
200
|
+
return MOOD_WORDS.find((row) => row.words.some((word) => haystack.includes(` ${word}`)));
|
|
201
|
+
}
|
|
202
|
+
/** FNV-1a, 32 bits: a stable number for a string. */
|
|
203
|
+
function hash(text) {
|
|
204
|
+
let h = 0x811c9dc5;
|
|
205
|
+
for (let i = 0; i < text.length; i++) {
|
|
206
|
+
h ^= text.charCodeAt(i);
|
|
207
|
+
h = Math.imul(h, 0x01000193);
|
|
208
|
+
}
|
|
209
|
+
return h >>> 0;
|
|
210
|
+
}
|
|
211
|
+
/** A number in [0, 1) from the seed and a label, so each part of the draft rolls its own die. */
|
|
212
|
+
function roll(seed, label) {
|
|
213
|
+
let t = (seed ^ hash(label)) >>> 0;
|
|
214
|
+
// mulberry32, one step.
|
|
215
|
+
t = (t + 0x6d2b79f5) >>> 0;
|
|
216
|
+
let r = Math.imul(t ^ (t >>> 15), 1 | t);
|
|
217
|
+
r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
|
|
218
|
+
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
|
|
219
|
+
}
|
|
220
|
+
/** A fresh seed for a new draft. */
|
|
221
|
+
export function newDraftSeed() {
|
|
222
|
+
const value = new Uint32Array(1);
|
|
223
|
+
crypto.getRandomValues(value);
|
|
224
|
+
return ((value[0] ?? 1) % 2147483646) + 1;
|
|
225
|
+
}
|
|
226
|
+
/** The golden angle: each "Try another" lands far from the colors before it. */
|
|
227
|
+
const GOLDEN_ANGLE = 137.508;
|
|
228
|
+
function draftColors(input) {
|
|
229
|
+
const fromLine = lineMood(`${input.name} ${input.idea}`);
|
|
230
|
+
const base = fromLine?.mood ?? MOODS[Math.floor(roll(input.seed, "mood") * MOODS.length)];
|
|
231
|
+
const mood = base ?? "calm";
|
|
232
|
+
const [from, to] = fromLine?.hues ?? [0, 360];
|
|
233
|
+
const firstHue = from + roll(input.seed, "hue") * (to - from);
|
|
234
|
+
const k = input.picks.colors;
|
|
235
|
+
// The business's own mood holds for two presses, then the moods take turns.
|
|
236
|
+
const moodNow = MOODS[(MOODS.indexOf(mood) + Math.floor(k / 2)) % MOODS.length] ?? mood;
|
|
237
|
+
const hue = (firstHue + k * GOLDEN_ANGLE) % 360;
|
|
238
|
+
return { mood, palette: generatePalette({ hue, mood: moodNow }) };
|
|
239
|
+
}
|
|
240
|
+
function draftFonts(input, mood) {
|
|
241
|
+
// Pairs that suit the mood first, then the rest, each group in a seed-picked order.
|
|
242
|
+
const order = [...DRAFT_FONT_PAIRS].sort((a, b) => {
|
|
243
|
+
const fit = Number(b.moods.includes(mood)) - Number(a.moods.includes(mood));
|
|
244
|
+
return fit || roll(input.seed, a.display) - roll(input.seed, b.display);
|
|
245
|
+
});
|
|
246
|
+
const pair = order[input.picks.fonts % order.length] ?? DRAFT_FONT_PAIRS[0];
|
|
247
|
+
if (!pair)
|
|
248
|
+
throw new Error("No draft fonts");
|
|
249
|
+
const font = (id, weight) => {
|
|
250
|
+
const found = catalogFont(id);
|
|
251
|
+
if (!found)
|
|
252
|
+
throw new Error(`${id} is not in the kit's font catalog`);
|
|
253
|
+
return {
|
|
254
|
+
family: found.family,
|
|
255
|
+
id,
|
|
256
|
+
weights: [weight],
|
|
257
|
+
license: "OFL-1.1",
|
|
258
|
+
};
|
|
259
|
+
};
|
|
260
|
+
return { pair, display: font(pair.display, pair.weight), text: font(pair.text, TEXT_WEIGHT) };
|
|
261
|
+
}
|
|
262
|
+
const BADGES = ["circle", "rounded", "hexagon", "square"];
|
|
263
|
+
/** The logo for a pick: icon marks and monograms take turns, each in the next badge shape. */
|
|
264
|
+
function draftLogo(input) {
|
|
265
|
+
const icons = matchIcons(`${input.name} ${input.idea}`, 4);
|
|
266
|
+
const matched = (icons[0]?.score ?? 0) > 0;
|
|
267
|
+
const k = input.picks.logo + (matched ? 0 : 1);
|
|
268
|
+
const turn = Math.floor(k / 2);
|
|
269
|
+
const badge = BADGES[(turn + Math.floor(roll(input.seed, "badge") * BADGES.length)) % 4];
|
|
270
|
+
const icon = icons[turn % Math.max(1, icons.length)]?.name ?? "sparkles";
|
|
271
|
+
return {
|
|
272
|
+
kind: k % 2 === 0 ? "icon" : "monogram",
|
|
273
|
+
badge: badge ?? "circle",
|
|
274
|
+
icon,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
/** The short name under a home-screen icon: the name, or its first word, or its first letters. */
|
|
278
|
+
function shortNameOf(name) {
|
|
279
|
+
if (name.length <= 12)
|
|
280
|
+
return name;
|
|
281
|
+
const first = name.split(/\s+/)[0] ?? name;
|
|
282
|
+
return (first.length <= 12 ? first : name.slice(0, 12)).trim();
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Draw the draft. The name and the line are trimmed and cut to DRAFT_LIMITS; an empty line makes
|
|
286
|
+
* the name the tagline. Loads the display font (for a monogram) through `fonts`.
|
|
287
|
+
*/
|
|
288
|
+
export async function draftBrand(input, ctx) {
|
|
289
|
+
const name = input.name.trim().slice(0, DRAFT_LIMITS.name) || "?";
|
|
290
|
+
const idea = input.idea.trim().slice(0, DRAFT_LIMITS.idea);
|
|
291
|
+
const clean = { ...input, name, idea };
|
|
292
|
+
const { mood, palette } = draftColors(clean);
|
|
293
|
+
const fonts = draftFonts(clean, mood);
|
|
294
|
+
const logo = draftLogo(clean);
|
|
295
|
+
const light = palette.light;
|
|
296
|
+
let svg;
|
|
297
|
+
if (logo.kind === "icon") {
|
|
298
|
+
svg = iconMark(logo.icon, { badge: logo.badge, fill: light.primary, ink: light.onPrimary });
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
const engine = await engineFor(ctx.fonts);
|
|
302
|
+
const font = await engine.font({ id: fonts.display.id, weight: fonts.pair.weight });
|
|
303
|
+
svg = monogramMark(engine, font, {
|
|
304
|
+
letters: initials(name),
|
|
305
|
+
badge: logo.badge,
|
|
306
|
+
fill: light.primary,
|
|
307
|
+
ink: light.onPrimary,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
const tagline = idea || name;
|
|
311
|
+
const draft = {
|
|
312
|
+
$schema: BRAND_SCHEMA_URL,
|
|
313
|
+
schemaVersion: 1,
|
|
314
|
+
name,
|
|
315
|
+
shortName: shortNameOf(name),
|
|
316
|
+
locales: [input.locale],
|
|
317
|
+
defaultLocale: input.locale,
|
|
318
|
+
copy: {
|
|
319
|
+
[input.locale]: {
|
|
320
|
+
tagline,
|
|
321
|
+
description: tagline,
|
|
322
|
+
card: { title: name, ...(idea ? { subtitle: idea } : {}) },
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
mark: {
|
|
326
|
+
svg,
|
|
327
|
+
tile: {
|
|
328
|
+
fill: "ground",
|
|
329
|
+
shape: logo.badge === "circle" ? "circle" : "rounded",
|
|
330
|
+
padding: 0.04,
|
|
331
|
+
},
|
|
332
|
+
source: logo.kind === "icon"
|
|
333
|
+
? { kind: "icon", ref: `lucide:${logo.icon}` }
|
|
334
|
+
: { kind: "monogram" },
|
|
335
|
+
},
|
|
336
|
+
wordmark: { text: name, font: "display", weight: fonts.pair.weight, tracking: -0.01 },
|
|
337
|
+
colors: palette,
|
|
338
|
+
fonts: { display: fonts.display, text: fonts.text },
|
|
339
|
+
};
|
|
340
|
+
const parsed = parseBrand(draft);
|
|
341
|
+
if (!parsed.ok) {
|
|
342
|
+
throw new Error(`The draft did not make a valid brand: ${parsed.issues.map((issue) => `${issue.path} ${issue.message}`).join("; ")}`);
|
|
343
|
+
}
|
|
344
|
+
return parsed.brand;
|
|
345
|
+
}
|
|
346
|
+
const PICK_KEYS = ["colors", "fonts", "logo"];
|
|
347
|
+
/** The draft as link parameters: name, idea, locale, seed, colors, fonts and logo. */
|
|
348
|
+
export function draftParams(input) {
|
|
349
|
+
const params = new URLSearchParams({
|
|
350
|
+
name: input.name.trim().slice(0, DRAFT_LIMITS.name),
|
|
351
|
+
idea: input.idea.trim().slice(0, DRAFT_LIMITS.idea),
|
|
352
|
+
locale: input.locale,
|
|
353
|
+
seed: String(input.seed),
|
|
354
|
+
});
|
|
355
|
+
for (const key of PICK_KEYS)
|
|
356
|
+
params.set(key, String(input.picks[key]));
|
|
357
|
+
return params;
|
|
358
|
+
}
|
|
359
|
+
function count(value, max) {
|
|
360
|
+
if (value === null || !/^\d{1,10}$/.test(value))
|
|
361
|
+
return null;
|
|
362
|
+
const number = Number(value);
|
|
363
|
+
return number <= max ? number : null;
|
|
364
|
+
}
|
|
365
|
+
/** A draft read back from link parameters, or null when any part is missing or out of range. */
|
|
366
|
+
export function readDraftParams(params) {
|
|
367
|
+
const name = params.get("name")?.trim() ?? "";
|
|
368
|
+
const idea = params.get("idea")?.trim() ?? "";
|
|
369
|
+
const locale = LOCALES.find((code) => code === params.get("locale"));
|
|
370
|
+
const seed = count(params.get("seed"), 2147483647);
|
|
371
|
+
if (!name || name.length > DRAFT_LIMITS.name || idea.length > DRAFT_LIMITS.idea)
|
|
372
|
+
return null;
|
|
373
|
+
if (!locale || !seed)
|
|
374
|
+
return null;
|
|
375
|
+
const picks = { colors: 0, fonts: 0, logo: 0 };
|
|
376
|
+
for (const key of PICK_KEYS) {
|
|
377
|
+
const value = count(params.get(key) ?? "0", 9999);
|
|
378
|
+
if (value === null)
|
|
379
|
+
return null;
|
|
380
|
+
picks[key] = value;
|
|
381
|
+
}
|
|
382
|
+
return { name, idea, locale, seed, picks };
|
|
383
|
+
}
|