web_ui: shared web token + component layer (Phase 3)

The web counterpart to design_system (Dart): one :root block of frozen tokens
plus the components that were duplicated across repos. Extracted from
dufs-cloud and awesome-mcp-explorer, which both now consume it.

- tokens.css: the frozen palette, spacing/radius/type scales, shadow policy and
  focus ring, under the canonical variable names from unified-design-system.
  Also ships the global :focus-visible rule, rather than leaving each consumer
  to remember it.
- RangeSlider: reconciled from the two drifted copies. Takes the accessible DOM
  from awesome-mcp-explorer (real <button> thumbs with role="slider" and the
  aria-value* trio) and grab-the-nearer-thumb from dufs-cloud. Keyboard support
  (arrows/PageUp/PageDown/Home/End) is new: both donors were pointer-only, which
  the design system rules out, and an arrow-key-deaf role="slider" is an ARIA
  role that lies.
- quantile.ts + fuzzyMatch: the genuinely duplicated helpers.

FilterBar is deliberately absent. The two same-named components share no
structure -- a horizontal <select> strip over files vs a vertical tri-state chip
sidebar over servers, with disjoint FilterState, props and sort keys. It is the
name-collision-is-not-a-duplicate trap, so extracting it would ship a widget
nobody imports.

dist/ is committed (and un-ignored against the root rule) because Vite does not
transpile dependencies; consumers stay at zero config. Consumed via pnpm's
&path:/web_ui suffix -- npm cannot install from a subdirectory of a git repo.

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 14:40:45 +02:00
parent d25f2885da
commit 03bf1ed08b
34 changed files with 3737 additions and 0 deletions

10
web_ui/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
# dist/ is deliberately NOT ignored here: consumers pull this package as a git
# dep and Vite does not transpile dependencies, so the built ESM + .d.ts must be
# committed in the tag. See README.md. The repo root ignores `dist/` globally,
# so this un-ignores it for this subtree only — without it, `git add web_ui`
# silently skips the build and the tag ships a package with no code in it.
!dist/
!dist/**
node_modules/
coverage/
*.tsbuildinfo

94
web_ui/README.md Normal file
View File

@ -0,0 +1,94 @@
# @kuhyx/web-ui
The shared token + component layer for kuhy's web apps — the web counterpart to
`~/utils/design_system` (Dart). Owns the frozen palette, the spacing/radius/type
scales, and the components that were each reimplemented in two or more repos.
Consumers import the tokens rather than transcribing the token table by hand.
Source of truth for the *values* is `~/utils/unified-design-system/tokens.md`;
this package is its executable form for the web.
## Install
```jsonc
// package.json — pnpm only, see "Why the &path: suffix" below.
"dependencies": {
"@kuhyx/web-ui": "github:kuhyx/utils#web_ui-v0.1.0&path:/web_ui"
}
```
```ts
import "@kuhyx/web-ui/tokens.css"; // once, at the app entry point
import "@kuhyx/web-ui/range-slider.css"; // only if you use RangeSlider
import { RangeSlider, fuzzyMatch, quantileValue } from "@kuhyx/web-ui";
```
### Why the `&path:/web_ui` suffix
`utils` is a monorepo with no root `package.json`, and **npm cannot install from
a subdirectory of a git repo** — `github:user/repo#tag` clones the root, looks
for `package.json` there, and fails with `ENOENT` (verified against npm 11.16).
pnpm supports the subdirectory via `&path:/<dir>`, which is why both consumers
are pnpm. This is the only difference from the Dart (`git: {url, ref, path}`) and
Python (`#subdirectory=`) conventions the rest of `utils` follows.
### Why `dist/` is committed
Vite does not transpile dependencies, so shipping raw `.tsx` would force
per-repo bundler config on every consumer. The tag carries a prebuilt `dist/`
(ESM + `.d.ts`, JSX already compiled, `.ts` specifiers rewritten to `.js`), so
consumers stay at zero config. **`pnpm build` before tagging, always** — a tag
whose `dist/` is stale ships old code to every consumer with no error.
## What's here
| Export | Notes |
| --- | --- |
| `tokens.css` | The `:root` block: palette, spacing, radius, type, shadows, focus ring. Also sets `:focus-visible` globally. |
| `RangeSlider` | Dual-thumb slider over a value *distribution*, not a linear ramp. |
| `range-slider.css` | Its styling, entirely in tokens. |
| `quantile.ts` helpers | `nth`, `clamp01`, `quantileValue`, `valueQuantile`, `fractionFromPointer`. |
| `fuzzyMatch` | Case-insensitive subsequence match. |
`fractionFromPointer` is deliberately pure geometry: it makes the slider's
behaviour testable without layout, which jsdom does not implement.
### RangeSlider is keyboard-operable
Both donor copies were pointer-only. The design system requires that every
action be reachable with the keyboard alone, and a `role="slider"` that ignores
arrow keys is an ARIA role that lies — so the shared version adds:
| Key | Effect |
| --- | --- |
| `←` `↓` / `→` `↑` | Step one sample through the distribution |
| `PageDown` / `PageUp` | Step ten samples |
| `Home` / `End` | Jump to the distribution's bounds |
Pass `label` **and** `format` to get the built-in head (name + value readout);
omit them for a bare track when the caller draws its own chrome, as
dufs-cloud's `SizeRange`/`DurationRange` wrappers do.
## Development
```bash
pnpm install
pnpm test # vitest
pnpm coverage # 100% branches/functions/lines/statements, enforced
pnpm lint # tsc --noEmit && eslint
pnpm build # tsc -b + copy CSS into dist/
```
The 100% bar matches Phase 1's `design_system`: a shared layer two repos depend
on does not get to be the least-tested code in the tree.
## Releasing
```bash
pnpm test && pnpm build
git add web_ui && git commit -m "web_ui: <what changed>"
git tag web_ui-v<major>.<minor>.<patch> && git push origin main --tags
```
Then bump the tag in each consumer's `package.json` and re-run `pnpm install`.

10
web_ui/dist/index.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
/**
* The shared web token + component layer.
*
* `tokens.css` is not re-exported here — CSS is imported by path:
* `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 { fuzzyMatch } from "./text.ts";
//# sourceMappingURL=index.d.ts.map

1
web_ui/dist/index.d.ts.map vendored Normal file
View File

@ -0,0 +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"}

10
web_ui/dist/index.js vendored Normal file
View File

@ -0,0 +1,10 @@
/**
* The shared web token + component layer.
*
* `tokens.css` is not re-exported here — CSS is imported by path:
* `import "@kuhyx/web-ui/tokens.css";`
*/
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

1
web_ui/dist/index.js.map vendored Normal file
View File

@ -0,0 +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"}

43
web_ui/dist/quantile.d.ts vendored Normal file
View File

@ -0,0 +1,43 @@
/**
* Distribution-aware slider maths.
*
* A linear min..max track is useless on skewed data, which is the normal case
* in both consumers: dufs-cloud's file sizes span bytes to gigabytes, and
* awesome-mcp-explorer's star counts run 0..166,785 with a median of 8, so a
* linear midpoint sits above 99.9% of all rows. Mapping the track through the
* actual distribution puts the middle of the track at the median, which makes
* equal track distance mean roughly equal numbers of items.
*
* This was duplicated in dufs-cloud and awesome-mcp-explorer before it moved
* here; both now import it.
*/
/**
* Bounds-checked array access (callers guarantee the index is valid). Keeps the
* quantile helpers free of scattered non-null assertions or dead undefined
* guards while still satisfying `noUncheckedIndexedAccess`.
*/
export declare function nth(values: readonly number[], index: number): number;
/** Clamp to the unit interval. */
export declare function clamp01(x: number): number;
/**
* The value at quantile `f` (0..1) of an ascending, non-empty array, with linear
* interpolation between the two straddling samples. This is what makes the
* sliders distribution-aware: f = 0.5 returns the *median*, so the middle of the
* track sits where half the items are — not the arithmetic midpoint of the
* range. Empty input yields 0 (callers hide the slider in that case).
*/
export declare function quantileValue(sorted: readonly number[], f: number): number;
/**
* The quantile (0..1) of `value` within `sorted` — the inverse of
* {@link quantileValue}, used to place a thumb at the right spot on the track.
* Values at or beyond the ends map to 0/1.
*/
export declare function valueQuantile(sorted: readonly number[], value: number): number;
/**
* Pointer x to a 0..1 fraction of a track.
*
* Extracted as pure geometry so slider behaviour is testable without layout,
* which jsdom does not implement.
*/
export declare function fractionFromPointer(rect: DOMRect, clientX: number): number;
//# sourceMappingURL=quantile.d.ts.map

1
web_ui/dist/quantile.d.ts.map vendored Normal file
View File

@ -0,0 +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"}

79
web_ui/dist/quantile.js vendored Normal file
View File

@ -0,0 +1,79 @@
/**
* Distribution-aware slider maths.
*
* A linear min..max track is useless on skewed data, which is the normal case
* in both consumers: dufs-cloud's file sizes span bytes to gigabytes, and
* awesome-mcp-explorer's star counts run 0..166,785 with a median of 8, so a
* linear midpoint sits above 99.9% of all rows. Mapping the track through the
* actual distribution puts the middle of the track at the median, which makes
* equal track distance mean roughly equal numbers of items.
*
* This was duplicated in dufs-cloud and awesome-mcp-explorer before it moved
* here; both now import it.
*/
/**
* Bounds-checked array access (callers guarantee the index is valid). Keeps the
* quantile helpers free of scattered non-null assertions or dead undefined
* guards while still satisfying `noUncheckedIndexedAccess`.
*/
export function nth(values, index) {
const value = values[index];
if (value === undefined) {
throw new RangeError(`index ${index} out of range`);
}
return value;
}
/** Clamp to the unit interval. */
export function clamp01(x) {
return Math.min(1, Math.max(0, x));
}
/**
* The value at quantile `f` (0..1) of an ascending, non-empty array, with linear
* interpolation between the two straddling samples. This is what makes the
* sliders distribution-aware: f = 0.5 returns the *median*, so the middle of the
* track sits where half the items are — not the arithmetic midpoint of the
* range. Empty input yields 0 (callers hide the slider in that case).
*/
export function quantileValue(sorted, f) {
const n = sorted.length;
if (n === 0)
return 0;
const pos = clamp01(f) * (n - 1);
const lo = Math.floor(pos);
const hi = Math.ceil(pos);
const a = nth(sorted, lo);
const b = nth(sorted, hi);
return Math.round(a + (b - a) * (pos - lo));
}
/**
* The quantile (0..1) of `value` within `sorted` — the inverse of
* {@link quantileValue}, used to place a thumb at the right spot on the track.
* Values at or beyond the ends map to 0/1.
*/
export function valueQuantile(sorted, value) {
const n = sorted.length;
if (n <= 1)
return 0;
if (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.
const hi = sorted.findIndex((v) => v >= value);
const lo = hi - 1;
const a = nth(sorted, lo);
const b = nth(sorted, hi);
// a < value <= b here, so b - a > 0 (no divide-by-zero).
return (lo + (value - a) / (b - a)) / (n - 1);
}
/**
* Pointer x to a 0..1 fraction of a track.
*
* Extracted as pure geometry so slider behaviour is testable without layout,
* which jsdom does not implement.
*/
export function fractionFromPointer(rect, clientX) {
return rect.width === 0 ? 0 : clamp01((clientX - rect.left) / rect.width);
}
//# sourceMappingURL=quantile.js.map

1
web_ui/dist/quantile.js.map vendored Normal file
View File

@ -0,0 +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"}

68
web_ui/dist/range-slider.css vendored Normal file
View File

@ -0,0 +1,68 @@
/*
* Styling for the shared RangeSlider. Imported separately from tokens.css:
* import "@kuhyx/web-ui/tokens.css";
* import "@kuhyx/web-ui/range-slider.css";
*
* Every value routes through a token — no literals.
*/
.slider-head {
display: flex;
justify-content: space-between;
color: var(--text-muted);
font-size: var(--text-caption);
letter-spacing: var(--tracking-caption);
}
.slider-value {
/* Tabular figures so a dragging readout does not reflow the row. */
font-variant-numeric: tabular-nums;
}
.slider-track {
position: relative;
height: 22px;
margin-top: var(--space-1);
/* Let the element own the gesture instead of the page scrolling under it. */
touch-action: none;
}
.slider-track::before {
content: "";
position: absolute;
top: 9px;
right: 0;
left: 0;
height: 4px;
border-radius: 2px;
background: var(--border);
}
.slider-fill {
position: absolute;
top: 9px;
height: 4px;
border-radius: 2px;
background: var(--accent);
}
.slider-thumb {
position: absolute;
top: 4px;
width: 14px;
height: 14px;
margin-left: -7px;
padding: 0;
border: 2px solid var(--accent);
border-radius: 50%;
background: var(--bg);
cursor: pointer;
}
/* The thumbs are the keyboard target, so the ring must be unmistakable. The
* global :focus-visible rule in tokens.css already colours it; the wider offset
* here keeps it clear of the 14px circle. */
.slider-thumb:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}

23
web_ui/dist/range-slider.d.ts vendored Normal file
View File

@ -0,0 +1,23 @@
export interface RangeSliderProps {
/**
* Formats a value for the head readout. Omit it (with `label`) to render a
* bare track — dufs-cloud's wrappers draw their own labels and value notes,
* so a built-in head would duplicate them.
*/
readonly format?: (value: number) => string;
/** Current upper bound; equal to the distribution max when unconstrained. */
readonly hi: number;
/** Accessible name. Falls back to a generic one when omitted. */
readonly label?: string;
/** Current lower bound; equal to the distribution min when unconstrained. */
readonly lo: number;
/** Reports both thumb values (raw, clamped so lo <= hi) on every change. */
readonly onChange: (lo: number, hi: number) => void;
/** 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. */
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

1
web_ui/dist/range-slider.d.ts.map vendored Normal file
View File

@ -0,0 +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;mDACmD;AACnD,wBAAgB,YAAY,CAC1B,MAAM,EAAE,SAAS,MAAM,EAAE,EACzB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,GACZ,MAAM,CAMR;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"}

109
web_ui/dist/range-slider.js vendored Normal file
View File

@ -0,0 +1,109 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
/**
* A dual-thumb slider whose track maps through a value *distribution* rather
* than a linear min..max ramp (see `./quantile.ts` for why).
*
* Reconciled from the two drifted copies in dufs-cloud and
* awesome-mcp-explorer. It takes the accessible DOM from awesome-mcp-explorer
* (real `<button>` thumbs carrying `role="slider"` and the `aria-value*` trio,
* so the control is a focus stop) and the grab-the-nearer-thumb pointer
* behaviour from dufs-cloud (pressing anywhere on the track moves the closer
* handle, instead of requiring a direct hit on an
* 8px target).
*
* Keyboard support is new in the shared version. Both donors were pointer-only,
* which the design system rules out outright: every action must be reachable
* with the keyboard alone, and a `role="slider"` that does not respond to arrow
* keys is an ARIA role that lies. Arrow keys step by one item through the
* distribution, Home/End jump to the bounds.
*
* The component deals only in raw values; callers translate the extremes into
* "no filter".
*/
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. */
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 by exactly one item rather than stalling on a fractional step.
const index = Math.round(valueQuantile(values, current) * last);
return nth(values, Math.min(last, Math.max(0, index + delta)));
}
export function RangeSlider({ format, hi, label, lo, onChange, values, }) {
const [drag, setDrag] = useState(null);
const apply = useCallback((thumb, value) => {
if (thumb === "lo")
onChange(Math.min(value, hi), hi);
else
onChange(lo, Math.max(value, lo));
}, [hi, lo, onChange]);
// 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 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));
}
}, [apply, hi, lo, values]);
// Fewer than two values means there is nothing to range over.
if (values.length < 2)
return null;
const lowest = nth(values, 0);
const highest = nth(values, values.length - 1);
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",
});
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
// does something sensible instead of requiring a direct hit.
const thumb = Math.abs(value - lo) <= Math.abs(value - hi) ? "lo" : "hi";
setDrag(thumb);
event.currentTarget.setPointerCapture(event.pointerId);
apply(thumb, value);
}, onPointerMove: (event) => {
if (drag === null)
return;
apply(drag, valueAt(event.currentTarget, event.clientX));
}, onPointerUp: (event) => {
if (drag === null)
return;
setDrag(null);
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

1
web_ui/dist/range-slider.js.map vendored Normal file
View File

@ -0,0 +1 @@
{"version":3,"file":"range-slider.js","sourceRoot":"","sources":["../src/range-slider.tsx"],"names":[],"mappings":"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"}

16
web_ui/dist/text.d.ts vendored Normal file
View File

@ -0,0 +1,16 @@
/**
* Small text helpers shared by the consumers' filter layers.
*
* Hand-rolled rather than pulling in Fuse.js, matching the house posture of no
* runtime dependencies for things this small.
*/
/**
* Case-insensitive subsequence match: every character of `query` appears in
* `target`, in order, though not necessarily adjacently. An empty query matches
* everything, so callers can pass the raw search box value straight through.
*
* dufs-cloud called this `fuzzyMatch` and awesome-mcp-explorer `matchesFuzzy`;
* the implementations were identical apart from identifier names.
*/
export declare function fuzzyMatch(query: string, target: string): boolean;
//# sourceMappingURL=text.d.ts.map

1
web_ui/dist/text.d.ts.map vendored Normal file
View File

@ -0,0 +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"}

27
web_ui/dist/text.js vendored Normal file
View File

@ -0,0 +1,27 @@
/**
* Small text helpers shared by the consumers' filter layers.
*
* Hand-rolled rather than pulling in Fuse.js, matching the house posture of no
* runtime dependencies for things this small.
*/
/**
* Case-insensitive subsequence match: every character of `query` appears in
* `target`, in order, though not necessarily adjacently. An empty query matches
* everything, so callers can pass the raw search box value straight through.
*
* dufs-cloud called this `fuzzyMatch` and awesome-mcp-explorer `matchesFuzzy`;
* the implementations were identical apart from identifier names.
*/
export function fuzzyMatch(query, target) {
if (query === "")
return true;
const q = query.toLowerCase();
const t = target.toLowerCase();
let index = 0;
for (let scan = 0; scan < t.length && index < q.length; scan++) {
if (t[scan] === q[index])
index++;
}
return index === q.length;
}
//# sourceMappingURL=text.js.map

1
web_ui/dist/text.js.map vendored Normal file
View File

@ -0,0 +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"}

112
web_ui/dist/tokens.css vendored Normal file
View File

@ -0,0 +1,112 @@
/*
* The frozen design tokens, shared by every one of kuhy's web repos.
*
* Source of truth: ~/utils/unified-design-system/tokens.md (values) and
* README.md (the CSS custom-property names). The same numbers ship for Dart in
* ~/utils/design_system, so a change here is a change there — do not edit one
* side alone, and do not introduce a value that is not on a scale below.
*
* Dark is the default; light is an override, matching the README.
*/
:root {
/* Neutrals ------------------------------------------------------------- */
--bg: #211d1b;
--surface-1: #2b2624;
--surface-2: #38312e;
--border: #463e3a;
--text: #eceae9;
--text-muted: #aaa09a;
/* Accent + semantic roles ---------------------------------------------- */
--accent: #b8862e;
--info: #b8862e; /* = accent */
--success: #8a9a3c;
--warning: #e0a63c;
--danger: #e2585f;
/* Text/icons ON a filled accent/success/warning/danger surface. Never
* --text: every semantic fill sits in the same mid-light band, where
* near-white measures 2.2:1-3.6:1 against a 4.5:1 floor. */
--on-fill: #211d1b;
/* Text/icons on a dark translucent scrim/overlay — fixed, not theme-tied,
* because the scrim itself is always dark. */
--on-scrim: #eceae9;
/* Corner radius --------------------------------------------------------- */
--radius-sm: 8px; /* buttons, inputs, chips */
--radius-md: 12px; /* cards */
--radius-lg: 16px; /* sheets, dialogs */
/* Spacing (4px base) ---------------------------------------------------- */
--space-1: 4px;
--space-2: 8px;
--space-3: 16px;
--space-4: 24px;
--space-5: 32px;
--space-6: 48px;
/* Typography ------------------------------------------------------------ */
--font-sans: -apple-system, "Segoe UI", Roboto, system-ui, sans-serif;
--font-mono: ui-monospace, "Cascadia Code", monospace;
--text-display: 32px;
--text-title: 24px;
--text-subtitle: 20px;
--text-body: 16px; /* the floor for anything a user actually reads */
--text-label: 14px; /* UI chrome only */
--text-caption: 12px; /* UI chrome only */
--tracking-display: -0.2px;
--tracking-title: -0.1px;
--tracking-label: 0.05em;
--tracking-caption: 0.1em;
/* Prose line-length cap (rule 21): ~65-70 characters. */
--measure: 40rem;
/* Shadows: light surfaces only, and only on floating/overlay elements —
* never inline cards or list rows. Blur = 2x offset, tinted from ink. */
--shadow-1: 0 2px 4px rgba(33, 29, 27, 0.12); /* dropdown */
--shadow-2: 0 4px 8px rgba(33, 29, 27, 0.16); /* modal */
/* Focus ring: the platform default is black, invisible on --bg. */
--focus-ring: #b8862e; /* = accent */
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f6f4f3;
--surface-1: #fcfbfb;
--surface-2: #fcfbfb;
--border: #e0dad7;
--text: #211d1b;
--text-muted: #70625b;
/* --on-fill / --on-scrim stay fixed — the fills and the scrim do not
* change with page theme. */
}
}
/* Dark surfaces take no shadow at all; elevation is a fill step. Light
* surfaces opt in explicitly via --shadow-1 / --shadow-2. */
@media (prefers-color-scheme: dark) {
:root {
--shadow-1: none;
--shadow-2: none;
}
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
font-size: var(--text-body);
}
/* Ships here rather than being left to each consumer: an unstyled focus ring
* is the accessibility regression this layer exists to prevent, and several of
* these apps are keyboard-only by necessity. */
:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 1px;
}

51
web_ui/package.json Normal file
View File

@ -0,0 +1,51 @@
{
"name": "@kuhyx/web-ui",
"version": "0.1.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.",
"license": "MIT",
"engines": {
"node": ">=24",
"pnpm": ">=10"
},
"packageManager": "pnpm@10.13.1",
"files": [
"dist"
],
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./tokens.css": "./dist/tokens.css",
"./range-slider.css": "./dist/range-slider.css"
},
"scripts": {
"build": "tsc -b && cp src/tokens.css src/range-slider.css dist/",
"lint": "tsc --noEmit && eslint src",
"test": "vitest run",
"coverage": "vitest run --coverage"
},
"peerDependencies": {
"react": ">=19"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitest/coverage-v8": "^4.1.10",
"eslint": "^10.7.0",
"jsdom": "^29.1.1",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"typescript": "^6.0.3",
"typescript-eslint": "^8.64.0",
"vitest": "^4.1.10"
}
}

2164
web_ui/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

9
web_ui/src/index.ts Normal file
View File

@ -0,0 +1,9 @@
/**
* The shared web token + component layer.
*
* `tokens.css` is not re-exported here — CSS is imported by path:
* `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 { fuzzyMatch } from "./text.ts";

108
web_ui/src/quantile.test.ts Normal file
View File

@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import {
clamp01,
fractionFromPointer,
nth,
quantileValue,
valueQuantile,
} from "./quantile.ts";
describe("nth", () => {
it("reads a value", () => {
expect(nth([5, 6, 7], 1)).toBe(6);
});
it("throws for an index out of range", () => {
expect(() => nth([1], 5)).toThrow(RangeError);
});
});
describe("clamp01", () => {
it("passes a value already in range", () => {
expect(clamp01(0.5)).toBe(0.5);
});
it("clamps both ends", () => {
expect(clamp01(-1)).toBe(0);
expect(clamp01(2)).toBe(1);
});
});
describe("quantileValue", () => {
it("returns 0 for an empty distribution", () => {
expect(quantileValue([], 0.5)).toBe(0);
});
it("returns the ends at f=0 and f=1", () => {
expect(quantileValue([1, 2, 100], 0)).toBe(1);
expect(quantileValue([1, 2, 100], 1)).toBe(100);
});
it("returns the MEDIAN at f=0.5, not the linear midpoint", () => {
// The whole point of the quantile mapping, on a distribution shaped like
// the real star counts (1 .. 59k, long tail). The linear midpoint of that
// range is ~29,500 — above every server but one, which is exactly why a
// plain min..max slider is useless here. The median is 3.
expect(quantileValue([1, 2, 3, 4, 59_000], 0.5)).toBe(3);
});
it("interpolates between samples", () => {
expect(quantileValue([0, 10], 0.5)).toBe(5);
});
it("clamps an out-of-range fraction", () => {
expect(quantileValue([1, 5], -1)).toBe(1);
expect(quantileValue([1, 5], 9)).toBe(5);
});
});
describe("valueQuantile", () => {
it("returns 0 for a distribution with fewer than two samples", () => {
expect(valueQuantile([], 5)).toBe(0);
expect(valueQuantile([5], 5)).toBe(0);
});
it("maps the ends to 0 and 1", () => {
expect(valueQuantile([1, 2, 100], 1)).toBe(0);
expect(valueQuantile([1, 2, 100], 100)).toBe(1);
});
it("clamps values beyond the ends", () => {
expect(valueQuantile([1, 2, 100], -5)).toBe(0);
expect(valueQuantile([1, 2, 100], 1000)).toBe(1);
});
it("inverts quantileValue", () => {
const sorted = [1, 4, 9, 16, 25];
for (const f of [0.25, 0.5, 0.75]) {
const value = quantileValue(sorted, f);
expect(valueQuantile(sorted, value)).toBeCloseTo(f, 1);
}
});
it("interpolates strictly inside the range", () => {
expect(valueQuantile([0, 10], 5)).toBeCloseTo(0.5);
});
});
/** jsdom has no layout, so the rect is supplied rather than measured. */
const rect = (over: Partial<DOMRect> = {}): DOMRect =>
({ left: 100, width: 200, ...over }) as DOMRect;
describe("fractionFromPointer", () => {
it("maps the pointer to a fraction of the track", () => {
expect(fractionFromPointer(rect(), 200)).toBe(0.5);
});
it("clamps outside the track", () => {
expect(fractionFromPointer(rect(), 0)).toBe(0);
expect(fractionFromPointer(rect(), 9999)).toBe(1);
});
it("returns 0 for a zero-width track rather than dividing by zero", () => {
// jsdom reports zero-sized rects for everything, so this is the common
// case in tests rather than an exotic one.
expect(fractionFromPointer(rect({ width: 0 }), 150)).toBe(0);
});
});

79
web_ui/src/quantile.ts Normal file
View File

@ -0,0 +1,79 @@
/**
* Distribution-aware slider maths.
*
* A linear min..max track is useless on skewed data, which is the normal case
* in both consumers: dufs-cloud's file sizes span bytes to gigabytes, and
* awesome-mcp-explorer's star counts run 0..166,785 with a median of 8, so a
* linear midpoint sits above 99.9% of all rows. Mapping the track through the
* actual distribution puts the middle of the track at the median, which makes
* equal track distance mean roughly equal numbers of items.
*
* This was duplicated in dufs-cloud and awesome-mcp-explorer before it moved
* here; both now import it.
*/
/**
* Bounds-checked array access (callers guarantee the index is valid). Keeps the
* quantile helpers free of scattered non-null assertions or dead undefined
* guards while still satisfying `noUncheckedIndexedAccess`.
*/
export function nth(values: readonly number[], index: number): number {
const value = values[index];
if (value === undefined) {
throw new RangeError(`index ${index} out of range`);
}
return value;
}
/** Clamp to the unit interval. */
export function clamp01(x: number): number {
return Math.min(1, Math.max(0, x));
}
/**
* The value at quantile `f` (0..1) of an ascending, non-empty array, with linear
* interpolation between the two straddling samples. This is what makes the
* sliders distribution-aware: f = 0.5 returns the *median*, so the middle of the
* track sits where half the items are — not the arithmetic midpoint of the
* range. Empty input yields 0 (callers hide the slider in that case).
*/
export function quantileValue(sorted: readonly number[], f: number): number {
const n = sorted.length;
if (n === 0) return 0;
const pos = clamp01(f) * (n - 1);
const lo = Math.floor(pos);
const hi = Math.ceil(pos);
const a = nth(sorted, lo);
const b = nth(sorted, hi);
return Math.round(a + (b - a) * (pos - lo));
}
/**
* The quantile (0..1) of `value` within `sorted` — the inverse of
* {@link quantileValue}, used to place a thumb at the right spot on the track.
* Values at or beyond the ends map to 0/1.
*/
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 (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.
const hi = sorted.findIndex((v) => v >= value);
const lo = hi - 1;
const a = nth(sorted, lo);
const b = nth(sorted, hi);
// a < value <= b here, so b - a > 0 (no divide-by-zero).
return (lo + (value - a) / (b - a)) / (n - 1);
}
/**
* Pointer x to a 0..1 fraction of a track.
*
* Extracted as pure geometry so slider behaviour is testable without layout,
* which jsdom does not implement.
*/
export function fractionFromPointer(rect: DOMRect, clientX: number): number {
return rect.width === 0 ? 0 : clamp01((clientX - rect.left) / rect.width);
}

View File

@ -0,0 +1,68 @@
/*
* Styling for the shared RangeSlider. Imported separately from tokens.css:
* import "@kuhyx/web-ui/tokens.css";
* import "@kuhyx/web-ui/range-slider.css";
*
* Every value routes through a token — no literals.
*/
.slider-head {
display: flex;
justify-content: space-between;
color: var(--text-muted);
font-size: var(--text-caption);
letter-spacing: var(--tracking-caption);
}
.slider-value {
/* Tabular figures so a dragging readout does not reflow the row. */
font-variant-numeric: tabular-nums;
}
.slider-track {
position: relative;
height: 22px;
margin-top: var(--space-1);
/* Let the element own the gesture instead of the page scrolling under it. */
touch-action: none;
}
.slider-track::before {
content: "";
position: absolute;
top: 9px;
right: 0;
left: 0;
height: 4px;
border-radius: 2px;
background: var(--border);
}
.slider-fill {
position: absolute;
top: 9px;
height: 4px;
border-radius: 2px;
background: var(--accent);
}
.slider-thumb {
position: absolute;
top: 4px;
width: 14px;
height: 14px;
margin-left: -7px;
padding: 0;
border: 2px solid var(--accent);
border-radius: 50%;
background: var(--bg);
cursor: pointer;
}
/* The thumbs are the keyboard target, so the ring must be unmistakable. The
* global :focus-visible rule in tokens.css already colours it; the wider offset
* here keeps it clear of the 14px circle. */
.slider-thumb:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}

View File

@ -0,0 +1,209 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { RangeSlider, steppedValue } 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];
/** The track width the jsdom rect stub reports (see src/test/setup.ts). */
const TRACK_WIDTH = 1000;
function renderSlider(
properties: Partial<React.ComponentProps<typeof RangeSlider>> = {},
): { onChange: ReturnType<typeof vi.fn> } {
const onChange = vi.fn();
render(
<RangeSlider
hi={90}
lo={0}
onChange={onChange}
values={VALUES}
{...properties}
/>,
);
return { onChange };
}
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);
});
});
describe("RangeSlider", () => {
it("renders nothing when there is nothing to range over", () => {
const { container } = render(<RangeSlider hi={1} lo={1} onChange={vi.fn()} values={[1]} />);
expect(container).toBeEmptyDOMElement();
});
it("exposes both thumbs as sliders with the value trio", () => {
renderSlider();
const [low, high] = thumbs();
expect(low).toHaveAttribute("aria-valuemin", "0");
expect(low).toHaveAttribute("aria-valuemax", "90");
expect(low).toHaveAttribute("aria-valuenow", "0");
expect(high).toHaveAttribute("aria-valuenow", "90");
});
it("names the thumbs from the label", () => {
renderSlider({ label: "Stars" });
expect(screen.getByLabelText("Stars minimum")).toBeInTheDocument();
expect(screen.getByLabelText("Stars maximum")).toBeInTheDocument();
});
it("falls back to a generic name without a label", () => {
renderSlider();
expect(screen.getByLabelText("Range minimum")).toBeInTheDocument();
});
it("draws the head only when both label and format are given", () => {
renderSlider({ format: (v): string => `${String(v)}*`, label: "Stars" });
expect(screen.getByText("Stars")).toBeInTheDocument();
expect(screen.getByText("0* – 90*")).toBeInTheDocument();
});
it("omits the head when the caller draws its own chrome", () => {
renderSlider({ label: "Stars" });
expect(screen.queryByText("Stars")).not.toBeInTheDocument();
});
it("is focusable, so the control is a keyboard stop", () => {
renderSlider();
const [low] = thumbs();
low?.focus();
expect(low).toHaveFocus();
});
describe("keyboard", () => {
it.each([
["ArrowRight", 10],
["ArrowUp", 10],
])("%s steps the low thumb up", (key, expected) => {
const { onChange } = renderSlider();
fireEvent.keyDown(thumbs()[0] as HTMLElement, { key });
expect(onChange).toHaveBeenCalledWith(expected, 90);
});
it.each([
["ArrowLeft", 80],
["ArrowDown", 80],
])("%s steps the high thumb down", (key, expected) => {
const { onChange } = renderSlider();
fireEvent.keyDown(thumbs()[1] as HTMLElement, { key });
expect(onChange).toHaveBeenCalledWith(0, expected);
});
it("PageUp jumps ten samples", () => {
const { onChange } = renderSlider({ lo: 0 });
fireEvent.keyDown(thumbs()[0] as HTMLElement, { 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" });
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" });
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" });
expect(onChange).toHaveBeenCalledWith(0, 90);
});
it("ignores keys it does not handle", () => {
const { onChange } = renderSlider();
fireEvent.keyDown(thumbs()[0] as HTMLElement, { 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" });
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" });
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;
}
it("grabs the nearer thumb when pressed anywhere on the track", () => {
const { onChange } = renderSlider();
// 10% along: nearer the low thumb, which sits at 0. The track is
// quantile-mapped over n-1 intervals, so 0.1 lands on 9, not 10.
fireEvent.pointerDown(track(), { clientX: TRACK_WIDTH * 0.1, pointerId: 1 });
expect(onChange).toHaveBeenCalledWith(9, 90);
});
it("grabs the high thumb when the press is nearer to it", () => {
const { onChange } = renderSlider();
fireEvent.pointerDown(track(), { clientX: TRACK_WIDTH * 0.9, pointerId: 1 });
expect(onChange).toHaveBeenCalledWith(0, 81);
});
it("follows the pointer while dragging", () => {
const { onChange } = renderSlider();
fireEvent.pointerDown(track(), { clientX: 0, pointerId: 1 });
fireEvent.pointerMove(track(), { clientX: TRACK_WIDTH * 0.3, pointerId: 1 });
expect(onChange).toHaveBeenLastCalledWith(27, 90);
});
it("ignores movement when no thumb is held", () => {
const { onChange } = renderSlider();
fireEvent.pointerMove(track(), { clientX: TRACK_WIDTH * 0.3, pointerId: 1 });
expect(onChange).not.toHaveBeenCalled();
});
it("stops dragging on pointer up", () => {
const { onChange } = renderSlider();
fireEvent.pointerDown(track(), { clientX: 0, pointerId: 1 });
fireEvent.pointerUp(track(), { pointerId: 1 });
onChange.mockClear();
fireEvent.pointerMove(track(), { clientX: TRACK_WIDTH * 0.5, pointerId: 1 });
expect(onChange).not.toHaveBeenCalled();
});
it("ignores a pointer up that was never preceded by a press", () => {
const { onChange } = renderSlider();
fireEvent.pointerUp(track(), { pointerId: 1 });
expect(onChange).not.toHaveBeenCalled();
});
});
});

173
web_ui/src/range-slider.tsx Normal file
View File

@ -0,0 +1,173 @@
/**
* A dual-thumb slider whose track maps through a value *distribution* rather
* than a linear min..max ramp (see `./quantile.ts` for why).
*
* Reconciled from the two drifted copies in dufs-cloud and
* awesome-mcp-explorer. It takes the accessible DOM from awesome-mcp-explorer
* (real `<button>` thumbs carrying `role="slider"` and the `aria-value*` trio,
* so the control is a focus stop) and the grab-the-nearer-thumb pointer
* behaviour from dufs-cloud (pressing anywhere on the track moves the closer
* handle, instead of requiring a direct hit on an
* 8px target).
*
* Keyboard support is new in the shared version. Both donors were pointer-only,
* which the design system rules out outright: every action must be reachable
* with the keyboard alone, and a `role="slider"` that does not respond to arrow
* keys is an ARIA role that lies. Arrow keys step by one item through the
* distribution, Home/End jump to the bounds.
*
* The component deals only in raw values; callers translate the extremes into
* "no filter".
*/
import { useCallback, useState } from "react";
import { fractionFromPointer, nth, quantileValue, valueQuantile } from "./quantile.ts";
export interface RangeSliderProps {
/**
* Formats a value for the head readout. Omit it (with `label`) to render a
* bare track — dufs-cloud's wrappers draw their own labels and value notes,
* so a built-in head would duplicate them.
*/
readonly format?: (value: number) => string;
/** Current upper bound; equal to the distribution max when unconstrained. */
readonly hi: number;
/** Accessible name. Falls back to a generic one when omitted. */
readonly label?: string;
/** Current lower bound; equal to the distribution min when unconstrained. */
readonly lo: number;
/** Reports both thumb values (raw, clamped so lo <= hi) on every change. */
readonly onChange: (lo: number, hi: number) => void;
/** The full ascending distribution — not just its endpoints. */
readonly values: readonly number[];
}
type Thumb = "hi" | "lo";
/** Steps one thumb by `delta` positions through the distribution. Pure, so the
* keyboard contract is testable without layout. */
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 by exactly one item rather than stalling on a fractional step.
const index = Math.round(valueQuantile(values, current) * last);
return nth(values, Math.min(last, Math.max(0, index + delta)));
}
export function RangeSlider({
format,
hi,
label,
lo,
onChange,
values,
}: RangeSliderProps): null | React.JSX.Element {
const [drag, setDrag] = useState<Thumb | null>(null);
const apply = useCallback(
(thumb: Thumb, value: number): void => {
if (thumb === "lo") onChange(Math.min(value, hi), hi);
else onChange(lo, Math.max(value, lo));
},
[hi, lo, onChange],
);
// 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: HTMLElement, clientX: number): number =>
quantileValue(values, fractionFromPointer(track.getBoundingClientRect(), clientX)),
[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));
}
},
[apply, hi, lo, values],
);
// Fewer than two values means there is nothing to range over.
if (values.length < 2) return null;
const lowest = nth(values, 0);
const highest = nth(values, values.length - 1);
const loFraction = valueQuantile(values, lo) * 100;
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",
});
return (
<div className="slider">
{label !== undefined && format !== undefined && (
<div className="slider-head">
<span>{label}</span>
<span className="slider-value">
{format(lo)} – {format(hi)}
</span>
</div>
)}
<div
className="slider-track"
onPointerDown={(event): void => {
const value = valueAt(event.currentTarget, event.clientX);
// Grab whichever thumb is nearer, so a press anywhere on the track
// does something sensible instead of requiring a direct hit.
const thumb: Thumb = Math.abs(value - lo) <= Math.abs(value - hi) ? "lo" : "hi";
setDrag(thumb);
event.currentTarget.setPointerCapture(event.pointerId);
apply(thumb, value);
}}
onPointerMove={(event): void => {
if (drag === null) return;
apply(drag, valueAt(event.currentTarget, event.clientX));
}}
onPointerUp={(event): void => {
if (drag === null) return;
setDrag(null);
event.currentTarget.releasePointerCapture(event.pointerId);
}}
>
<div
className="slider-fill"
style={{ left: `${String(loFraction)}%`, width: `${String(hiFraction - loFraction)}%` }}
/>
<button aria-label={`${name} minimum`} {...thumbProps("lo")} />
<button aria-label={`${name} maximum`} {...thumbProps("hi")} />
</div>
</div>
);
}

38
web_ui/src/test/setup.ts Normal file
View File

@ -0,0 +1,38 @@
import "@testing-library/jest-dom/vitest";
// jsdom implements no layout engine (every rect is zero-sized) and no pointer
// capture. The slider needs a non-zero track width for fractionFromPointer to
// mean anything, and needs the capture calls to not throw.
const TRACK_WIDTH = 1000;
Object.defineProperty(Element.prototype, "getBoundingClientRect", {
configurable: true,
value(): DOMRect {
return {
bottom: 20,
height: 20,
left: 0,
right: TRACK_WIDTH,
toJSON: () => ({}),
top: 0,
width: TRACK_WIDTH,
x: 0,
y: 0,
};
},
});
Object.defineProperties(Element.prototype, {
releasePointerCapture: {
configurable: true,
value: (): void => {
// No pointer capture in jsdom; the slider only needs the call to not throw.
},
},
setPointerCapture: {
configurable: true,
value: (): void => {
// As above.
},
},
});

33
web_ui/src/text.test.ts Normal file
View File

@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { fuzzyMatch } from "./text.ts";
describe("fuzzyMatch", () => {
it("matches everything on an empty query", () => {
expect(fuzzyMatch("", "anything")).toBe(true);
});
it("matches an exact substring", () => {
expect(fuzzyMatch("cat", "concatenate")).toBe(true);
});
it("matches a non-adjacent subsequence", () => {
expect(fuzzyMatch("cne", "concatenate")).toBe(true);
});
it("ignores case on both sides", () => {
expect(fuzzyMatch("CaT", "concatenate")).toBe(true);
});
it("rejects characters that appear out of order", () => {
expect(fuzzyMatch("tac", "cat")).toBe(false);
});
it("rejects a query with a character the target lacks", () => {
expect(fuzzyMatch("dog", "concatenate")).toBe(false);
});
it("rejects a query longer than the target", () => {
expect(fuzzyMatch("catalogue", "cat")).toBe(false);
});
});

25
web_ui/src/text.ts Normal file
View File

@ -0,0 +1,25 @@
/**
* Small text helpers shared by the consumers' filter layers.
*
* Hand-rolled rather than pulling in Fuse.js, matching the house posture of no
* runtime dependencies for things this small.
*/
/**
* Case-insensitive subsequence match: every character of `query` appears in
* `target`, in order, though not necessarily adjacently. An empty query matches
* everything, so callers can pass the raw search box value straight through.
*
* dufs-cloud called this `fuzzyMatch` and awesome-mcp-explorer `matchesFuzzy`;
* the implementations were identical apart from identifier names.
*/
export function fuzzyMatch(query: string, target: string): boolean {
if (query === "") return true;
const q = query.toLowerCase();
const t = target.toLowerCase();
let index = 0;
for (let scan = 0; scan < t.length && index < q.length; scan++) {
if (t[scan] === q[index]) index++;
}
return index === q.length;
}

112
web_ui/src/tokens.css Normal file
View File

@ -0,0 +1,112 @@
/*
* The frozen design tokens, shared by every one of kuhy's web repos.
*
* Source of truth: ~/utils/unified-design-system/tokens.md (values) and
* README.md (the CSS custom-property names). The same numbers ship for Dart in
* ~/utils/design_system, so a change here is a change there — do not edit one
* side alone, and do not introduce a value that is not on a scale below.
*
* Dark is the default; light is an override, matching the README.
*/
:root {
/* Neutrals ------------------------------------------------------------- */
--bg: #211d1b;
--surface-1: #2b2624;
--surface-2: #38312e;
--border: #463e3a;
--text: #eceae9;
--text-muted: #aaa09a;
/* Accent + semantic roles ---------------------------------------------- */
--accent: #b8862e;
--info: #b8862e; /* = accent */
--success: #8a9a3c;
--warning: #e0a63c;
--danger: #e2585f;
/* Text/icons ON a filled accent/success/warning/danger surface. Never
* --text: every semantic fill sits in the same mid-light band, where
* near-white measures 2.2:1-3.6:1 against a 4.5:1 floor. */
--on-fill: #211d1b;
/* Text/icons on a dark translucent scrim/overlay — fixed, not theme-tied,
* because the scrim itself is always dark. */
--on-scrim: #eceae9;
/* Corner radius --------------------------------------------------------- */
--radius-sm: 8px; /* buttons, inputs, chips */
--radius-md: 12px; /* cards */
--radius-lg: 16px; /* sheets, dialogs */
/* Spacing (4px base) ---------------------------------------------------- */
--space-1: 4px;
--space-2: 8px;
--space-3: 16px;
--space-4: 24px;
--space-5: 32px;
--space-6: 48px;
/* Typography ------------------------------------------------------------ */
--font-sans: -apple-system, "Segoe UI", Roboto, system-ui, sans-serif;
--font-mono: ui-monospace, "Cascadia Code", monospace;
--text-display: 32px;
--text-title: 24px;
--text-subtitle: 20px;
--text-body: 16px; /* the floor for anything a user actually reads */
--text-label: 14px; /* UI chrome only */
--text-caption: 12px; /* UI chrome only */
--tracking-display: -0.2px;
--tracking-title: -0.1px;
--tracking-label: 0.05em;
--tracking-caption: 0.1em;
/* Prose line-length cap (rule 21): ~65-70 characters. */
--measure: 40rem;
/* Shadows: light surfaces only, and only on floating/overlay elements —
* never inline cards or list rows. Blur = 2x offset, tinted from ink. */
--shadow-1: 0 2px 4px rgba(33, 29, 27, 0.12); /* dropdown */
--shadow-2: 0 4px 8px rgba(33, 29, 27, 0.16); /* modal */
/* Focus ring: the platform default is black, invisible on --bg. */
--focus-ring: #b8862e; /* = accent */
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f6f4f3;
--surface-1: #fcfbfb;
--surface-2: #fcfbfb;
--border: #e0dad7;
--text: #211d1b;
--text-muted: #70625b;
/* --on-fill / --on-scrim stay fixed — the fills and the scrim do not
* change with page theme. */
}
}
/* Dark surfaces take no shadow at all; elevation is a fill step. Light
* surfaces opt in explicitly via --shadow-1 / --shadow-2. */
@media (prefers-color-scheme: dark) {
:root {
--shadow-1: none;
--shadow-2: none;
}
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
font-size: var(--text-body);
}
/* Ships here rather than being left to each consumer: an unstyled focus ring
* is the accessibility regression this layer exists to prevent, and several of
* these apps are keyboard-only by necessity. */
:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 1px;
}

41
web_ui/tsconfig.json Normal file
View File

@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"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,
emit is on and .ts import specifiers must be rewritten. */
"rewriteRelativeImportExtensions": true,
"allowImportingTsExtensions": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"]
}

18
web_ui/vitest.config.ts Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
exclude: ["src/index.ts", "src/**/*.d.ts"],
include: ["src/**/*.{ts,tsx}"],
provider: "v8",
// The same bar Phase 1's design_system holds: a shared layer that two
// repos depend on does not get to be the least-tested code in the tree.
thresholds: { branches: 100, functions: 100, lines: 100, statements: 100 },
},
environment: "jsdom",
globals: true,
include: ["src/**/*.test.{ts,tsx}"],
setupFiles: ["./src/test/setup.ts"],
},
});