Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
fc890ab
fix: read display-only page data and props through $derived
KATO-Hiro Nov 2, 2026
cdcd6f8
docs: add the plan for fixing build, check, and lint warnings
KATO-Hiro Nov 2, 2026
52c1cf0
fix: keep dnd-kit options and component IDs in sync with props
KATO-Hiro Nov 2, 2026
a30a21c
docs: record the results of phases 2 and 3 for fixing build warnings
KATO-Hiro Nov 2, 2026
982cf47
fix: drop the redundant tabindex on the provisional-grade icon
KATO-Hiro Nov 2, 2026
dfeee10
docs: revise the a11y fix in the plan and record the phase 4 result
KATO-Hiro Nov 2, 2026
7e3769e
fix: seed Superforms from load data once with untrack
KATO-Hiro Nov 3, 2026
d10d82c
docs: record the phase 5 result in the build warnings plan
KATO-Hiro Nov 3, 2026
575516d
chore: fail check and lint on warnings
KATO-Hiro Nov 3, 2026
0d1cee0
docs: record the phase 6 result in the build warnings plan
KATO-Hiro Nov 3, 2026
c5d9386
chore: fail lint on ESLint warnings too
KATO-Hiro Nov 3, 2026
6f694d1
docs: rewrite the build warnings plan into decisions and findings
KATO-Hiro Nov 3, 2026
1f661ce
docs: condense the build warnings plan
KATO-Hiro Nov 3, 2026
e42566e
chore: ignore ESLint-ignored files passed by the pre-commit hook
KATO-Hiro Nov 3, 2026
4d31799
docs: add lessons from fixing build warnings to the agent rules
KATO-Hiro Nov 3, 2026
ca6628e
test: cover the provisional-grade tooltip and workbook create defaults
KATO-Hiro Nov 3, 2026
126431f
docs: record the added E2E tests and review outcome in the plan
KATO-Hiro Nov 3, 2026
1c457c3
docs: remove the build warnings plan
KATO-Hiro Nov 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ Run project tools with `pnpm exec`, never `pnpm dlx`: `dlx` downloads the latest

Lefthook runs oxfmt, oxlint for JS/TS, and ESLint for Svelte before commit.

`check` and `lint` fail on warnings: fix them or disable with a reason. oxlint skips `.svelte`, so set lint rules in ESLint too.

Formatting belongs to oxfmt alone. Do not add ESLint or oxlint rules whose `meta.type` is `layout` (e.g. `svelte/indent`), because they would conflict with the formatter.

## Verification and Cross-review Before a PR
Expand Down
2 changes: 2 additions & 0 deletions docs/guides/agent-rules/accessibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ When `{#if}` reveals a modal, dialog, or focus-trap region, move focus programma

Icon-only `<Button>` variants require an explicit `aria-label`; Flowbite Svelte does not generate one automatically. Omitting `color` only applies the `'primary'` default style — it has no effect on `aria-label`.

`Tooltip`/`Popover` already make their trigger focusable; no `tabindex` or `<button>` needed.

## Form Labels

Every `<input>`, `<select>`, and `<textarea>` must have an associated `<label>` (via `for`/`id`) or `aria-label`. Placeholder text does not substitute for a label.
1 change: 1 addition & 0 deletions docs/guides/agent-rules/coding-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ Before removing an import, grep the entire file for all usages — removing one
When removing a dependency or renaming a symbol, sweep the **whole repo**, not just `src`:

- `grep --include='*.{ts,svelte,md}'` does NOT brace-expand — it silently matches nothing. Use `rg -ni 'name' -g '*.ts' -g '*.svelte' -g '*.md'` instead.
- `rg` alternation is `a|b`, not `a\|b`.
- Include root files (CONTRIBUTING.md, README), `e2e/`, and config (`vite.config.ts`) — not only `src prisma .claude`.

## Documentation
Expand Down
2 changes: 2 additions & 0 deletions docs/guides/agent-rules/svelte-runes.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ paths:

- `let` captures initial value only; use `$derived` for props/server data
- Referencing `$props()` in `$state()` initializer? Wrap with `untrack` if intentional (seed-only)
- Superforms: `superForm(untrack(() => data.form))`, never `$derived`
- Pass reactive library options as getters: `get id() { return id; }`
- `pnpm check` warns: "This reference only captures the initial value"

## `SvelteMap<K, V>` — Always Type
Expand Down
19 changes: 19 additions & 0 deletions e2e/votes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const VOTES_LIST_URL = '/votes';
const TASKS_GRADE_URL = '/tasks/grade';
const KNOWN_TASK_ID = 'abc422_a'; // From prisma/tasks.ts seed data
const KNOWN_VOTE_DETAIL_URL = '/votes/abc422_a'; // From prisma/tasks.ts seed data
const PROVISIONAL_TASK_ID = 'zone2021_f'; // PENDING task with a median in prisma/seed.ts

// ---------------------------------------------------------------------------
// Votes list page (/votes)
Expand Down Expand Up @@ -51,6 +52,24 @@ test.describe('votes list page (/votes)', () => {
timeout: TIMEOUT,
});
});

test('provisional grade icon opens its tooltip on keyboard focus', async ({ page }) => {
await page.goto(VOTES_LIST_URL);
const searchInput = page.getByPlaceholder('問題名・問題ID・出典で検索');
await expect(searchInput).toBeVisible({ timeout: TIMEOUT });
await searchInput.fill(PROVISIONAL_TASK_ID);

const icon = page.getByRole('img', { name: '暫定グレード' });
const tooltip = page.getByRole('tooltip');
await expect(icon).toBeVisible({ timeout: TIMEOUT });
await expect(tooltip).toBeHidden();

// The markup has no tabindex; flowbite-svelte's Tooltip adds it to the trigger at runtime.
await expect(icon).toHaveAttribute('tabindex', '0');
await icon.focus();
await expect(icon).toBeFocused();
await expect(tooltip).toContainText('暫定グレード');
});
});

// ---------------------------------------------------------------------------
Expand Down
21 changes: 21 additions & 0 deletions e2e/workbook_create.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { test, expect } from '@playwright/test';

import { loginAsAdmin } from './helpers/auth';

const TIMEOUT = 60 * 1000;
const WORKBOOK_CREATE_URL = '/workbooks/create';
const ADMIN_USER_ID = '1'; // From prisma/users.ts seed data

test.describe('workbook create form (admin)', () => {
test('pre-fills the admin as author of an official curriculum workbook', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(WORKBOOK_CREATE_URL);

// Hidden inputs have no label, so select them by name.
await expect(page.locator('input[name="userId"]')).toHaveValue(ADMIN_USER_ID, {
timeout: TIMEOUT,
});
await expect(page.locator('input[name="isOfficial"]')).toHaveValue('true');
await expect(page.getByLabel('問題集の種類')).toHaveValue('CURRICULUM');
});
});
4 changes: 2 additions & 2 deletions lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ pre-commit:
glob: '**/*.{js,jsx,ts,tsx,md,svelte}'

- name: oxlint
run: pnpm exec oxlint {staged_files}
run: pnpm exec oxlint --deny-warnings {staged_files}
glob: '**/*.{js,jsx,ts,tsx}'

- name: eslint
run: pnpm exec eslint {staged_files}
run: pnpm exec eslint --max-warnings 0 --no-warn-ignored {staged_files}
glob: '**/*.svelte'

- name: plan-lambda-check
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
"build": "prisma generate && vite build",
"preview": "vite preview",
"test": "npm run test:e2e && npm run test:unit",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --fail-on-warnings",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "oxfmt --check && oxlint . && eslint .",
"lint": "oxfmt --check && oxlint --deny-warnings . && eslint --max-warnings 0 .",
"format": "oxfmt",
"test:e2e": "playwright test",
"test:unit": "vitest",
Expand Down
6 changes: 4 additions & 2 deletions prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -680,10 +680,12 @@ async function addAnswer(
});
}

// Demo data: tasks with VotedGradeStatistics so the ±0 badge is visible in the UI.
// grade matches the task's official grade → diff=0 → ±0 badge displayed.
// Demo data: tasks with VotedGradeStatistics so vote-based UI is visible.
// APG4bPython_co: grade matches the official grade → diff=0 → ±0 badge displayed.
// zone2021_f: PENDING task with a median → provisional grade icon displayed (used by E2E).
const VOTE_STATS_DEMO_DATA = [
{ id: 'demo-vote-stat-apg4b-co', taskId: 'APG4bPython_co', grade: 'Q8' },
{ id: 'demo-vote-stat-zone2021-f', taskId: 'zone2021_f', grade: 'D3' },
] as const;

async function addVoteStatisticsDemoData() {
Expand Down
7 changes: 4 additions & 3 deletions src/features/votes/components/VotableGrade.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,12 @@
// 表示用のグレード(投票後に画面リロードなしで差し替えるためのローカル状態)
// PENDING かつ estimatedGrade(集計済み中央値)があればそれを優先表示。
// DBグレード付与済みの場合はそちらを優先。
const initialGrade = resolveDisplayGrade(taskResult.grade, estimatedGrade);
let displayGrade = $state<TaskGrade | string>(initialGrade);
let displayGrade = $state<TaskGrade | string>(
untrack(() => resolveDisplayGrade(taskResult.grade, estimatedGrade)),
);

// Use task_id as a deterministic component ID to avoid SSR/hydration mismatches.
const componentId = taskResult.task_id;
const componentId = $derived(taskResult.task_id);

const editProfileHref = `${resolve(EDIT_PROFILE_PAGE)}?tab=atcoder`;

Expand Down
4 changes: 3 additions & 1 deletion src/features/workbooks/components/shared/WorkBookForm.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<script lang="ts">
import { untrack } from 'svelte';

import { Breadcrumb, BreadcrumbItem } from 'flowbite-svelte';

import HeadingOne from '#lib/components/HeadingOne.svelte';
Expand Down Expand Up @@ -38,7 +40,7 @@
submitButtonLabel = '',
}: Props = $props();

const { form, message, errors, enhance } = superFormObject;
const { form, message, errors, enhance } = untrack(() => superFormObject);

let workBookTasksForTable: WorkBookTasksCreate | WorkBookTasksEdit = $derived(
$form.workBookTasks
Expand Down
4 changes: 2 additions & 2 deletions src/lib/components/AuthForm.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { tick } from 'svelte';
import { tick, untrack } from 'svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';

Expand Down Expand Up @@ -40,7 +40,7 @@
alternativePageLink,
}: Props = $props();

const { form, message, errors, submitting, enhance } = formProperties;
const { form, message, errors, submitting, enhance } = untrack(() => formProperties);

const homeHref = resolve(HOME_PAGE);
const forgotPasswordHref = resolve(FORGOT_PASSWORD_PAGE);
Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/InputFieldWrapper.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
message = '',
}: Props = $props();

let isReadOnly = !isEditable;
let isReadOnly = $derived(!isEditable);
</script>

<Label class="flex flex-col gap-2">
Expand Down
4 changes: 2 additions & 2 deletions src/routes/(admin)/account_transfer/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { onMount, onDestroy, untrack } from 'svelte';
import { run } from 'svelte/legacy';

import { superForm } from 'sveltekit-superforms';
Expand Down Expand Up @@ -28,7 +28,7 @@

let { data }: Props = $props();

const { form, errors, message, submitting, enhance } = superForm(data.form);
const { form, errors, message, submitting, enhance } = superForm(untrack(() => data.form));

let accountTransferMessages: FloatingMessages = $state([]);

Expand Down
2 changes: 1 addition & 1 deletion src/routes/(admin)/tags/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import TagListForEdit from '#lib/components/TagListForEdit.svelte';

let { data } = $props();
let tags = data.tags;
let tags = $derived(data.tags);
</script>

<div class="container mx-auto w-5/6">
Expand Down
4 changes: 2 additions & 2 deletions src/routes/(admin)/tags/[tag_id]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@

let { data } = $props();

let tag = data.tag;
let tasks = data.tasks;
let tag = $derived(data.tag);
let tasks = $derived(data.tasks);
</script>

<div class="container mx-auto w-3/4">
Expand Down
4 changes: 2 additions & 2 deletions src/routes/(admin)/tasks/[task_id]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@

let { data } = $props();

let task = data.task;
let tags = data.tags;
let task = $derived(data.task);
let tags = $derived(data.tags);
</script>

<div class="container mx-auto w-5/6 md:w-1/2">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,13 @@
get id() {
return columnId;
},
type: group,
get type() {
return group;
},
collisionPriority: 1, // CollisionPriority.Low
accept: group,
get accept() {
return group;
},
});

function attachDroppable(node: HTMLElement): { destroy: () => void } {
Expand Down
4 changes: 2 additions & 2 deletions src/routes/problems/[slug]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@

let { data } = $props();

let taskResult = data.taskResult;
let buttons = data.buttons;
let taskResult = $derived(data.taskResult);
let buttons = $derived(data.buttons);
</script>

<!-- TODO: 回答状況は、一覧表のリストやテーブルから更新する -->
Expand Down
8 changes: 4 additions & 4 deletions src/routes/users/[username]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@

let { data } = $props();

let username = data.username;
let atcoder_username = data.atcoder_username;
let isLoggedIn = data.isLoggedIn;
let taskResults = data.taskResults;
let username = $derived(data.username);
let atcoder_username = $derived(data.atcoder_username);
let isLoggedIn = $derived(data.isLoggedIn);
let taskResults = $derived(data.taskResults);

const problemsHref = resolve(PROBLEMS_PAGE);
</script>
Expand Down
1 change: 0 additions & 1 deletion src/routes/votes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@
<span
id="flask-{task.task_id}"
class="cursor-help text-gray-500 dark:text-gray-400"
tabindex="0"
role="img"
aria-label="暫定グレード"
>
Expand Down
4 changes: 2 additions & 2 deletions src/routes/workbooks/[slug]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@

let { data } = $props();

let workBook = data.workBook;
let workBook = $derived(data.workBook);
let workBookTasks: WorkBookTaskBase[] = $state([]);
let taskResults: Map<string, TaskResult> = $derived(data.taskResults);
let voteStatisticsMap = $derived(data.voteStatisticsMap);

let isLoggedIn = data.isLoggedIn;
let isLoggedIn = $derived(data.isLoggedIn);
let isAtCoderVerified = $derived(data.isAtCoderVerified);

// TODO: 関数をutilへ移動させる
Expand Down
12 changes: 7 additions & 5 deletions src/routes/workbooks/create/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import { superForm } from 'sveltekit-superforms/client';

import type { Task } from '#lib/types/task.js';
Expand All @@ -8,22 +9,23 @@

let { data } = $props();

// superForm owns the form state after init, so seed it once from the load data.
// See:
// https://superforms.rocks/concepts/nested-data
const initialData = {
const initialData = untrack(() => ({
...data.form,
workBookTasks: [] as WorkBookTasksBase,
};
}));
const superFormObject = superForm(initialData, {
dataType: 'json',
});
const { form } = superFormObject;

$form.authorId = data.author.id;
$form.isOfficial = data.isAdmin;
$form.authorId = untrack(() => data.author.id);
$form.isOfficial = untrack(() => data.isAdmin);
$form.workBookType = $form.isOfficial ? WorkBookType.CURRICULUM : WorkBookType.CREATED_BY_USER;

const tasksMapByIds: Map<string, Task> = data.tasksMapByIds;
const tasksMapByIds: Map<string, Task> = $derived(data.tasksMapByIds);
</script>

<WorkBookForm
Expand Down
10 changes: 6 additions & 4 deletions src/routes/workbooks/edit/[slug]/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import { superForm } from 'sveltekit-superforms/client';

import ErrorMessageAndReturnButton from '#lib/components/Messages/ErrorMessageAndReturnButton.svelte';
Expand All @@ -13,20 +14,21 @@
let { data } = $props();
let canView = $derived(data.status === FORBIDDEN ? false : true);

let workBook = data.workBook;
let workBook = $derived(data.workBook);

// superForm owns the form state after init, so seed it once from the load data.
// See:
// https://superforms.rocks/concepts/nested-data
const initialData = {
const initialData = untrack(() => ({
...data.form,
workBookTasks: workBook.workBookTasks as WorkBookTasksBase,
};
}));
const superFormObject = superForm(initialData, {
dataType: 'json',
});

// Create data of workbook for edit based on database.
const tasksMapByIds: Map<string, Task> = data.tasksMapByIds;
const tasksMapByIds: Map<string, Task> = $derived(data.tasksMapByIds);
</script>

{#if canView}
Expand Down
Loading