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 homepage in dark mode with a griffin logo, navigation bar showing Learn, Letters, Words, Practice, About and Sign in, the headline "Skip the drills. Start reading.", a Get Started for Free button, and a Thai language card marked available now
Client

Glyphin

Status

Live

Introduction

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 Learn screen in dark mode showing the Thai course path, with Stage 1 "Runtime First Decoding Wins" at 0/3 and lesson cards for มาก and ดี offering Learn and Practice buttons.

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.runes predicate that exempts node_modules), giving fine-grained reactivity without a heavy client runtime.
  • Server-first rendering: the root layout sets prerender = true with 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, plus svelte-check as a separate CI gate.
  • SCSS via Sass, with shared variables and mixins auto-injected through Vite's additionalData so 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.
Glyphin alphabet screen in dark mode showing a top navigation bar with Learn, Letters, Words, Practice, About and Sign in, a "0 of 90 letters learned" progress bar, and a grid of unlearned Thai consonant tiles marked with question marks

Key Implementation Details

Database-as-source-of-truth curriculum pipeline

  • Curriculum lives in Postgres and is published into a versioned delivery.* catalog carrying a publicationId and publicationCacheKey, which define the cache and version boundary for public lesson content.
  • A prebuild step (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-imports rule 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-src stays 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 allowing unsafe-inline for scripts.
  • Auth uses email OTP with @supabase/ssr request-scoped clients, getUser() for every trust decision (never cookie-derived sessions), and Cloudflare Turnstile allow-listed in script-src/frame-src.
  • SvelteKit's built-in CSRF origin check is documented as load-bearing for the /api/learner/sync endpoint, with guard comments at both the config and the route.

Local-first learner state with server reconciliation

  • Anonymous progress persists to localStorage so 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 node project for server trust boundaries, utils, and curriculum-mapping logic, and a jsdom project 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/ssr resolves on the server.
  • A single quality:check command 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.