diff --git a/web_ui/.gitignore b/web_ui/.gitignore new file mode 100644 index 0000000..3110c21 --- /dev/null +++ b/web_ui/.gitignore @@ -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 diff --git a/web_ui/README.md b/web_ui/README.md new file mode 100644 index 0000000..079ff01 --- /dev/null +++ b/web_ui/README.md @@ -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:/`, 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: " +git tag web_ui-v.. && git push origin main --tags +``` + +Then bump the tag in each consumer's `package.json` and re-run `pnpm install`. diff --git a/web_ui/dist/index.d.ts b/web_ui/dist/index.d.ts new file mode 100644 index 0000000..698bcea --- /dev/null +++ b/web_ui/dist/index.d.ts @@ -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 \ No newline at end of file diff --git a/web_ui/dist/index.d.ts.map b/web_ui/dist/index.d.ts.map new file mode 100644 index 0000000..6f893b9 --- /dev/null +++ b/web_ui/dist/index.d.ts.map @@ -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"} \ No newline at end of file diff --git a/web_ui/dist/index.js b/web_ui/dist/index.js new file mode 100644 index 0000000..8afe078 --- /dev/null +++ b/web_ui/dist/index.js @@ -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 \ No newline at end of file diff --git a/web_ui/dist/index.js.map b/web_ui/dist/index.js.map new file mode 100644 index 0000000..9eac3c7 --- /dev/null +++ b/web_ui/dist/index.js.map @@ -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"} \ No newline at end of file diff --git a/web_ui/dist/quantile.d.ts b/web_ui/dist/quantile.d.ts new file mode 100644 index 0000000..88e517f --- /dev/null +++ b/web_ui/dist/quantile.d.ts @@ -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 \ No newline at end of file diff --git a/web_ui/dist/quantile.d.ts.map b/web_ui/dist/quantile.d.ts.map new file mode 100644 index 0000000..a9122bd --- /dev/null +++ b/web_ui/dist/quantile.d.ts.map @@ -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"} \ No newline at end of file diff --git a/web_ui/dist/quantile.js b/web_ui/dist/quantile.js new file mode 100644 index 0000000..b41f2ac --- /dev/null +++ b/web_ui/dist/quantile.js @@ -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 \ No newline at end of file diff --git a/web_ui/dist/quantile.js.map b/web_ui/dist/quantile.js.map new file mode 100644 index 0000000..51c4e24 --- /dev/null +++ b/web_ui/dist/quantile.js.map @@ -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"} \ No newline at end of file diff --git a/web_ui/dist/range-slider.css b/web_ui/dist/range-slider.css new file mode 100644 index 0000000..0cb3d6e --- /dev/null +++ b/web_ui/dist/range-slider.css @@ -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; +} diff --git a/web_ui/dist/range-slider.d.ts b/web_ui/dist/range-slider.d.ts new file mode 100644 index 0000000..2e633c6 --- /dev/null +++ b/web_ui/dist/range-slider.d.ts @@ -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 \ 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 new file mode 100644 index 0000000..ac62093 --- /dev/null +++ b/web_ui/dist/range-slider.d.ts.map @@ -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"} \ No newline at end of file diff --git a/web_ui/dist/range-slider.js b/web_ui/dist/range-slider.js new file mode 100644 index 0000000..40a4240 --- /dev/null +++ b/web_ui/dist/range-slider.js @@ -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 `