@mccaskie-labs/workbench-config 0.1.20 → 0.1.22

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @mccaskie-labs/workbench-config
2
2
 
3
+ ## 0.1.22
4
+
5
+ ### Patch Changes
6
+
7
+ - Brand assets: `checkLogo` (PNG or JPEG by its own bytes, at most 256 KiB, SVG refused because Gmail and Outlook don't show it), `placeholderLogo` (a 96 × 96 PNG of the name's initials on the colour), `colourForId` (a steady colour per project id that white text reads on), `parseColour`, `whiteContrast`, `shownSize` and `LOGO_PATHS`. Used by Control's brand pages and each app's Brand settings page.
8
+
9
+ ## 0.1.21
10
+
11
+ ### Patch Changes
12
+
13
+ - The `ui` component: `capabilities/ui.json` (rules and probes for `@mccaskie-labs/workbench-ui`) joins the manifests, and `ui` joins the component list.
14
+
3
15
  ## 0.1.20
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -28,6 +28,7 @@ const { WORKBENCH_GATEWAY_KEY } = config.parse(env)
28
28
  | `redact(value)`, `redactAll(text, secrets)` | `[redacted]` in place of a secret, for logs |
29
29
  | `capabilities`, `MANIFEST_VERSION`, `capabilitySchema` | The Workbench capabilities manifests and their schema. The JSON files are also at `@mccaskie-labs/workbench-config/capabilities/<component>.json` |
30
30
  | `defineBrand(json)`, `brandSchema`, `resolveBrand`, `BRAND_DEFAULTS`, `BRAND_FILE` | The app's brand file, `brand.json` at the repo root: `name`, `siteUrl` (https), `logo.url` (https PNG, JPEG or SVG; optional `alt`, `width`, `height`), `colours.primary` (`#rrggbb`; `text`, `background`, `muted` default), optional `fonts.heading`/`fonts.body` (`{ family, url? }`, `url` an https stylesheet), `supportEmail` and `footer`. Unknown keys fail. Account emails, PDFs and Supabase's sign-in emails all read it |
31
+ | `checkLogo(bytes)`, `placeholderLogo(name, colour)`, `colourForId(id)`, `parseColour`, `whiteContrast`, `shownSize`, `LOGO_PATHS`, `MAX_LOGO_BYTES`, `BrandAssetError` | Setting a brand from an upload: the logo is checked by its own bytes (PNG or JPEG, at most 256 KiB; SVG refused because Gmail and Outlook don't show it), a project with no logo gets its initials on its colour as a 96 × 96 PNG, and a project with no colour gets a steady one from its id that white text reads on (WCAG 4.5:1). Runs in Workers, the browser and Node |
31
32
  | `VENDOR_SETTINGS`, `resolveVendorSettings(file, env)`, `vendorSettingsFileSchema`, `VENDOR_SETTINGS_FILE`, `SEND_EMAIL_HOOK`, `SEND_EMAIL_HOOK_PATH`, `SEND_EMAIL_HOOK_SECRET` | The vendor settings baseline: settings that live in a vendor dashboard (field, value, where it is, what breaks if it changes). A project changes a row in `vendor-settings.json` at its repo root, with a reason. Control applies it with `vendor_settings_apply` and compares it every day |
32
33
  | `DEFAULT_GATEWAY_URL` | `https://gateway.mccaskielabs.dev` |
33
34
 
@@ -32,7 +32,8 @@
32
32
  "r2",
33
33
  "kv",
34
34
  "github",
35
- "strings"
35
+ "strings",
36
+ "ui"
36
37
  ]
37
38
  },
38
39
  "title": {
@@ -0,0 +1,91 @@
1
+ {
2
+ "$schema": "./schema.json",
3
+ "format": 1,
4
+ "component": "ui",
5
+ "title": "One look for every page",
6
+ "summary": "workbench-ui gives every Workbench page the same dark look: the page shell, a top bar with the environment tag, cards that fold, jump pills, outcome pills, and one way to write times, money and durations. It returns HTML text in the app's own Worker, with no Gateway call and no cost.",
7
+ "status": "built",
8
+ "surfaces": {
9
+ "package": [
10
+ "shell",
11
+ "topBar",
12
+ "card",
13
+ "jumpNav",
14
+ "SECTIONS_SCRIPT",
15
+ "pill",
16
+ "chip",
17
+ "when",
18
+ "shortWhen",
19
+ "timeLeft",
20
+ "money",
21
+ "duration",
22
+ "escape",
23
+ "STYLE",
24
+ "CSS"
25
+ ]
26
+ },
27
+ "rules": [
28
+ {
29
+ "id": "ui.one-look",
30
+ "kind": "must",
31
+ "text": "Build every page a person opens with shell (or STYLE for a page that writes its own document), so each page has the same top bar, cards and pills.",
32
+ "enforced_by": "review",
33
+ "probe": "ui.page"
34
+ },
35
+ {
36
+ "id": "ui.environment-tag",
37
+ "kind": "must",
38
+ "text": "Pass the environment to topBar, so every page says whether it is staging or production.",
39
+ "enforced_by": "review",
40
+ "probe": "ui.page"
41
+ },
42
+ {
43
+ "id": "ui.escape",
44
+ "kind": "must",
45
+ "text": "Escape any text that goes into an HTML argument (a card's inner, a top bar's extra) with escape. Arguments described as plain text are escaped for you.",
46
+ "enforced_by": "review",
47
+ "probe": "ui.page"
48
+ },
49
+ {
50
+ "id": "ui.no-own-colours",
51
+ "kind": "must_not",
52
+ "text": "Give a page its own colours. A page's own rules go after the shared ones and use the colour tokens (--surface, --line, --pass and so on).",
53
+ "enforced_by": "review",
54
+ "probe": "ui.page"
55
+ },
56
+ {
57
+ "id": "ui.one-format",
58
+ "kind": "must",
59
+ "text": "Write the times, amounts and durations a page shows with when, shortWhen, timeLeft, money and duration, not hand-built strings.",
60
+ "enforced_by": "review",
61
+ "probe": "ui.formats"
62
+ }
63
+ ],
64
+ "examples": [
65
+ {
66
+ "id": "ui.example.page",
67
+ "title": "A page with one folding card",
68
+ "lang": "ts",
69
+ "probe": "ui.page",
70
+ "code": "import { workbenchEnv } from '@mccaskie-labs/workbench-config'\nimport { card, pill, shell, shortWhen, topBar } from '@mccaskie-labs/workbench-ui'\n\nconst html = shell({\n title: 'Orders',\n site: 'my-app',\n top: topBar({ name: 'my-app', href: '/', env: workbenchEnv(env), links: [['History', '/history']] }),\n body: card('Last order', `<div class=\"body\">${pill('pass', 'Sent')} ${shortWhen(order.sent_at)}</div>`, {\n id: 'last-order',\n about: 'The most recent order and when it went out.',\n collapsible: true,\n }),\n})"
71
+ }
72
+ ],
73
+ "probes": [
74
+ {
75
+ "id": "ui.page",
76
+ "half": "runtime",
77
+ "expect": "pass",
78
+ "smoke": true,
79
+ "cost_hint_usd": 0,
80
+ "note": "In the Worker itself: builds one page with shell, topBar, jumpNav, a folding card and a pill, and checks the shared stylesheet is there with its colour tokens, the environment tag is shown, the pill carries its tone, the jump pill points at the card, and markup in a plain-text title and in text passed through escape both come out escaped"
81
+ },
82
+ {
83
+ "id": "ui.formats",
84
+ "half": "runtime",
85
+ "expect": "pass",
86
+ "smoke": true,
87
+ "cost_hint_usd": 0,
88
+ "note": "In the Worker itself: compares money (US$0.0042, US$12.50, and an en dash for nothing), duration (640 ms, 2.35 s, 3 min 5 s) and timeLeft against a fixed now (42 minutes left) with the expected text, and checks when and shortWhen write one fixed moment as its Manila day and hour with UTC beside it"
89
+ }
90
+ ]
91
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Checks and makes a brand's logo and colour, the same way wherever a brand
3
+ * is set: Control's new-project and Brand pages, and the Brand settings page
4
+ * inside each app (decided by Robert, 11 November 2026). Runs in Workers, the
5
+ * browser and Node: no Node built-ins.
6
+ */
7
+ /** The logo emails and PDFs show. Gmail and Outlook don't show SVG, so PNG or JPEG only. */
8
+ export declare const MAX_LOGO_BYTES: number;
9
+ /** Where an app serves its logo, by type. Control's Access bypass opens exactly these paths. */
10
+ export declare const LOGO_PATHS: {
11
+ readonly png: "/brand/logo.png";
12
+ readonly jpg: "/brand/logo.jpg";
13
+ };
14
+ export type LogoExt = keyof typeof LOGO_PATHS;
15
+ export declare const LOGO_CONTENT_TYPES: Record<LogoExt, string>;
16
+ export type BrandAssetErrorCode = "invalid_logo" | "svg_logo" | "logo_too_big" | "invalid_colour";
17
+ /** A logo or colour that can't be used. The message is safe to show the person uploading. */
18
+ export declare class BrandAssetError extends Error {
19
+ readonly code: BrandAssetErrorCode;
20
+ constructor(code: BrandAssetErrorCode, message: string);
21
+ }
22
+ export interface CheckedLogo {
23
+ ext: LogoExt;
24
+ contentType: string;
25
+ /** Pixel size, read from the file. */
26
+ width: number;
27
+ height: number;
28
+ }
29
+ /** Checks an uploaded logo by its own bytes (not its name or declared type). Throws a BrandAssetError. */
30
+ export declare function checkLogo(bytes: Uint8Array): CheckedLogo;
31
+ /** The size a logo is shown at in brand.json: 48 pixels high, at most 240 wide. */
32
+ export declare function shownSize(logo: Pick<CheckedLogo, "width" | "height">): {
33
+ width: number;
34
+ height: number;
35
+ };
36
+ /** "#1F4E79" or "1f4e79" as "#1f4e79". Throws a BrandAssetError. */
37
+ export declare function parseColour(text: string): string;
38
+ /** WCAG contrast ratio of white text on this colour; 4.5 or more reads well. */
39
+ export declare function whiteContrast(colour: string): number;
40
+ /** A steady colour for a project id: always the same for that id, dark enough for white text. */
41
+ export declare function colourForId(id: string): string;
42
+ /** Up to two letters for a name: "Galing Gear" is GG, "wb-probe" is WP, "imprint" is I. */
43
+ export declare function initials(name: string): string;
44
+ /** A 96 × 96 PNG of the name's initials on the colour, for a project with no logo yet. */
45
+ export declare function placeholderLogo(name: string, colour: string): Promise<Uint8Array>;
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Checks and makes a brand's logo and colour, the same way wherever a brand
3
+ * is set: Control's new-project and Brand pages, and the Brand settings page
4
+ * inside each app (decided by Robert, 11 November 2026). Runs in Workers, the
5
+ * browser and Node: no Node built-ins.
6
+ */
7
+ /** The logo emails and PDFs show. Gmail and Outlook don't show SVG, so PNG or JPEG only. */
8
+ export const MAX_LOGO_BYTES = 256 * 1024;
9
+ /** Where an app serves its logo, by type. Control's Access bypass opens exactly these paths. */
10
+ export const LOGO_PATHS = { png: "/brand/logo.png", jpg: "/brand/logo.jpg" };
11
+ export const LOGO_CONTENT_TYPES = { png: "image/png", jpg: "image/jpeg" };
12
+ /** A logo or colour that can't be used. The message is safe to show the person uploading. */
13
+ export class BrandAssetError extends Error {
14
+ code;
15
+ constructor(code, message) {
16
+ super(message);
17
+ this.name = "BrandAssetError";
18
+ this.code = code;
19
+ }
20
+ }
21
+ /** Checks an uploaded logo by its own bytes (not its name or declared type). Throws a BrandAssetError. */
22
+ export function checkLogo(bytes) {
23
+ if (bytes.length === 0)
24
+ throw new BrandAssetError("invalid_logo", "The logo file is empty.");
25
+ if (bytes.length > MAX_LOGO_BYTES) {
26
+ throw new BrandAssetError("logo_too_big", `The logo is ${Math.ceil(bytes.length / 1024)} KiB; the limit is ${MAX_LOGO_BYTES / 1024} KiB. A PNG about 256 pixels high is plenty.`);
27
+ }
28
+ const png = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
29
+ let size = null;
30
+ let ext;
31
+ if (bytes.length > 24 && png.every((v, i) => bytes[i] === v)) {
32
+ ext = "png";
33
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
34
+ size = { width: dv.getUint32(16), height: dv.getUint32(20) };
35
+ }
36
+ else if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
37
+ ext = "jpg";
38
+ size = jpegSize(bytes);
39
+ }
40
+ else {
41
+ const head = new TextDecoder().decode(bytes.subarray(0, 512)).toLowerCase();
42
+ if (head.includes("<svg")) {
43
+ throw new BrandAssetError("svg_logo", "That's an SVG. Gmail and Outlook don't show SVG, so save the logo as a PNG first.");
44
+ }
45
+ throw new BrandAssetError("invalid_logo", "The logo must be a PNG or JPEG.");
46
+ }
47
+ if (!size || !size.width || !size.height) {
48
+ throw new BrandAssetError("invalid_logo", "The logo's size can't be read; save it again as a PNG.");
49
+ }
50
+ return { ext, contentType: LOGO_CONTENT_TYPES[ext], ...size };
51
+ }
52
+ function jpegSize(b) {
53
+ let i = 2;
54
+ while (i + 9 < b.length) {
55
+ if (b[i] !== 0xff)
56
+ return null;
57
+ const marker = b[i + 1];
58
+ const len = (b[i + 2] << 8) | b[i + 3];
59
+ const sof = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
60
+ if (sof)
61
+ return { height: (b[i + 5] << 8) | b[i + 6], width: (b[i + 7] << 8) | b[i + 8] };
62
+ i += 2 + len;
63
+ }
64
+ return null;
65
+ }
66
+ /** The size a logo is shown at in brand.json: 48 pixels high, at most 240 wide. */
67
+ export function shownSize(logo) {
68
+ return { width: Math.min(240, Math.max(16, Math.round((48 * logo.width) / logo.height))), height: 48 };
69
+ }
70
+ // ── Colour ───────────────────────────────────────────────────────────────────
71
+ /** "#1F4E79" or "1f4e79" as "#1f4e79". Throws a BrandAssetError. */
72
+ export function parseColour(text) {
73
+ const t = String(text ?? "").trim().replace(/^#?/, "#").toLowerCase();
74
+ if (!/^#[0-9a-f]{6}$/.test(t))
75
+ throw new BrandAssetError("invalid_colour", `"${text}" isn't a colour. Use six hex digits, for example #1f4e79.`);
76
+ return t;
77
+ }
78
+ const rgb = (colour) => [1, 3, 5].map((i) => parseInt(colour.slice(i, i + 2), 16));
79
+ function luminance(colour) {
80
+ const [r, g, b] = rgb(colour).map((v) => {
81
+ const c = v / 255;
82
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
83
+ });
84
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
85
+ }
86
+ /** WCAG contrast ratio of white text on this colour; 4.5 or more reads well. */
87
+ export function whiteContrast(colour) {
88
+ return 1.05 / (luminance(colour) + 0.05);
89
+ }
90
+ const hex2 = (n) => Math.round(n).toString(16).padStart(2, "0");
91
+ function hsl(hue, s, l) {
92
+ const k = (n) => (n + hue / 30) % 12;
93
+ const a = s * Math.min(l, 1 - l);
94
+ const f = (n) => 255 * (l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))));
95
+ return `#${hex2(f(0))}${hex2(f(8))}${hex2(f(4))}`;
96
+ }
97
+ /** A steady colour for a project id: always the same for that id, dark enough for white text. */
98
+ export function colourForId(id) {
99
+ let h = 2166136261;
100
+ for (const c of String(id))
101
+ h = Math.imul(h ^ c.charCodeAt(0), 16777619) >>> 0;
102
+ const hue = h % 360;
103
+ // Yellows and greens are bright at the same lightness, so darken until white text reads.
104
+ for (let l = 0.38;; l -= 0.02) {
105
+ const colour = hsl(hue, 0.55, l);
106
+ if (whiteContrast(colour) >= 4.5 || l <= 0.1)
107
+ return colour;
108
+ }
109
+ }
110
+ // ── Placeholder logo ─────────────────────────────────────────────────────────
111
+ // 5 × 7 letters and digits, top row first.
112
+ const GLYPHS = {
113
+ A: "01110 10001 10001 11111 10001 10001 10001", B: "11110 10001 10001 11110 10001 10001 11110",
114
+ C: "01110 10001 10000 10000 10000 10001 01110", D: "11110 10001 10001 10001 10001 10001 11110",
115
+ E: "11111 10000 10000 11110 10000 10000 11111", F: "11111 10000 10000 11110 10000 10000 10000",
116
+ G: "01110 10001 10000 10111 10001 10001 01111", H: "10001 10001 10001 11111 10001 10001 10001",
117
+ I: "01110 00100 00100 00100 00100 00100 01110", J: "00111 00010 00010 00010 00010 10010 01100",
118
+ K: "10001 10010 10100 11000 10100 10010 10001", L: "10000 10000 10000 10000 10000 10000 11111",
119
+ M: "10001 11011 10101 10101 10001 10001 10001", N: "10001 10001 11001 10101 10011 10001 10001",
120
+ O: "01110 10001 10001 10001 10001 10001 01110", P: "11110 10001 10001 11110 10000 10000 10000",
121
+ Q: "01110 10001 10001 10001 10101 10010 01101", R: "11110 10001 10001 11110 10100 10010 10001",
122
+ S: "01111 10000 10000 01110 00001 00001 11110", T: "11111 00100 00100 00100 00100 00100 00100",
123
+ U: "10001 10001 10001 10001 10001 10001 01110", V: "10001 10001 10001 10001 10001 01010 00100",
124
+ W: "10001 10001 10001 10101 10101 10101 01010", X: "10001 10001 01010 00100 01010 10001 10001",
125
+ Y: "10001 10001 10001 01010 00100 00100 00100", Z: "11111 00001 00010 00100 01000 10000 11111",
126
+ 0: "01110 10001 10011 10101 11001 10001 01110", 1: "00100 01100 00100 00100 00100 00100 01110",
127
+ 2: "01110 10001 00001 00010 00100 01000 11111", 3: "11111 00010 00100 00010 00001 10001 01110",
128
+ 4: "00010 00110 01010 10010 11111 00010 00010", 5: "11111 10000 11110 00001 00001 10001 01110",
129
+ 6: "00110 01000 10000 11110 10001 10001 01110", 7: "11111 00001 00010 00100 01000 01000 01000",
130
+ 8: "01110 10001 10001 01110 10001 10001 01110", 9: "01110 10001 10001 01111 00001 00010 01100",
131
+ };
132
+ /** Up to two letters for a name: "Galing Gear" is GG, "wb-probe" is WP, "imprint" is I. */
133
+ export function initials(name) {
134
+ const words = String(name).toUpperCase().split(/[^A-Z0-9]+/).filter(Boolean);
135
+ return words.slice(0, 2).map((w) => w[0]).join("") || "?";
136
+ }
137
+ const SIZE = 96;
138
+ const SCALE = 6;
139
+ /** A 96 × 96 PNG of the name's initials on the colour, for a project with no logo yet. */
140
+ export async function placeholderLogo(name, colour) {
141
+ const bg = rgb(parseColour(colour));
142
+ const fg = whiteContrast(colour) >= 4.5 ? [255, 255, 255] : [26, 26, 26];
143
+ const text = initials(name).split("").filter((c) => GLYPHS[c]);
144
+ // One filter byte (0) then RGB per row.
145
+ const stride = 1 + SIZE * 3;
146
+ const raw = new Uint8Array(SIZE * stride);
147
+ for (let y = 0; y < SIZE; y++)
148
+ for (let x = 0; x < SIZE; x++)
149
+ raw.set(bg, y * stride + 1 + x * 3);
150
+ const glyphW = 5 * SCALE;
151
+ const width = text.length * glyphW + Math.max(0, text.length - 1) * SCALE;
152
+ const x0 = Math.floor((SIZE - width) / 2);
153
+ const y0 = Math.floor((SIZE - 7 * SCALE) / 2);
154
+ text.forEach((c, k) => {
155
+ GLYPHS[c].split(" ").forEach((row, gy) => {
156
+ for (let gx = 0; gx < 5; gx++) {
157
+ if (row[gx] !== "1")
158
+ continue;
159
+ for (let dy = 0; dy < SCALE; dy++) {
160
+ for (let dx = 0; dx < SCALE; dx++) {
161
+ const X = x0 + k * (glyphW + SCALE) + gx * SCALE + dx;
162
+ const Y = y0 + gy * SCALE + dy;
163
+ raw.set(fg, Y * stride + 1 + X * 3);
164
+ }
165
+ }
166
+ }
167
+ });
168
+ });
169
+ const ihdr = new Uint8Array(13);
170
+ const dv = new DataView(ihdr.buffer);
171
+ dv.setUint32(0, SIZE);
172
+ dv.setUint32(4, SIZE);
173
+ ihdr[8] = 8; // bit depth
174
+ ihdr[9] = 2; // RGB
175
+ return concat([
176
+ new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
177
+ chunk("IHDR", ihdr),
178
+ chunk("IDAT", await zlib(raw)),
179
+ chunk("IEND", new Uint8Array(0)),
180
+ ]);
181
+ }
182
+ /** zlib-wrapped deflate, as PNG's IDAT wants ("deflate" in CompressionStream is the zlib format). */
183
+ async function zlib(data) {
184
+ const stream = new Blob([data]).stream().pipeThrough(new CompressionStream("deflate"));
185
+ return new Uint8Array(await new Response(stream).arrayBuffer());
186
+ }
187
+ const CRC_TABLE = (() => {
188
+ const t = new Uint32Array(256);
189
+ for (let n = 0; n < 256; n++) {
190
+ let c = n;
191
+ for (let k = 0; k < 8; k++)
192
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
193
+ t[n] = c >>> 0;
194
+ }
195
+ return t;
196
+ })();
197
+ function crc32(bytes) {
198
+ let c = 0xffffffff;
199
+ for (const b of bytes)
200
+ c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
201
+ return (c ^ 0xffffffff) >>> 0;
202
+ }
203
+ function chunk(type, data) {
204
+ const out = new Uint8Array(12 + data.length);
205
+ const dv = new DataView(out.buffer);
206
+ dv.setUint32(0, data.length);
207
+ for (let i = 0; i < 4; i++)
208
+ out[4 + i] = type.charCodeAt(i);
209
+ out.set(data, 8);
210
+ dv.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)));
211
+ return out;
212
+ }
213
+ function concat(parts) {
214
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
215
+ let at = 0;
216
+ for (const p of parts) {
217
+ out.set(p, at);
218
+ at += p.length;
219
+ }
220
+ return out;
221
+ }
@@ -1,4 +1,4 @@
1
- export declare const MANIFEST_VERSION: "71462ff7fe4c";
1
+ export declare const MANIFEST_VERSION: "2216125e22d4";
2
2
  export declare const capabilities: {
3
3
  readonly ai: {
4
4
  readonly $schema: "./schema.json";
@@ -1642,6 +1642,70 @@ export declare const capabilities: {
1642
1642
  readonly note: "The app's own checkStrings run passes in its tests, and npx workbench-strings check passes in CI";
1643
1643
  }];
1644
1644
  };
1645
+ readonly ui: {
1646
+ readonly $schema: "./schema.json";
1647
+ readonly format: 1;
1648
+ readonly component: "ui";
1649
+ readonly title: "One look for every page";
1650
+ readonly summary: "workbench-ui gives every Workbench page the same dark look: the page shell, a top bar with the environment tag, cards that fold, jump pills, outcome pills, and one way to write times, money and durations. It returns HTML text in the app's own Worker, with no Gateway call and no cost.";
1651
+ readonly status: "built";
1652
+ readonly surfaces: {
1653
+ readonly package: readonly ["shell", "topBar", "card", "jumpNav", "SECTIONS_SCRIPT", "pill", "chip", "when", "shortWhen", "timeLeft", "money", "duration", "escape", "STYLE", "CSS"];
1654
+ };
1655
+ readonly rules: readonly [{
1656
+ readonly id: "ui.one-look";
1657
+ readonly kind: "must";
1658
+ readonly text: "Build every page a person opens with shell (or STYLE for a page that writes its own document), so each page has the same top bar, cards and pills.";
1659
+ readonly enforced_by: "review";
1660
+ readonly probe: "ui.page";
1661
+ }, {
1662
+ readonly id: "ui.environment-tag";
1663
+ readonly kind: "must";
1664
+ readonly text: "Pass the environment to topBar, so every page says whether it is staging or production.";
1665
+ readonly enforced_by: "review";
1666
+ readonly probe: "ui.page";
1667
+ }, {
1668
+ readonly id: "ui.escape";
1669
+ readonly kind: "must";
1670
+ readonly text: "Escape any text that goes into an HTML argument (a card's inner, a top bar's extra) with escape. Arguments described as plain text are escaped for you.";
1671
+ readonly enforced_by: "review";
1672
+ readonly probe: "ui.page";
1673
+ }, {
1674
+ readonly id: "ui.no-own-colours";
1675
+ readonly kind: "must_not";
1676
+ readonly text: "Give a page its own colours. A page's own rules go after the shared ones and use the colour tokens (--surface, --line, --pass and so on).";
1677
+ readonly enforced_by: "review";
1678
+ readonly probe: "ui.page";
1679
+ }, {
1680
+ readonly id: "ui.one-format";
1681
+ readonly kind: "must";
1682
+ readonly text: "Write the times, amounts and durations a page shows with when, shortWhen, timeLeft, money and duration, not hand-built strings.";
1683
+ readonly enforced_by: "review";
1684
+ readonly probe: "ui.formats";
1685
+ }];
1686
+ readonly examples: readonly [{
1687
+ readonly id: "ui.example.page";
1688
+ readonly title: "A page with one folding card";
1689
+ readonly lang: "ts";
1690
+ readonly code: "import { workbenchEnv } from '@mccaskie-labs/workbench-config'\nimport { card, pill, shell, shortWhen, topBar } from '@mccaskie-labs/workbench-ui'\n\nconst html = shell({\n title: 'Orders',\n site: 'my-app',\n top: topBar({ name: 'my-app', href: '/', env: workbenchEnv(env), links: [['History', '/history']] }),\n body: card('Last order', `<div class=\"body\">${pill('pass', 'Sent')} ${shortWhen(order.sent_at)}</div>`, {\n id: 'last-order',\n about: 'The most recent order and when it went out.',\n collapsible: true,\n }),\n})";
1691
+ readonly probe: "ui.page";
1692
+ }];
1693
+ readonly probes: readonly [{
1694
+ readonly id: "ui.page";
1695
+ readonly half: "runtime";
1696
+ readonly expect: "pass";
1697
+ readonly smoke: true;
1698
+ readonly cost_hint_usd: 0;
1699
+ readonly note: "In the Worker itself: builds one page with shell, topBar, jumpNav, a folding card and a pill, and checks the shared stylesheet is there with its colour tokens, the environment tag is shown, the pill carries its tone, the jump pill points at the card, and markup in a plain-text title and in text passed through escape both come out escaped";
1700
+ }, {
1701
+ readonly id: "ui.formats";
1702
+ readonly half: "runtime";
1703
+ readonly expect: "pass";
1704
+ readonly smoke: true;
1705
+ readonly cost_hint_usd: 0;
1706
+ readonly note: "In the Worker itself: compares money (US$0.0042, US$12.50, and an en dash for nothing), duration (640 ms, 2.35 s, 3 min 5 s) and timeLeft against a fixed now (42 minutes left) with the expected text, and checks when and shortWhen write one fixed moment as its Manila day and hour with UTC beside it";
1707
+ }];
1708
+ };
1645
1709
  };
1646
1710
  export type Alias = (typeof capabilities)["ai"]["allowed"]["aliases"][number]["alias"];
1647
1711
  export type GatewayScope = (typeof capabilities)["gateway"]["allowed"]["scopes"][number];
@@ -1,6 +1,6 @@
1
1
  // Generated by `npm run capabilities:build` from capabilities/*.json. Do not edit.
2
2
  // check:capabilities fails in Checks when this file is stale.
3
- export const MANIFEST_VERSION = "71462ff7fe4c";
3
+ export const MANIFEST_VERSION = "2216125e22d4";
4
4
  export const capabilities = {
5
5
  "ai": {
6
6
  "$schema": "./schema.json",
@@ -2204,5 +2204,96 @@ export const capabilities = {
2204
2204
  "note": "The app's own checkStrings run passes in its tests, and npx workbench-strings check passes in CI"
2205
2205
  }
2206
2206
  ]
2207
+ },
2208
+ "ui": {
2209
+ "$schema": "./schema.json",
2210
+ "format": 1,
2211
+ "component": "ui",
2212
+ "title": "One look for every page",
2213
+ "summary": "workbench-ui gives every Workbench page the same dark look: the page shell, a top bar with the environment tag, cards that fold, jump pills, outcome pills, and one way to write times, money and durations. It returns HTML text in the app's own Worker, with no Gateway call and no cost.",
2214
+ "status": "built",
2215
+ "surfaces": {
2216
+ "package": [
2217
+ "shell",
2218
+ "topBar",
2219
+ "card",
2220
+ "jumpNav",
2221
+ "SECTIONS_SCRIPT",
2222
+ "pill",
2223
+ "chip",
2224
+ "when",
2225
+ "shortWhen",
2226
+ "timeLeft",
2227
+ "money",
2228
+ "duration",
2229
+ "escape",
2230
+ "STYLE",
2231
+ "CSS"
2232
+ ]
2233
+ },
2234
+ "rules": [
2235
+ {
2236
+ "id": "ui.one-look",
2237
+ "kind": "must",
2238
+ "text": "Build every page a person opens with shell (or STYLE for a page that writes its own document), so each page has the same top bar, cards and pills.",
2239
+ "enforced_by": "review",
2240
+ "probe": "ui.page"
2241
+ },
2242
+ {
2243
+ "id": "ui.environment-tag",
2244
+ "kind": "must",
2245
+ "text": "Pass the environment to topBar, so every page says whether it is staging or production.",
2246
+ "enforced_by": "review",
2247
+ "probe": "ui.page"
2248
+ },
2249
+ {
2250
+ "id": "ui.escape",
2251
+ "kind": "must",
2252
+ "text": "Escape any text that goes into an HTML argument (a card's inner, a top bar's extra) with escape. Arguments described as plain text are escaped for you.",
2253
+ "enforced_by": "review",
2254
+ "probe": "ui.page"
2255
+ },
2256
+ {
2257
+ "id": "ui.no-own-colours",
2258
+ "kind": "must_not",
2259
+ "text": "Give a page its own colours. A page's own rules go after the shared ones and use the colour tokens (--surface, --line, --pass and so on).",
2260
+ "enforced_by": "review",
2261
+ "probe": "ui.page"
2262
+ },
2263
+ {
2264
+ "id": "ui.one-format",
2265
+ "kind": "must",
2266
+ "text": "Write the times, amounts and durations a page shows with when, shortWhen, timeLeft, money and duration, not hand-built strings.",
2267
+ "enforced_by": "review",
2268
+ "probe": "ui.formats"
2269
+ }
2270
+ ],
2271
+ "examples": [
2272
+ {
2273
+ "id": "ui.example.page",
2274
+ "title": "A page with one folding card",
2275
+ "lang": "ts",
2276
+ "code": "import { workbenchEnv } from '@mccaskie-labs/workbench-config'\nimport { card, pill, shell, shortWhen, topBar } from '@mccaskie-labs/workbench-ui'\n\nconst html = shell({\n title: 'Orders',\n site: 'my-app',\n top: topBar({ name: 'my-app', href: '/', env: workbenchEnv(env), links: [['History', '/history']] }),\n body: card('Last order', `<div class=\"body\">${pill('pass', 'Sent')} ${shortWhen(order.sent_at)}</div>`, {\n id: 'last-order',\n about: 'The most recent order and when it went out.',\n collapsible: true,\n }),\n})",
2277
+ "probe": "ui.page"
2278
+ }
2279
+ ],
2280
+ "probes": [
2281
+ {
2282
+ "id": "ui.page",
2283
+ "half": "runtime",
2284
+ "expect": "pass",
2285
+ "smoke": true,
2286
+ "cost_hint_usd": 0,
2287
+ "note": "In the Worker itself: builds one page with shell, topBar, jumpNav, a folding card and a pill, and checks the shared stylesheet is there with its colour tokens, the environment tag is shown, the pill carries its tone, the jump pill points at the card, and markup in a plain-text title and in text passed through escape both come out escaped"
2288
+ },
2289
+ {
2290
+ "id": "ui.formats",
2291
+ "half": "runtime",
2292
+ "expect": "pass",
2293
+ "smoke": true,
2294
+ "cost_hint_usd": 0,
2295
+ "note": "In the Worker itself: compares money (US$0.0042, US$12.50, and an en dash for nothing), duration (640 ms, 2.35 s, 3 min 5 s) and timeLeft against a fixed now (42 minutes left) with the expected text, and checks when and shortWhen write one fixed moment as its Manila day and hour with UTC beside it"
2296
+ }
2297
+ ]
2207
2298
  }
2208
2299
  };
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- export declare const COMPONENTS: readonly ["gateway", "ai", "images", "image-search", "config", "email", "email-inbound", "email-sending", "database", "workers", "domains", "dns", "analytics", "country", "pdf", "access", "r2", "kv", "github", "strings"];
2
+ export declare const COMPONENTS: readonly ["gateway", "ai", "images", "image-search", "config", "email", "email-inbound", "email-sending", "database", "workers", "domains", "dns", "analytics", "country", "pdf", "access", "r2", "kv", "github", "strings", "ui"];
3
3
  export type Component = (typeof COMPONENTS)[number];
4
4
  export declare const allowedSchemas: {
5
5
  gateway: z.ZodObject<{
@@ -111,6 +111,7 @@ export declare const capabilitySchema: z.ZodObject<{
111
111
  pdf: "pdf";
112
112
  r2: "r2";
113
113
  strings: "strings";
114
+ ui: "ui";
114
115
  workers: "workers";
115
116
  }>;
116
117
  title: z.ZodString;
@@ -23,6 +23,7 @@ export const COMPONENTS = [
23
23
  "kv",
24
24
  "github",
25
25
  "strings",
26
+ "ui",
26
27
  ];
27
28
  const id = z.string().regex(/^[a-z0-9][a-z0-9.-]*$/, "lower case, digits, dots and hyphens");
28
29
  const code = z.string().regex(/^[a-z][a-z0-9_]*$/, "a snake_case error code");
package/dist/index.d.ts CHANGED
@@ -6,4 +6,5 @@ export { capabilitySchema, checkManifests, allowedSchemas, COMPONENTS, type Capa
6
6
  export { capabilities, MANIFEST_VERSION, type Alias, type GatewayScope } from "./capabilities.gen.ts";
7
7
  export { compareDevVars, parseDevVarNames } from "./dev-vars.ts";
8
8
  export { brandSchema, defineBrand, BRAND_FILE, resolveBrand, BRAND_DEFAULTS, type Brand, type ResolvedBrand } from "./brand.ts";
9
+ export { BrandAssetError, checkLogo, colourForId, initials, LOGO_CONTENT_TYPES, LOGO_PATHS, MAX_LOGO_BYTES, parseColour, placeholderLogo, shownSize, whiteContrast, type BrandAssetErrorCode, type CheckedLogo, type LogoExt, } from "./brand-assets.ts";
9
10
  export { VENDOR_SETTINGS, VENDOR_SETTINGS_FILE, SEND_EMAIL_HOOK, SEND_EMAIL_HOOK_PATH, SEND_EMAIL_HOOK_SECRET, vendorSettingsFileSchema, resolveVendorSettings, type VendorSetting, type VendorSettingEnv, type VendorSettingsFile, type ResolvedVendorSetting, } from "./vendor-settings.ts";
package/dist/index.js CHANGED
@@ -6,4 +6,5 @@ export { capabilitySchema, checkManifests, allowedSchemas, COMPONENTS, } from ".
6
6
  export { capabilities, MANIFEST_VERSION } from "./capabilities.gen.js";
7
7
  export { compareDevVars, parseDevVarNames } from "./dev-vars.js";
8
8
  export { brandSchema, defineBrand, BRAND_FILE, resolveBrand, BRAND_DEFAULTS } from "./brand.js";
9
+ export { BrandAssetError, checkLogo, colourForId, initials, LOGO_CONTENT_TYPES, LOGO_PATHS, MAX_LOGO_BYTES, parseColour, placeholderLogo, shownSize, whiteContrast, } from "./brand-assets.js";
9
10
  export { VENDOR_SETTINGS, VENDOR_SETTINGS_FILE, SEND_EMAIL_HOOK, SEND_EMAIL_HOOK_PATH, SEND_EMAIL_HOOK_SECRET, vendorSettingsFileSchema, resolveVendorSettings, } from "./vendor-settings.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mccaskie-labs/workbench-config",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "Workbench app configuration: WORKBENCH_ENV, typed variables that fail by name, secrets that are never printed, and the capabilities manifest.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",