diff --git a/README.md b/README.md index 0596bdf..bf5dc9b 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,15 @@ For the implemented product and current constraints, start with [docs/CURRENT_ST - Preview the worksheet before export - Download an A4 PDF with embedded Thai-capable fonts and progress feedback +## MVP contract + +The active MVP in this repo is intentionally narrower than the original historical planning docs: + +- Output is `PDF download` only +- Paper size is fixed to `A4` +- The shipped font set is `Traditional`, `Modern`, and `Cursive` +- Expanding scope for phase 2 is blocked until MVP docs, tests, and E2E coverage are aligned + ## Stack - React 19 + TypeScript @@ -37,7 +46,7 @@ For the implemented product and current constraints, start with [docs/CURRENT_ST ## Commands ```bash -pnpm install # install dependencies +pnpm install # required first step in a fresh worktree pnpm format # apply Prettier formatting pnpm format:check # verify formatting without changing files pnpm lint # run ESLint @@ -60,6 +69,16 @@ pnpm test:e2e:install pnpm test:e2e ``` +## Fresh worktree bootstrap + +When you create a new ephemeral worktree, run `pnpm install` before drawing conclusions from failed checks. Until dependencies exist locally, `pnpm test:run`, `pnpm build`, and `pnpm test:e2e` will fail for environment reasons rather than product reasons. + +Recommended personal workflow: + +- Use a local wrapper, alias, or script for worktree creation that immediately runs `pnpm install` +- Keep this as personal automation, not repo logic +- Use this repo verification order in every new worktree: `pnpm install`, `pnpm test:run`, `pnpm test:e2e` + The dev server runs at [http://localhost:5173](http://localhost:5173) by default. ## Project layout diff --git a/docs/CURRENT_STATE.md b/docs/CURRENT_STATE.md index 6f14c8d..ae6ed8e 100644 --- a/docs/CURRENT_STATE.md +++ b/docs/CURRENT_STATE.md @@ -1,11 +1,22 @@ # Current Project State -This document describes the implemented state of `thai_script_pro` as of March 27, 2026. +This document describes the implemented state of `thai_script_pro` as of March 28, 2026. + +This file is the source of truth for the shipped MVP in this repository. Historical planning docs in `docs/` remain useful background, but they do not override the behavior documented here. ## Product summary The app is a single-page worksheet generator for Thai script practice. It is entirely client-side and currently focuses on worksheet configuration, live preview, and PDF export. +## MVP locked decisions + +These decisions define the current MVP and should not be silently widened by later planning: + +- Output is `PDF download` only. Print support is out of scope for MVP. +- Paper size is not user-configurable in MVP. Export targets `A4` only. +- The shipped font scope is the current in-app set: `Traditional`, `Modern`, `Cursive`. +- Phase 2 planning is gated on MVP alignment and stronger regression coverage for current flows, not on adding more surface area first. + ## Implemented behavior ### Content selection @@ -65,9 +76,15 @@ Default configuration: - There is no print action in the current UI; output is PDF download only - There is no persistence layer or local storage for selections/settings - There is no backend -- There is no Playwright or E2E test suite in the repository - The current font set differs from the original MVP planning docs -- PDF export targets A4 only, even though earlier planning docs discussed optional Letter support +- PDF export targets A4 only; paper-size selection remains out of scope for MVP +- Playwright covers browser smoke flows, but broader end-to-end coverage is still in progress + +## Verification status + +- Vitest covers data, hooks, components, and selected app-level flows +- Playwright smoke coverage exists in `e2e/` for preview updates, download flow, and mobile sanity +- A fresh worktree is not ready for verification until `pnpm install` has been run locally ## Code map diff --git a/docs/PRD_02_SLICE_MODEL.md b/docs/PRD_02_SLICE_MODEL.md new file mode 100644 index 0000000..0fd064e --- /dev/null +++ b/docs/PRD_02_SLICE_MODEL.md @@ -0,0 +1,35 @@ +# PRD 2 slice model + +Use this document to shape the second PRD after MVP alignment is complete. The goal is to grow the app through thin vertical slices that are small enough to implement, test, and review without losing code context. + +## Gate before writing PRD 2 + +Do not start feature-expanding PRD work until all of the following are true: + +- The MVP contract in [CURRENT_STATE.md](./CURRENT_STATE.md) matches the implementation +- `pnpm install`, `pnpm test:run`, and `pnpm test:e2e` succeed in a fresh worktree +- Playwright covers the main create-preview-download flow and the most important current user paths + +## Required slice shape + +Every phase 2 slice should include these sections: + +- `User outcome`: the user-facing capability unlocked by the slice +- `UI changes`: visible controls, screens, messages, and interaction changes +- `State/data changes`: local state, persistence, derived data, and migrations if any +- `Vitest/RTL acceptance`: the key unit, hook, component, or app tests required +- `Playwright acceptance`: one or two browser flows that prove the slice works end to end + +## Recommended PRD 2 slice order + +1. `Saved worksheet presets and restore-last-session` +2. `Preview-to-export parity and export UX polish` +3. `Guided practice workflows such as reusable sets or session templates` +4. `New learning content types only after the workflow baseline is stable` + +## Slice rules + +- Prefer one meaningful user outcome per slice +- Avoid mixing persistence, new content breadth, and broad visual redesign in the same slice +- Keep each slice independently releasable +- Add tests as part of the slice definition of done, not afterward diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 6583644..6b09dd1 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -1,41 +1,152 @@ import { expect, test } from '@playwright/test' -test('desktop flow updates preview and downloads a pdf', async ({ page }) => { +test('consonant presets can be applied and cleared from the browser flow', async ({ + page, +}) => { await page.goto('/') - await expect( - page.getByRole('heading', { name: /thai worksheet generator/i }), - ).toBeVisible() - await expect(page.getByRole('region', { name: /preview/i })).toContainText( + const preview = page.getByRole('region', { name: /preview/i }) + const presetButton = page.getByRole('button', { name: /consonant presets/i }) + + await presetButton.click() + await page.getByRole('option', { name: /^Middle Class/i }).click() + + await expect(presetButton).toContainText(/middle class/i) + await expect(page.getByText(/9 of 44 selected/i)).toBeVisible() + await expect(preview).toContainText(/thai consonants writing practice/i) + + await presetButton.click() + await page.getByRole('option', { name: /^Middle Class/i }).click() + + await expect(presetButton).toContainText(/presets/i) + await expect(page.getByText(/0 of 44 selected/i)).toBeVisible() + await expect(preview).toContainText( /select consonants or vowels to see preview/i, ) +}) - await page.getByRole('button', { name: /consonant presets/i }).click() - await page.getByRole('option', { name: /^Middle Class/i }).click() +test('vowel presets can be applied and cleared from the browser flow', async ({ + page, +}) => { + await page.goto('/') - await expect( - page.getByRole('region', { name: /preview/i }), - ).not.toContainText(/select consonants or vowels to see preview/i) - await expect(page.getByRole('region', { name: /preview/i })).toContainText( - /thai consonants writing practice/i, + const preview = page.getByRole('region', { name: /preview/i }) + const presetButton = page.getByRole('button', { name: /vowel presets/i }) + + await presetButton.click() + await page.getByRole('option', { name: /^Short Vowels/i }).click() + + await expect(presetButton).toContainText(/short vowels/i) + await expect(page.getByText(/12 of 28 selected/i)).toBeVisible() + await expect(preview).toContainText(/thai vowels writing practice/i) + + await presetButton.click() + await page.getByRole('option', { name: /^Short Vowels/i }).click() + + await expect(presetButton).toContainText(/presets/i) + await expect(page.getByText(/0 of 28 selected/i)).toBeVisible() + await expect(preview).toContainText( + /select consonants or vowels to see preview/i, ) +}) + +test('manual character selection updates preview content', async ({ page }) => { + await page.goto('/') + + const preview = page.getByRole('region', { name: /preview/i }) + + await page.getByRole('button', { name: /ก\s*ไก่/i }).click() + await expect(preview).toContainText(/thai consonants writing practice/i) + await expect(preview).toContainText(/กอ ไก่/i) + + await page.getByRole('button', { name: /^อะ$/i }).click() + await expect(preview).toContainText(/thai characters writing practice/i) + await expect(preview).toContainText(/2 characters/i) + + await page.getByRole('button', { name: /ก\s*ไก่/i }).click() + await page.getByRole('button', { name: /^อะ$/i }).click() + await expect(preview).toContainText( + /select consonants or vowels to see preview/i, + ) +}) + +test('sheet options clamp the layout and surface feedback in browser flow', async ({ + page, +}) => { + await page.goto('/') + + await page.getByRole('button', { name: /consonant presets/i }).click() + await page.getByRole('option', { name: /^Middle Class/i }).click() await page.getByLabel(/font size/i).selectOption('small') await page.getByLabel(/^columns$/i).selectOption('12') + await page.getByLabel(/^ghost copies$/i).selectOption('10') await page.getByLabel(/font size/i).selectOption('large') await expect(page.getByLabel(/^columns$/i)).toHaveValue('7') + await expect(page.getByLabel(/^ghost copies$/i)).toHaveValue('7') await expect( page.getByRole('status').filter({ hasText: /adjusted to 7 columns so it fits on the page/i, }), ).toContainText(/adjusted to 7 columns so it fits on the page/i) +}) + +test('pdf export shows progress and completes as a download', async ({ + page, +}) => { + await page.route('**/fonts/*', async (route) => { + await page.waitForTimeout(150) + await route.continue() + }) + + await page.goto('/') + await page.getByRole('button', { name: /consonant presets/i }).click() + await page.getByRole('option', { name: /^Middle Class/i }).click() + const exportStatus = page.locator( + 'section[aria-label="Output actions"] [role="status"]', + ) const downloadPromise = page.waitForEvent('download') await page.getByRole('button', { name: /download pdf/i }).click() - const download = await downloadPromise + await expect( + page.getByRole('button', { + name: /preparing pdf|building \d+\/\d+|starting download/i, + }), + ).toBeDisabled() + await expect(exportStatus).toContainText( + /preparing your pdf|building your pdf pages|starting your pdf download/i, + ) + + const download = await downloadPromise expect(download.suggestedFilename()).toBe('thai-script-practice.pdf') + await expect( + page.getByRole('button', { name: /download pdf/i }), + ).toBeEnabled() +}) + +test('pdf export failure shows a user-facing error toast', async ({ page }) => { + await page.route('**/fonts/*', async (route) => { + await route.fulfill({ + status: 404, + contentType: 'text/plain', + body: 'missing font for test', + }) + }) + + await page.goto('/') + await page.getByRole('button', { name: /consonant presets/i }).click() + await page.getByRole('option', { name: /^Middle Class/i }).click() + + await page.getByRole('button', { name: /download pdf/i }).click() + + await expect( + page.getByText(/pdf export failed\. please try again\./i), + ).toBeVisible() + await expect( + page.getByRole('button', { name: /download pdf/i }), + ).toBeEnabled() }) test.describe('responsive smoke', () => { diff --git a/src/components/VowelDisplay.tsx b/src/components/VowelDisplay.tsx index 1038383..1d3e055 100644 --- a/src/components/VowelDisplay.tsx +++ b/src/components/VowelDisplay.tsx @@ -1,7 +1,4 @@ -import { - formatVowelWithPlaceholder, - splitVowelForDisplay, -} from '@/data/vowels' +import { formatVowelWithPlaceholder, splitVowelForDisplay } from '@/data/vowels' interface VowelDisplayProps { char: string