@mccaskie-labs/workbench-config 0.1.21 → 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,11 @@
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
+
3
9
  ## 0.1.21
4
10
 
5
11
  ### 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
 
@@ -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
+ }
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.21",
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",