diff --git a/frontend/src/lib/reader/foliate.d.ts b/frontend/src/lib/reader/foliate.d.ts
new file mode 100644
index 0000000..cb52e99
--- /dev/null
+++ b/frontend/src/lib/reader/foliate.d.ts
@@ -0,0 +1,90 @@
+/**
+ * Hand-written types for the vendored foliate-js (src/lib/vendor/foliate-js).
+ *
+ * `$foliate` is a Vite-only alias, so TypeScript cannot resolve it and this
+ * ambient declaration is the only candidate — which is the point: svelte-check
+ * never walks the untyped vendored JS. Only the surface Chitai calls is typed.
+ * When you start using a new method, add it here rather than casting to `any`.
+ */
+declare module '$foliate/view.js' {
+ /** A TOC entry. `id` is assigned by foliate's `assignIDs`, not by the book. */
+ export interface FoliateTocItem {
+ id: number;
+ label: string;
+ href: string;
+ subitems?: FoliateTocItem[];
+ }
+
+ /** `event.detail` of the `relocate` event. Shape from SectionProgress.getProgress. */
+ export interface FoliateRelocateDetail {
+ /** Overall progress through the book, 0–1. Chitai stores this as `percentage`. */
+ fraction: number;
+ section: { current: number; total: number };
+ location: { current: number; next: number; total: number };
+ /** Estimated remaining reading time, in minutes. */
+ time: { section: number; total: number };
+ /** Null when the book has no TOC entry covering this position. */
+ tocItem?: FoliateTocItem | null;
+ pageItem?: { label: string; href: string } | null;
+ cfi: string;
+ range?: Range;
+ }
+
+ /** `event.detail` of the `load` event. `doc` is the section's iframe document. */
+ export interface FoliateLoadDetail {
+ doc: Document;
+ index: number;
+ }
+
+ export interface FoliateRenderer extends HTMLElement {
+ /**
+ * A single string sets the stylesheet appended to
, which wins over the
+ * book's own CSS on equal specificity. A [before, after] tuple also sets one
+ * prepended to , which the book's CSS overrides. Re-applied by the
+ * paginator on every section load. Absent on the fixed-layout renderer.
+ */
+ setStyles?(styles: string | [before: string, after: string]): void;
+ getContents(): { doc: Document; index: number }[];
+ destroy(): void;
+ }
+
+ export interface FoliateBook {
+ toc?: FoliateTocItem[];
+ /** 'rtl' for right-to-left books; drives goLeft/goRight. */
+ dir?: string;
+ metadata?: Record;
+ rendition?: { layout?: string };
+ }
+
+ /** Accepted by goTo/select/init: a CFI or href, a spine index, or a fraction. */
+ export type FoliateTarget = string | number | { fraction: number };
+
+ export class View extends HTMLElement {
+ book: FoliateBook;
+ renderer: FoliateRenderer;
+ /** True when the book is pre-paginated; the renderer is then foliate-fxl. */
+ isFixedLayout: boolean;
+ lastLocation: FoliateRelocateDetail | null;
+
+ open(book: File | string | FoliateBook): Promise;
+ close(): void;
+ init(opts: { lastLocation?: FoliateTarget | null; showTextStart?: boolean }): Promise;
+
+ /** Returns undefined on failure — it logs and swallows. Check before relying on it. */
+ resolveNavigation(target: FoliateTarget): { index: number; anchor?: unknown } | undefined;
+ goTo(target: FoliateTarget): Promise<{ index: number } | undefined>;
+ goToFraction(fraction: number): Promise;
+
+ prev(distance?: number): Promise;
+ next(distance?: number): Promise;
+ /** Direction-aware: inverts against prev/next for rtl books. */
+ goLeft(): Promise;
+ goRight(): Promise;
+ }
+
+ export function makeBook(file: File | string): Promise;
+
+ export class ResponseError extends Error {}
+ export class NotFoundError extends Error {}
+ export class UnsupportedTypeError extends Error {}
+}
diff --git a/frontend/src/lib/reader/foliate.ts b/frontend/src/lib/reader/foliate.ts
new file mode 100644
index 0000000..735c71f
--- /dev/null
+++ b/frontend/src/lib/reader/foliate.ts
@@ -0,0 +1,32 @@
+import type { View } from '$foliate/view.js';
+
+let pending: Promise | undefined;
+
+/**
+ * Loads foliate-js and registers its custom elements.
+ *
+ * view.js calls customElements.define() at module scope and subclasses
+ * HTMLElement, and its class fields construct DOM helpers eagerly — so importing
+ * it on the server throws. This module is safe to import anywhere because it
+ * touches nothing at module scope; the vendored code is only pulled in when
+ * loadFoliate() is called, which must be from onMount or a browser guard.
+ *
+ * The promise is cached so concurrent callers share one load. ESM already dedupes
+ * module evaluation; this mainly makes the "define runs once" contract explicit.
+ * Editing a file under src/lib/vendor invalidates the module and re-runs
+ * customElements.define, which throws — hard-refresh after re-vendoring.
+ */
+export function loadFoliate(): Promise {
+ return (pending ??= import('$foliate/view.js').then(() => undefined));
+}
+
+/**
+ * Creates a . Call only after loadFoliate() has resolved.
+ *
+ * Built imperatively rather than written in markup so SSR never emits an unknown
+ * element for Svelte to hydrate, and so svelte-check has no unknown attributes to
+ * complain about.
+ */
+export function createFoliateView(): View {
+ return document.createElement('foliate-view') as View;
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index a5567ee..114ad9f 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -10,7 +10,21 @@
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
- }
+ },
+ // `exclude` replaces the generated list rather than merging with it, so the
+ // service-worker entries from .svelte-kit/tsconfig.json are repeated here.
+ // src/lib/vendor is added: it is vendored third-party JS and checkJs is on,
+ // so without this svelte-check reports thousands of errors from foliate-js.
+ "exclude": [
+ "node_modules/**",
+ "src/service-worker.js",
+ "src/service-worker/**/*.js",
+ "src/service-worker.ts",
+ "src/service-worker/**/*.ts",
+ "src/service-worker.d.ts",
+ "src/service-worker/**/*.d.ts",
+ "src/lib/vendor/**"
+ ]
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 2d35c4f..ea0da9c 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -1,7 +1,23 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
+import { fileURLToPath } from 'node:url';
export default defineConfig({
- plugins: [tailwindcss(), sveltekit()]
+ plugins: [tailwindcss(), sveltekit()],
+ resolve: {
+ alias: {
+ // Deliberately a Vite-only alias, and deliberately not in kit.alias or
+ // tsconfig paths. TypeScript cannot resolve `$foliate/*`, so the ambient
+ // declaration in src/lib/reader/foliate.d.ts is the only candidate and
+ // svelte-check never walks the ~11k lines of untyped vendored JS.
+ $foliate: fileURLToPath(new URL('./src/lib/vendor/foliate-js', import.meta.url))
+ }
+ },
+ optimizeDeps: {
+ // Behind a dynamic import in foliate's fixed-layout.js, so Vite would
+ // otherwise discover it mid-session and force a full page reload the first
+ // time someone opens a fixed-layout EPUB.
+ include: ['construct-style-sheets-polyfill']
+ }
});