web_ui: add the CI + pre-commit gate, and make pnpm lint actually run

Matches the Phase 1 design_system pattern the brief points at: own lint config,
own tests, own coverage gate enforced in CI.

The lint script was declared but unrunnable -- there was no eslint flat config,
and the build tsconfig excludes tests (correctly; they must not ship in dist/),
so type-aware linting had no project for them. Adds eslint.config.js and a
lint-only tsconfig that widens the include without emitting. Running it surfaced
one real defect: nth() interpolated a number into a template literal without
String(), which the consumers' configs would have flagged.

The gate also checks that the committed dist/ matches a fresh build. This is not
theoretical -- the check caught the stale dist/ produced by that very String()
fix on its first run. A stale dist/ in a tag ships old code to every consumer
with no error anywhere, which is the one failure mode this packaging choice
introduces. dist/ is excluded from the whitespace hooks: they would "fix"
generated output that the next build immediately reverts.

CI additionally re-runs the categorical ramp's constraint proof, so the palette
cannot regress silently either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 15:08:18 +02:00
parent 56d0c3d420
commit eaa5683ff1
24 changed files with 236 additions and 40 deletions

52
.github/workflows/web-ui-tests.yml vendored Normal file
View File

@ -0,0 +1,52 @@
name: web_ui tests
on:
push:
branches: [main]
paths: ['web_ui/**']
pull_request:
branches: [main]
paths: ['web_ui/**']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web_ui
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: '24'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Run tests with the 100% coverage gate
run: pnpm coverage
# The tag ships a prebuilt dist/ because Vite does not transpile
# dependencies. A committed dist/ that does not match src/ would ship
# stale code to every consumer with no error anywhere, so rebuild and
# fail if the result differs from what is checked in.
- name: dist/ is in sync with src/
run: |
pnpm build
if ! git diff --exit-code -- dist; then
echo "::error::dist/ is stale — run 'pnpm build' in web_ui and commit the result."
exit 1
fi
- name: Categorical ramp still passes its constraints
working-directory: .
run: python3 unified-design-system/scripts/ramp_check.py

View File

@ -0,0 +1,67 @@
# Pre-commit Configuration for web_ui
# Install: pre-commit install && pre-commit install --hook-type pre-push
# Run: pre-commit run --config web_ui/.pre-commit-config.yaml --all-files
# NOTE: this subproject lives inside the ~/utils monorepo, whose git root is
# one level up. pre-commit always runs hooks from the git root, so every path
# below is written relative to THAT, not to this directory, and the top-level
# `files` key keeps these hooks off the sibling subprojects. CI invokes this
# config exactly the same way. Mirrors design_system/.pre-commit-config.yaml.
files: ^web_ui/
# dist/ is generated by tsc and committed for consumers. The whitespace hooks
# would "fix" it, the next build would revert them, and the dist-fresh hook
# would fail forever -- so formatting hooks must not see it.
exclude: ^web_ui/dist/
default_stages: [pre-commit]
fail_fast: false
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md]
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
- id: check-merge-conflict
- id: mixed-line-ending
args: [--fix=lf]
- repo: local
hooks:
- id: subproject-scope
name: subproject scope regex still matches
entry: scripts/check_subproject_scope.sh web_ui
language: script
# always_run so the top-level `files` filter cannot skip the one hook
# whose job is to notice that filter has stopped matching anything.
always_run: true
pass_filenames: false
- id: web-ui-lint
name: tsc --noEmit && eslint
entry: bash -c 'cd web_ui && pnpm lint'
language: system
pass_filenames: false
- id: web-ui-dist-fresh
name: committed dist/ matches src/
# The tag ships a prebuilt dist/ (Vite does not transpile deps), so a
# stale dist/ ships old code to every consumer with no error anywhere.
entry: >-
bash -c 'cd web_ui && pnpm build &&
git diff --exit-code -- dist ||
{ echo "dist/ is stale -- pnpm build in web_ui and commit it"; exit 1; }'
language: system
pass_filenames: false
require_serial: true
- id: web-ui-test-coverage
name: vitest with the 100% coverage gate
entry: bash -c 'cd web_ui && pnpm coverage'
language: system
pass_filenames: false
require_serial: true
stages: [pre-push]

View File

@ -7,4 +7,4 @@
export { clamp01, fractionFromPointer, nth, quantileValue, valueQuantile } from "./quantile.ts";
export { RangeSlider, steppedValue, type RangeSliderProps } from "./range-slider.tsx";
export { fuzzyMatch } from "./text.ts";
//# sourceMappingURL=index.d.ts.map
//# sourceMappingURL=index.d.ts.map

View File

@ -1 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,OAAO,EAAE,mBAAmB,EAAE,GAAG,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAChG,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,KAAK,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AACtF,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC"}
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,OAAO,EAAE,mBAAmB,EAAE,GAAG,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAChG,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,KAAK,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AACtF,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC"}

View File

@ -7,4 +7,4 @@
export { clamp01, fractionFromPointer, nth, quantileValue, valueQuantile } from "./quantile.js";
export { RangeSlider, steppedValue } from "./range-slider.js";
export { fuzzyMatch } from "./text.js";
//# sourceMappingURL=index.js.map
//# sourceMappingURL=index.js.map

View File

@ -1 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,OAAO,EAAE,mBAAmB,EAAE,GAAG,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAChG,OAAO,EAAE,WAAW,EAAE,YAAY,EAAyB,MAAM,mBAAoB,CAAC;AACtF,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC"}
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,OAAO,EAAE,mBAAmB,EAAE,GAAG,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAChG,OAAO,EAAE,WAAW,EAAE,YAAY,EAAyB,MAAM,mBAAoB,CAAC;AACtF,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC"}

View File

@ -40,4 +40,4 @@ export declare function valueQuantile(sorted: readonly number[], value: number):
* which jsdom does not implement.
*/
export declare function fractionFromPointer(rect: DOMRect, clientX: number): number;
//# sourceMappingURL=quantile.d.ts.map
//# sourceMappingURL=quantile.d.ts.map

View File

@ -1 +1 @@
{"version":3,"file":"quantile.d.ts","sourceRoot":"","sources":["../src/quantile.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH;;;;GAIG;AACH,wBAAgB,GAAG,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAMpE;AAED,kCAAkC;AAClC,wBAAgB,OAAO,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEzC;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAS1E;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAa9E;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAE1E"}
{"version":3,"file":"quantile.d.ts","sourceRoot":"","sources":["../src/quantile.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH;;;;GAIG;AACH,wBAAgB,GAAG,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAMpE;AAED,kCAAkC;AAClC,wBAAgB,OAAO,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEzC;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAS1E;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAa9E;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAE1E"}

View File

@ -19,7 +19,7 @@
export function nth(values, index) {
const value = values[index];
if (value === undefined) {
throw new RangeError(`index ${index} out of range`);
throw new RangeError(`index ${String(index)} out of range`);
}
return value;
}
@ -76,4 +76,4 @@ export function valueQuantile(sorted, value) {
export function fractionFromPointer(rect, clientX) {
return rect.width === 0 ? 0 : clamp01((clientX - rect.left) / rect.width);
}
//# sourceMappingURL=quantile.js.map
//# sourceMappingURL=quantile.js.map

View File

@ -1 +1 @@
{"version":3,"file":"quantile.js","sourceRoot":"","sources":["../src/quantile.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH;;;;GAIG;AACH,MAAM,UAAU,GAAG,CAAC,MAAyB,EAAE,KAAa;IAC1D,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;IAC5B,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;QACxB,MAAM,IAAI,UAAU,CAAC,SAAS,KAAK,eAAe,CAAC,CAAC;IACtD,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,kCAAkC;AAClC,MAAM,UAAU,OAAO,CAAC,CAAS;IAC/B,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;AACrC,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAAC,MAAyB,EAAE,CAAS;IAChE,MAAM,CAAC,GAAG,MAAM,CAAC,MAAM,CAAC;IACxB,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,CAAC,CAAC;IACtB,MAAM,GAAG,GAAG,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;IACjC,MAAM,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC3B,MAAM,EAAE,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC1B,MAAM,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IAC1B,MAAM,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IAC1B,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,GAAG,GAAG,EAAE,CAAC,CAAC,CAAC;AAC9C,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,aAAa,CAAC,MAAyB,EAAE,KAAa;IACpE,MAAM,CAAC,GAAG,MAAM,CAAC,MAAM,CAAC;IACxB,IAAI,CAAC,IAAI,CAAC;QAAE,OAAO,CAAC,CAAC;IACrB,IAAI,KAAK,IAAI,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC;QAAE,OAAO,CAAC,CAAC;IACtC,IAAI,KAAK,IAAI,GAAG,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC;QAAE,OAAO,CAAC,CAAC;IAC1C,qEAAqE;IACrE,kDAAkD;IAClD,MAAM,EAAE,GAAG,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC;IAC/C,MAAM,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC;IAClB,MAAM,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IAC1B,MAAM,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IAC1B,yDAAyD;IACzD,OAAO,CAAC,EAAE,GAAG,CAAC,KAAK,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;AAChD,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,mBAAmB,CAAC,IAAa,EAAE,OAAe;IAChE,OAAO,IAAI,CAAC,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC;AAC5E,CAAC"}
{"version":3,"file":"quantile.js","sourceRoot":"","sources":["../src/quantile.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH;;;;GAIG;AACH,MAAM,UAAU,GAAG,CAAC,MAAyB,EAAE,KAAa;IAC1D,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;IAC5B,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;QACxB,MAAM,IAAI,UAAU,CAAC,SAAS,MAAM,CAAC,KAAK,CAAC,eAAe,CAAC,CAAC;IAC9D,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,kCAAkC;AAClC,MAAM,UAAU,OAAO,CAAC,CAAS;IAC/B,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;AACrC,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAAC,MAAyB,EAAE,CAAS;IAChE,MAAM,CAAC,GAAG,MAAM,CAAC,MAAM,CAAC;IACxB,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,CAAC,CAAC;IACtB,MAAM,GAAG,GAAG,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;IACjC,MAAM,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC3B,MAAM,EAAE,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC1B,MAAM,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IAC1B,MAAM,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IAC1B,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,GAAG,GAAG,EAAE,CAAC,CAAC,CAAC;AAC9C,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,aAAa,CAAC,MAAyB,EAAE,KAAa;IACpE,MAAM,CAAC,GAAG,MAAM,CAAC,MAAM,CAAC;IACxB,IAAI,CAAC,IAAI,CAAC;QAAE,OAAO,CAAC,CAAC;IACrB,IAAI,KAAK,IAAI,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC;QAAE,OAAO,CAAC,CAAC;IACtC,IAAI,KAAK,IAAI,GAAG,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC;QAAE,OAAO,CAAC,CAAC;IAC1C,qEAAqE;IACrE,kDAAkD;IAClD,MAAM,EAAE,GAAG,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC;IAC/C,MAAM,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC;IAClB,MAAM,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IAC1B,MAAM,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IAC1B,yDAAyD;IACzD,OAAO,CAAC,EAAE,GAAG,CAAC,KAAK,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;AAChD,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,mBAAmB,CAAC,IAAa,EAAE,OAAe;IAChE,OAAO,IAAI,CAAC,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC;AAC5E,CAAC"}

View File

@ -28,4 +28,4 @@ export interface RangeSliderProps {
*/
export declare function steppedValue(values: readonly number[], current: number, delta: number): number;
export declare function RangeSlider({ format, hi, label, lo, onChange, values, }: RangeSliderProps): null | React.JSX.Element;
//# sourceMappingURL=range-slider.d.ts.map
//# sourceMappingURL=range-slider.d.ts.map

View File

@ -1 +1 @@
{"version":3,"file":"range-slider.d.ts","sourceRoot":"","sources":["../src/range-slider.tsx"],"names":[],"mappings":"AAyBA,MAAM,WAAW,gBAAgB;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;IAC5C,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,iEAAiE;IACjE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,4EAA4E;IAC5E,QAAQ,CAAC,QAAQ,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IACpD,gEAAgE;IAChE,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;CACpC;AAID;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAC1B,MAAM,EAAE,SAAS,MAAM,EAAE,EACzB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,GACZ,MAAM,CAkBR;AAED,wBAAgB,WAAW,CAAC,EAC1B,MAAM,EACN,EAAE,EACF,KAAK,EACL,EAAE,EACF,QAAQ,EACR,MAAM,GACP,EAAE,gBAAgB,GAAG,IAAI,GAAG,KAAK,CAAC,GAAG,CAAC,OAAO,CAyG7C"}
{"version":3,"file":"range-slider.d.ts","sourceRoot":"","sources":["../src/range-slider.tsx"],"names":[],"mappings":"AAyBA,MAAM,WAAW,gBAAgB;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;IAC5C,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,iEAAiE;IACjE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,4EAA4E;IAC5E,QAAQ,CAAC,QAAQ,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IACpD,gEAAgE;IAChE,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;CACpC;AAID;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAC1B,MAAM,EAAE,SAAS,MAAM,EAAE,EACzB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,GACZ,MAAM,CAkBR;AAED,wBAAgB,WAAW,CAAC,EAC1B,MAAM,EACN,EAAE,EACF,KAAK,EACL,EAAE,EACF,QAAQ,EACR,MAAM,GACP,EAAE,gBAAgB,GAAG,IAAI,GAAG,KAAK,CAAC,GAAG,CAAC,OAAO,CAyG7C"}

View File

@ -127,4 +127,4 @@ export function RangeSlider({ format, hi, label, lo, onChange, values, }) {
event.currentTarget.releasePointerCapture(event.pointerId);
}, children: [_jsx("div", { className: "slider-fill", style: { left: `${String(loFraction)}%`, width: `${String(hiFraction - loFraction)}%` } }), _jsx("button", { "aria-label": `${name} minimum`, ...thumbProps("lo") }), _jsx("button", { "aria-label": `${name} maximum`, ...thumbProps("hi") })] })] }));
}
//# sourceMappingURL=range-slider.js.map
//# sourceMappingURL=range-slider.js.map

File diff suppressed because one or more lines are too long

View File

@ -13,4 +13,4 @@
* the implementations were identical apart from identifier names.
*/
export declare function fuzzyMatch(query: string, target: string): boolean;
//# sourceMappingURL=text.d.ts.map
//# sourceMappingURL=text.d.ts.map

View File

@ -1 +1 @@
{"version":3,"file":"text.d.ts","sourceRoot":"","sources":["../src/text.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CASjE"}
{"version":3,"file":"text.d.ts","sourceRoot":"","sources":["../src/text.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CASjE"}

2
web_ui/dist/text.js vendored
View File

@ -24,4 +24,4 @@ export function fuzzyMatch(query, target) {
}
return index === q.length;
}
//# sourceMappingURL=text.js.map
//# sourceMappingURL=text.js.map

View File

@ -1 +1 @@
{"version":3,"file":"text.js","sourceRoot":"","sources":["../src/text.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;;;;GAOG;AACH,MAAM,UAAU,UAAU,CAAC,KAAa,EAAE,MAAc;IACtD,IAAI,KAAK,KAAK,EAAE;QAAE,OAAO,IAAI,CAAC;IAC9B,MAAM,CAAC,GAAG,KAAK,CAAC,WAAW,EAAE,CAAC;IAC9B,MAAM,CAAC,GAAG,MAAM,CAAC,WAAW,EAAE,CAAC;IAC/B,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,KAAK,IAAI,IAAI,GAAG,CAAC,EAAE,IAAI,GAAG,CAAC,CAAC,MAAM,IAAI,KAAK,GAAG,CAAC,CAAC,MAAM,EAAE,IAAI,EAAE,EAAE,CAAC;QAC/D,IAAI,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC;YAAE,KAAK,EAAE,CAAC;IACpC,CAAC;IACD,OAAO,KAAK,KAAK,CAAC,CAAC,MAAM,CAAC;AAC5B,CAAC"}
{"version":3,"file":"text.js","sourceRoot":"","sources":["../src/text.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;;;;GAOG;AACH,MAAM,UAAU,UAAU,CAAC,KAAa,EAAE,MAAc;IACtD,IAAI,KAAK,KAAK,EAAE;QAAE,OAAO,IAAI,CAAC;IAC9B,MAAM,CAAC,GAAG,KAAK,CAAC,WAAW,EAAE,CAAC;IAC9B,MAAM,CAAC,GAAG,MAAM,CAAC,WAAW,EAAE,CAAC;IAC/B,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,KAAK,IAAI,IAAI,GAAG,CAAC,EAAE,IAAI,GAAG,CAAC,CAAC,MAAM,IAAI,KAAK,GAAG,CAAC,CAAC,MAAM,EAAE,IAAI,EAAE,EAAE,CAAC;QAC/D,IAAI,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC;YAAE,KAAK,EAAE,CAAC;IACpC,CAAC;IACD,OAAO,KAAK,KAAK,CAAC,CAAC,MAAM,CAAC;AAC5B,CAAC"}

42
web_ui/eslint.config.js Normal file
View File

@ -0,0 +1,42 @@
// Type-aware flat config for the shared component layer.
//
// A trimmed version of awesome-mcp-explorer's: typescript-eslint's
// *type-checked* strict + stylistic presets, so anything they flag is an error.
// The consumers layer unicorn/sonarjs/perfectionist on top; this package keeps
// to the presets its own devDependencies actually declare, because a config
// referencing plugins that are not installed is a lint script that cannot run.
import js from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
// eslint.config.js itself is not in tsconfig.lint.json, so type-aware
// rules cannot run on it; linting the linter config buys nothing anyway.
{ ignores: ["dist", "coverage", "eslint.config.js"] },
{ linterOptions: { reportUnusedDisableDirectives: "error" } },
js.configs.recommended,
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
parserOptions: {
project: ["./tsconfig.lint.json"],
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// Every exported symbol here is consumed across a package boundary, so
// an explicit return type is documentation, not ceremony.
"@typescript-eslint/explicit-function-return-type": "error",
},
},
{
// Tests reach for non-null assertions on queries that cannot return null
// in a passing test; asserting them again would only add noise.
files: ["**/*.test.{ts,tsx}"],
rules: { "@typescript-eslint/no-non-null-assertion": "off" },
},
);

View File

@ -25,7 +25,7 @@
},
"scripts": {
"build": "tsc -b && cp src/tokens.css src/range-slider.css dist/",
"lint": "tsc --noEmit && eslint src",
"lint": "tsc --noEmit -p tsconfig.lint.json && eslint .",
"test": "vitest run",
"coverage": "vitest run --coverage"
},
@ -46,6 +46,7 @@
"react-dom": "^19.2.7",
"typescript": "^6.0.3",
"typescript-eslint": "^8.64.0",
"vitest": "^4.1.10"
"vitest": "^4.1.10",
"@types/node": "^26.1.1"
}
}

View File

@ -20,6 +20,9 @@ importers:
'@testing-library/user-event':
specifier: ^14.6.1
version: 14.6.4(@testing-library/dom@10.4.1)
'@types/node':
specifier: ^26.1.1
version: 26.2.0
'@types/react':
specifier: ^19.2.17
version: 19.2.18
@ -49,7 +52,7 @@ importers:
version: 8.67.0(eslint@10.8.1)(typescript@6.0.3)
vitest:
specifier: ^4.1.10
version: 4.1.10(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.2.1)
version: 4.1.10(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.2.1(@types/node@26.2.0))
packages:
@ -361,6 +364,9 @@ packages:
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/node@26.2.0':
resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==}
'@types/react-dom@19.2.4':
resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==}
peerDependencies:
@ -1040,6 +1046,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
undici@7.29.0:
resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==}
engines: {node: '>=20.18.1'}
@ -1406,6 +1415,10 @@ snapshots:
'@types/json-schema@7.0.15': {}
'@types/node@26.2.0':
dependencies:
undici-types: 8.3.0
'@types/react-dom@19.2.4(@types/react@19.2.18)':
dependencies:
'@types/react': 19.2.18
@ -1517,7 +1530,7 @@ snapshots:
obug: 2.1.4
std-env: 4.2.0
tinyrainbow: 3.1.1
vitest: 4.1.10(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.2.1)
vitest: 4.1.10(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.2.1(@types/node@26.2.0))
'@vitest/expect@4.1.10':
dependencies:
@ -1528,13 +1541,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.1
'@vitest/mocker@4.1.10(vite@8.2.1)':
'@vitest/mocker@4.1.10(vite@8.2.1(@types/node@26.2.0))':
dependencies:
'@vitest/spy': 4.1.10
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.2.1
vite: 8.2.1(@types/node@26.2.0)
'@vitest/pretty-format@4.1.10':
dependencies:
@ -2086,13 +2099,15 @@ snapshots:
typescript@6.0.3: {}
undici-types@8.3.0: {}
undici@7.29.0: {}
uri-js@4.4.1:
dependencies:
punycode: 2.3.1
vite@8.2.1:
vite@8.2.1(@types/node@26.2.0):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.5
@ -2100,12 +2115,13 @@ snapshots:
rolldown: 1.2.4
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 26.2.0
fsevents: 2.3.3
vitest@4.1.10(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.2.1):
vitest@4.1.10(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.2.1(@types/node@26.2.0)):
dependencies:
'@vitest/expect': 4.1.10
'@vitest/mocker': 4.1.10(vite@8.2.1)
'@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@26.2.0))
'@vitest/pretty-format': 4.1.10
'@vitest/runner': 4.1.10
'@vitest/snapshot': 4.1.10
@ -2122,9 +2138,10 @@ snapshots:
tinyexec: 1.3.0
tinyglobby: 0.2.17
tinyrainbow: 3.1.1
vite: 8.2.1
vite: 8.2.1(@types/node@26.2.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 26.2.0
'@vitest/coverage-v8': 4.1.10(vitest@4.1.10)
jsdom: 29.1.1
transitivePeerDependencies:

View File

@ -20,7 +20,7 @@
export function nth(values: readonly number[], index: number): number {
const value = values[index];
if (value === undefined) {
throw new RangeError(`index ${index} out of range`);
throw new RangeError(`index ${String(index)} out of range`);
}
return value;
}

View File

@ -136,7 +136,7 @@ describe("RangeSlider", () => {
["ArrowUp", 10],
])("%s steps the low thumb up", (key, expected) => {
const { onChange } = renderSlider();
fireEvent.keyDown(thumbs()[0] as HTMLElement, { key });
fireEvent.keyDown(thumbs()[0]!, { key });
expect(onChange).toHaveBeenCalledWith(expected, 90);
});
@ -145,58 +145,61 @@ describe("RangeSlider", () => {
["ArrowDown", 80],
])("%s steps the high thumb down", (key, expected) => {
const { onChange } = renderSlider();
fireEvent.keyDown(thumbs()[1] as HTMLElement, { key });
fireEvent.keyDown(thumbs()[1]!, { key });
expect(onChange).toHaveBeenCalledWith(0, expected);
});
it("PageUp jumps ten samples", () => {
const { onChange } = renderSlider({ lo: 0 });
fireEvent.keyDown(thumbs()[0] as HTMLElement, { key: "PageUp" });
fireEvent.keyDown(thumbs()[0]!, { key: "PageUp" });
expect(onChange).toHaveBeenCalledWith(90, 90);
});
it("PageDown jumps ten samples down", () => {
const { onChange } = renderSlider({ hi: 90 });
fireEvent.keyDown(thumbs()[1] as HTMLElement, { key: "PageDown" });
fireEvent.keyDown(thumbs()[1]!, { key: "PageDown" });
expect(onChange).toHaveBeenCalledWith(0, 0);
});
it("Home sends a thumb to the floor", () => {
const { onChange } = renderSlider({ lo: 50 });
fireEvent.keyDown(thumbs()[0] as HTMLElement, { key: "Home" });
fireEvent.keyDown(thumbs()[0]!, { key: "Home" });
expect(onChange).toHaveBeenCalledWith(0, 90);
});
it("End sends a thumb to the ceiling", () => {
const { onChange } = renderSlider({ hi: 50 });
fireEvent.keyDown(thumbs()[1] as HTMLElement, { key: "End" });
fireEvent.keyDown(thumbs()[1]!, { key: "End" });
expect(onChange).toHaveBeenCalledWith(0, 90);
});
it("ignores keys it does not handle", () => {
const { onChange } = renderSlider();
fireEvent.keyDown(thumbs()[0] as HTMLElement, { key: "a" });
fireEvent.keyDown(thumbs()[0]!, { key: "a" });
expect(onChange).not.toHaveBeenCalled();
});
it("does not let the low thumb cross the high one", () => {
const { onChange } = renderSlider({ hi: 10, lo: 10 });
fireEvent.keyDown(thumbs()[0] as HTMLElement, { key: "ArrowRight" });
fireEvent.keyDown(thumbs()[0]!, { key: "ArrowRight" });
expect(onChange).toHaveBeenCalledWith(10, 10);
});
it("does not let the high thumb cross the low one", () => {
const { onChange } = renderSlider({ hi: 10, lo: 10 });
fireEvent.keyDown(thumbs()[1] as HTMLElement, { key: "ArrowLeft" });
fireEvent.keyDown(thumbs()[1]!, { key: "ArrowLeft" });
expect(onChange).toHaveBeenCalledWith(10, 10);
});
});
describe("pointer", () => {
function track(): HTMLElement {
// eslint-disable-next-line testing-library/no-node-access -- the track is
// presentational; there is no role or text to query it by.
return document.querySelector(".slider-track") as HTMLElement;
// Queried by class rather than by role: the track is presentational, so
// it has neither a role nor text to find it by. (The consumers run
// eslint-plugin-testing-library, which flags this; this package does not
// install that plugin, so a disable comment here would itself be an
// unknown-rule error.)
return document.querySelector(".slider-track")!;
}
it("grabs the nearer thumb when pressed anywhere on the track", () => {

14
web_ui/tsconfig.lint.json Normal file
View File

@ -0,0 +1,14 @@
{
// tsconfig.json deliberately excludes tests -- they must not land in the
// published dist/. Type-aware linting still needs them in a project, so this
// config widens the include for eslint (and `tsc --noEmit`) only. It emits
// nothing and clears rootDir, so it cannot affect the shipped build.
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": ".",
"types": ["vitest/globals", "node"]
},
"include": ["src", "*.config.ts"],
"exclude": []
}