diff --git a/.claude/rules/svelte-components.md b/.claude/rules/svelte-components.md index 028cf1fc8..c3e93a773 100644 --- a/.claude/rules/svelte-components.md +++ b/.claude/rules/svelte-components.md @@ -154,3 +154,46 @@ const map: Partial>> = { }; // Safe to guard with: {#if map[type]} or if (map[type]) ``` + +## Props — Pass Domain Model Objects; Derive Computed Values Internally + +When a component's data comes from a domain model, pass the model object as a single prop +rather than individual fields. This keeps the call site in sync with the model automatically. + +Computed values (status, labels, flags derived from multiple fields) must NOT be props — +they belong inside the component as `$derived`. + +```typescript +// Bad: individual fields + derived value as prop +interface Props { + handle: string; + validationCode: string; + isValidated: boolean; + status: string; // derived — should not be a prop +} + +// Good: model object as prop; status derived inside +// (username is from User model; atCoderAccount is from a separate domain model — two props is correct here) +interface Props { + username: string; + atCoderAccount: { handle: string; validationCode: string; isValidated: boolean }; +} +let { username, atCoderAccount }: Props = $props(); +const status = $derived(atCoderAccount.isValidated ? 'validated' : ...); +``` + +Call site passes the object directly from `$derived(data.atCoderAccount)`. + +## $derived for data.\* Fields in +page.svelte + +When reading fields from `data` in a `+page.svelte`, use `$derived` rather than plain assignment: + +```typescript +// Bad: stale after load() re-runs following a form action +const atCoderAccount = data.atCoderAccount; + +// Good: stays in sync when SvelteKit re-runs load() after an action +const atCoderAccount = $derived(data.atCoderAccount); +``` + +`data` is a reactive prop that SvelteKit updates after each form action. A plain assignment captures the initial value only. diff --git a/.claude/rules/sveltekit.md b/.claude/rules/sveltekit.md index d7f8bf00b..058816523 100644 --- a/.claude/rules/sveltekit.md +++ b/.claude/rules/sveltekit.md @@ -76,3 +76,25 @@ The same pattern applies to `url.searchParams.get()` in `+server.ts` handlers. ## Page Component Props SvelteKit page components (`+page.svelte`) accept only `data` and `form` as props (`svelte/valid-prop-names-in-kit-pages`). Commented-out features that reference other props are not "dead code" — remove only the violating prop declaration, preserve the feature code. + +## load() — Group Related Model Fields as Objects + +When a `load()` function returns fields from the same domain model (e.g., `AtCoderAccount`), +group them as an object rather than flattening to top-level keys. +Apply default values at this boundary so the page component typically does not need to handle `undefined`. + +```typescript +// Bad: flat, scattered across top-level keys +atcoder_username: user?.atCoderAccount?.handle ?? '', +atcoder_validationcode: user?.atCoderAccount?.validationCode ?? '', +is_validated: user?.atCoderAccount?.isValidated ?? false, + +// Good: grouped by model, defaults absorbed here +atCoderAccount: { + handle: user?.atCoderAccount?.handle ?? '', + validationCode: user?.atCoderAccount?.validationCode ?? '', + isValidated: user?.atCoderAccount?.isValidated ?? false, +}, +``` + +When consuming in `+page.svelte`, use `$derived` to maintain reactivity across load() re-runs after form actions (see svelte-components.md). diff --git a/.claude/rules/testing-e2e.md b/.claude/rules/testing-e2e.md new file mode 100644 index 000000000..9c394361a --- /dev/null +++ b/.claude/rules/testing-e2e.md @@ -0,0 +1,122 @@ +--- +description: E2E testing rules and patterns (Playwright) +paths: + - '**/*.spec.ts' + - 'e2e/**' +--- + +# E2E Tests (Playwright) + +## No Path Aliases + +The `e2e/` directory is outside SvelteKit's build pipeline — `$lib`, `$features`, and other path aliases are not resolved. Define string constant values as local constants with a reference comment: + +```typescript +// Mirrors WorkBookTab.SOLUTION from $features/workbooks/types/workbook +const TAB_SOLUTION = 'solution'; +``` + +Avoid importing values from `src/` in E2E test files. Type-only imports (`import type`) are acceptable since they are erased at compile time: + +```typescript +// Bad: runtime import — path alias not resolved in e2e/ +import { TAB_SOLUTION } from '$features/workbooks/types/workbook'; + +// Good: type-only import — compile-time only +import type { WorkBookTab } from '$features/workbooks/types/workbook'; +const TAB_SOLUTION: WorkBookTab = 'solution'; +``` + +## Describe Hierarchy + +When a `describe` block for a user role grows large, split it by behavioral dimension rather than adding more flat `test()` calls: + +```typescript +test.describe('logged-in user', () => { + test.describe('tab visibility', () => { ... }); + test.describe('URL parameter handling', () => { ... }); + test.describe('navigation interactions', () => { ... }); + test.describe('session state', () => { ... }); +}); +``` + +## Parameterized Tests + +Playwright has no native `test.each`. Use `for...of` loops — the official recommended pattern. + +**Single test with a loop** — use when testing a sequence or workflow within one test: + +```typescript +// Mirrors TaskGrade from $lib/types/task — do not import from src/ in E2E files +const GRADES = ['Q10', 'Q9', 'Q8'] as const; + +for (const grade of GRADES) { + await gradeButton(page, grade).click(); + await expect(page).toHaveURL(`?grades=${grade}`); +} +``` + +**Multiple tests from parameters** — use when each parameter represents an independent case: + +```typescript +const GRADES = ['Q10', 'Q9', 'Q8'] as const; + +for (const grade of GRADES) { + test(`filters by grade ${grade}`, async ({ page }) => { + await page.goto('/tasks'); + await gradeButton(page, grade).click(); + await expect(page).toHaveURL(`?grades=${grade}`); + }); +} +``` + +## Assertions + +After an interaction that changes element state (active tab, toggle, selection), assert the _new_ state — not just that the element is visible, which may have been true before the interaction. Assert an active CSS class, `aria-selected`, or similar attribute instead of `toBeVisible()`. + +## Flowbite Toggle + +Flowbite's `Toggle` renders an `sr-only` `` inside a `