W0 web foundation: Vite + React SPA under /app/, sync-pull ledger, auth, tokens, shared components
Build Kvitt images / build (push) Failing after 1h14m5s
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:
@@ -0,0 +1,36 @@
|
||||
// Fails when a committed generated file differs from what its source gives (part of npm run check):
|
||||
// src/api/schema.d.ts from ../contracts/openapi.yaml, src/i18n/{sv,en}.json, src/design/icons.generated.ts.
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const web = new URL('../', import.meta.url);
|
||||
let failed = false;
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'kvitt-web-api-'));
|
||||
try {
|
||||
const fresh = join(dir, 'schema.d.ts');
|
||||
execFileSync('npx', ['openapi-typescript', '../contracts/openapi.yaml', '-o', fresh], { cwd: web, stdio: 'pipe' });
|
||||
const committed = readFileSync(new URL('src/api/schema.d.ts', web), 'utf-8');
|
||||
if (readFileSync(fresh, 'utf-8') !== committed) {
|
||||
console.error('src/api/schema.d.ts is stale: run npm run api:types');
|
||||
failed = true;
|
||||
} else {
|
||||
console.log('src/api/schema.d.ts: up to date');
|
||||
}
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
for (const script of ['scripts/gen-i18n.mjs', 'scripts/gen-icons.mjs']) {
|
||||
try {
|
||||
execFileSync(process.execPath, [script, '--check'], { cwd: web, stdio: 'inherit' });
|
||||
console.log(`${script}: up to date`);
|
||||
} catch (error) {
|
||||
console.error(`${script} --check failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) process.exit(1);
|
||||
@@ -0,0 +1,60 @@
|
||||
// Builds src/i18n/{sv,en}.json from docs/ux/strings.csv (the app's strings, SPEC §13.4) and src/i18n/web-strings.csv
|
||||
// (web-only keys, prefix web_, same columns). Android placeholders become positional braces: %1$s and %1$d -> {1},
|
||||
// a bare %d or %s -> {1}, {2}, … in order of appearance, %% -> %. Strings marked formatted=false are copied as is.
|
||||
// Plurals keep their key:one / key:other keys. `--check` compares with the files on disk instead of writing.
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { parseCsvRecords } from '../src/lib/csv.ts';
|
||||
|
||||
const root = new URL('../', import.meta.url);
|
||||
const sources = [new URL('../docs/ux/strings.csv', root), new URL('src/i18n/web-strings.csv', root)];
|
||||
|
||||
export function convertPlaceholders(text, formatted) {
|
||||
if (!formatted) return text;
|
||||
let next = 0;
|
||||
return text.replace(/%(?:(\d+)\$)?([sd%])/g, (_match, index, type) => {
|
||||
if (type === '%') return '%';
|
||||
if (index) return `{${index}}`;
|
||||
next += 1;
|
||||
return `{${next}}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCatalogs() {
|
||||
const catalogs = { en: {}, sv: {} };
|
||||
for (const source of sources) {
|
||||
for (const record of parseCsvRecords(readFileSync(source, 'utf-8'))) {
|
||||
const key = record.key;
|
||||
if (!key) continue;
|
||||
if (key in catalogs.en) throw new Error(`duplicate string key ${key} in ${source.pathname}`);
|
||||
const formatted = !(record.notes ?? '').includes('formatted=false');
|
||||
catalogs.en[key] = convertPlaceholders(record.en, formatted);
|
||||
catalogs.sv[key] = convertPlaceholders(record.sv, formatted);
|
||||
}
|
||||
}
|
||||
const sorted = (obj) =>
|
||||
Object.fromEntries(
|
||||
Object.keys(obj)
|
||||
.sort()
|
||||
.map((k) => [k, obj[k]]),
|
||||
);
|
||||
return { en: sorted(catalogs.en), sv: sorted(catalogs.sv) };
|
||||
}
|
||||
|
||||
const check = process.argv.includes('--check');
|
||||
const catalogs = buildCatalogs();
|
||||
let stale = false;
|
||||
for (const lang of ['sv', 'en']) {
|
||||
const target = new URL(`src/i18n/${lang}.json`, root);
|
||||
const text = JSON.stringify(catalogs[lang], null, 2) + '\n';
|
||||
if (check) {
|
||||
const current = existsSync(target) ? readFileSync(target, 'utf-8') : '';
|
||||
if (current !== text) {
|
||||
console.error(`src/i18n/${lang}.json is stale: run npm run i18n:import`);
|
||||
stale = true;
|
||||
}
|
||||
} else {
|
||||
writeFileSync(target, text);
|
||||
console.log(`src/i18n/${lang}.json: ${Object.keys(catalogs[lang]).length} keys`);
|
||||
}
|
||||
}
|
||||
if (stale) process.exit(1);
|
||||
@@ -0,0 +1,190 @@
|
||||
// Builds src/design/icons.generated.ts from Material Symbols Rounded (design-spec §2.12; Apache-2.0):
|
||||
// outline = @material-symbols/svg-500 (FILL 0, weight 500), filled = @material-symbols/svg-600 (FILL 1, weight 600).
|
||||
// Only the icons listed here are embedded; a feature plan adds a name and runs `npm run icons:import`.
|
||||
// `--check` compares with the file on disk instead of writing.
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
/** The 33 category icon tokens of SPEC §9.7, in CategoryIcons.ALL order. Both variants (picker and tiles). */
|
||||
export const CATEGORY_ICONS = [
|
||||
'shopping_cart',
|
||||
'restaurant',
|
||||
'local_cafe',
|
||||
'home',
|
||||
'bolt',
|
||||
'water_drop',
|
||||
'wifi',
|
||||
'directions_car',
|
||||
'train',
|
||||
'local_gas_station',
|
||||
'flight',
|
||||
'hotel',
|
||||
'luggage',
|
||||
'movie',
|
||||
'music_note',
|
||||
'sports_esports',
|
||||
'fitness_center',
|
||||
'medical_services',
|
||||
'medication',
|
||||
'shopping_bag',
|
||||
'checkroom',
|
||||
'pets',
|
||||
'child_care',
|
||||
'school',
|
||||
'card_giftcard',
|
||||
'celebration',
|
||||
'build',
|
||||
'chair',
|
||||
'subscriptions',
|
||||
'category',
|
||||
'key',
|
||||
'shield',
|
||||
'account_balance',
|
||||
];
|
||||
|
||||
/** UI icons of design-spec §2.12 plus the web's own (search, keyboard, logout, language, theme). Outline variant. */
|
||||
const UI_OUTLINE = [
|
||||
'home',
|
||||
'bar_chart',
|
||||
'settings',
|
||||
'add',
|
||||
'close',
|
||||
'arrow_back',
|
||||
'more_vert',
|
||||
'edit',
|
||||
'delete',
|
||||
'check',
|
||||
'swap_horiz',
|
||||
'handshake',
|
||||
'cloud_upload',
|
||||
'cloud_off',
|
||||
'cloud_done',
|
||||
'sync',
|
||||
'sync_problem',
|
||||
'warning',
|
||||
'error',
|
||||
'info',
|
||||
'check_circle',
|
||||
'chevron_right',
|
||||
'expand_more',
|
||||
'expand_less',
|
||||
'inventory_2',
|
||||
'unarchive',
|
||||
'event',
|
||||
'call_split',
|
||||
'notes',
|
||||
'person',
|
||||
'apps',
|
||||
'backspace',
|
||||
'calculate',
|
||||
'date_range',
|
||||
'content_paste',
|
||||
'link',
|
||||
'dns',
|
||||
'person_add',
|
||||
'add_a_photo',
|
||||
'photo_library',
|
||||
'receipt_long',
|
||||
'schedule',
|
||||
'pause',
|
||||
'event_repeat',
|
||||
'drag_indicator',
|
||||
'restart_alt',
|
||||
'lock',
|
||||
'download',
|
||||
'refresh',
|
||||
'phonelink_erase',
|
||||
'smartphone',
|
||||
'block',
|
||||
'devices',
|
||||
'add_link',
|
||||
'key',
|
||||
'share',
|
||||
'content_copy',
|
||||
'memory',
|
||||
'open_in_new',
|
||||
'arrow_forward',
|
||||
'search',
|
||||
'keyboard',
|
||||
'logout',
|
||||
'language',
|
||||
'contrast',
|
||||
'lightbulb',
|
||||
];
|
||||
|
||||
/** Filled UI icons of design-spec §2.12 (selected nav, status, handshake, app mark). */
|
||||
const UI_FILLED = [
|
||||
'home',
|
||||
'bar_chart',
|
||||
'settings',
|
||||
'handshake',
|
||||
'cloud_done',
|
||||
'warning',
|
||||
'error',
|
||||
'check_circle',
|
||||
'receipt_long',
|
||||
'search',
|
||||
'lightbulb',
|
||||
];
|
||||
|
||||
/**
|
||||
* Spec token names that Material Symbols 0.48 ships under another file name (the glyph is the same). The spec's name
|
||||
* stays the key in the generated tables. The APK-update icons of S-17 are not embedded: the web has no APK update.
|
||||
*/
|
||||
const FILE_NAMES = {
|
||||
card_giftcard: 'redeem',
|
||||
expand_more: 'keyboard_arrow_down',
|
||||
expand_less: 'keyboard_arrow_up',
|
||||
phonelink_erase: 'mobile_cancel',
|
||||
smartphone: 'mobile',
|
||||
};
|
||||
|
||||
const root = new URL('../', import.meta.url);
|
||||
|
||||
function pathOf(pkg, file) {
|
||||
const url = new URL(`node_modules/@material-symbols/${pkg}/rounded/${file}.svg`, root);
|
||||
const svg = readFileSync(url, 'utf-8');
|
||||
if (!svg.includes('viewBox="0 -960 960 960"')) throw new Error(`${pkg}/${file}: unexpected viewBox`);
|
||||
const paths = [...svg.matchAll(/<path d="([^"]+)"\/>/g)].map((m) => m[1]);
|
||||
if (paths.length !== 1) throw new Error(`${pkg}/${file}: expected one path, found ${paths.length}`);
|
||||
return paths[0];
|
||||
}
|
||||
|
||||
function table(names, pkg, suffix) {
|
||||
const unique = [...new Set(names)].sort();
|
||||
return unique.map((name) => ` ${name}: '${pathOf(pkg, (FILE_NAMES[name] ?? name) + suffix)}',`).join('\n');
|
||||
}
|
||||
|
||||
const text = `// Generated by scripts/gen-icons.mjs (npm run icons:import). Do not edit.
|
||||
// Material Symbols Rounded, viewBox 0 -960 960 960; outline from @material-symbols/svg-500, filled from
|
||||
// @material-symbols/svg-600. Licence: Apache License 2.0 (Google).
|
||||
|
||||
export const OUTLINE = {
|
||||
${table([...CATEGORY_ICONS, ...UI_OUTLINE], 'svg-500', '')}
|
||||
} as const;
|
||||
|
||||
export const FILLED = {
|
||||
${table([...CATEGORY_ICONS, ...UI_FILLED], 'svg-600', '-fill')}
|
||||
} as const;
|
||||
|
||||
export const CATEGORY_ICON_TOKENS = [
|
||||
${CATEGORY_ICONS.map((n) => ` '${n}',`).join('\n')}
|
||||
] as const;
|
||||
|
||||
export type OutlineIconName = keyof typeof OUTLINE;
|
||||
export type FilledIconName = keyof typeof FILLED;
|
||||
export type CategoryIconToken = (typeof CATEGORY_ICON_TOKENS)[number];
|
||||
`;
|
||||
|
||||
const target = new URL('src/design/icons.generated.ts', root);
|
||||
if (process.argv.includes('--check')) {
|
||||
const current = existsSync(target) ? readFileSync(target, 'utf-8') : '';
|
||||
if (current !== text) {
|
||||
console.error('src/design/icons.generated.ts is stale: run npm run icons:import');
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
writeFileSync(target, text);
|
||||
console.log(
|
||||
`src/design/icons.generated.ts: ${new Set([...CATEGORY_ICONS, ...UI_OUTLINE]).size} outline, ${new Set([...CATEGORY_ICONS, ...UI_FILLED]).size} filled`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user