c5d246f728d6
replace static peachscript text with animated RuneCanvas — scrolling rows with accent embers
- time
- 2026-04-27 17:50 UTC · 6 months ago
- machine
- koh2
- parent
- f2a5092253bd
↓ peach-browser-c5d246f7.tar.gzatlas at this save
files changed3 files
src/popup/components/RuneCanvas.tsx+199 −0src/popup/styles/popup.css+4 −30src/popup/views/Export.tsx+2 −1src/popup/components/RuneCanvas.tsx+199 −0
@@ -0,0 +1,199 @@1+import { onCleanup, onMount } from "solid-js";2+3+interface RuneCanvasProps {4+ runeString: string;5+ width: number;6+ height: number;7+}8+9+const FONT_SIZE = 17;10+const LINE_HEIGHT = FONT_SIZE * 2.3;11+const LETTER_WIDTH = FONT_SIZE * 1.35;12+const LETTER_SPACING = FONT_SIZE * 0.18;13+const SPEEDS = [18, 24, 14];14+const FALLBACK_ACCENT = "#F49C70";15+16+function readAccentHex(): string {17+ const raw = getComputedStyle(document.documentElement)18+ .getPropertyValue("--peach-accent")19+ .trim();20+ if (/^#[0-9a-fA-F]{6}$/.test(raw)) return raw;21+ return FALLBACK_ACCENT;22+}23+24+export function RuneCanvas(props: RuneCanvasProps) {25+ let canvas!: HTMLCanvasElement;26+ let raf = 0;27+ let running = false;28+ let accentHex = FALLBACK_ACCENT;29+ let accentRgb: [number, number, number] = [0xf4, 0x9c, 0x70];30+31+ function setAccent(hex: string) {32+ accentHex = hex;33+ accentRgb = [34+ parseInt(hex.slice(1, 3), 16),35+ parseInt(hex.slice(3, 5), 16),36+ parseInt(hex.slice(5, 7), 16),37+ ];38+ }39+40+ onMount(() => {41+ const ctx = canvas.getContext("2d");42+ if (!ctx) return;43+44+ setAccent(readAccentHex());45+46+ const accentObserver = new MutationObserver(() => {47+ const next = readAccentHex();48+ if (next !== accentHex) setAccent(next);49+ });50+ accentObserver.observe(document.documentElement, {51+ attributes: true,52+ attributeFilter: ["style"],53+ });54+55+ const W = props.width;56+ const H = props.height;57+58+ const SYMBOLS_PER_ROW =59+ Math.ceil(W / (LETTER_WIDTH + LETTER_SPACING)) + 4;60+ const NUM_ROWS = Math.floor(H / LINE_HEIGHT) + 1;61+ const TOTAL_VISIBLE = NUM_ROWS * SYMBOLS_PER_ROW;62+ const GLOW_COUNT = Math.min(8, Math.max(3, Math.floor(TOTAL_VISIBLE * 0.03)));63+64+ interface Glow {65+ row: number;66+ col: number;67+ phase: number;68+ speed: number;69+ lifetime: number;70+ age: number;71+ }72+73+ const spawn = (): Glow => ({74+ row: Math.floor(Math.random() * NUM_ROWS),75+ col: Math.floor(Math.random() * SYMBOLS_PER_ROW),76+ phase: Math.random(),77+ speed: 0.4 + Math.random() * 0.4,78+ lifetime: 2000 + Math.random() * 3000,79+ age: 0,80+ });81+82+ const glows: Glow[] = Array.from({ length: GLOW_COUNT }, spawn);83+84+ const offsets: number[] = Array.from({ length: NUM_ROWS }, () =>85+ Math.random() * props.runeString.length * (LETTER_WIDTH + LETTER_SPACING),86+ );87+88+ const TOTAL_STRING_WIDTH =89+ props.runeString.length * (LETTER_WIDTH + LETTER_SPACING) || 1;90+91+ let lastTime = performance.now();92+93+ const frame = (now: number) => {94+ const dt = Math.min(now - lastTime, 64); // clamp huge dt after pause95+ lastTime = now;96+97+ ctx.clearRect(0, 0, W, H);98+ ctx.fillStyle = "#1a1410";99+ ctx.fillRect(0, 0, W, H);100+ ctx.font = `${FONT_SIZE}px PeachScript, monospace`;101+ ctx.textBaseline = "alphabetic";102+103+ const glowMap = new Map<string, number>();104+ for (const g of glows) {105+ g.age += dt;106+ g.phase = (g.phase + (g.speed * dt) / 1000) % 1;107+ const lifetimeFraction = g.age / g.lifetime;108+ const lifetimeEnvelope = Math.sin(lifetimeFraction * Math.PI);109+ const pulseAlpha = Math.sin(g.phase * Math.PI * 2) * 0.5 + 0.5;110+ const alpha = Math.max(0, lifetimeEnvelope) * pulseAlpha * 0.85;111+ glowMap.set(`${g.row},${g.col}`, alpha);112+ if (g.age >= g.lifetime) Object.assign(g, spawn());113+ }114+115+ const [ar, ag, ab] = accentRgb;116+ const runes = props.runeString;117+ const runeLen = runes.length;118+ if (runeLen === 0) {119+ raf = requestAnimationFrame(frame);120+ return;121+ }122+123+ for (let row = 0; row < NUM_ROWS; row++) {124+ const speed = SPEEDS[row % SPEEDS.length];125+ offsets[row] = (offsets[row] + (speed * dt) / 1000) % TOTAL_STRING_WIDTH;126+ const y = row * LINE_HEIGHT + FONT_SIZE + 12;127+128+ for (let col = 0; col < SYMBOLS_PER_ROW; col++) {129+ const x =130+ col * (LETTER_WIDTH + LETTER_SPACING) -131+ (offsets[row] % (LETTER_WIDTH + LETTER_SPACING));132+ if (x < -LETTER_WIDTH || x > W + LETTER_WIDTH) continue;133+134+ const baseIdx = Math.floor(offsets[row] / (LETTER_WIDTH + LETTER_SPACING));135+ const strIdx = (((baseIdx + col) % runeLen) + runeLen) % runeLen;136+ const char = runes[strIdx];137+138+ const glowAlpha = glowMap.get(`${row},${col}`) ?? 0;139+140+ if (glowAlpha > 0.01) {141+ ctx.save();142+ ctx.shadowColor = `rgba(${ar}, ${ag}, ${ab}, ${glowAlpha * 0.9})`;143+ ctx.shadowBlur = 10;144+ ctx.fillStyle = `rgba(${ar}, ${ag}, ${ab}, ${0.6 + glowAlpha * 0.4})`;145+ ctx.fillText(char, x, y);146+ ctx.restore();147+ } else {148+ ctx.fillStyle = "rgba(245, 240, 234, 0.82)";149+ ctx.fillText(char, x, y);150+ }151+ }152+ }153+154+ raf = requestAnimationFrame(frame);155+ };156+157+ const start = () => {158+ if (running) return;159+ running = true;160+ lastTime = performance.now();161+ raf = requestAnimationFrame(frame);162+ };163+164+ const stop = () => {165+ if (!running) return;166+ running = false;167+ cancelAnimationFrame(raf);168+ };169+170+ const onVisibility = () => {171+ if (document.hidden) stop();172+ else start();173+ };174+ document.addEventListener("visibilitychange", onVisibility);175+176+ // Font-load guard: render boxes if font isn't ready, so wait for it.177+ document.fonts.load(`${FONT_SIZE}px PeachScript`).finally(start);178+179+ onCleanup(() => {180+ stop();181+ accentObserver.disconnect();182+ document.removeEventListener("visibilitychange", onVisibility);183+ });184+ });185+186+ return (187+ <canvas188+ ref={canvas}189+ width={props.width}190+ height={props.height}191+ class="peach-rune-canvas"192+ style={{193+ display: "block",194+ width: `${props.width}px`,195+ height: `${props.height}px`,196+ }}197+ />198+ );199+}src/popup/styles/popup.css+4 −30
@@ -1575,38 +1575,12 @@15751575 display: grid;15761576 gap: 10px;15771577 }1578-.peach-script-string {1579- font-family: "PeachScript", monospace;1580- font-size: 22px;1581- line-height: 1.55;1582- letter-spacing: 0.04em;1583- color: var(--peach-t0);1584- word-break: break-all;1585- overflow-wrap: anywhere;1586- white-space: normal;1587- user-select: all;1588- background: color-mix(in srgb, var(--peach-accent) 5%, var(--peach-bg));1589- border: 1px solid color-mix(in srgb, var(--peach-accent) 22%, var(--peach-t3));1578+.peach-rune-canvas {15901579 border-radius: 8px;1591- padding: 14px 16px;1592- margin: 0;1580+ border: 1px solid color-mix(in srgb, var(--peach-accent) 22%, var(--peach-t3));1581+ background: #1a1410;15931582 flex: 1;1594- min-height: 140px;1595- overflow-x: hidden;1596- overflow-y: auto;1597- scrollbar-width: thin;1598- scrollbar-color: var(--peach-accent-dim) transparent;1599- text-shadow: 0 0 24px color-mix(in srgb, var(--peach-accent) 32%, transparent);1600-}1601-.peach-script-string::-webkit-scrollbar {1602- width: 6px;1603-}1604-.peach-script-string::-webkit-scrollbar-thumb {1605- background: var(--peach-accent-dim);1606- border-radius: 3px;1607-}1608-.peach-script-string::-webkit-scrollbar-track {1609- background: transparent;1583+ align-self: center;16101584 }16111585 .peach-script-input {16121586 font-family: "PeachScript", monospace;src/popup/views/Export.tsx+2 −1
@@ -7,6 +7,7 @@77 import { sendMessage } from "../App";88 import { buildPayload } from "../../crypto/peachPayload";99 import { encodePeachScript } from "../../crypto/peachScript";10+import { RuneCanvas } from "../components/RuneCanvas";1011 import {1112 QR_MAX_BYTES,1213 renderPeachQR,@@ -204,7 +205,7 @@204205 >205206 <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-result-group">206207 <p class="peach-settings-label">peachscript</p>207- <div class="peach-script-string">{script()}</div>208+ <RuneCanvas runeString={script() ?? ""} width={288} height={240} />208209 <p class="peach-export-card-copy" style="margin:0 0 8px">209210 {entryCount()} entr{entryCount() === 1 ? "y" : "ies"}210211 {stats().cards > 0 ? ` · ${stats().cards} card${stats().cards === 1 ? "" : "s"}` : ""}