diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..8cef748
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,43 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+concurrency:
+ group: quality-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ quality:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+
+ - name: Set up pnpm
+ uses: pnpm/action-setup@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Check formatting
+ run: pnpm format:check
+
+ - name: Lint
+ run: pnpm lint
+
+ - name: Run unit tests
+ run: pnpm test:run
+
+ - name: Build
+ run: pnpm build
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..0176114
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,6 @@
+dist
+coverage
+node_modules
+pnpm-lock.yaml
+playwright-report
+test-results
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 0000000..b2095be
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,4 @@
+{
+ "semi": false,
+ "singleQuote": true
+}
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
index c36e315..41783d5 100644
--- a/.vscode/extensions.json
+++ b/.vscode/extensions.json
@@ -2,6 +2,8 @@
"recommendations": [
"streetsidesoftware.code-spell-checker",
"bradlc.vscode-tailwindcss",
+ "dbaeumer.vscode-eslint",
+ "esbenp.prettier-vscode",
"ms-playwright.playwright"
]
}
diff --git a/README.md b/README.md
index 7f0fdc3..0596bdf 100644
--- a/README.md
+++ b/README.md
@@ -38,6 +38,11 @@ For the implemented product and current constraints, start with [docs/CURRENT_ST
```bash
pnpm install # install dependencies
+pnpm format # apply Prettier formatting
+pnpm format:check # verify formatting without changing files
+pnpm lint # run ESLint
+pnpm lint:fix # run ESLint with autofixes
+pnpm check # run formatting, lint, tests, and build
pnpm dev # start dev server (http://localhost:5173)
pnpm build # production build
pnpm preview # preview production build
@@ -69,7 +74,12 @@ The dev server runs at [http://localhost:5173](http://localhost:5173) by default
## Testing
-`pnpm test:run` executes the current unit and component suite. The repository does not currently include Playwright or any E2E test setup.
+`pnpm test:run` executes the current unit and component suite. `pnpm test:e2e` runs the Playwright smoke test in `e2e/`.
+
+## CI and quality gates
+
+GitHub Actions runs formatting, linting, tests, and build checks for pull requests and pushes to `main`.
+To block merges until those checks pass, configure a branch protection rule or ruleset on `main` and require the `quality` status check.
## Contributor conventions
diff --git a/docs/MVP_TASKS_TDD.md b/docs/MVP_TASKS_TDD.md
index 5128b9c..ae9f7da 100644
--- a/docs/MVP_TASKS_TDD.md
+++ b/docs/MVP_TASKS_TDD.md
@@ -14,13 +14,13 @@ Small chunks for Test-Driven Development. Each task follows **Red** (write faili
## Phase 0: Bootstrap project
-| Task | What to do | TDD note |
-|------|------------|----------|
-| **0.1** | Create Vite + React + TypeScript project with pnpm | No test first; scaffold only. |
-| **0.2** | Add TailwindCSS and basic config | No test first. |
+| Task | What to do | TDD note |
+| ------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
+| **0.1** | Create Vite + React + TypeScript project with pnpm | No test first; scaffold only. |
+| **0.2** | Add TailwindCSS and basic config | No test first. |
| **0.3** | Add Vitest, jsdom, @testing-library/react, @testing-library/user-event; config for React | **Test first:** one dummy component test that renders and asserts text; then ensure Vitest runs. |
-| **0.4** | Add Playwright (optional now; use later for E2E) | Can defer. |
-| **0.5** | Add Shadcn/Base UI (when first needed) | Defer until we build UI components. |
+| **0.4** | Add Playwright (optional now; use later for E2E) | Can defer. |
+| **0.5** | Add Shadcn/Base UI (when first needed) | Defer until we build UI components. |
---
@@ -28,15 +28,15 @@ Small chunks for Test-Driven Development. Each task follows **Red** (write faili
Pure data and config—no UI. We test **logic and structure** with Vitest.
-| Task | What to test (write first) | What to implement |
-|------|----------------------------|--------------------|
-| **1.1** | `THAI_CONSONANTS` has length 44; each item has `id`, `char`, optional `name` | Define `THAI_CONSONANTS` array (e.g. in `src/data/consonants.ts`). |
-| **1.2** | `THAI_VOWELS` exists; each item has `id`, `char` (or similar); count as per PRD (~32) | Define `THAI_VOWELS` in `src/data/vowels.ts`. |
-| **1.3** | Grid guide options: exactly 3 (Cross, Sandwich, Thai) | `GRID_GUIDE_OPTIONS` in `src/data/sheetOptions.ts`. |
-| **1.4** | Font options: 5 fonts; default is Noto Serif Thai | `FONT_OPTIONS` and default in `sheetOptions.ts`. |
-| **1.5** | Font size options: Small / Medium / Large (or 18/24/32pt); default | `FONT_SIZE_OPTIONS` and default. |
-| **1.6** | Paper size: at least A4, optional Letter | `PAPER_SIZE_OPTIONS` and default. |
-| **1.7** | Default sheet config object: rows per character (e.g. 2), ghost copies (e.g. 3) | `DEFAULT_SHEET_CONFIG` and type `SheetConfig`. |
+| Task | What to test (write first) | What to implement |
+| ------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
+| **1.1** | `THAI_CONSONANTS` has length 44; each item has `id`, `char`, optional `name` | Define `THAI_CONSONANTS` array (e.g. in `src/data/consonants.ts`). |
+| **1.2** | `THAI_VOWELS` exists; each item has `id`, `char` (or similar); count as per PRD (~32) | Define `THAI_VOWELS` in `src/data/vowels.ts`. |
+| **1.3** | Grid guide options: exactly 3 (Cross, Sandwich, Thai) | `GRID_GUIDE_OPTIONS` in `src/data/sheetOptions.ts`. |
+| **1.4** | Font options: 5 fonts; default is Noto Serif Thai | `FONT_OPTIONS` and default in `sheetOptions.ts`. |
+| **1.5** | Font size options: Small / Medium / Large (or 18/24/32pt); default | `FONT_SIZE_OPTIONS` and default. |
+| **1.6** | Paper size: at least A4, optional Letter | `PAPER_SIZE_OPTIONS` and default. |
+| **1.7** | Default sheet config object: rows per character (e.g. 2), ghost copies (e.g. 3) | `DEFAULT_SHEET_CONFIG` and type `SheetConfig`. |
---
@@ -44,17 +44,17 @@ Pure data and config—no UI. We test **logic and structure** with Vitest.
Selection state and UI. We test **behavior**: what the user sees and what happens when they click.
-| Task | What to test (write first) | What to implement |
-|------|----------------------------|--------------------|
-| **2.1** | Hook or util: initial state = no consonants/vowels selected; toggle adds/removes id | `useContentSelection` or `selectionReducer` + tests. |
-| **2.2** | “Select all consonants” sets all 44; “Clear consonants” sets 0 | Extend hook/util; test in isolation (unit test). |
-| **2.3** | Same for vowels: Select all / Clear | Same pattern for vowels. |
+| Task | What to test (write first) | What to implement |
+| ------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
+| **2.1** | Hook or util: initial state = no consonants/vowels selected; toggle adds/removes id | `useContentSelection` or `selectionReducer` + tests. |
+| **2.2** | “Select all consonants” sets all 44; “Clear consonants” sets 0 | Extend hook/util; test in isolation (unit test). |
+| **2.3** | Same for vowels: Select all / Clear | Same pattern for vowels. |
| **2.4** | Component renders section “Consonants” and list of consonant items (by role or label) | `ContentSelection.tsx`: show consonants from `THAI_CONSONANTS`. |
-| **2.5** | Clicking a consonant toggles selection (visual or aria state) | Wire toggle to state; assert selection count or state. |
-| **2.6** | Component shows “Vowels” and vowel items; toggle works | Add vowels to `ContentSelection`. |
-| **2.7** | Button “Select all” (consonants) → summary shows “44 consonants” (or equivalent) | Add Select all button; assert summary text. |
-| **2.8** | Button “Clear” (consonants) → summary shows “0 consonants” | Add Clear button for consonants. |
-| **2.9** | Select all / Clear for vowels; summary shows “X consonants, Y vowels selected” | Same for vowels; unified summary. |
+| **2.5** | Clicking a consonant toggles selection (visual or aria state) | Wire toggle to state; assert selection count or state. |
+| **2.6** | Component shows “Vowels” and vowel items; toggle works | Add vowels to `ContentSelection`. |
+| **2.7** | Button “Select all” (consonants) → summary shows “44 consonants” (or equivalent) | Add Select all button; assert summary text. |
+| **2.8** | Button “Clear” (consonants) → summary shows “0 consonants” | Add Clear button for consonants. |
+| **2.9** | Select all / Clear for vowels; summary shows “X consonants, Y vowels selected” | Same for vowels; unified summary. |
---
@@ -62,14 +62,14 @@ Selection state and UI. We test **behavior**: what the user sees and what happen
Form controls for sheet configuration. Test **that controls exist and that changing them updates state**.
-| Task | What to test (write first) | What to implement |
-|------|----------------------------|--------------------|
+| Task | What to test (write first) | What to implement |
+| ------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| **3.1** | Component receives `config` and `onChange`; changing “Rows per character” calls `onChange` with new value | `SheetOptions.tsx`: number input or select for rows. |
-| **3.2** | “Ghost copies per row” control; onChange with new value | Add control; test. |
-| **3.3** | Paper size dropdown: options A4, Letter; onChange | Dropdown from `PAPER_SIZE_OPTIONS`. |
-| **3.4** | Grid guide dropdown: 3 options; onChange | Dropdown from `GRID_GUIDE_OPTIONS`. |
-| **3.5** | Font dropdown: 5 options; default selected; onChange | Font dropdown; default from constants. |
-| **3.6** | Font size dropdown: Small/Medium/Large (or pt); onChange | Font size dropdown. |
+| **3.2** | “Ghost copies per row” control; onChange with new value | Add control; test. |
+| **3.3** | Paper size dropdown: options A4, Letter; onChange | Dropdown from `PAPER_SIZE_OPTIONS`. |
+| **3.4** | Grid guide dropdown: 3 options; onChange | Dropdown from `GRID_GUIDE_OPTIONS`. |
+| **3.5** | Font dropdown: 5 options; default selected; onChange | Font dropdown; default from constants. |
+| **3.6** | Font size dropdown: Small/Medium/Large (or pt); onChange | Font size dropdown. |
---
@@ -77,12 +77,12 @@ Form controls for sheet configuration. Test **that controls exist and that chang
Preview area that reflects selection and options. Test **that it receives props and re-renders**, not pixel-perfect layout.
-| Task | What to test (write first) | What to implement |
-|------|----------------------------|--------------------|
+| Task | What to test (write first) | What to implement |
+| ------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| **4.1** | Preview renders a region (e.g. “Preview” or role="region") and shows something when given selected consonants | `Preview.tsx`: takes `selectedConsonants`, `selectedVowels`, `config`; render placeholder. |
-| **4.2** | When `selectedConsonants` changes, preview content updates (e.g. character count or first char) | Assert content reflects new selection. |
-| **4.3** | When `config` (e.g. rows per character) changes, preview reflects it (e.g. more rows) | Assert preview uses config. |
-| **4.4** | Preview shows correct grid guide and font (e.g. class or data attribute from config) | Apply grid and font from config. |
+| **4.2** | When `selectedConsonants` changes, preview content updates (e.g. character count or first char) | Assert content reflects new selection. |
+| **4.3** | When `config` (e.g. rows per character) changes, preview reflects it (e.g. more rows) | Assert preview uses config. |
+| **4.4** | Preview shows correct grid guide and font (e.g. class or data attribute from config) | Apply grid and font from config. |
---
@@ -90,12 +90,12 @@ Preview area that reflects selection and options. Test **that it receives props
Buttons and handlers; mock print and PDF in tests.
-| Task | What to test (write first) | What to implement |
-|------|----------------------------|--------------------|
-| **5.1** | “Print” and “Download PDF” buttons are present (by role or text) | `OutputActions.tsx`: two buttons. |
-| **5.2** | Click “Print” calls `window.print` (mock `window.print`, assert called) | `onPrint` handler. |
+| Task | What to test (write first) | What to implement |
+| ------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
+| **5.1** | “Print” and “Download PDF” buttons are present (by role or text) | `OutputActions.tsx`: two buttons. |
+| **5.2** | Click “Print” calls `window.print` (mock `window.print`, assert called) | `onPrint` handler. |
| **5.3** | Click “Download PDF” triggers download (mock PDF lib / blob; assert download or callback) | PDF generation (e.g. jsPDF or browser print-to-PDF); mock in test. |
-| **5.4** | PDF content uses current selection and sheet config (integration or unit test with mock) | Pass selection + config into PDF generator. |
+| **5.4** | PDF content uses current selection and sheet config (integration or unit test with mock) | Pass selection + config into PDF generator. |
---
@@ -103,11 +103,11 @@ Buttons and handlers; mock print and PDF in tests.
Lift state to one place; one happy-path E2E.
-| Task | What to test (write first) | What to implement |
-|------|----------------------------|--------------------|
-| **6.1** | App page renders ContentSelection, SheetOptions, Preview, OutputActions | `App.tsx`: compose sections; state in parent or context. |
-| **6.2** | Changing selection updates preview; changing options updates preview | Integration: RTL or E2E that changes controls and asserts preview. |
-| **6.3** | Playwright: open app → select some consonants → change options → see preview → click Print (mock or real) | One E2E test for critical path. |
+| Task | What to test (write first) | What to implement |
+| ------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
+| **6.1** | App page renders ContentSelection, SheetOptions, Preview, OutputActions | `App.tsx`: compose sections; state in parent or context. |
+| **6.2** | Changing selection updates preview; changing options updates preview | Integration: RTL or E2E that changes controls and asserts preview. |
+| **6.3** | Playwright: open app → select some consonants → change options → see preview → click Print (mock or real) | One E2E test for critical path. |
---
diff --git a/docs/PRD_01_MVP.md b/docs/PRD_01_MVP.md
index 7e6124a..447d427 100644
--- a/docs/PRD_01_MVP.md
+++ b/docs/PRD_01_MVP.md
@@ -54,7 +54,7 @@
- **Font:** dropdown with the following options; **default: Noto Serif Thai**.
- Noto Sans Thai
- Noto Sans Thai Looped
- - Noto Serif Thai *(default)*
+ - Noto Serif Thai _(default)_
- Mali
- Playpen Sans Thai
- **Font size:** dropdown for character size on the practice sheet. Provide a small set of sensible options (e.g., Small / Medium / Large, or specific point sizes such as 18pt, 24pt, 32pt) so the sheet is readable when printed.
@@ -115,7 +115,7 @@
- **Components:** Shadcn with Base UI.
- **Testing:** Vitest (unit and component tests) with React Testing Library and user-event, Playwright (e2e tests). Vitest + RTL is used for fast feedback during TDD; Playwright for full user-flow validation. See §9.1 for component-testing approach.
-*(If you need to specify versions, PDF library, or how Shadcn and Base UI are combined, add those details here.)*
+_(If you need to specify versions, PDF library, or how Shadcn and Base UI are combined, add those details here.)_
---
@@ -130,13 +130,13 @@
Component tests use **Vitest** as the runner with **@testing-library/react** and **@testing-library/user-event**. They assert **behavior and UX** (what the user sees, clicks, and types, and how the UI updates), not implementation details. Playwright remains the place for full user flows; RTL covers one screen or component in isolation.
-| PRD area | What to component-test with RTL |
-|----------|---------------------------------|
-| **4.1 Content selection** | Selecting/deselecting consonants and vowels; "Select all" and "Clear"; selection count or summary text updates. |
+| PRD area | What to component-test with RTL |
+| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **4.1 Content selection** | Selecting/deselecting consonants and vowels; "Select all" and "Clear"; selection count or summary text updates. |
| **4.2 Sheet configuration** | Changing rows per character, ghost copies, paper size, grid guide, font, and font size; correct options in dropdowns and that changes update state or callbacks. |
-| **4.3 Preview** | Preview section receives the right props/state and re-renders when selection or options change (no need to assert pixel-perfect layout). |
-| **4.4 Output** | "Print" and "Download PDF" buttons are present and invoke the correct handlers (mock `window.print` and PDF generation). |
-| **4.5 Responsive** | Optional: render at different viewport widths and assert key controls remain present and usable (e.g. no horizontal scroll for main flows). |
+| **4.3 Preview** | Preview section receives the right props/state and re-renders when selection or options change (no need to assert pixel-perfect layout). |
+| **4.4 Output** | "Print" and "Download PDF" buttons are present and invoke the correct handlers (mock `window.print` and PDF generation). |
+| **4.5 Responsive** | Optional: render at different viewport widths and assert key controls remain present and usable (e.g. no horizontal scroll for main flows). |
**Boundaries:** Use RTL for one section or component at a time; mock print and PDF generation. Use Playwright for full-page, real-browser flows (select → configure → preview → print/PDF). Avoid duplicating full-flow coverage in both layers.
diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts
index 14e6f02..6583644 100644
--- a/e2e/smoke.spec.ts
+++ b/e2e/smoke.spec.ts
@@ -3,19 +3,21 @@ import { expect, test } from '@playwright/test'
test('desktop flow updates preview and downloads a pdf', async ({ page }) => {
await page.goto('/')
- await expect(page.getByRole('heading', { name: /thai worksheet generator/i })).toBeVisible()
+ await expect(
+ page.getByRole('heading', { name: /thai worksheet generator/i }),
+ ).toBeVisible()
await expect(page.getByRole('region', { name: /preview/i })).toContainText(
- /select consonants or vowels to see preview/i
+ /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()
- 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 }),
+ ).not.toContainText(/select consonants or vowels to see preview/i)
await expect(page.getByRole('region', { name: /preview/i })).toContainText(
- /thai consonants writing practice/i
+ /thai consonants writing practice/i,
)
await page.getByLabel(/font size/i).selectOption('small')
@@ -23,11 +25,11 @@ test('desktop flow updates preview and downloads a pdf', async ({ page }) => {
await page.getByLabel(/font size/i).selectOption('large')
await expect(page.getByLabel(/^columns$/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
- )
+ 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)
const downloadPromise = page.waitForEvent('download')
await page.getByRole('button', { name: /download pdf/i }).click()
@@ -46,9 +48,13 @@ test.describe('responsive smoke', () => {
await page.getByRole('option', { name: /^Short Vowels/i }).click()
await expect(page.getByRole('region', { name: /preview/i })).toContainText(
- /thai vowels writing practice/i
+ /thai vowels writing practice/i,
)
- await expect(page.getByRole('button', { name: /download pdf/i })).toBeVisible()
- await expect(page.getByText(/scroll sideways to view all columns/i)).toHaveCount(0)
+ await expect(
+ page.getByRole('button', { name: /download pdf/i }),
+ ).toBeVisible()
+ await expect(
+ page.getByText(/scroll sideways to view all columns/i),
+ ).toHaveCount(0)
})
})
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..e806067
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,56 @@
+import js from '@eslint/js'
+import globals from 'globals'
+import eslintConfigPrettier from 'eslint-config-prettier'
+import jsxA11y from 'eslint-plugin-jsx-a11y'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import tseslint from 'typescript-eslint'
+
+export default tseslint.config(
+ {
+ ignores: [
+ 'dist/**',
+ 'coverage/**',
+ 'node_modules/**',
+ 'playwright-report/**',
+ 'test-results/**',
+ ],
+ },
+ {
+ files: ['**/*.{js,jsx,ts,tsx}'],
+ extends: [js.configs.recommended, ...tseslint.configs.recommended],
+ languageOptions: {
+ ecmaVersion: 'latest',
+ globals: {
+ ...globals.browser,
+ },
+ sourceType: 'module',
+ },
+ plugins: {
+ 'react-hooks': reactHooks,
+ 'react-refresh': reactRefresh,
+ },
+ rules: {
+ ...reactHooks.configs.recommended.rules,
+ 'react-refresh/only-export-components': [
+ 'warn',
+ { allowConstantExport: true },
+ ],
+ },
+ },
+ {
+ files: ['**/*.{jsx,tsx}'],
+ extends: [jsxA11y.flatConfigs.recommended],
+ },
+ {
+ files: ['**/*.{config,test,spec}.{js,jsx,ts,tsx}', 'e2e/**/*.ts'],
+ languageOptions: {
+ globals: {
+ ...globals.node,
+ ...globals.browser,
+ ...globals.vitest,
+ },
+ },
+ },
+ eslintConfigPrettier,
+)
diff --git a/index.html b/index.html
index 1117e12..608956b 100644
--- a/index.html
+++ b/index.html
@@ -1,12 +1,15 @@
-
+
Thai Script Pro
-
-
-
+
+
+
diff --git a/package.json b/package.json
index 47788c2..592ec41 100644
--- a/package.json
+++ b/package.json
@@ -2,10 +2,16 @@
"name": "thai-script-pro",
"private": true,
"version": "0.0.0",
+ "packageManager": "pnpm@10.32.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
+ "check": "pnpm format:check && pnpm lint && pnpm test:run && pnpm build",
+ "format": "prettier --write .",
+ "format:check": "prettier --check .",
+ "lint": "eslint . --cache --cache-location .cache/eslint/",
+ "lint:fix": "eslint . --fix --cache --cache-location .cache/eslint/",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run",
@@ -18,6 +24,7 @@
"react-dom": "^19.2.4"
},
"devDependencies": {
+ "@eslint/js": "^9.39.4",
"@playwright/test": "^1.58.2",
"@tailwindcss/vite": "4.2.2",
"@testing-library/jest-dom": "^6.9.1",
@@ -26,9 +33,17 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
+ "eslint": "^9.39.4",
+ "eslint-config-prettier": "^10.1.8",
+ "eslint-plugin-jsx-a11y": "^6.10.2",
+ "eslint-plugin-react-hooks": "^7.0.1",
+ "eslint-plugin-react-refresh": "^0.5.2",
+ "globals": "^17.4.0",
"jsdom": "^29.0.1",
+ "prettier": "^3.8.1",
"tailwindcss": "^4.2.2",
"typescript": "~5.9.3",
+ "typescript-eslint": "^8.57.2",
"vite": "^8.0.3",
"vitest": "^4.1.2"
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 301abd8..73c8d7f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -18,6 +18,9 @@ importers:
specifier: ^19.2.4
version: 19.2.4(react@19.2.4)
devDependencies:
+ '@eslint/js':
+ specifier: ^9.39.4
+ version: 9.39.4
'@playwright/test':
specifier: ^1.58.2
version: 1.58.2
@@ -42,15 +45,39 @@ importers:
'@vitejs/plugin-react':
specifier: ^6.0.1
version: 6.0.1(vite@8.0.3(esbuild@0.27.4)(jiti@2.6.1))
+ eslint:
+ specifier: ^9.39.4
+ version: 9.39.4(jiti@2.6.1)
+ eslint-config-prettier:
+ specifier: ^10.1.8
+ version: 10.1.8(eslint@9.39.4(jiti@2.6.1))
+ eslint-plugin-jsx-a11y:
+ specifier: ^6.10.2
+ version: 6.10.2(eslint@9.39.4(jiti@2.6.1))
+ eslint-plugin-react-hooks:
+ specifier: ^7.0.1
+ version: 7.0.1(eslint@9.39.4(jiti@2.6.1))
+ eslint-plugin-react-refresh:
+ specifier: ^0.5.2
+ version: 0.5.2(eslint@9.39.4(jiti@2.6.1))
+ globals:
+ specifier: ^17.4.0
+ version: 17.4.0
jsdom:
specifier: ^29.0.1
version: 29.0.1
+ prettier:
+ specifier: ^3.8.1
+ version: 3.8.1
tailwindcss:
specifier: ^4.2.2
version: 4.2.2
typescript:
specifier: ~5.9.3
version: 5.9.3
+ typescript-eslint:
+ specifier: ^8.57.2
+ version: 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
vite:
specifier: ^8.0.3
version: 8.0.3(esbuild@0.27.4)(jiti@2.6.1)
@@ -78,10 +105,57 @@ packages:
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
+ '@babel/compat-data@7.29.0':
+ resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/core@7.29.0':
+ resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/generator@7.29.1':
+ resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-compilation-targets@7.28.6':
+ resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-globals@7.28.0':
+ resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-imports@7.28.6':
+ resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-transforms@7.28.6':
+ resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
+ '@babel/helper-string-parser@7.27.1':
+ resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
+ engines: {node: '>=6.9.0'}
+
'@babel/helper-validator-identifier@7.28.5':
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'}
+ '@babel/helper-validator-option@7.27.1':
+ resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helpers@7.29.2':
+ resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.29.2':
+ resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
'@babel/runtime@7.28.6':
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
engines: {node: '>=6.9.0'}
@@ -90,6 +164,18 @@ packages:
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
engines: {node: '>=6.9.0'}
+ '@babel/template@7.28.6':
+ resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/traverse@7.29.0':
+ resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/types@7.29.0':
+ resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
+ engines: {node: '>=6.9.0'}
+
'@bramus/specificity@2.4.2':
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
hasBin: true
@@ -295,6 +381,44 @@ packages:
cpu: [x64]
os: [win32]
+ '@eslint-community/eslint-utils@4.9.1':
+ resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ peerDependencies:
+ eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
+
+ '@eslint-community/regexpp@4.12.2':
+ resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
+ engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
+
+ '@eslint/config-array@0.21.2':
+ resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/config-helpers@0.4.2':
+ resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/core@0.17.0':
+ resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/eslintrc@3.3.5':
+ resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/js@9.39.4':
+ resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/object-schema@2.1.7':
+ resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/plugin-kit@0.4.1':
+ resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
'@exodus/bytes@1.15.0':
resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -304,6 +428,22 @@ packages:
'@noble/hashes':
optional: true
+ '@humanfs/core@0.19.1':
+ resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanfs/node@0.16.7':
+ resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanwhocodes/module-importer@1.0.1':
+ resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
+ engines: {node: '>=12.22'}
+
+ '@humanwhocodes/retry@0.4.3':
+ resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
+ engines: {node: '>=18.18'}
+
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -573,6 +713,9 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+ '@types/json-schema@7.0.15':
+ resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
+
'@types/pako@2.0.4':
resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
@@ -590,6 +733,65 @@ packages:
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
+ '@typescript-eslint/eslint-plugin@8.57.2':
+ resolution: {integrity: sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ '@typescript-eslint/parser': ^8.57.2
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/parser@8.57.2':
+ resolution: {integrity: sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/project-service@8.57.2':
+ resolution: {integrity: sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/scope-manager@8.57.2':
+ resolution: {integrity: sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/tsconfig-utils@8.57.2':
+ resolution: {integrity: sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/type-utils@8.57.2':
+ resolution: {integrity: sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/types@8.57.2':
+ resolution: {integrity: sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/typescript-estree@8.57.2':
+ resolution: {integrity: sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/utils@8.57.2':
+ resolution: {integrity: sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/visitor-keys@8.57.2':
+ resolution: {integrity: sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
'@vitejs/plugin-react@6.0.1':
resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -632,14 +834,34 @@ packages:
'@vitest/utils@4.1.2':
resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==}
+ acorn-jsx@5.3.2:
+ resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
+ peerDependencies:
+ acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
+
+ acorn@8.16.0:
+ resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+
+ ajv@6.14.0:
+ resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
+
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
+ ansi-styles@4.3.0:
+ resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
+ engines: {node: '>=8'}
+
ansi-styles@5.2.0:
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
engines: {node: '>=10'}
+ argparse@2.0.1:
+ resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+
aria-query@5.3.0:
resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
@@ -647,17 +869,99 @@ packages:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'}
+ array-buffer-byte-length@1.0.2:
+ resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
+ engines: {node: '>= 0.4'}
+
+ array-includes@3.1.9:
+ resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==}
+ engines: {node: '>= 0.4'}
+
+ array.prototype.flat@1.3.3:
+ resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==}
+ engines: {node: '>= 0.4'}
+
+ array.prototype.flatmap@1.3.3:
+ resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==}
+ engines: {node: '>= 0.4'}
+
+ arraybuffer.prototype.slice@1.0.4:
+ resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
+ engines: {node: '>= 0.4'}
+
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
+ ast-types-flow@0.0.8:
+ resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
+
+ async-function@1.0.0:
+ resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
+ engines: {node: '>= 0.4'}
+
+ available-typed-arrays@1.0.7:
+ resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
+ engines: {node: '>= 0.4'}
+
+ axe-core@4.11.1:
+ resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==}
+ engines: {node: '>=4'}
+
+ axobject-query@4.1.0:
+ resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
+ engines: {node: '>= 0.4'}
+
+ balanced-match@1.0.2:
+ resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+
+ balanced-match@4.0.4:
+ resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
+ engines: {node: 18 || 20 || >=22}
+
base64-arraybuffer@1.0.2:
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
engines: {node: '>= 0.6.0'}
+ baseline-browser-mapping@2.10.11:
+ resolution: {integrity: sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
+ brace-expansion@1.1.13:
+ resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==}
+
+ brace-expansion@5.0.5:
+ resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==}
+ engines: {node: 18 || 20 || >=22}
+
+ browserslist@4.28.1:
+ resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
+ engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+ hasBin: true
+
+ call-bind-apply-helpers@1.0.2:
+ resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
+ engines: {node: '>= 0.4'}
+
+ call-bind@1.0.8:
+ resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==}
+ engines: {node: '>= 0.4'}
+
+ call-bound@1.0.4:
+ resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
+ engines: {node: '>= 0.4'}
+
+ callsites@3.1.0:
+ resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
+ engines: {node: '>=6'}
+
+ caniuse-lite@1.0.30001781:
+ resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==}
+
canvg@3.0.11:
resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==}
engines: {node: '>=10.0.0'}
@@ -666,12 +970,30 @@ packages:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
+ chalk@4.1.2:
+ resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
+ engines: {node: '>=10'}
+
+ color-convert@2.0.1:
+ resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
+ engines: {node: '>=7.0.0'}
+
+ color-name@1.1.4:
+ resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+
+ concat-map@0.0.1:
+ resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
+
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
core-js@3.48.0:
resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==}
+ cross-spawn@7.0.6:
+ resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
+ engines: {node: '>= 8'}
+
css-line-break@2.1.0:
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
@@ -685,13 +1007,48 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+ damerau-levenshtein@1.0.8:
+ resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
+
data-urls@7.0.0:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ data-view-buffer@1.0.2:
+ resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
+ engines: {node: '>= 0.4'}
+
+ data-view-byte-length@1.0.2:
+ resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==}
+ engines: {node: '>= 0.4'}
+
+ data-view-byte-offset@1.0.1:
+ resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
+ engines: {node: '>= 0.4'}
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
+ deep-is@0.1.4:
+ resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
+
+ define-data-property@1.1.4:
+ resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
+ engines: {node: '>= 0.4'}
+
+ define-properties@1.2.1:
+ resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
+ engines: {node: '>= 0.4'}
+
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
@@ -710,6 +1067,16 @@ packages:
resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==}
engines: {node: '>=20'}
+ dunder-proto@1.0.1:
+ resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
+ engines: {node: '>= 0.4'}
+
+ electron-to-chromium@1.5.328:
+ resolution: {integrity: sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==}
+
+ emoji-regex@9.2.2:
+ resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
+
enhanced-resolve@5.20.1:
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
engines: {node: '>=10.13.0'}
@@ -718,21 +1085,135 @@ packages:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
+ es-abstract@1.24.1:
+ resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==}
+ engines: {node: '>= 0.4'}
+
+ es-define-property@1.0.1:
+ resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
+ engines: {node: '>= 0.4'}
+
+ es-errors@1.3.0:
+ resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
+ engines: {node: '>= 0.4'}
+
es-module-lexer@2.0.0:
resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==}
+ es-object-atoms@1.1.1:
+ resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
+ engines: {node: '>= 0.4'}
+
+ es-set-tostringtag@2.1.0:
+ resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
+ engines: {node: '>= 0.4'}
+
+ es-shim-unscopables@1.1.0:
+ resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==}
+ engines: {node: '>= 0.4'}
+
+ es-to-primitive@1.3.0:
+ resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
+ engines: {node: '>= 0.4'}
+
esbuild@0.27.4:
resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==}
engines: {node: '>=18'}
hasBin: true
+ escalade@3.2.0:
+ resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
+ engines: {node: '>=6'}
+
+ escape-string-regexp@4.0.0:
+ resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
+ engines: {node: '>=10'}
+
+ eslint-config-prettier@10.1.8:
+ resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==}
+ hasBin: true
+ peerDependencies:
+ eslint: '>=7.0.0'
+
+ eslint-plugin-jsx-a11y@6.10.2:
+ resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==}
+ engines: {node: '>=4.0'}
+ peerDependencies:
+ eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9
+
+ eslint-plugin-react-hooks@7.0.1:
+ resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
+
+ eslint-plugin-react-refresh@0.5.2:
+ resolution: {integrity: sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==}
+ peerDependencies:
+ eslint: ^9 || ^10
+
+ eslint-scope@8.4.0:
+ resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ eslint-visitor-keys@3.4.3:
+ resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
+ eslint-visitor-keys@4.2.1:
+ resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ eslint-visitor-keys@5.0.1:
+ resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
+ eslint@9.39.4:
+ resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ hasBin: true
+ peerDependencies:
+ jiti: '*'
+ peerDependenciesMeta:
+ jiti:
+ optional: true
+
+ espree@10.4.0:
+ resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ esquery@1.7.0:
+ resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
+ engines: {node: '>=0.10'}
+
+ esrecurse@4.3.0:
+ resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
+ engines: {node: '>=4.0'}
+
+ estraverse@5.3.0:
+ resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
+ engines: {node: '>=4.0'}
+
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
+ esutils@2.0.3:
+ resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
+ engines: {node: '>=0.10.0'}
+
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
+ fast-deep-equal@3.1.3:
+ resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+
+ fast-json-stable-stringify@2.1.0:
+ resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
+
+ fast-levenshtein@2.0.6:
+ resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
+
fast-png@6.4.0:
resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==}
@@ -748,6 +1229,25 @@ packages:
fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
+ file-entry-cache@8.0.0:
+ resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
+ engines: {node: '>=16.0.0'}
+
+ find-up@5.0.0:
+ resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
+ engines: {node: '>=10'}
+
+ flat-cache@4.0.1:
+ resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
+ engines: {node: '>=16'}
+
+ flatted@3.4.2:
+ resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
+
+ for-each@0.3.5:
+ resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
+ engines: {node: '>= 0.4'}
+
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -758,9 +1258,92 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
+ function-bind@1.1.2:
+ resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+
+ function.prototype.name@1.1.8:
+ resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==}
+ engines: {node: '>= 0.4'}
+
+ functions-have-names@1.2.3:
+ resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
+
+ generator-function@2.0.1:
+ resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
+ engines: {node: '>= 0.4'}
+
+ gensync@1.0.0-beta.2:
+ resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
+ engines: {node: '>=6.9.0'}
+
+ get-intrinsic@1.3.0:
+ resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
+ engines: {node: '>= 0.4'}
+
+ get-proto@1.0.1:
+ resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
+ engines: {node: '>= 0.4'}
+
+ get-symbol-description@1.1.0:
+ resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
+ engines: {node: '>= 0.4'}
+
+ glob-parent@6.0.2:
+ resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
+ engines: {node: '>=10.13.0'}
+
+ globals@14.0.0:
+ resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
+ engines: {node: '>=18'}
+
+ globals@17.4.0:
+ resolution: {integrity: sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==}
+ engines: {node: '>=18'}
+
+ globalthis@1.0.4:
+ resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
+ engines: {node: '>= 0.4'}
+
+ gopd@1.2.0:
+ resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
+ engines: {node: '>= 0.4'}
+
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+ has-bigints@1.1.0:
+ resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
+ engines: {node: '>= 0.4'}
+
+ has-flag@4.0.0:
+ resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
+ engines: {node: '>=8'}
+
+ has-property-descriptors@1.0.2:
+ resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
+
+ has-proto@1.2.0:
+ resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==}
+ engines: {node: '>= 0.4'}
+
+ has-symbols@1.1.0:
+ resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
+ engines: {node: '>= 0.4'}
+
+ has-tostringtag@1.0.2:
+ resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
+ engines: {node: '>= 0.4'}
+
+ hasown@2.0.2:
+ resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
+ engines: {node: '>= 0.4'}
+
+ hermes-estree@0.25.1:
+ resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
+
+ hermes-parser@0.25.1:
+ resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
+
html-encoding-sniffer@6.0.0:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -769,34 +1352,193 @@ packages:
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
engines: {node: '>=8.0.0'}
+ ignore@5.3.2:
+ resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
+ engines: {node: '>= 4'}
+
+ ignore@7.0.5:
+ resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
+ engines: {node: '>= 4'}
+
+ import-fresh@3.3.1:
+ resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
+ engines: {node: '>=6'}
+
+ imurmurhash@0.1.4:
+ resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
+ engines: {node: '>=0.8.19'}
+
indent-string@4.0.0:
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
engines: {node: '>=8'}
+ internal-slot@1.1.0:
+ resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
+ engines: {node: '>= 0.4'}
+
iobuffer@5.4.0:
resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==}
- is-potential-custom-element-name@1.0.1:
- resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
+ is-array-buffer@3.0.5:
+ resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
+ engines: {node: '>= 0.4'}
- jiti@2.6.1:
- resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
- hasBin: true
+ is-async-function@2.1.1:
+ resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==}
+ engines: {node: '>= 0.4'}
- js-tokens@4.0.0:
- resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+ is-bigint@1.1.0:
+ resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==}
+ engines: {node: '>= 0.4'}
- jsdom@29.0.1:
- resolution: {integrity: sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==}
- engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
- peerDependencies:
- canvas: ^3.0.0
- peerDependenciesMeta:
- canvas:
- optional: true
+ is-boolean-object@1.2.2:
+ resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
+ engines: {node: '>= 0.4'}
- jspdf@4.2.1:
- resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==}
+ is-callable@1.2.7:
+ resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
+ engines: {node: '>= 0.4'}
+
+ is-data-view@1.0.2:
+ resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==}
+ engines: {node: '>= 0.4'}
+
+ is-date-object@1.1.0:
+ resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
+ engines: {node: '>= 0.4'}
+
+ is-extglob@2.1.1:
+ resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
+ engines: {node: '>=0.10.0'}
+
+ is-finalizationregistry@1.1.1:
+ resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
+ engines: {node: '>= 0.4'}
+
+ is-generator-function@1.1.2:
+ resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
+ engines: {node: '>= 0.4'}
+
+ is-glob@4.0.3:
+ resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
+ engines: {node: '>=0.10.0'}
+
+ is-map@2.0.3:
+ resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
+ engines: {node: '>= 0.4'}
+
+ is-negative-zero@2.0.3:
+ resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
+ engines: {node: '>= 0.4'}
+
+ is-number-object@1.1.1:
+ resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==}
+ engines: {node: '>= 0.4'}
+
+ is-potential-custom-element-name@1.0.1:
+ resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
+
+ is-regex@1.2.1:
+ resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
+ engines: {node: '>= 0.4'}
+
+ is-set@2.0.3:
+ resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==}
+ engines: {node: '>= 0.4'}
+
+ is-shared-array-buffer@1.0.4:
+ resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==}
+ engines: {node: '>= 0.4'}
+
+ is-string@1.1.1:
+ resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
+ engines: {node: '>= 0.4'}
+
+ is-symbol@1.1.1:
+ resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==}
+ engines: {node: '>= 0.4'}
+
+ is-typed-array@1.1.15:
+ resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
+ engines: {node: '>= 0.4'}
+
+ is-weakmap@2.0.2:
+ resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
+ engines: {node: '>= 0.4'}
+
+ is-weakref@1.1.1:
+ resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==}
+ engines: {node: '>= 0.4'}
+
+ is-weakset@2.0.4:
+ resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
+ engines: {node: '>= 0.4'}
+
+ isarray@2.0.5:
+ resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
+
+ isexe@2.0.0:
+ resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
+ jiti@2.6.1:
+ resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
+ hasBin: true
+
+ js-tokens@4.0.0:
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+ js-yaml@4.1.1:
+ resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
+ hasBin: true
+
+ jsdom@29.0.1:
+ resolution: {integrity: sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
+ peerDependencies:
+ canvas: ^3.0.0
+ peerDependenciesMeta:
+ canvas:
+ optional: true
+
+ jsesc@3.1.0:
+ resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ json-buffer@3.0.1:
+ resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
+
+ json-schema-traverse@0.4.1:
+ resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
+
+ json-stable-stringify-without-jsonify@1.0.1:
+ resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
+
+ json5@2.2.3:
+ resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ jspdf@4.2.1:
+ resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==}
+
+ jsx-ast-utils@3.3.5:
+ resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
+ engines: {node: '>=4.0'}
+
+ keyv@4.5.4:
+ resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
+
+ language-subtag-registry@0.3.23:
+ resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
+
+ language-tags@1.0.9:
+ resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==}
+ engines: {node: '>=0.10'}
+
+ levn@0.4.1:
+ resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
+ engines: {node: '>= 0.8.0'}
lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
@@ -872,10 +1614,20 @@ packages:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
+ locate-path@6.0.0:
+ resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
+ engines: {node: '>=10'}
+
+ lodash.merge@4.6.2:
+ resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
+
lru-cache@11.2.7:
resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==}
engines: {node: 20 || >=22}
+ lru-cache@5.1.1:
+ resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
@@ -883,6 +1635,10 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+ math-intrinsics@1.1.0:
+ resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
+ engines: {node: '>= 0.4'}
+
mdn-data@2.27.1:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
@@ -890,20 +1646,84 @@ packages:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'}
+ minimatch@10.2.4:
+ resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
+ engines: {node: 18 || 20 || >=22}
+
+ minimatch@3.1.5:
+ resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
+ natural-compare@1.4.0:
+ resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
+
+ node-releases@2.0.36:
+ resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
+
+ object-inspect@1.13.4:
+ resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
+ engines: {node: '>= 0.4'}
+
+ object-keys@1.1.1:
+ resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
+ engines: {node: '>= 0.4'}
+
+ object.assign@4.1.7:
+ resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
+ engines: {node: '>= 0.4'}
+
+ object.fromentries@2.0.8:
+ resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==}
+ engines: {node: '>= 0.4'}
+
+ object.values@1.2.1:
+ resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
+ engines: {node: '>= 0.4'}
+
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
+ optionator@0.9.4:
+ resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
+ engines: {node: '>= 0.8.0'}
+
+ own-keys@1.0.1:
+ resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
+ engines: {node: '>= 0.4'}
+
+ p-limit@3.1.0:
+ resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
+ engines: {node: '>=10'}
+
+ p-locate@5.0.0:
+ resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
+ engines: {node: '>=10'}
+
pako@2.1.0:
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
+ parent-module@1.0.1:
+ resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
+ engines: {node: '>=6'}
+
parse5@8.0.0:
resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
+ path-exists@4.0.0:
+ resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
+ engines: {node: '>=8'}
+
+ path-key@3.1.1:
+ resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+ engines: {node: '>=8'}
+
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -931,10 +1751,23 @@ packages:
engines: {node: '>=18'}
hasBin: true
+ possible-typed-array-names@1.1.0:
+ resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
+ engines: {node: '>= 0.4'}
+
postcss@8.5.8:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
+ prelude-ls@1.2.1:
+ resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
+ engines: {node: '>= 0.8.0'}
+
+ prettier@3.8.1:
+ resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==}
+ engines: {node: '>=14'}
+ hasBin: true
+
pretty-format@27.5.1:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
@@ -962,13 +1795,25 @@ packages:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
engines: {node: '>=8'}
+ reflect.getprototypeof@1.0.10:
+ resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
+ engines: {node: '>= 0.4'}
+
regenerator-runtime@0.13.11:
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
+ regexp.prototype.flags@1.5.4:
+ resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
+ engines: {node: '>= 0.4'}
+
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
+ resolve-from@4.0.0:
+ resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
+ engines: {node: '>=4'}
+
rgbcolor@1.0.1:
resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==}
engines: {node: '>= 0.8.15'}
@@ -978,6 +1823,18 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
+ safe-array-concat@1.1.3:
+ resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
+ engines: {node: '>=0.4'}
+
+ safe-push-apply@1.0.0:
+ resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
+ engines: {node: '>= 0.4'}
+
+ safe-regex-test@1.1.0:
+ resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
+ engines: {node: '>= 0.4'}
+
saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
@@ -985,6 +1842,51 @@ packages:
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
+ semver@6.3.1:
+ resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
+ hasBin: true
+
+ semver@7.7.4:
+ resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ set-function-length@1.2.2:
+ resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
+ engines: {node: '>= 0.4'}
+
+ set-function-name@2.0.2:
+ resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==}
+ engines: {node: '>= 0.4'}
+
+ set-proto@1.0.0:
+ resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
+ engines: {node: '>= 0.4'}
+
+ shebang-command@2.0.0:
+ resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+ engines: {node: '>=8'}
+
+ shebang-regex@3.0.0:
+ resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+ engines: {node: '>=8'}
+
+ side-channel-list@1.0.0:
+ resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
+ engines: {node: '>= 0.4'}
+
+ side-channel-map@1.0.1:
+ resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
+ engines: {node: '>= 0.4'}
+
+ side-channel-weakmap@1.0.2:
+ resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
+ engines: {node: '>= 0.4'}
+
+ side-channel@1.1.0:
+ resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
+ engines: {node: '>= 0.4'}
+
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
@@ -1002,10 +1904,38 @@ packages:
std-env@4.0.0:
resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==}
+ stop-iteration-iterator@1.1.0:
+ resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
+ engines: {node: '>= 0.4'}
+
+ string.prototype.includes@2.0.1:
+ resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==}
+ engines: {node: '>= 0.4'}
+
+ string.prototype.trim@1.2.10:
+ resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==}
+ engines: {node: '>= 0.4'}
+
+ string.prototype.trimend@1.0.9:
+ resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==}
+ engines: {node: '>= 0.4'}
+
+ string.prototype.trimstart@1.0.8:
+ resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
+ engines: {node: '>= 0.4'}
+
strip-indent@3.0.0:
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
engines: {node: '>=8'}
+ strip-json-comments@3.1.1:
+ resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
+ engines: {node: '>=8'}
+
+ supports-color@7.2.0:
+ resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
+ engines: {node: '>=8'}
+
svg-pathdata@6.0.3:
resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==}
engines: {node: '>=12.0.0'}
@@ -1053,18 +1983,64 @@ packages:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
+ ts-api-utils@2.5.0:
+ resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
+ engines: {node: '>=18.12'}
+ peerDependencies:
+ typescript: '>=4.8.4'
+
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+ type-check@0.4.0:
+ resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
+ engines: {node: '>= 0.8.0'}
+
+ typed-array-buffer@1.0.3:
+ resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
+ engines: {node: '>= 0.4'}
+
+ typed-array-byte-length@1.0.3:
+ resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==}
+ engines: {node: '>= 0.4'}
+
+ typed-array-byte-offset@1.0.4:
+ resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==}
+ engines: {node: '>= 0.4'}
+
+ typed-array-length@1.0.7:
+ resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
+ engines: {node: '>= 0.4'}
+
+ typescript-eslint@8.57.2:
+ resolution: {integrity: sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.0.0'
+
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
+ unbox-primitive@1.1.0:
+ resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
+ engines: {node: '>= 0.4'}
+
undici@7.24.6:
resolution: {integrity: sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==}
engines: {node: '>=20.18.1'}
+ update-browserslist-db@1.2.3:
+ resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
+ hasBin: true
+ peerDependencies:
+ browserslist: '>= 4.21.0'
+
+ uri-js@4.4.1:
+ resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+
utrie@1.0.2:
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
@@ -1162,11 +2138,36 @@ packages:
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ which-boxed-primitive@1.1.1:
+ resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
+ engines: {node: '>= 0.4'}
+
+ which-builtin-type@1.2.1:
+ resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==}
+ engines: {node: '>= 0.4'}
+
+ which-collection@1.0.2:
+ resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
+ engines: {node: '>= 0.4'}
+
+ which-typed-array@1.1.20:
+ resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==}
+ engines: {node: '>= 0.4'}
+
+ which@2.0.2:
+ resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
+ engines: {node: '>= 8'}
+ hasBin: true
+
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
+ word-wrap@1.2.5:
+ resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
+ engines: {node: '>=0.10.0'}
+
xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
@@ -1174,6 +2175,22 @@ packages:
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+ yallist@3.1.1:
+ resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+
+ yocto-queue@0.1.0:
+ resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
+ engines: {node: '>=10'}
+
+ zod-validation-error@4.0.2:
+ resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ zod: ^3.25.0 || ^4.0.0
+
+ zod@4.3.6:
+ resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
+
snapshots:
'@adobe/css-tools@4.4.4': {}
@@ -1202,12 +2219,104 @@ snapshots:
js-tokens: 4.0.0
picocolors: 1.1.1
+ '@babel/compat-data@7.29.0': {}
+
+ '@babel/core@7.29.0':
+ dependencies:
+ '@babel/code-frame': 7.29.0
+ '@babel/generator': 7.29.1
+ '@babel/helper-compilation-targets': 7.28.6
+ '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
+ '@babel/helpers': 7.29.2
+ '@babel/parser': 7.29.2
+ '@babel/template': 7.28.6
+ '@babel/traverse': 7.29.0
+ '@babel/types': 7.29.0
+ '@jridgewell/remapping': 2.3.5
+ convert-source-map: 2.0.0
+ debug: 4.4.3
+ gensync: 1.0.0-beta.2
+ json5: 2.2.3
+ semver: 6.3.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/generator@7.29.1':
+ dependencies:
+ '@babel/parser': 7.29.2
+ '@babel/types': 7.29.0
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+ jsesc: 3.1.0
+
+ '@babel/helper-compilation-targets@7.28.6':
+ dependencies:
+ '@babel/compat-data': 7.29.0
+ '@babel/helper-validator-option': 7.27.1
+ browserslist: 4.28.1
+ lru-cache: 5.1.1
+ semver: 6.3.1
+
+ '@babel/helper-globals@7.28.0': {}
+
+ '@babel/helper-module-imports@7.28.6':
+ dependencies:
+ '@babel/traverse': 7.29.0
+ '@babel/types': 7.29.0
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-module-imports': 7.28.6
+ '@babel/helper-validator-identifier': 7.28.5
+ '@babel/traverse': 7.29.0
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-string-parser@7.27.1': {}
+
'@babel/helper-validator-identifier@7.28.5': {}
+ '@babel/helper-validator-option@7.27.1': {}
+
+ '@babel/helpers@7.29.2':
+ dependencies:
+ '@babel/template': 7.28.6
+ '@babel/types': 7.29.0
+
+ '@babel/parser@7.29.2':
+ dependencies:
+ '@babel/types': 7.29.0
+
'@babel/runtime@7.28.6': {}
'@babel/runtime@7.29.2': {}
+ '@babel/template@7.28.6':
+ dependencies:
+ '@babel/code-frame': 7.29.0
+ '@babel/parser': 7.29.2
+ '@babel/types': 7.29.0
+
+ '@babel/traverse@7.29.0':
+ dependencies:
+ '@babel/code-frame': 7.29.0
+ '@babel/generator': 7.29.1
+ '@babel/helper-globals': 7.28.0
+ '@babel/parser': 7.29.2
+ '@babel/template': 7.28.6
+ '@babel/types': 7.29.0
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/types@7.29.0':
+ dependencies:
+ '@babel/helper-string-parser': 7.27.1
+ '@babel/helper-validator-identifier': 7.28.5
+
'@bramus/specificity@2.4.2':
dependencies:
css-tree: 3.2.1
@@ -1330,30 +2439,87 @@ snapshots:
'@esbuild/win32-x64@0.27.4':
optional: true
- '@exodus/bytes@1.15.0': {}
-
- '@jridgewell/gen-mapping@0.3.13':
+ '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))':
dependencies:
- '@jridgewell/sourcemap-codec': 1.5.5
- '@jridgewell/trace-mapping': 0.3.31
+ eslint: 9.39.4(jiti@2.6.1)
+ eslint-visitor-keys: 3.4.3
- '@jridgewell/remapping@2.3.5':
- dependencies:
- '@jridgewell/gen-mapping': 0.3.13
- '@jridgewell/trace-mapping': 0.3.31
+ '@eslint-community/regexpp@4.12.2': {}
- '@jridgewell/resolve-uri@3.1.2': {}
+ '@eslint/config-array@0.21.2':
+ dependencies:
+ '@eslint/object-schema': 2.1.7
+ debug: 4.4.3
+ minimatch: 3.1.5
+ transitivePeerDependencies:
+ - supports-color
- '@jridgewell/sourcemap-codec@1.5.5': {}
+ '@eslint/config-helpers@0.4.2':
+ dependencies:
+ '@eslint/core': 0.17.0
- '@jridgewell/trace-mapping@0.3.31':
+ '@eslint/core@0.17.0':
dependencies:
- '@jridgewell/resolve-uri': 3.1.2
- '@jridgewell/sourcemap-codec': 1.5.5
+ '@types/json-schema': 7.0.15
- '@napi-rs/wasm-runtime@1.1.1':
+ '@eslint/eslintrc@3.3.5':
dependencies:
- '@emnapi/core': 1.9.1
+ ajv: 6.14.0
+ debug: 4.4.3
+ espree: 10.4.0
+ globals: 14.0.0
+ ignore: 5.3.2
+ import-fresh: 3.3.1
+ js-yaml: 4.1.1
+ minimatch: 3.1.5
+ strip-json-comments: 3.1.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@eslint/js@9.39.4': {}
+
+ '@eslint/object-schema@2.1.7': {}
+
+ '@eslint/plugin-kit@0.4.1':
+ dependencies:
+ '@eslint/core': 0.17.0
+ levn: 0.4.1
+
+ '@exodus/bytes@1.15.0': {}
+
+ '@humanfs/core@0.19.1': {}
+
+ '@humanfs/node@0.16.7':
+ dependencies:
+ '@humanfs/core': 0.19.1
+ '@humanwhocodes/retry': 0.4.3
+
+ '@humanwhocodes/module-importer@1.0.1': {}
+
+ '@humanwhocodes/retry@0.4.3': {}
+
+ '@jridgewell/gen-mapping@0.3.13':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/remapping@2.3.5':
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/sourcemap-codec@1.5.5': {}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ '@napi-rs/wasm-runtime@1.1.1':
+ dependencies:
+ '@emnapi/core': 1.9.1
'@emnapi/runtime': 1.9.1
'@tybys/wasm-util': 0.10.1
optional: true
@@ -1535,6 +2701,8 @@ snapshots:
'@types/estree@1.0.8': {}
+ '@types/json-schema@7.0.15': {}
+
'@types/pako@2.0.4': {}
'@types/raf@3.4.3':
@@ -1551,6 +2719,97 @@ snapshots:
'@types/trusted-types@2.0.7':
optional: true
+ '@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/regexpp': 4.12.2
+ '@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.57.2
+ '@typescript-eslint/type-utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.57.2
+ eslint: 9.39.4(jiti@2.6.1)
+ ignore: 7.0.5
+ natural-compare: 1.4.0
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/scope-manager': 8.57.2
+ '@typescript-eslint/types': 8.57.2
+ '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.57.2
+ debug: 4.4.3
+ eslint: 9.39.4(jiti@2.6.1)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/project-service@8.57.2(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3)
+ '@typescript-eslint/types': 8.57.2
+ debug: 4.4.3
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/scope-manager@8.57.2':
+ dependencies:
+ '@typescript-eslint/types': 8.57.2
+ '@typescript-eslint/visitor-keys': 8.57.2
+
+ '@typescript-eslint/tsconfig-utils@8.57.2(typescript@5.9.3)':
+ dependencies:
+ typescript: 5.9.3
+
+ '@typescript-eslint/type-utils@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/types': 8.57.2
+ '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ debug: 4.4.3
+ eslint: 9.39.4(jiti@2.6.1)
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/types@8.57.2': {}
+
+ '@typescript-eslint/typescript-estree@8.57.2(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/project-service': 8.57.2(typescript@5.9.3)
+ '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3)
+ '@typescript-eslint/types': 8.57.2
+ '@typescript-eslint/visitor-keys': 8.57.2
+ debug: 4.4.3
+ minimatch: 10.2.4
+ semver: 7.7.4
+ tinyglobby: 0.2.15
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/utils@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
+ '@typescript-eslint/scope-manager': 8.57.2
+ '@typescript-eslint/types': 8.57.2
+ '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3)
+ eslint: 9.39.4(jiti@2.6.1)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/visitor-keys@8.57.2':
+ dependencies:
+ '@typescript-eslint/types': 8.57.2
+ eslint-visitor-keys: 5.0.1
+
'@vitejs/plugin-react@6.0.1(vite@8.0.3(esbuild@0.27.4)(jiti@2.6.1))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.7
@@ -1597,25 +2856,140 @@ snapshots:
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
+ acorn-jsx@5.3.2(acorn@8.16.0):
+ dependencies:
+ acorn: 8.16.0
+
+ acorn@8.16.0: {}
+
+ ajv@6.14.0:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-json-stable-stringify: 2.1.0
+ json-schema-traverse: 0.4.1
+ uri-js: 4.4.1
+
ansi-regex@5.0.1: {}
+ ansi-styles@4.3.0:
+ dependencies:
+ color-convert: 2.0.1
+
ansi-styles@5.2.0: {}
+ argparse@2.0.1: {}
+
aria-query@5.3.0:
dependencies:
dequal: 2.0.3
aria-query@5.3.2: {}
+ array-buffer-byte-length@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ is-array-buffer: 3.0.5
+
+ array-includes@3.1.9:
+ dependencies:
+ call-bind: 1.0.8
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-abstract: 1.24.1
+ es-object-atoms: 1.1.1
+ get-intrinsic: 1.3.0
+ is-string: 1.1.1
+ math-intrinsics: 1.1.0
+
+ array.prototype.flat@1.3.3:
+ dependencies:
+ call-bind: 1.0.8
+ define-properties: 1.2.1
+ es-abstract: 1.24.1
+ es-shim-unscopables: 1.1.0
+
+ array.prototype.flatmap@1.3.3:
+ dependencies:
+ call-bind: 1.0.8
+ define-properties: 1.2.1
+ es-abstract: 1.24.1
+ es-shim-unscopables: 1.1.0
+
+ arraybuffer.prototype.slice@1.0.4:
+ dependencies:
+ array-buffer-byte-length: 1.0.2
+ call-bind: 1.0.8
+ define-properties: 1.2.1
+ es-abstract: 1.24.1
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ is-array-buffer: 3.0.5
+
assertion-error@2.0.1: {}
+ ast-types-flow@0.0.8: {}
+
+ async-function@1.0.0: {}
+
+ available-typed-arrays@1.0.7:
+ dependencies:
+ possible-typed-array-names: 1.1.0
+
+ axe-core@4.11.1: {}
+
+ axobject-query@4.1.0: {}
+
+ balanced-match@1.0.2: {}
+
+ balanced-match@4.0.4: {}
+
base64-arraybuffer@1.0.2:
optional: true
+ baseline-browser-mapping@2.10.11: {}
+
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
+ brace-expansion@1.1.13:
+ dependencies:
+ balanced-match: 1.0.2
+ concat-map: 0.0.1
+
+ brace-expansion@5.0.5:
+ dependencies:
+ balanced-match: 4.0.4
+
+ browserslist@4.28.1:
+ dependencies:
+ baseline-browser-mapping: 2.10.11
+ caniuse-lite: 1.0.30001781
+ electron-to-chromium: 1.5.328
+ node-releases: 2.0.36
+ update-browserslist-db: 1.2.3(browserslist@4.28.1)
+
+ call-bind-apply-helpers@1.0.2:
+ dependencies:
+ es-errors: 1.3.0
+ function-bind: 1.1.2
+
+ call-bind@1.0.8:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-define-property: 1.0.1
+ get-intrinsic: 1.3.0
+ set-function-length: 1.2.2
+
+ call-bound@1.0.4:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ get-intrinsic: 1.3.0
+
+ callsites@3.1.0: {}
+
+ caniuse-lite@1.0.30001781: {}
+
canvg@3.0.11:
dependencies:
'@babel/runtime': 7.28.6
@@ -1630,11 +3004,30 @@ snapshots:
chai@6.2.2: {}
+ chalk@4.1.2:
+ dependencies:
+ ansi-styles: 4.3.0
+ supports-color: 7.2.0
+
+ color-convert@2.0.1:
+ dependencies:
+ color-name: 1.1.4
+
+ color-name@1.1.4: {}
+
+ concat-map@0.0.1: {}
+
convert-source-map@2.0.0: {}
core-js@3.48.0:
optional: true
+ cross-spawn@7.0.6:
+ dependencies:
+ path-key: 3.1.1
+ shebang-command: 2.0.0
+ which: 2.0.2
+
css-line-break@2.1.0:
dependencies:
utrie: 1.0.2
@@ -1649,6 +3042,8 @@ snapshots:
csstype@3.2.3: {}
+ damerau-levenshtein@1.0.8: {}
+
data-urls@7.0.0:
dependencies:
whatwg-mimetype: 5.0.0
@@ -1656,8 +3051,44 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
+ data-view-buffer@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-data-view: 1.0.2
+
+ data-view-byte-length@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-data-view: 1.0.2
+
+ data-view-byte-offset@1.0.1:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-data-view: 1.0.2
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
decimal.js@10.6.0: {}
+ deep-is@0.1.4: {}
+
+ define-data-property@1.1.4:
+ dependencies:
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ gopd: 1.2.0
+
+ define-properties@1.2.1:
+ dependencies:
+ define-data-property: 1.1.4
+ has-property-descriptors: 1.0.2
+ object-keys: 1.1.1
+
dequal@2.0.3: {}
detect-libc@2.1.2: {}
@@ -1671,6 +3102,16 @@ snapshots:
'@types/trusted-types': 2.0.7
optional: true
+ dunder-proto@1.0.1:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-errors: 1.3.0
+ gopd: 1.2.0
+
+ electron-to-chromium@1.5.328: {}
+
+ emoji-regex@9.2.2: {}
+
enhanced-resolve@5.20.1:
dependencies:
graceful-fs: 4.2.11
@@ -1678,8 +3119,90 @@ snapshots:
entities@6.0.1: {}
+ es-abstract@1.24.1:
+ dependencies:
+ array-buffer-byte-length: 1.0.2
+ arraybuffer.prototype.slice: 1.0.4
+ available-typed-arrays: 1.0.7
+ call-bind: 1.0.8
+ call-bound: 1.0.4
+ data-view-buffer: 1.0.2
+ data-view-byte-length: 1.0.2
+ data-view-byte-offset: 1.0.1
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.1
+ es-set-tostringtag: 2.1.0
+ es-to-primitive: 1.3.0
+ function.prototype.name: 1.1.8
+ get-intrinsic: 1.3.0
+ get-proto: 1.0.1
+ get-symbol-description: 1.1.0
+ globalthis: 1.0.4
+ gopd: 1.2.0
+ has-property-descriptors: 1.0.2
+ has-proto: 1.2.0
+ has-symbols: 1.1.0
+ hasown: 2.0.2
+ internal-slot: 1.1.0
+ is-array-buffer: 3.0.5
+ is-callable: 1.2.7
+ is-data-view: 1.0.2
+ is-negative-zero: 2.0.3
+ is-regex: 1.2.1
+ is-set: 2.0.3
+ is-shared-array-buffer: 1.0.4
+ is-string: 1.1.1
+ is-typed-array: 1.1.15
+ is-weakref: 1.1.1
+ math-intrinsics: 1.1.0
+ object-inspect: 1.13.4
+ object-keys: 1.1.1
+ object.assign: 4.1.7
+ own-keys: 1.0.1
+ regexp.prototype.flags: 1.5.4
+ safe-array-concat: 1.1.3
+ safe-push-apply: 1.0.0
+ safe-regex-test: 1.1.0
+ set-proto: 1.0.0
+ stop-iteration-iterator: 1.1.0
+ string.prototype.trim: 1.2.10
+ string.prototype.trimend: 1.0.9
+ string.prototype.trimstart: 1.0.8
+ typed-array-buffer: 1.0.3
+ typed-array-byte-length: 1.0.3
+ typed-array-byte-offset: 1.0.4
+ typed-array-length: 1.0.7
+ unbox-primitive: 1.1.0
+ which-typed-array: 1.1.20
+
+ es-define-property@1.0.1: {}
+
+ es-errors@1.3.0: {}
+
es-module-lexer@2.0.0: {}
+ es-object-atoms@1.1.1:
+ dependencies:
+ es-errors: 1.3.0
+
+ es-set-tostringtag@2.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ has-tostringtag: 1.0.2
+ hasown: 2.0.2
+
+ es-shim-unscopables@1.1.0:
+ dependencies:
+ hasown: 2.0.2
+
+ es-to-primitive@1.3.0:
+ dependencies:
+ is-callable: 1.2.7
+ is-date-object: 1.1.0
+ is-symbol: 1.1.1
+
esbuild@0.27.4:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.4
@@ -1710,12 +3233,130 @@ snapshots:
'@esbuild/win32-x64': 0.27.4
optional: true
+ escalade@3.2.0: {}
+
+ escape-string-regexp@4.0.0: {}
+
+ eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)):
+ dependencies:
+ eslint: 9.39.4(jiti@2.6.1)
+
+ eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@2.6.1)):
+ dependencies:
+ aria-query: 5.3.2
+ array-includes: 3.1.9
+ array.prototype.flatmap: 1.3.3
+ ast-types-flow: 0.0.8
+ axe-core: 4.11.1
+ axobject-query: 4.1.0
+ damerau-levenshtein: 1.0.8
+ emoji-regex: 9.2.2
+ eslint: 9.39.4(jiti@2.6.1)
+ hasown: 2.0.2
+ jsx-ast-utils: 3.3.5
+ language-tags: 1.0.9
+ minimatch: 3.1.5
+ object.fromentries: 2.0.8
+ safe-regex-test: 1.1.0
+ string.prototype.includes: 2.0.1
+
+ eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)):
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/parser': 7.29.2
+ eslint: 9.39.4(jiti@2.6.1)
+ hermes-parser: 0.25.1
+ zod: 4.3.6
+ zod-validation-error: 4.0.2(zod@4.3.6)
+ transitivePeerDependencies:
+ - supports-color
+
+ eslint-plugin-react-refresh@0.5.2(eslint@9.39.4(jiti@2.6.1)):
+ dependencies:
+ eslint: 9.39.4(jiti@2.6.1)
+
+ eslint-scope@8.4.0:
+ dependencies:
+ esrecurse: 4.3.0
+ estraverse: 5.3.0
+
+ eslint-visitor-keys@3.4.3: {}
+
+ eslint-visitor-keys@4.2.1: {}
+
+ eslint-visitor-keys@5.0.1: {}
+
+ eslint@9.39.4(jiti@2.6.1):
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
+ '@eslint-community/regexpp': 4.12.2
+ '@eslint/config-array': 0.21.2
+ '@eslint/config-helpers': 0.4.2
+ '@eslint/core': 0.17.0
+ '@eslint/eslintrc': 3.3.5
+ '@eslint/js': 9.39.4
+ '@eslint/plugin-kit': 0.4.1
+ '@humanfs/node': 0.16.7
+ '@humanwhocodes/module-importer': 1.0.1
+ '@humanwhocodes/retry': 0.4.3
+ '@types/estree': 1.0.8
+ ajv: 6.14.0
+ chalk: 4.1.2
+ cross-spawn: 7.0.6
+ debug: 4.4.3
+ escape-string-regexp: 4.0.0
+ eslint-scope: 8.4.0
+ eslint-visitor-keys: 4.2.1
+ espree: 10.4.0
+ esquery: 1.7.0
+ esutils: 2.0.3
+ fast-deep-equal: 3.1.3
+ file-entry-cache: 8.0.0
+ find-up: 5.0.0
+ glob-parent: 6.0.2
+ ignore: 5.3.2
+ imurmurhash: 0.1.4
+ is-glob: 4.0.3
+ json-stable-stringify-without-jsonify: 1.0.1
+ lodash.merge: 4.6.2
+ minimatch: 3.1.5
+ natural-compare: 1.4.0
+ optionator: 0.9.4
+ optionalDependencies:
+ jiti: 2.6.1
+ transitivePeerDependencies:
+ - supports-color
+
+ espree@10.4.0:
+ dependencies:
+ acorn: 8.16.0
+ acorn-jsx: 5.3.2(acorn@8.16.0)
+ eslint-visitor-keys: 4.2.1
+
+ esquery@1.7.0:
+ dependencies:
+ estraverse: 5.3.0
+
+ esrecurse@4.3.0:
+ dependencies:
+ estraverse: 5.3.0
+
+ estraverse@5.3.0: {}
+
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.8
+ esutils@2.0.3: {}
+
expect-type@1.3.0: {}
+ fast-deep-equal@3.1.3: {}
+
+ fast-json-stable-stringify@2.1.0: {}
+
+ fast-levenshtein@2.0.6: {}
+
fast-png@6.4.0:
dependencies:
'@types/pako': 2.0.4
@@ -1728,14 +3369,118 @@ snapshots:
fflate@0.8.2: {}
+ file-entry-cache@8.0.0:
+ dependencies:
+ flat-cache: 4.0.1
+
+ find-up@5.0.0:
+ dependencies:
+ locate-path: 6.0.0
+ path-exists: 4.0.0
+
+ flat-cache@4.0.1:
+ dependencies:
+ flatted: 3.4.2
+ keyv: 4.5.4
+
+ flatted@3.4.2: {}
+
+ for-each@0.3.5:
+ dependencies:
+ is-callable: 1.2.7
+
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
+ function-bind@1.1.2: {}
+
+ function.prototype.name@1.1.8:
+ dependencies:
+ call-bind: 1.0.8
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ functions-have-names: 1.2.3
+ hasown: 2.0.2
+ is-callable: 1.2.7
+
+ functions-have-names@1.2.3: {}
+
+ generator-function@2.0.1: {}
+
+ gensync@1.0.0-beta.2: {}
+
+ get-intrinsic@1.3.0:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.1
+ function-bind: 1.1.2
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ has-symbols: 1.1.0
+ hasown: 2.0.2
+ math-intrinsics: 1.1.0
+
+ get-proto@1.0.1:
+ dependencies:
+ dunder-proto: 1.0.1
+ es-object-atoms: 1.1.1
+
+ get-symbol-description@1.1.0:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+
+ glob-parent@6.0.2:
+ dependencies:
+ is-glob: 4.0.3
+
+ globals@14.0.0: {}
+
+ globals@17.4.0: {}
+
+ globalthis@1.0.4:
+ dependencies:
+ define-properties: 1.2.1
+ gopd: 1.2.0
+
+ gopd@1.2.0: {}
+
graceful-fs@4.2.11: {}
+ has-bigints@1.1.0: {}
+
+ has-flag@4.0.0: {}
+
+ has-property-descriptors@1.0.2:
+ dependencies:
+ es-define-property: 1.0.1
+
+ has-proto@1.2.0:
+ dependencies:
+ dunder-proto: 1.0.1
+
+ has-symbols@1.1.0: {}
+
+ has-tostringtag@1.0.2:
+ dependencies:
+ has-symbols: 1.1.0
+
+ hasown@2.0.2:
+ dependencies:
+ function-bind: 1.1.2
+
+ hermes-estree@0.25.1: {}
+
+ hermes-parser@0.25.1:
+ dependencies:
+ hermes-estree: 0.25.1
+
html-encoding-sniffer@6.0.0:
dependencies:
'@exodus/bytes': 1.15.0
@@ -1748,16 +3493,143 @@ snapshots:
text-segmentation: 1.0.3
optional: true
+ ignore@5.3.2: {}
+
+ ignore@7.0.5: {}
+
+ import-fresh@3.3.1:
+ dependencies:
+ parent-module: 1.0.1
+ resolve-from: 4.0.0
+
+ imurmurhash@0.1.4: {}
+
indent-string@4.0.0: {}
+ internal-slot@1.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ hasown: 2.0.2
+ side-channel: 1.1.0
+
iobuffer@5.4.0: {}
+ is-array-buffer@3.0.5:
+ dependencies:
+ call-bind: 1.0.8
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+
+ is-async-function@2.1.1:
+ dependencies:
+ async-function: 1.0.0
+ call-bound: 1.0.4
+ get-proto: 1.0.1
+ has-tostringtag: 1.0.2
+ safe-regex-test: 1.1.0
+
+ is-bigint@1.1.0:
+ dependencies:
+ has-bigints: 1.1.0
+
+ is-boolean-object@1.2.2:
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
+ is-callable@1.2.7: {}
+
+ is-data-view@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+ is-typed-array: 1.1.15
+
+ is-date-object@1.1.0:
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
+ is-extglob@2.1.1: {}
+
+ is-finalizationregistry@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+
+ is-generator-function@1.1.2:
+ dependencies:
+ call-bound: 1.0.4
+ generator-function: 2.0.1
+ get-proto: 1.0.1
+ has-tostringtag: 1.0.2
+ safe-regex-test: 1.1.0
+
+ is-glob@4.0.3:
+ dependencies:
+ is-extglob: 2.1.1
+
+ is-map@2.0.3: {}
+
+ is-negative-zero@2.0.3: {}
+
+ is-number-object@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
is-potential-custom-element-name@1.0.1: {}
+ is-regex@1.2.1:
+ dependencies:
+ call-bound: 1.0.4
+ gopd: 1.2.0
+ has-tostringtag: 1.0.2
+ hasown: 2.0.2
+
+ is-set@2.0.3: {}
+
+ is-shared-array-buffer@1.0.4:
+ dependencies:
+ call-bound: 1.0.4
+
+ is-string@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
+ is-symbol@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+ has-symbols: 1.1.0
+ safe-regex-test: 1.1.0
+
+ is-typed-array@1.1.15:
+ dependencies:
+ which-typed-array: 1.1.20
+
+ is-weakmap@2.0.2: {}
+
+ is-weakref@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+
+ is-weakset@2.0.4:
+ dependencies:
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+
+ isarray@2.0.5: {}
+
+ isexe@2.0.0: {}
+
jiti@2.6.1: {}
js-tokens@4.0.0: {}
+ js-yaml@4.1.1:
+ dependencies:
+ argparse: 2.0.1
+
jsdom@29.0.1:
dependencies:
'@asamuzakjp/css-color': 5.0.1
@@ -1784,6 +3656,16 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
+ jsesc@3.1.0: {}
+
+ json-buffer@3.0.1: {}
+
+ json-schema-traverse@0.4.1: {}
+
+ json-stable-stringify-without-jsonify@1.0.1: {}
+
+ json5@2.2.3: {}
+
jspdf@4.2.1:
dependencies:
'@babel/runtime': 7.28.6
@@ -1795,6 +3677,28 @@ snapshots:
dompurify: 3.3.2
html2canvas: 1.4.1
+ jsx-ast-utils@3.3.5:
+ dependencies:
+ array-includes: 3.1.9
+ array.prototype.flat: 1.3.3
+ object.assign: 4.1.7
+ object.values: 1.2.1
+
+ keyv@4.5.4:
+ dependencies:
+ json-buffer: 3.0.1
+
+ language-subtag-registry@0.3.23: {}
+
+ language-tags@1.0.9:
+ dependencies:
+ language-subtag-registry: 0.3.23
+
+ levn@0.4.1:
+ dependencies:
+ prelude-ls: 1.2.1
+ type-check: 0.4.0
+
lightningcss-android-arm64@1.32.0:
optional: true
@@ -1844,28 +3748,112 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
+ locate-path@6.0.0:
+ dependencies:
+ p-locate: 5.0.0
+
+ lodash.merge@4.6.2: {}
+
lru-cache@11.2.7: {}
+ lru-cache@5.1.1:
+ dependencies:
+ yallist: 3.1.1
+
lz-string@1.5.0: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
+ math-intrinsics@1.1.0: {}
+
mdn-data@2.27.1: {}
min-indent@1.0.1: {}
+ minimatch@10.2.4:
+ dependencies:
+ brace-expansion: 5.0.5
+
+ minimatch@3.1.5:
+ dependencies:
+ brace-expansion: 1.1.13
+
+ ms@2.1.3: {}
+
nanoid@3.3.11: {}
+ natural-compare@1.4.0: {}
+
+ node-releases@2.0.36: {}
+
+ object-inspect@1.13.4: {}
+
+ object-keys@1.1.1: {}
+
+ object.assign@4.1.7:
+ dependencies:
+ call-bind: 1.0.8
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.1
+ has-symbols: 1.1.0
+ object-keys: 1.1.1
+
+ object.fromentries@2.0.8:
+ dependencies:
+ call-bind: 1.0.8
+ define-properties: 1.2.1
+ es-abstract: 1.24.1
+ es-object-atoms: 1.1.1
+
+ object.values@1.2.1:
+ dependencies:
+ call-bind: 1.0.8
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.1
+
obug@2.1.1: {}
+ optionator@0.9.4:
+ dependencies:
+ deep-is: 0.1.4
+ fast-levenshtein: 2.0.6
+ levn: 0.4.1
+ prelude-ls: 1.2.1
+ type-check: 0.4.0
+ word-wrap: 1.2.5
+
+ own-keys@1.0.1:
+ dependencies:
+ get-intrinsic: 1.3.0
+ object-keys: 1.1.1
+ safe-push-apply: 1.0.0
+
+ p-limit@3.1.0:
+ dependencies:
+ yocto-queue: 0.1.0
+
+ p-locate@5.0.0:
+ dependencies:
+ p-limit: 3.1.0
+
pako@2.1.0: {}
+ parent-module@1.0.1:
+ dependencies:
+ callsites: 3.1.0
+
parse5@8.0.0:
dependencies:
entities: 6.0.1
+ path-exists@4.0.0: {}
+
+ path-key@3.1.1: {}
+
pathe@2.0.3: {}
performance-now@2.1.0:
@@ -1885,12 +3873,18 @@ snapshots:
optionalDependencies:
fsevents: 2.3.2
+ possible-typed-array-names@1.1.0: {}
+
postcss@8.5.8:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
+ prelude-ls@1.2.1: {}
+
+ prettier@3.8.1: {}
+
pretty-format@27.5.1:
dependencies:
ansi-regex: 5.0.1
@@ -1918,11 +3912,33 @@ snapshots:
indent-string: 4.0.0
strip-indent: 3.0.0
+ reflect.getprototypeof@1.0.10:
+ dependencies:
+ call-bind: 1.0.8
+ define-properties: 1.2.1
+ es-abstract: 1.24.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.1
+ get-intrinsic: 1.3.0
+ get-proto: 1.0.1
+ which-builtin-type: 1.2.1
+
regenerator-runtime@0.13.11:
optional: true
+ regexp.prototype.flags@1.5.4:
+ dependencies:
+ call-bind: 1.0.8
+ define-properties: 1.2.1
+ es-errors: 1.3.0
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ set-function-name: 2.0.2
+
require-from-string@2.0.2: {}
+ resolve-from@4.0.0: {}
+
rgbcolor@1.0.1:
optional: true
@@ -1947,12 +3963,91 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12
+ safe-array-concat@1.1.3:
+ dependencies:
+ call-bind: 1.0.8
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+ has-symbols: 1.1.0
+ isarray: 2.0.5
+
+ safe-push-apply@1.0.0:
+ dependencies:
+ es-errors: 1.3.0
+ isarray: 2.0.5
+
+ safe-regex-test@1.1.0:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-regex: 1.2.1
+
saxes@6.0.0:
dependencies:
xmlchars: 2.2.0
scheduler@0.27.0: {}
+ semver@6.3.1: {}
+
+ semver@7.7.4: {}
+
+ set-function-length@1.2.2:
+ dependencies:
+ define-data-property: 1.1.4
+ es-errors: 1.3.0
+ function-bind: 1.1.2
+ get-intrinsic: 1.3.0
+ gopd: 1.2.0
+ has-property-descriptors: 1.0.2
+
+ set-function-name@2.0.2:
+ dependencies:
+ define-data-property: 1.1.4
+ es-errors: 1.3.0
+ functions-have-names: 1.2.3
+ has-property-descriptors: 1.0.2
+
+ set-proto@1.0.0:
+ dependencies:
+ dunder-proto: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.1
+
+ shebang-command@2.0.0:
+ dependencies:
+ shebang-regex: 3.0.0
+
+ shebang-regex@3.0.0: {}
+
+ side-channel-list@1.0.0:
+ dependencies:
+ es-errors: 1.3.0
+ object-inspect: 1.13.4
+
+ side-channel-map@1.0.1:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ object-inspect: 1.13.4
+
+ side-channel-weakmap@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ object-inspect: 1.13.4
+ side-channel-map: 1.0.1
+
+ side-channel@1.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ object-inspect: 1.13.4
+ side-channel-list: 1.0.0
+ side-channel-map: 1.0.1
+ side-channel-weakmap: 1.0.2
+
siginfo@2.0.0: {}
source-map-js@1.2.1: {}
@@ -1964,10 +4059,50 @@ snapshots:
std-env@4.0.0: {}
+ stop-iteration-iterator@1.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ internal-slot: 1.1.0
+
+ string.prototype.includes@2.0.1:
+ dependencies:
+ call-bind: 1.0.8
+ define-properties: 1.2.1
+ es-abstract: 1.24.1
+
+ string.prototype.trim@1.2.10:
+ dependencies:
+ call-bind: 1.0.8
+ call-bound: 1.0.4
+ define-data-property: 1.1.4
+ define-properties: 1.2.1
+ es-abstract: 1.24.1
+ es-object-atoms: 1.1.1
+ has-property-descriptors: 1.0.2
+
+ string.prototype.trimend@1.0.9:
+ dependencies:
+ call-bind: 1.0.8
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.1
+
+ string.prototype.trimstart@1.0.8:
+ dependencies:
+ call-bind: 1.0.8
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.1
+
strip-indent@3.0.0:
dependencies:
min-indent: 1.0.1
+ strip-json-comments@3.1.1: {}
+
+ supports-color@7.2.0:
+ dependencies:
+ has-flag: 4.0.0
+
svg-pathdata@6.0.3:
optional: true
@@ -2007,13 +4142,82 @@ snapshots:
dependencies:
punycode: 2.3.1
+ ts-api-utils@2.5.0(typescript@5.9.3):
+ dependencies:
+ typescript: 5.9.3
+
tslib@2.8.1:
optional: true
+ type-check@0.4.0:
+ dependencies:
+ prelude-ls: 1.2.1
+
+ typed-array-buffer@1.0.3:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-typed-array: 1.1.15
+
+ typed-array-byte-length@1.0.3:
+ dependencies:
+ call-bind: 1.0.8
+ for-each: 0.3.5
+ gopd: 1.2.0
+ has-proto: 1.2.0
+ is-typed-array: 1.1.15
+
+ typed-array-byte-offset@1.0.4:
+ dependencies:
+ available-typed-arrays: 1.0.7
+ call-bind: 1.0.8
+ for-each: 0.3.5
+ gopd: 1.2.0
+ has-proto: 1.2.0
+ is-typed-array: 1.1.15
+ reflect.getprototypeof: 1.0.10
+
+ typed-array-length@1.0.7:
+ dependencies:
+ call-bind: 1.0.8
+ for-each: 0.3.5
+ gopd: 1.2.0
+ is-typed-array: 1.1.15
+ possible-typed-array-names: 1.1.0
+ reflect.getprototypeof: 1.0.10
+
+ typescript-eslint@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3):
+ dependencies:
+ '@typescript-eslint/eslint-plugin': 8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ eslint: 9.39.4(jiti@2.6.1)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
typescript@5.9.3: {}
+ unbox-primitive@1.1.0:
+ dependencies:
+ call-bound: 1.0.4
+ has-bigints: 1.1.0
+ has-symbols: 1.1.0
+ which-boxed-primitive: 1.1.1
+
undici@7.24.6: {}
+ update-browserslist-db@1.2.3(browserslist@4.28.1):
+ dependencies:
+ browserslist: 4.28.1
+ escalade: 3.2.0
+ picocolors: 1.1.1
+
+ uri-js@4.4.1:
+ dependencies:
+ punycode: 2.3.1
+
utrie@1.0.2:
dependencies:
base64-arraybuffer: 1.0.2
@@ -2074,11 +4278,68 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
+ which-boxed-primitive@1.1.1:
+ dependencies:
+ is-bigint: 1.1.0
+ is-boolean-object: 1.2.2
+ is-number-object: 1.1.1
+ is-string: 1.1.1
+ is-symbol: 1.1.1
+
+ which-builtin-type@1.2.1:
+ dependencies:
+ call-bound: 1.0.4
+ function.prototype.name: 1.1.8
+ has-tostringtag: 1.0.2
+ is-async-function: 2.1.1
+ is-date-object: 1.1.0
+ is-finalizationregistry: 1.1.1
+ is-generator-function: 1.1.2
+ is-regex: 1.2.1
+ is-weakref: 1.1.1
+ isarray: 2.0.5
+ which-boxed-primitive: 1.1.1
+ which-collection: 1.0.2
+ which-typed-array: 1.1.20
+
+ which-collection@1.0.2:
+ dependencies:
+ is-map: 2.0.3
+ is-set: 2.0.3
+ is-weakmap: 2.0.2
+ is-weakset: 2.0.4
+
+ which-typed-array@1.1.20:
+ dependencies:
+ available-typed-arrays: 1.0.7
+ call-bind: 1.0.8
+ call-bound: 1.0.4
+ for-each: 0.3.5
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ has-tostringtag: 1.0.2
+
+ which@2.0.2:
+ dependencies:
+ isexe: 2.0.0
+
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
+ word-wrap@1.2.5: {}
+
xml-name-validator@5.0.0: {}
xmlchars@2.2.0: {}
+
+ yallist@3.1.1: {}
+
+ yocto-queue@0.1.0: {}
+
+ zod-validation-error@4.0.2(zod@4.3.6):
+ dependencies:
+ zod: 4.3.6
+
+ zod@4.3.6: {}
diff --git a/renovate.json b/renovate.json
index 5db72dd..22a9943 100644
--- a/renovate.json
+++ b/renovate.json
@@ -1,6 +1,4 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
- "extends": [
- "config:recommended"
- ]
+ "extends": ["config:recommended"]
}
diff --git a/src/App.test.tsx b/src/App.test.tsx
index 1a932b0..a774cd7 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -30,23 +30,32 @@ function createDeferred() {
}
describe('App', () => {
- const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
+ const originalClientWidth = Object.getOwnPropertyDescriptor(
+ HTMLElement.prototype,
+ 'clientWidth',
+ )
beforeEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
downloadPracticePdfMock.mockClear()
downloadPracticePdfMock.mockResolvedValue(undefined)
- vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => {
- callback(0)
- return 0
- })
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation(
+ (callback: FrameRequestCallback) => {
+ callback(0)
+ return 0
+ },
+ )
})
afterEach(() => {
vi.useRealTimers()
if (originalClientWidth) {
- Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth)
+ Object.defineProperty(
+ HTMLElement.prototype,
+ 'clientWidth',
+ originalClientWidth,
+ )
} else {
Reflect.deleteProperty(HTMLElement.prototype, 'clientWidth')
}
@@ -55,21 +64,29 @@ describe('App', () => {
it('renders the Thai Script Pro hero copy', () => {
render( )
expect(screen.getByText('Thai Script Pro')).toBeInTheDocument()
- expect(screen.getByRole('heading', { name: /thai worksheet generator/i })).toBeInTheDocument()
expect(
- screen.getByText('Create polished printable Thai writing sheets in seconds.')
+ screen.getByRole('heading', { name: /thai worksheet generator/i }),
+ ).toBeInTheDocument()
+ expect(
+ screen.getByText(
+ 'Create polished printable Thai writing sheets in seconds.',
+ ),
).toBeInTheDocument()
})
it('renders content selection, sheet options, preview, and output actions', () => {
render( )
- const contentSelectionHeading = screen.getByRole('heading', { name: /consonants/i })
+ const contentSelectionHeading = screen.getByRole('heading', {
+ name: /consonants/i,
+ })
const rowsSelect = screen.getByLabelText(/^rows$/i)
expect(contentSelectionHeading).toBeInTheDocument()
expect(rowsSelect).toBeInTheDocument()
expect(screen.getByRole('region', { name: /preview/i })).toBeInTheDocument()
- expect(screen.getByRole('button', { name: /download pdf/i })).toBeInTheDocument()
+ expect(
+ screen.getByRole('button', { name: /download pdf/i }),
+ ).toBeInTheDocument()
})
it('downloads PDF through the native generator helper', async () => {
@@ -85,7 +102,7 @@ describe('App', () => {
selectedVowelIds: [],
config: DEFAULT_SHEET_CONFIG,
onProgress: expect.any(Function),
- })
+ }),
)
})
@@ -99,13 +116,17 @@ describe('App', () => {
await user.click(screen.getByRole('button', { name: /consonant presets/i }))
await user.click(screen.getByRole('option', { name: /^Middle Class/i }))
- expect(screen.getByRole('button', { name: /consonant presets/i })).toHaveTextContent(
- 'Middle Class'
- )
- expect(screen.getByText(new RegExp(`${mc.consonantIds.length} of 44 selected`, 'i'))).toBeInTheDocument()
- expect(screen.getByRole('region', { name: /preview/i })).not.toHaveTextContent(
- /select consonants or vowels to see preview/i
- )
+ expect(
+ screen.getByRole('button', { name: /consonant presets/i }),
+ ).toHaveTextContent('Middle Class')
+ expect(
+ screen.getByText(
+ new RegExp(`${mc.consonantIds.length} of 44 selected`, 'i'),
+ ),
+ ).toBeInTheDocument()
+ expect(
+ screen.getByRole('region', { name: /preview/i }),
+ ).not.toHaveTextContent(/select consonants or vowels to see preview/i)
})
it('lets the user deselect a checked consonant preset through the dropdown', async () => {
@@ -117,10 +138,12 @@ describe('App', () => {
await user.click(screen.getByRole('button', { name: /consonant presets/i }))
await user.click(screen.getByRole('option', { name: /^Middle Class/i }))
- expect(screen.getByRole('button', { name: /consonant presets/i })).toHaveTextContent('Presets')
+ expect(
+ screen.getByRole('button', { name: /consonant presets/i }),
+ ).toHaveTextContent('Presets')
expect(screen.getByText(/0 of 44 selected/i)).toBeInTheDocument()
expect(screen.getByRole('region', { name: /preview/i })).toHaveTextContent(
- /select consonants or vowels to see preview/i
+ /select consonants or vowels to see preview/i,
)
})
@@ -134,15 +157,20 @@ describe('App', () => {
await user.click(screen.getByRole('button', { name: /vowel presets/i }))
await user.click(screen.getByRole('option', { name: /^Short Vowels/i }))
- expect(screen.getByRole('button', { name: /vowel presets/i })).toHaveTextContent(
- 'Short Vowels'
- )
expect(
- screen.getByText(new RegExp(`${short.vowelIds.length} of ${THAI_VOWELS.length} selected`, 'i'))
+ screen.getByRole('button', { name: /vowel presets/i }),
+ ).toHaveTextContent('Short Vowels')
+ expect(
+ screen.getByText(
+ new RegExp(
+ `${short.vowelIds.length} of ${THAI_VOWELS.length} selected`,
+ 'i',
+ ),
+ ),
).toBeInTheDocument()
- expect(screen.getByRole('region', { name: /preview/i })).not.toHaveTextContent(
- /select consonants or vowels to see preview/i
- )
+ expect(
+ screen.getByRole('region', { name: /preview/i }),
+ ).not.toHaveTextContent(/select consonants or vowels to see preview/i)
})
it('lets the user deselect a checked vowel preset through the dropdown', async () => {
@@ -154,9 +182,11 @@ describe('App', () => {
await user.click(screen.getByRole('button', { name: /vowel presets/i }))
await user.click(screen.getByRole('option', { name: /^Short Vowels/i }))
- expect(screen.getByRole('button', { name: /vowel presets/i })).toHaveTextContent('Presets')
+ expect(
+ screen.getByRole('button', { name: /vowel presets/i }),
+ ).toHaveTextContent('Presets')
expect(screen.getByRole('region', { name: /preview/i })).toHaveTextContent(
- /select consonants or vowels to see preview/i
+ /select consonants or vowels to see preview/i,
)
})
@@ -171,43 +201,69 @@ describe('App', () => {
expect(screen.getByLabelText(/^columns$/i)).toHaveValue('7')
expect(screen.getByLabelText(/^ghost copies$/i)).toHaveValue('7')
- expect(screen.getByText('Adjusted to 7 columns so it fits on the page.')).toBeInTheDocument()
+ expect(
+ screen.getByText('Adjusted to 7 columns so it fits on the page.'),
+ ).toBeInTheDocument()
})
it('keeps the toast visible for 5 seconds before auto-dismissing', async () => {
vi.useFakeTimers()
render( )
- fireEvent.change(screen.getByLabelText(/font size/i), { target: { value: 'small' } })
- fireEvent.change(screen.getByLabelText(/^columns$/i), { target: { value: '12' } })
- fireEvent.change(screen.getByLabelText(/font size/i), { target: { value: 'large' } })
+ fireEvent.change(screen.getByLabelText(/font size/i), {
+ target: { value: 'small' },
+ })
+ fireEvent.change(screen.getByLabelText(/^columns$/i), {
+ target: { value: '12' },
+ })
+ fireEvent.change(screen.getByLabelText(/font size/i), {
+ target: { value: 'large' },
+ })
- expect(screen.getByText('Adjusted to 7 columns so it fits on the page.')).toBeInTheDocument()
+ expect(
+ screen.getByText('Adjusted to 7 columns so it fits on the page.'),
+ ).toBeInTheDocument()
act(() => {
vi.advanceTimersByTime(4900)
})
- expect(screen.getByText('Adjusted to 7 columns so it fits on the page.')).toBeInTheDocument()
+ expect(
+ screen.getByText('Adjusted to 7 columns so it fits on the page.'),
+ ).toBeInTheDocument()
act(() => {
vi.advanceTimersByTime(100)
})
- expect(screen.queryByText('Adjusted to 7 columns so it fits on the page.')).not.toBeInTheDocument()
+ expect(
+ screen.queryByText('Adjusted to 7 columns so it fits on the page.'),
+ ).not.toBeInTheDocument()
})
it('allows the user to dismiss the clamp toast immediately', async () => {
vi.useFakeTimers()
render( )
- fireEvent.change(screen.getByLabelText(/font size/i), { target: { value: 'small' } })
- fireEvent.change(screen.getByLabelText(/^columns$/i), { target: { value: '9' } })
- fireEvent.change(screen.getByLabelText(/font size/i), { target: { value: 'large' } })
+ fireEvent.change(screen.getByLabelText(/font size/i), {
+ target: { value: 'small' },
+ })
+ fireEvent.change(screen.getByLabelText(/^columns$/i), {
+ target: { value: '9' },
+ })
+ fireEvent.change(screen.getByLabelText(/font size/i), {
+ target: { value: 'large' },
+ })
- expect(screen.getByText('Adjusted to 7 columns so it fits on the page.')).toBeInTheDocument()
+ expect(
+ screen.getByText('Adjusted to 7 columns so it fits on the page.'),
+ ).toBeInTheDocument()
- fireEvent.click(screen.getByRole('button', { name: /dismiss notification/i }))
+ fireEvent.click(
+ screen.getByRole('button', { name: /dismiss notification/i }),
+ )
- expect(screen.queryByText('Adjusted to 7 columns so it fits on the page.')).not.toBeInTheDocument()
+ expect(
+ screen.queryByText('Adjusted to 7 columns so it fits on the page.'),
+ ).not.toBeInTheDocument()
})
it('does not show a toast when the selected columns already fit the new font size', async () => {
@@ -217,7 +273,9 @@ describe('App', () => {
await user.selectOptions(screen.getByLabelText(/^columns$/i), '7')
await user.selectOptions(screen.getByLabelText(/font size/i), 'large')
- expect(screen.queryByText('Adjusted to 7 columns so it fits on the page.')).not.toBeInTheDocument()
+ expect(
+ screen.queryByText('Adjusted to 7 columns so it fits on the page.'),
+ ).not.toBeInTheDocument()
})
it('disables the download button and shows progress while the PDF is being prepared', async () => {
@@ -233,8 +291,12 @@ describe('App', () => {
await user.click(screen.getByRole('button', { name: /download pdf/i }))
- expect(screen.getByRole('button', { name: /building 2\/5/i })).toBeDisabled()
- expect(screen.getByText('Building your PDF pages (2 of 5)...')).toBeInTheDocument()
+ expect(
+ screen.getByRole('button', { name: /building 2\/5/i }),
+ ).toBeDisabled()
+ expect(
+ screen.getByText('Building your PDF pages (2 of 5)...'),
+ ).toBeInTheDocument()
deferred.resolve(undefined)
await act(async () => {
@@ -242,19 +304,25 @@ describe('App', () => {
})
expect(screen.getByRole('button', { name: /download pdf/i })).toBeEnabled()
- expect(screen.getAllByRole('status').some((node) => node.textContent === '')).toBe(true)
+ expect(
+ screen.getAllByRole('status').some((node) => node.textContent === ''),
+ ).toBe(true)
})
it('shows an error toast if the PDF export fails', async () => {
const user = userEvent.setup()
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {})
downloadPracticePdfMock.mockRejectedValueOnce(new Error('network failure'))
render( )
await user.click(screen.getByRole('button', { name: /download pdf/i }))
- expect(await screen.findByText('PDF export failed. Please try again.')).toBeInTheDocument()
+ expect(
+ await screen.findByText('PDF export failed. Please try again.'),
+ ).toBeInTheDocument()
expect(screen.getByRole('button', { name: /download pdf/i })).toBeEnabled()
expect(consoleErrorSpy).toHaveBeenCalled()
})
@@ -269,7 +337,9 @@ describe('App', () => {
const button = screen.getByRole('button', { name: /download pdf/i })
await user.click(button)
- expect(screen.getByRole('button', { name: /preparing pdf/i })).toBeDisabled()
+ expect(
+ screen.getByRole('button', { name: /preparing pdf/i }),
+ ).toBeDisabled()
await user.click(screen.getByRole('button', { name: /preparing pdf/i }))
diff --git a/src/App.tsx b/src/App.tsx
index 6575b27..fe8c5b4 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -16,19 +16,27 @@ import type { SheetConfig } from './data/sheetOptions'
function App() {
const selection = useContentSelection()
- const [sheetConfig, setSheetConfig] = useState(DEFAULT_SHEET_CONFIG)
+ const [sheetConfig, setSheetConfig] =
+ useState(DEFAULT_SHEET_CONFIG)
const [toastMessage, setToastMessage] = useState(null)
const toastTimeoutRef = useRef(null)
- const selectedFontFamily = FONT_FAMILY_MAP[sheetConfig.font] || '"Sarabun", sans-serif'
+ const selectedFontFamily =
+ FONT_FAMILY_MAP[sheetConfig.font] || '"Sarabun", sans-serif'
const handleInitialPreviewColumns = useCallback(
- ({ columns, ghostCopiesPerRow }: { columns: number; ghostCopiesPerRow: number }) => {
+ ({
+ columns,
+ ghostCopiesPerRow,
+ }: {
+ columns: number
+ ghostCopiesPerRow: number
+ }) => {
setSheetConfig((current) => ({
...current,
columns,
ghostCopiesPerRow,
}))
},
- []
+ [],
)
const { previewRootRef } = useInitialPreviewColumns({
fontSize: sheetConfig.fontSize,
@@ -141,7 +149,12 @@ function App() {
onClick={dismissToast}
className="rounded-full p-1 text-white/80 transition-colors hover:bg-white/10 hover:text-white"
>
-
+
{
it('renders a Consonants section with heading', () => {
render( )
- expect(screen.getByRole('heading', { name: /consonants/i })).toBeInTheDocument()
+ expect(
+ screen.getByRole('heading', { name: /consonants/i }),
+ ).toBeInTheDocument()
})
it('renders all 44 consonant items', () => {
const { container } = render( )
- const consonantGrid = container.querySelector('[data-consonant-grid="true"]')
- if (!consonantGrid) throw new Error('Expected consonant grid to be rendered')
+ const consonantGrid = container.querySelector(
+ '[data-consonant-grid="true"]',
+ )
+ if (!consonantGrid)
+ throw new Error('Expected consonant grid to be rendered')
const buttons = consonantGrid.querySelectorAll('button')
expect(buttons).toHaveLength(44)
})
@@ -29,7 +35,9 @@ describe('ContentSelection', () => {
expect(summary).toBeInTheDocument()
const firstConsonant = THAI_CONSONANTS[0]
- const buttons = screen.getAllByRole('button', { name: new RegExp(`^${firstConsonant.char}`) })
+ const buttons = screen.getAllByRole('button', {
+ name: new RegExp(`^${firstConsonant.char}`),
+ })
await user.click(buttons[0])
expect(screen.getByText(/1 consonants?/i)).toBeInTheDocument()
@@ -38,7 +46,9 @@ describe('ContentSelection', () => {
it('Select all consonants button selects all 44', async () => {
const user = userEvent.setup()
render( )
- const selectAllBtn = screen.getByRole('button', { name: /select all.*consonant/i })
+ const selectAllBtn = screen.getByRole('button', {
+ name: /select all.*consonant/i,
+ })
await user.click(selectAllBtn)
expect(screen.getByText(/44 consonants?/i)).toBeInTheDocument()
})
@@ -46,14 +56,18 @@ describe('ContentSelection', () => {
it('Clear consonants button clears selection', async () => {
const user = userEvent.setup()
render( )
- await user.click(screen.getByRole('button', { name: /select all.*consonant/i }))
+ await user.click(
+ screen.getByRole('button', { name: /select all.*consonant/i }),
+ )
await user.click(screen.getByRole('button', { name: /clear.*consonant/i }))
expect(screen.getByText(/0 consonants?/i)).toBeInTheDocument()
})
it('renders a presets dropdown for consonants', () => {
render( )
- expect(screen.getByRole('button', { name: /consonant presets/i })).toHaveTextContent('Presets')
+ expect(
+ screen.getByRole('button', { name: /consonant presets/i }),
+ ).toHaveTextContent('Presets')
})
it('shows all preset options with their full labels when opened', async () => {
@@ -62,27 +76,24 @@ describe('ContentSelection', () => {
await user.click(screen.getByRole('button', { name: /consonant presets/i }))
- expect(screen.getByRole('listbox', { name: /consonant preset options/i })).toBeInTheDocument()
- expect(screen.getByRole('option', { name: /Low Class - Group 1/i })).toHaveAttribute(
- 'title',
- 'Low Class Consonants - Unpaired'
- )
- expect(screen.getByRole('option', { name: /Low Class - Group 2/i })).toHaveAttribute(
- 'title',
- 'Low Class Consonants - Paired'
- )
- expect(screen.getByRole('option', { name: /^Middle Class/i })).toHaveAttribute(
- 'title',
- 'Middle Class Consonants'
- )
- expect(screen.getByRole('option', { name: /^High Class/i })).toHaveAttribute(
- 'title',
- 'High Class Consonants'
- )
- expect(screen.getByRole('option', { name: /Low Class - Group 1/i })).toHaveClass(
- 'border',
- 'border-transparent'
- )
+ expect(
+ screen.getByRole('listbox', { name: /consonant preset options/i }),
+ ).toBeInTheDocument()
+ expect(
+ screen.getByRole('option', { name: /Low Class - Group 1/i }),
+ ).toHaveAttribute('title', 'Low Class Consonants - Unpaired')
+ expect(
+ screen.getByRole('option', { name: /Low Class - Group 2/i }),
+ ).toHaveAttribute('title', 'Low Class Consonants - Paired')
+ expect(
+ screen.getByRole('option', { name: /^Middle Class/i }),
+ ).toHaveAttribute('title', 'Middle Class Consonants')
+ expect(
+ screen.getByRole('option', { name: /^High Class/i }),
+ ).toHaveAttribute('title', 'High Class Consonants')
+ expect(
+ screen.getByRole('option', { name: /Low Class - Group 1/i }),
+ ).toHaveClass('border', 'border-transparent')
})
it('selecting LCG1 marks the expected consonants selected', async () => {
@@ -93,20 +104,26 @@ describe('ContentSelection', () => {
render( )
await user.click(screen.getByRole('button', { name: /consonant presets/i }))
- await user.click(screen.getByRole('option', { name: /Low Class - Group 1/i }))
-
- expect(screen.getByRole('button', { name: /consonant presets/i })).toHaveTextContent(
- 'Low Class - Group 1'
+ await user.click(
+ screen.getByRole('option', { name: /Low Class - Group 1/i }),
)
+
+ expect(
+ screen.getByRole('button', { name: /consonant presets/i }),
+ ).toHaveTextContent('Low Class - Group 1')
expect(
- screen.getByText(new RegExp(`${lcg1.consonantIds.length} of ${THAI_CONSONANTS.length} selected`, 'i'))
+ screen.getByText(
+ new RegExp(
+ `${lcg1.consonantIds.length} of ${THAI_CONSONANTS.length} selected`,
+ 'i',
+ ),
+ ),
).toBeInTheDocument()
lcg1.consonantIds.forEach((id) => {
- expect(screen.getAllByRole('button', { name: new RegExp(`^${id}`) })[0]).toHaveAttribute(
- 'aria-pressed',
- 'true'
- )
+ expect(
+ screen.getAllByRole('button', { name: new RegExp(`^${id}`) })[0],
+ ).toHaveAttribute('aria-pressed', 'true')
})
})
@@ -117,11 +134,15 @@ describe('ContentSelection', () => {
const trigger = screen.getByRole('button', { name: /consonant presets/i })
await user.click(trigger)
- expect(screen.getByRole('listbox', { name: /consonant preset options/i })).toBeInTheDocument()
+ expect(
+ screen.getByRole('listbox', { name: /consonant preset options/i }),
+ ).toBeInTheDocument()
await user.click(document.body)
- expect(screen.queryByRole('listbox', { name: /consonant preset options/i })).not.toBeInTheDocument()
+ expect(
+ screen.queryByRole('listbox', { name: /consonant preset options/i }),
+ ).not.toBeInTheDocument()
})
it('clicking a checked preset row deselects that group', async () => {
@@ -132,18 +153,27 @@ describe('ContentSelection', () => {
render( )
await user.click(screen.getByRole('button', { name: /consonant presets/i }))
- await user.click(screen.getByRole('option', { name: /Low Class - Group 1/i }))
+ await user.click(
+ screen.getByRole('option', { name: /Low Class - Group 1/i }),
+ )
await user.click(screen.getByRole('button', { name: /consonant presets/i }))
- await user.click(screen.getByRole('option', { name: /Low Class - Group 1/i }))
+ await user.click(
+ screen.getByRole('option', { name: /Low Class - Group 1/i }),
+ )
- expect(screen.getByRole('button', { name: /consonant presets/i })).toHaveTextContent('Presets')
- expect(screen.getByText(new RegExp(`0 of ${THAI_CONSONANTS.length} selected`, 'i'))).toBeInTheDocument()
+ expect(
+ screen.getByRole('button', { name: /consonant presets/i }),
+ ).toHaveTextContent('Presets')
+ expect(
+ screen.getByText(
+ new RegExp(`0 of ${THAI_CONSONANTS.length} selected`, 'i'),
+ ),
+ ).toBeInTheDocument()
lcg1.consonantIds.forEach((id) => {
- expect(screen.getAllByRole('button', { name: new RegExp(`^${id}`) })[0]).toHaveAttribute(
- 'aria-pressed',
- 'false'
- )
+ expect(
+ screen.getAllByRole('button', { name: new RegExp(`^${id}`) })[0],
+ ).toHaveAttribute('aria-pressed', 'false')
})
})
@@ -156,15 +186,22 @@ describe('ContentSelection', () => {
render( )
await user.click(screen.getByRole('button', { name: /consonant presets/i }))
- await user.click(screen.getByRole('option', { name: /Low Class - Group 1/i }))
+ await user.click(
+ screen.getByRole('option', { name: /Low Class - Group 1/i }),
+ )
await user.click(screen.getByRole('button', { name: /consonant presets/i }))
await user.click(screen.getByRole('option', { name: /^Middle Class/i }))
- expect(screen.getByRole('button', { name: /consonant presets/i })).toHaveTextContent('Custom')
+ expect(
+ screen.getByRole('button', { name: /consonant presets/i }),
+ ).toHaveTextContent('Custom')
expect(
screen.getByText(
- new RegExp(`${lcg1.consonantIds.length + mc.consonantIds.length} of ${THAI_CONSONANTS.length} selected`, 'i')
- )
+ new RegExp(
+ `${lcg1.consonantIds.length + mc.consonantIds.length} of ${THAI_CONSONANTS.length} selected`,
+ 'i',
+ ),
+ ),
).toBeInTheDocument()
})
@@ -176,12 +213,21 @@ describe('ContentSelection', () => {
render( )
await user.click(screen.getByRole('button', { name: /consonant presets/i }))
- await user.click(screen.getByRole('option', { name: /Low Class - Group 1/i }))
+ await user.click(
+ screen.getByRole('option', { name: /Low Class - Group 1/i }),
+ )
await user.click(screen.getAllByRole('button', { name: /^ก/ })[0])
- expect(screen.getByRole('button', { name: /consonant presets/i })).toHaveTextContent('Custom')
expect(
- screen.getByText(new RegExp(`${lcg1.consonantIds.length + 1} of ${THAI_CONSONANTS.length} selected`, 'i'))
+ screen.getByRole('button', { name: /consonant presets/i }),
+ ).toHaveTextContent('Custom')
+ expect(
+ screen.getByText(
+ new RegExp(
+ `${lcg1.consonantIds.length + 1} of ${THAI_CONSONANTS.length} selected`,
+ 'i',
+ ),
+ ),
).toBeInTheDocument()
})
@@ -193,18 +239,31 @@ describe('ContentSelection', () => {
render( )
await user.click(screen.getByRole('button', { name: /consonant presets/i }))
- await user.click(screen.getByRole('option', { name: /Low Class - Group 1/i }))
+ await user.click(
+ screen.getByRole('option', { name: /Low Class - Group 1/i }),
+ )
await user.click(screen.getAllByRole('button', { name: /^ก/ })[0])
await user.click(screen.getByRole('button', { name: /consonant presets/i }))
- const presetOption = screen.getByRole('option', { name: /Low Class - Group 1/i })
+ const presetOption = screen.getByRole('option', {
+ name: /Low Class - Group 1/i,
+ })
expect(presetOption).toHaveAttribute('aria-selected', 'true')
await user.click(presetOption)
- expect(screen.getByRole('button', { name: /consonant presets/i })).toHaveTextContent('Custom')
- expect(screen.getByText(new RegExp(`1 of ${THAI_CONSONANTS.length} selected`, 'i'))).toBeInTheDocument()
- expect(screen.getAllByRole('button', { name: /^ก/ })[0]).toHaveAttribute('aria-pressed', 'true')
+ expect(
+ screen.getByRole('button', { name: /consonant presets/i }),
+ ).toHaveTextContent('Custom')
+ expect(
+ screen.getByText(
+ new RegExp(`1 of ${THAI_CONSONANTS.length} selected`, 'i'),
+ ),
+ ).toBeInTheDocument()
+ expect(screen.getAllByRole('button', { name: /^ก/ })[0]).toHaveAttribute(
+ 'aria-pressed',
+ 'true',
+ )
})
it('renders Vowels section and summary shows vowels count', async () => {
@@ -212,12 +271,16 @@ describe('ContentSelection', () => {
render( )
expect(screen.getByRole('heading', { name: /vowels/i })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /select all.*vowel/i }))
- expect(screen.getByText(new RegExp(`${THAI_VOWELS.length} vowels?`, 'i'))).toBeInTheDocument()
+ expect(
+ screen.getByText(new RegExp(`${THAI_VOWELS.length} vowels?`, 'i')),
+ ).toBeInTheDocument()
})
it('renders a presets dropdown for vowels', () => {
render( )
- expect(screen.getByRole('button', { name: /vowel presets/i })).toHaveTextContent('Presets')
+ expect(
+ screen.getByRole('button', { name: /vowel presets/i }),
+ ).toHaveTextContent('Presets')
})
it('shows all vowel preset options with their full labels when opened', async () => {
@@ -226,18 +289,24 @@ describe('ContentSelection', () => {
await user.click(screen.getByRole('button', { name: /vowel presets/i }))
- expect(screen.getByRole('listbox', { name: /vowel preset options/i })).toBeInTheDocument()
- expect(screen.getByRole('option', { name: /^Short Vowels/i })).toHaveAttribute(
- 'title',
- 'Short-duration vowel forms'
- )
- expect(screen.getByRole('option', { name: /^Long Vowels/i })).toHaveAttribute(
- 'title',
- 'Long-duration vowel forms'
- )
- expect(screen.queryByRole('option', { name: /^Monophthongs/i })).not.toBeInTheDocument()
- expect(screen.queryByRole('option', { name: /^Diphthongs/i })).not.toBeInTheDocument()
- expect(screen.queryByRole('option', { name: /^Form-Changing/i })).not.toBeInTheDocument()
+ expect(
+ screen.getByRole('listbox', { name: /vowel preset options/i }),
+ ).toBeInTheDocument()
+ expect(
+ screen.getByRole('option', { name: /^Short Vowels/i }),
+ ).toHaveAttribute('title', 'Short-duration vowel forms')
+ expect(
+ screen.getByRole('option', { name: /^Long Vowels/i }),
+ ).toHaveAttribute('title', 'Long-duration vowel forms')
+ expect(
+ screen.queryByRole('option', { name: /^Monophthongs/i }),
+ ).not.toBeInTheDocument()
+ expect(
+ screen.queryByRole('option', { name: /^Diphthongs/i }),
+ ).not.toBeInTheDocument()
+ expect(
+ screen.queryByRole('option', { name: /^Form-Changing/i }),
+ ).not.toBeInTheDocument()
})
it('closes the vowel presets menu when pressing Escape', async () => {
@@ -245,11 +314,15 @@ describe('ContentSelection', () => {
render( )
await user.click(screen.getByRole('button', { name: /vowel presets/i }))
- expect(screen.getByRole('listbox', { name: /vowel preset options/i })).toBeInTheDocument()
+ expect(
+ screen.getByRole('listbox', { name: /vowel preset options/i }),
+ ).toBeInTheDocument()
await user.keyboard('{Escape}')
- expect(screen.queryByRole('listbox', { name: /vowel preset options/i })).not.toBeInTheDocument()
+ expect(
+ screen.queryByRole('listbox', { name: /vowel preset options/i }),
+ ).not.toBeInTheDocument()
})
it('selecting a vowel preset marks the expected vowels selected', async () => {
@@ -262,16 +335,22 @@ describe('ContentSelection', () => {
await user.click(screen.getByRole('button', { name: /vowel presets/i }))
await user.click(screen.getByRole('option', { name: /^Short Vowels/i }))
- expect(screen.getByRole('button', { name: /vowel presets/i })).toHaveTextContent('Short Vowels')
expect(
- screen.getByText(new RegExp(`${short.vowelIds.length} of ${THAI_VOWELS.length} selected`, 'i'))
+ screen.getByRole('button', { name: /vowel presets/i }),
+ ).toHaveTextContent('Short Vowels')
+ expect(
+ screen.getByText(
+ new RegExp(
+ `${short.vowelIds.length} of ${THAI_VOWELS.length} selected`,
+ 'i',
+ ),
+ ),
).toBeInTheDocument()
short.vowelIds.forEach((id) => {
- expect(screen.getByRole('button', { name: formatVowelWithPlaceholder(id) })).toHaveAttribute(
- 'aria-pressed',
- 'true'
- )
+ expect(
+ screen.getByRole('button', { name: formatVowelWithPlaceholder(id) }),
+ ).toHaveAttribute('aria-pressed', 'true')
})
})
@@ -288,11 +367,16 @@ describe('ContentSelection', () => {
await user.click(screen.getByRole('button', { name: /vowel presets/i }))
await user.click(screen.getByRole('option', { name: /^Long Vowels/i }))
- expect(screen.getByRole('button', { name: /vowel presets/i })).toHaveTextContent('Custom')
+ expect(
+ screen.getByRole('button', { name: /vowel presets/i }),
+ ).toHaveTextContent('Custom')
expect(
screen.getByText(
- new RegExp(`${new Set([...short.vowelIds, ...long.vowelIds]).size} of ${THAI_VOWELS.length} selected`, 'i')
- )
+ new RegExp(
+ `${new Set([...short.vowelIds, ...long.vowelIds]).size} of ${THAI_VOWELS.length} selected`,
+ 'i',
+ ),
+ ),
).toBeInTheDocument()
})
@@ -308,14 +392,17 @@ describe('ContentSelection', () => {
await user.click(screen.getByRole('button', { name: /vowel presets/i }))
await user.click(screen.getByRole('option', { name: /^Short Vowels/i }))
- expect(screen.getByRole('button', { name: /vowel presets/i })).toHaveTextContent('Presets')
- expect(screen.getByText(new RegExp(`0 of ${THAI_VOWELS.length} selected`, 'i'))).toBeInTheDocument()
+ expect(
+ screen.getByRole('button', { name: /vowel presets/i }),
+ ).toHaveTextContent('Presets')
+ expect(
+ screen.getByText(new RegExp(`0 of ${THAI_VOWELS.length} selected`, 'i')),
+ ).toBeInTheDocument()
short.vowelIds.forEach((id) => {
- expect(screen.getByRole('button', { name: formatVowelWithPlaceholder(id) })).toHaveAttribute(
- 'aria-pressed',
- 'false'
- )
+ expect(
+ screen.getByRole('button', { name: formatVowelWithPlaceholder(id) }),
+ ).toHaveAttribute('aria-pressed', 'false')
})
})
@@ -328,7 +415,11 @@ describe('ContentSelection', () => {
await user.click(screen.getByRole('button', { name: /vowel presets/i }))
await user.click(screen.getByRole('option', { name: /^Short Vowels/i }))
- await user.click(screen.getByRole('button', { name: new RegExp(`^${formatVowelWithPlaceholder('า')}$`) }))
+ await user.click(
+ screen.getByRole('button', {
+ name: new RegExp(`^${formatVowelWithPlaceholder('า')}$`),
+ }),
+ )
await user.click(screen.getByRole('button', { name: /vowel presets/i }))
const presetOption = screen.getByRole('option', { name: /^Short Vowels/i })
@@ -336,18 +427,27 @@ describe('ContentSelection', () => {
await user.click(presetOption)
- expect(screen.getByRole('button', { name: /vowel presets/i })).toHaveTextContent('Custom')
- expect(screen.getByText(new RegExp(`1 of ${THAI_VOWELS.length} selected`, 'i'))).toBeInTheDocument()
- expect(screen.getByRole('button', { name: new RegExp(`^${formatVowelWithPlaceholder('า')}$`) })).toHaveAttribute(
- 'aria-pressed',
- 'true'
- )
+ expect(
+ screen.getByRole('button', { name: /vowel presets/i }),
+ ).toHaveTextContent('Custom')
+ expect(
+ screen.getByText(new RegExp(`1 of ${THAI_VOWELS.length} selected`, 'i')),
+ ).toBeInTheDocument()
+ expect(
+ screen.getByRole('button', {
+ name: new RegExp(`^${formatVowelWithPlaceholder('า')}$`),
+ }),
+ ).toHaveAttribute('aria-pressed', 'true')
})
it('shows vowels with a placeholder dash', () => {
render( )
- expect(screen.getByRole('button', { name: formatVowelWithPlaceholder('ะ') })).toBeInTheDocument()
- expect(screen.getByRole('button', { name: formatVowelWithPlaceholder('เ') })).toBeInTheDocument()
+ expect(
+ screen.getByRole('button', { name: formatVowelWithPlaceholder('ะ') }),
+ ).toBeInTheDocument()
+ expect(
+ screen.getByRole('button', { name: formatVowelWithPlaceholder('เ') }),
+ ).toBeInTheDocument()
})
it('marks visible Thai consonant glyphs and names as non-translatable', () => {
@@ -355,10 +455,10 @@ describe('ContentSelection', () => {
const firstConsonant = THAI_CONSONANTS[0]
const glyph = Array.from(container.querySelectorAll('span')).find(
- (node) => node.textContent === firstConsonant.char
+ (node) => node.textContent === firstConsonant.char,
)
const name = Array.from(container.querySelectorAll('span')).find(
- (node) => node.textContent === firstConsonant.name
+ (node) => node.textContent === firstConsonant.name,
)
expect(glyph).toHaveAttribute('translate', 'no')
diff --git a/src/components/ContentSelection.tsx b/src/components/ContentSelection.tsx
index aafff3b..e2a9e5b 100644
--- a/src/components/ContentSelection.tsx
+++ b/src/components/ContentSelection.tsx
@@ -35,22 +35,29 @@ export interface ContentSelectionProps {
export function ContentSelection(props: ContentSelectionProps = {}) {
const hook = useContentSelection()
- const selectedConsonantIds = props.selectedConsonantIds ?? hook.selectedConsonantIds
+ const selectedConsonantIds =
+ props.selectedConsonantIds ?? hook.selectedConsonantIds
const selectedVowelIds = props.selectedVowelIds ?? hook.selectedVowelIds
const toggleConsonant = props.onToggleConsonant ?? hook.toggleConsonant
const toggleVowel = props.onToggleVowel ?? hook.toggleVowel
- const applyConsonantPreset = props.onApplyConsonantPreset ?? hook.applyConsonantPreset
+ const applyConsonantPreset =
+ props.onApplyConsonantPreset ?? hook.applyConsonantPreset
const applyVowelPreset = props.onApplyVowelPreset ?? hook.applyVowelPreset
- const selectAllConsonants = props.onSelectAllConsonants ?? hook.selectAllConsonants
+ const selectAllConsonants =
+ props.onSelectAllConsonants ?? hook.selectAllConsonants
const clearConsonants = props.onClearConsonants ?? hook.clearConsonants
const selectAllVowels = props.onSelectAllVowels ?? hook.selectAllVowels
const clearVowels = props.onClearVowels ?? hook.clearVowels
- const fontStyle = props.fontFamily ? { fontFamily: props.fontFamily } : undefined
+ const fontStyle = props.fontFamily
+ ? { fontFamily: props.fontFamily }
+ : undefined
const consonantSet = new Set(selectedConsonantIds)
const vowelSet = new Set(selectedVowelIds)
- const consonantPresetTriggerLabel = getConsonantPresetTriggerLabel(selectedConsonantIds)
- const exactConsonantPreset = getConsonantExactMatchPreset(selectedConsonantIds)
+ const consonantPresetTriggerLabel =
+ getConsonantPresetTriggerLabel(selectedConsonantIds)
+ const exactConsonantPreset =
+ getConsonantExactMatchPreset(selectedConsonantIds)
const vowelPresetTriggerLabel = getVowelPresetTriggerLabel(selectedVowelIds)
const consonantTriggerClasses = exactConsonantPreset
@@ -60,7 +67,8 @@ export function ContentSelection(props: ContentSelectionProps = {}) {
return (
- {selectedConsonantIds.length} consonants, {selectedVowelIds.length} vowels selected
+ {selectedConsonantIds.length} consonants, {selectedVowelIds.length}{' '}
+ vowels selected
preset.id}
- isItemSelected={(preset) => preset.consonantIds.every((id) => consonantSet.has(id))}
+ isItemSelected={(preset) =>
+ preset.consonantIds.every((id) => consonantSet.has(id))
+ }
getItemTitle={(preset) => preset.fullLabel}
getOptionClassName={(preset, isSelected) => {
- const colorClasses = CONSONANT_GROUP_COLOR_CLASSES[preset.colorKey]
+ const colorClasses =
+ CONSONANT_GROUP_COLOR_CLASSES[preset.colorKey]
return `flex w-full items-start justify-between rounded-lg px-3 py-2 text-left transition-colors ${
isSelected ? colorClasses.rowActive : colorClasses.rowIdle
}`
}}
renderOptionContent={(preset, isSelected) => {
- const colorClasses = CONSONANT_GROUP_COLOR_CLASSES[preset.colorKey]
+ const colorClasses =
+ CONSONANT_GROUP_COLOR_CLASSES[preset.colorKey]
return (
<>
- {preset.shortLabel}
- {preset.fullLabel}
+
+ {preset.shortLabel}
+
+
+ {preset.fullLabel}
+
-
+
{c.char}
{c.name && (
@@ -168,22 +190,34 @@ export function ContentSelection(props: ContentSelectionProps = {}) {
triggerAriaLabel="Vowel presets"
listboxAriaLabel="Vowel preset options"
triggerLabel={vowelPresetTriggerLabel}
- triggerTitle={vowelPresetTriggerLabel === 'Custom' ? 'Custom selection' : vowelPresetTriggerLabel}
+ triggerTitle={
+ vowelPresetTriggerLabel === 'Custom'
+ ? 'Custom selection'
+ : vowelPresetTriggerLabel
+ }
triggerClassName="inline-flex min-w-32 items-center justify-between gap-3 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50"
items={THAI_VOWEL_PRESETS}
getItemKey={(preset) => preset.id}
- isItemSelected={(preset) => preset.vowelIds.every((id) => vowelSet.has(id))}
+ isItemSelected={(preset) =>
+ preset.vowelIds.every((id) => vowelSet.has(id))
+ }
getItemTitle={(preset) => preset.fullLabel}
getOptionClassName={(_, isSelected) =>
`flex w-full items-start justify-between rounded-lg px-3 py-2 text-left transition-colors ${
- isSelected ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-50'
+ isSelected
+ ? 'bg-indigo-50 text-indigo-700'
+ : 'text-gray-700 hover:bg-gray-50'
}`
}
renderOptionContent={(preset, isSelected) => (
<>
- {preset.shortLabel}
- {preset.fullLabel}
+
+ {preset.shortLabel}
+
+
+ {preset.fullLabel}
+
{
it('renders Download PDF button', () => {
render( )
- expect(screen.getByRole('button', { name: /download pdf/i })).toBeInTheDocument()
+ expect(
+ screen.getByRole('button', { name: /download pdf/i }),
+ ).toBeInTheDocument()
})
it('calls onDownloadPdf when Download PDF is clicked', async () => {
@@ -29,10 +31,14 @@ describe('OutputActions', () => {
isDownloading
downloadLabel="Preparing PDF..."
statusMessage="Preparing your PDF..."
- />
+ />,
)
- expect(screen.getByRole('button', { name: /preparing pdf/i })).toBeDisabled()
- expect(screen.getByRole('status')).toHaveTextContent('Preparing your PDF...')
+ expect(
+ screen.getByRole('button', { name: /preparing pdf/i }),
+ ).toBeDisabled()
+ expect(screen.getByRole('status')).toHaveTextContent(
+ 'Preparing your PDF...',
+ )
})
})
diff --git a/src/components/OutputActions.tsx b/src/components/OutputActions.tsx
index b97381a..e6aca2e 100644
--- a/src/components/OutputActions.tsx
+++ b/src/components/OutputActions.tsx
@@ -12,7 +12,10 @@ export function OutputActions({
statusMessage,
}: OutputActionsProps) {
return (
-
) : (
-
+
{
selectedConsonantIds={[]}
selectedVowelIds={[]}
config={DEFAULT_SHEET_CONFIG}
- />
+ />,
)
- expect(screen.getByText(/select consonants or vowels to see preview/i)).toBeInTheDocument()
+ expect(
+ screen.getByText(/select consonants or vowels to see preview/i),
+ ).toBeInTheDocument()
})
it('renders a preview region', () => {
@@ -24,7 +26,7 @@ describe('Preview', () => {
selectedConsonantIds={[]}
selectedVowelIds={[]}
config={DEFAULT_SHEET_CONFIG}
- />
+ />,
)
const region = screen.getByRole('region', { name: /preview/i })
expect(region).toBeInTheDocument()
@@ -37,7 +39,7 @@ describe('Preview', () => {
selectedConsonantIds={ids}
selectedVowelIds={[]}
config={DEFAULT_SHEET_CONFIG}
- />
+ />,
)
const region = screen.getByRole('region', { name: /preview/i })
expect(region).toHaveTextContent(THAI_CONSONANTS[0].char)
@@ -50,11 +52,15 @@ describe('Preview', () => {
selectedConsonantIds={[]}
selectedVowelIds={[THAI_VOWELS[0].id, THAI_VOWELS[10].id]}
config={DEFAULT_SHEET_CONFIG}
- />
+ />,
)
const region = screen.getByRole('region', { name: /preview/i })
- expect(region).toHaveTextContent(formatVowelWithPlaceholder(THAI_VOWELS[0].char))
- expect(region).toHaveTextContent(formatVowelWithPlaceholder(THAI_VOWELS[10].char))
+ expect(region).toHaveTextContent(
+ formatVowelWithPlaceholder(THAI_VOWELS[0].char),
+ )
+ expect(region).toHaveTextContent(
+ formatVowelWithPlaceholder(THAI_VOWELS[10].char),
+ )
})
it('updates when selectedConsonantIds changes', () => {
@@ -63,7 +69,7 @@ describe('Preview', () => {
selectedConsonantIds={[THAI_CONSONANTS[0].id]}
selectedVowelIds={[]}
config={DEFAULT_SHEET_CONFIG}
- />
+ />,
)
let region = screen.getByRole('region', { name: /preview/i })
expect(region).toHaveTextContent(THAI_CONSONANTS[0].char)
@@ -73,11 +79,15 @@ describe('Preview', () => {
selectedConsonantIds={[THAI_CONSONANTS[1].id]}
selectedVowelIds={[]}
config={DEFAULT_SHEET_CONFIG}
- />
+ />,
)
region = screen.getByRole('region', { name: /preview/i })
- expect(region).not.toHaveTextContent(`${THAI_CONSONANTS[0].char}อ ${THAI_CONSONANTS[0].name}`)
- expect(region).toHaveTextContent(`${THAI_CONSONANTS[1].char}อ ${THAI_CONSONANTS[1].name}`)
+ expect(region).not.toHaveTextContent(
+ `${THAI_CONSONANTS[0].char}อ ${THAI_CONSONANTS[0].name}`,
+ )
+ expect(region).toHaveTextContent(
+ `${THAI_CONSONANTS[1].char}อ ${THAI_CONSONANTS[1].name}`,
+ )
})
it('does not show paper size metadata', () => {
@@ -86,9 +96,11 @@ describe('Preview', () => {
selectedConsonantIds={[THAI_CONSONANTS[0].id]}
selectedVowelIds={[]}
config={DEFAULT_SHEET_CONFIG}
- />
+ />,
)
- expect(screen.getByRole('region', { name: /preview/i })).not.toHaveTextContent('A4')
+ expect(
+ screen.getByRole('region', { name: /preview/i }),
+ ).not.toHaveTextContent('A4')
})
it('shows the semantic font label in preview metadata', () => {
@@ -98,10 +110,12 @@ describe('Preview', () => {
selectedConsonantIds={[THAI_CONSONANTS[0].id]}
selectedVowelIds={[]}
config={config}
- />
+ />,
)
- expect(screen.getByRole('region', { name: /preview/i })).toHaveTextContent('Modern')
+ expect(screen.getByRole('region', { name: /preview/i })).toHaveTextContent(
+ 'Modern',
+ )
})
it('marks Thai preview text as non-translatable', () => {
@@ -110,20 +124,22 @@ describe('Preview', () => {
selectedConsonantIds={[THAI_CONSONANTS[0].id]}
selectedVowelIds={[THAI_VOWELS[0].id]}
config={DEFAULT_SHEET_CONFIG}
- />
+ />,
)
const worksheetTitle = Array.from(container.querySelectorAll('h3')).find(
- (node) => node.textContent === 'Thai Script Pro'
+ (node) => node.textContent === 'Thai Script Pro',
)
const consonantGlyph = Array.from(container.querySelectorAll('span')).find(
- (node) => node.textContent === THAI_CONSONANTS[0].char
+ (node) => node.textContent === THAI_CONSONANTS[0].char,
)
const consonantName = Array.from(container.querySelectorAll('span')).find(
- (node) => node.textContent === `${THAI_CONSONANTS[0].char}อ ${THAI_CONSONANTS[0].name}`
+ (node) =>
+ node.textContent ===
+ `${THAI_CONSONANTS[0].char}อ ${THAI_CONSONANTS[0].name}`,
)
const vowelGlyphWrapper = container.querySelector(
- '[aria-hidden="true"][translate="no"][lang="th"]'
+ '[aria-hidden="true"][translate="no"][lang="th"]',
)
expect(worksheetTitle).toHaveAttribute('translate', 'no')
@@ -140,10 +156,12 @@ describe('Preview', () => {
selectedConsonantIds={[THAI_CONSONANTS[0].id]}
selectedVowelIds={[]}
config={{ ...DEFAULT_SHEET_CONFIG, fontSize: 'small', columns: 12 }}
- />
+ />,
)
- expect(screen.queryByText(/scroll sideways to view all columns/i)).not.toBeInTheDocument()
+ expect(
+ screen.queryByText(/scroll sideways to view all columns/i),
+ ).not.toBeInTheDocument()
})
it('summarizes mixed selections in the preview metadata', () => {
@@ -152,7 +170,7 @@ describe('Preview', () => {
selectedConsonantIds={[THAI_CONSONANTS[0].id]}
selectedVowelIds={[THAI_VOWELS[0].id]}
config={DEFAULT_SHEET_CONFIG}
- />
+ />,
)
const region = screen.getByRole('region', { name: /preview/i })
diff --git a/src/components/Preview.tsx b/src/components/Preview.tsx
index 984734c..acb7404 100644
--- a/src/components/Preview.tsx
+++ b/src/components/Preview.tsx
@@ -1,7 +1,11 @@
import type { SheetConfig } from '../data/sheetOptions'
import { THAI_CONSONANTS } from '../data/consonants'
import { THAI_VOWELS } from '../data/vowels'
-import { FONT_OPTIONS, FONT_FAMILY_MAP, FONT_SIZE_MAP } from '../data/sheetOptions'
+import {
+ FONT_OPTIONS,
+ FONT_FAMILY_MAP,
+ FONT_SIZE_MAP,
+} from '../data/sheetOptions'
import {
buildWorksheetSubtitle,
EMPTY_WORKSHEET_MESSAGE,
@@ -36,7 +40,10 @@ function PracticeGrid({
config: SheetConfig
}) {
const sz = FONT_SIZE_MAP[config.fontSize] || FONT_SIZE_MAP.medium
- const firstRowGhostCopies = Math.min(config.ghostCopiesPerRow, Math.max(config.columns - 1, 0))
+ const firstRowGhostCopies = Math.min(
+ config.ghostCopiesPerRow,
+ Math.max(config.columns - 1, 0),
+ )
const laterRowGhostCopies = Math.min(firstRowGhostCopies + 1, config.columns)
const cellStyle: React.CSSProperties = {
@@ -69,7 +76,9 @@ function PracticeGrid({
{Array.from({ length: config.rowsPerCharacter }).map((_, row) => {
const isFirstRow = row === 0
- const ghostCopies = isFirstRow ? firstRowGhostCopies : laterRowGhostCopies
+ const ghostCopies = isFirstRow
+ ? firstRowGhostCopies
+ : laterRowGhostCopies
return (
{
const isModel = isFirstRow && col === 0
const ghostIdx = isFirstRow ? col - 1 : col
- const isGhost = isFirstRow ? col > 0 && col <= ghostCopies : col < ghostCopies
+ const isGhost = isFirstRow
+ ? col > 0 && col <= ghostCopies
+ : col < ghostCopies
return (
@@ -143,12 +154,19 @@ function PracticeGrid({
)
}
-export function Preview({ selectedConsonantIds, selectedVowelIds, config }: PreviewProps) {
- const consonants = THAI_CONSONANTS.filter((c) => selectedConsonantIds.includes(c.id))
+export function Preview({
+ selectedConsonantIds,
+ selectedVowelIds,
+ config,
+}: PreviewProps) {
+ const consonants = THAI_CONSONANTS.filter((c) =>
+ selectedConsonantIds.includes(c.id),
+ )
const vowels = THAI_VOWELS.filter((v) => selectedVowelIds.includes(v.id))
const fontFamily = FONT_FAMILY_MAP[config.font] || '"Sarabun", sans-serif'
- const fontLabel = FONT_OPTIONS.find((f) => f.id === config.font)?.label || 'Traditional'
+ const fontLabel =
+ FONT_OPTIONS.find((f) => f.id === config.font)?.label || 'Traditional'
const totalChars = consonants.length + vowels.length
const subtitle = buildWorksheetSubtitle({
consonantCount: consonants.length,
@@ -157,12 +175,13 @@ export function Preview({ selectedConsonantIds, selectedVowelIds, config }: Prev
})
return (
-
+
{totalChars === 0 ? (
-
-
- {EMPTY_WORKSHEET_MESSAGE}
-
+
+
{EMPTY_WORKSHEET_MESSAGE}
) : (
{WORKSHEET_TITLE}
-
- {subtitle}
-
+
{subtitle}
-
-
+
+
{consonants.map((c) => (
-
+
{c.char}
{
})
it('renders rows control', () => {
- render( )
+ render(
+ ,
+ )
expect(screen.getByLabelText(/^rows$/i)).toBeInTheDocument()
})
@@ -34,34 +36,40 @@ describe('SheetOptions', () => {
const input = screen.getByLabelText(/^rows$/i)
fireEvent.change(input, { target: { value: '4' } })
expect(mockOnChange).toHaveBeenLastCalledWith(
- expect.objectContaining({ rowsPerCharacter: 4 })
+ expect.objectContaining({ rowsPerCharacter: 4 }),
)
})
it('renders columns control with default selected', () => {
- render( )
+ render(
+ ,
+ )
const columnsSelect = screen.getByLabelText(/^columns$/i)
expect(columnsSelect).toBeInTheDocument()
expect(columnsSelect).toHaveValue('3')
})
it('renders ghost copies control', () => {
- render( )
+ render(
+ ,
+ )
expect(screen.getByLabelText(/^ghost copies$/i)).toBeInTheDocument()
})
it('orders the first three controls as rows, columns, and ghost copies', () => {
- render( )
+ render(
+ ,
+ )
const selects = screen.getAllByRole('combobox')
- expect(selects.slice(0, 3).map((select) => select.getAttribute('id'))).toEqual([
- 'rows-per-char',
- 'columns',
- 'ghost-copies',
- ])
+ expect(
+ selects.slice(0, 3).map((select) => select.getAttribute('id')),
+ ).toEqual(['rows-per-char', 'columns', 'ghost-copies'])
})
it('does not render a paper size control', () => {
- render( )
+ render(
+ ,
+ )
expect(screen.queryByLabelText(/paper size/i)).not.toBeInTheDocument()
})
@@ -70,7 +78,7 @@ describe('SheetOptions', () => {
+ />,
)
expect(screen.getByRole('option', { name: '5 copies' })).not.toBeDisabled()
expect(screen.getByRole('option', { name: '6 copies' })).toBeDisabled()
@@ -96,18 +104,28 @@ describe('SheetOptions', () => {
}
render( )
- fireEvent.change(screen.getByLabelText(/^columns$/i), { target: { value: '5' } })
+ fireEvent.change(screen.getByLabelText(/^columns$/i), {
+ target: { value: '5' },
+ })
expect(mockOnChange).toHaveBeenLastCalledWith(
- expect.objectContaining({ columns: 5, ghostCopiesPerRow: 5 })
+ expect.objectContaining({ columns: 5, ghostCopiesPerRow: 5 }),
)
})
it('omits 10 columns for the medium font size', () => {
- render( )
+ render(
+ ,
+ )
- expect(screen.getByRole('option', { name: '3 columns' })).toBeInTheDocument()
- expect(screen.getByRole('option', { name: '9 columns' })).toBeInTheDocument()
- expect(screen.queryByRole('option', { name: '10 columns' })).not.toBeInTheDocument()
+ expect(
+ screen.getByRole('option', { name: '3 columns' }),
+ ).toBeInTheDocument()
+ expect(
+ screen.getByRole('option', { name: '9 columns' }),
+ ).toBeInTheDocument()
+ expect(
+ screen.queryByRole('option', { name: '10 columns' }),
+ ).not.toBeInTheDocument()
})
it('limits large font size to 3 through 7 columns', () => {
@@ -115,15 +133,27 @@ describe('SheetOptions', () => {
+ />,
)
- expect(screen.getByRole('option', { name: '3 columns' })).toBeInTheDocument()
- expect(screen.getByRole('option', { name: '4 columns' })).toBeInTheDocument()
- expect(screen.getByRole('option', { name: '5 columns' })).toBeInTheDocument()
- expect(screen.getByRole('option', { name: '6 columns' })).toBeInTheDocument()
- expect(screen.getByRole('option', { name: '7 columns' })).toBeInTheDocument()
- expect(screen.queryByRole('option', { name: '8 columns' })).not.toBeInTheDocument()
+ expect(
+ screen.getByRole('option', { name: '3 columns' }),
+ ).toBeInTheDocument()
+ expect(
+ screen.getByRole('option', { name: '4 columns' }),
+ ).toBeInTheDocument()
+ expect(
+ screen.getByRole('option', { name: '5 columns' }),
+ ).toBeInTheDocument()
+ expect(
+ screen.getByRole('option', { name: '6 columns' }),
+ ).toBeInTheDocument()
+ expect(
+ screen.getByRole('option', { name: '7 columns' }),
+ ).toBeInTheDocument()
+ expect(
+ screen.queryByRole('option', { name: '8 columns' }),
+ ).not.toBeInTheDocument()
})
it('exposes columns up to 12 for the small font size', () => {
@@ -131,10 +161,12 @@ describe('SheetOptions', () => {
+ />,
)
- expect(screen.getByRole('option', { name: '12 columns' })).toBeInTheDocument()
+ expect(
+ screen.getByRole('option', { name: '12 columns' }),
+ ).toBeInTheDocument()
})
it('falls back to the largest allowed column value if the current config is out of range', () => {
@@ -142,31 +174,39 @@ describe('SheetOptions', () => {
+ />,
)
expect(screen.getByLabelText(/^columns$/i)).toHaveValue('7')
})
it('renders grid guide dropdown with 3 options', () => {
- render( )
+ render(
+ ,
+ )
const gridSelect = screen.getByLabelText(/grid guide/i)
expect(gridSelect).toBeInTheDocument()
const options = screen.getAllByRole('option')
- const gridOptions = options.filter((o) => o.getAttribute('value')?.match(/cross|sandwich|thai/))
+ const gridOptions = options.filter((o) =>
+ o.getAttribute('value')?.match(/cross|sandwich|thai/),
+ )
expect(gridOptions.length).toBeGreaterThanOrEqual(3)
expect(screen.getByRole('option', { name: 'Thai' })).toBeInTheDocument()
})
it('renders font dropdown with default selected', () => {
- render( )
+ render(
+ ,
+ )
const fontSelect = screen.getByRole('combobox', { name: /^font$/i })
expect(fontSelect).toBeInTheDocument()
expect(fontSelect).toHaveValue('traditional')
})
it('renders font size dropdown', () => {
- render( )
+ render(
+ ,
+ )
expect(screen.getByLabelText(/font size/i)).toBeInTheDocument()
expect(screen.getByRole('option', { name: 'Small' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: 'Medium' })).toBeInTheDocument()
diff --git a/src/components/SheetOptions.tsx b/src/components/SheetOptions.tsx
index d6cdd90..12675c7 100644
--- a/src/components/SheetOptions.tsx
+++ b/src/components/SheetOptions.tsx
@@ -21,8 +21,12 @@ const labelClasses =
export function SheetOptions({ config, onChange }: SheetOptionsProps) {
const allowedColumnOptions = getAllowedColumnOptions(config.fontSize)
- const fallbackColumns = allowedColumnOptions[allowedColumnOptions.length - 1]?.value ?? config.columns
- const selectedColumns = allowedColumnOptions.some((option) => option.value === config.columns)
+ const fallbackColumns =
+ allowedColumnOptions[allowedColumnOptions.length - 1]?.value ??
+ config.columns
+ const selectedColumns = allowedColumnOptions.some(
+ (option) => option.value === config.columns,
+ )
? config.columns
: fallbackColumns
@@ -39,7 +43,10 @@ export function SheetOptions({ config, onChange }: SheetOptionsProps) {
}
return (
-
+
Sheet Options
@@ -50,7 +57,9 @@ export function SheetOptions({ config, onChange }: SheetOptionsProps) {
update({ rowsPerCharacter: Number(e.target.value) || 1 })}
+ onChange={(e) =>
+ update({ rowsPerCharacter: Number(e.target.value) || 1 })
+ }
className={selectClasses}
>
{ROWS_PER_CHARACTER_OPTIONS.map((o) => (
@@ -86,11 +95,17 @@ export function SheetOptions({ config, onChange }: SheetOptionsProps) {
update({ ghostCopiesPerRow: Number(e.target.value) || 1 })}
+ onChange={(e) =>
+ update({ ghostCopiesPerRow: Number(e.target.value) || 1 })
+ }
className={selectClasses}
>
{GHOST_COPIES_OPTIONS.map((o) => (
- config.columns}>
+ config.columns}
+ >
{o.label}
))}
diff --git a/src/components/VowelDisplay.test.tsx b/src/components/VowelDisplay.test.tsx
index 0a1e6dc..3e3837a 100644
--- a/src/components/VowelDisplay.test.tsx
+++ b/src/components/VowelDisplay.test.tsx
@@ -7,7 +7,7 @@ const COMBINING_MARKS = new Set(['ั', 'ิ', 'ี', 'ึ', 'ื', '็', 'ํ'
function getLeafSpans(container: HTMLElement) {
const ariaHidden = container.querySelector('[aria-hidden="true"]')!
return Array.from(ariaHidden.querySelectorAll('span')).filter(
- (s) => s.children.length === 0
+ (s) => s.children.length === 0,
)
}
@@ -17,7 +17,11 @@ describe('VowelDisplay', () => {
it('renders placeholder อ with placeholderClassName for a simple suffix vowel (ะ)', () => {
const { container } = render(
-
+ ,
)
const spans = getLeafSpans(container)
const placeholder = spans.find((s) => s.textContent === 'อ')
@@ -28,7 +32,11 @@ describe('VowelDisplay', () => {
it('renders composite glyph with upper mark in glyphClassName (ิ)', () => {
const { container } = render(
-
+ ,
)
const spans = getLeafSpans(container)
const glyph = spans.find((s) => s.textContent === 'อิ')
@@ -38,7 +46,11 @@ describe('VowelDisplay', () => {
it('renders composite glyph with lower mark in glyphClassName (ุ)', () => {
const { container } = render(
-
+ ,
)
const spans = getLeafSpans(container)
const glyph = spans.find((s) => s.textContent === 'อุ')
@@ -48,7 +60,11 @@ describe('VowelDisplay', () => {
it('renders prefix with glyphClassName (เ)', () => {
const { container } = render(
-
+ ,
)
const spans = getLeafSpans(container)
const prefix = spans.find((s) => s.textContent === 'เ')
@@ -58,7 +74,11 @@ describe('VowelDisplay', () => {
it('renders suffix with glyphClassName (า in ำ)', () => {
const { container } = render(
-
+ ,
)
const spans = getLeafSpans(container)
const suffix = spans.find((s) => s.textContent === 'า')
@@ -70,13 +90,20 @@ describe('VowelDisplay', () => {
const vowelsWithMarks = ['ั', 'ิ', 'ี', 'ึ', 'ื', 'ุ', 'ู']
for (const char of vowelsWithMarks) {
const { container } = render(
-
+ ,
)
const spans = getLeafSpans(container)
const placeholder = spans.find(
- (s) => s.textContent === 'อ' && s.className.includes(PLACEHOLDER)
+ (s) => s.textContent === 'อ' && s.className.includes(PLACEHOLDER),
)
- expect(placeholder, `placeholder อ overlay missing for vowel ${char}`).toBeDefined()
+ expect(
+ placeholder,
+ `placeholder อ overlay missing for vowel ${char}`,
+ ).toBeDefined()
expect(placeholder!.className).not.toContain(GLYPH)
}
})
@@ -85,14 +112,18 @@ describe('VowelDisplay', () => {
const vowelsWithMarks = ['ั', 'ิ', 'ี', 'ึ', 'ื', 'ุ', 'ู', 'ำ']
for (const char of vowelsWithMarks) {
const { container } = render(
-
+ ,
)
for (const span of getLeafSpans(container)) {
const text = span.textContent || ''
const firstChar = Array.from(text)[0]
expect(
COMBINING_MARKS.has(firstChar),
- `Span text "${text}" for vowel ${char} starts with a combining mark — would render a dotted circle`
+ `Span text "${text}" for vowel ${char} starts with a combining mark — would render a dotted circle`,
).toBe(false)
}
}
diff --git a/src/components/VowelDisplay.tsx b/src/components/VowelDisplay.tsx
index 530959a..5d112f3 100644
--- a/src/components/VowelDisplay.tsx
+++ b/src/components/VowelDisplay.tsx
@@ -1,4 +1,7 @@
-import { formatVowelWithPlaceholder, splitVowelForDisplay } from '../data/vowels'
+import {
+ formatVowelWithPlaceholder,
+ splitVowelForDisplay,
+} from '../data/vowels'
interface VowelDisplayProps {
char: string
@@ -20,7 +23,9 @@ export function VowelDisplay({
const hasMarks = upper || lower
return (
-
+
{!ariaHidden && {label} }
{`อ${upper}${lower}`}
- {'อ'}
+
+ {'อ'}
+
) : (
อ
diff --git a/src/data/consonants.test.ts b/src/data/consonants.test.ts
index b853738..f379159 100644
--- a/src/data/consonants.test.ts
+++ b/src/data/consonants.test.ts
@@ -32,16 +32,25 @@ describe('THAI_CONSONANTS', () => {
})
it('defines the four consonant presets with valid memberships', () => {
- const consonantIds = new Set(THAI_CONSONANTS.map((consonant) => consonant.id))
+ const consonantIds = new Set(
+ THAI_CONSONANTS.map((consonant) => consonant.id),
+ )
- expect(THAI_CONSONANT_PRESETS.map((preset) => preset.id)).toEqual(['LCG1', 'LCG2', 'MC', 'HC'])
+ expect(THAI_CONSONANT_PRESETS.map((preset) => preset.id)).toEqual([
+ 'LCG1',
+ 'LCG2',
+ 'MC',
+ 'HC',
+ ])
for (const preset of THAI_CONSONANT_PRESETS) {
expect(preset.shortLabel.length).toBeGreaterThan(0)
expect(preset.fullLabel.length).toBeGreaterThan(0)
expect(preset.colorKey in CONSONANT_GROUP_COLOR_CLASSES).toBe(true)
expect(preset.consonantIds.length).toBeGreaterThan(0)
- preset.consonantIds.forEach((id) => expect(consonantIds.has(id)).toBe(true))
+ preset.consonantIds.forEach((id) =>
+ expect(consonantIds.has(id)).toBe(true),
+ )
}
})
@@ -49,7 +58,8 @@ describe('THAI_CONSONANTS', () => {
for (const consonant of THAI_CONSONANTS) {
const preset = getConsonantPresetByConsonantId(consonant.id)
expect(preset).toBeDefined()
- if (!preset) throw new Error(`Expected preset for consonant ${consonant.id}`)
+ if (!preset)
+ throw new Error(`Expected preset for consonant ${consonant.id}`)
expect(preset.colorKey in CONSONANT_GROUP_COLOR_CLASSES).toBe(true)
}
})
diff --git a/src/data/consonants.ts b/src/data/consonants.ts
index fc71341..cdd6904 100644
--- a/src/data/consonants.ts
+++ b/src/data/consonants.ts
@@ -73,7 +73,22 @@ export const THAI_CONSONANT_PRESETS: ThaiConsonantPreset[] = [
shortLabel: 'Low Class - Group 2',
fullLabel: 'Low Class Consonants - Paired',
colorKey: 'amber',
- consonantIds: ['ค', 'ฅ', 'ฆ', 'ช', 'ซ', 'ฌ', 'ฑ', 'ฒ', 'ท', 'ธ', 'พ', 'ฟ', 'ภ', 'ฮ'],
+ consonantIds: [
+ 'ค',
+ 'ฅ',
+ 'ฆ',
+ 'ช',
+ 'ซ',
+ 'ฌ',
+ 'ฑ',
+ 'ฒ',
+ 'ท',
+ 'ธ',
+ 'พ',
+ 'ฟ',
+ 'ภ',
+ 'ฮ',
+ ],
},
{
id: 'MC',
@@ -93,8 +108,10 @@ export const THAI_CONSONANT_PRESETS: ThaiConsonantPreset[] = [
export const CONSONANT_GROUP_COLOR_CLASSES = {
teal: {
- tileIdle: 'border-teal-200 bg-teal-50/70 text-teal-900 hover:bg-teal-100/80',
- tileActive: 'border-teal-300 bg-teal-100 ring-1 ring-teal-200 text-teal-950',
+ tileIdle:
+ 'border-teal-200 bg-teal-50/70 text-teal-900 hover:bg-teal-100/80',
+ tileActive:
+ 'border-teal-300 bg-teal-100 ring-1 ring-teal-200 text-teal-950',
tileMeta: 'text-teal-700',
rowIdle: 'border border-transparent hover:bg-teal-50/70 text-gray-700',
rowActive: 'border border-teal-200 bg-teal-50 text-teal-800',
@@ -103,28 +120,36 @@ export const CONSONANT_GROUP_COLOR_CLASSES = {
trigger: 'border-teal-200 bg-teal-50 text-teal-800 hover:bg-teal-100/80',
},
amber: {
- tileIdle: 'border-amber-200 bg-amber-50/70 text-amber-900 hover:bg-amber-100/80',
- tileActive: 'border-amber-300 bg-amber-100 ring-1 ring-amber-200 text-amber-950',
+ tileIdle:
+ 'border-amber-200 bg-amber-50/70 text-amber-900 hover:bg-amber-100/80',
+ tileActive:
+ 'border-amber-300 bg-amber-100 ring-1 ring-amber-200 text-amber-950',
tileMeta: 'text-amber-700',
rowIdle: 'border border-transparent hover:bg-amber-50/70 text-gray-700',
rowActive: 'border border-amber-200 bg-amber-50 text-amber-800',
rowMeta: 'text-amber-700',
check: 'text-amber-500',
- trigger: 'border-amber-200 bg-amber-50 text-amber-800 hover:bg-amber-100/80',
+ trigger:
+ 'border-amber-200 bg-amber-50 text-amber-800 hover:bg-amber-100/80',
},
indigo: {
- tileIdle: 'border-indigo-200 bg-indigo-50/70 text-indigo-900 hover:bg-indigo-100/80',
- tileActive: 'border-indigo-300 bg-indigo-100 ring-1 ring-indigo-200 text-indigo-950',
+ tileIdle:
+ 'border-indigo-200 bg-indigo-50/70 text-indigo-900 hover:bg-indigo-100/80',
+ tileActive:
+ 'border-indigo-300 bg-indigo-100 ring-1 ring-indigo-200 text-indigo-950',
tileMeta: 'text-indigo-700',
rowIdle: 'border border-transparent hover:bg-indigo-50/70 text-gray-700',
rowActive: 'border border-indigo-200 bg-indigo-50 text-indigo-800',
rowMeta: 'text-indigo-700',
check: 'text-indigo-500',
- trigger: 'border-indigo-200 bg-indigo-50 text-indigo-800 hover:bg-indigo-100/80',
+ trigger:
+ 'border-indigo-200 bg-indigo-50 text-indigo-800 hover:bg-indigo-100/80',
},
rose: {
- tileIdle: 'border-rose-200 bg-rose-50/70 text-rose-900 hover:bg-rose-100/80',
- tileActive: 'border-rose-300 bg-rose-100 ring-1 ring-rose-200 text-rose-950',
+ tileIdle:
+ 'border-rose-200 bg-rose-50/70 text-rose-900 hover:bg-rose-100/80',
+ tileActive:
+ 'border-rose-300 bg-rose-100 ring-1 ring-rose-200 text-rose-950',
tileMeta: 'text-rose-700',
rowIdle: 'border border-transparent hover:bg-rose-50/70 text-gray-700',
rowActive: 'border border-rose-200 bg-rose-50 text-rose-800',
@@ -139,17 +164,21 @@ export function getConsonantPresetById(id: ThaiConsonantPreset['id']) {
}
export function getConsonantPresetByConsonantId(consonantId: string) {
- return THAI_CONSONANT_PRESETS.find((preset) => preset.consonantIds.includes(consonantId))
+ return THAI_CONSONANT_PRESETS.find((preset) =>
+ preset.consonantIds.includes(consonantId),
+ )
}
-export function getConsonantPresetTriggerLabel(selectedConsonantIds: string[]): string {
+export function getConsonantPresetTriggerLabel(
+ selectedConsonantIds: string[],
+): string {
if (selectedConsonantIds.length === 0) return 'Presets'
const selectedSet = new Set(selectedConsonantIds)
const exactMatch = THAI_CONSONANT_PRESETS.find(
(preset) =>
preset.consonantIds.length === selectedSet.size &&
- preset.consonantIds.every((id) => selectedSet.has(id))
+ preset.consonantIds.every((id) => selectedSet.has(id)),
)
return exactMatch?.shortLabel ?? 'Custom'
@@ -162,6 +191,6 @@ export function getConsonantExactMatchPreset(selectedConsonantIds: string[]) {
return THAI_CONSONANT_PRESETS.find(
(preset) =>
preset.consonantIds.length === selectedSet.size &&
- preset.consonantIds.every((id) => selectedSet.has(id))
+ preset.consonantIds.every((id) => selectedSet.has(id)),
)
}
diff --git a/src/data/sheetOptions.test.ts b/src/data/sheetOptions.test.ts
index c10cb3f..566c99c 100644
--- a/src/data/sheetOptions.test.ts
+++ b/src/data/sheetOptions.test.ts
@@ -95,15 +95,15 @@ describe('COLUMNS_OPTIONS', () => {
})
it('filters the rendered column options to the allowed max', () => {
- expect(getAllowedColumnOptions('small').map((option) => option.value)).toEqual([
- 3, 4, 5, 6, 7, 8, 9, 10, 11, 12,
- ])
- expect(getAllowedColumnOptions('medium').map((option) => option.value)).toEqual([
- 3, 4, 5, 6, 7, 8, 9,
- ])
- expect(getAllowedColumnOptions('large').map((option) => option.value)).toEqual([
- 3, 4, 5, 6, 7,
- ])
+ expect(
+ getAllowedColumnOptions('small').map((option) => option.value),
+ ).toEqual([3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
+ expect(
+ getAllowedColumnOptions('medium').map((option) => option.value),
+ ).toEqual([3, 4, 5, 6, 7, 8, 9])
+ expect(
+ getAllowedColumnOptions('large').map((option) => option.value),
+ ).toEqual([3, 4, 5, 6, 7])
})
it('derives initial columns from available width without exceeding the printable cap', () => {
@@ -119,7 +119,7 @@ describe('COLUMNS_OPTIONS', () => {
fontSize: 'large',
columns: 12,
ghostCopiesPerRow: 10,
- })
+ }),
).toEqual({
...DEFAULT_SHEET_CONFIG,
fontSize: 'large',
@@ -141,7 +141,7 @@ describe('COLUMNS_OPTIONS', () => {
})
expect(getSheetConfigClampNotice(previousConfig, nextConfig)).toBe(
- 'Adjusted to 7 columns so it fits on the page.'
+ 'Adjusted to 7 columns so it fits on the page.',
)
})
diff --git a/src/data/sheetOptions.ts b/src/data/sheetOptions.ts
index 058a6e6..ef51566 100644
--- a/src/data/sheetOptions.ts
+++ b/src/data/sheetOptions.ts
@@ -98,7 +98,8 @@ export const FONT_FAMILY_MAP: Record = {
export function getMaxColumnsForFontSize(fontSize: string): number {
const cellPx = (FONT_SIZE_MAP[fontSize] || FONT_SIZE_MAP.medium).cellPx
const narrowestPageWidthPt = Math.min(PDF_A4_WIDTH_PT, PDF_LETTER_WIDTH_PT)
- const printableWidthPx = (narrowestPageWidthPt - PDF_PAGE_MARGIN_X_PT * 2) / PX_TO_PT
+ const printableWidthPx =
+ (narrowestPageWidthPt - PDF_PAGE_MARGIN_X_PT * 2) / PX_TO_PT
const computedMax = Math.floor(printableWidthPx / cellPx)
const maxConfigured = COLUMNS_OPTIONS[COLUMNS_OPTIONS.length - 1].value
const minConfigured = COLUMNS_OPTIONS[0].value
@@ -111,7 +112,10 @@ export function getAllowedColumnOptions(fontSize: string) {
return COLUMNS_OPTIONS.filter((option) => option.value <= maxColumns)
}
-export function getInitialColumnsForWidth(fontSize: string, availableWidthPx: number): number {
+export function getInitialColumnsForWidth(
+ fontSize: string,
+ availableWidthPx: number,
+): number {
const cellPx = (FONT_SIZE_MAP[fontSize] || FONT_SIZE_MAP.medium).cellPx
const printableMax = getMaxColumnsForFontSize(fontSize)
const viewportFitMax = Math.floor(availableWidthPx / cellPx)
@@ -135,7 +139,7 @@ export function normalizeSheetConfig(config: SheetConfig): SheetConfig {
export function getSheetConfigClampNotice(
previousConfig: SheetConfig,
- nextConfig: SheetConfig
+ nextConfig: SheetConfig,
): string | null {
if (nextConfig.fontSize === previousConfig.fontSize) return null
if (nextConfig.columns === previousConfig.columns) return null
diff --git a/src/data/vowels.test.ts b/src/data/vowels.test.ts
index eefe405..9de82f1 100644
--- a/src/data/vowels.test.ts
+++ b/src/data/vowels.test.ts
@@ -30,10 +30,15 @@ describe('THAI_VOWELS', () => {
it('defines vowel presets with valid memberships', () => {
const vowelIds = new Set(THAI_VOWELS.map((vowel) => vowel.id))
- const shortPreset = THAI_VOWEL_PRESETS.find((preset) => preset.id === 'SHORT')
+ const shortPreset = THAI_VOWEL_PRESETS.find(
+ (preset) => preset.id === 'SHORT',
+ )
const longPreset = THAI_VOWEL_PRESETS.find((preset) => preset.id === 'LONG')
- expect(THAI_VOWEL_PRESETS.map((preset) => preset.id)).toEqual(['SHORT', 'LONG'])
+ expect(THAI_VOWEL_PRESETS.map((preset) => preset.id)).toEqual([
+ 'SHORT',
+ 'LONG',
+ ])
for (const preset of THAI_VOWEL_PRESETS) {
expect(preset.shortLabel.length).toBeGreaterThan(0)
@@ -42,7 +47,8 @@ describe('THAI_VOWELS', () => {
preset.vowelIds.forEach((id) => expect(vowelIds.has(id)).toBe(true))
}
- if (!shortPreset || !longPreset) throw new Error('Expected short and long vowel presets to exist')
+ if (!shortPreset || !longPreset)
+ throw new Error('Expected short and long vowel presets to exist')
expect(shortPreset.vowelIds).toContain('ฤ')
expect(shortPreset.vowelIds).toContain('ฦ')
@@ -51,7 +57,9 @@ describe('THAI_VOWELS', () => {
const longSet = new Set(longPreset.vowelIds)
expect([...shortSet].filter((id) => longSet.has(id))).toEqual([])
- expect(new Set([...shortPreset.vowelIds, ...longPreset.vowelIds])).toEqual(vowelIds)
+ expect(new Set([...shortPreset.vowelIds, ...longPreset.vowelIds])).toEqual(
+ vowelIds,
+ )
})
it('formats vowels with a placeholder อ', () => {
diff --git a/src/data/vowels.ts b/src/data/vowels.ts
index 0ded16a..da90402 100644
--- a/src/data/vowels.ts
+++ b/src/data/vowels.ts
@@ -25,7 +25,10 @@ export function splitVowelForDisplay(char: string): VowelDisplayParts {
const parts = Array.from(char)
let prefixLength = 0
- while (prefixLength < parts.length && PREPOSED_VOWELS.has(parts[prefixLength])) {
+ while (
+ prefixLength < parts.length &&
+ PREPOSED_VOWELS.has(parts[prefixLength])
+ ) {
prefixLength += 1
}
@@ -106,13 +109,43 @@ export const THAI_VOWEL_PRESETS: ThaiVowelPreset[] = [
id: 'SHORT',
shortLabel: 'Short Vowels',
fullLabel: 'Short-duration vowel forms',
- vowelIds: ['ะ', 'ั', 'ิ', 'ึ', 'ุ', 'ฤ', 'ฦ', 'เาะ', 'ัวะ', 'ัะ', 'เียะ', 'ือะ'],
+ vowelIds: [
+ 'ะ',
+ 'ั',
+ 'ิ',
+ 'ึ',
+ 'ุ',
+ 'ฤ',
+ 'ฦ',
+ 'เาะ',
+ 'ัวะ',
+ 'ัะ',
+ 'เียะ',
+ 'ือะ',
+ ],
},
{
id: 'LONG',
shortLabel: 'Long Vowels',
fullLabel: 'Long-duration vowel forms',
- vowelIds: ['า', 'ำ', 'ี', 'ื', 'ู', 'เ', 'แ', 'โ', 'ใ', 'ไ', 'ฤๅ', 'ฦๅ', 'เีย', 'ือ', 'ัว', 'ียว'],
+ vowelIds: [
+ 'า',
+ 'ำ',
+ 'ี',
+ 'ื',
+ 'ู',
+ 'เ',
+ 'แ',
+ 'โ',
+ 'ใ',
+ 'ไ',
+ 'ฤๅ',
+ 'ฦๅ',
+ 'เีย',
+ 'ือ',
+ 'ัว',
+ 'ียว',
+ ],
},
]
@@ -127,7 +160,7 @@ export function getVowelPresetTriggerLabel(selectedVowelIds: string[]): string {
const exactMatch = THAI_VOWEL_PRESETS.find(
(preset) =>
preset.vowelIds.length === selectedSet.size &&
- preset.vowelIds.every((id) => selectedSet.has(id))
+ preset.vowelIds.every((id) => selectedSet.has(id)),
)
return exactMatch?.shortLabel ?? 'Custom'
diff --git a/src/data/worksheetContent.ts b/src/data/worksheetContent.ts
index 1824eb5..adedd64 100644
--- a/src/data/worksheetContent.ts
+++ b/src/data/worksheetContent.ts
@@ -1,5 +1,6 @@
export const WORKSHEET_TITLE = 'Thai Script Pro'
-export const EMPTY_WORKSHEET_MESSAGE = 'Select consonants or vowels to see preview.'
+export const EMPTY_WORKSHEET_MESSAGE =
+ 'Select consonants or vowels to see preview.'
export interface WorksheetSummaryArgs {
consonantCount: number
@@ -7,7 +8,10 @@ export interface WorksheetSummaryArgs {
fontLabel: string
}
-export function getWorksheetCharacterLabel(consonantCount: number, vowelCount: number): string {
+export function getWorksheetCharacterLabel(
+ consonantCount: number,
+ vowelCount: number,
+): string {
if (consonantCount > 0 && vowelCount > 0) return 'Characters'
if (consonantCount > 0) return 'Consonants'
return 'Vowels'
diff --git a/src/hooks/useContentSelection.test.ts b/src/hooks/useContentSelection.test.ts
index e704112..ad5823d 100644
--- a/src/hooks/useContentSelection.test.ts
+++ b/src/hooks/useContentSelection.test.ts
@@ -54,8 +54,12 @@ describe('useContentSelection', () => {
result.current.applyConsonantPreset('LCG1')
})
- expect(result.current.selectedConsonantIds.sort()).toEqual([...preset.consonantIds].sort())
- expect(result.current.activeConsonantPresetLabel).toBe('Low Class - Group 1')
+ expect(result.current.selectedConsonantIds.sort()).toEqual(
+ [...preset.consonantIds].sort(),
+ )
+ expect(result.current.activeConsonantPresetLabel).toBe(
+ 'Low Class - Group 1',
+ )
})
it('applyConsonantPreset removes a preset when clicked again', () => {
@@ -84,7 +88,7 @@ describe('useContentSelection', () => {
})
expect(new Set(result.current.selectedConsonantIds)).toEqual(
- new Set([...lcg1.consonantIds, ...mc.consonantIds])
+ new Set([...lcg1.consonantIds, ...mc.consonantIds]),
)
expect(result.current.activeConsonantPresetLabel).toBe('Custom')
@@ -92,8 +96,12 @@ describe('useContentSelection', () => {
result.current.applyConsonantPreset('MC')
})
- expect(new Set(result.current.selectedConsonantIds)).toEqual(new Set(lcg1.consonantIds))
- expect(result.current.activeConsonantPresetLabel).toBe('Low Class - Group 1')
+ expect(new Set(result.current.selectedConsonantIds)).toEqual(
+ new Set(lcg1.consonantIds),
+ )
+ expect(result.current.activeConsonantPresetLabel).toBe(
+ 'Low Class - Group 1',
+ )
})
it('applyConsonantPreset removes a fully selected preset from a custom selection', () => {
@@ -155,7 +163,9 @@ describe('useContentSelection', () => {
result.current.applyVowelPreset('SHORT')
})
- expect(new Set(result.current.selectedVowelIds)).toEqual(new Set(preset.vowelIds))
+ expect(new Set(result.current.selectedVowelIds)).toEqual(
+ new Set(preset.vowelIds),
+ )
expect(result.current.activeVowelPresetLabel).toBe('Short Vowels')
})
@@ -186,14 +196,16 @@ describe('useContentSelection', () => {
expect(result.current.activeVowelPresetLabel).toBe('Custom')
expect(new Set(result.current.selectedVowelIds)).toEqual(
- new Set([...short.vowelIds, ...long.vowelIds])
+ new Set([...short.vowelIds, ...long.vowelIds]),
)
act(() => {
result.current.applyVowelPreset('LONG')
})
- expect(new Set(result.current.selectedVowelIds)).toEqual(new Set(short.vowelIds))
+ expect(new Set(result.current.selectedVowelIds)).toEqual(
+ new Set(short.vowelIds),
+ )
expect(result.current.activeVowelPresetLabel).toBe('Short Vowels')
})
diff --git a/src/hooks/useContentSelection.ts b/src/hooks/useContentSelection.ts
index 20ae625..3017950 100644
--- a/src/hooks/useContentSelection.ts
+++ b/src/hooks/useContentSelection.ts
@@ -13,8 +13,12 @@ import {
} from '../data/vowels'
export function useContentSelection() {
- const [selectedConsonantIds, setSelectedConsonantIds] = useState>(new Set())
- const [selectedVowelIds, setSelectedVowelIds] = useState>(new Set())
+ const [selectedConsonantIds, setSelectedConsonantIds] = useState>(
+ new Set(),
+ )
+ const [selectedVowelIds, setSelectedVowelIds] = useState>(
+ new Set(),
+ )
const toggleConsonant = useCallback((id: string) => {
setSelectedConsonantIds((prev) => {
@@ -42,23 +46,26 @@ export function useContentSelection() {
setSelectedConsonantIds(new Set())
}, [])
- const applyConsonantPreset = useCallback((presetId: ThaiConsonantPreset['id']) => {
- const preset = getConsonantPresetById(presetId)
- if (!preset) return
+ const applyConsonantPreset = useCallback(
+ (presetId: ThaiConsonantPreset['id']) => {
+ const preset = getConsonantPresetById(presetId)
+ if (!preset) return
- setSelectedConsonantIds((prev) => {
- const next = new Set(prev)
+ setSelectedConsonantIds((prev) => {
+ const next = new Set(prev)
- const isApplied = preset.consonantIds.every((id) => next.has(id))
+ const isApplied = preset.consonantIds.every((id) => next.has(id))
- preset.consonantIds.forEach((id) => {
- if (isApplied) next.delete(id)
- else next.add(id)
- })
+ preset.consonantIds.forEach((id) => {
+ if (isApplied) next.delete(id)
+ else next.add(id)
+ })
- return next
- })
- }, [])
+ return next
+ })
+ },
+ [],
+ )
const selectAllVowels = useCallback(() => {
setSelectedVowelIds(new Set(THAI_VOWELS.map((v) => v.id)))
@@ -88,8 +95,12 @@ export function useContentSelection() {
return {
selectedConsonantIds: Array.from(selectedConsonantIds),
selectedVowelIds: Array.from(selectedVowelIds),
- activeConsonantPresetLabel: getConsonantPresetTriggerLabel(Array.from(selectedConsonantIds)),
- activeVowelPresetLabel: getVowelPresetTriggerLabel(Array.from(selectedVowelIds)),
+ activeConsonantPresetLabel: getConsonantPresetTriggerLabel(
+ Array.from(selectedConsonantIds),
+ ),
+ activeVowelPresetLabel: getVowelPresetTriggerLabel(
+ Array.from(selectedVowelIds),
+ ),
toggleConsonant,
toggleVowel,
applyConsonantPreset,
diff --git a/src/hooks/useInitialPreviewColumns.test.tsx b/src/hooks/useInitialPreviewColumns.test.tsx
index eb9f1c7..690fb4b 100644
--- a/src/hooks/useInitialPreviewColumns.test.tsx
+++ b/src/hooks/useInitialPreviewColumns.test.tsx
@@ -11,7 +11,10 @@ function TestHarness({
fontSize?: string
columns?: number
ghostCopiesPerRow?: number
- onInitialize?: (adjustment: { columns: number; ghostCopiesPerRow: number }) => void
+ onInitialize?: (adjustment: {
+ columns: number
+ ghostCopiesPerRow: number
+ }) => void
}) {
const { previewRootRef } = useInitialPreviewColumns({
fontSize,
@@ -28,7 +31,10 @@ function TestHarness({
}
describe('useInitialPreviewColumns', () => {
- const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
+ const originalClientWidth = Object.getOwnPropertyDescriptor(
+ HTMLElement.prototype,
+ 'clientWidth',
+ )
beforeEach(() => {
vi.restoreAllMocks()
@@ -36,7 +42,11 @@ describe('useInitialPreviewColumns', () => {
afterEach(() => {
if (originalClientWidth) {
- Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth)
+ Object.defineProperty(
+ HTMLElement.prototype,
+ 'clientWidth',
+ originalClientWidth,
+ )
} else {
Reflect.deleteProperty(HTMLElement.prototype, 'clientWidth')
}
diff --git a/src/hooks/useInitialPreviewColumns.ts b/src/hooks/useInitialPreviewColumns.ts
index ba5914b..939a6e0 100644
--- a/src/hooks/useInitialPreviewColumns.ts
+++ b/src/hooks/useInitialPreviewColumns.ts
@@ -26,13 +26,18 @@ export function useInitialPreviewColumns({
if (hasInitializedColumnsRef.current) return
const previewRoot = previewRootRef.current
- const previewSurface = previewRoot?.querySelector('[data-preview-surface="true"]')
+ const previewSurface = previewRoot?.querySelector(
+ '[data-preview-surface="true"]',
+ )
if (!previewSurface) return
const styles = window.getComputedStyle(previewSurface)
const paddingLeft = Number.parseFloat(styles.paddingLeft) || 0
const paddingRight = Number.parseFloat(styles.paddingRight) || 0
- const availableWidthPx = Math.max(0, previewSurface.clientWidth - paddingLeft - paddingRight)
+ const availableWidthPx = Math.max(
+ 0,
+ previewSurface.clientWidth - paddingLeft - paddingRight,
+ )
const initialColumns = getInitialColumnsForWidth(fontSize, availableWidthPx)
hasInitializedColumnsRef.current = true
diff --git a/src/hooks/usePdfExport.test.ts b/src/hooks/usePdfExport.test.ts
index 5108b87..3cedfaa 100644
--- a/src/hooks/usePdfExport.test.ts
+++ b/src/hooks/usePdfExport.test.ts
@@ -18,10 +18,12 @@ function createDeferred() {
describe('usePdfExport', () => {
beforeEach(() => {
vi.restoreAllMocks()
- vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => {
- callback(0)
- return 0
- })
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation(
+ (callback: FrameRequestCallback) => {
+ callback(0)
+ return 0
+ },
+ )
})
it('passes the selected content and config to the PDF downloader', async () => {
@@ -32,7 +34,7 @@ describe('usePdfExport', () => {
selectedVowelIds: ['sara-a'],
config: DEFAULT_SHEET_CONFIG,
downloadPdf,
- })
+ }),
)
await act(async () => {
@@ -45,7 +47,7 @@ describe('usePdfExport', () => {
selectedVowelIds: ['sara-a'],
config: DEFAULT_SHEET_CONFIG,
onProgress: expect.any(Function),
- })
+ }),
)
})
@@ -61,7 +63,7 @@ describe('usePdfExport', () => {
selectedVowelIds: [],
config: DEFAULT_SHEET_CONFIG,
downloadPdf,
- })
+ }),
)
let exportPromise!: Promise
@@ -71,7 +73,9 @@ describe('usePdfExport', () => {
expect(result.current.pdfExportState.phase).toBe('generating')
expect(result.current.pdfExportState.label).toBe('Building 2/5')
- expect(result.current.pdfExportState.statusMessage).toBe('Building your PDF pages (2 of 5)...')
+ expect(result.current.pdfExportState.statusMessage).toBe(
+ 'Building your PDF pages (2 of 5)...',
+ )
await act(async () => {
deferred.resolve()
@@ -90,7 +94,7 @@ describe('usePdfExport', () => {
selectedVowelIds: [],
config: DEFAULT_SHEET_CONFIG,
downloadPdf,
- })
+ }),
)
let firstExport!: Promise
@@ -113,7 +117,9 @@ describe('usePdfExport', () => {
it('calls onError when export fails', async () => {
const onError = vi.fn()
const downloadPdf = vi.fn().mockRejectedValue(new Error('boom'))
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {})
const { result } = renderHook(() =>
usePdfExport({
selectedConsonantIds: [],
@@ -121,7 +127,7 @@ describe('usePdfExport', () => {
config: DEFAULT_SHEET_CONFIG,
downloadPdf,
onError,
- })
+ }),
)
await act(async () => {
diff --git a/src/hooks/usePdfExport.ts b/src/hooks/usePdfExport.ts
index 0af07c7..a6623ac 100644
--- a/src/hooks/usePdfExport.ts
+++ b/src/hooks/usePdfExport.ts
@@ -6,7 +6,12 @@ import {
type PdfExportProgress,
} from '../pdf/downloadPracticePdf'
-type PdfExportPhase = 'idle' | 'preparing' | 'generating' | 'downloading' | 'error'
+type PdfExportPhase =
+ | 'idle'
+ | 'preparing'
+ | 'generating'
+ | 'downloading'
+ | 'error'
export interface PdfExportState {
phase: PdfExportPhase
@@ -21,7 +26,10 @@ const IDLE_PDF_EXPORT_STATE: PdfExportState = {
}
async function waitForNextPaint(): Promise {
- if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
+ if (
+ typeof window !== 'undefined' &&
+ typeof window.requestAnimationFrame === 'function'
+ ) {
await new Promise((resolve) => {
window.requestAnimationFrame(() => resolve())
})
@@ -36,7 +44,7 @@ async function waitForNextPaint(): Promise {
function getPdfExportState(
phase: Exclude,
completed?: number,
- total?: number
+ total?: number,
): PdfExportState {
if (phase === 'preparing') {
return {
@@ -47,7 +55,8 @@ function getPdfExportState(
}
if (phase === 'generating') {
- const hasProgress = typeof completed === 'number' && typeof total === 'number' && total > 0
+ const hasProgress =
+ typeof completed === 'number' && typeof total === 'number' && total > 0
return {
phase,
label: hasProgress ? `Building ${completed}/${total}` : 'Building PDF...',
@@ -79,7 +88,9 @@ export function usePdfExport({
onError,
downloadPdf = downloadPracticePdf,
}: UsePdfExportOptions) {
- const [pdfExportState, setPdfExportState] = useState(IDLE_PDF_EXPORT_STATE)
+ const [pdfExportState, setPdfExportState] = useState(
+ IDLE_PDF_EXPORT_STATE,
+ )
const isPdfExportingRef = useRef(false)
const handleDownloadPdf = useCallback(async () => {
diff --git a/src/index.css b/src/index.css
index 75a465a..af82e6e 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,4 +1,4 @@
-@import "tailwindcss";
+@import 'tailwindcss';
@layer base {
button:not(:disabled),
@@ -24,26 +24,26 @@
.guide-lines-thai {
background:
- repeating-linear-gradient(to right, #c8e6c9 0 6px, transparent 6px 12px) 0 25% / 100% 1px
- no-repeat,
- repeating-linear-gradient(to right, #c8e6c9 0 6px, transparent 6px 12px) 0 50% / 100% 1px
- no-repeat,
- repeating-linear-gradient(to right, #c8e6c9 0 6px, transparent 6px 12px) 0 75% / 100% 1px
- no-repeat;
+ repeating-linear-gradient(to right, #c8e6c9 0 6px, transparent 6px 12px) 0
+ 25% / 100% 1px no-repeat,
+ repeating-linear-gradient(to right, #c8e6c9 0 6px, transparent 6px 12px) 0
+ 50% / 100% 1px no-repeat,
+ repeating-linear-gradient(to right, #c8e6c9 0 6px, transparent 6px 12px) 0
+ 75% / 100% 1px no-repeat;
}
.guide-lines-cross {
background:
- repeating-linear-gradient(to right, #e0e0e0 0 6px, transparent 6px 12px) 0 50% / 100% 1px
- no-repeat,
- repeating-linear-gradient(to bottom, #e0e0e0 0 6px, transparent 6px 12px) 50% 0 / 1px 100%
- no-repeat;
+ repeating-linear-gradient(to right, #e0e0e0 0 6px, transparent 6px 12px) 0
+ 50% / 100% 1px no-repeat,
+ repeating-linear-gradient(to bottom, #e0e0e0 0 6px, transparent 6px 12px)
+ 50% 0 / 1px 100% no-repeat;
}
.guide-lines-sandwich {
background:
- repeating-linear-gradient(to right, #e0e0e0 0 6px, transparent 6px 12px) 0 25% / 100% 1px
- no-repeat,
- repeating-linear-gradient(to right, #e0e0e0 0 6px, transparent 6px 12px) 0 75% / 100% 1px
- no-repeat;
+ repeating-linear-gradient(to right, #e0e0e0 0 6px, transparent 6px 12px) 0
+ 25% / 100% 1px no-repeat,
+ repeating-linear-gradient(to right, #e0e0e0 0 6px, transparent 6px 12px) 0
+ 75% / 100% 1px no-repeat;
}
diff --git a/src/pdf/downloadPracticePdf.test.ts b/src/pdf/downloadPracticePdf.test.ts
index cec68e3..4a79b80 100644
--- a/src/pdf/downloadPracticePdf.test.ts
+++ b/src/pdf/downloadPracticePdf.test.ts
@@ -119,8 +119,12 @@ describe('buildPracticePdf', () => {
config,
})
- expect(textRecords.some((record) => record.fontName === 'Prompt-SemiBold')).toBe(true)
- expect(textRecords.some((record) => record.fontName === 'Prompt-Regular')).toBe(true)
+ expect(
+ textRecords.some((record) => record.fontName === 'Prompt-SemiBold'),
+ ).toBe(true)
+ expect(
+ textRecords.some((record) => record.fontName === 'Prompt-Regular'),
+ ).toBe(true)
})
it('draws model glyphs centered with a middle baseline at the cell midpoint', async () => {
@@ -147,7 +151,7 @@ describe('buildPracticePdf', () => {
record.text === firstConsonant.char &&
record.fontName === 'Sarabun-SemiBold' &&
record.options?.align === 'center' &&
- record.options?.baseline === 'middle'
+ record.options?.baseline === 'middle',
)
expect(modelGlyph).toBeDefined()
@@ -176,15 +180,21 @@ describe('buildPracticePdf', () => {
const thaiLines = await runGuideTest('thai')
expect(thaiLines).toHaveLength(3)
- expect(thaiLines.every((line) => line.color.join(',') === '200,230,201')).toBe(true)
+ expect(
+ thaiLines.every((line) => line.color.join(',') === '200,230,201'),
+ ).toBe(true)
const crossLines = await runGuideTest('cross')
expect(crossLines).toHaveLength(2)
- expect(crossLines.every((line) => line.color.join(',') === '224,224,224')).toBe(true)
+ expect(
+ crossLines.every((line) => line.color.join(',') === '224,224,224'),
+ ).toBe(true)
const sandwichLines = await runGuideTest('sandwich')
expect(sandwichLines).toHaveLength(2)
- expect(sandwichLines.every((line) => line.color.join(',') === '224,224,224')).toBe(true)
+ expect(
+ sandwichLines.every((line) => line.color.join(',') === '224,224,224'),
+ ).toBe(true)
})
it('uses preblended ghost colors that follow the existing opacity progression', async () => {
@@ -206,7 +216,7 @@ describe('buildPracticePdf', () => {
record.text === firstConsonant.char &&
record.fontName === 'Sarabun-Regular' &&
record.options?.align === 'center' &&
- record.options?.baseline === 'middle'
+ record.options?.baseline === 'middle',
)
expect(ghostGlyphs).toHaveLength(3)
@@ -257,7 +267,7 @@ describe('buildPracticePdf', () => {
record.text === firstConsonant.char &&
record.fontName === 'Sarabun-SemiBold' &&
record.options?.align === 'center' &&
- record.options?.baseline === 'middle'
+ record.options?.baseline === 'middle',
)
expect(modelGlyph).toBeDefined()
@@ -266,10 +276,16 @@ describe('buildPracticePdf', () => {
it('emits generation progress while building large exports', async () => {
const { doc } = createMockDoc()
- const progressStates: Array<{ phase: string; completed?: number; total?: number }> = []
+ const progressStates: Array<{
+ phase: string
+ completed?: number
+ total?: number
+ }> = []
const args: DownloadPracticePdfArgs = {
- selectedConsonantIds: THAI_CONSONANTS.slice(0, 6).map((consonant) => consonant.id),
+ selectedConsonantIds: THAI_CONSONANTS.slice(0, 6).map(
+ (consonant) => consonant.id,
+ ),
selectedVowelIds: [],
config: {
...DEFAULT_SHEET_CONFIG,
@@ -285,7 +301,11 @@ describe('buildPracticePdf', () => {
await buildPracticePdf(doc, args)
expect(progressStates).toHaveLength(6)
- expect(progressStates[0]).toEqual({ phase: 'generating', completed: 1, total: 6 })
+ expect(progressStates[0]).toEqual({
+ phase: 'generating',
+ completed: 1,
+ total: 6,
+ })
expect(progressStates[progressStates.length - 1]).toEqual({
phase: 'generating',
completed: 6,
@@ -300,7 +320,9 @@ describe('downloadPracticePdf', () => {
const progressStates: string[] = []
const createObjectURL = vi.fn(() => 'blob:pdf')
const revokeObjectURL = vi.fn()
- const anchorClick = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
+ const anchorClick = vi
+ .spyOn(HTMLAnchorElement.prototype, 'click')
+ .mockImplementation(() => {})
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
arrayBuffer: async () => new TextEncoder().encode('font').buffer,
@@ -313,7 +335,7 @@ describe('downloadPracticePdf', () => {
constructor() {
return doc
}
- } as unknown as (...args: any[]) => any
+ } as unknown as (...args: unknown[]) => PdfDocLike,
)
vi.stubGlobal('fetch', fetchMock)
Object.defineProperty(URL, 'createObjectURL', {
diff --git a/src/pdf/downloadPracticePdf.ts b/src/pdf/downloadPracticePdf.ts
index 558b8ea..a536ec9 100644
--- a/src/pdf/downloadPracticePdf.ts
+++ b/src/pdf/downloadPracticePdf.ts
@@ -2,7 +2,12 @@ import { jsPDF } from 'jspdf'
import type { SheetConfig } from '../data/sheetOptions'
import { registerPdfFonts } from './pdfFonts'
import { buildPracticePdf } from './pdfDocument'
-import { PDF_FILENAME, type PdfDocLike, getGhostTextColor, getPdfFontFamily } from './pdfShared'
+import {
+ PDF_FILENAME,
+ type PdfDocLike,
+ getGhostTextColor,
+ getPdfFontFamily,
+} from './pdfShared'
export interface DownloadPracticePdfArgs {
selectedConsonantIds: string[]
@@ -17,12 +22,18 @@ export interface PdfExportProgress {
total?: number
}
-function emitProgress(args: DownloadPracticePdfArgs, state: PdfExportProgress): void {
+function emitProgress(
+ args: DownloadPracticePdfArgs,
+ state: PdfExportProgress,
+): void {
args.onProgress?.(state)
}
async function yieldToBrowser(): Promise {
- if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
+ if (
+ typeof window !== 'undefined' &&
+ typeof window.requestAnimationFrame === 'function'
+ ) {
await new Promise((resolve) => {
window.requestAnimationFrame(() => resolve())
})
@@ -48,7 +59,9 @@ function triggerPdfDownload(blob: Blob, filename: string): void {
}, 0)
}
-export async function downloadPracticePdf(args: DownloadPracticePdfArgs): Promise {
+export async function downloadPracticePdf(
+ args: DownloadPracticePdfArgs,
+): Promise {
const doc = new jsPDF({
format: 'a4',
orientation: 'portrait',
diff --git a/src/pdf/pdfDocument.ts b/src/pdf/pdfDocument.ts
index be4fd02..739b37e 100644
--- a/src/pdf/pdfDocument.ts
+++ b/src/pdf/pdfDocument.ts
@@ -6,7 +6,10 @@ import {
EMPTY_WORKSHEET_MESSAGE,
WORKSHEET_TITLE,
} from '../data/worksheetContent'
-import type { DownloadPracticePdfArgs, PdfExportProgress } from './downloadPracticePdf'
+import type {
+ DownloadPracticePdfArgs,
+ PdfExportProgress,
+} from './downloadPracticePdf'
import {
BLOCK_YIELD_INTERVAL,
BORDER_COLOR,
@@ -31,12 +34,18 @@ import {
THAI_GUIDE_COLOR,
} from './pdfShared'
-function emitProgress(args: DownloadPracticePdfArgs, state: PdfExportProgress): void {
+function emitProgress(
+ args: DownloadPracticePdfArgs,
+ state: PdfExportProgress,
+): void {
args.onProgress?.(state)
}
async function yieldToBrowser(): Promise {
- if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
+ if (
+ typeof window !== 'undefined' &&
+ typeof window.requestAnimationFrame === 'function'
+ ) {
await new Promise((resolve) => {
window.requestAnimationFrame(() => resolve())
})
@@ -50,7 +59,7 @@ async function yieldToBrowser(): Promise {
function getPdfBlocks(args: DownloadPracticePdfArgs): PdfCharacterBlock[] {
const consonantBlocks = THAI_CONSONANTS.filter((c) =>
- args.selectedConsonantIds.includes(c.id)
+ args.selectedConsonantIds.includes(c.id),
).map((c) => ({
primaryLabel: c.char,
secondaryLabel: `${c.char}อ ${c.name}`,
@@ -58,7 +67,7 @@ function getPdfBlocks(args: DownloadPracticePdfArgs): PdfCharacterBlock[] {
}))
const vowelBlocks = THAI_VOWELS.filter((v) =>
- args.selectedVowelIds.includes(v.id)
+ args.selectedVowelIds.includes(v.id),
).map((v) => {
const displayText = formatVowelWithPlaceholder(v.char)
return {
@@ -74,12 +83,14 @@ function drawDocumentHeader(
doc: PdfDocLike,
layout: PdfLayout,
subtitle: string,
- fontFamily: PdfFontFamily
+ fontFamily: PdfFontFamily,
): number {
doc.setTextColor(...PRIMARY_TEXT_COLOR)
doc.setFont(fontFamily.semibold.fontName)
doc.setFontSize(layout.titleFontSize)
- doc.text(WORKSHEET_TITLE, layout.pageWidth / 2, layout.topY, { align: 'center' })
+ doc.text(WORKSHEET_TITLE, layout.pageWidth / 2, layout.topY, {
+ align: 'center',
+ })
const subtitleY = layout.topY + 24
doc.setFont(fontFamily.regular.fontName)
@@ -96,12 +107,14 @@ function drawBlockLabel(
x: number,
y: number,
layout: PdfLayout,
- fontFamily: PdfFontFamily
+ fontFamily: PdfFontFamily,
): void {
doc.setTextColor(...PRIMARY_TEXT_COLOR)
doc.setFont(fontFamily.semibold.fontName)
doc.setFontSize(layout.headerPrimaryFontSize)
- doc.text(block.primaryLabel, x, y + layout.headerPrimaryFontSize, { baseline: 'bottom' })
+ doc.text(block.primaryLabel, x, y + layout.headerPrimaryFontSize, {
+ baseline: 'bottom',
+ })
if (!block.secondaryLabel) return
@@ -111,7 +124,7 @@ function drawBlockLabel(
doc.text(
block.secondaryLabel,
x + layout.headerPrimaryFontSize + 8,
- y + layout.headerPrimaryFontSize - 1
+ y + layout.headerPrimaryFontSize - 1,
)
}
@@ -121,7 +134,7 @@ function drawGuideLines(
y: number,
size: number,
guide: string,
- dash: number
+ dash: number,
): void {
const guideColor = guide === 'thai' ? THAI_GUIDE_COLOR : NEUTRAL_GUIDE_COLOR
const yQuarter = y + size * 0.25
@@ -158,7 +171,7 @@ function drawGridCell(
y: number,
size: number,
guide: string,
- dash: number
+ dash: number,
): void {
doc.setLineWidth(BORDER_LINE_WIDTH)
doc.setDrawColor(...BORDER_COLOR)
@@ -173,7 +186,7 @@ function drawGridGlyph(
y: number,
layout: PdfLayout,
fontName: string,
- color: RgbColor
+ color: RgbColor,
): void {
doc.setFont(fontName)
doc.setFontSize(layout.glyphFontSize)
@@ -191,9 +204,12 @@ function drawPracticeGrid(
y: number,
config: SheetConfig,
layout: PdfLayout,
- fontFamily: PdfFontFamily
+ fontFamily: PdfFontFamily,
): void {
- const firstRowGhostCopies = Math.min(config.ghostCopiesPerRow, Math.max(config.columns - 1, 0))
+ const firstRowGhostCopies = Math.min(
+ config.ghostCopiesPerRow,
+ Math.max(config.columns - 1, 0),
+ )
const laterRowGhostCopies = Math.min(firstRowGhostCopies + 1, config.columns)
for (let row = 0; row < config.rowsPerCharacter; row += 1) {
@@ -205,9 +221,18 @@ function drawPracticeGrid(
const cellY = y + row * layout.cellSize
const isModel = isFirstRow && col === 0
const ghostIdx = isFirstRow ? col - 1 : col
- const isGhost = isFirstRow ? col > 0 && col <= ghostCopies : col < ghostCopies
-
- drawGridCell(doc, cellX, cellY, layout.cellSize, config.gridGuide, layout.guideDash)
+ const isGhost = isFirstRow
+ ? col > 0 && col <= ghostCopies
+ : col < ghostCopies
+
+ drawGridCell(
+ doc,
+ cellX,
+ cellY,
+ layout.cellSize,
+ config.gridGuide,
+ layout.guideDash,
+ )
if (isModel) {
drawGridGlyph(
@@ -217,7 +242,7 @@ function drawPracticeGrid(
cellY,
layout,
fontFamily.semibold.fontName,
- PRIMARY_TEXT_COLOR
+ PRIMARY_TEXT_COLOR,
)
}
@@ -229,17 +254,21 @@ function drawPracticeGrid(
cellY,
layout,
fontFamily.regular.fontName,
- getGhostTextColor(ghostIdx, ghostCopies)
+ getGhostTextColor(ghostIdx, ghostCopies),
)
}
}
}
}
-export async function buildPracticePdf(doc: PdfDocLike, args: DownloadPracticePdfArgs): Promise {
+export async function buildPracticePdf(
+ doc: PdfDocLike,
+ args: DownloadPracticePdfArgs,
+): Promise {
const layout = getPdfLayout(doc, args.config)
const fontFamily = getPdfFontFamily(args.config.font)
- const fontLabel = args.config.font.charAt(0).toUpperCase() + args.config.font.slice(1)
+ const fontLabel =
+ args.config.font.charAt(0).toUpperCase() + args.config.font.slice(1)
const blocks = getPdfBlocks(args)
const subtitle = buildWorksheetSubtitle({
consonantCount: args.selectedConsonantIds.length,
@@ -275,7 +304,7 @@ export async function buildPracticePdf(doc: PdfDocLike, args: DownloadPracticePd
currentY + LABEL_ROW_HEIGHT + GRID_GAP_Y,
args.config,
layout,
- fontFamily
+ fontFamily,
)
currentY += blockHeight
diff --git a/src/pdf/pdfFonts.ts b/src/pdf/pdfFonts.ts
index a4ebec4..77d0120 100644
--- a/src/pdf/pdfFonts.ts
+++ b/src/pdf/pdfFonts.ts
@@ -17,21 +17,23 @@ function arrayBufferToBinaryString(buffer: ArrayBuffer): string {
}
function buildFontUrl(fileName: string): string {
- return new URL(`${import.meta.env.BASE_URL}fonts/${fileName}`, window.location.origin).toString()
+ return new URL(
+ `${import.meta.env.BASE_URL}fonts/${fileName}`,
+ window.location.origin,
+ ).toString()
}
async function loadFontBinary(fileName: string): Promise {
const cached = loadedFontFiles.get(fileName)
if (cached) return cached
- const fontPromise = fetch(buildFontUrl(fileName))
- .then(async (response) => {
- if (!response.ok) {
- throw new Error(`Failed to load PDF font: ${fileName}`)
- }
+ const fontPromise = fetch(buildFontUrl(fileName)).then(async (response) => {
+ if (!response.ok) {
+ throw new Error(`Failed to load PDF font: ${fileName}`)
+ }
- return arrayBufferToBinaryString(await response.arrayBuffer())
- })
+ return arrayBufferToBinaryString(await response.arrayBuffer())
+ })
loadedFontFiles.set(fileName, fontPromise)
return fontPromise
diff --git a/src/pdf/pdfShared.test.ts b/src/pdf/pdfShared.test.ts
index b630dab..70d955a 100644
--- a/src/pdf/pdfShared.test.ts
+++ b/src/pdf/pdfShared.test.ts
@@ -35,7 +35,9 @@ function createMockDoc(width = 595.28, height = 841.89): PdfDocLike {
describe('pdfShared', () => {
it('returns the default embedded family when the font id is unknown', () => {
- expect(getPdfFontFamily('unknown')).toEqual(getPdfFontFamily(DEFAULT_SHEET_CONFIG.font))
+ expect(getPdfFontFamily('unknown')).toEqual(
+ getPdfFontFamily(DEFAULT_SHEET_CONFIG.font),
+ )
})
it('returns each unique embedded font file once', () => {
@@ -56,15 +58,21 @@ describe('pdfShared', () => {
})
expect(layout.cellSize).toBeLessThan(75)
- expect(layout.gridX + layout.cellSize * 12).toBeLessThanOrEqual(layout.pageWidth - layout.marginX)
+ expect(layout.gridX + layout.cellSize * 12).toBeLessThanOrEqual(
+ layout.pageWidth - layout.marginX,
+ )
})
it('lightens later ghost copies relative to earlier ones', () => {
const firstGhost = getGhostTextColor(0, 3)
const lastGhost = getGhostTextColor(2, 3)
- expect(firstGhost.every((channel) => channel >= 0 && channel <= 255)).toBe(true)
- expect(lastGhost.every((channel) => channel >= 0 && channel <= 255)).toBe(true)
+ expect(firstGhost.every((channel) => channel >= 0 && channel <= 255)).toBe(
+ true,
+ )
+ expect(lastGhost.every((channel) => channel >= 0 && channel <= 255)).toBe(
+ true,
+ )
expect(lastGhost[0]).toBeGreaterThan(firstGhost[0])
expect(lastGhost[1]).toBeGreaterThan(firstGhost[1])
expect(lastGhost[2]).toBeGreaterThan(firstGhost[2])
diff --git a/src/pdf/pdfShared.ts b/src/pdf/pdfShared.ts
index b893082..97f8d7e 100644
--- a/src/pdf/pdfShared.ts
+++ b/src/pdf/pdfShared.ts
@@ -62,7 +62,12 @@ export interface PdfDocLike {
setLineDashPattern(dashArray: number[], dashPhase: number): void
setLineWidth(width: number): void
setTextColor(r: number, g: number, b: number): void
- text(text: string, x: number, y: number, options?: Record): void
+ text(
+ text: string,
+ x: number,
+ y: number,
+ options?: Record,
+ ): void
}
export const FIRST_PAGE_TOP_Y = 48
@@ -86,7 +91,10 @@ export const BLOCK_YIELD_INTERVAL = 4
const PDF_FONT_FAMILIES: Record = {
traditional: {
regular: { fileName: 'Sarabun-Regular.ttf', fontName: 'Sarabun-Regular' },
- semibold: { fileName: 'Sarabun-SemiBold.ttf', fontName: 'Sarabun-SemiBold' },
+ semibold: {
+ fileName: 'Sarabun-SemiBold.ttf',
+ fontName: 'Sarabun-SemiBold',
+ },
},
modern: {
regular: { fileName: 'Prompt-Regular.ttf', fontName: 'Prompt-Regular' },
@@ -99,7 +107,9 @@ const PDF_FONT_FAMILIES: Record = {
}
export function getPdfFontFamily(fontId: string): PdfFontFamily {
- return PDF_FONT_FAMILIES[fontId] || PDF_FONT_FAMILIES[DEFAULT_SHEET_CONFIG.font]
+ return (
+ PDF_FONT_FAMILIES[fontId] || PDF_FONT_FAMILIES[DEFAULT_SHEET_CONFIG.font]
+ )
}
export function getAllPdfFonts(): FontVariant[] {
@@ -121,19 +131,22 @@ export function getGhostTextColor(index: number, total: number): RgbColor {
const opacity = getGhostOpacity(index, total)
return GHOST_BASE_COLOR.map((channel) =>
- Math.round(255 - (255 - channel) * opacity)
+ Math.round(255 - (255 - channel) * opacity),
) as RgbColor
}
export function getPdfLayout(doc: PdfDocLike, config: SheetConfig): PdfLayout {
const pageWidth = doc.internal.pageSize.getWidth()
const pageHeight = doc.internal.pageSize.getHeight()
- const rawCellSize = (FONT_SIZE_MAP[config.fontSize] || FONT_SIZE_MAP.medium).cellPx * PX_TO_PT
+ const rawCellSize =
+ (FONT_SIZE_MAP[config.fontSize] || FONT_SIZE_MAP.medium).cellPx * PX_TO_PT
const maxGridWidth = pageWidth - PDF_PAGE_MARGIN_X_PT * 2
const gridScale = Math.min(1, maxGridWidth / (rawCellSize * config.columns))
const cellSize = rawCellSize * gridScale
const glyphFontSize =
- (FONT_SIZE_MAP[config.fontSize] || FONT_SIZE_MAP.medium).text * PX_TO_PT * gridScale
+ (FONT_SIZE_MAP[config.fontSize] || FONT_SIZE_MAP.medium).text *
+ PX_TO_PT *
+ gridScale
return {
pageWidth,
@@ -160,5 +173,10 @@ export function getGridHeight(config: SheetConfig, layout: PdfLayout): number {
}
export function getBlockHeight(config: SheetConfig, layout: PdfLayout): number {
- return layout.labelRowHeight + layout.gridGapY + getGridHeight(config, layout) + layout.blockGapY
+ return (
+ layout.labelRowHeight +
+ layout.gridGapY +
+ getGridHeight(config, layout) +
+ layout.blockGapY
+ )
}
diff --git a/tsconfig.node.json b/tsconfig.node.json
index 53ee1ca..ddc75be 100644
--- a/tsconfig.node.json
+++ b/tsconfig.node.json
@@ -7,7 +7,7 @@
"moduleResolution": "bundler",
"composite": true,
"outDir": ".tsbuild/node",
- "tsBuildInfoFile": ".tsbuild/tsconfig.node.tsbuildinfo",
+ "tsBuildInfoFile": ".tsbuild/tsconfig.node.tsbuildinfo"
},
"include": ["vite.config.ts"]
}