From c5b3fceeaebfbcfd03962b9319648e7ec711831e Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Wed, 16 Sep 2026 21:46:41 +0200 Subject: [PATCH] ts_core 0.1.1, web_ui 0.4.0: consume @kuhyx/ts-config The two shared packages are the first repos on the preset (link:../ts_config inside the monorepo; consumers outside use the git tag). Each eslint.config.js is now the defineConfig() call plus one override with its reason: a public API name (`nextChance`, `fuzzyMatch`) the boolean-prefix rule would rename, which is a breaking change for the three repos importing it. Everything else the preset flagged is fixed in code, not disabled: key order (perfectionist), hex casing, sort compare functions in tests, Iterator#map().toArray(), explicit returns on multiline arrows. web_ui: react-refresh/only-export-components was a real finding -- range-slider.tsx exported `steppedValue` next to the component, which breaks Vite HMR for the module. The keyboard contract moves to stepped-value.ts as `steppedValue` + a new pure `keyTarget(key, values, current)`, which also resolves unicorn/no-late-event-control: the handler now decides the target first and calls preventDefault() only when it owns the key. Its tests move with it (the 250-line gate caught the combined file). Minor bump for the new export. dist/ rebuilt in both packages. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DnAYyNH1Sm4iJNidfYbrZJ --- ts_core/dist/clock.d.ts.map | 2 +- ts_core/dist/clock.js | 6 +- ts_core/dist/clock.js.map | 2 +- ts_core/dist/index.d.ts | 2 +- ts_core/dist/index.d.ts.map | 2 +- ts_core/dist/index.js.map | 2 +- ts_core/dist/rng.d.ts | 4 +- ts_core/dist/rng.d.ts.map | 2 +- ts_core/dist/rng.js | 4 +- ts_core/dist/rng.js.map | 2 +- ts_core/eslint.config.js | 46 ++++--- ts_core/package.json | 9 +- ts_core/pnpm-lock.yaml | 64 +++++----- ts_core/src/clock.test.ts | 2 +- ts_core/src/clock.ts | 6 +- ts_core/src/index.ts | 4 +- ts_core/src/rng.test.ts | 16 +-- ts_core/src/rng.ts | 8 +- ts_core/tsconfig.json | 22 +--- web_ui/dist/index.d.ts | 3 +- web_ui/dist/index.d.ts.map | 2 +- web_ui/dist/index.js | 3 +- web_ui/dist/index.js.map | 2 +- web_ui/dist/quantile.d.ts.map | 2 +- web_ui/dist/quantile.js | 4 +- web_ui/dist/quantile.js.map | 2 +- web_ui/dist/range-slider.d.ts | 11 -- web_ui/dist/range-slider.d.ts.map | 2 +- web_ui/dist/range-slider.js | 89 ++++--------- web_ui/dist/range-slider.js.map | 2 +- web_ui/dist/stepped-value.d.ts | 19 +++ web_ui/dist/stepped-value.d.ts.map | 1 + web_ui/dist/stepped-value.js | 70 +++++++++++ web_ui/dist/stepped-value.js.map | 1 + web_ui/eslint.config.js | 61 ++++----- web_ui/package.json | 11 +- web_ui/pnpm-lock.yaml | 192 ++++++++++++++--------------- web_ui/src/index.ts | 3 +- web_ui/src/quantile.ts | 3 +- web_ui/src/range-slider.test.tsx | 59 +-------- web_ui/src/range-slider.tsx | 90 ++++---------- web_ui/src/stepped-value.test.ts | 79 ++++++++++++ web_ui/src/stepped-value.ts | 78 ++++++++++++ web_ui/src/test/setup.ts | 2 +- web_ui/src/tokens.test.ts | 10 +- web_ui/tsconfig.json | 21 +--- 46 files changed, 535 insertions(+), 492 deletions(-) create mode 100644 web_ui/dist/stepped-value.d.ts create mode 100644 web_ui/dist/stepped-value.d.ts.map create mode 100644 web_ui/dist/stepped-value.js create mode 100644 web_ui/dist/stepped-value.js.map create mode 100644 web_ui/src/stepped-value.test.ts create mode 100644 web_ui/src/stepped-value.ts diff --git a/ts_core/dist/clock.d.ts.map b/ts_core/dist/clock.d.ts.map index 839dbc4..c5b76b4 100644 --- a/ts_core/dist/clock.d.ts.map +++ b/ts_core/dist/clock.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"clock.d.ts","sourceRoot":"","sources":["../src/clock.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,WAAW,KAAK;IACpB,4EAA4E;IAC5E,GAAG,EAAE,MAAM,MAAM,CAAA;CAClB;AAED,2EAA2E;AAC3E,eAAO,MAAM,eAAe,QAAO,KAEjC,CAAA;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,SAAS,EAAE,KAAyB,CAAA;AAEjD,sEAAsE;AACtE,MAAM,WAAW,WAAY,SAAQ,KAAK;IACxC,mEAAmE;IACnE,OAAO,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;IAElC,2DAA2D;IAC3D,GAAG,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAA;CAC9B;AAED,uEAAuE;AACvE,eAAO,MAAM,iBAAiB,GAAI,gBAAW,KAAG,WAW/C,CAAA"} \ No newline at end of file +{"version":3,"file":"clock.d.ts","sourceRoot":"","sources":["../src/clock.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,WAAW,KAAK;IACpB,4EAA4E;IAC5E,GAAG,EAAE,MAAM,MAAM,CAAA;CAClB;AAED,2EAA2E;AAC3E,eAAO,MAAM,eAAe,QAAO,KAAmD,CAAA;AAEtF;;;;;;;GAOG;AACH,eAAO,MAAM,SAAS,EAAE,KAAyB,CAAA;AAEjD,sEAAsE;AACtE,MAAM,WAAW,WAAY,SAAQ,KAAK;IACxC,mEAAmE;IACnE,OAAO,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;IAElC,2DAA2D;IAC3D,GAAG,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAA;CAC9B;AAED,uEAAuE;AACvE,eAAO,MAAM,iBAAiB,GAAI,gBAAW,KAAG,WAW/C,CAAA"} \ No newline at end of file diff --git a/ts_core/dist/clock.js b/ts_core/dist/clock.js index 954a4ec..847c42a 100644 --- a/ts_core/dist/clock.js +++ b/ts_core/dist/clock.js @@ -1,7 +1,5 @@ /** A {@link Clock} backed by the browser's monotonic performance timer. */ -export const createRealClock = () => ({ - now: () => performance.now(), -}); +export const createRealClock = () => ({ now: () => performance.now() }); /** * The one real clock instance UI code should pass around. * @@ -15,10 +13,10 @@ export const realClock = createRealClock(); export const createManualClock = (startMs = 0) => { let current = startMs; return { - now: () => current, advance: (deltaMs) => { current += deltaMs; }, + now: () => current, set: (timeMs) => { current = timeMs; }, diff --git a/ts_core/dist/clock.js.map b/ts_core/dist/clock.js.map index ba08390..1af3bdf 100644 --- a/ts_core/dist/clock.js.map +++ b/ts_core/dist/clock.js.map @@ -1 +1 @@ -{"version":3,"file":"clock.js","sourceRoot":"","sources":["../src/clock.ts"],"names":[],"mappings":"AAaA,2EAA2E;AAC3E,MAAM,CAAC,MAAM,eAAe,GAAG,GAAU,EAAE,CAAC,CAAC;IAC3C,GAAG,EAAE,GAAW,EAAE,CAAC,WAAW,CAAC,GAAG,EAAE;CACrC,CAAC,CAAA;AAEF;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,SAAS,GAAU,eAAe,EAAE,CAAA;AAWjD,uEAAuE;AACvE,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,OAAO,GAAG,CAAC,EAAe,EAAE;IAC5D,IAAI,OAAO,GAAG,OAAO,CAAA;IACrB,OAAO;QACL,GAAG,EAAE,GAAW,EAAE,CAAC,OAAO;QAC1B,OAAO,EAAE,CAAC,OAAe,EAAQ,EAAE;YACjC,OAAO,IAAI,OAAO,CAAA;QACpB,CAAC;QACD,GAAG,EAAE,CAAC,MAAc,EAAQ,EAAE;YAC5B,OAAO,GAAG,MAAM,CAAA;QAClB,CAAC;KACF,CAAA;AACH,CAAC,CAAA"} \ No newline at end of file +{"version":3,"file":"clock.js","sourceRoot":"","sources":["../src/clock.ts"],"names":[],"mappings":"AAaA,2EAA2E;AAC3E,MAAM,CAAC,MAAM,eAAe,GAAG,GAAU,EAAE,CAAC,CAAC,EAAE,GAAG,EAAE,GAAW,EAAE,CAAC,WAAW,CAAC,GAAG,EAAE,EAAE,CAAC,CAAA;AAEtF;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,SAAS,GAAU,eAAe,EAAE,CAAA;AAWjD,uEAAuE;AACvE,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,OAAO,GAAG,CAAC,EAAe,EAAE;IAC5D,IAAI,OAAO,GAAG,OAAO,CAAA;IACrB,OAAO;QACL,OAAO,EAAE,CAAC,OAAe,EAAQ,EAAE;YACjC,OAAO,IAAI,OAAO,CAAA;QACpB,CAAC;QACD,GAAG,EAAE,GAAW,EAAE,CAAC,OAAO;QAC1B,GAAG,EAAE,CAAC,MAAc,EAAQ,EAAE;YAC5B,OAAO,GAAG,MAAM,CAAA;QAClB,CAAC;KACF,CAAA;AACH,CAAC,CAAA"} \ No newline at end of file diff --git a/ts_core/dist/index.d.ts b/ts_core/dist/index.d.ts index 3b36514..1e52674 100644 --- a/ts_core/dist/index.d.ts +++ b/ts_core/dist/index.d.ts @@ -6,6 +6,6 @@ * -- that is the coupling that makes people vendor their own copy instead, * which is how these ended up duplicated four ways to begin with. */ -export { createManualClock, createRealClock, realClock, type Clock, type ManualClock, } from './clock.ts'; +export { type Clock, createManualClock, createRealClock, type ManualClock, realClock, } from './clock.ts'; export { createRng, createSeededRng, nextChance, nextFloat, nextInt, pick, type Rng, type SeededRng, } from './rng.ts'; //# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/ts_core/dist/index.d.ts.map b/ts_core/dist/index.d.ts.map index f06cc7b..5c62b81 100644 --- a/ts_core/dist/index.d.ts.map +++ b/ts_core/dist/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EACL,iBAAiB,EACjB,eAAe,EACf,SAAS,EACT,KAAK,KAAK,EACV,KAAK,WAAW,GACjB,MAAM,YAAY,CAAA;AACnB,OAAO,EACL,SAAS,EACT,eAAe,EACf,UAAU,EACV,SAAS,EACT,OAAO,EACP,IAAI,EACJ,KAAK,GAAG,EACR,KAAK,SAAS,GACf,MAAM,UAAU,CAAA"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EACL,KAAK,KAAK,EACV,iBAAiB,EACjB,eAAe,EACf,KAAK,WAAW,EAChB,SAAS,GACV,MAAM,YAAY,CAAA;AACnB,OAAO,EACL,SAAS,EACT,eAAe,EACf,UAAU,EACV,SAAS,EACT,OAAO,EACP,IAAI,EACJ,KAAK,GAAG,EACR,KAAK,SAAS,GACf,MAAM,UAAU,CAAA"} \ No newline at end of file diff --git a/ts_core/dist/index.js.map b/ts_core/dist/index.js.map index f8480a1..2cfa470 100644 --- a/ts_core/dist/index.js.map +++ b/ts_core/dist/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EACL,iBAAiB,EACjB,eAAe,EACf,SAAS,GAGV,MAAM,YAAY,CAAA;AACnB,OAAO,EACL,SAAS,EACT,eAAe,EACf,UAAU,EACV,SAAS,EACT,OAAO,EACP,IAAI,GAGL,MAAM,UAAU,CAAA"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAEL,iBAAiB,EACjB,eAAe,EAEf,SAAS,GACV,MAAM,YAAY,CAAA;AACnB,OAAO,EACL,SAAS,EACT,eAAe,EACf,UAAU,EACV,SAAS,EACT,OAAO,EACP,IAAI,GAGL,MAAM,UAAU,CAAA"} \ No newline at end of file diff --git a/ts_core/dist/rng.d.ts b/ts_core/dist/rng.d.ts index c9b1015..691abb7 100644 --- a/ts_core/dist/rng.d.ts +++ b/ts_core/dist/rng.d.ts @@ -54,14 +54,14 @@ export declare const pick: (rng: Rng, items: readonly [T, ...T[]]) => T; * around rather than threading mutable state through free functions. */ export interface SeededRng { + /** True with the given probability in [0, 1]. */ + chance: (probability: number) => boolean; /** Uniform float in [0, 1). */ float: () => number; /** Uniform integer in [minInclusive, maxInclusive]. */ int: (minInclusive: number, maxInclusive: number) => number; /** Uniform element of a non-empty array. */ pick: (items: readonly T[]) => T; - /** True with the given probability in [0, 1]. */ - chance: (probability: number) => boolean; } /** * Creates a {@link SeededRng} over the same mulberry32 core. diff --git a/ts_core/dist/rng.d.ts.map b/ts_core/dist/rng.d.ts.map index b0c2d38..d54897a 100644 --- a/ts_core/dist/rng.d.ts.map +++ b/ts_core/dist/rng.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"rng.d.ts","sourceRoot":"","sources":["../src/rng.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,gDAAgD;AAChD,MAAM,WAAW,GAAG;IAClB,qDAAqD;IACrD,CAAC,EAAE,MAAM,CAAA;CACV;AAED,mDAAmD;AACnD,eAAO,MAAM,SAAS,GAAI,MAAM,MAAM,KAAG,GAA0B,CAAA;AAEnE;;;;;GAKG;AACH,eAAO,MAAM,SAAS,GAAI,KAAK,GAAG,KAAG,MAMpC,CAAA;AAED,uEAAuE;AACvE,eAAO,MAAM,OAAO,GAClB,KAAK,GAAG,EACR,cAAc,MAAM,EACpB,cAAc,MAAM,KACnB,MAC4E,CAAA;AAE/E,2DAA2D;AAC3D,eAAO,MAAM,UAAU,GAAI,KAAK,GAAG,EAAE,aAAa,MAAM,KAAG,OAC7B,CAAA;AAE9B;;;;;;GAMG;AACH,eAAO,MAAM,IAAI,GAAI,CAAC,EAAE,KAAK,GAAG,EAAE,OAAO,SAAS,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC,KAAG,CAW/D,CAAA;AAED;;;GAGG;AACH,MAAM,WAAW,SAAS;IACxB,+BAA+B;IAC/B,KAAK,EAAE,MAAM,MAAM,CAAA;IACnB,uDAAuD;IACvD,GAAG,EAAE,CAAC,YAAY,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,KAAK,MAAM,CAAA;IAC3D,4CAA4C;IAC5C,IAAI,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,SAAS,CAAC,EAAE,KAAK,CAAC,CAAA;IACnC,iDAAiD;IACjD,MAAM,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAA;CACzC;AAED;;;;;;GAMG;AACH,eAAO,MAAM,eAAe,GAAI,MAAM,MAAM,KAAG,SAgB9C,CAAA"} \ No newline at end of file +{"version":3,"file":"rng.d.ts","sourceRoot":"","sources":["../src/rng.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,gDAAgD;AAChD,MAAM,WAAW,GAAG;IAClB,qDAAqD;IACrD,CAAC,EAAE,MAAM,CAAA;CACV;AAED,mDAAmD;AACnD,eAAO,MAAM,SAAS,GAAI,MAAM,MAAM,KAAG,GAA0B,CAAA;AAEnE;;;;;GAKG;AACH,eAAO,MAAM,SAAS,GAAI,KAAK,GAAG,KAAG,MAMpC,CAAA;AAED,uEAAuE;AACvE,eAAO,MAAM,OAAO,GAClB,KAAK,GAAG,EACR,cAAc,MAAM,EACpB,cAAc,MAAM,KACnB,MAC4E,CAAA;AAE/E,2DAA2D;AAC3D,eAAO,MAAM,UAAU,GAAI,KAAK,GAAG,EAAE,aAAa,MAAM,KAAG,OAC7B,CAAA;AAE9B;;;;;;GAMG;AACH,eAAO,MAAM,IAAI,GAAI,CAAC,EAAE,KAAK,GAAG,EAAE,OAAO,SAAS,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC,KAAG,CAW/D,CAAA;AAED;;;GAGG;AACH,MAAM,WAAW,SAAS;IACxB,iDAAiD;IACjD,MAAM,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAA;IACxC,+BAA+B;IAC/B,KAAK,EAAE,MAAM,MAAM,CAAA;IACnB,uDAAuD;IACvD,GAAG,EAAE,CAAC,YAAY,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,KAAK,MAAM,CAAA;IAC3D,4CAA4C;IAC5C,IAAI,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,SAAS,CAAC,EAAE,KAAK,CAAC,CAAA;CACpC;AAED;;;;;;GAMG;AACH,eAAO,MAAM,eAAe,GAAI,MAAM,MAAM,KAAG,SAgB9C,CAAA"} \ No newline at end of file diff --git a/ts_core/dist/rng.js b/ts_core/dist/rng.js index 6aa0d80..6fdf654 100644 --- a/ts_core/dist/rng.js +++ b/ts_core/dist/rng.js @@ -32,7 +32,7 @@ export const createRng = (seed) => ({ s: seed >>> 0 }); * of it, so they all consume exactly one step per call. */ export const nextFloat = (rng) => { - rng.s = (rng.s + 0x6d_2b_79_f5) >>> 0; + rng.s = (rng.s + 0x6D_2B_79_F5) >>> 0; let t = rng.s; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); @@ -72,6 +72,7 @@ export const createSeededRng = (seed) => { const state = createRng(seed); const float = () => nextFloat(state); return { + chance: (probability) => float() < probability, float, int: (minInclusive, maxInclusive) => nextInt(state, minInclusive, maxInclusive), pick: (items) => { @@ -81,7 +82,6 @@ export const createSeededRng = (seed) => { } return chosen; }, - chance: (probability) => float() < probability, }; }; //# sourceMappingURL=rng.js.map \ No newline at end of file diff --git a/ts_core/dist/rng.js.map b/ts_core/dist/rng.js.map index d176a26..254b927 100644 --- a/ts_core/dist/rng.js.map +++ b/ts_core/dist/rng.js.map @@ -1 +1 @@ -{"version":3,"file":"rng.js","sourceRoot":"","sources":["../src/rng.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"rng.js","sourceRoot":"","sources":["../src/rng.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/ts_core/eslint.config.js b/ts_core/eslint.config.js index be5b2e0..75d62d0 100644 --- a/ts_core/eslint.config.js +++ b/ts_core/eslint.config.js @@ -1,28 +1,24 @@ -// Type-aware flat config, mirroring web_ui's so the two shared TS packages -// hold an identical bar. -import js from "@eslint/js"; -import tseslint from "typescript-eslint"; +// The shared preset, nothing else local except one frozen API name. This +// package is one of the repos the bar is defined for. Framework-free, so no +// React layer. +import { defineConfig } from "@kuhyx/ts-config"; -export default tseslint.config( - { ignores: ["dist", "coverage", "eslint.config.js"] }, - { linterOptions: { reportUnusedDisableDirectives: "error" } }, - - js.configs.recommended, - ...tseslint.configs.strictTypeChecked, - ...tseslint.configs.stylisticTypeChecked, - - { - files: ["**/*.ts"], - languageOptions: { - parserOptions: { - project: ["./tsconfig.lint.json"], - tsconfigRootDir: import.meta.dirname, +export default defineConfig({ + // Flat files under src/, no layers to fence and no alias configured. + aliasOnly: false, + overrides: [ + { + files: ["src/rng.ts"], + rules: { + // `nextChance` is public API consumed by three repos; the preset's + // boolean-name prefixes would rename it to `isNextChance`, which is + // worse prose and a breaking change for no safety gain. + "unicorn/consistent-boolean-name": "off", }, }, - rules: { - // Every exported symbol crosses a package boundary, so an explicit - // return type is documentation, not ceremony. - "@typescript-eslint/explicit-function-return-type": "error", - }, - }, -); + ], + // tsconfig.json excludes the tests so they never land in dist/; the lint + // project widens the include for type-aware rules (see tsconfig.lint.json). + project: ["./tsconfig.lint.json"], + tsconfigRootDir: import.meta.dirname, +}); diff --git a/ts_core/package.json b/ts_core/package.json index f566bfc..9f6e11a 100644 --- a/ts_core/package.json +++ b/ts_core/package.json @@ -1,6 +1,6 @@ { "name": "@kuhyx/ts-core", - "version": "0.1.0", + "version": "0.1.1", "private": false, "type": "module", "description": "Framework-free TypeScript utilities shared across kuhy's games and web apps: an injectable Clock and a seeded mulberry32 RNG. Deliberately no React and no CSS -- see @kuhyx/web-ui for the UI layer.", @@ -9,7 +9,7 @@ "node": ">=24", "pnpm": ">=10" }, - "packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c", + "packageManager": "pnpm@12.4.2", "files": [ "dist" ], @@ -29,11 +29,12 @@ }, "devDependencies": { "@eslint/js": "10.0.1", + "@kuhyx/ts-config": "link:../ts_config", "@types/node": "26.5.1", - "@vitest/coverage-v8": "5.0.0", + "@vitest/coverage-v8": "5.0.1", "eslint": "10.10.0", "typescript": "6.0.3", "typescript-eslint": "8.70.0", - "vitest": "5.0.0" + "vitest": "5.0.1" } } diff --git a/ts_core/pnpm-lock.yaml b/ts_core/pnpm-lock.yaml index 79f0069..87da34f 100644 --- a/ts_core/pnpm-lock.yaml +++ b/ts_core/pnpm-lock.yaml @@ -169,12 +169,15 @@ importers: '@eslint/js': specifier: 10.0.1 version: 10.0.1(eslint@10.10.0(supports-color@7.2.0)) + '@kuhyx/ts-config': + specifier: link:../ts_config + version: link:../ts_config '@types/node': specifier: 26.5.1 version: 26.5.1 '@vitest/coverage-v8': - specifier: 5.0.0 - version: 5.0.0(vitest@5.0.0) + specifier: 5.0.1 + version: 5.0.1(vitest@5.0.1) eslint: specifier: 10.10.0 version: 10.10.0(supports-color@7.2.0) @@ -185,8 +188,8 @@ importers: specifier: 8.70.0 version: 8.70.0(eslint@10.10.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) vitest: - specifier: 5.0.0 - version: 5.0.0(@types/node@26.5.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.1(@types/node@26.5.1)) + specifier: 5.0.1 + version: 5.0.1(@types/node@26.5.1)(@vitest/coverage-v8@5.0.1)(vite@8.2.1(@types/node@26.5.1)) packages: @@ -280,9 +283,6 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@jridgewell/sourcemap-codec@1.6.0': resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} @@ -471,11 +471,11 @@ packages: resolution: {integrity: sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@vitest/coverage-v8@5.0.0': - resolution: {integrity: sha512-toMg6PZGCIa/lQNCDoASrfb1ly4hsUKXFtFYC9kD4t78o5Y6LyNJU7AENt8eHPr3quYdxaxK7hj2mnbFfUk9NA==} + '@vitest/coverage-v8@5.0.1': + resolution: {integrity: sha512-FRC8ACiudC3dI6MTplzRSYWHDRnIv2IPfbzs4FdoJNsMal/35sWV8hwIfV8ZcqzSPy+uXHeMVONt9CEqtOU17w==} peerDependencies: - '@vitest/browser': 5.0.0 - vitest: 5.0.0 + '@vitest/browser': 5.0.1 + vitest: 5.0.1 peerDependenciesMeta: '@vitest/browser': optional: true @@ -488,8 +488,8 @@ packages: resolution: {integrity: sha512-1EOLRfsTMnyAr3+kEAsP4o9dhaDlGPpD7H5iLBBeq//YpNB1VIahkPhB+eRp9N2Dkfw8oySROjE3yf9XDeaIkQ==} engines: {node: '>=22'} - '@vitest/mocker@5.0.0': - resolution: {integrity: sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==} + '@vitest/mocker@5.0.1': + resolution: {integrity: sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -499,8 +499,8 @@ packages: vite: optional: true - '@vitest/spy@5.0.0': - resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==} + '@vitest/spy@5.0.1': + resolution: {integrity: sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==} acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} @@ -978,20 +978,20 @@ packages: yaml: optional: true - vitest@5.0.0: - resolution: {integrity: sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==} + vitest@5.0.1: + resolution: {integrity: sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==} engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 5.0.0 - '@vitest/browser-preview': 5.0.0 + '@vitest/browser-playwright': 5.0.1 + '@vitest/browser-preview': 5.0.1 '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 - '@vitest/coverage-istanbul': 5.0.0 - '@vitest/coverage-v8': 5.0.0 - '@vitest/ui': 5.0.0 + '@vitest/coverage-istanbul': 5.0.1 + '@vitest/coverage-v8': 5.0.1 + '@vitest/ui': 5.0.1 happy-dom: '*' jsdom: '*' vite: ^6.4.0 || ^7.0.0 || ^8.0.0 @@ -1118,14 +1118,12 @@ snapshots: '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/sourcemap-codec@1.5.5': {} - '@jridgewell/sourcemap-codec@1.6.0': {} '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@keyv/bigmap@1.3.1(keyv@5.6.0)': dependencies: @@ -1289,7 +1287,7 @@ snapshots: '@typescript-eslint/types': 8.70.0 eslint-visitor-keys: 5.0.1 - '@vitest/coverage-v8@5.0.0(vitest@5.0.0)': + '@vitest/coverage-v8@5.0.1(vitest@5.0.1)': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/istanbul-lib-coverage': 1.0.1 @@ -1299,7 +1297,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 5.0.0(@types/node@26.5.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.1(@types/node@26.5.1)) + vitest: 5.0.1(@types/node@26.5.1)(@vitest/coverage-v8@5.0.1)(vite@8.2.1(@types/node@26.5.1)) '@vitest/istanbul-lib-coverage@1.0.1': {} @@ -1307,16 +1305,16 @@ snapshots: dependencies: '@vitest/istanbul-lib-coverage': 1.0.1 - '@vitest/mocker@5.0.0(vite@8.2.1(@types/node@26.5.1))': + '@vitest/mocker@5.0.1(vite@8.2.1(@types/node@26.5.1))': dependencies: '@jridgewell/trace-mapping': 0.3.31 - '@vitest/spy': 5.0.0 + '@vitest/spy': 5.0.1 estree-walker: 3.0.3 magic-string: 1.3.1 optionalDependencies: vite: 8.2.1(@types/node@26.5.1) - '@vitest/spy@5.0.0': {} + '@vitest/spy@5.0.1': {} acorn-jsx@5.3.2(acorn@8.18.0): dependencies: @@ -1725,10 +1723,10 @@ snapshots: '@types/node': 26.5.1 fsevents: 2.3.3 - vitest@5.0.0(@types/node@26.5.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.1(@types/node@26.5.1)): + vitest@5.0.1(@types/node@26.5.1)(@vitest/coverage-v8@5.0.1)(vite@8.2.1(@types/node@26.5.1)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.0(vite@8.2.1(@types/node@26.5.1)) + '@vitest/mocker': 5.0.1(vite@8.2.1(@types/node@26.5.1)) chai: 6.2.2 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -1743,7 +1741,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.5.1 - '@vitest/coverage-v8': 5.0.0(vitest@5.0.0) + '@vitest/coverage-v8': 5.0.1(vitest@5.0.1) transitivePeerDependencies: - msw diff --git a/ts_core/src/clock.test.ts b/ts_core/src/clock.test.ts index 3835c66..d57a80e 100644 --- a/ts_core/src/clock.test.ts +++ b/ts_core/src/clock.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from 'vitest' import { + type Clock, createManualClock, createRealClock, realClock, - type Clock, } from './clock.ts' describe('createRealClock', () => { diff --git a/ts_core/src/clock.ts b/ts_core/src/clock.ts index 66ffe51..8d4535e 100644 --- a/ts_core/src/clock.ts +++ b/ts_core/src/clock.ts @@ -12,9 +12,7 @@ export interface Clock { } /** A {@link Clock} backed by the browser's monotonic performance timer. */ -export const createRealClock = (): Clock => ({ - now: (): number => performance.now(), -}) +export const createRealClock = (): Clock => ({ now: (): number => performance.now() }) /** * The one real clock instance UI code should pass around. @@ -39,10 +37,10 @@ export interface ManualClock extends Clock { export const createManualClock = (startMs = 0): ManualClock => { let current = startMs return { - now: (): number => current, advance: (deltaMs: number): void => { current += deltaMs }, + now: (): number => current, set: (timeMs: number): void => { current = timeMs }, diff --git a/ts_core/src/index.ts b/ts_core/src/index.ts index 2af2421..e9abf5f 100644 --- a/ts_core/src/index.ts +++ b/ts_core/src/index.ts @@ -7,11 +7,11 @@ * which is how these ended up duplicated four ways to begin with. */ export { + type Clock, createManualClock, createRealClock, - realClock, - type Clock, type ManualClock, + realClock, } from './clock.ts' export { createRng, diff --git a/ts_core/src/rng.test.ts b/ts_core/src/rng.test.ts index 8a667c7..72c255d 100644 --- a/ts_core/src/rng.test.ts +++ b/ts_core/src/rng.test.ts @@ -20,8 +20,8 @@ import { * would defeat the entire point of the test. */ const GOLDEN_12345 = [ - 0.979_728_267_760_947_3, 0.306_752_264_499_664_3, 0.484_205_421_525_985, - 0.817_934_412_509_203, 0.509_428_369_347_006_1, + 0.9797282677609473, 0.3067522644996643, 0.484205421525985, + 0.817934412509203, 0.5094283693470061, ] describe('mulberry32 core', () => { @@ -51,7 +51,7 @@ describe('mulberry32 core', () => { it('stays within [0, 1) across many draws', () => { const rng = createRng(42) - for (let i = 0; i < 1000; i += 1) { + for (let index = 0; index < 1000; index += 1) { const value = nextFloat(rng) expect(value).toBeGreaterThanOrEqual(0) expect(value).toBeLessThan(1) @@ -68,10 +68,10 @@ describe('nextInt', () => { it('is inclusive of both bounds', () => { const rng = createRng(3) const seen = new Set() - for (let i = 0; i < 500; i += 1) { + for (let index = 0; index < 500; index += 1) { seen.add(nextInt(rng, 1, 3)) } - expect([...seen].sort()).toEqual([1, 2, 3]) + expect([...seen].toSorted((a, b) => a - b)).toEqual([1, 2, 3]) }) it('returns the only value when min equals max', () => { @@ -97,7 +97,7 @@ describe('nextChance', () => { it('lands near the requested probability over many draws', () => { const rng = createRng(2024) let hits = 0 - for (let i = 0; i < 4000; i += 1) { + for (let index = 0; index < 4000; index += 1) { if (nextChance(rng, 0.25)) { hits += 1 } @@ -115,10 +115,10 @@ describe('pick', () => { it('eventually returns every element', () => { const rng = createRng(4) const seen = new Set() - for (let i = 0; i < 500; i += 1) { + for (let index = 0; index < 500; index += 1) { seen.add(pick(rng, ['a', 'b', 'c'])) } - expect([...seen].sort()).toEqual(['a', 'b', 'c']) + expect([...seen].toSorted((a, b) => a.localeCompare(b))).toEqual(['a', 'b', 'c']) }) it('is deterministic for a given seed', () => { diff --git a/ts_core/src/rng.ts b/ts_core/src/rng.ts index 4338d2e..b0c6cf9 100644 --- a/ts_core/src/rng.ts +++ b/ts_core/src/rng.ts @@ -40,7 +40,7 @@ export const createRng = (seed: number): Rng => ({ s: seed >>> 0 }) * of it, so they all consume exactly one step per call. */ export const nextFloat = (rng: Rng): number => { - rng.s = (rng.s + 0x6d_2b_79_f5) >>> 0 + rng.s = (rng.s + 0x6D_2B_79_F5) >>> 0 let t = rng.s t = Math.imul(t ^ (t >>> 15), t | 1) t ^= t + Math.imul(t ^ (t >>> 7), t | 61) @@ -84,14 +84,14 @@ export const pick = (rng: Rng, items: readonly [T, ...T[]]): T => { * around rather than threading mutable state through free functions. */ export interface SeededRng { + /** True with the given probability in [0, 1]. */ + chance: (probability: number) => boolean /** Uniform float in [0, 1). */ float: () => number /** Uniform integer in [minInclusive, maxInclusive]. */ int: (minInclusive: number, maxInclusive: number) => number /** Uniform element of a non-empty array. */ pick: (items: readonly T[]) => T - /** True with the given probability in [0, 1]. */ - chance: (probability: number) => boolean } /** @@ -105,6 +105,7 @@ export const createSeededRng = (seed: number): SeededRng => { const state = createRng(seed) const float = (): number => nextFloat(state) return { + chance: (probability: number): boolean => float() < probability, float, int: (minInclusive: number, maxInclusive: number): number => nextInt(state, minInclusive, maxInclusive), @@ -115,6 +116,5 @@ export const createSeededRng = (seed: number): SeededRng => { } return chosen }, - chance: (probability: number): boolean => float() < probability, } } diff --git a/ts_core/tsconfig.json b/ts_core/tsconfig.json index 12dc55e..1223e51 100644 --- a/ts_core/tsconfig.json +++ b/ts_core/tsconfig.json @@ -1,4 +1,6 @@ { + // Strictness comes from the shared base; only output/lib options live here. + "extends": "@kuhyx/ts-config/tsconfig", "compilerOptions": { "target": "ES2023", // DOM is here only for `performance.now()` in clock.ts -- the package @@ -9,21 +11,6 @@ "module": "ESNext", "moduleResolution": "bundler", - /* Strictness: everything on, matching the consumers so a file that - compiles here compiles there. */ - "strict": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "exactOptionalPropertyTypes": true, - "useUnknownInCatchVariables": true, - "verbatimModuleSyntax": true, - "isolatedModules": true, - "erasableSyntaxOnly": true, - /* Ships a prebuilt dist/ (ESM + .d.ts) because consumers use Vite, which does not transpile dependencies. */ "rewriteRelativeImportExtensions": true, @@ -32,10 +19,7 @@ "declarationMap": true, "sourceMap": true, "outDir": "dist", - "rootDir": "src", - - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true + "rootDir": "src" }, "include": ["src"], "exclude": ["src/**/*.test.ts"] diff --git a/web_ui/dist/index.d.ts b/web_ui/dist/index.d.ts index 698bcea..9f2ff8e 100644 --- a/web_ui/dist/index.d.ts +++ b/web_ui/dist/index.d.ts @@ -5,6 +5,7 @@ * `import "@kuhyx/web-ui/tokens.css";` */ export { clamp01, fractionFromPointer, nth, quantileValue, valueQuantile } from "./quantile.ts"; -export { RangeSlider, steppedValue, type RangeSliderProps } from "./range-slider.tsx"; +export { RangeSlider, type RangeSliderProps } from "./range-slider.tsx"; +export { keyTarget, steppedValue } from "./stepped-value.ts"; export { fuzzyMatch } from "./text.ts"; //# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/web_ui/dist/index.d.ts.map b/web_ui/dist/index.d.ts.map index 6f893b9..fd9c4a1 100644 --- a/web_ui/dist/index.d.ts.map +++ b/web_ui/dist/index.d.ts.map @@ -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"} \ No newline at end of file +{"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,KAAK,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AACxE,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAC7D,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC"} \ No newline at end of file diff --git a/web_ui/dist/index.js b/web_ui/dist/index.js index 8afe078..1625185 100644 --- a/web_ui/dist/index.js +++ b/web_ui/dist/index.js @@ -5,6 +5,7 @@ * `import "@kuhyx/web-ui/tokens.css";` */ export { clamp01, fractionFromPointer, nth, quantileValue, valueQuantile } from "./quantile.js"; -export { RangeSlider, steppedValue } from "./range-slider.js"; +export { RangeSlider } from "./range-slider.js"; +export { keyTarget, steppedValue } from "./stepped-value.js"; export { fuzzyMatch } from "./text.js"; //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/web_ui/dist/index.js.map b/web_ui/dist/index.js.map index 9eac3c7..2a51007 100644 --- a/web_ui/dist/index.js.map +++ b/web_ui/dist/index.js.map @@ -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"} \ No newline at end of file +{"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,EAAyB,MAAM,mBAAoB,CAAC;AACxE,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAC7D,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC"} \ No newline at end of file diff --git a/web_ui/dist/quantile.d.ts.map b/web_ui/dist/quantile.d.ts.map index a9122bd..37ab4ba 100644 --- a/web_ui/dist/quantile.d.ts.map +++ b/web_ui/dist/quantile.d.ts.map @@ -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"} \ No newline at end of file +{"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,CAY9E;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAE1E"} \ No newline at end of file diff --git a/web_ui/dist/quantile.js b/web_ui/dist/quantile.js index e851fe4..6965f27 100644 --- a/web_ui/dist/quantile.js +++ b/web_ui/dist/quantile.js @@ -52,9 +52,7 @@ export function quantileValue(sorted, f) { */ export function valueQuantile(sorted, value) { const n = sorted.length; - if (n <= 1) - return 0; - if (value <= nth(sorted, 0)) + if ((n <= 1) || (value <= nth(sorted, 0))) return 0; if (value >= nth(sorted, n - 1)) return 1; diff --git a/web_ui/dist/quantile.js.map b/web_ui/dist/quantile.js.map index bee78f1..2e8aae8 100644 --- a/web_ui/dist/quantile.js.map +++ b/web_ui/dist/quantile.js.map @@ -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,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"} \ No newline at end of file +{"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,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,KAAK,IAAI,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;QAAE,OAAO,CAAC,CAAC;IACpD,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"} \ No newline at end of file diff --git a/web_ui/dist/range-slider.d.ts b/web_ui/dist/range-slider.d.ts index 0bf8b57..539390f 100644 --- a/web_ui/dist/range-slider.d.ts +++ b/web_ui/dist/range-slider.d.ts @@ -16,16 +16,5 @@ export interface RangeSliderProps { /** The full ascending distribution — not just its endpoints. */ readonly values: readonly number[]; } -/** - * Steps one thumb by `delta` positions through the distribution. Pure, so the - * keyboard contract is testable without layout. - * - * Steps to the next *distinct* value rather than the next index. Real - * distributions are lumpy -- awesome-mcp-explorer's star counts hold ~1500 - * duplicate zeros -- and an index step inside a run of equal values changes the - * index while leaving the value (and therefore the thumb, and the filter) - * exactly where it was. That reads as a dead arrow key. - */ -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 \ No newline at end of file diff --git a/web_ui/dist/range-slider.d.ts.map b/web_ui/dist/range-slider.d.ts.map index af3fcb5..9faf123 100644 --- a/web_ui/dist/range-slider.d.ts.map +++ b/web_ui/dist/range-slider.d.ts.map @@ -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"} \ No newline at end of file +{"version":3,"file":"range-slider.d.ts","sourceRoot":"","sources":["../src/range-slider.tsx"],"names":[],"mappings":"AA0BA,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,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,CAkG7C"} \ No newline at end of file diff --git a/web_ui/dist/range-slider.js b/web_ui/dist/range-slider.js index 9e238d7..66996c5 100644 --- a/web_ui/dist/range-slider.js +++ b/web_ui/dist/range-slider.js @@ -22,36 +22,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; */ import { useCallback, useState } from "react"; import { fractionFromPointer, nth, quantileValue, valueQuantile } from "./quantile.js"; -/** - * Steps one thumb by `delta` positions through the distribution. Pure, so the - * keyboard contract is testable without layout. - * - * Steps to the next *distinct* value rather than the next index. Real - * distributions are lumpy -- awesome-mcp-explorer's star counts hold ~1500 - * duplicate zeros -- and an index step inside a run of equal values changes the - * index while leaving the value (and therefore the thumb, and the filter) - * exactly where it was. That reads as a dead arrow key. - */ -export function steppedValue(values, current, delta) { - const last = values.length - 1; - // Round to a real sample index so a thumb sitting between two samples still - // advances rather than stalling on a fractional step. - const index = Math.round(valueQuantile(values, current) * last); - const direction = Math.sign(delta); - if (direction === 0) - return nth(values, index); - let cursor = index; - for (let taken = 0; taken < Math.abs(delta); taken++) { - let next = cursor + direction; - while (next >= 0 && next <= last && nth(values, next) === nth(values, cursor)) { - next += direction; - } - if (next < 0 || next > last) - break; - cursor = next; - } - return nth(values, cursor); -} +import { keyTarget } from "./stepped-value.js"; export function RangeSlider({ format, hi, label, lo, onChange, values, }) { const [drag, setDrag] = useState(null); const apply = useCallback((thumb, value) => { @@ -63,29 +34,17 @@ export function RangeSlider({ format, hi, label, lo, onChange, values, }) { // Reads the rect off the event's own target rather than a ref: the pointer // handlers are bound to the track, so currentTarget *is* the track. A ref // would add a null branch that cannot happen once the element is mounted. - const valueAt = useCallback((track, clientX) => quantileValue(values, fractionFromPointer(track.getBoundingClientRect(), clientX)), [values]); + const valueAt = useCallback((track, clientX) => { + const fraction = fractionFromPointer(track.getBoundingClientRect(), clientX); + return quantileValue(values, fraction); + }, [values]); const onKeyDown = useCallback((thumb, event) => { const current = thumb === "lo" ? lo : hi; - const step = (delta) => { - event.preventDefault(); - apply(thumb, steppedValue(values, current, delta)); - }; - if (event.key === "ArrowLeft" || event.key === "ArrowDown") - step(-1); - else if (event.key === "ArrowRight" || event.key === "ArrowUp") - step(1); - else if (event.key === "PageDown") - step(-10); - else if (event.key === "PageUp") - step(10); - else if (event.key === "Home") { - event.preventDefault(); - apply(thumb, nth(values, 0)); - } - else if (event.key === "End") { - event.preventDefault(); - apply(thumb, nth(values, values.length - 1)); - } + const target = keyTarget(event.key, values, current); + if (target === undefined) + return; + event.preventDefault(); + apply(thumb, target); }, [apply, hi, lo, values]); // Fewer than two values means there is nothing to range over. if (values.length < 2) @@ -95,19 +54,21 @@ export function RangeSlider({ format, hi, label, lo, onChange, values, }) { const loFraction = valueQuantile(values, lo) * 100; const hiFraction = valueQuantile(values, hi) * 100; const name = label ?? "Range"; - const thumbProps = (thumb) => ({ - "aria-valuemax": highest, - "aria-valuemin": lowest, - "aria-valuenow": thumb === "lo" ? lo : hi, - className: `slider-thumb slider-${thumb}`, - onKeyDown: (event) => { - onKeyDown(thumb, event); - }, - role: "slider", - style: { left: `${String(thumb === "lo" ? loFraction : hiFraction)}%` }, - tabIndex: 0, - type: "button", - }); + const thumbProps = (thumb) => { + return { + "aria-valuemax": highest, + "aria-valuemin": lowest, + "aria-valuenow": thumb === "lo" ? lo : hi, + className: `slider-thumb slider-${thumb}`, + onKeyDown: (event) => { + onKeyDown(thumb, event); + }, + role: "slider", + style: { left: `${String(thumb === "lo" ? loFraction : hiFraction)}%` }, + tabIndex: 0, + type: "button", + }; + }; return (_jsxs("div", { className: "slider", children: [label !== undefined && format !== undefined && (_jsxs("div", { className: "slider-head", children: [_jsx("span", { children: label }), _jsxs("span", { className: "slider-value", children: [format(lo), " \u2013 ", format(hi)] })] })), _jsxs("div", { className: "slider-track", onPointerDown: (event) => { const value = valueAt(event.currentTarget, event.clientX); // Grab whichever thumb is nearer, so a press anywhere on the track diff --git a/web_ui/dist/range-slider.js.map b/web_ui/dist/range-slider.js.map index 537c126..999e4aa 100644 --- a/web_ui/dist/range-slider.js.map +++ b/web_ui/dist/range-slider.js.map @@ -1 +1 @@ -{"version":3,"file":"range-slider.js","sourceRoot":"","sources":["../src/range-slider.tsx"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"range-slider.js","sourceRoot":"","sources":["../src/range-slider.tsx"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/web_ui/dist/stepped-value.d.ts b/web_ui/dist/stepped-value.d.ts new file mode 100644 index 0000000..2a583ab --- /dev/null +++ b/web_ui/dist/stepped-value.d.ts @@ -0,0 +1,19 @@ +/** + * Steps one thumb by `delta` positions through the distribution. Pure, so the + * keyboard contract is testable without layout. + * + * Steps to the next *distinct* value rather than the next index. Real + * distributions are lumpy -- awesome-mcp-explorer's star counts hold ~1500 + * duplicate zeros -- and an index step inside a run of equal values changes the + * index while leaving the value (and therefore the thumb, and the filter) + * exactly where it was. That reads as a dead arrow key. + */ +export declare function steppedValue(values: readonly number[], current: number, delta: number): number; +/** + * The value a thumb at `current` moves to for a key, or `undefined` when the + * key is not one the slider handles (so the event is left to the browser). + * Arrow keys step one distinct value, Page keys ten, Home/End jump to the + * bounds. + */ +export declare function keyTarget(key: string, values: readonly number[], current: number): number | undefined; +//# sourceMappingURL=stepped-value.d.ts.map \ No newline at end of file diff --git a/web_ui/dist/stepped-value.d.ts.map b/web_ui/dist/stepped-value.d.ts.map new file mode 100644 index 0000000..cdd6079 --- /dev/null +++ b/web_ui/dist/stepped-value.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"stepped-value.d.ts","sourceRoot":"","sources":["../src/stepped-value.ts"],"names":[],"mappings":"AAOA;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAC1B,MAAM,EAAE,SAAS,MAAM,EAAE,EACzB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,GACZ,MAAM,CAkBR;AAED;;;;;GAKG;AACH,wBAAgB,SAAS,CACvB,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,SAAS,MAAM,EAAE,EACzB,OAAO,EAAE,MAAM,GACd,MAAM,GAAG,SAAS,CA0BpB"} \ No newline at end of file diff --git a/web_ui/dist/stepped-value.js b/web_ui/dist/stepped-value.js new file mode 100644 index 0000000..f4b2737 --- /dev/null +++ b/web_ui/dist/stepped-value.js @@ -0,0 +1,70 @@ +/** + * The keyboard contract of the range slider, kept pure and in its own module + * so range-slider.tsx exports only the component (react-refresh needs that + * for HMR to keep working) and the contract is testable without layout. + */ +import { nth, valueQuantile } from "./quantile.js"; +/** + * Steps one thumb by `delta` positions through the distribution. Pure, so the + * keyboard contract is testable without layout. + * + * Steps to the next *distinct* value rather than the next index. Real + * distributions are lumpy -- awesome-mcp-explorer's star counts hold ~1500 + * duplicate zeros -- and an index step inside a run of equal values changes the + * index while leaving the value (and therefore the thumb, and the filter) + * exactly where it was. That reads as a dead arrow key. + */ +export function steppedValue(values, current, delta) { + const last = values.length - 1; + // Round to a real sample index so a thumb sitting between two samples still + // advances rather than stalling on a fractional step. + const index = Math.round(valueQuantile(values, current) * last); + const direction = Math.sign(delta); + if (direction === 0) + return nth(values, index); + let cursor = index; + for (let taken = 0; taken < Math.abs(delta); taken++) { + let next = cursor + direction; + while (next >= 0 && next <= last && nth(values, next) === nth(values, cursor)) { + next += direction; + } + if (next < 0 || next > last) + break; + cursor = next; + } + return nth(values, cursor); +} +/** + * The value a thumb at `current` moves to for a key, or `undefined` when the + * key is not one the slider handles (so the event is left to the browser). + * Arrow keys step one distinct value, Page keys ten, Home/End jump to the + * bounds. + */ +export function keyTarget(key, values, current) { + switch (key) { + case "ArrowDown": + case "ArrowLeft": { + return steppedValue(values, current, -1); + } + case "ArrowRight": + case "ArrowUp": { + return steppedValue(values, current, 1); + } + case "End": { + return nth(values, values.length - 1); + } + case "Home": { + return nth(values, 0); + } + case "PageDown": { + return steppedValue(values, current, -10); + } + case "PageUp": { + return steppedValue(values, current, 10); + } + default: { + return undefined; + } + } +} +//# sourceMappingURL=stepped-value.js.map \ No newline at end of file diff --git a/web_ui/dist/stepped-value.js.map b/web_ui/dist/stepped-value.js.map new file mode 100644 index 0000000..76f7772 --- /dev/null +++ b/web_ui/dist/stepped-value.js.map @@ -0,0 +1 @@ +{"version":3,"file":"stepped-value.js","sourceRoot":"","sources":["../src/stepped-value.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAE,GAAG,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAEnD;;;;;;;;;GASG;AACH,MAAM,UAAU,YAAY,CAC1B,MAAyB,EACzB,OAAe,EACf,KAAa;IAEb,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC;IAC/B,4EAA4E;IAC5E,sDAAsD;IACtD,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,aAAa,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC,CAAC;IAChE,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACnC,IAAI,SAAS,KAAK,CAAC;QAAE,OAAO,GAAG,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;IAE/C,IAAI,MAAM,GAAG,KAAK,CAAC;IACnB,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,KAAK,EAAE,EAAE,CAAC;QACrD,IAAI,IAAI,GAAG,MAAM,GAAG,SAAS,CAAC;QAC9B,OAAO,IAAI,IAAI,CAAC,IAAI,IAAI,IAAI,IAAI,IAAI,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,KAAK,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,CAAC;YAC9E,IAAI,IAAI,SAAS,CAAC;QACpB,CAAC;QACD,IAAI,IAAI,GAAG,CAAC,IAAI,IAAI,GAAG,IAAI;YAAE,MAAM;QACnC,MAAM,GAAG,IAAI,CAAC;IAChB,CAAC;IACD,OAAO,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAC7B,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,SAAS,CACvB,GAAW,EACX,MAAyB,EACzB,OAAe;IAEf,QAAQ,GAAG,EAAE,CAAC;QACZ,KAAK,WAAW,CAAC;QACjB,KAAK,WAAW,CAAC,CAAC,CAAC;YACjB,OAAO,YAAY,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,CAAC;QAC3C,CAAC;QACD,KAAK,YAAY,CAAC;QAClB,KAAK,SAAS,CAAC,CAAC,CAAC;YACf,OAAO,YAAY,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC;QAC1C,CAAC;QACD,KAAK,KAAK,CAAC,CAAC,CAAC;YACX,OAAO,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;QACxC,CAAC;QACD,KAAK,MAAM,CAAC,CAAC,CAAC;YACZ,OAAO,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;QACxB,CAAC;QACD,KAAK,UAAU,CAAC,CAAC,CAAC;YAChB,OAAO,YAAY,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC,CAAC;QAC5C,CAAC;QACD,KAAK,QAAQ,CAAC,CAAC,CAAC;YACd,OAAO,YAAY,CAAC,MAAM,EAAE,OAAO,EAAE,EAAE,CAAC,CAAC;QAC3C,CAAC;QACD,OAAO,CAAC,CAAC,CAAC;YACR,OAAO,SAAS,CAAC;QACnB,CAAC;IACH,CAAC;AACH,CAAC"} \ No newline at end of file diff --git a/web_ui/eslint.config.js b/web_ui/eslint.config.js index 2654444..e5faae9 100644 --- a/web_ui/eslint.config.js +++ b/web_ui/eslint.config.js @@ -1,42 +1,25 @@ -// 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"; +// The shared preset with the React layer, plus one frozen API name. This +// package is one of the repos the bar is defined for. +import { defineConfig } from "@kuhyx/ts-config"; -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, +export default defineConfig({ + // Flat files under src/, no layers to fence and no alias configured. + aliasOnly: false, + overrides: [ + { + files: ["src/text.ts"], + rules: { + // `fuzzyMatch` is public API at four call sites in dufs-cloud and + // awesome-mcp-explorer; the preset's prefix list would rename it to + // `matchesFuzzy`, a breaking change for no safety gain. Revisit at + // the next major. + "unicorn/consistent-boolean-name": "off", }, }, - 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" }, - }, -); + ], + // tsconfig.json excludes the tests so they never land in dist/; the lint + // project widens the include for type-aware rules (see tsconfig.lint.json). + project: ["./tsconfig.lint.json"], + react: true, + tsconfigRootDir: import.meta.dirname, +}); diff --git a/web_ui/package.json b/web_ui/package.json index f6de76e..4f58464 100644 --- a/web_ui/package.json +++ b/web_ui/package.json @@ -1,6 +1,6 @@ { "name": "@kuhyx/web-ui", - "version": "0.3.2", + "version": "0.4.0", "private": false, "type": "module", "description": "The shared token + component layer for kuhy's web apps: the frozen palette, spacing/radius/type scales as CSS custom properties, and the components that were each reimplemented in two or more repos.", @@ -9,7 +9,7 @@ "node": ">=24", "pnpm": ">=10" }, - "packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c", + "packageManager": "pnpm@12.4.2", "files": [ "dist" ], @@ -34,19 +34,20 @@ }, "devDependencies": { "@eslint/js": "10.0.1", + "@kuhyx/ts-config": "link:../ts_config", "@testing-library/jest-dom": "7.0.1", "@testing-library/react": "16.3.3", "@testing-library/user-event": "14.6.7", + "@types/node": "26.5.1", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", - "@vitest/coverage-v8": "5.0.0", + "@vitest/coverage-v8": "5.0.1", "eslint": "10.10.0", "jsdom": "30.0.1", "react": "19.3.0", "react-dom": "19.3.0", "typescript": "6.0.3", "typescript-eslint": "8.70.0", - "vitest": "5.0.0", - "@types/node": "26.5.1" + "vitest": "5.0.1" } } diff --git a/web_ui/pnpm-lock.yaml b/web_ui/pnpm-lock.yaml index 55fcbda..5290a91 100644 --- a/web_ui/pnpm-lock.yaml +++ b/web_ui/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.4.1 - version: 12.4.1 + specifier: 12.4.2 + version: 12.4.2 packages: - '@pnpm/exe.android-arm64@12.4.1': - resolution: {integrity: sha512-/HwsqXMSmlOfgtV9+O0ratzjV6Vd/8n1hh4rGHpCGmDURvt52MwZxdbhyxP4K03ZfvgSDvotFPr8O+RzE5Eu8A==} + '@pnpm/exe.android-arm64@12.4.2': + resolution: {integrity: sha512-E255MbcQ0V1577M2BV0ajWuP7KmTPYA0jqZnk3rK6Lq3kTvZWulMMb7iqRmnLsQbyWTkMEB2CfyM70loVDA8xg==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.4.1': - resolution: {integrity: sha512-+l74Qb4c2YjOzNKHXJLg+1wr8xHM1ckkUhnU5KRUK9TJqiiczt6yeTZqqzHIlJ8i6pAoj5V0OJevDRwnQKLgrQ==} + '@pnpm/exe.android-x64@12.4.2': + resolution: {integrity: sha512-J1pSeCUwuKxMG70ZzpWn8JzElxiEa8NN/3N0SlZRtU2xbztChaJCKF3HaBNIj9yPfeofWzJo/lwNvDDjraQuZw==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.4.1': - resolution: {integrity: sha512-6rkZkT3iGfaxknUdGHraqSWFvTa6N0ajAHluv9Ax0GRWs0sIcGNiFhDopv6xSZCsJZmG483aNS/b6UEDy3blfw==} + '@pnpm/exe.darwin-arm64@12.4.2': + resolution: {integrity: sha512-A0WDo8iErfZBXgrLseQxw8i8Y9ctUpOEl/Uu+cubnTzpD8tT9ykIB548L8YTM2WD4OS+ZOHSxy8aGZcvKq8PaQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.4.1': - resolution: {integrity: sha512-Vb1CHlR88HghC1qUxjxjs82zQSXnXacPD+btG2CmG8Q/hBU7Q0/b2YWJKSQqZXicunV5khFtfTlAwJddV9RkYA==} + '@pnpm/exe.darwin-x64@12.4.2': + resolution: {integrity: sha512-MSgJdovBWHcb5DEOvfPH9yNi/T5O1Xa4ess+E1ESGo/5yuty7S4JoiIjami+fsNXfnoQMlwsMUqYU4zAvBcNCw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.4.1': - resolution: {integrity: sha512-iT3iHz3Nl0Sxxj7UPOtZ/aQ81AFsQhjoeWMAlPkSRO04gsgDGAXv3UYxOFaesMWsfNxaGn0A+CITbuCHFF66FA==} + '@pnpm/exe.freebsd-x64@12.4.2': + resolution: {integrity: sha512-h2YumlQSNvgbRPv+RXwABohX65f9bOBZn+jMIt7bFDISZPCzQ+Nvpt6Awbp4ip5PwQgYxbu5iREJ1fHE39Fm8A==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.4.1': - resolution: {integrity: sha512-aBooZfNXM5f+OGUgCAMFWpE/kAhsWfvmqIyMtHy6zl3aNxyInWrcY/Saln/UElzo7lZWMC0Yktroxd/2J26lNQ==} + '@pnpm/exe.linux-arm64-musl@12.4.2': + resolution: {integrity: sha512-LwSEtSEDTv6S51YLs3YvSkPyun/QmfMic1UGICUkPWFu6ByP43RdMlkKvmVkfGhAYCpnxO057vrmyJqtfZrPCA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.4.1': - resolution: {integrity: sha512-TlOdacTTP09BgcMvwWBFRsu8VAjfwqwnslBj+XSq1JFM3ck4f3k+1O/747EuctxZxs3/o785b6Q3s7Pd92Ptsg==} + '@pnpm/exe.linux-arm64@12.4.2': + resolution: {integrity: sha512-2dSiDXyhx+RTHsewxex8f/jVjqQXWJ2oow4kCVHEWdZKeBpgMxvZ6fHkTAUBJXgqhbKIDHvuNlZBP7gJfUWL5A==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.4.1': - resolution: {integrity: sha512-r/ab/MlIBo75oizUP5ITiziCCrnXz4SJwfErLQ+603AshB+Yq7xTMCoMtzTSCAMu6aaymIKkAFtZvd2J75Wq0w==} + '@pnpm/exe.linux-ppc64@12.4.2': + resolution: {integrity: sha512-8Itc+jQk+MTz04LS9D1RcH+VctAWmzM4l1cJQ+Sx7pAJNo7EKHdRMbC0Tok1jyQ7eEHNJZD5EleYneZqWfZM+g==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.4.1': - resolution: {integrity: sha512-C/D1QWdKMiB8+wv/spl1rGITFUuqC+aI/fb6h8NB5sqxsOaGXeYc/g891oCuzggHn6SODk9p+I09hI76x/cMNw==} + '@pnpm/exe.linux-riscv64@12.4.2': + resolution: {integrity: sha512-hleOeqhTVpH+z9RVMGnxvU4ZnrkBUClWjCbHD9u6kwyqhGSpevoU1wTGish+CBRhmIgMAy9pAfFpqhbAKOKNfw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.4.1': - resolution: {integrity: sha512-nxz5zD4yXt94uzbStDk0QTPKW+aE92hH1b5tFXK9ctB1HE9Xcq1vjTiA2LsZMFC6p4gdu5OwQeFqYNAVGa6QlA==} + '@pnpm/exe.linux-s390x@12.4.2': + resolution: {integrity: sha512-LAsQRRdP9aToENR6dtcIJ9l+e1zMYOX0tQcLGpRyLPVBQcYRLlvAPcmDshsiIHQjp05SDa9FI0czX1ZQ1a7a/A==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.4.1': - resolution: {integrity: sha512-5AwgFdGhVUg2kIweYGfxzSLEHiIG77PQhZAkXC3TwofQHsu1Wr+TrV5/rNX2PopFnHRzuE581zoB8F6Wle32yg==} + '@pnpm/exe.linux-x64-musl@12.4.2': + resolution: {integrity: sha512-kzfzH2/0BWdTABK14Yj5a1xsdkTEQUp2eXEPNakaD9jKL025lq3hyaKHIz/gIZaPDMe/1bFFK/En4ztFlbBJxw==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.4.1': - resolution: {integrity: sha512-FJOZuuuQMhp0oLzBtcKkLXknBI92hfkmSnlKc47vfin4HrmfID5khY2lGekL9tCzk1cpR+HShEw/meFl+nHtzQ==} + '@pnpm/exe.linux-x64@12.4.2': + resolution: {integrity: sha512-/pbt0UVTa8NMDhzOWLQRfZ6G9ROKXlJPZtx845BqyWfc7hCrWXhTLBd70yO2y8+E+IWN3oHM1s/JsIQNGk1yvg==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.4.1': - resolution: {integrity: sha512-OO7eKBL9S+xk5hRy+JUUZSNJknGuGe3GEUffsrlC2LbqKF02g+VX1anGIzSbJDvkkdnpJhSlxF5AUz2JzJu2Jw==} + '@pnpm/exe.win32-arm64@12.4.2': + resolution: {integrity: sha512-PsW19e4dAUNpZ0cS9flaxFuAmpt2dKlH/Vvi8TZ4qyJjjQzue/CEsWm+6wKVP4CJ7IT8RdL4qh+soOPWIgF2Zw==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.4.1': - resolution: {integrity: sha512-x7gJHZgHo6hp354xCYA2NvoFzYJkHwovt2kVsUBK5EmXULLcP51JGMq09CX+5FRFJUZFKoXjLu3mZWmfP7o6PQ==} + '@pnpm/exe.win32-x64@12.4.2': + resolution: {integrity: sha512-+xGoeE0g55ztWvl8i5QqdmNfW3nIrTVcoQrNshEOwxthm9Ag48oXton3uxOA3/SANyz5AXexEjj2KO20NnOrEw==} cpu: [x64] os: [win32] - pnpm@12.4.1: - resolution: {integrity: sha512-LoHjmdc/6DkNqyXgaqeIq3pZCCSNL1o3D4K0gRR6ano2e/gEj5pv22Rg8hpm8FQt7bi5TKLIcjWWdBkgsWVtTA==} + pnpm@12.4.2: + resolution: {integrity: sha512-CK3GYTGAJ1x8ntraOdzwjJxhrU5+rzMKTzRh8QKw+QdCNFTRF/mOctR/7wYWBwZE17/8lzpqV/UJCm18NosHyQ==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.4.1': + '@pnpm/exe.android-arm64@12.4.2': optional: true - '@pnpm/exe.android-x64@12.4.1': + '@pnpm/exe.android-x64@12.4.2': optional: true - '@pnpm/exe.darwin-arm64@12.4.1': + '@pnpm/exe.darwin-arm64@12.4.2': optional: true - '@pnpm/exe.darwin-x64@12.4.1': + '@pnpm/exe.darwin-x64@12.4.2': optional: true - '@pnpm/exe.freebsd-x64@12.4.1': + '@pnpm/exe.freebsd-x64@12.4.2': optional: true - '@pnpm/exe.linux-arm64-musl@12.4.1': + '@pnpm/exe.linux-arm64-musl@12.4.2': optional: true - '@pnpm/exe.linux-arm64@12.4.1': + '@pnpm/exe.linux-arm64@12.4.2': optional: true - '@pnpm/exe.linux-ppc64@12.4.1': + '@pnpm/exe.linux-ppc64@12.4.2': optional: true - '@pnpm/exe.linux-riscv64@12.4.1': + '@pnpm/exe.linux-riscv64@12.4.2': optional: true - '@pnpm/exe.linux-s390x@12.4.1': + '@pnpm/exe.linux-s390x@12.4.2': optional: true - '@pnpm/exe.linux-x64-musl@12.4.1': + '@pnpm/exe.linux-x64-musl@12.4.2': optional: true - '@pnpm/exe.linux-x64@12.4.1': + '@pnpm/exe.linux-x64@12.4.2': optional: true - '@pnpm/exe.win32-arm64@12.4.1': + '@pnpm/exe.win32-arm64@12.4.2': optional: true - '@pnpm/exe.win32-x64@12.4.1': + '@pnpm/exe.win32-x64@12.4.2': optional: true - pnpm@12.4.1: + pnpm@12.4.2: optionalDependencies: - '@pnpm/exe.android-arm64': 12.4.1 - '@pnpm/exe.android-x64': 12.4.1 - '@pnpm/exe.darwin-arm64': 12.4.1 - '@pnpm/exe.darwin-x64': 12.4.1 - '@pnpm/exe.freebsd-x64': 12.4.1 - '@pnpm/exe.linux-arm64': 12.4.1 - '@pnpm/exe.linux-arm64-musl': 12.4.1 - '@pnpm/exe.linux-ppc64': 12.4.1 - '@pnpm/exe.linux-riscv64': 12.4.1 - '@pnpm/exe.linux-s390x': 12.4.1 - '@pnpm/exe.linux-x64': 12.4.1 - '@pnpm/exe.linux-x64-musl': 12.4.1 - '@pnpm/exe.win32-arm64': 12.4.1 - '@pnpm/exe.win32-x64': 12.4.1 + '@pnpm/exe.android-arm64': 12.4.2 + '@pnpm/exe.android-x64': 12.4.2 + '@pnpm/exe.darwin-arm64': 12.4.2 + '@pnpm/exe.darwin-x64': 12.4.2 + '@pnpm/exe.freebsd-x64': 12.4.2 + '@pnpm/exe.linux-arm64': 12.4.2 + '@pnpm/exe.linux-arm64-musl': 12.4.2 + '@pnpm/exe.linux-ppc64': 12.4.2 + '@pnpm/exe.linux-riscv64': 12.4.2 + '@pnpm/exe.linux-s390x': 12.4.2 + '@pnpm/exe.linux-x64': 12.4.2 + '@pnpm/exe.linux-x64-musl': 12.4.2 + '@pnpm/exe.win32-arm64': 12.4.2 + '@pnpm/exe.win32-x64': 12.4.2 --- lockfileVersion: '9.0' @@ -169,9 +169,12 @@ importers: '@eslint/js': specifier: 10.0.1 version: 10.0.1(eslint@10.10.0(supports-color@7.2.0)) + '@kuhyx/ts-config': + specifier: link:../ts_config + version: link:../ts_config '@testing-library/jest-dom': specifier: 7.0.1 - version: 7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.0) + version: 7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.1) '@testing-library/react': specifier: 16.3.3 version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -188,8 +191,8 @@ importers: specifier: 19.3.0 version: 19.3.0(@types/react@19.3.0) '@vitest/coverage-v8': - specifier: 5.0.0 - version: 5.0.0(vitest@5.0.0) + specifier: 5.0.1 + version: 5.0.1(vitest@5.0.1) eslint: specifier: 10.10.0 version: 10.10.0(supports-color@7.2.0) @@ -209,8 +212,8 @@ importers: specifier: 8.70.0 version: 8.70.0(eslint@10.10.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) vitest: - specifier: 5.0.0 - version: 5.0.0(@types/node@26.5.1)(@vitest/coverage-v8@5.0.0)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.5.1)) + specifier: 5.0.1 + version: 5.0.1(@types/node@26.5.1)(@vitest/coverage-v8@5.0.1)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.5.1)) packages: @@ -372,9 +375,6 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@jridgewell/sourcemap-codec@1.6.0': resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} @@ -615,11 +615,11 @@ packages: resolution: {integrity: sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@vitest/coverage-v8@5.0.0': - resolution: {integrity: sha512-toMg6PZGCIa/lQNCDoASrfb1ly4hsUKXFtFYC9kD4t78o5Y6LyNJU7AENt8eHPr3quYdxaxK7hj2mnbFfUk9NA==} + '@vitest/coverage-v8@5.0.1': + resolution: {integrity: sha512-FRC8ACiudC3dI6MTplzRSYWHDRnIv2IPfbzs4FdoJNsMal/35sWV8hwIfV8ZcqzSPy+uXHeMVONt9CEqtOU17w==} peerDependencies: - '@vitest/browser': 5.0.0 - vitest: 5.0.0 + '@vitest/browser': 5.0.1 + vitest: 5.0.1 peerDependenciesMeta: '@vitest/browser': optional: true @@ -632,8 +632,8 @@ packages: resolution: {integrity: sha512-1EOLRfsTMnyAr3+kEAsP4o9dhaDlGPpD7H5iLBBeq//YpNB1VIahkPhB+eRp9N2Dkfw8oySROjE3yf9XDeaIkQ==} engines: {node: '>=22'} - '@vitest/mocker@5.0.0': - resolution: {integrity: sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==} + '@vitest/mocker@5.0.1': + resolution: {integrity: sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -643,8 +643,8 @@ packages: vite: optional: true - '@vitest/spy@5.0.0': - resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==} + '@vitest/spy@5.0.1': + resolution: {integrity: sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==} acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} @@ -1265,20 +1265,20 @@ packages: yaml: optional: true - vitest@5.0.0: - resolution: {integrity: sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==} + vitest@5.0.1: + resolution: {integrity: sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==} engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 5.0.0 - '@vitest/browser-preview': 5.0.0 + '@vitest/browser-playwright': 5.0.1 + '@vitest/browser-preview': 5.0.1 '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 - '@vitest/coverage-istanbul': 5.0.0 - '@vitest/coverage-v8': 5.0.0 - '@vitest/ui': 5.0.0 + '@vitest/coverage-istanbul': 5.0.1 + '@vitest/coverage-v8': 5.0.1 + '@vitest/ui': 5.0.1 happy-dom: '*' jsdom: '*' vite: ^6.4.0 || ^7.0.0 || ^8.0.0 @@ -1487,14 +1487,12 @@ snapshots: '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/sourcemap-codec@1.5.5': {} - '@jridgewell/sourcemap-codec@1.6.0': {} '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@keyv/bigmap@1.3.1(keyv@5.6.0)': dependencies: @@ -1564,7 +1562,7 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.0)': + '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.1)': dependencies: '@adobe/css-tools': 4.5.0 '@testing-library/dom': 10.4.1 @@ -1574,7 +1572,7 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 optionalDependencies: - vitest: 5.0.0(@types/node@26.5.1)(@vitest/coverage-v8@5.0.0)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.5.1)) + vitest: 5.0.1(@types/node@26.5.1)(@vitest/coverage-v8@5.0.1)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.5.1)) '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: @@ -1708,7 +1706,7 @@ snapshots: '@typescript-eslint/types': 8.70.0 eslint-visitor-keys: 5.0.1 - '@vitest/coverage-v8@5.0.0(vitest@5.0.0)': + '@vitest/coverage-v8@5.0.1(vitest@5.0.1)': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/istanbul-lib-coverage': 1.0.1 @@ -1718,7 +1716,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 5.0.0(@types/node@26.5.1)(@vitest/coverage-v8@5.0.0)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.5.1)) + vitest: 5.0.1(@types/node@26.5.1)(@vitest/coverage-v8@5.0.1)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.5.1)) '@vitest/istanbul-lib-coverage@1.0.1': {} @@ -1726,16 +1724,16 @@ snapshots: dependencies: '@vitest/istanbul-lib-coverage': 1.0.1 - '@vitest/mocker@5.0.0(vite@8.2.2(@types/node@26.5.1))': + '@vitest/mocker@5.0.1(vite@8.2.2(@types/node@26.5.1))': dependencies: '@jridgewell/trace-mapping': 0.3.31 - '@vitest/spy': 5.0.0 + '@vitest/spy': 5.0.1 estree-walker: 3.0.3 magic-string: 1.3.1 optionalDependencies: vite: 8.2.2(@types/node@26.5.1) - '@vitest/spy@5.0.0': {} + '@vitest/spy@5.0.1': {} acorn-jsx@5.3.2(acorn@8.18.0): dependencies: @@ -2283,10 +2281,10 @@ snapshots: '@types/node': 26.5.1 fsevents: 2.3.3 - vitest@5.0.0(@types/node@26.5.1)(@vitest/coverage-v8@5.0.0)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.5.1)): + vitest@5.0.1(@types/node@26.5.1)(@vitest/coverage-v8@5.0.1)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.5.1)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@26.5.1)) + '@vitest/mocker': 5.0.1(vite@8.2.2(@types/node@26.5.1)) chai: 6.2.2 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -2301,7 +2299,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.5.1 - '@vitest/coverage-v8': 5.0.0(vitest@5.0.0) + '@vitest/coverage-v8': 5.0.1(vitest@5.0.1) jsdom: 30.0.1 transitivePeerDependencies: - msw diff --git a/web_ui/src/index.ts b/web_ui/src/index.ts index 7c6f421..627105c 100644 --- a/web_ui/src/index.ts +++ b/web_ui/src/index.ts @@ -5,5 +5,6 @@ * `import "@kuhyx/web-ui/tokens.css";` */ export { clamp01, fractionFromPointer, nth, quantileValue, valueQuantile } from "./quantile.ts"; -export { RangeSlider, steppedValue, type RangeSliderProps } from "./range-slider.tsx"; +export { RangeSlider, type RangeSliderProps } from "./range-slider.tsx"; +export { keyTarget, steppedValue } from "./stepped-value.ts"; export { fuzzyMatch } from "./text.ts"; diff --git a/web_ui/src/quantile.ts b/web_ui/src/quantile.ts index e23e371..55c687e 100644 --- a/web_ui/src/quantile.ts +++ b/web_ui/src/quantile.ts @@ -55,8 +55,7 @@ export function quantileValue(sorted: readonly number[], f: number): number { */ export function valueQuantile(sorted: readonly number[], value: number): number { const n = sorted.length; - if (n <= 1) return 0; - if (value <= nth(sorted, 0)) return 0; + if ((n <= 1) || (value <= nth(sorted, 0))) return 0; if (value >= nth(sorted, n - 1)) return 1; // value strictly inside the range, so there is an index hi >= 1 with // sorted[hi] >= value and sorted[hi - 1] < value. diff --git a/web_ui/src/range-slider.test.tsx b/web_ui/src/range-slider.test.tsx index b1b5f69..3b9112a 100644 --- a/web_ui/src/range-slider.test.tsx +++ b/web_ui/src/range-slider.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; -import { RangeSlider, steppedValue } from "./range-slider.tsx"; +import { RangeSlider } from "./range-slider.tsx"; /** Ten evenly spaced values, so a quantile fraction maps to a predictable value. */ const VALUES = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90]; @@ -29,63 +29,6 @@ function thumbs(): HTMLElement[] { return screen.getAllByRole("slider"); } -describe("steppedValue", () => { - it("advances one sample at a time", () => { - expect(steppedValue(VALUES, 30, 1)).toBe(40); - }); - - it("moves backwards for a negative delta", () => { - expect(steppedValue(VALUES, 30, -1)).toBe(20); - }); - - it("clamps at the top", () => { - expect(steppedValue(VALUES, 90, 5)).toBe(90); - }); - - it("clamps at the bottom", () => { - expect(steppedValue(VALUES, 0, -5)).toBe(0); - }); - - it("rounds a between-samples value to a real sample", () => { - expect(steppedValue(VALUES, 34, 1)).toBe(40); - }); - - it("returns the current sample for a zero delta", () => { - expect(steppedValue(VALUES, 30, 0)).toBe(30); - }); - - describe("on a lumpy distribution", () => { - // The shape that actually broke in the browser: awesome-mcp-explorer's - // star counts hold ~1500 duplicate zeros, so an index step inside the run - // moved the index but not the value, and the arrow key looked dead. - const LUMPY = [...Array.from({ length: 50 }, () => 0), 5, 5, 5, 100, 5000]; - - it("escapes a run of duplicates instead of stalling", () => { - expect(steppedValue(LUMPY, 0, 1)).toBe(5); - }); - - it("keeps stepping past the second run", () => { - expect(steppedValue(LUMPY, 5, 1)).toBe(100); - }); - - it("steps back out of a run", () => { - expect(steppedValue(LUMPY, 5, -1)).toBe(0); - }); - - it("counts distinct values for a multi-step jump", () => { - expect(steppedValue(LUMPY, 0, 2)).toBe(100); - }); - - it("clamps at the top rather than running off the end", () => { - expect(steppedValue(LUMPY, 5000, 1)).toBe(5000); - }); - - it("clamps at the bottom", () => { - expect(steppedValue(LUMPY, 0, -1)).toBe(0); - }); - }); -}); - describe("RangeSlider", () => { it("renders nothing when there is nothing to range over", () => { const { container } = render(); diff --git a/web_ui/src/range-slider.tsx b/web_ui/src/range-slider.tsx index fc1b97d..28477c0 100644 --- a/web_ui/src/range-slider.tsx +++ b/web_ui/src/range-slider.tsx @@ -22,6 +22,7 @@ import { useCallback, useState } from "react"; import { fractionFromPointer, nth, quantileValue, valueQuantile } from "./quantile.ts"; +import { keyTarget } from "./stepped-value.ts"; export interface RangeSliderProps { /** @@ -44,40 +45,6 @@ export interface RangeSliderProps { type Thumb = "hi" | "lo"; -/** - * Steps one thumb by `delta` positions through the distribution. Pure, so the - * keyboard contract is testable without layout. - * - * Steps to the next *distinct* value rather than the next index. Real - * distributions are lumpy -- awesome-mcp-explorer's star counts hold ~1500 - * duplicate zeros -- and an index step inside a run of equal values changes the - * index while leaving the value (and therefore the thumb, and the filter) - * exactly where it was. That reads as a dead arrow key. - */ -export function steppedValue( - values: readonly number[], - current: number, - delta: number, -): number { - const last = values.length - 1; - // Round to a real sample index so a thumb sitting between two samples still - // advances rather than stalling on a fractional step. - const index = Math.round(valueQuantile(values, current) * last); - const direction = Math.sign(delta); - if (direction === 0) return nth(values, index); - - let cursor = index; - for (let taken = 0; taken < Math.abs(delta); taken++) { - let next = cursor + direction; - while (next >= 0 && next <= last && nth(values, next) === nth(values, cursor)) { - next += direction; - } - if (next < 0 || next > last) break; - cursor = next; - } - return nth(values, cursor); -} - export function RangeSlider({ format, hi, @@ -86,7 +53,7 @@ export function RangeSlider({ onChange, values, }: RangeSliderProps): null | React.JSX.Element { - const [drag, setDrag] = useState(null); + const [drag, setDrag] = useState(null); const apply = useCallback( (thumb: Thumb, value: number): void => { @@ -100,29 +67,20 @@ export function RangeSlider({ // handlers are bound to the track, so currentTarget *is* the track. A ref // would add a null branch that cannot happen once the element is mounted. const valueAt = useCallback( - (track: HTMLElement, clientX: number): number => - quantileValue(values, fractionFromPointer(track.getBoundingClientRect(), clientX)), + (track: HTMLElement, clientX: number): number => { + const fraction = fractionFromPointer(track.getBoundingClientRect(), clientX); + return quantileValue(values, fraction); + }, [values], ); const onKeyDown = useCallback( (thumb: Thumb, event: React.KeyboardEvent): void => { const current = thumb === "lo" ? lo : hi; - const step = (delta: number): void => { - event.preventDefault(); - apply(thumb, steppedValue(values, current, delta)); - }; - if (event.key === "ArrowLeft" || event.key === "ArrowDown") step(-1); - else if (event.key === "ArrowRight" || event.key === "ArrowUp") step(1); - else if (event.key === "PageDown") step(-10); - else if (event.key === "PageUp") step(10); - else if (event.key === "Home") { - event.preventDefault(); - apply(thumb, nth(values, 0)); - } else if (event.key === "End") { - event.preventDefault(); - apply(thumb, nth(values, values.length - 1)); - } + const target = keyTarget(event.key, values, current); + if (target === undefined) return; + event.preventDefault(); + apply(thumb, target); }, [apply, hi, lo, values], ); @@ -136,19 +94,21 @@ export function RangeSlider({ const hiFraction = valueQuantile(values, hi) * 100; const name = label ?? "Range"; - const thumbProps = (thumb: Thumb): React.ComponentProps<"button"> => ({ - "aria-valuemax": highest, - "aria-valuemin": lowest, - "aria-valuenow": thumb === "lo" ? lo : hi, - className: `slider-thumb slider-${thumb}`, - onKeyDown: (event): void => { - onKeyDown(thumb, event); - }, - role: "slider", - style: { left: `${String(thumb === "lo" ? loFraction : hiFraction)}%` }, - tabIndex: 0, - type: "button", - }); + const thumbProps = (thumb: Thumb): React.ComponentProps<"button"> => { + return { + "aria-valuemax": highest, + "aria-valuemin": lowest, + "aria-valuenow": thumb === "lo" ? lo : hi, + className: `slider-thumb slider-${thumb}`, + onKeyDown: (event): void => { + onKeyDown(thumb, event); + }, + role: "slider", + style: { left: `${String(thumb === "lo" ? loFraction : hiFraction)}%` }, + tabIndex: 0, + type: "button", + }; + }; return (
diff --git a/web_ui/src/stepped-value.test.ts b/web_ui/src/stepped-value.test.ts new file mode 100644 index 0000000..0964b8b --- /dev/null +++ b/web_ui/src/stepped-value.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; + +import { keyTarget, steppedValue } from "./stepped-value.ts"; + +/** Ten evenly spaced values, so a quantile fraction maps to a predictable value. */ +const VALUES = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90]; + +describe("keyTarget", () => { + it("leaves keys the slider does not own to the browser", () => { + expect(keyTarget("Tab", VALUES, 30)).toBeUndefined(); + expect(keyTarget("a", VALUES, 30)).toBeUndefined(); + }); + + it("maps every owned key onto the distribution", () => { + expect(keyTarget("ArrowLeft", VALUES, 30)).toBe(20); + expect(keyTarget("ArrowDown", VALUES, 30)).toBe(20); + expect(keyTarget("ArrowRight", VALUES, 30)).toBe(40); + expect(keyTarget("ArrowUp", VALUES, 30)).toBe(40); + expect(keyTarget("Home", VALUES, 30)).toBe(0); + expect(keyTarget("End", VALUES, 30)).toBe(90); + }); +}); + +describe("steppedValue", () => { + it("advances one sample at a time", () => { + expect(steppedValue(VALUES, 30, 1)).toBe(40); + }); + + it("moves backwards for a negative delta", () => { + expect(steppedValue(VALUES, 30, -1)).toBe(20); + }); + + it("clamps at the top", () => { + expect(steppedValue(VALUES, 90, 5)).toBe(90); + }); + + it("clamps at the bottom", () => { + expect(steppedValue(VALUES, 0, -5)).toBe(0); + }); + + it("rounds a between-samples value to a real sample", () => { + expect(steppedValue(VALUES, 34, 1)).toBe(40); + }); + + it("returns the current sample for a zero delta", () => { + expect(steppedValue(VALUES, 30, 0)).toBe(30); + }); + + describe("on a lumpy distribution", () => { + // The shape that actually broke in the browser: awesome-mcp-explorer's + // star counts hold ~1500 duplicate zeros, so an index step inside the run + // moved the index but not the value, and the arrow key looked dead. + const LUMPY = [...Array.from({ length: 50 }, () => 0), 5, 5, 5, 100, 5000]; + + it("escapes a run of duplicates instead of stalling", () => { + expect(steppedValue(LUMPY, 0, 1)).toBe(5); + }); + + it("keeps stepping past the second run", () => { + expect(steppedValue(LUMPY, 5, 1)).toBe(100); + }); + + it("steps back out of a run", () => { + expect(steppedValue(LUMPY, 5, -1)).toBe(0); + }); + + it("counts distinct values for a multi-step jump", () => { + expect(steppedValue(LUMPY, 0, 2)).toBe(100); + }); + + it("clamps at the top rather than running off the end", () => { + expect(steppedValue(LUMPY, 5000, 1)).toBe(5000); + }); + + it("clamps at the bottom", () => { + expect(steppedValue(LUMPY, 0, -1)).toBe(0); + }); + }); +}); diff --git a/web_ui/src/stepped-value.ts b/web_ui/src/stepped-value.ts new file mode 100644 index 0000000..2e5c97d --- /dev/null +++ b/web_ui/src/stepped-value.ts @@ -0,0 +1,78 @@ +/** + * The keyboard contract of the range slider, kept pure and in its own module + * so range-slider.tsx exports only the component (react-refresh needs that + * for HMR to keep working) and the contract is testable without layout. + */ +import { nth, valueQuantile } from "./quantile.ts"; + +/** + * Steps one thumb by `delta` positions through the distribution. Pure, so the + * keyboard contract is testable without layout. + * + * Steps to the next *distinct* value rather than the next index. Real + * distributions are lumpy -- awesome-mcp-explorer's star counts hold ~1500 + * duplicate zeros -- and an index step inside a run of equal values changes the + * index while leaving the value (and therefore the thumb, and the filter) + * exactly where it was. That reads as a dead arrow key. + */ +export function steppedValue( + values: readonly number[], + current: number, + delta: number, +): number { + const last = values.length - 1; + // Round to a real sample index so a thumb sitting between two samples still + // advances rather than stalling on a fractional step. + const index = Math.round(valueQuantile(values, current) * last); + const direction = Math.sign(delta); + if (direction === 0) return nth(values, index); + + let cursor = index; + for (let taken = 0; taken < Math.abs(delta); taken++) { + let next = cursor + direction; + while (next >= 0 && next <= last && nth(values, next) === nth(values, cursor)) { + next += direction; + } + if (next < 0 || next > last) break; + cursor = next; + } + return nth(values, cursor); +} + +/** + * The value a thumb at `current` moves to for a key, or `undefined` when the + * key is not one the slider handles (so the event is left to the browser). + * Arrow keys step one distinct value, Page keys ten, Home/End jump to the + * bounds. + */ +export function keyTarget( + key: string, + values: readonly number[], + current: number, +): number | undefined { + switch (key) { + case "ArrowDown": + case "ArrowLeft": { + return steppedValue(values, current, -1); + } + case "ArrowRight": + case "ArrowUp": { + return steppedValue(values, current, 1); + } + case "End": { + return nth(values, values.length - 1); + } + case "Home": { + return nth(values, 0); + } + case "PageDown": { + return steppedValue(values, current, -10); + } + case "PageUp": { + return steppedValue(values, current, 10); + } + default: { + return undefined; + } + } +} diff --git a/web_ui/src/test/setup.ts b/web_ui/src/test/setup.ts index 11ed777..a41ef56 100644 --- a/web_ui/src/test/setup.ts +++ b/web_ui/src/test/setup.ts @@ -13,7 +13,7 @@ const TRACK_WIDTH = 1000; if (hasDom) { Object.defineProperty(Element.prototype, "getBoundingClientRect", { configurable: true, - value(): DOMRect { + value: (): DOMRect => { return { bottom: 20, height: 20, diff --git a/web_ui/src/tokens.test.ts b/web_ui/src/tokens.test.ts index b9dea41..159ea00 100644 --- a/web_ui/src/tokens.test.ts +++ b/web_ui/src/tokens.test.ts @@ -1,6 +1,5 @@ // @vitest-environment node import { readFileSync } from "node:fs"; - import { describe, expect, it } from "vitest"; const css = readFileSync(new URL("tokens.css", import.meta.url), "utf8"); @@ -13,9 +12,10 @@ const css = readFileSync(new URL("tokens.css", import.meta.url), "utf8"); */ function declared(name: string): string[] { - return [...css.matchAll(new RegExp(`--${name}:\\s*([^;]+);`, "g"))].map((m) => - (m[1] ?? "").trim(), - ); + return css + .matchAll(new RegExp(String.raw`--${name}:\s*([^;]+);`, "g")) + .map((m) => (m[1] ?? "").trim()) + .toArray(); } describe("tokens.css", () => { @@ -124,7 +124,7 @@ describe("tokens.css", () => { const block = /@media \(prefers-reduced-motion: reduce\)\s*\{([\s\S]*?)\n\}/.exec(css)?.[1]; expect(block).toBeDefined(); for (const step of ["fast", "base", "slow"]) { - expect(block).toMatch(new RegExp(`--duration-${step}:\\s*0ms;`)); + expect(block).toMatch(new RegExp(String.raw`--duration-${step}:\s*0ms;`)); } }); diff --git a/web_ui/tsconfig.json b/web_ui/tsconfig.json index 0ed8b47..bd99235 100644 --- a/web_ui/tsconfig.json +++ b/web_ui/tsconfig.json @@ -1,4 +1,6 @@ { + // Strictness comes from the shared base; only output/lib options live here. + "extends": "@kuhyx/ts-config/tsconfig", "compilerOptions": { "target": "ES2023", "lib": ["ESNext", "DOM", "DOM.Iterable"], @@ -7,20 +9,6 @@ "jsx": "react-jsx", "types": ["vitest/globals"], - /* Strictness: everything on, matching both consumers so a file that - compiles here compiles there. */ - "strict": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "exactOptionalPropertyTypes": true, - "useUnknownInCatchVariables": true, - "verbatimModuleSyntax": true, - "isolatedModules": true, - "erasableSyntaxOnly": true, /* This package ships a prebuilt dist/ (ESM + .d.ts) because consumers use Vite, which does not transpile dependencies. So unlike the consumers, @@ -31,10 +19,7 @@ "declarationMap": true, "sourceMap": true, "outDir": "dist", - "rootDir": "src", - - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true + "rootDir": "src" }, "include": ["src"], "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"]