# Chitai frontend

SvelteKit web app for the eBook library. See the repo-root `AGENTS.md` for the overall picture and
dev-environment setup.

**Stack:** SvelteKit 2 with `adapter-node` · Svelte 5 (runes) · Tailwind v4 · Zod v4 ·
vendored `foliate-js` (EPUB) · vendored `pdf.js` (PDF) · `mode-watcher` (dark mode) ·
`svelte-sonner` (toasts) · pnpm.

Two experimental flags are on in `svelte.config.js` and the codebase depends on both:
`kit.experimental.remoteFunctions` and `compilerOptions.experimental.async` (`await` in components).

Tailwind v4 has **no config file** — the theme, colour tokens (hex, not oklch) and
`@custom-variant dark` all live in `src/app.css`. `dark` is the _only_ custom variant defined, so
generated components that assume others — shadcn's slider ships `data-horizontal:` / `data-vertical:`
classes — silently produce no styles. Use the `data-[orientation=…]` form instead.

## Talking to the backend

There are two mechanisms; pick deliberately.

**1. Remote functions — the default.** `src/lib/api/*.remote.ts` export `query` / `command` / `form`
functions from `$app/server`. Each takes a Zod schema from `$lib/schema` as its validator and runs
on the server, reaching the API through `locals.api` (the `ApiClient` in `src/lib/server/api.ts`):

```ts
export const getBook = query(stringCoerce, async (id): Promise<Book> => {
	const { locals } = getRequestEvent();
	const response = await locals.api.get(`/books/${id}`);
	if (!response.ok) error(response.status === 404 ? 404 : 500, '…');
	return await response.json();
});
```

Conventions: build query strings with `createQueryParams` from `$lib/utils`; on a failed response
throw SvelteKit's `error(status, message)`; multipart uploads go through `postMultipart` /
`putMultipart`. Re-export new modules from `src/lib/api/index.ts`.

**2. The catch-all proxy** at `src/routes/api/[...path]/+server.ts` forwards GET/POST/PATCH/DELETE
to the backend with the auth header attached. Use it only where the **browser itself** must fetch
the backend — e.g. streaming a book file into the EPUB/PDF reader. It is not the general-purpose
path.

## Auth

`src/hooks.server.ts` is a `sequence` of two handles: the first reads the `authToken` cookie,
constructs an `ApiClient`, validates it with `GET /access/me` and fills `locals.user` /
`locals.authToken` / `locals.api` (clearing the cookie if invalid); the second redirects any route
outside `/login` to the login page when there is no user.

The cookie is set in `src/lib/api/auth.remote.ts` (`login`) — httpOnly, secure, sameSite strict, one
week — and deleted by `logout`. The backend JWT never reaches client-side JS.

## Schemas

- `src/lib/schema/*.ts` — hand-written Zod schemas, used as remote-function input validators and as
  the source of the exported TS types. Mirror `backend/src/chitai/schemas/` when the API changes.
- `src/lib/schema/common.ts` — shared building blocks: `stringCoerce` / `arrayCoerce` coercion
  helpers, `PaginatedResponse<T>`, and the pagination / search / order query schemas that most list
  endpoints compose from.
- `src/lib/schema/openapi/schema.d.ts` — **generated** from the backend's OpenAPI document with
  `openapi-typescript`. Never hand-edit; regenerate after backend API changes.

## State

Client state lives in classes in `src/lib/state/*.svelte.ts` using `$state` / `$derived`, shared via
Svelte context with a module-level `Symbol` key and a `setXState` / `getXState` pair:

```ts
const LIBRARY_KEY = Symbol('LIBRARY');
export function setLibraryState(libraries: Library[]) {
	return setContext(LIBRARY_KEY, new LibraryState(libraries));
}
export function getLibraryState() {
	return getContext<ReturnType<typeof setLibraryState>>(LIBRARY_KEY);
}
```

Follow that pattern rather than introducing stores. `library.svelte.ts` is the reference — including
its optimistic-delete-with-rollback and toast handling. `bookCollection` / `bookSelection` /
`bookOperations` split list data, selection and mutations across three cooperating classes.

## Components

- `src/lib/components/ui/` — vendored shadcn-svelte (`components.json`) plus jsrepo blocks from
  `@ieedan/shadcn-svelte-extras` (`jsrepo.json`). Treat as generated: add components with the CLIs
  rather than hand-writing them, and prefer wrapping over editing.
- App components live in `forms/`, `layout/`, `view/` (browser, grid/list/table, filters, sort) and
  `reader/` (see [The readers](#the-readers)).
- `cn()` from `$lib/utils` merges Tailwind classes; the `WithElementRef` / `WithoutChild` helpers
  there are the shadcn prop-typing conventions.

## The readers

**PDF** is the pdf.js viewer vendored under `static/pdfjs/`, pointed at by an iframe. Untouched by
the EPUB work; leave it alone unless the task is about PDFs.

**EPUB** is built on `foliate-js`, copied verbatim into `src/lib/vendor/foliate-js/` by
`scripts/vendor-foliate.sh` (pinned commit; see `src/lib/vendor/foliate-js/README.chitai.md`).
Upstream has no npm release and recommends a submodule; this repo has none and already vendors
pdf.js the same way, so it is copied instead. Only the import closure reachable from `view.js` is
vendored, and **`pdf.js` in that directory is our stub, not upstream's** — the real one imports a
bare `@pdfjs/pdf.min.mjs` that Rollup resolves at build time even though the path never runs.

Layout:

| Path                                       | What                                                                         |
| ------------------------------------------ | ---------------------------------------------------------------------------- |
| `lib/vendor/foliate-js/`                   | The engine. Do not edit — `vendor-foliate.sh` overwrites it.                 |
| `lib/reader/foliate.ts`                    | Lazy loader for the custom elements. The only thing that imports `$foliate`. |
| `lib/reader/settings.ts` · `stylesheet.ts` | Defaults/bounds, and the CSS injected into the book.                         |
| `lib/reader/progress.ts`                   | Debounced progress writer with a `sendBeacon` flush.                         |
| `lib/state/reader-settings.svelte.ts`      | Settings state, persisted to `localStorage`.                                 |
| `components/reader/foliate-view.svelte`    | Wraps `<foliate-view>`; owns the imperative lifecycle.                       |
| `components/reader/epub-reader.svelte`     | The shell: chrome, TOC, errors, progress.                                    |

Things that will bite:

- **`$foliate` is a Vite-only alias.** It is deliberately absent from `kit.alias` and tsconfig
  `paths` so TypeScript cannot resolve it and falls back to the ambient declaration in
  `lib/reader/foliate-js.d.ts`; `src/lib/vendor` is also in tsconfig `exclude`. Without both,
  `checkJs` walks ~11k lines of untyped JS. The declaration file must **not** be named `foliate.d.ts`
  — beside `foliate.ts`, TypeScript takes it for that file's emitted declaration and drops it.
- **Never import the vendored code at module scope.** `view.js` calls `customElements.define` and
  subclasses `HTMLElement` on import, so it must stay behind `loadFoliate()` inside `onMount`. SSR is
  otherwise on for the reader route.
- **Sections render in iframes, which swallow key events.** Keyboard handlers are bound per section
  document on the `load` event, and modifier combinations are replayed onto the host window so app
  shortcuts (the sidebar's ctrl+B) still work while reading.
- **Renderer settings split two ways.** Flow, gap, margins, column count and line width are
  _attributes_ set with `setAttribute` (there is no JS property API, no `margin` shorthand and no
  `spread` — a spread is `max-column-count: 2`). Typography is CSS passed to `renderer.setStyles`,
  which takes a `[before, after]` pair: the first is prepended to the section head so the book
  overrides it, the second appended so it wins. User settings belong in the second, with
  `!important`, or the book's own CSS beats them.
- **Progress needs no locations pre-pass.** `relocate` carries both a CFI and an overall `fraction`,
  which map straight onto `epub_cfi` and `percentage`.

## Routing

Route groups carry the layout structure:

- `(root)` — the authenticated shell (sidebar, header); `+layout.server.ts` loads the libraries.
- `(root)/(library)` — library-scoped pages: `library/[libraryId]/view`, `book/[bookId]`, edit, and
  the readers at `book/[bookId]/read/{epub,pdf}/[fileId]`.
- `+layout@.svelte` breakouts reset to the root layout for the login page and the full-screen reader.

## Conventions

Prettier (`.prettierrc`): tabs, single quotes, no trailing commas, 100 columns, with the Svelte and
Tailwind plugins. Run `pnpm check` (svelte-check) and `pnpm lint` before considering work done —
but take a baseline first, because neither is clean (see below).

`src/lib/vendor/` is excluded from Prettier, ESLint and svelte-check. Don't reformat vendored code.

## Known rough edges

Observed in the current tree — don't mistake these for intentional patterns to copy:

- `pnpm check` is not clean. **Baseline as of 2026-08-13: 30 errors, 1 warning, 8 files**, most of
  them in `src/routes/api/[...path]/+server.ts` (see below). Get your own baseline before assuming
  an error is yours. `src/lib/schema/openapi/schema.d.ts` was regenerated on that date and is
  current; regenerate it again after any backend API change, with
  `pnpm exec openapi-typescript http://localhost:8000/schema/openapi.json -o src/lib/schema/openapi/schema.d.ts`
  against a backend running **your** branch — a stale server silently writes a stale file.
- **Prettier is clean and CI blocks on it** — run `pnpm format` before finishing. Two things it
  must not touch are in `.prettierignore`: the vendored foliate-js, and
  `src/lib/schema/openapi/schema.d.ts`, which `openapi-typescript` regenerates in its own style.
- `pnpm exec eslint .` reports **87 pre-existing errors as of 2026-08-17**, all in `src/`. CI runs
  it non-blocking (`continue-on-error`) until that reaches zero. `static/pdfjs/` is ignored
  alongside `src/lib/vendor/` — it is vendored too, and linting it produced 1717 further errors.
- `src/routes/api/[...path]/+server.ts` — all four handlers are annotated `RequestHandler` while the
  import of that type is commented out at line 4. It also buffers whole **responses** with
  `arrayBuffer()` and forwards no `Range` header, so book downloads are not streamed. **Requests**
  are streamed — POST and PATCH pass `request.body` through with `duplex: 'half'` (see `bodyOf`),
  because a zipped Calibre library upload cannot be held in this process. The response side is
  still buffered; see `TODO.md`.
- `src/app.d.ts` — `App.Locals["user"]` is typed from `lucide-svelte`'s `User` _icon_ component
  rather than the `User` interface in `$lib/server/auth`.
- No CSP, which foliate's README asks for because EPUBs can carry scripts. See `TODO.md` for why it
  is not enabled yet.
