client_v2: add ESLint + wire lint/format/typecheck into lefthook

client_v2 has matured, so enforce its checks like the old client:

- Add ESLint (typescript-eslint + eslint-plugin-svelte) with a flat
  config; `lint` now runs prettier --check and eslint.
- Add a "Client v2" group to lefthook pre-commit and ci: eslint,
  prettier, and svelte-check type checking (svelte-kit sync first).
- Exclude generated paraglide output from prettier.

Fix the linting warnings this surfaced: drop dead vars and an unused
svelte-ignore, mark intentional reactive reads with `void`, remove a
useless children snippet, and clean up useless string-literal mustaches.

Disable svelte/no-navigation-without-resolve (base-path resolution is
centralized in params.ts helpers, so the rule only false-positives and
wrapping call sites would double-apply the base path). Keep
svelte/prefer-svelte-reactivity on with per-line disables at the
transient-local Set/Map sites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
DonkieandClaude Opus 4.8 committed 2026-07-23 10:23:17 +02:00
1 parent 52b0580df9
commit 5eb660f420
21 files changed
+1728 -59

No files matched your search

+3
View File
@@ -6,3 +6,6 @@ build
.svelte-kit
dist
node_modules
# Generated code (paraglide i18n compiler output — also gitignored)
src/lib/paraglide
+2 -2
View File
@@ -82,7 +82,7 @@ Everything viewed is subscribed to and live-updates. The seam is `api/live.ts`
- **`inventory`** is a reactive normalized **cache**. `api/liveSync.ts` runs one
central subscription per resource and applies remote events to it, so every
cache-backed view (inspectors, locations) is live automatically.
- The **paginated list** fetches server-paged data *outside* the cache, so it
- The **paginated list** fetches server-paged data _outside_ the cache, so it
keeps its **own** subscription and refetches the current page on relevant
events.
- Local edits patch the cache **and** emit a `local` event (mirroring the server
@@ -101,7 +101,7 @@ CSS custom properties in `src/app.css`.
- **Desktop** — two panes: the filament/spool list on the left, a live inspector
on the right.
- **Mobile** — the list becomes full-width and front-and-centre; selecting an item
slides the *same* inspector up as a bottom-sheet **Drawer**
slides the _same_ inspector up as a bottom-sheet **Drawer**
(`src/lib/components/Drawer.svelte`). The top-bar search/nav reflow onto their
own rows and the "Add spools" button becomes a FAB.
+55
View File
@@ -0,0 +1,55 @@
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import prettier from 'eslint-config-prettier';
import globals from 'globals';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
export default ts.config(
// Generated, tooling-managed, and build-output directories are not linted.
{
ignores: [
'build/',
'.svelte-kit/',
'dist/',
'package/',
'src/lib/paraglide/', // auto-generated by inlang paraglide-js
'project.inlang/', // inlang-managed
'locales/' // Weblate-managed translation files
]
},
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node }
},
rules: {
// Typed as `no-undef`; TypeScript already reports undefined identifiers.
'no-undef': 'off',
// Base-path resolution is centralized in src/lib/library/params.ts helpers
// (selectHref/libraryHref/etc. already call resolve() from $app/paths), and
// generic components take a pre-resolved href prop. This rule can't see through
// those helpers, so it only produces false positives here — and blindly wrapping
// call sites in resolve() would double-apply the deploy base path.
'svelte/no-navigation-without-resolve': 'off'
// svelte/prefer-svelte-reactivity is left ON to guard against in-place mutation of
// reactive Set/Map. The current hits are all transient computation-local collections
// and are silenced with per-line eslint-disable comments at each site.
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig
}
}
}
);
+1586
View File
File diff suppressed because it is too large. Load diff
+8 -1
View File
@@ -13,19 +13,26 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check ."
"lint": "prettier --check . && eslint .",
"lint:eslint": "eslint ."
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"@inlang/paraglide-js": "^2.18.2",
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.8.1",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@types/qrcode": "^1.5.6",
"eslint": "^9.39.5",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.22.0",
"globals": "^16.5.0",
"prettier": "^3.3.3",
"prettier-plugin-svelte": "^3.2.7",
"svelte": "^5.1.9",
"svelte-check": "^4.0.5",
"typescript": "^5.6.3",
"typescript-eslint": "^8.65.0",
"vite": "^5.4.10"
},
"dependencies": {
+1 -1
View File
@@ -37,4 +37,4 @@
"lt",
"tr"
]
}
}
@@ -162,6 +162,7 @@
]).then(([vn, localMats, extMats]) => {
vendorNames = vn;
const specs: typeof materialSpecs = {};
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient local, not reactive state
const names = new Set<string>(localMats);
// Seed with the built-in common materials first…
for (const m of COMMON_MATERIALS) {
@@ -388,9 +389,6 @@
}
}
let targetName = $derived(
creating ? nf.name.trim() || m['add.newFilament']() : chosen ? cName(chosen) : ''
);
let summary = $derived(chosen || creating ? m['add.summary']({ count: countN }) : '');
// --- validation ---------------------------------------------------------
@@ -520,11 +518,11 @@
{#each externalResults as ext (ext.id)}
<button class="res-item" onclick={() => choose({ source: 'external', ext })}>
<Swatch
colors={externalColors(ext)}
direction={externalDirection(ext)}
size={18}
radius={5}
/>
colors={externalColors(ext)}
direction={externalDirection(ext)}
size={18}
radius={5}
/>
<div class="res-name">
<span class="rn">{ext.name}</span>
<span class="rs">{ext.manufacturer} · {ext.material}</span>
@@ -9,13 +9,7 @@
let { value, placeholder = '—', mono = false, oninput }: Props = $props();
</script>
<input
class="edit"
class:mono
{value}
{placeholder}
oninput={(e) => oninput?.(e.currentTarget.value)}
/>
<input class="edit" class:mono {value} {placeholder} oninput={(e) => oninput?.(e.currentTarget.value)} />
<style>
.edit {
+10 -2
View File
@@ -58,7 +58,11 @@
{#if pageCount > 1}
<div class="nums">
{#if hrefFor && page > 1}
<a class="pg nav" href={hrefFor(page - 1)} data-sveltekit-keepfocus data-sveltekit-noscroll
<a
class="pg nav"
href={hrefFor(page - 1)}
data-sveltekit-keepfocus
data-sveltekit-noscroll
aria-label={m['pagination.prev']()}><ChevronLeft size={16} /></a
>
{:else}
@@ -79,7 +83,11 @@
{/if}
{/each}
{#if hrefFor && page < pageCount}
<a class="pg nav" href={hrefFor(page + 1)} data-sveltekit-keepfocus data-sveltekit-noscroll
<a
class="pg nav"
href={hrefFor(page + 1)}
data-sveltekit-keepfocus
data-sveltekit-noscroll
aria-label={m['pagination.next']()}><ChevronRight size={16} /></a
>
{:else}
@@ -137,7 +137,6 @@
<p class="hint">{m['scanner.description']()}</p>
<div class="stage">
<!-- svelte-ignore a11y_media_has_caption -->
<video bind:this={video} playsinline></video>
{#if error}
<div class="msg error">{error}</div>
@@ -84,6 +84,7 @@
});
async function loadInitial(signal: AbortSignal) {
try {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient local, not reactive state
const seen = new Set<number>();
const ids: number[] = [];
let offset = 0;
@@ -149,6 +150,7 @@
async function runSearch(q: string, signal: AbortSignal) {
try {
const { spools, filaments } = await searchAll(q, COLOR_THRESHOLD, signal);
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient local, not reactive state
const ids = new Set<number>(spools.map((m) => m.entity.id));
// The /search spool pass only matches spool-native fields, so also pull
// the spools of any matched filament — that's how a filament- or
@@ -196,6 +198,7 @@
const recentCount = $derived(visibleSpools.filter(isRecent).length);
function toggle(id: number) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient local; `selected` updates via reassignment below
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else next.add(id);
@@ -258,6 +261,7 @@
});
function toggleF(id: string) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient local; `selectedF` updates via reassignment below
const next = new Set(selectedF);
if (next.has(id)) next.delete(id);
else next.add(id);
@@ -49,7 +49,7 @@
$effect(() => {
const id = filamentId;
const archived = showArchived;
revision; // refetch on live spool events
void revision; // refetch on live spool events
let cancelled = false;
spoolSource
.listSpools({
@@ -121,9 +121,7 @@
</div>
<SectionLabel>
{#snippet children()}<span style="padding-left:20px"
>{m['inspector.spoolsCount']({ count: spools.length })}</span
>{/snippet}
<span style="padding-left:20px">{m['inspector.spoolsCount']({ count: spools.length })}</span>
{#snippet right()}
<button
class="arch-toggle"
@@ -47,7 +47,7 @@
const ctrl = new AbortController();
const gq = isGrouped ? buildGroupQuery(libraryState, ctrl.signal) : null;
const sq = isGrouped ? null : buildFlatSpoolQuery(libraryState, ctrl.signal);
revision; // refetch on live events
void revision; // refetch on live events
const mine = ++reqId;
loading = true;
errored = false;
@@ -49,7 +49,7 @@
let reqId = 0;
$effect(() => {
const ctrl = new AbortController();
revision; // refetch on live events
void revision; // refetch on live events
const want = untrack(() => spools.length) || PAGE;
const q = buildScopedSpoolQuery(libraryState, group, want, 0, ctrl.signal);
const mine = ++reqId;
@@ -160,16 +160,6 @@
// "matched: <field>" label. Extra fields resolve to the field's display name;
// native fields get a translated label (falling back to the raw name).
const NATIVE_MATCH = new Set([
'name',
'material',
'article_number',
'comment',
'location',
'lot_nr',
'id',
'color'
]);
const MATCH_LABEL: Record<EntityKind, Record<string, () => string>> = {
filament: {
name: m['filament.fields.name'],
@@ -257,11 +247,7 @@
onclick={onResultClick}
>
<span class="id mono">#{spool.entity.id}</span>
<Swatch
colors={filament?.colors ?? []}
direction={filament?.multiColorDirection}
size={20}
/>
<Swatch colors={filament?.colors ?? []} direction={filament?.multiColorDirection} size={20} />
<span class="text">
<span class="title">{filament?.name || m['search.unknownFilament']()}</span>
{#if spool.entity.location}<span class="sub">{spool.entity.location}</span>{/if}
@@ -308,12 +294,7 @@
<a
class="result"
class:active={activeIndex === indexOf('vendor', vendor.entity.id)}
href={searchResultHref(
page.url.searchParams,
page.url.pathname,
'vendor',
vendor.entity.id
)}
href={searchResultHref(page.url.searchParams, page.url.pathname, 'vendor', vendor.entity.id)}
data-sveltekit-noscroll
onclick={onResultClick}
>
+8 -1
View File
@@ -1,4 +1,11 @@
import { getFields, setField, deleteField, type EntityType, type FieldDef, type FieldParams } from '$lib/api/fields';
import {
getFields,
setField,
deleteField,
type EntityType,
type FieldDef,
type FieldParams
} from '$lib/api/fields';
// Reactive cache of extra-field definitions per entity type. Loaded on demand
// (inspectors) and by the settings manager; refetched after mutations.
+1 -2
View File
@@ -28,8 +28,7 @@ export function hexRgb(h: string): [number, number, number] {
export function swatchStyle(colors: string[] | undefined, direction?: MultiColorDirection): string {
if (!colors || !colors.length) return 'background:#555';
if (colors.length > 1) {
const angle =
direction === 'coaxial' ? '90deg' : direction === 'longitudinal' ? '0deg' : '135deg';
const angle = direction === 'coaxial' ? '90deg' : direction === 'longitudinal' ? '0deg' : '135deg';
// Each colour gets its own band, but boundaries are softened by a narrow
// feather rather than a hard edge or a full-slot blur: colour i stays solid
// across most of [i*step, (i+1)*step] and only fades over `feather`% on each
-2
View File
@@ -1,5 +1,3 @@
import { browser } from '$app/environment';
// Spoolman ships as a client-side SPA served from the backend, so disable SSR
// and prerender the shell.
export const ssr = false;
+8 -4
View File
@@ -129,6 +129,7 @@
});
// A NULL location and an empty-string one are distinct rows to the database
// but the same "No location" card here, so counts are summed by key.
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient local, not reactive state
const totals = new Map<string, number>();
for (const g of page.items) totals.set(g.key, (totals.get(g.key) ?? 0) + g.spoolCount);
@@ -360,6 +361,7 @@
// Which locations currently hold spools, by display name.
function presentNames(): Set<string> {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient local, not reactive state
const names = new Set<string>();
for (const [key, b] of Object.entries(buckets)) {
if (b.total > 0) names.add(key === EMPTY ? NO_LOCATION : key);
@@ -666,12 +668,14 @@
<div class="chip-info">
<div class="chip-title">
<span class="chip-id mono">#{s.id}</span>
{#if v.name !== '?'}{v.name}{' - '}{/if}{f.name}
{#if v.name !== '?'}{v.name} -
{/if}{f.name}
</div>
<div class="chip-subtitle">
{f.material}{' - '}<span class:low={settings.isLow(s.remaining, s.unused)}
>{weightAuto(s.remaining)}</span
>{' / '}{weightAuto(f.weight)}{#if s.lastUsedLabel}{' - '}{m['locations.lastUsed']({
{f.material} -
<span class:low={settings.isLow(s.remaining, s.unused)}>{weightAuto(s.remaining)}</span>
/ {weightAuto(f.weight)}{#if s.lastUsedLabel}
- {m['locations.lastUsed']({
time: s.lastUsedLabel
})}{/if}
</div>
+1 -1
View File
@@ -8,7 +8,7 @@ export default defineConfig({
paraglideVitePlugin({
project: './project.inlang',
outdir: './src/lib/paraglide',
strategy: ["localStorage", "preferredLanguage", "baseLocale"]
strategy: ['localStorage', 'preferredLanguage', 'baseLocale']
})
],
server: { port: 5174 }
+28
View File
@@ -23,6 +23,21 @@ pre-commit:
root: "client/"
run: ./node_modules/.bin/prettier --write .
stage_fixed: true
- group: # Client v2 checks
parallel: false
jobs:
- name: Run lint check on client_v2 files
root: "client_v2/"
run: ./node_modules/.bin/eslint --fix . # Hardcoded path because Windows messes up if we use npx
stage_fixed: true
- name: Run format check on client_v2 files
root: "client_v2/"
run: ./node_modules/.bin/prettier --write .
stage_fixed: true
- name: Run type check on client_v2 files
root: "client_v2/"
# svelte-kit sync generates .svelte-kit/tsconfig.json, which svelte-check needs.
run: ./node_modules/.bin/svelte-kit sync && ./node_modules/.bin/svelte-check --tsconfig ./tsconfig.json
# Same as pre-commit but no fixes
ci:
@@ -46,4 +61,17 @@ ci:
- name: Run format check on client files
root: "client/"
run: ./node_modules/.bin/prettier --check .
- group: # Client v2 checks
parallel: false
jobs:
- name: Run lint check on client_v2 files
root: "client_v2/"
run: ./node_modules/.bin/eslint . # Hardcoded path because Windows messes up if we use npx
- name: Run format check on client_v2 files
root: "client_v2/"
run: ./node_modules/.bin/prettier --check .
- name: Run type check on client_v2 files
root: "client_v2/"
# svelte-kit sync generates .svelte-kit/tsconfig.json, which svelte-check needs.
run: ./node_modules/.bin/svelte-kit sync && ./node_modules/.bin/svelte-check --tsconfig ./tsconfig.json