From 14117ffbcd75235e5d2e140dfed1c3bc15e0900f Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 6 Oct 2026 01:52:43 +1100 Subject: [PATCH 1/2] feat(brand): add dot motif components and a brand kit page The new brand treats the dot as the atom: stone dots are the noise, one rose dot is the Skill you picked. Pages need shared primitives for it, so page agents can apply the brand without each one porting the prototype. - Braille spark: a pure text util in shared/ so the weekly email can print the same bars, plus a component with today in rose. - Run chip: dashed run and solid install in one switch, a compact row variant, and a solid chip for the skilld self install. Copies still report to Analytics Engine through useInstallCopy. - Change grid: 13 weeks of changes with bump rings only when two declared versions show the bump. - Three canvas textures on one lifecycle composable that pauses offscreen and stills under reduced motion or a software renderer. - /brand-kit shows every motif in its states and stays out of the index. --- app/assets/css/main.css | 10 + app/components/BrailleSpark.vue | 62 +++ app/components/ChangeGrid.vue | 319 +++++++++++++++ app/components/InstallCommand.vue | 45 +- app/components/RunChip.vue | 189 +++++++++ app/components/SkilldInstallChip.vue | 34 ++ app/components/TextureBrailleNames.client.vue | 50 +++ app/components/TextureConverge.client.vue | 43 ++ app/components/TextureFileMinimap.client.vue | 32 ++ app/components/_CommandChip.vue | 279 +++++++++++++ app/composables/useInstallCopy.ts | 7 +- app/composables/useTextureCanvas.ts | 205 ++++++++++ app/pages/brand-kit/index.vue | 387 ++++++++++++++++++ app/utils/textures/braille-names.ts | 274 +++++++++++++ app/utils/textures/converge.ts | 68 +++ app/utils/textures/dots.ts | 41 ++ app/utils/textures/file-minimap.ts | 339 +++++++++++++++ nuxt.config.ts | 2 +- shared/braille-spark.ts | 49 +++ shared/change-grid.ts | 173 ++++++++ test/a11y.spec.ts | 113 +++++ test/unit/braille-spark.test.ts | 39 ++ test/unit/change-grid.test.ts | 124 ++++++ 23 files changed, 2872 insertions(+), 12 deletions(-) create mode 100644 app/components/BrailleSpark.vue create mode 100644 app/components/ChangeGrid.vue create mode 100644 app/components/RunChip.vue create mode 100644 app/components/SkilldInstallChip.vue create mode 100644 app/components/TextureBrailleNames.client.vue create mode 100644 app/components/TextureConverge.client.vue create mode 100644 app/components/TextureFileMinimap.client.vue create mode 100644 app/components/_CommandChip.vue create mode 100644 app/composables/useTextureCanvas.ts create mode 100644 app/pages/brand-kit/index.vue create mode 100644 app/utils/textures/braille-names.ts create mode 100644 app/utils/textures/converge.ts create mode 100644 app/utils/textures/dots.ts create mode 100644 app/utils/textures/file-minimap.ts create mode 100644 shared/braille-spark.ts create mode 100644 shared/change-grid.ts create mode 100644 test/unit/braille-spark.test.ts create mode 100644 test/unit/change-grid.test.ts diff --git a/app/assets/css/main.css b/app/assets/css/main.css index b08b796d..716469a0 100644 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -26,6 +26,9 @@ --syntax-dim: var(--ui-text-muted); --syntax-bin: var(--ui-text); --syntax-arg: oklch(0.44 0.17 17.32); + /* The one rose dot in a brand motif: the Skill you picked. A graphic, never + text, so it keeps the brighter mark rose instead of the button rose. */ + --brand-dot: oklch(0.645 0.246 16.44); } .dark { @@ -41,6 +44,7 @@ --ui-color-primary-400: oklch(0.555 0.225 17.32); --ui-color-primary-500: oklch(0.555 0.225 17.32); --syntax-arg: oklch(0.8 0.12 17.32); + --brand-dot: oklch(0.712 0.194 13.43); } /* High contrast mode */ @@ -154,6 +158,12 @@ font-weight: 500; } +/* The Skill name, when `split-name` separates it from its owner and repo. */ +.install-command__name { + color: var(--syntax-arg); + font-weight: 600; +} + /* Lead emphasis — the one place a page may spend the accent on a whole surface. Light, not a bar: a masked corner wash plus a hairline that ignites at the leading top corner and dies inside a third of the width. Replaced a 2px rose diff --git a/app/components/BrailleSpark.vue b/app/components/BrailleSpark.vue new file mode 100644 index 00000000..debc6aeb --- /dev/null +++ b/app/components/BrailleSpark.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/app/components/ChangeGrid.vue b/app/components/ChangeGrid.vue new file mode 100644 index 00000000..8b13e870 --- /dev/null +++ b/app/components/ChangeGrid.vue @@ -0,0 +1,319 @@ + + + + + diff --git a/app/components/InstallCommand.vue b/app/components/InstallCommand.vue index 7a9de2da..701b2d70 100644 --- a/app/components/InstallCommand.vue +++ b/app/components/InstallCommand.vue @@ -1,26 +1,53 @@ diff --git a/app/components/RunChip.vue b/app/components/RunChip.vue new file mode 100644 index 00000000..c6176963 --- /dev/null +++ b/app/components/RunChip.vue @@ -0,0 +1,189 @@ + + + + + diff --git a/app/components/SkilldInstallChip.vue b/app/components/SkilldInstallChip.vue new file mode 100644 index 00000000..6977183d --- /dev/null +++ b/app/components/SkilldInstallChip.vue @@ -0,0 +1,34 @@ + + + diff --git a/app/components/TextureBrailleNames.client.vue b/app/components/TextureBrailleNames.client.vue new file mode 100644 index 00000000..f242a8e4 --- /dev/null +++ b/app/components/TextureBrailleNames.client.vue @@ -0,0 +1,50 @@ + + + diff --git a/app/components/TextureConverge.client.vue b/app/components/TextureConverge.client.vue new file mode 100644 index 00000000..b99a6cbb --- /dev/null +++ b/app/components/TextureConverge.client.vue @@ -0,0 +1,43 @@ + + + diff --git a/app/components/TextureFileMinimap.client.vue b/app/components/TextureFileMinimap.client.vue new file mode 100644 index 00000000..e294a6d6 --- /dev/null +++ b/app/components/TextureFileMinimap.client.vue @@ -0,0 +1,32 @@ + + + diff --git a/app/components/_CommandChip.vue b/app/components/_CommandChip.vue new file mode 100644 index 00000000..4ff0466d --- /dev/null +++ b/app/components/_CommandChip.vue @@ -0,0 +1,279 @@ + + + + + diff --git a/app/composables/useInstallCopy.ts b/app/composables/useInstallCopy.ts index 9da5710d..2a21c475 100644 --- a/app/composables/useInstallCopy.ts +++ b/app/composables/useInstallCopy.ts @@ -38,7 +38,8 @@ export type InstallCopyMode = 'run' | 'install' export function useInstallCopy( source: MaybeRefOrGetter, surface: string, - mode: InstallCopyMode, + /** A getter lets one control copy either grammar, as the run chip switch does. */ + mode: MaybeRefOrGetter, target: MaybeRefOrGetter, ) { const { copy: rawCopy, copied, isSupported } = useClipboard({ @@ -49,6 +50,8 @@ export function useInstallCopy( async function copy(value?: string): Promise { const t = toValue(target) + // Read before the await, so a switch flipped mid-copy cannot relabel it. + const copiedMode = toValue(mode) if (!t) return { _tag: 'error', message: 'Install target unavailable.' } @@ -69,7 +72,7 @@ export function useInstallCopy( method: 'POST', body: { surface, - mode, + mode: copiedMode, kind: t.kind, ...(t.kind === 'skill' ? { owner: t.owner, name: t.name } diff --git a/app/composables/useTextureCanvas.ts b/app/composables/useTextureCanvas.ts new file mode 100644 index 00000000..c97e9821 --- /dev/null +++ b/app/composables/useTextureCanvas.ts @@ -0,0 +1,205 @@ +import type { MaybeRefOrGetter, Ref } from 'vue' +import { + useDocumentVisibility, + useIntersectionObserver, + usePreferredReducedMotion, + useRafFn, + useResizeObserver, +} from '@vueuse/core' +import { isSoftwareRenderer, webglRendererName } from '~/utils/webgl-context' + +/** The design tokens a texture paints with, read from the page. */ +export interface TextureColors { + ink: string + muted: string + faint: string + /** The one rose dot. */ + dot: string + /** The mono font stack, for textures that draw letters. */ + mono: string +} + +/** + * One texture. The composable owns the canvas; the scene owns the picture. + * Sizes are CSS pixels, times are seconds. + */ +export interface TextureScene { + /** Recompute geometry for a new box. */ + layout: (width: number, height: number) => void + /** Paint one whole frame. `still` is true for the one frame of a texture that does not move. */ + draw: (ctx: CanvasRenderingContext2D, time: number, colors: TextureColors, still: boolean) => void + /** The time a still frame shows. */ + restTime: number + /** The time the animated clock starts from. Defaults to `restTime`. */ + startTime?: number +} + +/** Textures step at 4 Hz or slower, so 20 fps reads as smooth. */ +const FPS = 20 +const MAX_DPR = 2 + +const TOKENS = { + ink: ['--ui-text', '#57534e'], + muted: ['--ui-text-muted', '#78716c'], + faint: ['--ui-text-dimmed', '#a8a29e'], + dot: ['--brand-dot', '#f43f5e'], +} as const + +let softwareRenderer: boolean | undefined + +/** + * Whether the browser draws without a GPU. A canvas there costs main-thread + * time on every frame, so textures show one still frame instead. + * + * A browser that refuses an accelerated WebGL context counts as software. The + * probe runs once per page and releases its context straight away. + */ +function rendersInSoftware(): boolean { + if (softwareRenderer !== undefined) + return softwareRenderer + const probe = document.createElement('canvas') + const gl = probe.getContext('webgl2', { failIfMajorPerformanceCaveat: true }) + softwareRenderer = !gl || isSoftwareRenderer(webglRendererName(gl)) + gl?.getExtension('WEBGL_lose_context')?.loseContext() + return softwareRenderer +} + +/** + * The canvas can only paint a colour it can parse. A token it cannot read + * (an old browser and `oklch()`) falls back to a fixed warm stone. + */ +function paintable(ctx: CanvasRenderingContext2D, value: string, fallback: string): string { + if (!value) + return fallback + ctx.fillStyle = '#000000' + ctx.fillStyle = value + const onBlack = ctx.fillStyle + ctx.fillStyle = '#ffffff' + ctx.fillStyle = value + return onBlack === ctx.fillStyle ? value : fallback +} + +/** + * The canvas lifecycle every brand texture shares. + * + * - Starts after hydration, once the main thread is idle (`onNuxtReady`). + * - Sizes the canvas to its container at a device pixel ratio capped at 2. + * - Animates at about 20 fps, and pauses offscreen or in a hidden tab. + * - Draws one still frame under reduced motion or a software renderer, at 1x + * for the software renderer. + * - Reads the colour tokens again when the colour mode changes, and redraws. + */ +export function useTextureCanvas( + container: Ref, + canvas: Ref, + scene: MaybeRefOrGetter, +) { + const reducedMotion = usePreferredReducedMotion() + const visibility = useDocumentVisibility() + const colorMode = useColorMode() + + const ready = ref(false) + const software = ref(false) + const onScreen = ref(false) + const still = computed(() => software.value || reducedMotion.value === 'reduce') + + let ctx: CanvasRenderingContext2D | null = null + let colors: TextureColors | null = null + let width = 0 + let height = 0 + let startedAt = 0 + let disposed = false + + function readColors() { + const el = container.value + if (!ctx || !el) + return + const style = getComputedStyle(el) + const read = ([token, fallback]: readonly [string, string]) => paintable(ctx!, style.getPropertyValue(token).trim(), fallback) + colors = { + ink: read(TOKENS.ink), + muted: read(TOKENS.muted), + faint: read(TOKENS.faint), + dot: read(TOKENS.dot), + mono: style.getPropertyValue('--font-mono').trim() || 'ui-monospace, monospace', + } + } + + function resize() { + const el = container.value + const target = canvas.value + if (!ctx || !el || !target) + return + const box = el.getBoundingClientRect() + const dpr = Math.min(window.devicePixelRatio || 1, software.value ? 1 : MAX_DPR) + width = box.width + height = box.height + target.width = Math.max(1, Math.round(width * dpr)) + target.height = Math.max(1, Math.round(height * dpr)) + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + if (width && height) + toValue(scene).layout(width, height) + } + + function frame() { + if (!ready.value || !ctx || !colors || !width || !height) + return + const current = toValue(scene) + const time = still.value + ? current.restTime + : (current.startTime ?? current.restTime) + (performance.now() - startedAt) / 1000 + current.draw(ctx, time, colors, still.value) + } + + const { pause, resume } = useRafFn(frame, { immediate: false, fpsLimit: FPS }) + + const running = computed(() => ready.value && !still.value && onScreen.value && visibility.value === 'visible') + watch(running, (isRunning) => { + if (isRunning) + resume() + else + pause() + }) + // A still frame has no loop, so it redraws on its own when it stops moving. + watch(still, frame) + + useIntersectionObserver(container, ([entry]) => { + onScreen.value = entry?.isIntersecting ?? false + }) + useResizeObserver(container, () => { + resize() + frame() + }) + // The colour mode plugin swaps the root class before post-flush watchers run. + watch(() => colorMode.value, () => { + readColors() + frame() + }, { flush: 'post' }) + watch(() => toValue(scene), () => { + resize() + frame() + }) + + onNuxtReady(() => { + if (disposed || ready.value) + return + ctx = canvas.value?.getContext('2d') ?? null + if (!ctx) + return + software.value = rendersInSoftware() + readColors() + resize() + startedAt = performance.now() + ready.value = true + frame() + // Letters drawn before the mono font loads would keep the fallback face. + void document.fonts?.ready.then(frame) + }) + + onBeforeUnmount(() => { + disposed = true + pause() + }) + + return { still, ready } +} diff --git a/app/pages/brand-kit/index.vue b/app/pages/brand-kit/index.vue new file mode 100644 index 00000000..50c14770 --- /dev/null +++ b/app/pages/brand-kit/index.vue @@ -0,0 +1,387 @@ + + + diff --git a/app/utils/textures/braille-names.ts b/app/utils/textures/braille-names.ts new file mode 100644 index 00000000..11cec3f4 --- /dev/null +++ b/app/utils/textures/braille-names.ts @@ -0,0 +1,274 @@ +import type { TextureScene } from '~/composables/useTextureCanvas' +import { hash, ripple } from './dots' + +/** + * "Braille names": real Skill names read as braille far from the rose dot and + * resolve into letters near it. The dot hops between names, and each hop + * ripples outward. + * + * Each frame is a grid of characters. The picked name is ink, its neighbours + * muted then faint letters, then the same names in braille, thinning to + * sparse dots. The canvas paints that grid at a fixed cell width, so a + * fallback font never shifts a column. + */ + +const SEP = ' ' +const FONT_SIZE = 11 +const CELL_W = 6.6 +const LINE_H = 14 +const HOLD = 4.2 +const MOVE = 1.4 +const CYCLE = HOLD + MOVE +const RING_SPEED = 80 +const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map(v => (v + 0.5) / 16) +const DOT_ALPHA = [0.22, 0.36, 0.52, 0.7] + +// Tones: -1 blank, 0 to 3 braille dot strength, then letters and the bullet. +const T_FAINT = 4 +const T_MUTED = 5 +const T_INK = 6 +const T_ROSE = 7 + +/** Six-dot literary braille. Bit k is dot k + 1, the same order as U+2800. */ +const BRAILLE: Record = {} +for (const token of 'a1 b12 c14 d145 e15 f124 g1245 h125 i24 j245 k13 l123 m134 n1345 o135 p1234 q12345 r1235 s234 t2345 u136 v1236 w2456 x1346 y13456 z1356 -36 /34 .256 _456'.split(' ')) { + let mask = 0 + for (const dot of token.slice(1)) + mask |= 1 << (Number(dot) - 1) + BRAILLE[token[0]!] = mask +} +for (const [index, digit] of [...'1234567890'].entries()) + BRAILLE[digit] = BRAILLE['abcdefghij'[index]!]! + +interface Span { y: number, s: number, e: number } +interface Line { text: string, spans: Span[] } +interface Moment { a: Span, b: Span, e: number, age: number } + +/** At rest the first ring sits about 120px out, so a still frame still shows the ripple. */ +const REST = MOVE / 2 + 1.5 + +export function createBrailleNamesScene(names: readonly string[]): TextureScene { + let w = 0 + let h = 0 + let cols = 0 + let rows = 0 + let ox = 0 + let oy = 0 + let sx2 = 1 + let sy2 = 1 + let reach = 1 + let lines: Line[] = [] + let picks: Span[] = [] + let chars: string[] = [] + let tones = new Int8Array(0) + + // One row of names, cut at a random offset. With `align`, the cut puts the first name that fits fully in view. + function buildRow(y: number, align = false): Line { + const stream: { s: number, len: number }[] = [] + let text = '' + let last = -1 + for (let k = 0; text.length < cols + 600; k++) { + let i = Math.floor(hash(y, k, 5) * names.length) + if (i === last) + i = (i + 1) % names.length + last = i + if (k > 0) + text += SEP + stream.push({ s: text.length, len: names[i]!.length }) + text += names[i] + } + let offset = Math.floor(hash(y, 7, 3) * 28) + const fits = (max: number) => stream.find((n, k) => k > 0 && n.len <= max) + const fit = align && (fits(Math.min(cols - 2, Math.max(22, cols * 0.6))) || fits(cols - 2)) + if (fit) + offset = Math.max(0, fit.s - 2 - Math.floor(hash(y, 11, 3) * (cols - 1 - fit.len))) + return { text: text.slice(offset, offset + cols), spans: stream.map(n => ({ y, s: n.s - offset, e: n.s - offset + n.len })) } + } + + function layout(width: number, height: number) { + w = width + h = height + const c = Math.max(8, Math.floor(w / CELL_W)) + const r = Math.max(4, Math.floor(h / LINE_H)) + ox = (w - c * CELL_W) / 2 + oy = (h - r * LINE_H) / 2 + reach = Math.max(w, h) * 0.18 + if (c === cols && r === rows && lines.length) + return + cols = c + rows = r + sx2 = Math.max(5, cols * 0.28) ** 2 + sy2 = Math.max(1.6, rows * 0.12) ** 2 + if (!names.length) { + lines = Array.from({ length: rows }, () => ({ text: '', spans: [] })) + picks = [] + } + else { + lines = Array.from({ length: rows }, (_, y) => buildRow(y)) + // Three band rows always hold a name in full view, so the pick has somewhere to hop. + for (const y of [0.55, 0.36, 0.7].map(f => Math.floor(rows * f))) { + if (!lines[y]!.spans.some(span => span.s >= 2 && span.e <= cols)) + lines[y] = buildRow(y, true) + } + // A pick is a name fully in view, in the middle band, with room for the dot two cells before it. + picks = [] + for (let y = Math.floor(rows * 0.25); y < Math.ceil(rows * 0.75) && y < rows; y++) { + for (const span of lines[y]!.spans) { + if (span.s >= 2 && span.e <= cols) + picks.push(span) + } + } + // Rest on a short pick near 40% across and 55% down, then hop in hashed order. + const score = (p: Span) => (Math.abs((p.s + p.e) / 2 - cols * 0.4) + (p.e - p.s) * 0.5) / cols + Math.abs(p.y - rows * 0.55) / rows + picks.sort((a, b) => hash(a.y, a.s, 9) - hash(b.y, b.s, 9)) + const first = picks.reduce((best, p, i) => (score(p) < score(picks[best]!) ? i : best), 0) + if (picks.length) + picks.unshift(picks.splice(first, 1)[0]!) + } + chars = Array.from({ length: cols * rows }) + tones = new Int8Array(cols * rows) + } + + const inside = (p: Span, x: number, y: number) => (y === p.y && x >= p.s && x < p.e ? 1 : 0) + const gapX = (p: Span, x: number) => (x < p.s ? p.s - x : x >= p.e ? x - p.e + 1 : 0) + // Letters hold a tight band around the pick; braille density falls off much farther out, in pixels. + const letterField = (p: Span, x: number, y: number) => Math.exp(-(gapX(p, x) ** 2) / sx2 - ((y - p.y) ** 2) / sy2) + const dotField = (p: Span, x: number, y: number) => Math.exp(-Math.hypot(gapX(p, x) * CELL_W, (y - p.y) * LINE_H) / reach) + + // Where the pick is at time t: blending from a to b by e, and how long ago the rose dot landed. + function timeline(t: number): Moment | null { + const count = picks.length + if (!count) + return null + if (count < 2) + return { a: picks[0]!, b: picks[0]!, e: 1, age: (((t - MOVE / 2) % CYCLE) + CYCLE) % CYCLE } + const n = Math.floor(t / CYCLE) + const phase = t - n * CYCLE + const b = picks[((n % count) + count) % count]! + const a = phase < MOVE ? picks[(((n - 1) % count) + count) % count]! : b + const u = Math.min(1, phase / MOVE) + const age = phase >= MOVE / 2 ? phase - MOVE / 2 : phase + CYCLE - MOVE / 2 + return { a, b, e: u * u * (3 - 2 * u), age } + } + + // Fill chars and tones for one frame. The grid alone says what to paint. + function compose(moment: Moment | null, t: number) { + const a = moment?.a ?? null + const b = moment?.b ?? null + const e = moment?.e ?? 1 + const age = moment?.age ?? 99 + const rose = a && b ? (e < 0.5 ? a : b) : null + const rx = rose ? ox + (rose.s - 2) * CELL_W + CELL_W / 2 : 0 + const ry = rose ? oy + rose.y * LINE_H + LINE_H / 2 : 0 + for (let y = 0; y < rows; y++) { + const text = lines[y]!.text + for (let x = 0; x < cols; x++) { + const k = y * cols + x + let ch = text[x] || ' ' + let tone = -1 + if (rose && y === rose.y && x === rose.s - 2) { + ch = '•' + tone = T_ROSE + } + else if (ch !== ' ') { + const threshold = BAYER[(y & 3) * 4 + (x & 3)]! + const ink = a && b ? inside(a, x, y) * (1 - e) + inside(b, x, y) * e : 0 + if (threshold < ink) { + tone = T_INK + } + else { + const letters = a && b ? letterField(a, x, y) * (1 - e) + letterField(b, x, y) * e : 0 + const base = a && b ? dotField(a, x, y) * (1 - e) + dotField(b, x, y) * e : 0 + const ring = rose ? ripple(Math.hypot(ox + x * CELL_W + CELL_W / 2 - rx, oy + y * LINE_H + LINE_H / 2 - ry), age, RING_SPEED) : 0 + // The ripple lifts every cell it crosses: dots light up, braille near the band reads as letters. + const q = Math.min(3, Math.floor(Math.min(1, letters + ring * 0.42) * 3 + threshold)) + if (q === 3) { + tone = T_MUTED + } + else if (q === 2) { + tone = T_FAINT + } + else { + const full = BRAILLE[ch.toLowerCase()] ?? 0 + let mask = full + if (q === 0) { + // Sparse noise far out, a 4 Hz staggered shimmer, denser where the base field or a ring is. + const p = base * 0.5 + ring * 0.9 + 0.03 + const step = Math.floor(t * 4 + hash(x, y, 99) * 4) + mask = 0 + for (let i = 0; i < 6; i++) { + if ((full >> i) & 1 && hash(x * 2 + (i >= 3 ? 1 : 0), y * 3 + (i % 3), step) < p) + mask |= 1 << i + } + } + if (mask) { + ch = String.fromCharCode(0x2800 + mask) + tone = q === 1 ? 2 : Math.min(3, Math.floor((base * 0.6 + ring) * 3.2)) + } + else { + ch = ' ' + } + } + } + } + chars[k] = ch + tones[k] = tone + } + } + } + + return { + layout, + restTime: REST, + startTime: MOVE / 2 + 0.9, + draw(ctx, t, colors) { + if (!rows) + return + compose(timeline(t), t) + ctx.clearRect(0, 0, w, h) + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + const fills: Record = { [T_FAINT]: colors.faint, [T_MUTED]: colors.muted, [T_INK]: colors.ink } + // Letters, one fill per tone, each centred in its own fixed cell. + for (const tone of [T_FAINT, T_MUTED, T_INK]) { + ctx.font = `${tone === T_INK ? 600 : 400} ${FONT_SIZE}px ${colors.mono}` + ctx.fillStyle = fills[tone]! + for (let k = 0; k < tones.length; k++) { + if (tones[k] === tone) + ctx.fillText(chars[k]!, ox + (k % cols) * CELL_W + CELL_W / 2, oy + Math.floor(k / cols) * LINE_H + LINE_H / 2 + 0.5) + } + } + // Braille, decoded from each cell's code point: two columns of three dots, centred on the line. + ctx.fillStyle = colors.ink + const r = 1.05 + for (let bucket = 0; bucket < 4; bucket++) { + ctx.globalAlpha = DOT_ALPHA[bucket]! + ctx.beginPath() + for (let k = 0; k < tones.length; k++) { + if (tones[k] !== bucket) + continue + const mask = chars[k]!.charCodeAt(0) - 0x2800 + const cx = ox + (k % cols) * CELL_W + const cy = oy + Math.floor(k / cols) * LINE_H + LINE_H / 2 + for (let i = 0; i < 6; i++) { + if (!((mask >> i) & 1)) + continue + const x = cx + CELL_W * (i >= 3 ? 0.75 : 0.25) + const y = cy + ((i % 3) - 1) * 3.4 + ctx.moveTo(x + r, y) + ctx.arc(x, y, r, 0, Math.PI * 2) + } + } + ctx.fill() + } + ctx.globalAlpha = 1 + const k = tones.indexOf(T_ROSE) + if (k >= 0) { + ctx.fillStyle = colors.dot + ctx.beginPath() + ctx.arc(ox + (k % cols) * CELL_W + CELL_W / 2, oy + Math.floor(k / cols) * LINE_H + LINE_H / 2, 2.4, 0, Math.PI * 2) + ctx.fill() + } + }, + } +} diff --git a/app/utils/textures/converge.ts b/app/utils/textures/converge.ts new file mode 100644 index 00000000..2846d8dc --- /dev/null +++ b/app/utils/textures/converge.ts @@ -0,0 +1,68 @@ +import type { TextureScene } from '~/composables/useTextureCanvas' +import { fillDots, hash } from './dots' + +/** + * "Converge": scattered dots fall into one line that ends on a rose dot. Many + * Skills in, one out. + * + * As a divider the dots hold their place and drift a little. As a loading + * indicator they stream left to right into the line, so the motion reads as + * progress. + */ + +export interface ConvergeOptions { + /** Where the line ends, as a share of the width. The rose dot sits just past it. */ + end: number + /** Dots per column. */ + perColumn: number + /** Radius of the rose dot. */ + dot: number + /** Stream the dots toward the rose dot. */ + flow: boolean +} + +const ALPHA = [0.16, 0.28, 0.42, 0.6, 0.8] +const FLOW_SPEED = 0.16 + +export function createConvergeScene(options: ConvergeOptions): TextureScene { + let w = 0 + let h = 0 + let columns = 0 + + return { + restTime: 3, + layout(width, height) { + w = width + h = height + columns = Math.max(20, Math.floor((w * options.end - 14) / 7)) + }, + draw(ctx, t, colors) { + ctx.clearRect(0, 0, w, h) + const cy = h / 2 + const end = w * options.end + const buckets: number[][] = [[], [], [], [], []] + for (let c = 0; c < columns; c++) { + for (let i = 0; i < options.perColumn; i++) { + const start = c / (columns - 1) + // A streaming dot keeps its own lane and phase, and wraps back to the left. + const u = options.flow ? (start + t * FLOW_SPEED * (0.8 + hash(c, i, 13) * 0.4)) % 1 : start + const x = 14 + u * (end - 24) + const spread = (1 - u) ** 1.5 * h * 0.42 + const base = hash(c, i, 7) * 2 - 1 + const jitter = 0.16 * Math.sin(t * (0.6 + hash(c, i, 3)) + hash(c, i, 5) * 6.283) + buckets[Math.min(4, Math.floor(u * 5))]!.push(x, cy + (base + jitter * (1 - u)) * spread) + } + } + ctx.fillStyle = colors.ink + for (const [k, alpha] of ALPHA.entries()) { + ctx.globalAlpha = alpha + fillDots(ctx, buckets[k]!, 1.15 + k * 0.12) + } + ctx.globalAlpha = 1 + ctx.fillStyle = colors.dot + ctx.beginPath() + ctx.arc(end + 10, cy, options.dot, 0, Math.PI * 2) + ctx.fill() + }, + } +} diff --git a/app/utils/textures/dots.ts b/app/utils/textures/dots.ts new file mode 100644 index 00000000..5d223ce1 --- /dev/null +++ b/app/utils/textures/dots.ts @@ -0,0 +1,41 @@ +/** + * Drawing helpers the brand textures share. Every texture is a field of stone + * dots with one rose dot, so they share the noise, the ripple, and the batch + * fill. + */ + +/** A stable pseudo-random number in [0, 1] for three integers. */ +export function hash(x: number, y: number, seed: number): number { + let n = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0 + n = Math.imul(n ^ (n >>> 13), 1274126177) + return ((n ^ (n >>> 16)) >>> 0) / 4294967295 +} + +/** + * Brightness of three rings, half a second apart, at distance `d` from their + * origin `age` seconds after they left it. The CLI noise field uses the same + * model: a Gaussian ring front with exponential decay. + */ +export function ripple(d: number, age: number, speed: number): number { + let brightness = 0 + for (let ring = 0; ring < 3; ring++) { + const ringAge = age - ring * 0.5 + if (ringAge < 0) + continue + const front = (d - ringAge * speed) / 16 + brightness += Math.exp(-front * front) * Math.exp(-ringAge * 0.45) + } + return brightness +} + +/** Fill every dot in a flat `[x, y, x, y, ...]` list as one path. */ +export function fillDots(ctx: CanvasRenderingContext2D, points: readonly number[], radius: number): void { + if (!points.length) + return + ctx.beginPath() + for (let k = 0; k < points.length; k += 2) { + ctx.moveTo(points[k]! + radius, points[k + 1]!) + ctx.arc(points[k]!, points[k + 1]!, radius, 0, Math.PI * 2) + } + ctx.fill() +} diff --git a/app/utils/textures/file-minimap.ts b/app/utils/textures/file-minimap.ts new file mode 100644 index 00000000..6742e127 --- /dev/null +++ b/app/utils/textures/file-minimap.ts @@ -0,0 +1,339 @@ +import type { TextureScene } from '~/composables/useTextureCanvas' +import { fillDots, hash } from './dots' + +/** + * "File minimap": SKILL.md files drawn as editor minimap rows of dots, one + * column per file. A rose cursor sits on the changed line of one file, erases + * it back to its first word, and types the new line. + * + * With real sources, those columns draw the real files. The rest, and every + * column without a source, draw a generated file with the shape of a SKILL.md: + * frontmatter, a title, an intro, then sections of prose, lists, or code. + */ + +// Geometry in CSS px. One dot is one character cell. +const PITCH = 3.2 +const ROW_PITCH = 6.4 +const LINE_CELLS = 20 +const COLUMN_CELLS = 25 +/** A real line folds four characters into each dot, so 80 columns fill one minimap line. */ +const CHARS_PER_CELL = 4 +const ALPHA = [0.15, 0.3, 0.46, 0.64, 0.85] +const RADIUS = [0.85, 0.85, 0.9, 0.95, 1.1] +const CYCLE = 7 + +/** Runs of dots as flat triples: start cell, length, level 0 to 4. */ +interface Row { kind: 'prose' | 'other', runs: number[] } +type Rows = (Row | null)[] + +interface Column { x: number, offset: number, rows: Rows, speed: number, phase: number } +interface Focal { column: number, row: number, prefix: number, keep: number } + +function words(out: number[], seed: number, from: number, to: number, level: number, minLen = 2, spread = 6): number[] { + let x = from + for (let i = 0; i < 40; i++) { + const len = minLen + Math.floor(hash(seed, i, 11) * spread) + if (x + len > to) + break + out.push(x, len, level) + x += len + 1 + } + return out +} + +const lineEnd = (row: Row | null | undefined) => row?.runs.length ? row.runs[row.runs.length - 3]! + row.runs[row.runs.length - 2]! : 0 + +function generatedFile(seed: number, rows: Rows): void { + let n = 0 + const rnd = () => hash(seed, n++, 23) + const ri = (k: number) => Math.floor(rnd() * k) + const line = (runs: number[], kind: Row['kind'] = 'other') => rows.push({ kind, runs }) + const gap = () => rows.push(null) + const ws = (from: number, to: number, level: number, out: number[] = [], minLen?: number, spread?: number) => words(out, ri(1e9), from, to, level, minLen, spread) + const para = (count: number) => { + for (let j = 0; j < count; j++) + line(ws(0, j === count - 1 ? 6 + ri(11) : LINE_CELLS - ri(3), 1), 'prose') + } + line([0, 3, 0]) + line(ws(6, 9 + ri(7), 1, [0, 4, 2])) + line(ws(9, LINE_CELLS, 1, [0, 7, 2])) + if (rnd() < 0.6) + line(ws(2, LINE_CELLS - ri(5), 1)) + line([0, 3, 0]) + gap() + line(ws(2, 8 + ri(10), 4, [0, 1, 4])) + gap() + para(2 + ri(2)) + const sections = 2 + ri(3) + for (let i = 0; i < sections; i++) { + gap() + line(ws(3, 8 + ri(9), 3, [0, 2, 3])) + gap() + const kind = rnd() + if (kind < 0.36) { + para(2 + ri(3)) + } + else if (kind < 0.72) { + const items = 2 + ri(3) + for (let j = 0; j < items; j++) { + line(ws(2, LINE_CELLS - ri(7), 1, [0, 1, 3])) + if (rnd() < 0.3) + line(ws(2, 7 + ri(10), 1)) + } + } + else { + line([0, 3, 0]) + const count = 2 + ri(4) + for (let j = 0; j < count; j++) + line(ws(ri(3) * 2, LINE_CELLS - ri(6), 0, [], 1, 5)) + line([0, 3, 0]) + } + } + gap() + gap() + gap() +} + +/** Cells of one line of text: a run per stretch of characters, at one level. */ +function textRuns(text: string, from: number, level: number, out: number[]): number[] { + let start = -1 + const cells = Math.min(LINE_CELLS - from, Math.ceil(text.length / CHARS_PER_CELL)) + for (let cell = 0; cell <= cells; cell++) { + const chunk = text.slice(cell * CHARS_PER_CELL, (cell + 1) * CHARS_PER_CELL) + const filled = cell < cells && chunk.trim().length > 0 + if (filled && start < 0) + start = cell + if (!filled && start >= 0) { + out.push(from + start, cell - start, level) + start = -1 + } + } + return out +} + +/** One real SKILL.md as minimap rows. Long lines fold at the column edge, as an editor wraps them. */ +function sourceFile(source: string, rows: Rows): void { + let frontmatter = false + let code = false + for (const [index, raw] of source.replace(/\r\n?/g, '\n').split('\n').entries()) { + const text = raw.replace(/\t/g, ' ').trimEnd() + if (!text) { + rows.push(null) + continue + } + if ((index === 0 && text === '---') || (frontmatter && text === '---')) { + frontmatter = index === 0 + rows.push({ kind: 'other', runs: [0, 3, 0] }) + continue + } + if (text.startsWith('```')) { + code = !code + rows.push({ kind: 'other', runs: [0, 3, 0] }) + continue + } + const indent = Math.min(6, Math.floor((text.length - text.trimStart().length) / CHARS_PER_CELL)) + const body = text.trimStart() + let level = 1 + let kind: Row['kind'] = 'other' + let marker: number[] = [] + let rest = body + if (code) { + level = 0 + } + else if (frontmatter) { + const colon = body.indexOf(':') + if (colon > 0) { + marker = textRuns(body.slice(0, colon + 1), indent, 2, []) + rest = body.slice(colon + 1) + } + } + else if (/^#\s/.test(body)) { + level = 4 + } + else if (/^#{2,6}\s/.test(body)) { + level = 3 + } + else if (/^(?:[-*+]|\d+\.)\s/.test(body)) { + marker = [indent, 1, 3] + rest = body.replace(/^(?:[-*+]|\d+\.)\s+/, '') + } + else { + kind = 'prose' + } + const markerEnd = marker.length ? marker[marker.length - 3]! + marker[marker.length - 2]! + 1 : indent + const width = (LINE_CELLS - markerEnd) * CHARS_PER_CELL + // Fold at most three rows, so one long paragraph cannot fill the column. + for (let fold = 0; fold < 3 && rest.trim(); fold++) { + const part = rest.slice(0, width) + rest = rest.slice(width) + rows.push({ kind, runs: textRuns(part, markerEnd, level, fold === 0 ? [...marker] : []) }) + } + } + rows.push(null, null, null) +} + +export function createFileMinimapScene(sources: readonly string[] = []): TextureScene { + let w = 0 + let h = 0 + let rowCount = 0 + let columns: Column[] = [] + let focal: Focal | null = null + const cache = new Map() + + function layout(width: number, height: number) { + w = width + h = height + const x0 = -PITCH * 7 + rowCount = Math.ceil(h / ROW_PITCH) + 1 + columns = [] + for (let c = 0; x0 + c * COLUMN_CELLS * PITCH < w; c++) { + // Real files start at their top, so the frontmatter stays in view. + const source = sources.length ? sources[(c + sources.length - 1) % sources.length] : undefined + const offset = source ? 0 : Math.floor(hash(c, 0, 41) * 30) + const rows: Rows = [] + if (source) + sourceFile(source, rows) + for (let f = 0; rows.length < offset + rowCount; f++) + generatedFile(Math.floor(hash(c, f, 43) * 1e9), rows) + columns.push({ x: x0 + c * COLUMN_CELLS * PITCH, offset, rows, speed: 9 + hash(c, 1, 5) * 9, phase: hash(c, 2, 5) }) + } + // The cursor column is the whole column nearest 40% across. + let fc = -1 + let best = Infinity + columns.forEach((column, i) => { + if (column.x < 0 || column.x + LINE_CELLS * PITCH > w) + return + const d = Math.abs(column.x + LINE_CELLS * PITCH / 2 - w * 0.4) + if (d < best) { + best = d + fc = i + } + }) + if (fc < 0) + fc = Math.min(1, columns.length - 1) + const column = columns[fc]! + const mid = Math.max(1, Math.round(h * 0.52 / ROW_PITCH - 0.5)) + let row = -1 + for (let d = 0; d < rowCount && row < 0; d++) { + for (const r of [mid + d, mid - d]) { + if (r < 1 || r >= rowCount - 1) + continue + const line = column.rows[column.offset + r] + if (line && line.kind === 'prose' && lineEnd(line) >= 12) { + row = r + break + } + } + } + if (row < 0) + row = mid + const line = column.rows[column.offset + row] + const prefix = line && line.runs.length && line.runs[0] === 0 ? Math.min(7, line.runs[1]!) : 4 + focal = { column: fc, row, prefix, keep: prefix + 1 } + cache.clear() + } + + // The changed line after edit n, as levels per cell (0 is empty). The first word survives every edit. + function content(n: number): Uint8Array { + const hit = cache.get(n) + if (hit) + return hit + const to = LINE_CELLS - Math.floor(hash(n, 5, 89) * 4) + const runs = words([0, focal!.prefix, 1], Math.floor(hash(n, focal!.column, 83) * 1e9), focal!.keep, to, 2) + const end = runs[runs.length - 3]! + runs[runs.length - 2]! + if (to - end >= 3) + runs.push(end + 1, to - end - 1, 2) + const cells = new Uint8Array(runs[runs.length - 3]! + runs[runs.length - 2]!) + for (let i = 0; i < runs.length; i += 3) + cells.fill(runs[i + 2]! + 1, runs[i]!, runs[i]! + runs[i + 1]!) + if (cache.size > 8) + cache.clear() + cache.set(n, cells) + return cells + } + + // Rest on the old line, erase to the first word, pause, type the new line. + function edit(t: number, still: boolean): { cells: Uint8Array, k: number } { + if (still) { + const cells = content(0) + return { cells, k: cells.length } + } + const n = Math.floor(t / CYCLE) + const u = t - n * CYCLE + const before = content(n - 1) + const after = content(n) + const keep = focal!.keep + const t1 = 2.2 + const t2 = t1 + Math.max(0, before.length - keep) / 18 + const t3 = t2 + 0.4 + if (u < t1) + return { cells: before, k: before.length } + if (u < t2) + return { cells: before, k: Math.max(keep, before.length - Math.floor((u - t1) * 18)) } + let k = keep + let at = t3 + while (k < after.length) { + at += (after[k] ? 0.07 : 0.16) * (0.6 + hash(n, k, 71) * 0.8) + if (at > u) + break + k++ + } + return { cells: after, k } + } + + return { + layout, + restTime: 0, + startTime: 1, + // A still frame has no reading position and shows the changed line at rest. + draw(ctx, t, colors, still) { + if (!w || !focal) + return + ctx.clearRect(0, 0, w, h) + const buckets: number[][] = [[], [], [], [], []] + columns.forEach((column, ci) => { + // A reading position walks down some files, and its row darkens as it passes. + let head = -1e9 + if (!still) { + const span = h + 8 * ROW_PITCH + const pos = t * column.speed + column.phase * span + if (hash(ci, Math.floor(pos / span), 61) < 0.6) + head = (pos % span) - 4 * ROW_PITCH + } + for (let r = 0; r < rowCount; r++) { + if (ci === focal!.column && r === focal!.row) + continue + const line = column.rows[column.offset + r] + if (!line) + continue + const y = (r + 0.5) * ROW_PITCH + const d = Math.abs(y - head) + const boost = d < ROW_PITCH * 0.6 ? 2 : d < ROW_PITCH * 1.6 ? 1 : 0 + const runs = line.runs + for (let i = 0; i < runs.length; i += 3) { + const bucket = buckets[Math.min(4, runs[i + 2]! + boost)]! + for (let k = 0; k < runs[i + 1]!; k++) + bucket.push(column.x + (runs[i]! + k + 0.5) * PITCH, y) + } + } + }) + const column = columns[focal.column]! + const fy = (focal.row + 0.5) * ROW_PITCH + const { cells, k } = edit(t, still) + for (let i = 0; i < Math.min(k, cells.length); i++) { + if (cells[i]) + buckets[cells[i]! - 1]!.push(column.x + (i + 0.5) * PITCH, fy) + } + ctx.fillStyle = colors.ink + for (let level = 0; level < 5; level++) { + ctx.globalAlpha = ALPHA[level]! + fillDots(ctx, buckets[level]!, RADIUS[level]!) + } + ctx.globalAlpha = 1 + ctx.fillStyle = colors.dot + ctx.beginPath() + ctx.arc(column.x + (k + 1) * PITCH, fy, 1.8, 0, Math.PI * 2) + ctx.fill() + }, + } +} diff --git a/nuxt.config.ts b/nuxt.config.ts index 5ceedb71..6893b232 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -811,7 +811,7 @@ export default defineNuxtConfig({ pages: { includeAppSources: true, // An /agents page waits on a CLI release; it answers 404 until then. - exclude: ['/skills/**', '/gh/**', '/people/**', '/make-skill', '/@**', '/admin/**', '/me/**', '/login', '/onboarding/**', '/collections/new', '/cli/**', '/brand-kit/_**', ...unpublishedAgentPaths(), ...frozenNoindexPaths([...discoveredAgentRoutes(), ...discoveredComparisonRoutes()])], + exclude: ['/skills/**', '/gh/**', '/people/**', '/make-skill', '/@**', '/admin/**', '/me/**', '/login', '/onboarding/**', '/collections/new', '/cli/**', '/brand-kit', '/brand-kit/_**', ...unpublishedAgentPaths(), ...frozenNoindexPaths([...discoveredAgentRoutes(), ...discoveredComparisonRoutes()])], }, skills: { sources: ['/api/__sitemap__/skills'], diff --git a/shared/braille-spark.ts b/shared/braille-spark.ts new file mode 100644 index 00000000..6275d946 --- /dev/null +++ b/shared/braille-spark.ts @@ -0,0 +1,49 @@ +/** + * Seven days of counts as seven braille bars. + * + * The spark is plain text, so the same string works on the site, in the + * weekly email, in Discord, and in a terminal. This module imports nothing, + * so an email renderer or a script can load it without the app. + * + * Each bar is one braille cell that fills from the bottom: `⣀⣤⣶⣿`. A day with + * no count is the blank braille cell U+2800, which keeps the column width of + * the other bars where a space would collapse. + */ + +/** The number of days in one spark. */ +export const SPARK_DAYS = 7 + +/** Bar glyphs by level. Level 0 is the blank braille cell. */ +export const SPARK_BARS = ['⠀', '⣀', '⣤', '⣶', '⣿'] as const + +const TOP_LEVEL = SPARK_BARS.length - 1 + +/** + * Exactly seven whole counts, oldest first, today last. + * + * Shorter input pads the older days with zero. Longer input keeps the newest + * seven. A negative, fractional, or non-finite count becomes a whole count of + * zero or more, because a count of mentions cannot be anything else. + */ +export function sparkWeek(counts: readonly number[]): number[] { + const whole = counts.slice(-SPARK_DAYS).map(count => Number.isFinite(count) && count > 0 ? Math.floor(count) : 0) + return [...Array.from({ length: SPARK_DAYS - whole.length }).fill(0), ...whole] +} + +/** + * The bar level for each day, from 0 (blank) to 4 (full). + * + * Levels scale to the busiest day of the same week, so a spark shows the shape + * of one week and never compares two Skills. Any day with a count gets at + * least level 1, so a quiet day never reads as a silent one. + */ +export function sparkLevels(counts: readonly number[]): number[] { + const week = sparkWeek(counts) + const max = Math.max(...week) + return week.map(count => count === 0 ? 0 : Math.max(1, Math.round(count / max * TOP_LEVEL))) +} + +/** The seven bars as one string of seven characters, oldest first. */ +export function brailleSpark(counts: readonly number[]): string { + return sparkLevels(counts).map(level => SPARK_BARS[level]).join('') +} diff --git a/shared/change-grid.ts b/shared/change-grid.ts new file mode 100644 index 00000000..acd7f6b1 --- /dev/null +++ b/shared/change-grid.ts @@ -0,0 +1,173 @@ +/** + * Thirteen weeks of Skill changes as a day grid, with the version bump of + * each change when skilld knows it. + * + * The grid rolls: today is the bottom right cell, and each column above and + * to the left holds the seven days before it. Rows are not weekdays. + * + * A bump comes only from two declared versions side by side. Few SKILL.md + * files declare a version, so most changes carry no bump, and the grid draws + * them as plain dots. This module never guesses one. + */ + +export const GRID_WEEKS = 13 +export const GRID_DAYS = 7 +export const GRID_CELLS = GRID_WEEKS * GRID_DAYS + +const DAY_SECONDS = 86_400 + +export type Bump = 'first' | 'major' | 'minor' | 'patch' + +/** A known bump draws rings; an unknown one draws a plain dot. */ +export type ChangeMark + = | { _tag: 'plain' } + | { _tag: 'bump', bump: Bump } + +/** One recorded change to a Skill. */ +export interface SkillChange { + /** Unix seconds. */ + at: number + /** The version the SKILL.md declared after this change, if it declared one. */ + version?: string | null + /** What changed, in one line. */ + note?: string | null +} + +export interface ChangeHistory { + changes: readonly SkillChange[] + /** + * True when `changes` starts at the first version of the Skill. Only then + * can the oldest change be a first version. Without it, the oldest change + * has nothing to compare against and gets a plain dot. + */ + fromFirst?: boolean +} + +export interface DatedChange { + at: number + /** Whole UTC days before `now`. Zero is today. */ + daysAgo: number + version: string | null + note: string | null + mark: ChangeMark +} + +export interface ChangeCell { + daysAgo: number + /** 0 is the oldest week, 12 is this week. */ + column: number + /** 0 is the top row, 6 is the bottom row. */ + row: number + /** Every change on this day, newest first. */ + changes: DatedChange[] + /** The strongest mark of the day. */ + mark: ChangeMark + /** True for the cell that holds the newest change. */ + newest: boolean +} + +const PLAIN: ChangeMark = { _tag: 'plain' } + +/** Where a day sits in the grid, or null when it falls outside the 13 weeks. */ +export function gridCell(daysAgo: number): { column: number, row: number } | null { + if (!Number.isInteger(daysAgo) || daysAgo < 0 || daysAgo >= GRID_CELLS) + return null + const index = GRID_CELLS - 1 - daysAgo + return { column: Math.floor(index / GRID_DAYS), row: index % GRID_DAYS } +} + +type Semver = readonly [number, number, number] + +/** `1`, `1.2`, `1.2.3`, with an optional `v` and any prerelease or build suffix. */ +function parseVersion(version: string | null | undefined): Semver | null { + const match = version?.trim().match(/^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[-+].*)?$/) + if (!match) + return null + return [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)] +} + +/** + * The bump from one declared version to the next. + * + * Null when either version is missing or unreadable, when the version did not + * change, or when it went down. None of those says how big the change was. + */ +export function bumpBetween(previous: string | null | undefined, next: string | null | undefined): Exclude | null { + const a = parseVersion(previous) + const b = parseVersion(next) + if (!a || !b) + return null + for (const [index, bump] of (['major', 'minor', 'patch'] as const).entries()) { + if (b[index]! > a[index]!) + return bump + if (b[index]! < a[index]!) + return null + } + return null +} + +/** Every change, newest first, with its age and its mark. */ +export function datedChanges(history: ChangeHistory, now: number): DatedChange[] { + const today = Math.floor(now / DAY_SECONDS) + const oldestFirst = [...history.changes].sort((a, b) => a.at - b.at) + return oldestFirst + .map((change, index): DatedChange => { + const version = change.version?.trim() || null + const previous = oldestFirst[index - 1] + let mark = PLAIN + if (previous) { + const bump = bumpBetween(previous.version, version) + if (bump) + mark = { _tag: 'bump', bump } + } + else if (history.fromFirst && parseVersion(version)) { + mark = { _tag: 'bump', bump: 'first' } + } + return { + at: change.at, + // A clock a little ahead of ours still means today. + daysAgo: Math.max(0, today - Math.floor(change.at / DAY_SECONDS)), + version, + note: change.note?.trim() || null, + mark, + } + }) + .reverse() +} + +const STRENGTH: Record = { patch: 1, minor: 2, major: 3, first: 4 } + +function strength(mark: ChangeMark): number { + return mark._tag === 'bump' ? STRENGTH[mark.bump] : 0 +} + +/** + * The change days inside the 13 weeks, newest first. + * + * Changes on the same day share one cell, and the cell shows the strongest + * mark among them. Changes older than the grid still count as the previous + * version of the first change inside it. + */ +export function changeGrid(history: ChangeHistory, now: number): ChangeCell[] { + const cells = new Map() + for (const change of datedChanges(history, now)) { + const position = gridCell(change.daysAgo) + if (!position) + continue + const cell = cells.get(change.daysAgo) + if (cell) { + cell.changes.push(change) + if (strength(change.mark) > strength(cell.mark)) + cell.mark = change.mark + continue + } + cells.set(change.daysAgo, { + daysAgo: change.daysAgo, + ...position, + changes: [change], + mark: change.mark, + newest: cells.size === 0, + }) + } + return [...cells.values()] +} diff --git a/test/a11y.spec.ts b/test/a11y.spec.ts index 097f0356..c4994b81 100644 --- a/test/a11y.spec.ts +++ b/test/a11y.spec.ts @@ -452,6 +452,116 @@ describe('accessibility: components', () => { expect(results.violations, formatViolations(results)).toHaveLength(0) wrapper.unmount() }) + + it('brailleSpark has no violations and names every count for a screen reader', async () => { + const container = createIsolatedContainer() + const wrapper = await mountSuspended( + await loadComponent('BrailleSpark'), + { attachTo: container, props: { counts: [3, 5, 4, 8, 12, 19, 31] } }, + ) + const results = await runAxe(container) + expect(results.violations, formatViolations(results)).toHaveLength(0) + expect(container.querySelector('[role="img"]')?.getAttribute('aria-label')).toContain('3, 5, 4, 8, 12, 19, 31') + wrapper.unmount() + }) + + it('commandChip has no violations and keeps the command copyable as one string', async () => { + const container = createIsolatedContainer() + const command = 'npx skilld run mattpocock/skills/tdd' + const wrapper = await mountSuspended( + await loadComponent('_CommandChip'), + { + attachTo: container, + props: { command, mode: 'run', surface: 'test', target: { kind: 'skill', owner: 'mattpocock', name: 'tdd' } }, + }, + ) + const results = await runAxe(container) + expect(results.violations, formatViolations(results)).toHaveLength(0) + expect(container.querySelector('code')?.textContent).toBe(command) + wrapper.unmount() + }) + + it('runChip has no violations and its switch swaps the command and the consequence', async () => { + const container = createIsolatedContainer() + const wrapper = await mountSuspended( + await loadComponent('RunChip'), + { attachTo: container, props: { owner: 'mattpocock', repo: 'skills', skill: 'tdd', surface: 'test' } }, + ) + const results = await runAxe(container) + expect(results.violations, formatViolations(results)).toHaveLength(0) + expect(container.querySelector('code')?.textContent).toBe('npx skilld run mattpocock/skills/tdd') + + await wrapper.get('button[aria-pressed="false"]').trigger('click') + await nextTick() + + expect(container.querySelector('code')?.textContent).toBe('npx skilld install mattpocock/skills/tdd') + expect(container.textContent).toContain('Adds the Skill files and a lockfile entry.') + wrapper.unmount() + }) + + it('runChip compact has no violations', async () => { + const container = createIsolatedContainer() + const wrapper = await mountSuspended( + await loadComponent('RunChip'), + { attachTo: container, props: { owner: 'mattpocock', repo: 'skills', skill: 'tdd', surface: 'test', variant: 'compact' } }, + ) + const results = await runAxe(container) + expect(results.violations, formatViolations(results)).toHaveLength(0) + wrapper.unmount() + }) + + it('skilldInstallChip has no violations', async () => { + const container = createIsolatedContainer() + const wrapper = await mountSuspended( + await loadComponent('SkilldInstallChip'), + { attachTo: container, props: { surface: 'test' } }, + ) + const results = await runAxe(container) + expect(results.violations, formatViolations(results)).toHaveLength(0) + expect(container.querySelector('code')?.textContent).toBe('npx skilld install skilld --global') + wrapper.unmount() + }) + + it('changeGrid has no violations and steps to an older change from the keyboard', async () => { + const container = createIsolatedContainer() + const day = 86_400 + const now = 20_000 * day + const wrapper = await mountSuspended( + await loadComponent('ChangeGrid'), + { + attachTo: container, + props: { + name: 'tdd', + now, + fromFirst: true, + changes: [ + { at: now - 2 * day, version: '2.1.0', note: 'Added a refactor checklist' }, + { at: now - 33 * day, version: '2.0.0', note: 'Split the red and green steps' }, + ], + }, + }, + ) + const results = await runAxe(container) + expect(results.violations, formatViolations(results)).toHaveLength(0) + expect(container.querySelector('[aria-live]')?.textContent).toContain('Added a refactor checklist') + + await wrapper.get('button').trigger('keydown', { key: 'ArrowLeft' }) + await nextTick() + + expect(container.querySelector('[aria-live]')?.textContent).toContain('Split the red and green steps') + wrapper.unmount() + }) + + it('changeGrid with no changes has no violations', async () => { + const container = createIsolatedContainer() + const wrapper = await mountSuspended( + await loadComponent('ChangeGrid'), + { attachTo: container, props: { name: 'tdd', now: 20_000 * 86_400, changes: [] } }, + ) + const results = await runAxe(container) + expect(results.violations, formatViolations(results)).toHaveLength(0) + wrapper.unmount() + }) }) describe('accessibility: component coverage', () => { @@ -460,6 +570,9 @@ describe('accessibility: component coverage', () => { 'OutcomeClusterGrid', // Content section (fetches /api/clusters), tested at page level 'KeyboardShortcutsModal.client', // Client-only modal requires full app context 'NoiseField.client', // Decorative client-only canvas + 'TextureBrailleNames.client', // Decorative client-only canvas, hidden from screen readers + 'TextureConverge.client', // Client-only canvas; its loading state is one role="status" with a text label + 'TextureFileMinimap.client', // Decorative client-only canvas, hidden from screen readers 'OgBrand', // OG image component, rendered server-side only 'OgLayout', // OG image layout component, rendered server-side only 'SkillCard', // Tested at page level diff --git a/test/unit/braille-spark.test.ts b/test/unit/braille-spark.test.ts new file mode 100644 index 00000000..44273dfa --- /dev/null +++ b/test/unit/braille-spark.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { brailleSpark, sparkLevels, sparkWeek } from '../../shared/braille-spark' + +const BLANK = '⠀' + +describe('braille spark', () => { + it('scales seven counts to the busiest day of the week', () => { + expect(brailleSpark([3, 5, 4, 8, 12, 19, 31])).toBe('⣀⣀⣀⣀⣤⣤⣿') + }) + + it('draws a day with no count as the blank braille cell', () => { + expect(brailleSpark([1, 0, 2, 2, 6, 9, 7])).toBe(`⣀${BLANK}⣀⣀⣶⣿⣶`) + }) + + it('gives a small count at least the lowest bar', () => { + expect(sparkLevels([1, 1000, 0, 0, 0, 0, 0])).toEqual([1, 4, 0, 0, 0, 0, 0]) + }) + + it('draws a silent week as seven blank cells', () => { + expect(brailleSpark([0, 0, 0, 0, 0, 0, 0])).toBe(BLANK.repeat(7)) + }) + + it('fills every bar when every day is equal', () => { + expect(brailleSpark([4, 4, 4, 4, 4, 4, 4])).toBe('⣿⣿⣿⣿⣿⣿⣿') + }) + + it.each([ + { name: 'pads missing older days with zero', input: [2, 4], week: [0, 0, 0, 0, 0, 2, 4] }, + { name: 'keeps the newest seven of a longer series', input: [9, 9, 1, 2, 3, 4, 5, 6, 7], week: [1, 2, 3, 4, 5, 6, 7] }, + { name: 'turns bad counts into whole counts of zero or more', input: [-3, Number.NaN, Infinity, 2.9, 0, 1, 5], week: [0, 0, 0, 2, 0, 1, 5] }, + { name: 'reads an empty series as a silent week', input: [], week: [0, 0, 0, 0, 0, 0, 0] }, + ])('$name', ({ input, week }) => { + expect(sparkWeek(input)).toEqual(week) + }) + + it('always returns seven characters, so rows of sparks stay aligned', () => { + expect([...brailleSpark([5])]).toHaveLength(7) + }) +}) diff --git a/test/unit/change-grid.test.ts b/test/unit/change-grid.test.ts new file mode 100644 index 00000000..9f23e358 --- /dev/null +++ b/test/unit/change-grid.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' +import { bumpBetween, changeGrid, datedChanges, gridCell } from '../../shared/change-grid' + +const DAY = 86_400 +// Noon UTC, so a change at any hour of a day stays on that day. +const NOW = 20_000 * DAY + 12 * 3600 +const daysAgo = (days: number, hour = 9) => (20_000 - days) * DAY + hour * 3600 + +describe('change grid layout', () => { + it.each([ + { days: 0, cell: { column: 12, row: 6 } }, + { days: 6, cell: { column: 12, row: 0 } }, + { days: 7, cell: { column: 11, row: 6 } }, + { days: 90, cell: { column: 0, row: 0 } }, + ])('puts $days days ago at column $cell.column, row $cell.row', ({ days, cell }) => { + expect(gridCell(days)).toEqual(cell) + }) + + it.each([-1, 91, 1.5])('has no cell for day %d', (days) => { + expect(gridCell(days)).toBeNull() + }) + + it('counts whole UTC days, so a change late yesterday is one day ago', () => { + const [change] = datedChanges({ changes: [{ at: daysAgo(1, 23) }] }, NOW) + expect(change?.daysAgo).toBe(1) + }) + + it('treats a change stamped a little in the future as today', () => { + const [change] = datedChanges({ changes: [{ at: NOW + 3600 }] }, NOW) + expect(change?.daysAgo).toBe(0) + }) +}) + +describe('change grid bumps', () => { + it.each([ + { previous: '1.0.0', next: '2.0.0', bump: 'major' }, + { previous: '2.0.0', next: '2.1.0', bump: 'minor' }, + { previous: '2.1.0', next: '2.1.1', bump: 'patch' }, + { previous: 'v1.2', next: '1.3', bump: 'minor' }, + { previous: '1.0.0-beta.1', next: '1.0.1', bump: 'patch' }, + ])('reads $previous to $next as $bump', ({ previous, next, bump }) => { + expect(bumpBetween(previous, next)).toBe(bump) + }) + + it.each([ + { name: 'a missing previous version', previous: null, next: '1.0.0' }, + { name: 'a missing next version', previous: '1.0.0', next: undefined }, + { name: 'an unreadable version', previous: '1.0.0', next: 'latest' }, + { name: 'an unchanged version', previous: '1.2.0', next: '1.2.0' }, + { name: 'a version that went down', previous: '2.0.0', next: '1.9.0' }, + ])('invents no bump for $name', ({ previous, next }) => { + expect(bumpBetween(previous, next)).toBeNull() + }) + + it('marks each change against the change before it, newest first', () => { + const changes = datedChanges({ + fromFirst: true, + changes: [ + { at: daysAgo(2), version: '2.1.0', note: 'Added a refactor checklist' }, + { at: daysAgo(61), version: '1.0.0', note: 'First version' }, + { at: daysAgo(33), version: '2.0.0', note: 'Split red and green steps' }, + ], + }, NOW) + expect(changes.map(change => [change.daysAgo, change.mark])).toEqual([ + [2, { _tag: 'bump', bump: 'minor' }], + [33, { _tag: 'bump', bump: 'major' }], + [61, { _tag: 'bump', bump: 'first' }], + ]) + }) + + it('gives the oldest change a plain dot unless the history starts at the first version', () => { + const [oldest] = datedChanges({ changes: [{ at: daysAgo(10), version: '1.0.0' }] }, NOW) + expect(oldest?.mark).toEqual({ _tag: 'plain' }) + }) + + it('gives a first change with no declared version a plain dot', () => { + const [oldest] = datedChanges({ fromFirst: true, changes: [{ at: daysAgo(10) }] }, NOW) + expect(oldest?.mark).toEqual({ _tag: 'plain' }) + }) + + it('compares only with the change right before, so a gap in versions stays plain', () => { + const changes = datedChanges({ + changes: [ + { at: daysAgo(30), version: '1.0.0' }, + { at: daysAgo(20) }, + { at: daysAgo(10), version: '1.1.0' }, + ], + }, NOW) + expect(changes.map(change => change.mark._tag)).toEqual(['plain', 'plain', 'plain']) + }) +}) + +describe('change grid cells', () => { + it('keeps only the 13 weeks and marks the newest change day', () => { + const cells = changeGrid({ + changes: [ + { at: daysAgo(120), version: '1.0.0' }, + { at: daysAgo(40), version: '1.1.0' }, + { at: daysAgo(5), version: '1.1.1' }, + ], + }, NOW) + expect(cells.map(cell => ({ daysAgo: cell.daysAgo, newest: cell.newest, mark: cell.mark }))).toEqual([ + { daysAgo: 5, newest: true, mark: { _tag: 'bump', bump: 'patch' } }, + // The change before the grid still supplies the previous version. + { daysAgo: 40, newest: false, mark: { _tag: 'bump', bump: 'minor' } }, + ]) + }) + + it('puts changes on the same day in one cell with the strongest mark', () => { + const [cell] = changeGrid({ + changes: [ + { at: daysAgo(3, 8), version: '1.0.0' }, + { at: daysAgo(3, 9), version: '2.0.0', note: 'Rewrote the steps' }, + { at: daysAgo(3, 10), version: '2.0.1', note: 'Fixed a typo' }, + ], + }, NOW) + expect(cell?.mark).toEqual({ _tag: 'bump', bump: 'major' }) + expect(cell?.changes.map(change => change.note)).toEqual(['Fixed a typo', 'Rewrote the steps', null]) + }) + + it('returns no cells for a Skill with no changes', () => { + expect(changeGrid({ changes: [] }, NOW)).toEqual([]) + }) +}) From 2c7ca86df6d31661609fc40c593a86a6b868b12c Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 6 Oct 2026 02:06:11 +1100 Subject: [PATCH 2/2] fix(brand): keep run chip segments whole and spark labels readable Screenshots of /brand-kit showed three defects: a wrapped run command broke after a hyphen inside the Skill name, the spark count lost the space before its unit, and the change note read as one run-on word to a screen reader. The spark bars also failed the axe contrast check, so they use the muted token now. --- app/assets/css/main.css | 7 +++++++ app/components/BrailleSpark.vue | 6 ++++-- app/components/ChangeGrid.vue | 5 +++-- app/components/InstallCommand.vue | 2 +- app/pages/brand-kit/index.vue | 13 +++++++------ 5 files changed, 22 insertions(+), 11 deletions(-) diff --git a/app/assets/css/main.css b/app/assets/css/main.css index 716469a0..4191323e 100644 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -164,6 +164,13 @@ font-weight: 600; } +/* Each segment is one unit, so a wrapped command breaks after a slash, never + after a hyphen inside a name. A segment wider than the line still wraps. */ +.install-command--split .install-command__target, +.install-command--split .install-command__name { + display: inline-block; +} + /* Lead emphasis — the one place a page may spend the accent on a whole surface. Light, not a bar: a masked corner wash plus a hairline that ignites at the leading top corner and dies inside a third of the width. Replaced a 2px rose diff --git a/app/components/BrailleSpark.vue b/app/components/BrailleSpark.vue index debc6aeb..31baf383 100644 --- a/app/components/BrailleSpark.vue +++ b/app/components/BrailleSpark.vue @@ -25,13 +25,14 @@ const { const week = computed(() => sparkWeek(counts)) const bars = computed(() => [...brailleSpark(counts)]) const shown = computed(() => total ?? week.value.reduce((sum, count) => sum + count, 0)) +const totalText = computed(() => showUnit ? `${shown.value.toLocaleString('en-US')} ${unit}` : shown.value.toLocaleString('en-US')) const label = computed(() => `${shown.value.toLocaleString('en-US')} ${unit}. Per day, oldest first: ${week.value.join(', ')}. Today: ${week.value.at(-1)}.`) @@ -53,7 +54,8 @@ const label = computed(() => `${shown.value.toLocaleString('en-US')} ${unit}. Pe font-size: 0.875rem; line-height: 1; letter-spacing: 0.04em; - color: var(--ui-text-dimmed); + /* Muted, not dimmed: the bars are a graphic and need 3:1 against the surface. */ + color: var(--ui-text-muted); } .braille-spark__today { diff --git a/app/components/ChangeGrid.vue b/app/components/ChangeGrid.vue index 8b13e870..66fdd7dc 100644 --- a/app/components/ChangeGrid.vue +++ b/app/components/ChangeGrid.vue @@ -207,8 +207,9 @@ const summary = computed(() => count.value === 0