Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
23 changes: 20 additions & 3 deletions docs/CURRENT_STATE.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Expand Down
35 changes: 35 additions & 0 deletions docs/PRD_02_SLICE_MODEL.md
Original file line number Diff line number Diff line change
@@ -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
137 changes: 124 additions & 13 deletions e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down
5 changes: 1 addition & 4 deletions src/components/VowelDisplay.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
import {
formatVowelWithPlaceholder,
splitVowelForDisplay,
} from '@/data/vowels'
import { formatVowelWithPlaceholder, splitVowelForDisplay } from '@/data/vowels'

interface VowelDisplayProps {
char: string
Expand Down
Loading