Radically simple rewrite of Unscramm with a minimal stack:
- Vite + React + TypeScript
- Tailwind CSS v4 (tokens via
@themein CSS) - Framer Motion for animations
- Vitest + Testing Library for tests
See PRD: docs/unscramm-v3-prd-spec.md and style guide: docs/design_guidelines/styleguide.md.
Unscramm is an interactive "animated spellcheck" that shows how a misspelled word transforms into the correct word via clear, accessible character transitions. It serves two goals:
- Accessibility-focused visualization for dyslexic readers, or anyone learning to read, to see how letters change through deletion, movement, and insertion.
- A process experiment in rigorous, modular development where the majority of implementation is driven through AI-assisted workflows with clear specs and tests.
Unscramm runs on multiple platforms from a single codebase:
- 🌐 Chrome Extension - Browser popup with spell-checking
- 🖥️ Mac Menu Bar App - Native macOS app via Tauri
See PLATFORM-GUIDE.md for architecture details and platform-specific development.
# Chrome Extension
npm install
npm run dev:chrome # Development
npm run build:chrome # Production build
# Mac Menu Bar App (macOS only)
npm run tauri:dev # Development
npm run tauri:build # Production build- Install:
npm install - Dev:
npm run dev(defaults to Chrome extension) - Build:
npm run build(defaults to Chrome extension) - Test (CI):
npm test
The app is packaged as a Manifest V3 Chrome extension with a popup UI and built-in spell-checking capabilities.
- Built-in Dictionary: Includes a frequency-based English dictionary (~370k words) for offline spell suggestions
- Smart Suggestions: Uses Damerau-Levenshtein distance to find the best corrections for misspelled words
- Three-Stage Flow:
- Input: Paste from clipboard or type a word
- Suggestions: View ranked spelling suggestions
- Animation: Watch the transformation from misspelled to correct word
- Animated Visualization: Color-coded character transitions (red=deletions, green=insertions, yellow=moves)
npm run buildThis builds the extension to the dist/ folder, including:
manifest.json- Chrome extension manifestindex.html- Popup UIassets/- Bundled JS, CSS, and imagesicon*.svg- Extension iconsfrequency-dictionary.txt- Built-in word frequency dictionary
- Build the extension:
npm run build - Open Chrome and navigate to
chrome://extensions/ - Enable "Developer mode" (toggle in top-right)
- Click "Load unpacked"
- Select the
dist/folder - The Unscramm extension icon will appear in your toolbar
- Click the icon to open the popup and use the app
Source of truth: src/index.css under the @theme block. These CSS variables define colors, typography, and semantic states used across the app.
- Colors:
--color-bg,--color-panel,--color-button,--color-button-hover,--color-text,--color-text-secondary - Semantic colors:
--color-deletion(red),--color-move(yellow),--color-insertion(green) - Typography:
--font-sans
Usage:
- Tailwind with variables:
bg-[--color-panel] text-[--color-text] - Utilities from
src/index.css:.text-deletion,.text-move,.text-insertion
Benefits: consistency, easy theming, maintainability, and accessible contrast tuning from a single place.
- Runner: Vitest (v2)
- Environment: jsdom
- Library:
@testing-library/react+@testing-library/jest-dom - Setup:
src/test/setup.ts
The setup file does two things:
- jest-dom matchers via
@testing-library/jest-dom/vitestso you can use matchers liketoBeInTheDocument(). - matchMedia polyfill for jsdom so components using reduced-motion queries work in tests.
In src/test/setup.ts, we define a minimal matchMedia on globalThis and mirror it to window:
import '@testing-library/jest-dom/vitest'
if (typeof (globalThis as any).matchMedia !== 'function') {
const reduced = (globalThis as any).__TEST_MATCH_MEDIA_REDUCED__ ?? true
;(globalThis as any).matchMedia = (query: string) => ({
matches: reduced && query.includes('prefers-reduced-motion: reduce'),
media: query,
onchange: null,
addListener() {},
removeListener() {},
addEventListener() {},
removeEventListener() {},
dispatchEvent() { return false },
})
}
if (typeof window !== 'undefined' && typeof window.matchMedia !== 'function') {
// @ts-ignore
window.matchMedia = (globalThis as any).matchMedia
}Tip: set (globalThis as any).__TEST_MATCH_MEDIA_REDUCED__ = false if you need to simulate non-reduced motion in specific tests.
npm testThere are unit tests for computeEditPlan() and a smoke test for WordUnscrambler that asserts the final DOM equals the target word after animation.
This project uses Tailwind CSS v4 with tokens defined directly in CSS (no tailwind.config.js required). See src/index.css:
@import "tailwindcss";
@theme {
--color-bg: #111;
--color-panel: #181818;
--color-button: #333;
--color-button-hover: #222;
--color-text: #ffffff;
--color-text-secondary: #777777;
--color-deletion: #ef4444;
--color-insertion: #22c55e;
--color-move: #eab308;
--font-sans: "Istok Web", system-ui, Avenir, Helvetica, Arial, sans-serif;
}These map 1:1 to the semantic tokens in docs/design_guidelines/styleguide.md.
The core component lives at src/components/WordUnscrambler.tsx and follows the PRD phases:
- idle → deleting → moving → inserting → final
It uses computeEditPlan() to determine deletions/insertions and performs a FLIP-style reorder for survivors. Reduced motion is respected via matchMedia('(prefers-reduced-motion: reduce)').