W0 web foundation: Vite + React SPA under /app/, sync-pull ledger, auth, tokens, shared components
Build Kvitt images / build (push) Failing after 1h14m5s

web/ is the browser client's scaffold: Vite 8, React 19, TypeScript 5.9 strict, React Router 7 (basename /app, every
fixed route with a lazy placeholder page), TanStack Query 5 (refetch on focus and every 60 s), openapi-typescript
types generated from contracts/openapi.yaml with openapi-fetch. Reads go through GET /sync (no REST reads exist for
the ledger, SPEC §7): useLedger() pulls from cursor 0 and then incrementally, restarting on a new epoch or
cursor_ahead. Login pairs the browser as "Web · <browser>" with an invite code (E-3); every call sends the SPEC
§14.3.1 headers, a 401 signs out. kvitt-domain's split, balances, allocation and settlement rules are ported to
web/src/lib and pass every case of contracts/test-vectors. Design tokens from design-spec §2 in tokens.css
(light-dark()), Material Symbols Rounded (Apache-2.0) as generated inline SVG, sv/en catalogs generated from
docs/ux/strings.csv plus web-strings.csv. Gate: cd web && npm run check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-21 21:00:03 +02:00
parent 3fbd393082
commit 2ae65a5a9f
154 changed files with 15651 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
/**
* The typed API client (openapi-fetch over the generated `schema.d.ts`). Same origin as the SPA (`/me`, `/sync`, …).
* Every call carries the headers of SPEC §14.3.1: `X-Kvitt-Protocol: 1`, `X-Kvitt-App-Version`,
* `X-Kvitt-App-Version-Name`, `Accept-Language`, and `Authorization: Bearer <token>` while signed in. A 401 signs the
* session out (`unauthorized` or `device_revoked`), which sends the router to /app/login.
*/
import createClient, { type Client } from 'openapi-fetch';
import { BUILD } from '../app/buildInfo';
import type { Language } from '../i18n/language';
import type { paths } from './schema';
import type { SessionStore } from './session';
import type { ErrorBody, ErrorCode } from './types';
/** The protocol version this client speaks (SPEC §14.4). */
export const PROTOCOL_VERSION = '1';
/** The operations' header parameters are set by the middleware below, so callers never pass them. */
type WithoutHeaderParams<Operation> = Operation extends { parameters: infer Params }
? Omit<Operation, 'parameters'> & { parameters: Omit<Params, 'header'> & { header?: never } }
: Operation;
export type ApiPaths = {
[Path in keyof paths]: { [Method in keyof paths[Path]]: WithoutHeaderParams<paths[Path][Method]> };
};
export type Api = Client<ApiPaths>;
export interface ApiOptions {
/** The origin the SPA is served from; the API lives at its root (OpenAPI `servers`). */
baseUrl: string;
session: SessionStore;
language: () => Language;
fetch?: (request: Request) => Promise<Response>;
}
/** An API failure: an error body of SPEC §14.3.2, or `network` when the server could not be reached. */
export class ApiError extends Error {
constructor(
readonly status: number,
readonly code: ErrorCode | 'network' | 'unexpected',
readonly field: string | null,
message: string,
readonly retryAfterSeconds: number | null = null,
) {
super(message);
this.name = 'ApiError';
}
}
function isErrorBody(value: unknown): value is ErrorBody {
return typeof value === 'object' && value !== null && typeof (value as { code?: unknown }).code === 'string';
}
async function errorCodeOf(response: Response): Promise<string | null> {
try {
const body: unknown = await response.clone().json();
return isErrorBody(body) ? body.code : null;
} catch (error) {
if (error instanceof SyntaxError || error instanceof TypeError) return null;
throw error;
}
}
export function createApi(options: ApiOptions): Api {
const client = createClient<ApiPaths>({ baseUrl: options.baseUrl, fetch: options.fetch });
client.use({
onRequest({ request }) {
request.headers.set('X-Kvitt-Protocol', PROTOCOL_VERSION);
request.headers.set('X-Kvitt-App-Version', String(BUILD.versionCode));
request.headers.set('X-Kvitt-App-Version-Name', BUILD.versionName);
request.headers.set('Accept-Language', options.language());
const session = options.session.get();
if (session !== null) request.headers.set('Authorization', `Bearer ${session.token}`);
return request;
},
async onResponse({ request, response }) {
if (response.status === 401 && request.headers.has('Authorization')) {
const code = await errorCodeOf(response);
options.session.signOut(code === 'device_revoked' ? 'device_revoked' : 'unauthorized');
}
return response;
},
});
return client;
}
interface Outcome<D> {
data?: D;
error?: unknown;
response: Response;
}
async function settle<D>(pending: Promise<Outcome<D>>): Promise<Outcome<D>> {
try {
return await pending;
} catch (cause) {
if (cause instanceof DOMException && cause.name === 'AbortError') throw cause;
throw new ApiError(0, 'network', null, cause instanceof Error ? cause.message : 'network error');
}
}
function failure(outcome: Outcome<unknown>): ApiError {
const { response, error } = outcome;
const retryAfter = response.headers.get('Retry-After');
const retryAfterSeconds = retryAfter === null ? null : Number(retryAfter);
if (isErrorBody(error)) {
return new ApiError(response.status, error.code, error.field, error.message, retryAfterSeconds);
}
return new ApiError(response.status, 'unexpected', null, `HTTP ${response.status}`, retryAfterSeconds);
}
/** The response body of a successful call; throws ApiError otherwise. */
export async function unwrap<D>(pending: Promise<Outcome<D>>): Promise<D> {
const outcome = await settle(pending);
if (!outcome.response.ok) throw failure(outcome);
if (outcome.data === undefined) throw new ApiError(outcome.response.status, 'unexpected', null, 'empty body');
return outcome.data;
}
/** For calls answered with 204 No Content; throws ApiError on failure. */
export async function unwrapEmpty(pending: Promise<Outcome<unknown>>): Promise<void> {
const outcome = await settle(pending);
if (!outcome.response.ok) throw failure(outcome);
}