utils/web_ui/README.md
Krzysztof kuhy Rudnicki decf5824e6
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
web_ui tests / test (push) Has been cancelled
web_ui 0.5.0: port to Astro + custom element
RangeSlider.astro + <range-slider> replace the React component.
Pure helpers, tokens.css and range-slider.css are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
2026-11-01 20:03:45 +01:00

142 lines
6.3 KiB
Markdown

# @kuhyx/web-ui
The shared token + component layer for kuhy's web apps — the web counterpart to
`~/src/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 `~/src/utils/unified-design-system/DOCS-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.5.0&path:/web_ui"
}
```
Astro is the only supported framework (`peerDependencies.astro >=7.4.0`).
```astro
---
// Layout / page frontmatter
import RangeSlider from "@kuhyx/web-ui/RangeSlider.astro";
import "@kuhyx/web-ui/tokens.css"; // once, in the shared layout
import "@kuhyx/web-ui/range-slider.css"; // only if you use RangeSlider
---
<RangeSlider label="Stars" />
<script>
import "@kuhyx/web-ui/range-slider"; // registers <range-slider>
const slider = document.querySelector("range-slider");
slider.values = [0, 0, 3, 40, 900]; // ascending distribution
slider.format = (value) => `${value} stars`;
slider.addEventListener("change", (event) => {
const { lo, hi } = event.detail; // bubbles; raw values, lo <= hi
});
</script>
```
Pure helpers (`fuzzyMatch`, `quantileValue`, ...) come from
`import { fuzzyMatch } from "@kuhyx/web-ui";`. That entry point touches no DOM,
so it is safe in frontmatter.
### 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, and why `.astro` ships as source
tsc cannot emit `.astro`, so `src/RangeSlider.astro` ships as it is and Astro
compiles it from the dependency. Everything TypeScript ships prebuilt: the tag
carries `dist/` (ESM + `.d.ts`, `.ts` specifiers rewritten to `.js`), and the
`.astro` file imports `../dist/...`, never the `.ts` source. **`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.astro` | Server-rendered shell for `<range-slider>`. Props: `values`, `lo`, `hi`, `label`, `format` (server-side, first paint of the head only). Its `<script>` registers the element. |
| `range-slider` | The custom element. `values` and `format` properties, `lo` / `hi` / `label` attributes, bubbling `change` event with `{ lo, hi }`. Adopts the server markup, or builds it in plain HTML. |
| `range-slider.css` | Its styling, entirely in tokens. |
| `quantile.ts` helpers | `nth`, `clamp01`, `quantileValue`, `valueQuantile`, `fractionFromPointer`. |
| `stepped-value.ts` helpers | `keyTarget`, `steppedValue`. |
| `fuzzyMatch` | Case-insensitive subsequence match. |
The element writes `lo` / `hi` itself on every change, so it works without any
wiring; set the attributes from outside to override it (e.g. a URL-hash restore).
With fewer than two `values` the slider stays hidden. `values` set before the
element upgrades is picked up on connect.
### The categorical ramp
`--cat-1` … `--cat-6` are for charts, tags and category dots — N mutually
distinguishable colours with **no** inherent meaning. Never use them for
semantic state; `--success`/`--warning`/`--danger` own that.
They deliberately leave the warm wedge the rest of the palette sits in, because
hue separation is the whole point of a categorical scale. The values were chosen
by search under a ≥20 CIE ΔE floor across normal vision *and* deuteranopia,
protanopia and tritanopia, ≥3:1 contrast on **both** backgrounds, and a
monotonic lightness staircase so the ramp survives greyscale. Re-run the proof
with `python3 ~/src/utils/unified-design-system/scripts/ramp_check.py` — it exits
non-zero if an edit breaks any of it. A seventh hue is not available: the best
candidate reaches only ΔE 17.9. Group the tail into "other" instead.
`fractionFromPointer` is deliberately pure geometry: it makes the slider's
behaviour testable without layout, which jsdom does not implement.
### `<range-slider>` 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 to the next **distinct** value |
| `PageDown` / `PageUp` | Step ten distinct values |
| `Home` / `End` | Jump to the distribution's bounds |
Set `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.
Stepping targets the next *distinct* value, not the next index. Real
distributions are lumpy — ~1500 of awesome-mcp-explorer's ~2981 star counts are
`0` — and an index step inside a run of equal values moves the index while
leaving the value, the thumb and the filter untouched. That reads as a dead
arrow key, and jsdom's evenly-spaced fixtures do not catch it.
## Development
```bash
pnpm install
pnpm test # vitest (jsdom for the element, AstroContainer for the .astro markup)
pnpm coverage # 100% branches/functions/lines/statements, enforced
pnpm lint # tsc --noEmit && eslint && astro check
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`.