From a10fb2c689034b2a57454c7a04aec0361d5b9473 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:56:25 +0530 Subject: [PATCH 1/3] Welcome: animate a still image logo with generated frames (Assemble, Shine, Glow pulse, Glitch, Embers) --- src/app/TerminalApp.ts | 2 +- src/graphics/logoAnimation.ts | 502 +++++++++++++++++++++++++++++ src/welcome/compose.ts | 31 +- src/welcome/editor.ts | 22 +- src/welcome/model.ts | 6 + src/welcome/render.ts | 26 +- src/welcome/startup.ts | 8 +- src/welcome/studio.ts | 8 +- tests/welcomeEditor.test.ts | 2 +- tests/welcomeLogoAnimation.test.ts | 204 ++++++++++++ 10 files changed, 794 insertions(+), 17 deletions(-) create mode 100644 src/graphics/logoAnimation.ts create mode 100644 tests/welcomeLogoAnimation.test.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 027d7901..eb32265f 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -9141,7 +9141,7 @@ export class TerminalApp { private welcomeCapabilities() { return {color: colorLevel() !== 'none', level: colorLevel(), home: homedir(), safeGlyphs: getCurrentGlyphMode() === 'safe', reducedMotion: this.promptConfiguration.presentation.reducedMotion || isReducedMotion(), - effectsOff: this.promptConfiguration.presentation.effectsOff, legacy: createWelcomeSnapshot(this.buildIdentity, this.shellCwd, this.shellId, this.welcomeUnderstanding()), accent: idleStops(this.promptConfiguration, 'theme')[0], chroma: idleStops(this.promptConfiguration, 'appearance')[0]}; + effectsOff: this.promptConfiguration.presentation.effectsOff, hidden: this.terminalFocus === 'blurred', legacy: createWelcomeSnapshot(this.buildIdentity, this.shellCwd, this.shellId, this.welcomeUnderstanding()), accent: idleStops(this.promptConfiguration, 'theme')[0], chroma: idleStops(this.promptConfiguration, 'appearance')[0]}; } private startProviderPanel(family: 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation' | 'editor'): void { diff --git a/src/graphics/logoAnimation.ts b/src/graphics/logoAnimation.ts new file mode 100644 index 00000000..68461293 --- /dev/null +++ b/src/graphics/logoAnimation.ts @@ -0,0 +1,502 @@ +/** + * Procedural logo animation: frames NMSh generates in memory from one still raster, for the native welcome. No file is + * read or written, nothing is shelled out, and the result is an ordinary `DecodedAnimation`, so it is shown through the + * same frame path as a GIF or APNG welcome image (Kitty graphics, sixel, character art). + * + * The source is reduced first (at most `LOGO_ANIMATION_LIMITS.pixels`), every preset fits the shared animation budget + * (frames, decoded bytes, total time), and generation is deterministic: the same raster and preset give the same frames. + * The last frame is always the (reduced) still image itself, so "Once" settles exactly on the picture. + */ +import {ANIMATION_LIMITS, type DecodedAnimation} from './animation.js'; +import type {Raster} from './raster.js'; +import {scaleBox} from './scale.js'; + +export const LOGO_ANIMATIONS = ['off', 'assemble', 'shine', 'glow', 'glitch', 'embers'] as const; +export type LogoAnimation = typeof LOGO_ANIMATIONS[number]; +export const LOGO_PLAYS = ['once', 'loop'] as const; +export type LogoPlay = typeof LOGO_PLAYS[number]; + +export const LOGO_ANIMATION_LIMITS = Object.freeze({ + /** Longest side and pixel count of the reduced working raster. */ + longSide: 360, pixels: 90_000, + /** A gentle loop settles on the still after this long, even if nothing else stopped it. */ + loopPlayMs: 30_000, +}); + +export interface GeneratedLogoAnimation extends DecodedAnimation { + preset: Exclude; + play: LogoPlay; + /** Frames from here repeat after the intro (a loop); `frames.length` when the animation plays once. */ + loopFrom: number; + /** The longest time the animation plays before it shows its last frame (the still). */ + playMs: number; +} + +/** One preset's timing. Intro frames, then (loop only) a rest on the still, the loop motif and another rest. */ +interface Plan {intro: number; introStep: number; motif: number; motifStep: number; rest: number} +const PLANS: Record, Plan> = { + assemble: {intro: 2040, introStep: 60, motif: 1200, motifStep: 80, rest: 880}, + shine: {intro: 1440, introStep: 60, motif: 900, motifStep: 60, rest: 1300}, + glow: {intro: 1800, introStep: 60, motif: 1600, motifStep: 100, rest: 800}, + glitch: {intro: 1080, introStep: 60, motif: 360, motifStep: 60, rest: 1700}, + embers: {intro: 2040, introStep: 60, motif: 1600, motifStep: 80, rest: 680}, +}; + +/** Frames and total time a preset uses; tests and admission read this, generation follows it. */ +export function logoAnimationShape(preset: Exclude, play: LogoPlay): {frames: number; totalMs: number} { + const plan = PLANS[preset]; + const intro = Math.round(plan.intro / plan.introStep), motif = Math.round(plan.motif / plan.motifStep); + return play === 'once' ? {frames: intro + 1, totalMs: plan.intro + 100} : {frames: intro + 1 + motif + 1, totalMs: plan.intro + plan.rest * 2 + plan.motif}; +} + +/** Reduce the still before generating so every frame fits the budget. */ +export function logoWorkingSize(width: number, height: number, frames: number): {width: number; height: number} { + const budgetPixels = Math.floor(ANIMATION_LIMITS.maxDecodedBytes / (4 * Math.max(1, frames))); + const pixels = Math.min(LOGO_ANIMATION_LIMITS.pixels, budgetPixels); + let scale = Math.min(1, LOGO_ANIMATION_LIMITS.longSide / Math.max(width, height), Math.sqrt(pixels / Math.max(1, width * height))); + if (!Number.isFinite(scale) || scale <= 0) scale = 1; + return {width: Math.max(1, Math.floor(width * scale)), height: Math.max(1, Math.floor(height * scale))}; +} + +export function generateLogoAnimation(source: Raster, preset: Exclude, play: LogoPlay): GeneratedLogoAnimation { + const shape = logoAnimationShape(preset, play); + const size = logoWorkingSize(source.width, source.height, shape.frames); + const still = size.width === source.width && size.height === source.height ? {width: source.width, height: source.height, data: source.data.slice()} : scaleBox(source, size.width, size.height); + const scene = new Scene(still, seedFor(preset, still)); + const plan = PLANS[preset]; + const draw = DRAW[preset]; + const frames: DecodedAnimation['frames'] = []; + const steps = (span: number, step: number) => Array.from({length: Math.round(span / step)}, (_, i) => i * step); + for (const t of steps(plan.intro, plan.introStep)) frames.push({raster: draw(scene, 'intro', t / 1000, plan.intro / 1000), delayMs: plan.introStep}); + let loopFrom = frames.length + 1; + if (play === 'once') { frames.push({raster: copy(still), delayMs: 100}); loopFrom = frames.length; } + else { + frames.push({raster: copy(still), delayMs: plan.rest}); + for (const t of steps(plan.motif, plan.motifStep)) frames.push({raster: draw(scene, 'motif', t / 1000, plan.motif / 1000), delayMs: plan.motifStep}); + frames.push({raster: copy(still), delayMs: plan.rest}); + } + const totalMs = frames.reduce((sum, frame) => sum + frame.delayMs, 0); + return {width: still.width, height: still.height, frames, loopCount: play === 'once' ? 1 : 0, truncated: false, preset, play, loopFrom, + playMs: play === 'once' ? totalMs : LOGO_ANIMATION_LIMITS.loopPlayMs}; +} + +function copy(raster: Raster): Raster { return {width: raster.width, height: raster.height, data: raster.data.slice()}; } +function seedFor(preset: string, raster: Raster): number { + let hash = 2166136261; + for (const char of `${preset}:${raster.width}x${raster.height}`) hash = Math.imul(hash ^ char.charCodeAt(0), 16777619); + return hash >>> 0; +} +/** mulberry32: small, fast and deterministic. */ +function random(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} +const clamp01 = (v: number) => v < 0 ? 0 : v > 1 ? 1 : v; +const smooth = (a: number, b: number, v: number) => { const x = clamp01((v - a) / (b - a)); return x * x * (3 - 2 * x); }; +const easeInOut = (x: number) => x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; +const easeOut = (x: number) => 1 - Math.pow(1 - clamp01(x), 3); + +interface Point {x: number; y: number; r: number; g: number; b: number} + +/** What every preset needs to know about the still: alpha, edges, colors and seeded sample points. */ +class Scene { + readonly width: number; readonly height: number; readonly size: number; + readonly alpha: Float32Array; + readonly edge: Float32Array; + readonly opaque: Point[] = []; + readonly edges: Point[] = []; + readonly tops: Point[] = []; + readonly bounds: {left: number; top: number; right: number; bottom: number}; + private haloCache?: {light: Float32Array; reach: number}; + constructor(readonly still: Raster, readonly seed: number) { + const {width, height, data} = still; + this.width = width; this.height = height; this.size = Math.max(8, Math.min(width, height)); + this.alpha = new Float32Array(width * height); + for (let i = 0; i < width * height; i++) this.alpha[i] = data[i * 4 + 3]! / 255; + this.edge = new Float32Array(width * height); + const d = Math.max(1, Math.round(this.size / 140)); + const at = (x: number, y: number) => x < 0 || y < 0 || x >= width || y >= height ? 0 : this.alpha[y * width + x]!; + let left = width, top = height, right = -1, bottom = -1; + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { + const i = y * width + x, a = this.alpha[i]!; + if (a <= 0.5) continue; + left = Math.min(left, x); right = Math.max(right, x); top = Math.min(top, y); bottom = Math.max(bottom, y); + const low = Math.min(at(x - d, y), at(x + d, y), at(x, y - d), at(x, y + d)); + const lum = (0.2126 * data[i * 4]! + 0.7152 * data[i * 4 + 1]! + 0.0722 * data[i * 4 + 2]!) / 255; + const point = {x, y, r: data[i * 4]!, g: data[i * 4 + 1]!, b: data[i * 4 + 2]!}; + this.opaque.push(point); + const edge = a * (1 - low); + this.edge[i] = edge; + if (edge > 0.4) { this.edges.push(point); if (at(x, y - d) < 0.5 && lum > 0.2) this.tops.push(point); } + } + // A logo without transparency still animates: its rim is the image border. + if (!this.edges.length) for (const p of this.opaque) if (p.x === 0 || p.y === 0 || p.x === width - 1 || p.y === height - 1) this.edges.push(p); + if (!this.tops.length) this.tops.push(...this.edges.filter(p => p.y <= (top + (bottom - top) * 0.6))); + this.bounds = right < 0 ? {left: 0, top: 0, right: width - 1, bottom: height - 1} : {left, top, right, bottom}; + } + rng(salt: number): () => number { return random(this.seed ^ Math.imul(salt + 1, 0x9e3779b1)); } + /** A fresh premultiplied float canvas. */ + canvas(): Float32Array { return new Float32Array(this.width * this.height * 4); } + /** The still, premultiplied, with alpha and light multipliers per pixel. */ + logo(buf: Float32Array, opacity = 1, lift?: (i: number, x: number, y: number) => number, cover?: (i: number) => number): void { + const {data} = this.still; + for (let y = 0, i = 0; y < this.height; y++) for (let x = 0; x < this.width; x++, i++) { + const a = this.alpha[i]! * opacity * (cover ? cover(i) : 1); + if (a <= 0) continue; + const k = lift ? lift(i, x, y) : 0; + const o = i * 4; + for (let c = 0; c < 3; c++) { const v = data[o + c]! / 255; buf[o + c] = buf[o + c]! * (1 - a) + (v + (1 - v) * k) * a; } + buf[o + 3] = buf[o + 3]! * (1 - a) + a; + } + } + /** Additive light: brightens what is there and lights transparent pixels with its own color. */ + light(buf: Float32Array, x: number, y: number, radius: number, r: number, g: number, b: number, k: number): void { + if (k <= 0.002 || radius <= 0) return; + const reach = Math.ceil(radius * 2.2); + const x0 = Math.max(0, Math.floor(x - reach)), x1 = Math.min(this.width - 1, Math.ceil(x + reach)); + const y0 = Math.max(0, Math.floor(y - reach)), y1 = Math.min(this.height - 1, Math.ceil(y + reach)); + const inv = 1 / (radius * radius); + for (let py = y0; py <= y1; py++) for (let px = x0; px <= x1; px++) { + const d2 = ((px - x) ** 2 + (py - y) ** 2) * inv; + if (d2 > 4.8) continue; + const w = k * Math.exp(-d2 * 1.4); + const o = (py * this.width + px) * 4; + buf[o] = buf[o]! + r * w; buf[o + 1] = buf[o + 1]! + g * w; buf[o + 2] = buf[o + 2]! + b * w; buf[o + 3] = buf[o + 3]! + w; + } + } + /** A four-point glint: a hot core with thin horizontal and vertical rays. */ + star(buf: Float32Array, x: number, y: number, length: number, r: number, g: number, b: number, k: number): void { + if (k <= 0.002 || length < 1) return; + const thick = Math.max(0.7, length / 12); + const reach = Math.ceil(length); + for (let py = Math.max(0, Math.floor(y - reach)); py <= Math.min(this.height - 1, Math.ceil(y + reach)); py++) { + for (let px = Math.max(0, Math.floor(x - reach)); px <= Math.min(this.width - 1, Math.ceil(x + reach)); px++) { + const dx = Math.abs(px - x), dy = Math.abs(py - y); + const ray = Math.max(Math.exp(-(dy * dy) / (thick * thick)) * Math.max(0, 1 - dx / length) ** 2, + Math.exp(-(dx * dx) / (thick * thick)) * Math.max(0, 1 - dy / length) ** 2); + const core = Math.exp(-(dx * dx + dy * dy) / (thick * thick * 4)); + const w = k * Math.min(1, ray + core); + if (w <= 0.002) continue; + const o = (py * this.width + px) * 4; + buf[o] = buf[o]! + r * w; buf[o + 1] = buf[o + 1]! + g * w; buf[o + 2] = buf[o + 2]! + b * w; buf[o + 3] = buf[o + 3]! + w; + } + } + } + /** The halo: a blurred, outside-only copy of the logo's own colors (computed once). */ + halo(): {light: Float32Array; reach: number} { + if (this.haloCache) return this.haloCache; + const {width, height} = this; + const reach = Math.max(2, Math.round(this.size * 0.045)); + let field: Float32Array = new Float32Array(width * height * 4); + for (let i = 0; i < width * height; i++) { + const a = this.alpha[i]!; + for (let c = 0; c < 3; c++) field[i * 4 + c] = this.still.data[i * 4 + c]! / 255 * a; + field[i * 4 + 3] = a; + } + for (let pass = 0; pass < 3; pass++) field = blur(blur(field, width, height, reach, 1, 0), width, height, reach, 0, 1); + for (let i = 0; i < width * height; i++) { + const a = field[i * 4 + 3]!; + const outside = 1 - this.alpha[i]!; + // Lift halo colors toward white a little so dark logos still read as glowing. + for (let c = 0; c < 3; c++) { const color = a > 0.001 ? field[i * 4 + c]! / a : 0; field[i * 4 + c] = (color + (1 - color) * 0.25) * a * outside; } + field[i * 4 + 3] = a * outside; + } + this.haloCache = {light: field, reach}; + return this.haloCache; + } + /** Premultiplied float canvas to straight-alpha RGBA. Added light over a full pixel brightens it. */ + raster(buf: Float32Array): Raster { + const data = new Uint8ClampedArray(this.width * this.height * 4); + for (let o = 0; o < data.length; o += 4) { + const a = Math.min(1, buf[o + 3]!); + if (a <= 0.002) continue; + for (let c = 0; c < 3; c++) data[o + c] = Math.round(Math.min(1, buf[o + c]! / a) * 255); + data[o + 3] = Math.round(a * 255); + } + return {width: this.width, height: this.height, data}; + } +} + +function blur(src: Float32Array, width: number, height: number, radius: number, dx: number, dy: number): Float32Array { + const out = new Float32Array(src.length); + const span = radius * 2 + 1; + const lines = dx ? height : width, length = dx ? width : height; + const index = (line: number, pos: number) => (dx ? line * width + pos : pos * width + line) * 4; + const sum = new Float64Array(4); + for (let line = 0; line < lines; line++) { + sum.fill(0); + for (let pos = -radius; pos <= radius; pos++) if (pos >= 0 && pos < length) for (let c = 0; c < 4; c++) sum[c] = sum[c]! + src[index(line, pos) + c]!; + for (let pos = 0; pos < length; pos++) { + const o = index(line, pos); + for (let c = 0; c < 4; c++) out[o + c] = sum[c]! / span; + const leave = pos - radius, enter = pos + radius + 1; + if (leave >= 0) for (let c = 0; c < 4; c++) sum[c] = sum[c]! - src[index(line, leave) + c]!; + if (enter < length) for (let c = 0; c < 4; c++) sum[c] = sum[c]! + src[index(line, enter) + c]!; + } + } + return out; +} + +const pick = (list: readonly T[], rnd: () => number): T => list[Math.min(list.length - 1, Math.floor(rnd() * list.length))]!; +const hot = (p: Point, k: number) => [(p.r / 255) + (1 - p.r / 255) * k, (p.g / 255) + (1 - p.g / 255) * k, (p.b / 255) + (1 - p.b / 255) * k] as const; + +type Draw = (scene: Scene, part: 'intro' | 'motif', t: number, span: number) => Raster; + +/** Sparks drift in, gather into the logo's shape and the picture forms out of their light; the motif twinkles. */ +const assemble: Draw = (scene, part, t, span) => { + const buf = scene.canvas(); + const {width, height, size, bounds} = scene; + if (part === 'motif') return twinkle(scene, buf, t, span); + const cx = (bounds.left + bounds.right) / 2, cy = (bounds.top + bounds.bottom) / 2; + const reach = Math.max(bounds.right - bounds.left, bounds.bottom - bounds.top) / 2 + 1; + // Pixels form from the center outwards with a little grain, each one lit as it appears. + const formAt = formTimes(scene, cx, cy, reach); + scene.logo(buf, 1, i => { + const at = formAt[i]!; + return (1 - smooth(at, at + 0.25, t)) * 0.85 + Math.max(0, 1 - Math.abs(t - at - 0.1) / 0.14) * 0.3; + }, i => smooth(formAt[i]!, formAt[i]! + 0.25, t)); + const rnd = scene.rng(1); + const count = Math.max(60, Math.min(170, Math.round(scene.opaque.length / 150))); + const radius = Math.max(1, size / 85); + for (let n = 0; n < count && scene.opaque.length; n++) { + const p = pick(scene.opaque, rnd); + // Sparks start scattered on a loose ring around the logo, inside the picture. + const angle = rnd() * Math.PI * 2, distance = Math.min(reach * (0.55 + rnd() * 0.75), Math.min(width, height) * (0.3 + rnd() * 0.2)); + const sx = Math.max(0, Math.min(width - 1, cx + Math.cos(angle) * distance)), sy = Math.max(0, Math.min(height - 1, cy + Math.sin(angle) * distance)); + const appear = rnd() * 0.6, leave = 0.35 + rnd() * 0.35, arrive = leave + 0.55 + rnd() * 0.45; + const swirl = (rnd() - 0.5) * size * 0.35, twinklePhase = rnd() * Math.PI * 2; + const progress = easeInOut(clamp01((t - leave) / (arrive - leave))); + const bend = Math.sin(Math.PI * progress) * swirl; + const dx = p.x - sx, dy = p.y - sy, len = Math.hypot(dx, dy) || 1; + const x = sx + dx * progress - (dy / len) * bend, y = sy + dy * progress + (dx / len) * bend; + const fadeIn = smooth(appear, appear + 0.18, t); + const settle = 1 - smooth(arrive, arrive + 0.35, t); + const flicker = 0.7 + 0.3 * Math.sin(t * 22 + twinklePhase); + const k = fadeIn * settle * flicker * (progress > 0 && progress < 1 ? 1 : 0.75); + const [r, g, b] = hot(p, 0.35); + scene.light(buf, x, y, radius * (1 + 0.6 * (1 - progress)), r, g, b, k * 0.9); + // A short tail while travelling. + if (progress > 0.02 && progress < 0.98) scene.light(buf, x - dx * 0.035, y - dy * 0.035, radius * 0.8, r, g, b, k * 0.35); + } + // As the picture completes, one bright glint marks it done. + const glint = scene.edges.length ? glintPoint(scene) : undefined; + if (glint) { + const g = Math.max(0, 1 - Math.abs(t - (span - 0.28)) / 0.22); + scene.star(buf, glint.x, glint.y, size * 0.16 * (0.5 + g), 1, 1, 1, g); + } + return scene.raster(buf); +}; + +const FORM = new WeakMap(); +/** When each pixel of the logo appears during Assemble (seconds): center first, with grain. */ +function formTimes(scene: Scene, cx: number, cy: number, reach: number): Float32Array { + let times = FORM.get(scene); + if (times) return times; + times = new Float32Array(scene.width * scene.height); + for (let y = 0, i = 0; y < scene.height; y++) for (let x = 0; x < scene.width; x++, i++) { + times[i] = 0.95 + 0.55 * clamp01(Math.hypot(x - cx, y - cy) / reach * 0.85 + (hash2(x, y, scene.seed) - 0.5) * 0.2); + } + FORM.set(scene, times); + return times; +} + +/** Small deterministic per-pixel noise in 0..1. */ +function hash2(x: number, y: number, seed: number): number { + let h = Math.imul(x, 374761393) + Math.imul(y, 668265263) + seed; + h = Math.imul(h ^ (h >>> 13), 1274126177); + return ((h ^ (h >>> 16)) >>> 0) / 4294967296; +} + +/** The upper-right bright rim point: where a glint reads as light catching the logo. */ +function glintPoint(scene: Scene): Point { + let best = scene.edges[0]!, score = -Infinity; + for (const p of scene.edges) { + const s = (p.x / scene.width) - (p.y / scene.height) * 1.2 + (p.r + p.g + p.b) / 765 * 0.3; + if (s > score) { score = s; best = p; } + } + return best; +} + +/** Gentle loop for Assemble: a few glints on the rim and some drifting dust. */ +function twinkle(scene: Scene, buf: Float32Array, t: number, span: number): Raster { + scene.logo(buf); + const rnd = scene.rng(11); + const glints = Math.min(5, Math.max(2, Math.round(scene.edges.length / 400))); + for (let n = 0; n < glints && scene.edges.length; n++) { + const p = pick(scene.edges, rnd); + const born = (n / glints) * (span - 0.45) + rnd() * 0.12, life = 0.42; + const k = Math.sin(Math.PI * clamp01((t - born) / life)); + const [r, g, b] = hot(p, 0.7); + scene.star(buf, p.x, p.y, scene.size * 0.11 * (0.6 + 0.4 * k), r, g, b, k); + } + for (let n = 0; n < 14 && scene.opaque.length; n++) { + const p = pick(scene.opaque, rnd); + const phase = rnd(); + const k = Math.sin(Math.PI * ((t / span + phase) % 1)) ** 2 * 0.45; + const drift = (t / span) * scene.size * 0.06; + const [r, g, b] = hot(p, 0.5); + scene.light(buf, p.x + Math.cos(phase * 6.28) * drift, p.y - drift, Math.max(0.8, scene.size / 140), r, g, b, k); + } + return scene.raster(buf); +} + +/** A specular band sweeps over the logo's own pixels; its rim catches the light; a glint finishes the pass. */ +const shine: Draw = (scene, part, t, span) => { + const buf = scene.canvas(); + const {width, height} = scene; + const sweepStart = part === 'intro' ? 0.1 : 0, sweepEnd = part === 'intro' ? 1.1 : span * 0.85; + const strength = part === 'intro' ? 1 : 0.7; + const p = clamp01((t - sweepStart) / (sweepEnd - sweepStart)); + const u = -0.12 + 1.24 * (0.6 * p + 0.4 * easeInOut(p)); + const {bounds} = scene; + const bw = Math.max(1, bounds.right - bounds.left), bh = Math.max(1, bounds.bottom - bounds.top); + const across = (x: number, y: number) => ((x - bounds.left) / bw) * 0.75 + ((y - bounds.top) / bh) * 0.25; + void width; void height; + const band = (x: number, y: number) => { + const d = across(x, y) - u; + return Math.exp(-(d * d) / 0.0025) * 0.95 + Math.exp(-(d * d) / 0.03) * 0.32; + }; + const opening = part === 'intro' ? smooth(0, 0.12, t) : 1; + scene.logo(buf, 0.6 + 0.4 * opening, (i, x, y) => strength * band(x, y) * (0.75 + 0.5 * scene.edge[i]!)); + // Light spills a little past the rim where the band crosses it. + if (t > sweepStart && t < sweepEnd + 0.05) { + for (const p of scene.edges) { + const k = band(p.x, p.y) * strength; + if (k > 0.25 && hash2(p.x, p.y, scene.seed) < 0.35) scene.light(buf, p.x, p.y, Math.max(1, scene.size / 90), 1, 1, 1, k * 0.22); + } + } + const glint = scene.edges.length ? glintPoint(scene) : undefined; + if (glint) { + const at = sweepEnd + 0.08, g = Math.max(0, 1 - Math.abs(t - at) / 0.2) * strength; + scene.star(buf, glint.x, glint.y, scene.size * 0.15 * (0.5 + g), 1, 1, 1, g); + } + return scene.raster(buf); +}; + +/** A soft halo in the logo's own colors blooms in, then breathes out; the motif is one slow breath. */ +const glow: Draw = (scene, part, t, span) => { + const buf = scene.canvas(); + const level = part === 'intro' + ? (smooth(0, 0.45, t) * (1 - 0.6 * smooth(0.5, 0.9, t)) + 0.35 * Math.sin(Math.PI * clamp01((t - 0.85) / 0.6)) ** 2) * (1 - smooth(1.25, span - 0.06, t)) + : 0.75 * Math.sin(Math.PI * t / span) ** 2; + const fade = part === 'intro' ? smooth(0, 0.35, t) : 1; + const {light} = scene.halo(); + const gain = 2.6 * level; + const {width, height} = scene, margin = Math.max(2, scene.size * 0.06); + for (let o = 0; o < light.length; o += 4) { + if (light[o + 3]! <= 0.001) continue; + // The halo thins out toward the picture's border so it never ends in a hard edge. + const x = (o / 4) % width, y = Math.floor(o / 4 / width); + const border = smooth(0, margin, Math.min(x, y, width - 1 - x, height - 1 - y)); + for (let c = 0; c < 4; c++) buf[o + c] = light[o + c]! * gain * border; + } + scene.logo(buf, fade, i => level * 0.45 * scene.edge[i]! + level * 0.08); + return scene.raster(buf); +}; + +/** Cyberpunk: RGB-split ghosts, displaced slices, scanlines and flicker, resolving into the clean logo. */ +const glitch: Draw = (scene, part, t, span) => { + const frame = Math.round(t * 1000 / 60) + (part === 'motif' ? 1000 : 0); + const rnd = scene.rng(frame); + const level = part === 'intro' + ? (t < 0.55 ? 0.85 + 0.15 * rnd() : (1 - smooth(0.55, span - 0.12, t)) * (0.5 + 0.5 * rnd())) + : [0.9, 0.35, 1, 0.55, 0.25, 0.08][Math.min(5, Math.floor(t / 0.06))]!; + const {width, height, still} = scene; + const buf = scene.canvas(); + const shift = Math.max(1, Math.round(scene.size * 0.035 * level)) * (rnd() < 0.5 ? -1 : 1); + // Ghosts: the logo's shape in cyan and magenta, offset either way. + const ghost = (dx: number, r: number, g: number, b: number, k: number) => { + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { + const sx = x - dx; + if (sx < 0 || sx >= width) continue; + const a = scene.alpha[y * width + sx]! * k; + if (a <= 0.01) continue; + const o = (y * width + x) * 4; + buf[o] = buf[o]! + r * a; buf[o + 1] = buf[o + 1]! + g * a; buf[o + 2] = buf[o + 2]! + b * a; buf[o + 3] = buf[o + 3]! + a; + } + }; + ghost(-shift, 0, 0.95, 1, 0.65 * level); + ghost(shift, 1, 0.1, 0.75, 0.65 * level); + // Displaced slices of the logo itself. + const slices: Array<{top: number; bottom: number; dx: number}> = []; + const count = level > 0.15 ? 1 + Math.floor(rnd() * 4 * level) : 0; + for (let n = 0; n < count; n++) { + const top = Math.floor(rnd() * height), tall = Math.max(1, Math.round(height * (0.03 + rnd() * 0.1))); + slices.push({top, bottom: top + tall, dx: Math.round((rnd() - 0.5) * width * 0.22 * level)}); + } + const flicker = level > 0.4 && rnd() < 0.22 ? 0.45 : 1; + const opacity = (1 - 0.18 * level) * flicker; + const data = still.data; + for (let y = 0; y < height; y++) { + const slice = slices.find(s => y >= s.top && y < s.bottom); + const dx = slice?.dx ?? 0; + const scan = level > 0.1 && y % 3 === 0 ? 1 - 0.35 * level : 1; + for (let x = 0; x < width; x++) { + const sx = x - dx; + if (sx < 0 || sx >= width) continue; + const si = y * width + sx; + const a = scene.alpha[si]! * opacity; + if (a <= 0) continue; + const o = (y * width + x) * 4; + for (let c = 0; c < 3; c++) buf[o + c] = buf[o + c]! * (1 - a) + data[si * 4 + c]! / 255 * scan * a; + buf[o + 3] = buf[o + 3]! * (1 - a) + a; + } + } + // A few blocks of corrupted color inside the logo's bounds. + const {bounds} = scene; + const blocks = level > 0.3 ? Math.floor(rnd() * 3 * level) : 0; + for (let n = 0; n < blocks; n++) { + const bw = Math.max(2, Math.round(scene.size * (0.04 + rnd() * 0.08))), bh = Math.max(1, Math.round(scene.size * (0.01 + rnd() * 0.025))); + const bx = bounds.left + Math.floor(rnd() * Math.max(1, bounds.right - bounds.left - bw)), by = bounds.top + Math.floor(rnd() * Math.max(1, bounds.bottom - bounds.top - bh)); + const color = rnd() < 0.5 ? [0, 1, 0.95] : [1, 0.15, 0.8]; + for (let y = by; y < Math.min(height, by + bh); y++) for (let x = bx; x < Math.min(width, bx + bw); x++) { + const o = (y * width + x) * 4; + const a = 0.75 * level; + for (let c = 0; c < 3; c++) buf[o + c] = buf[o + c]! * (1 - a) + color[c]! * a; + buf[o + 3] = buf[o + 3]! * (1 - a) + a; + } + } + return scene.raster(buf); +}; + +/** Glowing embers lift off the logo's bright upper rims, sway, flicker and fade; the rim warms while they rise. */ +const embers: Draw = (scene, part, t, span) => { + const buf = scene.canvas(); + const fade = part === 'intro' ? smooth(0, 0.3, t) : 1; + const rnd = scene.rng(part === 'intro' ? 3 : 5); + const count = part === 'intro' ? Math.max(26, Math.min(60, Math.round(scene.tops.length / 12))) : Math.max(14, Math.min(28, Math.round(scene.tops.length / 25))); + const sources = scene.tops.length ? scene.tops : scene.edges; + let heat = 0; + const sparks: Array<() => void> = []; + for (let n = 0; n < count && sources.length; n++) { + const p = pick(sources, rnd); + const born = part === 'intro' ? 0.1 + rnd() * (span - 1.0) : rnd() * (span - 0.9); + const life = 0.6 + rnd() * 0.35; + const age = (t - born) / life; + const rise = scene.size * (0.3 + rnd() * 0.3), sway = scene.size * (0.015 + rnd() * 0.03), phase = rnd() * 6.28, flick = rnd() * 6.28; + if (age <= 0 || age >= 1) continue; + heat += Math.sin(Math.PI * age); + const x = p.x + Math.sin(phase + age * 5) * sway, y = p.y - easeOut(age) * rise; + const k = Math.sin(Math.PI * Math.min(1, age * 1.6)) * (1 - age) * (0.75 + 0.25 * Math.sin(t * 30 + flick)); + // Ember color: the logo's own color at its source, warmed toward fire. + const [r, g, b] = [(p.r / 255) * 0.45 + 0.55, (p.g / 255) * 0.45 + 0.6 * 0.55, (p.b / 255) * 0.45 + 0.22 * 0.55]; + const tx = p.x + Math.sin(phase + age * 5 - 0.4) * sway, ty = p.y - easeOut(Math.max(0, age - 0.08)) * rise; + sparks.push(() => { + scene.light(buf, tx, ty, Math.max(1, scene.size / 75), r, g, b, k * 0.45); + scene.light(buf, x, y, Math.max(1.5, scene.size / 30) * (1 - age * 0.4), r, g, b, k * 0.6); + scene.light(buf, x, y, Math.max(1, scene.size / 75), 1, 0.92, 0.75, k * 1.5); + }); + } + const warm = Math.min(1, heat / Math.max(4, count * 0.25)) * 0.3; + scene.logo(buf, fade, i => warm * scene.edge[i]!); + for (const spark of sparks) spark(); + return scene.raster(buf); +}; + +const DRAW: Record, Draw> = {assemble, shine, glow, glitch, embers}; diff --git a/src/welcome/compose.ts b/src/welcome/compose.ts index 8e059189..1452051d 100644 --- a/src/welcome/compose.ts +++ b/src/welcome/compose.ts @@ -11,9 +11,13 @@ export interface WelcomeCapabilities { accent?: {red: number; green: number; blue: number}; chroma?: {red: number; green: number; blue: number}; legacy?: WelcomeSnapshot; catFrame?: WelcomeCatFrame; + /** The terminal window is not focused: generated logo motion rests on the still. */ + hidden?: boolean; /** The startup asset is still being read: say so rather than asking the person to choose one. */ loading?: boolean; - asset?: {rows?: string[]; width: number; height: number; reason?: string; frameDelays?: readonly number[]; loopCount?: number; truncated?: boolean}; + asset?: {rows?: string[]; width: number; height: number; reason?: string; frameDelays?: readonly number[]; loopCount?: number; truncated?: boolean; + /** A generated logo animation: frames from `loopFrom` repeat after the intro, for at most `playMs`. */ + loopFrom?: number; playMs?: number}; } export interface WelcomeImagePlacement { row: number; column: number; columns: number; rows: number; @@ -90,6 +94,18 @@ export function composeWelcome(model: WelcomeStudio, facts: WelcomeFacts, size: const delays = capabilities.asset?.frameDelays?.slice(0, 75).map(delay => Number.isFinite(delay) ? Math.max(1, Math.min(5000, delay)) : 100) ?? []; const duration = delays.reduce((sum, delay) => sum + delay, 0); const plays = capabilities.asset?.loopCount ?? 1; + const loopFrom = capabilities.asset?.loopFrom; + if (animated && loopFrom !== undefined && loopFrom > 0 && loopFrom < delays.length) { + // A generated logo animation: an intro, then a gentle loop until `playMs`, then its last frame (the still). + const playMs = Math.max(0, Math.min(60_000, capabilities.asset?.playMs ?? 5000)); + const at = Number.isFinite(time) ? Math.max(0, Math.min(time, playMs)) : playMs; + const sampled = at >= playMs ? {index: delays.length - 1, remaining: Infinity} : sampleIntroLoop(delays, loopFrom, at); + const playing = at < playMs; + const effectActive = moving && t < 5000; + const nextFrameMs = Math.min(effectActive ? 100 : 5000, playing ? Math.max(1, Math.min(sampled.remaining, playMs - at)) : 5000); + return {rows: lines.slice(0, height).map(line => { const ansi = truncateAnsi(capabilities.color ? line : stripAnsi(line), width); return {ansi, plain: stripAnsi(ansi)}; }), images, + active: effectActive || playing, frameIndex: sampled.index, nextFrameMs}; + } const animationEnd = plays > 0 ? Math.min(5000, duration * plays) : 5000; const animationActive = animated && t < animationEnd; const frameIndex = animated ? sampleFrame(delays, t >= animationEnd ? Infinity : t, plays) : 0; @@ -99,6 +115,19 @@ export function composeWelcome(model: WelcomeStudio, facts: WelcomeFacts, size: return {rows: lines.slice(0, height).map(line => { const ansi = truncateAnsi(capabilities.color ? line : stripAnsi(line), width); return {ansi, plain: stripAnsi(ansi)}; }), images, active: effectActive || animationActive, frameIndex, nextFrameMs}; } +/** Intro frames once, then frames from `loopFrom` repeating; also how long the sampled frame still shows. */ +function sampleIntroLoop(delays: readonly number[], loopFrom: number, time: number): {index: number; remaining: number} { + const intro = delays.slice(0, loopFrom).reduce((sum, delay) => sum + delay, 0); + const cycle = delays.slice(loopFrom).reduce((sum, delay) => sum + delay, 0); + let start = 0, position = time; + if (time >= intro && cycle > 0) { start = loopFrom; position = (time - intro) % cycle; } + for (let i = start; i < delays.length; i++) { + if (position < delays[i]!) return {index: i, remaining: delays[i]! - position}; + position -= delays[i]!; + } + return {index: delays.length - 1, remaining: 1}; +} + /** Delays already passed decoder admission. Cap work again so callers cannot supply an unbounded schedule. */ function sampleFrame(delays: readonly number[], time: number, plays: number): number { const bounded = delays.slice(0, 75).map(delay => Number.isFinite(delay) ? Math.max(1, Math.min(5000, delay)) : 100); diff --git a/src/welcome/editor.ts b/src/welcome/editor.ts index cc572e95..2fb707eb 100644 --- a/src/welcome/editor.ts +++ b/src/welcome/editor.ts @@ -5,7 +5,7 @@ import {tabCycleDelta} from '../ui/PanelShell.js'; import {renderStudioLayout, studioLabel, studioMuted, studioWrap, type StudioEntry} from '../ui/StudioLayout.js'; import {renderControlRows, renderValueControl} from '../ui/controls.js'; import {composeWelcome, type WelcomeCapabilities, type WelcomeFacts, type WelcomeComposition} from './compose.js'; -import {ALPHAS, DEFAULT_WELCOME_STUDIO, EFFECTS, FACTS, FITS, LAYOUTS, LOGOS, PALETTES, WELCOME_PRESETS, normalizeWelcomeStudio, welcomeText, type SavedWelcomePreset, type WelcomeStudio} from './model.js'; +import {ALPHAS, DEFAULT_WELCOME_STUDIO, EFFECTS, FACTS, FITS, LAYOUTS, LOGOS, LOGO_ANIMATIONS, LOGO_PLAYS, PALETTES, WELCOME_PRESETS, normalizeWelcomeStudio, welcomeText, type SavedWelcomePreset, type WelcomeStudio} from './model.js'; import type {AssetEntry} from './assets.js'; export interface WelcomeEditor { @@ -25,14 +25,14 @@ export function createWelcomeEditor(config: PromptConfiguration, now = Date.now( } const FIELDS = ['Provider', 'Logo', 'Asset path / picker', 'Fit', 'Alpha', 'Layout', 'Title', 'Greeting', 'Density', 'Border', 'Palette', 'Custom RGB', 'Still image effect', 'Links', ...FACTS.map(f => `Fact: ${f}${['hostname', 'user', 'path', 'git'].includes(f) ? ' (private, opt in)' : ''}`), - 'Preset', 'Save preset', 'Duplicate preset', 'Reset layout', 'Import JSON file', 'Export JSON file', 'Export private path'] as const; + 'Preset', 'Save preset', 'Duplicate preset', 'Reset layout', 'Import JSON file', 'Export JSON file', 'Export private path', 'Logo animation', 'Play'] as const; const SECTIONS = [ {label: 'Logo & image', fields: [0, 1, 2, 3, 4]}, {label: 'Typography & greeting', fields: [6, 7, 13]}, {label: 'Layout & position', fields: [5, 8, 9]}, {label: 'Theme & palette', fields: [10, 11]}, {label: 'Facts/modules', fields: Array.from({length: FACTS.length}, (_, i) => i + 14)}, - {label: 'Animation', fields: [12]}, + {label: 'Animation', fields: [31, 32, 12]}, {label: 'Presets', fields: [24, 25, 26, 27, 28, 29, 30]}, ] as const; const FIELD_ORDER = SECTIONS.flatMap(section => [...section.fields]); @@ -41,6 +41,8 @@ const choiceValues = (s: WelcomeEditor) => s.choices?.kind === 'preset' ? [...WE : s.choices?.kind === 'layout' ? [...LAYOUTS] : s.choices?.kind === 'logo' ? [...LOGOS] : [...EFFECTS]; export type WelcomeEditorAction = 'close' | 'save' | 'asset' | 'picker' | 'import' | 'export' | 'provider' | undefined; +const LOGO_ANIMATION_LABELS: Record = {off: 'Off', assemble: 'Assemble', shine: 'Shine', glow: 'Glow pulse', glitch: 'Glitch', embers: 'Embers'}; +const LOGO_PLAY_LABELS: Record = {once: 'Once at startup', loop: 'Gentle loop'}; const cycle = (values: readonly T[], current: T, delta: number): T => values[(values.indexOf(current) + delta + values.length) % values.length]!; export function welcomeEditorKey(state: WelcomeEditor, key: Key, now = Date.now(), providers: readonly WelcomeProviderId[] = WELCOME_PROVIDERS.map(p => p.id)): WelcomeEditorAction { if (state.input) { @@ -123,6 +125,8 @@ export function welcomeEditorKey(state: WelcomeEditor, key: Key, now = Date.now( case 28: input('import'); return; case 29: input('export'); return; case 30: state.includePath = !state.includePath; return; + case 31: m.logoAnimation = cycle(LOGO_ANIMATIONS, m.logoAnimation, delta); break; + case 32: m.logoPlay = cycle(LOGO_PLAYS, m.logoPlay, delta); break; default: { const fact = FACTS[state.selected - 14]; if (fact) m.modules = m.modules.includes(fact) ? m.modules.filter(f => f !== fact) : [...m.modules, fact]; @@ -133,7 +137,8 @@ export function welcomeEditorKey(state: WelcomeEditor, key: Key, now = Date.now( function values(s: WelcomeEditor): string[] { const m = s.draft; return [s.provider, m.logo, m.assetPath || 'Enter path; O browse', m.fit, m.alpha, m.layout, m.title, m.greeting, m.density, m.border ? 'On' : 'Off', m.palette, m.customColor, m.effect, m.links.join(' '), - ...FACTS.map(f => m.modules.includes(f) ? 'On' : 'Off'), [...WELCOME_PRESETS, ...s.presets][s.preset]?.name ?? 'Vespyr', `${s.presets.length}/12 saved`, 'Copy current layout', 'Built-in defaults', 'Local JSON only', 'No bytes exported', s.includePath ? 'Include path (private)' : 'Omit path (default)']; + ...FACTS.map(f => m.modules.includes(f) ? 'On' : 'Off'), [...WELCOME_PRESETS, ...s.presets][s.preset]?.name ?? 'Vespyr', `${s.presets.length}/12 saved`, 'Copy current layout', 'Built-in defaults', 'Local JSON only', 'No bytes exported', s.includePath ? 'Include path (private)' : 'Omit path (default)', + `${LOGO_ANIMATION_LABELS[m.logoAnimation]}${m.logoAnimation !== 'off' && m.logo !== 'image' ? ' · image logos only' : ''}`, LOGO_PLAY_LABELS[m.logoPlay]]; } /** Field-specific guidance belongs beside the editor, never in startup's composition. */ function fieldHelp(s: WelcomeEditor): string { @@ -154,7 +159,9 @@ function fieldHelp(s: WelcomeEditor): string { 27: 'Enter resets the draft to built-in defaults. S saves; Esc leaves saved settings unchanged.', 28: 'Import a local JSON preset into the draft. Unknown fields are refused; no code is run.', 29: 'Export JSON to a new file. Existing files are never overwritten. Asset bytes are never included.', - 30: 'Private asset paths are omitted by default. Include one only for your own use.'} as Record)[s.selected] ?? ''; + 30: 'Private asset paths are omitted by default. Include one only for your own use.', + 31: 'Animates your image logo at startup from frames NMSh makes in memory; no GIF needed. Reduced Motion and Effects Off show the still image.', + 32: 'Once plays about two seconds and settles on your image. Gentle loop repeats softly for up to 30 s and stops when you type.'} as Record)[s.selected] ?? ''; } export function renderWelcomeEditor(s: WelcomeEditor, columns: number, height: number, facts: WelcomeFacts, caps: WelcomeCapabilities, now: number, @@ -215,11 +222,12 @@ export function renderWelcomeEditor(s: WelcomeEditor, columns: number, height: n else if (gallery.kind === 'logo') model.logo = LOGOS[gallery.selected]!; else model.effect = EFFECTS[gallery.selected]!; } - const label = `Live preview · ${s.before ? 'before' : 'draft'} · ${s.paused ? 'paused' : now - s.started >= 5000 ? 'settled' : 'playing'}${s.reduced ? ' · still' : ''}`; - const result = [studioLabel(label)]; const frame = composeWelcome(model, facts, {columns: s.narrow ? Math.min(40, width) : width, rows: Math.max(0, room - 1)}, {...caps, ...(gallery && model.assetPath !== s.draft.assetPath ? {asset: undefined} : {}), reducedMotion: caps.reducedMotion || s.reduced, ...(s.theme ? {accent: {red: 90, green: 180, blue: 210}, chroma: {red: 230, green: 150, blue: 70}} : {})}, s.paused ? s.elapsed : now - s.started); s.preview = frame; s.previewTop = origin.y + 1; s.previewLeft = origin.x; + // A gentle logo loop keeps playing past five seconds; the label follows the frame, not the clock. + const label = `Live preview · ${s.before ? 'before' : 'draft'} · ${s.paused ? 'paused' : now - s.started >= 5000 && !frame.active ? 'settled' : 'playing'}${s.reduced ? ' · still' : ''}`; + const result = [studioLabel(label)]; const preview = s.provider === 'none' ? ['No startup welcome.'] : s.provider !== 'vespyr' ? s.captured?.map(row => caps.color ? row : row.replace(/\x1b\[[0-9;]*m/gu, '')) ?? ['External provider preview loading…'] : paint?.(frame) ?? frame.rows.map(row => row.ansi); diff --git a/src/welcome/model.ts b/src/welcome/model.ts index c9a9214e..f3bb0ad0 100644 --- a/src/welcome/model.ts +++ b/src/welcome/model.ts @@ -1,4 +1,5 @@ import {stripTerminalControls} from '../util/terminalControls.js'; +import {LOGO_ANIMATIONS, LOGO_PLAYS} from '../graphics/logoAnimation.js'; export const LOGOS = ['none', 'mascot', 'image', 'art'] as const; export const FITS = ['contain', 'cover', 'crop'] as const; @@ -6,6 +7,7 @@ export const ALPHAS = ['preserve', 'theme', 'black', 'white'] as const; export const LAYOUTS = ['left', 'right', 'top'] as const; export const PALETTES = ['theme', 'chroma', 'custom'] as const; export const EFFECTS = ['original', 'float', 'pulse', 'parallax', 'glow', 'shimmer', 'particles', 'reveal', 'panZoom'] as const; +export {LOGO_ANIMATIONS, LOGO_PLAYS}; export const FACTS = ['os', 'shell', 'terminal', 'time', 'uptime', 'version', 'hostname', 'user', 'path', 'git'] as const; export type WelcomeFact = typeof FACTS[number]; export interface WelcomeStudio { @@ -17,11 +19,14 @@ export interface WelcomeStudio { title: string; greeting: string; links: string[]; border: boolean; palette: typeof PALETTES[number]; customColor: string; modules: WelcomeFact[]; effect: typeof EFFECTS[number]; + /** Animate a still image logo at startup with generated frames; Off keeps the still picture. */ + logoAnimation: typeof LOGO_ANIMATIONS[number]; logoPlay: typeof LOGO_PLAYS[number]; } export const DEFAULT_WELCOME_STUDIO: WelcomeStudio = { customized: true, logo: 'mascot', assetPath: '', fit: 'contain', alpha: 'preserve', layout: 'left', density: 'compact', title: 'notMyShell', greeting: 'A real shell. A livelier terminal.', links: [], border: true, palette: 'theme', customColor: '#A67CF3', modules: ['os', 'shell', 'version'], effect: 'original', + logoAnimation: 'off', logoPlay: 'once', }; export function welcomeText(value: unknown, max = 160): string { return typeof value === 'string' ? stripTerminalControls(value, max * 8).replace(/[\r\n\t]/gu, ' ').slice(0, max) : ''; @@ -40,6 +45,7 @@ export function normalizeWelcomeStudio(value: unknown): WelcomeStudio { customColor: typeof v.customColor === 'string' && /^#[a-f0-9]{6}$/iu.test(v.customColor) ? v.customColor : d.customColor, modules: Array.isArray(v.modules) ? [...new Set(v.modules.filter((x): x is WelcomeFact => FACTS.includes(x as WelcomeFact)))].slice(0, FACTS.length) : [...d.modules], effect: choice(v.effect, EFFECTS, d.effect), + logoAnimation: choice(v.logoAnimation, LOGO_ANIMATIONS, d.logoAnimation), logoPlay: choice(v.logoPlay, LOGO_PLAYS, d.logoPlay), }; } export const WELCOME_PRESETS: ReadonlyArray<{name: string; model: WelcomeStudio}> = [ diff --git a/src/welcome/render.ts b/src/welcome/render.ts index ef193d97..62c21b58 100644 --- a/src/welcome/render.ts +++ b/src/welcome/render.ts @@ -4,6 +4,30 @@ import type {GraphicsService, PlacementOverlay} from '../graphics/service.js'; import type {WelcomeAsset} from './assets.js'; import type {WelcomeCapabilities, WelcomeComposition} from './compose.js'; import {displayWidth} from '../util/text.js'; +import {generateLogoAnimation, type GeneratedLogoAnimation} from '../graphics/logoAnimation.js'; +import type {WelcomeStudio} from './model.js'; + +/** One generated animation per admitted image (the latest preset): editors cycling presets never pile frames up. */ +const generated = new WeakMap(); +/** + * The asset startup and preview animate: a still image logo with a Logo animation gets generated frames, shown through the + * same frame path as a GIF. Off, text logos, GIF/APNG files, no color, Reduced Motion, Effects Off and a hidden window keep + * the admitted asset itself, so the still picture is exactly what Off shows. + */ +export function motionAsset(asset: WelcomeAsset | undefined, model: Pick | undefined, + caps: Pick): WelcomeAsset | undefined { + if (!asset || asset.kind !== 'image' || !model || model.logo !== 'image' || model.logoAnimation === 'off') return asset; + if (!caps.color || caps.reducedMotion || caps.effectsOff || caps.hidden) return asset; + if ((asset.animation?.frames.length ?? 1) > 1 || asset.animation?.truncated) return asset; + const key = `${model.logoAnimation}:${model.logoPlay}`; + let entry = generated.get(asset.image); + if (entry?.key !== key) { + try { entry = {key, animation: generateLogoAnimation(asset.image.raster, model.logoAnimation, model.logoPlay)}; } + catch { return asset; } + generated.set(asset.image, entry); + } + return {...asset, image: {...asset.image, key: `${asset.image.key}|logo:${key}`}, animation: entry.animation}; +} export interface WelcomeOverlay {x: number; y: number; placement: PlacementOverlay} /** Inline startup and P preview must explain when a viewer-only protocol becomes cells. */ export function inlineWelcomeReason(decision: GraphicsDecision): string | undefined { @@ -13,7 +37,7 @@ export function inlineWelcomeReason(decision: GraphicsDecision): string | undefi } export function assetCapabilities(asset: WelcomeAsset | undefined, reason?: string, animate = true): WelcomeCapabilities['asset'] { if (!asset) return reason ? {width: 1, height: 1, reason} : undefined; - return {width: asset.metadata.width, height: asset.metadata.height, ...(asset.kind === 'image' && asset.animation?.truncated ? {truncated: true} : {}), ...(asset.kind === 'art' ? {rows: asset.rows} : animate && asset.animation && !asset.animation.truncated ? {frameDelays: asset.animation.frames.map(f => f.delayMs), loopCount: asset.animation.loopCount} : {}), ...((asset.metadata.note ?? reason) ? {reason: [asset.metadata.note, reason].filter(Boolean).join(' ')} : {})}; + return {width: asset.metadata.width, height: asset.metadata.height, ...(asset.kind === 'image' && asset.animation?.truncated ? {truncated: true} : {}), ...(asset.kind === 'art' ? {rows: asset.rows} : animate && asset.animation && !asset.animation.truncated ? {frameDelays: asset.animation.frames.map(f => f.delayMs), loopCount: asset.animation.loopCount, ...('loopFrom' in asset.animation ? {loopFrom: (asset.animation as GeneratedLogoAnimation).loopFrom, playMs: (asset.animation as GeneratedLogoAnimation).playMs} : {})} : {}), ...((asset.metadata.note ?? reason) ? {reason: [asset.metadata.note, reason].filter(Boolean).join(' ')} : {})}; } export function paintWelcome(frame: WelcomeComposition, asset: WelcomeAsset | undefined, service: GraphicsService, owner: unknown, surface: 'inline' | 'viewer', background: {red: number; green: number; blue: number}, cellsOnly = false): {rows: string[]; overlay?: WelcomeOverlay} { diff --git a/src/welcome/startup.ts b/src/welcome/startup.ts index 78277e25..f7fa6cf5 100644 --- a/src/welcome/startup.ts +++ b/src/welcome/startup.ts @@ -3,7 +3,7 @@ import type {WelcomeSnapshot} from './legacy.js'; import {renderWelcome} from '../output/Welcome.js'; import type {GraphicsService} from '../graphics/service.js'; import {composeWelcome, type WelcomeCapabilities} from './compose.js'; -import {assetCapabilities, paintWelcome} from './render.js'; +import {assetCapabilities, motionAsset, paintWelcome} from './render.js'; import {loadWelcomeAsset, type WelcomeAsset} from './assets.js'; import {stripAnsi} from '../util/text.js'; @@ -44,7 +44,9 @@ export class NativeWelcomePresentation { if (!model?.customized || snapshot.captured) return {rows: renderWelcome(snapshot, columns).slice(0, height), images: [], active: false}; const capabilities = this.caps(); const caps = {...capabilities, effectsOff: capabilities.effectsOff || this.waitingForAsset, loading: this.waitingForAsset, asset: assetCapabilities(this.asset, this.reason ?? reason)}; - const frame = composeWelcome(model, snapshot.facts ?? {}, {columns, rows: height}, caps, this.still ? 5000 : elapsed); + // A generated logo animation plays only while motion is live; every still (and the durable card) is the admitted asset. + const live = this.still ? this.asset : motionAsset(this.asset, model, caps); + const frame = composeWelcome(model, snapshot.facts ?? {}, {columns, rows: height}, live === this.asset ? caps : {...caps, asset: assetCapabilities(live, this.reason ?? reason)}, this.still ? 5000 : elapsed); // Durable stills are sampled independently of playback and always rendered as portable cells. // A small key (not the art rows or legacy card) so playback frames do not serialize the whole capability set. const finalKey = JSON.stringify([columns, height, caps.color, caps.level, caps.safeGlyphs, caps.reducedMotion, caps.effectsOff, caps.accent, caps.chroma, caps.catFrame, @@ -57,7 +59,7 @@ export class NativeWelcomePresentation { } // At most ~30 repaints a second: very short GIF delays play slightly slower rather than driving 60 fps app renders. this.sequence.sync(!this.still && frame.active, this.repaint, frame.nextFrameMs === undefined ? undefined : Math.max(33, frame.nextFrameMs)); - const painted = paintWelcome(frame, this.asset, this.service, this.owner, 'inline', this.background(), this.still); + const painted = paintWelcome(frame, frame.active ? live : this.asset, this.service, this.owner, 'inline', this.background(), this.still); return {...frame, active: !this.still && frame.active, rows: painted.rows.map(ansi => ({ansi, plain: stripAnsi(ansi)}))}; } rows(snapshot: WelcomeSnapshot, columns: number, reason?: string) { diff --git a/src/welcome/studio.ts b/src/welcome/studio.ts index e182c661..c60cebb6 100644 --- a/src/welcome/studio.ts +++ b/src/welcome/studio.ts @@ -11,7 +11,7 @@ import type {WelcomeCapabilities, WelcomeFacts} from './compose.js'; import {loadWelcomeAsset, listWelcomeAssets, resolveWelcomePath, type WelcomeAsset} from './assets.js'; import {createWelcomeEditor, renderWelcomeEditor, welcomeEditorKey, type WelcomeEditor} from './editor.js'; import {exportWelcomePreset, importWelcomePreset, normalizeWelcomeStudio, type WelcomeStudio} from './model.js'; -import {inlineWelcomeReason, assetCapabilities, paintWelcome, type WelcomeOverlay} from './render.js'; +import {inlineWelcomeReason, assetCapabilities, motionAsset, paintWelcome, type WelcomeOverlay} from './render.js'; import {renderControlRows} from '../ui/controls.js'; export interface StudioHost { @@ -178,11 +178,13 @@ export class WelcomeStudioController { render(columns: number, height: number): string[] { const decision = this.host.decision(); const caps = this.host.capabilities(); - const asset = this.state.before ? this.beforeAsset : this.asset; + const still = this.state.before ? this.beforeAsset : this.asset; + // The preview animates the logo exactly as startup does (same generated frames, same gates). + const asset = motionAsset(still, this.state.before ? this.state.original : this.state.draft, {...caps, reducedMotion: caps.reducedMotion || this.state.reduced}); const reason = asset?.kind === 'image' && (decision.inline !== 'placeholder' && !['kitty', 'iterm2', 'sixel'].includes(decision.backend)) ? decision.reason : undefined; this.overlay = undefined; const rows = renderWelcomeEditor(this.state, columns, height, this.host.facts(this.state.before ? this.state.original : this.state.draft), {...caps, asset: assetCapabilities(asset, reason)}, Date.now(), frame => { - const painted = paintWelcome(frame, asset, this.host.graphics(), this.owner, 'viewer', this.host.background()); + const painted = paintWelcome(frame, frame.active || this.state.paused ? asset : still, this.host.graphics(), this.owner, 'viewer', this.host.background()); if (painted.overlay) this.overlay = {...painted.overlay, y: painted.overlay.y + (this.state.previewTop ?? 0), x: painted.overlay.x + (this.state.previewLeft ?? 0)}; return painted.rows; }); diff --git a/tests/welcomeEditor.test.ts b/tests/welcomeEditor.test.ts index e1267220..6b9ff7f5 100644 --- a/tests/welcomeEditor.test.ts +++ b/tests/welcomeEditor.test.ts @@ -61,7 +61,7 @@ test('section tabs jump without mutating the draft, and a choice gallery cancels test('all editor fields and pickers keep focus and complete controls in the four requested sizes', () => { for (const [columns, height] of [[120, 40], [80, 24], [60, 20], [40, 14]]) { const state = createWelcomeEditor(normalizePromptConfiguration({})); - for (let selected = 0; selected < 31; selected++) { + for (let selected = 0; selected < 33; selected++) { state.selected = selected; const rows = renderWelcomeEditor(state, columns!, height!, {}, {color: false}, 0); assert.ok(rows.length <= height! && rows.every(row => displayWidth(row) <= columns!)); diff --git a/tests/welcomeLogoAnimation.test.ts b/tests/welcomeLogoAnimation.test.ts new file mode 100644 index 00000000..6068aa0e --- /dev/null +++ b/tests/welcomeLogoAnimation.test.ts @@ -0,0 +1,204 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {ANIMATION_LIMITS} from '../src/graphics/animation.js'; +import {generateLogoAnimation, logoWorkingSize, LOGO_ANIMATIONS, LOGO_PLAYS, LOGO_ANIMATION_LIMITS, type LogoAnimation} from '../src/graphics/logoAnimation.js'; +import type {Raster} from '../src/graphics/raster.js'; +import {scaleBox} from '../src/graphics/scale.js'; +import {GraphicsService} from '../src/graphics/service.js'; +import type {GraphicsDecision} from '../src/graphics/capabilities.js'; +import {composeWelcome} from '../src/welcome/compose.js'; +import {createWelcomeEditor, renderWelcomeEditor, welcomeEditorKey} from '../src/welcome/editor.js'; +import {exportWelcomePreset, importWelcomePreset, normalizeWelcomeStudio, type WelcomeStudio} from '../src/welcome/model.js'; +import {assetCapabilities, motionAsset} from '../src/welcome/render.js'; +import {NativeWelcomePresentation} from '../src/welcome/startup.js'; +import type {WelcomeAsset} from '../src/welcome/assets.js'; +import {normalizePromptConfiguration} from '../src/prompt/configuration.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; + +const PRESETS = LOGO_ANIMATIONS.filter((p): p is Exclude => p !== 'off'); + +/** A transparent canvas with a colorful opaque square in the middle. */ +function logo(size = 120, square = 40): Raster { + const data = new Uint8ClampedArray(size * size * 4); + const from = (size - square) / 2; + for (let y = from; y < from + square; y++) for (let x = from; x < from + square; x++) { + const o = (y * size + x) * 4; + data[o] = 40 + x; data[o + 1] = 200 - y; data[o + 2] = 180; data[o + 3] = 255; + } + return {width: size, height: size, data}; +} +function asset(raster: Raster, frames = 1): WelcomeAsset { + const image = {key: 'welcome:test', name: 'Welcome image', path: '/logo.png', info: {format: 'png' as const, width: raster.width, height: raster.height, animated: frames > 1, alpha: true, bytes: 10}, raster, fileBytes: 10}; + const animation = frames > 1 ? {width: raster.width, height: raster.height, frames: Array.from({length: frames}, () => ({raster, delayMs: 100})), loopCount: 0, truncated: false} : undefined; + return {kind: 'image', image, ...(animation ? {animation} : {}), metadata: {format: 'png', bytes: 10, frames, width: raster.width, height: raster.height}}; +} +const model = (patch: Partial = {}) => normalizeWelcomeStudio({customized: true, logo: 'image', assetPath: '/logo.png', ...patch}); +const snapshot = (studio: WelcomeStudio) => ({identity: {version: '1', commit: 'abc'}, cwd: '.', shell: 'zsh' as const, studio, facts: {shell: 'zsh', version: '1'}}); +function service(sent: string[] = [], kitty = false): GraphicsService { + const decision: GraphicsDecision = {backend: kitty ? 'kitty' : 'unicode', evidence: kitty ? 'query' : 'none', inline: kitty ? 'placeholder' : 'unicode', unicode: 'half', cell: {width: 8, height: 16, source: 'estimated'}, reason: 'Character art.'}; + return new GraphicsService({decision: () => decision, send: data => { sent.push(data); }, scratchDir: () => '.', color: () => 'truecolor', safeGlyphs: () => false}); +} + +test('generated logo animations are deterministic and fit every animation budget', () => { + const big: Raster = {width: 1400, height: 1000, data: new Uint8ClampedArray(1400 * 1000 * 4).fill(200)}; + for (const preset of PRESETS) for (const play of LOGO_PLAYS) { + const a = generateLogoAnimation(logo(), preset, play), b = generateLogoAnimation(logo(), preset, play); + assert.equal(a.frames.length, b.frames.length); + a.frames.forEach((frame, i) => { assert.deepEqual(frame.raster.data, b.frames[i]!.raster.data, `${preset}/${play} frame ${i}`); assert.equal(frame.delayMs, b.frames[i]!.delayMs); }); + assert.ok(a.frames.some(frame => !frame.raster.data.every((v, i) => v === a.frames.at(-1)!.raster.data[i])), `${preset} moves`); + const large = generateLogoAnimation(big, preset, play); + for (const result of [a, large]) { + assert.ok(result.frames.length <= ANIMATION_LIMITS.maxFrames); + assert.ok(result.frames.reduce((sum, f) => sum + f.delayMs, 0) <= ANIMATION_LIMITS.maxTotalMs, `${preset}/${play} time`); + assert.ok(result.frames.reduce((sum, f) => sum + f.raster.data.length, 0) <= ANIMATION_LIMITS.maxDecodedBytes); + assert.ok(result.width * result.height <= LOGO_ANIMATION_LIMITS.pixels && Math.max(result.width, result.height) <= LOGO_ANIMATION_LIMITS.longSide); + assert.equal(result.loopCount, play === 'once' ? 1 : 0); + assert.equal(result.loopFrom, play === 'once' ? result.frames.length : result.loopFrom); + } + if (play === 'loop') assert.ok(a.loopFrom > 0 && a.loopFrom < a.frames.length && a.playMs === LOGO_ANIMATION_LIMITS.loopPlayMs); + } +}); + +test('the last frame is the still image; a reduced still is the shared box-filtered picture', () => { + const small = logo(); + for (const preset of PRESETS) for (const play of LOGO_PLAYS) assert.deepEqual(generateLogoAnimation(small, preset, play).frames.at(-1)!.raster.data, small.data); + const big = logo(800, 300); + const size = logoWorkingSize(800, 800, 60); + const reduced = scaleBox(big, size.width, size.height); + for (const preset of PRESETS) assert.deepEqual(generateLogoAnimation(big, preset, 'once').frames.at(-1)!.raster.data, reduced.data); +}); + +test('transparent pixels stay transparent outside each effect area', () => { + const size = 120, raster = logo(size, 40); + const lo = 40, hi = 79; // the square, inclusive + const distance = (x: number, y: number) => Math.hypot(Math.max(0, lo - x, x - hi), Math.max(0, lo - y, y - hi)); + const outside: Record boolean> = { + shine: (x, y) => distance(x, y) > 28, // the rim light and the closing glint + glow: (x, y) => distance(x, y) > 20, + glitch: (x, y) => y < lo || y > hi || Math.abs(x - (lo + hi) / 2) > 40, + embers: (x, y) => y > hi + 6 || distance(x, y) > 70, + }; + for (const [preset, far] of Object.entries(outside)) for (const play of LOGO_PLAYS) { + for (const frame of generateLogoAnimation(raster, preset as Exclude, play).frames) { + for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) if (far(x, y)) assert.equal(frame.raster.data[(y * size + x) * 4 + 3], 0, `${preset} ${x},${y}`); + } + } + // Assemble's sparks fly across the picture, but it settles exactly on the still. + const settled = generateLogoAnimation(raster, 'assemble', 'once').frames.at(-1)!.raster; + assert.deepEqual(settled.data, raster.data); +}); + +test('Off, text logos, GIFs and every motion gate keep the admitted asset itself', () => { + const still = asset(logo()); + const on = {color: true}; + assert.equal(motionAsset(still, model(), on), still); + assert.equal(motionAsset(still, model({logoAnimation: 'shine', logo: 'art'}), on), still); + const gif = asset(logo(), 3); + assert.equal(motionAsset(gif, model({logoAnimation: 'shine'}), on), gif); + for (const gate of [{color: false}, {color: true, reducedMotion: true}, {color: true, effectsOff: true}, {color: true, hidden: true}]) { + assert.equal(motionAsset(still, model({logoAnimation: 'glitch'}), gate), still); + } + const moving = motionAsset(still, model({logoAnimation: 'glitch'}), on)!; + assert.notEqual(moving, still); + assert.ok(moving.kind === 'image' && moving.animation && moving.animation.frames.length > 1 && moving.image.key !== still.image.key); + // The original asset is never changed. + assert.equal(still.kind === 'image' && still.animation, undefined); +}); + +test('Off renders byte-identically to a card saved before logo animation existed', () => { + const saved = normalizeWelcomeStudio({customized: true, logo: 'image', assetPath: '/logo.png'}); + const legacy: Record = {...saved}; + delete legacy.logoAnimation; delete legacy.logoPlay; + for (const kitty of [false, true]) { + const sentA: string[] = [], sentB: string[] = []; + const a = new NativeWelcomePresentation(snapshot(saved), service(sentA, kitty), () => ({color: true}), () => ({red: 0, green: 0, blue: 0}), () => {}, asset(logo())); + const b = new NativeWelcomePresentation(snapshot(normalizeWelcomeStudio(legacy)), service(sentB, kitty), () => ({color: true}), () => ({red: 0, green: 0, blue: 0}), () => {}, asset(logo())); + for (const time of [0, 400, 1200, 5000]) assert.deepEqual(a.frame(80, 40, time).rows, b.frame(80, 40, time).rows); + assert.deepEqual(sentA, sentB); + a.dispose(); b.dispose(); + } +}); + +test('startup plays the generated frames, settles on the still, and a gate shows the still', () => { + const sent: string[] = []; + const still = asset(logo()); + const off = new NativeWelcomePresentation(snapshot(model()), service(), () => ({color: true}), () => ({red: 0, green: 0, blue: 0}), () => {}, still); + const stillRows = off.frame(80, 40, 0).rows; + for (const preset of PRESETS) { + const native = new NativeWelcomePresentation(snapshot(model({logoAnimation: preset})), service(sent, true), () => ({color: true}), () => ({red: 0, green: 0, blue: 0}), () => {}, still); + const first = native.frame(80, 40, 0); + assert.equal(first.active, true); + const done = native.frame(80, 40, 4000); + assert.equal(done.active, false, `${preset} once ends`); + native.dispose(); + const gated = new NativeWelcomePresentation(snapshot(model({logoAnimation: preset})), service(), () => ({color: true, reducedMotion: true}), () => ({red: 0, green: 0, blue: 0}), () => {}, still); + const frame = gated.frame(80, 40, 0); + assert.equal(frame.active, false); + assert.deepEqual(frame.rows, stillRows, `${preset} reduced motion is the still`); + gated.dispose(); + } + const cells = new NativeWelcomePresentation(snapshot(model({logoAnimation: 'assemble'})), service(), () => ({color: true}), () => ({red: 0, green: 0, blue: 0}), () => {}, still); + assert.notDeepEqual(cells.frame(80, 40, 300).rows, stillRows, 'character art animates too'); + assert.deepEqual(cells.frame(80, 40, 5000).rows, stillRows, 'and settles on the still'); + cells.cancelMotion(); + assert.deepEqual(cells.frame(80, 40, 600).rows, stillRows, 'a cancelled animation shows the still'); + cells.dispose(); + assert.ok(sent.some(data => data.includes('\x1b_G')), 'Kitty frames go through the graphics service'); +}); + +test('a gentle loop repeats past the intro, keeps sampling within budget and settles when its time is up', () => { + const still = asset(logo()); + const moving = motionAsset(still, model({logoAnimation: 'shine', logoPlay: 'loop'}), {color: true})!; + const caps = {color: true, asset: assetCapabilities(moving)}; + assert.ok(caps.asset?.loopFrom && caps.asset.playMs === LOGO_ANIMATION_LIMITS.loopPlayMs); + const m = model({logoAnimation: 'shine', logoPlay: 'loop'}); + const frames = caps.asset!.frameDelays!; + const intro = frames.slice(0, caps.asset!.loopFrom).reduce((a, b) => a + b, 0), cycle = frames.slice(caps.asset!.loopFrom).reduce((a, b) => a + b, 0); + const at = (time: number) => composeWelcome(m, {}, {columns: 80, rows: 40}, caps, time); + assert.equal(at(intro + 1).frameIndex, caps.asset!.loopFrom); + assert.equal(at(intro + cycle + 1).frameIndex, caps.asset!.loopFrom, 'the loop wraps to its own start, not the intro'); + assert.equal(at(12_000).active, true); + const end = at(LOGO_ANIMATION_LIMITS.loopPlayMs); + assert.equal(end.active, false); + assert.equal(end.frameIndex, frames.length - 1); + for (const time of [0, 500, intro + 10, 9_000]) { + const frame = at(time); + assert.ok(frame.nextFrameMs! >= 1 && frame.nextFrameMs! <= 5000); + } +}); + +test('settings persist, normalize and round-trip through presets', () => { + const config = normalizePromptConfiguration({welcomeStudio: {customized: true, logo: 'image', logoAnimation: 'embers', logoPlay: 'loop'}}); + assert.equal(config.welcomeStudio.logoAnimation, 'embers'); + assert.equal(config.welcomeStudio.logoPlay, 'loop'); + assert.equal(normalizePromptConfiguration({}).welcomeStudio.logoAnimation, 'off'); + assert.equal(normalizeWelcomeStudio({logoAnimation: 'evil', logoPlay: 2}).logoAnimation, 'off'); + assert.equal(normalizeWelcomeStudio({logoAnimation: 'evil', logoPlay: 2}).logoPlay, 'once'); + const imported = importWelcomePreset(exportWelcomePreset(config.welcomeStudio)); + assert.equal(imported.logoAnimation, 'embers'); + assert.throws(() => importWelcomePreset('{"version":1,"model":{"logoAnimation":"gif"}}')); +}); + +test('Welcome Studio shows Logo animation and Play in the Animation section at 120x40 and 60x20', () => { + for (const [columns, height] of [[120, 40], [60, 20]] as const) { + const state = createWelcomeEditor(normalizePromptConfiguration({welcomeStudio: {customized: true, logo: 'image'}})); + // Tab to the Animation section: its first row is Logo animation. + for (let i = 0; i < 5; i++) welcomeEditorKey(state, {kind: 'complete'} as never); + assert.equal(state.selected, 31); + let rows = renderWelcomeEditor(state, columns, height, {}, {color: false}, 0).map(stripAnsi); + assert.ok(rows.length <= height && rows.every(row => displayWidth(row) <= columns)); + assert.ok(rows.some(row => row.startsWith('›') && row.includes('Logo animation') && row.includes('Off')), rows.join('\n')); + welcomeEditorKey(state, {kind: 'right'}); + assert.equal(state.draft.logoAnimation, 'assemble'); + rows = renderWelcomeEditor(state, columns, height, {}, {color: false}, 0).map(stripAnsi); + assert.ok(rows.some(row => row.startsWith('›') && row.includes('Assemble'))); + welcomeEditorKey(state, {kind: 'down'}); + assert.equal(state.selected, 32); + welcomeEditorKey(state, {kind: 'right'}); + assert.equal(state.draft.logoPlay, 'loop'); + rows = renderWelcomeEditor(state, columns, height, {}, {color: false}, 0).map(stripAnsi); + assert.ok(rows.some(row => row.startsWith('›') && row.includes('Play') && row.includes('Gentle loop')), rows.join('\n')); + assert.match(rows.at(-1)!, /Esc/); + if (height >= 40) assert.match(rows.join('\n'), /Gentle loop repeats softly/); + } +}); From 305c8b90b322ce94040cf1469b0891285a0904af Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:04:42 +0530 Subject: [PATCH 2/3] Logo animation: changelog entry; assert no frame timer remains after Once --- CHANGELOG.md | 1 + tests/welcomeLogoAnimation.test.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a8a25b9..07d9c25d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- **Logo animation for the native welcome.** Welcome Studio › Animation › **Logo animation** animates a still PNG, JPEG or WebP logo at startup from frames NMSh generates in memory (no GIF, no files written): **Assemble** (sparks gather and the logo forms out of their light), **Shine** (a specular band sweeps the logo's own pixels), **Glow pulse** (a halo in the logo's colors blooms and breathes), **Glitch** (RGB-split slices and flicker resolving to the clean logo) and **Embers** (glowing particles lift off the bright rims). **Play** is *Once at startup* (about two seconds, settling exactly on your image) or *Gentle loop* (repeats softly for up to 30 s, stops when you type and rests while the window is unfocused). Frames go through the same path as GIF/APNG welcome images (Kitty graphics, sixel, character art) within the animation budgets; Reduced Motion and Effects Off show the still image, and Off (the default) changes nothing. - **Kitty shaders in `/appearance`** (Kitty 0.49 or newer; [#399](https://github.com/raiseCatError/notMyShell/issues/399)). In Kitty, `/appearance` › **Kitty shaders** offers the GPU effects Kitty itself ships — Default, Blaze, Lightning and Motion-blur cursor trails (one at a time), Northern Lights, Matrix, Fireworks, Pond Ripple, Spotlight, Focus highlight, Dim inactive windows, Tab change and CRT — read from the installed Kitty, with the settings each installed shader really declares (durations, colors, intensity, speed…) in safe ranges. Effects are off until you choose them; Enter toggles, ←→ adjusts, Shift+↑↓ orders, and **S** shows the exact files and the resulting `custom_shaders` list before anything is written. Your own `custom_shaders` are kept exactly as written and in order, with NMSh's pipeline after (or before) them; NMSh refuses instead of guessing when it cannot prove that (dynamic includes, Kitty launch overrides, unreadable pipelines, a shader of yours replacing a shipped one, shared textures, a second cursor trail, more than 16 shader groups). **R** removes NMSh's files and its one include line, restoring your file byte for byte. Reduced Motion, Decorative effects Off and a Portable cursor take NMSh's effects (or its trail) back out of Kitty's pipeline. Effects cover the whole Kitty window, not only NMSh; saving configuration is not proof of GPU output. In `/cursor`, Host native setup on Kitty opens this screen, and a Kitty trail shader means NMSh never draws a second trail. - **Console for the browser preview** ([#466](https://github.com/raiseCatError/notMyShell/issues/466); [design](docs/design/document-workspace.md#console-runtime-diagnostics-of-the-browser-preview)). In the Chromium preview of a Markdown or HTML document, **`C`** lists what the page itself reported for the document now loaded: script exceptions, `console` errors, warnings and messages, failed loads and HTTP errors, Content Security Policy refusals and requests NMSh refused. It works like Problems — newest first, Tab filters by severity, typing searches, Shift+↑↓ marks, Del clears, Ctrl+C shows exactly what would be copied before copying — and Enter goes to the line in the file when that is certain (an HTML document unchanged since the page loaded); otherwise it says why. The Console only reads: it never turns scripts or the network on (with scripts off, only loading and policy messages appear), accepts no expressions, and ignores `/browser` pages and the Mermaid renderer. Entries stay in memory for the current load, bounded, with the preview's address, query values, tokens and likely secrets redacted before they are kept. The status line counts errors and warnings. - **`/doctor` reports document rendering** ([#462](https://github.com/raiseCatError/notMyShell/issues/462)). A Documents section names the Chrome or Chromium the previews would use (its version once a browser surface has checked it this session, and whether it is running), the Mermaid it found (path, version, a warning for a major version other than the tested 12), the Mermaid setting and, when diagrams would not be drawn, why. Nothing is launched and the Mermaid bundle is not read, so it says what was found, not that drawing works. diff --git a/tests/welcomeLogoAnimation.test.ts b/tests/welcomeLogoAnimation.test.ts index 6068aa0e..f65a5b86 100644 --- a/tests/welcomeLogoAnimation.test.ts +++ b/tests/welcomeLogoAnimation.test.ts @@ -13,6 +13,7 @@ import {assetCapabilities, motionAsset} from '../src/welcome/render.js'; import {NativeWelcomePresentation} from '../src/welcome/startup.js'; import type {WelcomeAsset} from '../src/welcome/assets.js'; import {normalizePromptConfiguration} from '../src/prompt/configuration.js'; +import {presentationClock} from '../src/motion/PresentationClock.js'; import {displayWidth, stripAnsi} from '../src/util/text.js'; const PRESETS = LOGO_ANIMATIONS.filter((p): p is Exclude => p !== 'off'); @@ -130,6 +131,7 @@ test('startup plays the generated frames, settles on the still, and a gate shows assert.equal(first.active, true); const done = native.frame(80, 40, 4000); assert.equal(done.active, false, `${preset} once ends`); + assert.equal(presentationClock.subscriberCount, 0, `${preset}: no frame timer after Once ends`); native.dispose(); const gated = new NativeWelcomePresentation(snapshot(model({logoAnimation: preset})), service(), () => ({color: true, reducedMotion: true}), () => ({red: 0, green: 0, blue: 0}), () => {}, still); const frame = gated.frame(80, 40, 0); From c36202366c8ed940e7aa59199fb778e0d034844e Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:22:11 +0530 Subject: [PATCH 3/3] Modern controls only: shared checkmark for on/off values, a permanent AGENTS rule and a guard test renderValueControl('bool') drew a radio-style (*) toggle; it now uses checkMark like renderToggle. AGENTS.md names the shared pickers, toggles and reviews to reuse, and tests/uiConsistency.test.ts fails on hand-drawn [x]/[ ] or (*)/( ) marks. --- AGENTS.md | 8 ++++++++ src/ui/controls.ts | 4 +++- tests/uiConsistency.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 5c4d147c..8a696041 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,14 @@ Any actual image goes through the shared graphics service (`src/graphics/`, [gra ## UI consistency (permanent rule) All new NMSh UI reuses the established components and interaction patterns before anything is drawn by hand: `framePanel`, the tab strip (`renderTabStrip`), the selected-row band (`selectedRowBand`), `renderControlRows`, `groupedList`, the Settings and row panels, the palette and glyph tables (`UI_COLORS`, `GLYPHS`, Safe Glyphs, NO_COLOR rules) and the shared panel keys (↑↓ select, ←→/Tab switch, Enter confirm, Esc back). Ad hoc bracketed menus (`[1 Option]`), one-off boxes and private color tables are not the default; a new surface that cannot reuse a component first extends the shared one. Menus, text, selection controls and chrome stay terminal text: graphics protocols and text sizing are for real images and document or celebration typography, never a replacement for ordinary menus. Reviews check this the same way they check input ownership. The multiline paste review (`src/input/BatchReview.ts`) is the reference: [multiline execution modes](docs/design/multiline-execution-modes.md). +Modern controls only (permanent rule): never hand-draw legacy widgets. No hard-coded `[x]` / `[ ]` checkboxes, `(*)` / `( )` +radios, `y/N` prompts or bracketed key hints in UI code. Use the shared pieces instead: `checkMark` (src/ui/glyphs.ts; it falls +back to `[x]`/`[ ]` only under Safe Glyphs), `renderToggle`, `renderSelect`, `renderMultiSelect`, `renderTextValue`, +`renderConfirm`/`renderConfirmButtons` (src/ui/formControls.ts), `renderValueControl` (src/ui/controls.ts), `selectedRowBand`, +`renderControlRows`, and for editors and reviews `renderStudioLayout`/`renderStudioReview` (src/ui/StudioLayout.ts). Before adding a +picker, menu or confirmation, find the existing one that does the job (Settings, /tools, /theme, the command palette, BatchReview) +and reuse or extend it. tests/uiConsistency.test.ts enforces the widget part of this rule. + ## Input invariants - `Shift+Enter`: newline (multiline) - `Ctrl+J`: fallback newline diff --git a/src/ui/controls.ts b/src/ui/controls.ts index 07d21013..d0f9a699 100644 --- a/src/ui/controls.ts +++ b/src/ui/controls.ts @@ -1,5 +1,6 @@ import {foreground, UI_COLORS} from './palette.js'; import {displayWidth} from '../util/text.js'; +import {checkMark} from './glyphs.js'; const RESET = '\u001B[0m'; @@ -27,7 +28,8 @@ export function renderControlRows(controls: ReadonlyArray { + const {readdirSync, readFileSync, statSync} = await import('node:fs'); + const {join, relative} = await import('node:path'); + // Content renderers and game art may draw these on purpose (Markdown task syntax, the browser's literal [ and ] keys); + // the shared mark owns the Safe-Glyph fallback. + const allowed = new Set(['src/ui/glyphs.ts', 'src/markdown/render.ts', 'src/agents/workspace/sidePanel.ts', 'src/viewer/markdownEdit.ts', 'src/browser/BrowserPanel.ts']); + const legacy = /(['"`])\s*(?:\[x\]|\[ \]|\(\*\)|\( \))\s*\1/u; + const offenders: string[] = []; + const walk = (dir: string): void => { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) { if (name !== 'arcade') walk(path); continue; } + if (!name.endsWith('.ts')) continue; + const file = relative(process.cwd(), path); + if (allowed.has(file)) continue; + readFileSync(path, 'utf8').split('\n').forEach((line, index) => { if (!/^\s*(?:\/\/|\*|\/\*)/u.test(line) && legacy.test(line)) offenders.push(`${file}:${index + 1}`); }); + } + }; + walk(join(process.cwd(), 'src')); + assert.deepEqual(offenders, [], 'use checkMark / renderToggle / renderMultiSelect from the shared UI components'); +});