A guided app for learning to read Thai script, built around real words people actually see on signs, menus, and street food stalls rather than an abstract alphabet chart. Each lesson breaks one useful word into its pieces, teaches only the letters and rules that word needs, then reinforces the pattern with short drills before unlocking the next step. Under the hood it's a server-rendered web app with a database-backed curriculum pipeline, designed from the start so the same lesson engine can carry other writing systems.
Glyphin
A web app teaching Thai script through real words found on signs and menus rather than alphabet drills, with a database-backed curriculum pipeline and script-agnostic lesson engine designed to carry dozens of writing systems without architectural rewrites.

Glyphin
Live
Introduction

The Challenge
Build a real-word-first reading app for Thai script that teaches high-frequency graphemes through practical vocabulary instead of abstract alphabet drills, while establishing a curriculum pipeline and lesson engine architecture flexible enough to support dozens of future writing systems without rewriting core contracts.
The Outcome
Deployed a fully functional Thai reading course, available to learners on the live site.
Tech Stack
Frontend
- SvelteKit
- Svelte
- SCSS
- Sass
- Vite
- jsdom
- Testing Library
- Bits UI
- Lucide
Language
- TypeScript
- Node.js
- PL/pgSQL
- Python
Backend
- Cloudflare Workers
- Supabase
- Cloudflare Turnstile
Database
- PostgreSQL
DevOps
- pnpm
- Husky
- lint-staged
- GitHub Actions
- Knip
- Wrangler
- markdownlint
- Dependabot
Tooling
- ESLint
- Vitest
- Prettier
- Stylelint
- SvelteKit 2 with Svelte 5 in runes mode (enforced project-wide via a
compilerOptions.runespredicate that exemptsnode_modules), giving fine-grained reactivity without a heavy client runtime. - Server-first rendering: the root layout sets
prerender = truewith SSR on by default, so public curriculum pages ship as static HTML and only routes that genuinely need per-request behaviour (/auth,/api/**) opt out. - Deployed to Cloudflare Workers Static Assets through
@sveltejs/adapter-cloudflare, with Wrangler for local/edge tooling. - Supabase on PostgreSQL holds the canonical curriculum and learner data — PL/pgSQL is the single largest language in the repo, covering migrations, delivery views, and the learner sync projector.
- TypeScript in strict mode with
verbatimModuleSyntax, plussvelte-checkas a separate CI gate. - SCSS via Sass, with shared variables and mixins auto-injected through Vite's
additionalDataso every component style file gets the design tokens for free. - Bits UI headless primitives (wrapped in app-owned components under
src/lib/components/ui) and Lucide icons for the interactive layer. - pnpm 11 on pinned Node 24.15.0, with a workspace-level override policy for transitive security advisories.

Key Implementation Details
Database-as-source-of-truth curriculum pipeline
- Curriculum lives in Postgres and is published into a versioned
delivery.*catalog carrying apublicationIdandpublicationCacheKey, which define the cache and version boundary for public lesson content. - A
prebuildstep (publication:generate) bakes the active publication into a generated lesson artifact so prerendered lesson HTML always matches a known content version. - An ESLint
no-restricted-importsrule blocks app code from importing the authoring source ($lib/data/thai) directly — everything must go through the published delivery catalog, with the data and server layers as the only exceptions. - Dedicated scripts separate "refresh content but keep my auth/learner data" (
db:content:refresh) from a full local reset, which keeps day-to-day curriculum iteration cheap.
Security boundary designed before the auth feature shipped
- Supabase is server-only: no browser client ships, so
connect-srcstays at'self'and the browser talks exclusively to app-owned SvelteKit endpoints. - CSP is configured with
mode: "auto"so SvelteKit emits SHA-256 hashes for prerendered pages (delivered via<meta>) and per-request nonces for SSR routes like/auth; the one inline theme script was deliberately extracted to a static file rather than allowingunsafe-inlinefor scripts. - Auth uses email OTP with
@supabase/ssrrequest-scoped clients,getUser()for every trust decision (never cookie-derived sessions), and Cloudflare Turnstile allow-listed inscript-src/frame-src. - SvelteKit's built-in CSRF origin check is documented as load-bearing for the
/api/learner/syncendpoint, with guard comments at both the config and the route.
Local-first learner state with server reconciliation
- Anonymous progress persists to
localStorageso the learner shell renders instantly and works offline; authenticated learner data arrives as a projection that overlays prerendered lesson pages instead of forcing them back to per-user HTML. - Local progress is treated as untrusted input at sign-in — merging into the account happens through a validated, idempotent, course-version-aware server path.
- Row-level security is treated as a last line of defence rather than the primary contract; all learner writes go through server-owned routes.
Script-agnostic lesson contract
- The data model encodes a strict two-phase lesson (learning → scored practice checkpoint) with typed anchor words, graphemes, rules, drills, and review sets; components render the contract rather than inventing lesson-specific shapes.
- DTOs deliberately use neutral names (
course,text,segments,graphemes) so the same engine can host other writing systems — 60+ candidate curricula (Hangul, Devanagari, Hebrew, Cherokee, cuneiform, Braille) are already scoped in the authoring notes. - Offline authoring CLIs (
curriculum:scaffold,validate,score,review) plus Python corpus/frequency analysis tooling support building new courses on frequency-first sequencing rather than alphabet order.
Two-project test setup and a strict quality gate
- Vitest runs two isolated projects: a
nodeproject for server trust boundaries, utils, and curriculum-mapping logic, and ajsdomproject using @testing-library/svelte for real component rendering and the localStorage progress store. They're deliberately kept apart so browser resolve conditions never leak into how@supabase/ssrresolves on the server. - A single
quality:checkcommand chains tests, Prettier, markdownlint, ESLint 10 flat config, Stylelint, and type-checking; Husky + lint-staged run the fast subset pre-commit. - The GitHub Actions workflow runs each check independently after install so one push surfaces every failure at once, with concurrency cancellation on superseded runs and Dependabot plus Knip covering dependency hygiene.
Documentation as an architectural constraint
- The repo enforces a hard split: durable reference docs (auth architecture, DB schema contract, SEO/indexing contracts, learning philosophy) live in
docs/, while dated plans and task trackers live in.ai/— with an explicit test ("if it has checkboxes or a status line, it's a plan"). - Agent- and Copilot-facing instruction files (
.github/instructions/*.instructions.md) encode per-area conventions for routes, components, API handlers, data/state, and lesson flow, so tooling-assisted changes stay inside the same boundaries as hand-written ones.
What I Contributed
- Designed and implemented a database-backed curriculum publication pipeline where lesson content flows from TypeScript authoring sources through Supabase delivery tables into versioned, cache-keyed artifacts, with ESLint enforcement blocking direct imports of authoring data from application code.
- Architected a server-first SvelteKit application with prerendering as the default, strict CSP (auto-mode hashes for static pages, nonces for SSR routes), server-only Supabase access, and CSRF protection — all documented as load-bearing security boundaries before the auth feature shipped.
- Built a two-project Vitest setup isolating Node server tests from jsdom component tests to prevent browser resolve conditions from altering how server dependencies like @supabase/ssr resolve, with a comprehensive CI quality gate running formatting, linting, type-checking, and tests independently on every push.
- Created a script-agnostic lesson contract and data model (course/text/segments/graphemes instead of language-specific names) with offline authoring CLIs and frequency-first sequencing tooling, enabling the same engine to carry other writing systems including an already-scoped Adlam/Fulani RTL course.
Outcome
-
Several early testers reported that the course helped them learn when previous attempts had not.
Glyphin is deployed as a fully functional Thai reading course. Several early testers have said it helped them learn when previous attempts had not.