Guidance for AI coding agents working in this repo. Humans: see README.md.
Text Visualizer — a lightweight client-side SPA built on Vite + Alpine.js + Tailwind.
The user types text, picks a display mode (QR, large text, marquee, blink, mirror, morse),
tunes a few controls, and the result opens fullscreen. UI is authored as a plain HTML page,
made reactive with Alpine, and routed with pinecone-router. Nothing is prerendered — the
server ships one index.html shell.
All controls (text, mode, speed, colors, orientation) live on the home page; the fullscreen view is deliberately chrome-free — just the visualization and a close button.
Run the full suite and fix anything red before presenting a change:
bun run check # Biome lint + format (writes)
bun run typecheck # tsc --noEmit
bun run test # Vitest unit tests
bun run build # Vite build (emits dist/ + 404.html)
bun run test:e2e # Playwright: dev + production-build suites (first time: bunx playwright install chromium)test:e2e runs two projects from one playwright.config.ts: dev (e2e/ vs.
the dev server) and preview (e2e-preview/ vs. the production build under the
Pages base path — this catches base-path and 404.html SPA-fallback regressions,
and is what CI gates on). Run a single suite with playwright test --project=dev|preview.
For UI changes, also look at the result (bun run dev, or a Playwright screenshot) —
several layout/timing bugs are only visible visually.
- One page (
home.html). The entire app issrc/pages/home.html— an Alpine component (x-data="textVisualizer") that manages persisted settings + transient view state. - The
page-templatesplugin (vite.config.ts) servessrc/pages/*.htmlat/pages/*.htmlin dev and emits them todist/pages/on build. - Persistent chrome is inline in
index.html— minimal nav (wordmark + dark-mode toggle) and a version+credit footer. - Pure logic is in
src/modes.ts(DOM/Alpine-free, unit-tested inmodes.test.ts): the mode registry (MODES— add a mode here + a matchingx-showview; the home buttons render from it viax-for), Morse conversion (textToMorse/morseTimeline), the centralizedspeed→ per-mode timing mappers, and deep-linkencodeQuery/decodeQuery. - Reactive glue is in
src/alpine.ts— thetextVisualizer()factory:open(mode),close(),updateQr(),fitText,fitMarqueeFont, the morse player, fullscreen + wake-lock, and URL sync. Browser APIs are feature-guarded so the factory stays unit-testable under Node. fitTextbinary-searches the max font size that fits using an off-screen helper (avoidsoverflow:hiddenskew); it's orientation-aware (swaps the available width/height when the stage is rotated for vertical layout).src/config.tsholds the deploy base path (BASE = "/text-visualizer/"), shared by the build and the router.
Bun (pm + runner) · Biome (lint/format) · Vitest (unit) · Playwright (e2e) ·
release-it (releases). Vite 8 is Rolldown/oxc-based. Runtime libs: alpinejs,
pinecone-router, tailwindcss/daisyui, uqr.
- Pages are plain HTML served by the
page-templatesplugin — not bundled. In dev its middleware must run BEFORE Vite's SPA fallback (added directly insideconfigureServer, not the returned post-hook), or/pages/x.htmlwould resolve toindex.html. On build it emits the files todist/pages/. - Don't call
Alpine.initTree()yourself. Alpine's initial walk inits the inline chrome; its MutationObserver inits the HTML pinecone loads into#app. A manual init double-binds handlers. fitTextuses an off-screen helper, notgetBoundingClientRect()on the actual element — the overlay'soverflow:hiddenclipsgetBoundingClientRect()and returns the container width, not the text's intrinsic width.- Alpine magic props (
$watch,$nextTick,$refs) aren't available in unit tests. Inject noop stubs:Object.assign(textVisualizer(), { $nextTick: () => {} }). Keep all DOM/browser work inside$nextTick/requestAnimationFramecallbacks or behindtypeof window/document/navigatorguards so the factory runs under Node (test.environment: "node"). persisted(value, key)is a node-safe$persistwrapper. It callswindow.Alpine.$persist(v).as("tv-"+key)in the browser but returns the plain value whenwindow/$persistis absent (unit tests). The persist plugin is registered inapp.ts.@alpinejs/persistships no types. Its ambientdeclare modulelives insrc/alpinejs-persist.d.ts(a file with no top-level import/export, so it provides types); the$persistmagic is added to Alpine via a module augmentation inglobals.d.ts.- NEVER put
x-showand a string:styleon the same element.x-showtoggles the element's inlinedisplay; a string:stylebinding rewrites the wholestyleattribute on every re-render and wipesdisplay:none, so the element pops visible. Use the object form (:style="{ background: bg }") — Alpine sets only those properties and leavesx-show'sdisplayalone. (Bit the overlay and the blink<p>.) - Marquee loops via two side-by-side copies, translating
translateX(0)→translateX(calc(-1 * var(--marquee-dist))).--marquee-distis the measured width of one copy, stored in reactive state (marqueeDist) and emitted inside the element's:stylestring — NOT set imperatively withstyle.setProperty, which the:stylere-render (on speed change) would wipe, breaking the loop. Measure only after arequestAnimationFrameso layout has committed. - Marquee font is canvas-measured (
fitMarqueeFont):ctx.measureTextgives the glyph ink height (actualBoundingBoxAscent + actualBoundingBoxDescent), sized to ~90% of the cross-axis so descenders (g/p/q) aren't clipped — a fixedfont-size: 100vhclips them. - All text modes (large, mirror, blink) must call
fitText. They shareTEXT_REFS(mode →x-ref); forgetting one leaves that mode at the default tiny font. - Vertical layout rotates the stage, not each element: the overlay's inner stage gets
rotate-90+ swapped100vh/100vwdims. Tailwind 4'srotate-90uses the standalonerotateCSS property (computedtransformstaysnone— test the right property). - The rotated stage MUST be
shrink-0. It's a flex child of the centered overlay; in vertical itswidth:100vhexceeds the (portrait) viewport width, so default flex-shrink collapses it to a square — text then fills only a centered square, not the full long axis. Only reproduces on mobile (on desktop100vh< viewport width, so it never shrinks). - Tailwind auto-scans
src/, so classes used only insrc/pages/*.htmlare generated — no@sourceneeded. - Biome lints the page HTML. Alpine-driven anchors (text via
x-text) tripa11y/useAnchorContent; it's turned off forsrc/pages/**/*.htmlvia abiome.jsonoverridesentry. - pinecone v7:
settings()is a function, called inalpine:init— NOT options passed toAlpine.plugin(). We setbasePath,targetID,hash. - basePath must NOT have a trailing slash (
import.meta.env.BASE_URLdoes) or routes double up. We strip it:.replace(/\/$/, ""). - The
notfoundroute ships a default handler thatconsole.errors. We override it withx-handler="[]"so the console stays clean. - GitHub Pages needs a
404.htmlSPA fallback. The build copiesdist/index.html→dist/404.htmlso deep links / refreshes boot the app. baselives insrc/config.ts(BASE = "/text-visualizer/"), applied for build + preview only (dev stays/). It drives both Vite'sbaseand the routerbasePath.
- Avoid
as/any— narrow with typedthisparams / type guards. - Match existing style; Biome formats (4-space indent, double quotes).
- Commit directly to
main(no feature branch). - Keep
README.mdin sync when behavior/visuals change.