diff --git a/app/assets/css/main.css b/app/assets/css/main.css index b08b796d..4191323e 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,19 @@ 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; +} + +/* 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 new file mode 100644 index 00000000..31baf383 --- /dev/null +++ b/app/components/BrailleSpark.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/app/components/ChangeGrid.vue b/app/components/ChangeGrid.vue new file mode 100644 index 00000000..66fdd7dc --- /dev/null +++ b/app/components/ChangeGrid.vue @@ -0,0 +1,320 @@ + + + + + diff --git a/app/components/InstallCommand.vue b/app/components/InstallCommand.vue index 7a9de2da..96a6f70e 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..cca4d1b0 --- /dev/null +++ b/app/pages/brand-kit/index.vue @@ -0,0 +1,388 @@ + + + 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([]) + }) +})