From 18c7ab6256977c48821a14b8e05096b5825b4618 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 6 Oct 2026 02:00:52 +1100 Subject: [PATCH 1/4] feat(brand): draw the caret mark from one geometry module The rose chevron lived in hand-written copies whose shades and wordmark weights had drifted apart. The new mark, an ink prompt caret plus one rose dot, now comes from shared/brand-mark.ts. AppLogo draws the lockup from it, and `pnpm brand:assets` writes every file in public/ under the filenames other repositories already link. --- app/components/AppLogo.vue | 76 +++++++++++--- package.json | 1 + public/favicon.ico | Bin 1791 -> 1801 bytes public/favicon.svg | 5 +- public/logo-icon.png | Bin 13014 -> 8207 bytes public/logo-icon.svg | 5 +- public/logo-light.svg | 14 +-- public/logo-mark-light.svg | 4 +- public/logo-mark.svg | 4 +- public/logo.svg | 14 +-- scripts/generate-brand-assets.ts | 65 ++++++++++++ shared/brand-mark.ts | 168 +++++++++++++++++++++++++++++++ 12 files changed, 303 insertions(+), 53 deletions(-) create mode 100644 scripts/generate-brand-assets.ts create mode 100644 shared/brand-mark.ts diff --git a/app/components/AppLogo.vue b/app/components/AppLogo.vue index 6d184ce2..26f038bf 100644 --- a/app/components/AppLogo.vue +++ b/app/components/AppLogo.vue @@ -1,15 +1,65 @@ + + + + diff --git a/package.json b/package.json index 4e8de003..1eedaca4 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "cron:docs:check": "tsx scripts/generate-cron-docs.ts --check", "cron:parity": "tsx scripts/check-schedule-parity.ts", "design:eyebrows": "tsx scripts/check-eyebrows.ts", + "brand:assets": "tsx scripts/generate-brand-assets.ts", "cli:grammar": "tsx scripts/check-published-cli-grammar.ts", "cli:docs": "tsx scripts/generate-cli-docs.ts", "start:playwright:webserver": "wrangler d1 migrations apply DB --local --config wrangler.jsonc && NODE_ENV=test wrangler dev --config .output/server/wrangler.json --persist-to .wrangler/state --port 5678", diff --git a/public/favicon.ico b/public/favicon.ico index 07ac606b43d8f41fefe6b8565628ffe4492473eb..84888952ce932cdd5d1da02defa801db7c47ccbc 100644 GIT binary patch delta 1704 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 - - - + diff --git a/public/logo-icon.png b/public/logo-icon.png index d9c4d084283f6a9cffb1fd8ac01acc0c48f75d4d..b85b7c82a68c866b57002790ab7678a5c12a75aa 100644 GIT binary patch literal 8207 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 R|4{{Y?u_2Qc^X!C{tr1$xQPG& diff --git a/public/logo-icon.svg b/public/logo-icon.svg index 68dd2e66..33dd9a56 100644 --- a/public/logo-icon.svg +++ b/public/logo-icon.svg @@ -1,4 +1 @@ - - - - + diff --git a/public/logo-light.svg b/public/logo-light.svg index f376adc7..c7aaadc7 100644 --- a/public/logo-light.svg +++ b/public/logo-light.svg @@ -1,13 +1 @@ - - - - - skilld - + diff --git a/public/logo-mark-light.svg b/public/logo-mark-light.svg index e7b72348..a265f267 100644 --- a/public/logo-mark-light.svg +++ b/public/logo-mark-light.svg @@ -1,3 +1 @@ - - - + diff --git a/public/logo-mark.svg b/public/logo-mark.svg index 12e38cc8..21d60c47 100644 --- a/public/logo-mark.svg +++ b/public/logo-mark.svg @@ -1,3 +1 @@ - - - + diff --git a/public/logo.svg b/public/logo.svg index 412011b5..416ccc92 100644 --- a/public/logo.svg +++ b/public/logo.svg @@ -1,13 +1 @@ - - - - - skilld - + diff --git a/scripts/generate-brand-assets.ts b/scripts/generate-brand-assets.ts new file mode 100644 index 00000000..7796242e --- /dev/null +++ b/scripts/generate-brand-assets.ts @@ -0,0 +1,65 @@ +// Writes every static brand file in public/ from the one mark geometry in +// shared/brand-mark.ts. Other repositories and READMEs link these filenames, +// so the names stay fixed while the drawing changes. +// +// Run: pnpm brand:assets +import { Buffer } from 'node:buffer' +import { writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { Resvg } from '@resvg/resvg-js' +import { BRAND_DARK, BRAND_LIGHT, BRAND_TILE, lockupSvg, markSvg } from '../shared/brand-mark' + +const PUBLIC = fileURLToPath(new URL('../public/', import.meta.url)) + +function png(svg: string, size: number): Buffer { + return Buffer.from(new Resvg(svg, { fitTo: { mode: 'width', value: size } }).render().asPng()) +} + +/** An ICO container of PNG entries. Every browser that still asks for favicon.ico reads PNG entries. */ +function ico(entries: Array<{ size: number, data: Buffer }>): Buffer { + const header = Buffer.alloc(6) + header.writeUInt16LE(0, 0) + header.writeUInt16LE(1, 2) + header.writeUInt16LE(entries.length, 4) + let offset = 6 + 16 * entries.length + const directory = entries.map(({ size, data }) => { + const entry = Buffer.alloc(16) + entry.writeUInt8(size >= 256 ? 0 : size, 0) + entry.writeUInt8(size >= 256 ? 0 : size, 1) + entry.writeUInt16LE(1, 4) + entry.writeUInt16LE(32, 6) + entry.writeUInt32LE(data.length, 8) + entry.writeUInt32LE(offset, 12) + offset += data.length + return entry + }) + return Buffer.concat([header, ...directory, ...entries.map(entry => entry.data)]) +} + +function brandFiles(): Record { + const tile = markSvg({ colors: BRAND_DARK, tile: BRAND_TILE }) + const smallTile = markSvg({ colors: BRAND_DARK, tile: BRAND_TILE, cut: 'small' }) + return { + // Browser tabs draw the SVG favicon at 16px, so it takes the small cut and follows the OS scheme. + 'favicon.svg': markSvg({ colors: BRAND_LIGHT, dark: BRAND_DARK, cut: 'small' }), + // favicon.ico cannot follow the scheme, so it sits on the stone tile. nuxt.config.ts declares these three sizes. + 'favicon.ico': ico([ + { size: 16, data: png(smallTile, 16) }, + { size: 32, data: png(tile, 32) }, + { size: 48, data: png(tile, 48) }, + ]), + // The weekly email header and the Discord digest avatar use the tile, because Gmail strips SVG. + 'logo-icon.svg': tile, + 'logo-icon.png': png(tile, 512), + // `logo` and `logo-mark` are for dark backgrounds; the `-light` files are for light ones. + 'logo.svg': lockupSvg({ colors: BRAND_DARK, height: 84 }), + 'logo-light.svg': lockupSvg({ colors: BRAND_LIGHT, height: 84 }), + 'logo-mark.svg': markSvg({ colors: BRAND_DARK }), + 'logo-mark-light.svg': markSvg({ colors: BRAND_LIGHT }), + } +} + +const files = brandFiles() +await Promise.all(Object.entries(files).map(([name, data]) => writeFile(resolve(PUBLIC, name), data))) +console.log(`Wrote ${Object.keys(files).length} brand files to public/: ${Object.keys(files).join(', ')}`) diff --git a/shared/brand-mark.ts b/shared/brand-mark.ts new file mode 100644 index 00000000..a6f54991 --- /dev/null +++ b/shared/brand-mark.ts @@ -0,0 +1,168 @@ +// The skilld mark: a prompt caret and one rose dot. Stone dots are the noise; +// the rose dot is the Skill you picked. This module is the only place the +// geometry lives. AppLogo, the OG lockup, and every file in public/ come from +// it (scripts/generate-brand-assets.ts writes the files). DESIGN.md describes +// the system; change the shapes here, then run `pnpm brand:assets`. + +export interface BrandColors { + /** The caret and the wordmark. */ + ink: string + /** The one rose dot. */ + dot: string +} + +/** Stone 900 and Tailwind rose 500 on light surfaces, stone 100 and rose 400 on dark ones. */ +export const BRAND_LIGHT: BrandColors = { ink: '#1c1917', dot: '#f43f5e' } +export const BRAND_DARK: BrandColors = { ink: '#f5f5f4', dot: '#fb7185' } + +/** The stone 900 tile behind raster icons, where the background is unknown. */ +export const BRAND_TILE = '#1c1917' + +/** Which cut of the mark to draw. `small` is for 16px, where the regular caret thins out. */ +export type MarkCut = 'regular' | 'small' + +/** The mark lives in a 100 unit box. The box starts at x 5 so the caret and the dot sit optically centred. */ +const MARK_VIEWBOX = { x: 5, y: 0, width: 100, height: 100 } as const + +const CARET_PATH: Record = { + regular: 'M58 50 L18 82 L18 68 L40.5 50 L18 32 L18 18 Z', + small: 'M58 50 L18 82 L18 62 L33 50 L18 38 L18 18 Z', +} + +const MARK_DOT: Record = { + regular: { cx: 80, cy: 50, r: 10 }, + small: { cx: 80, cy: 50, r: 12 }, +} + +/** The caret's ink, in mark units. Both cuts share it. */ +const CARET_INK = { left: 18, right: 58, top: 18, bottom: 82, centreY: 50 } as const + +/** + * The wordmark, outlined from IBM Plex Mono SemiBold (SIL Open Font License), + * so a file renders the same with or without the font installed. Font units: + * 1000 per em, y points up, the origin is the baseline at the glyph's left + * edge. Every glyph advances 600 units, because Plex Mono is monospaced. + */ +const GLYPHS: Record<'s' | 'k' | 'i' | 'l' | 'd', string> = { + s: 'M298 -12Q213 -12 151 14.5Q89 41 54 86L129 154Q161 119 202.5 100Q244 81 299 81Q347 81 376 97Q405 113 405 146Q405 174 385.5 184Q366 194 334 199L251 212Q219 217 188.5 226.5Q158 236 134.5 253Q111 270 97 296.5Q83 323 83 361Q83 442 143.5 485Q204 528 310 528Q385 528 437.5 507Q490 486 524 448L457 374Q435 398 398.5 416.5Q362 435 306 435Q208 435 208 374Q208 346 228 335.5Q248 325 280 320L362 307Q395 302 425.5 292.5Q456 283 479.5 266Q503 249 517 223Q531 197 531 159Q531 79 469.5 33.5Q408 -12 298 -12Z', + k: 'M77 740L205 740L205 306L211 306L286 388L413 516L562 516L357 313L584 0L431 0L266 240L205 182L205 0L77 0Z', + i: 'M332 596Q287 596 268.5 615Q250 634 250 661L250 685Q250 712 268.5 731Q287 750 332 750Q377 750 395.5 731Q414 712 414 685L414 661Q414 634 395.5 615Q377 596 332 596ZM98 101L268 101L268 415L98 415L98 516L396 516L396 101L554 101L554 0L98 0Z', + l: 'M72 101L236 101L236 639L72 639L72 740L364 740L364 101L529 101L529 0L72 0Z', + d: 'M403 91L396 91Q375 44 338.5 16Q302 -12 242 -12Q198 -12 161.5 4.5Q125 21 99 54.5Q73 88 59 139Q45 190 45 258Q45 394 99 461Q153 528 242 528Q302 528 338.5 500Q375 472 396 425L403 425L403 740L531 740L531 0L403 0ZM296 90Q318 90 337.5 95.5Q357 101 371.5 111.5Q386 122 394.5 138.5Q403 155 403 177L403 339Q403 361 394.5 377.5Q386 394 371.5 404.5Q357 415 337.5 420.5Q318 426 296 426Q240 426 209.5 392Q179 358 179 299L179 217Q179 158 209.5 124Q240 90 296 90Z', +} +const WORD = ['s', 'k', 'i', 'l', 'l', 'd'] as const +const FONT_UNITS = 1000 +const ADVANCE = 600 + +/** + * The lockup, in ems of the wordmark (from the brand lab, 2026-10-06). The + * caret box is 1.05em tall and centred on a line-height 1 box, which puts its + * centre 0.375em above the baseline for Plex Mono. The trailing dot is 0.3em + * across, sits 0.32em after the word and 0.12em above the baseline, so its + * centre lands at half the x-height (0.258em) and it reads as a cursor. + */ +const LOCKUP_EM = { + caretBoxHeight: 1.05, + caretCentre: 0.375, + gap: 0.38, + tracking: -0.02, + dotSize: 0.3, + dotGap: 0.32, + dotLift: 0.12, +} as const + +/** Lockup coordinates use 100 units per em, baseline at y 0, y pointing down. */ +const EM = 100 + +export interface LockupPart { + d: string + transform: string +} + +export interface LockupGeometry { + /** A tight box around the ink: x, y, width, height, in lockup units. */ + box: { x: number, y: number, width: number, height: number } + caret: LockupPart + glyphs: LockupPart[] + dot: { cx: number, cy: number, r: number } +} + +const round = (value: number): number => Math.round(value * 1e4) / 1e4 + +/** Where every piece of the lockup sits. Pure data; the SVG writer and AppLogo both draw from it. */ +export function lockupGeometry(): LockupGeometry { + // The lab drew the caret in a 56 by 80 crop of the mark box, 8 units of air on each side. + const caretScale = (LOCKUP_EM.caretBoxHeight * EM) / 80 + const caretTop = -LOCKUP_EM.caretCentre * EM - (CARET_INK.centreY - CARET_INK.top) * caretScale + const caretBottom = -LOCKUP_EM.caretCentre * EM + (CARET_INK.bottom - CARET_INK.centreY) * caretScale + const caretWidth = (CARET_INK.right - CARET_INK.left) * caretScale + const caretTranslateY = -LOCKUP_EM.caretCentre * EM - CARET_INK.centreY * caretScale + + const wordStart = caretWidth + 8 * caretScale + LOCKUP_EM.gap * EM + const glyphScale = EM / FONT_UNITS + const pitch = ADVANCE * glyphScale + LOCKUP_EM.tracking * EM + const glyphs = WORD.map((char, index) => ({ + d: GLYPHS[char], + transform: `matrix(${glyphScale} 0 0 ${-glyphScale} ${round(wordStart + index * pitch)} 0)`, + })) + const wordEnd = wordStart + WORD.length * pitch + + const r = (LOCKUP_EM.dotSize * EM) / 2 + const dot = { cx: round(wordEnd + LOCKUP_EM.dotGap * EM + r), cy: round(-(LOCKUP_EM.dotLift * EM + r)), r } + + // Glyph descent: `s` and `d` overshoot the baseline by 12 font units. + const bottom = Math.max(caretBottom, 12 * glyphScale) + return { + box: { x: 0, y: round(caretTop), width: round(dot.cx + r), height: round(bottom - caretTop) }, + caret: { + d: CARET_PATH.regular, + transform: `matrix(${round(caretScale)} 0 0 ${round(caretScale)} ${round(-CARET_INK.left * caretScale)} ${round(caretTranslateY)})`, + }, + glyphs, + dot, + } +} + +interface SvgOptions { + /** Colours for light surfaces, or the only colours when `dark` is absent. */ + colors: BrandColors + /** When set, a prefers-color-scheme rule switches to these colours. For favicons and README images. */ + dark?: BrandColors +} + +function schemeStyle(dark: BrandColors | undefined): string { + return dark ? `` : '' +} + +/** The standalone mark as an SVG file: caret plus dot, optionally on a rounded tile. */ +export function markSvg(options: SvgOptions & { cut?: MarkCut, tile?: string }): string { + const { colors, dark, cut = 'regular', tile } = options + const dot = MARK_DOT[cut] + if (tile) { + // A 512 tile with the mark's ink centred: x 18 to 90, y 18 to 82, centre (54, 50). + // The small cut fills more of the tile, because a 16px tile has no pixels to spare. + const scale = cut === 'small' ? 5.4 : 4.4 + const tx = round(256 - 54 * scale) + const ty = round(256 - 50 * scale) + return `${schemeStyle(dark)}` + + `\n` + } + const { x, y, width, height } = MARK_VIEWBOX + return `${schemeStyle(dark)}` + + `\n` +} + +/** The lockup as an SVG file: caret, outlined wordmark, trailing dot. `height` sets the intrinsic pixel height. */ +export function lockupSvg(options: SvgOptions & { height?: number }): string { + const { colors, dark, height } = options + const { box, caret, glyphs, dot } = lockupGeometry() + const size = height ? ` width="${round((height * box.width) / box.height)}" height="${height}"` : '' + const paths = [caret, ...glyphs].map(part => ``).join('') + return `${schemeStyle(dark)}` + + `${paths}\n` +} + +/** An SVG string as an `` value. */ +export function svgDataUri(svg: string): string { + return `data:image/svg+xml;utf8,${encodeURIComponent(svg.trim())}` +} From 72443b72e7c698bbc1187738c1418678b6e4d0a5 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 6 Oct 2026 02:00:53 +1100 Subject: [PATCH 2/4] feat(og): put the caret lockup and brand textures on OG cards Every card now carries the lockup, and its trailing dot is the card's one rose element, so the rose top bar, quote rails and avatar ring turn stone. Splash gets a Braille names field that radiates from the lockup dot. Skill cards get a File minimap seeded per Skill. /make-skill gets its own card instead of the default. The OG renderer has no braille glyphs, so the textures are SVG dots. --- app/components/OgBrand.vue | 27 +- app/components/OgImage/Collection.takumi.vue | 13 +- app/components/OgImage/Curator.takumi.vue | 15 +- app/components/OgImage/MakeSkill.takumi.vue | 49 ++++ app/components/OgImage/Page.takumi.vue | 11 - app/components/OgImage/Skill.takumi.vue | 28 +- app/components/OgImage/Splash.takumi.vue | 55 ++-- app/utils/og-texture.ts | 284 +++++++++++++++++++ layers/marketing/app/pages/make-skill.vue | 4 + test/unit/og-image-skill.test.ts | 2 +- test/unit/og-texture.test.ts | 47 +++ 11 files changed, 451 insertions(+), 84 deletions(-) create mode 100644 app/components/OgImage/MakeSkill.takumi.vue create mode 100644 app/utils/og-texture.ts create mode 100644 test/unit/og-texture.test.ts diff --git a/app/components/OgBrand.vue b/app/components/OgBrand.vue index 9fd4522a..9ddd9f7b 100644 --- a/app/components/OgBrand.vue +++ b/app/components/OgBrand.vue @@ -1,23 +1,20 @@ diff --git a/app/components/OgImage/Collection.takumi.vue b/app/components/OgImage/Collection.takumi.vue index 66393929..47c9cd7c 100644 --- a/app/components/OgImage/Collection.takumi.vue +++ b/app/components/OgImage/Collection.takumi.vue @@ -35,17 +35,6 @@ const truncatedReason = computed(() => {