Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .claude/hooks/ask-before-risky-commands.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Claude Code PreToolUse(Bash) gate — pathscale backend service.
#
# Prompts before prod-affecting / destructive commands in ANY wrapper form
# (env VAR=val …, tool -C <dir> …, chained with ; && |, multi-line, quoted via
# bash -c '…'). For everything else it stays SILENT (exit 0, no decision), so the
# normal permission rules — permissions.allow / ask / deny in .claude/settings.json
# and the session's permission mode — decide as usual.
#
# This is one layer of defense, not a replacement for the permission system: a
# pattern match over a command string is best-effort (quoting and indirection can
# evade any blocklist). It backs up the declarative permissions.ask list in
# .claude/settings.json — KEEP THE TWO IN SYNC — and for fully autonomous runs,
# prefer OS-level sandboxing on top.
#
# Adapted from PakhomovAlexander/project-hub. Edit RISKY_WORDS for this repo;
# tooling; further branches below gate `git`/`docker push`, `git clean`, recursive
# `rm`, `find -delete`, package publishing, PR-merge/release via `gh`, and a deploy
# script run by path. Add your own command families (e.g. `ssh`, a bespoke deploy
# CLI) to RISKY_WORDS, or trim what you don't use — and mirror the change in
# permissions.ask in .claude/settings.json.
set -u

# --- the watchlist: command words that should prompt before running ----------------
RISKY_WORDS="aws|gcloud|az|kubectl|helm|terraform|terragrunt|flyctl|fly"
# -----------------------------------------------------------------------------------

# Pull the command out of the hook's stdin JSON (jq if available, else python3).
if command -v jq >/dev/null 2>&1; then
cmd="$(jq -r '.tool_input.command // ""' 2>/dev/null)"
else
cmd="$(python3 -c 'import sys, json; print(json.load(sys.stdin).get("tool_input", {}).get("command", ""))' 2>/dev/null)"
fi

# Couldn't read the command → stay neutral, let normal permission rules decide.
[ -z "$cmd" ] && exit 0

# A command word counts as "at a command position" after start-of-line, whitespace,
# ; & | ( or a quote — and ends before whitespace, a quote, ) ; & | or end-of-line —
# so `bash -c 'git push'` is still seen.
b="(^|[[:space:];&|(\"'\`])"
e="([[:space:];&|)\"'\`]|$)"
# Global options that may sit between a tool and its subcommand (git -C dir push).
opts='([[:space:]]+(-[A-Za-z-]+|[-_A-Za-z0-9]+=[^[:space:]]+|-C[[:space:]]+[^[:space:]]+))*'

re="${b}(${RISKY_WORDS})${e}"
# git push / git clean, docker push — allowing global options before the subcommand.
re="$re|${b}git${opts}[[:space:]]+(push|clean)${e}"
re="$re|${b}docker${opts}[[:space:]]+push${e}"
# recursive rm (-r / -R / -fr / --recursive), with flags/paths in any order.
re="$re|${b}rm([[:space:]]+[^[:space:]]+)*[[:space:]]+(-[A-Za-z]*[rR]|--recursive)"
# find … -delete — irreversible bulk delete.
re="$re|${b}find([[:space:]]+[^[:space:]]+)*[[:space:]]+-delete${e}"
# package publishing — ships artifacts to a registry.
re="$re|${b}(npm|pnpm|yarn|bun|cargo|gem)([[:space:]]+[^[:space:]]+)*[[:space:]]+publish${e}"
re="$re|${b}twine([[:space:]]+[^[:space:]]+)*[[:space:]]+upload${e}"
# gh mutations that merge, ship, or destroy.
re="$re|${b}gh[[:space:]]+(pr[[:space:]]+merge|repo[[:space:]]+delete|release[[:space:]]+(create|delete))${e}"
re="$re|${b}gh[[:space:]]+api([[:space:]]+[^[:space:]]+)*[[:space:]]+(-X|--method)[[:space:]]+(POST|PUT|PATCH|DELETE)${e}"
# a deploy script invoked by path, e.g. ./scripts/deploy.sh — a word-list can't see it.
re="$re|${b}([./A-Za-z0-9_-]*/)?deploy(\.[A-Za-z]+)?${e}"
# WorkTable data migrations and endpoint regeneration rewrite committed artifacts.
re="$re|${b}([./A-Za-z0-9_-]*/)?regenerate_endpoints(\.[A-Za-z]+)?${e}"

if printf '%s\n' "$cmd" | grep -Eq "$re"; then
printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"Prod-affecting / destructive command — confirm before running."}}'
fi
# No match → no output: fall through to the normal permission flow.
exit 0
43 changes: 43 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"permissions": {
"allow": [
"Bash(bun run build:*)",
"Bash(bun run test:*)",
"Bash(bun run typecheck:*)",
"Bash(bun run lint:*)",
"Bash(bun install:*)",
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)"
],
"ask": [
"Bash(git push:*)",
"Bash(cargo publish:*)",
"Bash(npm publish:*)",
"Bash(bun publish:*)",
"Bash(docker push:*)",
"Bash(gh pr merge:*)",
"Bash(gh release:*)",
"Bash(aws:*)",
"Bash(gcloud:*)",
"Bash(az:*)",
"Bash(kubectl:*)",
"Bash(helm:*)",
"Bash(terraform:*)",
"Bash(flyctl:*)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/ask-before-risky-commands.sh\""
}
]
}
]
}
}
88 changes: 88 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Working agreement — UI

The operating contract for **any** coding agent working in this repository. This file is
the single source of truth for the rules: Codex, Cursor and Gemini CLI read `AGENTS.md`
natively, and Claude Code loads it through the `@AGENTS.md` import in
[`CLAUDE.md`](CLAUDE.md). **Never fork these rules into a per-vendor file.**

**JavaScript/TypeScript library** (`@pathscale/ui`), built with `bun`.

## Invariants (don't break these)

- **Read [`docs/frontend-conventions.md`](docs/frontend-conventions.md) before opening
implementation files.** It is the frontend working agreement: SolidJS/`@pathscale/ui`
conventions, and a context-efficient workflow. Reading it first keeps
context small and avoids re-deriving patterns that already exist.
- **Publishing to npm is irreversible.** A version can never be reused. Check the built artifact before releasing.
- **`bun` is the package manager** — its lockfile is authoritative. Don't introduce a second one by running npm/yarn/pnpm here.
- **Docs describe what is true now.** If you change behaviour, update the README and any affected doc in the same change.

## Build & run

```bash
bun install
bun run dev
bun run build
bun run lint
```

## Verification

Run what you build before reporting it done. Type-checks and tests verify code correctness,
not feature correctness — **if you can't run it, say so explicitly** rather than implying
success.

- Compare against the base branch rather than asserting: a pre-existing failing test or lint
error is not something you introduced, and saying so requires checking.
- A build that finishes suspiciously fast was cached, not rebuilt. Force a real rebuild when
the rebuild is the thing you're verifying.

## PR discipline

**Always paste the full PR URL** (`https://github.com/pathscale/UI/pull/<n>`), not just the number, so it's
clickable.

<!-- DORMANT — CI-green gating. Do not follow this rule yet; re-enable it as its own project.

Why it's off: CI here does not reliably attach checks to pull requests, so
`statusCheckRollup` comes back empty and "wait for green" would teach an agent to wait on
nothing. Verify per repo before switching this on.

To enable: ensure the workflow runs on `pull_request:`, confirm checks attach to a PR, then
uncomment the rule below.

After any push or PR, **check CI and don't call it done until it's green**:

```bash
gh pr view <number> --repo pathscale/UI --json statusCheckRollup
```

CI running → wait and recheck. CI failed → read the logs, fix, push, wait for green.
-->

## Keeping docs honest

Hit a factual error here — a stale path, a wrong command, a moved status? Fix it in the same
change. Don't open cosmetic rewording PRs.

Learned something durable — a gotcha, a decision, a constraint? It belongs **in this repo's
docs**, not in your agent's private memory. Repo docs are versioned, reviewable, and visible
to every agent and human; private memory dies with your machine.

## Git workflow

- **Always specify the branch when pushing**: `git push origin branch-name`
- **Branch naming**: `fix/issue-description` or `feat/issue-description`
- **No force-pushing** during PR review

## Guardrails

[`.claude/settings.json`](.claude/settings.json) and [`.claude/hooks/`](.claude/hooks/) make
Claude Code prompt a human before prod-affecting or destructive commands — pushes, publishing
to a registry, `gh pr merge`, cloud CLIs, recursive deletes, deploy scripts.

**Other agents don't get that net automatically.** Apply the same rule yourself: ask before
running any command family listed in
[`.claude/hooks/ask-before-risky-commands.sh`](.claude/hooks/ask-before-risky-commands.sh).
It is one layer of defence, not a guarantee — a pattern match over a command string is
best-effort.
68 changes: 68 additions & 0 deletions ANALYSIS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# @pathscale/ui — Internals Analysis

*Analysis of the repo at `github.com/pathscale/ui`, v1.2.11, cloned 2026-07-18.*

## What it is

A highly opinionated **SolidJS component library** (~104 component directories, 419 TS/TSX source files) targeting **HeroUI API parity** in Solid, built on a daisyUI-flavored CSS-variable theming system. It is the product of a large migration (documented in `docs/component-migration-map.md`): ~40 old components were removed, several renamed (`Loading→Spinner`, `DropdownSelect→Select`, `RadialProgress→ProgressCircle`), and the styling layer was refactored into per-component `.classes.ts` + `.css` files.

Toolchain: **Bun** (runtime, package manager, test runner), **rslib** (bundleless ESM build), **Biome** (lint/format), **Tailwind v4** (consumer-side; the library authors plain CSS + tokens), **TypeScript 6 strict**.

## Architecture at a glance

```
src/
index.ts # 922-line barrel — the single public surface
index.css # imports base + themes + icons, declares Tailwind v4 @theme tokens
components/ # ~104 folders, each: X.tsx + X.classes.ts + X.css + index.ts
_shared/ # createOverlayPosition — bespoke floating-ui replacement
hooks/ # date/ (internal date engine), form/ (TanStack Form wrapper),
# table/ (TanStack Table state slices), layout/ (useDesktop)
primitives/ # virtualizer/ (TanStack Virtual wrapper), streaming/ (buffer + subscription)
motion/ # driver-based tween engine + Solid components (MotionDiv, Presence, AnimatedCollapse)
lib/ # mergeRefs, chain, OverrideProps, homegrown cva, tag helpers
styles/ # base/, themes/{light,dark}.css, icons/generated-icons.css
```

### The component contract (enforced by CI)

Every component is a four-file quartet: `X.tsx` (implementation), `X.classes.ts` (a `CLASSES` const mapping semantic slots/variants to BEM-ish class strings like `button--primary`), `X.css` (actual styles in `@layer components`, driven by theme CSS variables), and `index.ts` (barrel). `scripts/check-contracts.ts` runs before every build and CI, and fails the build if a component lacks an `index.ts` with a type export, lacks `splitProps` or `twMerge` in its main file, or contains a purely static inline `style={{}}`.

Conventions worth knowing:
- **Class merging**: `twMerge(CLASSES.base, CLASSES.variant[v], flag && CLASSES.flag.x, local.class, local.className)` — consumer classes always win. Both `class` and `className` are accepted (React-refugee ergonomics).
- **Boolean props are `is*`** (`isDisabled`, `isOpen`, `isInvalid`), HeroUI-style, reconciled with native attrs (`isDisabled ?? disabled`).
- **`data-slot` on every part** plus state mirrored to `data-open`/`data-selected`/`data-invalid` etc., so CSS and tests target data attributes.
- **Compound components** via `Object.assign(Root, {Trigger, Content, ...})` + Solid context holding accessors/setters; controlled/uncontrolled via the `value/defaultValue/onChange` triple pattern.
- **No polymorphic `as` prop** — element types are hardcoded per part.

### Notable subsystems

- **Overlay positioning is bespoke** (`src/components/_shared/overlayPosition.ts`) — no floating-ui. Auto-flip, viewport clamping, rAF-batched, used by Dropdown/Select/pickers. All positioning bugs live in this one file.
- **Motion** (`src/motion/`): a driver-based tween system. The default driver is **immediate** — everything snaps to its end state until the consumer installs a real driver via `enablePopmotion(animate)` (popmotion is an optional peer; `popmotion.ts` is a 17-line adapter, not vendored code). Presets (`route`, `fade`, `toast`…) are built from tokens; `Presence`/`MotionDiv`/`AnimatedCollapse` are the Solid layer. Note: most components (Modal, Toast, Drawer) animate with **CSS**, not this system.
- **Metal-border** (newest feature): a WebGL "liquid metal" plasma border. All instances share **one offscreen GL context and one ~15fps RAF loop**; each instance blits a cropped region to its own 2D canvas and punches out the interior. Includes an SVG glow subsystem driven by GPU pixel readback with a hotspot state machine. Graceful degradation when WebGL is unavailable; pauses offscreen (IntersectionObserver) and under reduced-motion.
- **Forms**: `createForm` wraps TanStack Form with Standard Schema (Zod/Valibot/Arktype) validation wired to change+blur+submit; `Form`/`FormField`/`FormSubmitButton` compose it. Field errors are touch-gated (invisible until blur).
- **Dates**: a **fully internal date engine** (native `Date` + `Intl`, all dates constructed at noon to dodge DST) powering Calendar/RangeCalendar/DatePicker/DateRangePicker. The segmented `date-field`/`time-field` inputs are a separate, unrelated code path.
- **Table**: headless — thin presentational compound parts over `src/hooks/table` state-slice hooks wrapping TanStack Table. Virtualization pieces (`useVirtualRows`, `VirtualSpacerRow`) ship separately and are **not wired in**; integration is the consumer's job.
- **Icons**: build-time Iconify pipeline. `pluginIconify` scans source for `icon-[set--name]` classes and emits `src/styles/icons/generated-icons.css` (committed, currently 14 icons) with data-URI SVG masks. The `Icon` component just merges the icon class name onto a span.
- **Theming**: 2 themes (`light` default, `dark`) switched via `data-theme` on `<html>`. Tokens are daisyUI-compatible (`--color-primary`, `--color-base-100`, `--b1`…) plus HeroUI-style (`--color-default/-foreground`) plus a ~35-variable glass-surface set. `src/index.css`'s `@theme` block re-exposes them as Tailwind v4 utilities.

### Build & CI

`bun run build` = contract check → `rslib build` (bundleless ESM, file-per-module, d.ts, CSS copied verbatim) → purge-manifest generation (`dist/purge-manifest.json`, consumed by `@pathscale/rsbuild-plugin-ui-css-purge` so apps can drop CSS for components they don't import). CI (GitHub Actions on `master`) runs contract check, `tsc --noEmit`, and build; PR previews are built from the playground and deployed to Surge. Versioning/publishing is manual (bare version-number commits). The playground (`playground/`, Vite + Tailwind v4) aliases `@pathscale/ui` to local `src/` for instant hot-reload — its `App.tsx` is a single ~7,300-line demo page.

## Issues found (worth acting on)

1. **🔴 Leaked credential**: `.github/workflows/preview.yml` hardcodes a Surge token in plaintext (`SURGE_TOKEN: 256124bc...`). Anyone can deploy/teardown sites on that account. Rotate the token and move it to repo secrets.
2. **Broken `./stores` export**: `package.json` exports `./stores` → `dist/stores/index.js`, but there is no `src/stores/` — the build never produces that file, so importing `@pathscale/ui/stores` throws. Remove the entry or add the module.
3. **Stale README**: it tells consumers to import `dist/styles/compat/daisy-primitives.css` (no `compat/` exists in source) and points to `docs/motion.md` (doesn't exist; only `component-migration-map.md` does).
4. **Tests exist but CI never runs them**: 4 test files under `tests/` (bun:test, pure-function coverage only) and no `test` script in package.json; CI doesn't invoke `bun test`.
5. **CI depends on a sibling checkout**: the build symlinks `pathscale/rsbuild-plugin-ui-css-purge` to `../rsbuild-plugin-ui-css-purge` — local builds may behave differently from CI if you don't replicate that layout.
6. **Duplicated helpers**: `invokeEventHandler` is copy-pasted into ~6 components (Card, Dropdown, Tabs, Toast, Select, Table), as is `sortItemsByDomOrder` — a bug fix must touch all copies.
7. **Dead/legacy code**: the homegrown `cva` in `src/lib/style/classes.ts` is imported by no component (the real styling engine is `CLASSES` + `twMerge`); `scripts/copy-css.js` is superseded by rslib's copy step; `cally` is an unused devDependency; `useFieldNew.ts` is legacy naming (exports `useField`).
8. **Minor internal inconsistencies**: metal-border's header comment says ~30fps but the constant is 66ms (~15fps); its `GLOW_READBACK_INTERVAL_MS` throttle constant isn't actually applied in the loop; `AnimatedCollapse` has an intentionally no-op ResizeObserver placeholder.

## Overall assessment

The library is coherent and unusually disciplined for its size: one enforced component anatomy, one styling contract, one state-management idiom, and heavy leverage of TanStack for the hard generic problems (form/table/virtual) while keeping bespoke code where it pays off (overlay positioning, date engine, motion seam, WebGL effects). The main risks are operational rather than architectural: the leaked CI token, the untested-in-CI test suite, dead config (`./stores`), and copy-pasted micro-helpers. The design choice that most often surprises newcomers: **nothing in the motion system animates until a driver is installed**, and **the virtualizer is intentionally not integrated into Table**.

Companion docs (for Claude's future sessions, also human-readable): [CLAUDE.md](CLAUDE.md) (usage reference) and [CLAUDE-INTERNALS.md](CLAUDE-INTERNALS.md) (modification guide).
Loading
Loading