diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..40f903d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +# .dockerignore +**/node_modules +**/dist +**/.turbo +**/coverage +**/*.log +.git +.github +docs +design +*.db +*.db-* +media +.env +.env.* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..96f57cb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +# .github/workflows/ci.yml +name: ci +on: + push: + branches: [main, 'feat/**'] + pull_request: + +jobs: + unit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: { version: 9 } + - uses: actions/setup-node@v4 + with: { node-version: 22, cache: pnpm } + - run: pnpm install --frozen-lockfile + - run: pnpm typecheck + - run: pnpm -r test + + smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: 22 } + - name: Build and start the real stack + run: docker compose -f docker-compose.ci.yml up -d --build + - name: Install smoke deps + run: npm install ws@8 + - name: Run the cold-start smoke (signup → IRC → restart → intact) + run: BOOL_IRC_HOST=irc node tools/ci-smoke.mjs + - name: Dump logs on failure + if: failure() + run: docker compose -f docker-compose.ci.yml logs diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..30303b5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,45 @@ +# syntax=docker/dockerfile:1 + +# ---- Builder: compile all workspace packages (needs toolchain for better-sqlite3) ---- +FROM node:20-bookworm AS builder +WORKDIR /app +RUN npm install -g pnpm@9 + +# Install deps with the full workspace manifest set for better layer caching +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./ +COPY packages/client/package.json packages/client/ +COPY packages/server/package.json packages/server/ +COPY packages/shared/package.json packages/shared/ +RUN pnpm install --frozen-lockfile || pnpm install + +# Copy sources and build everything (shared → server dist, client → static assets). +# The desktop (Electron) package is not part of the container — its deps are not +# installed above, so exclude it from the recursive build. +COPY . . +RUN pnpm --filter '!@bool/desktop' -r build + +# ---- Runtime: slim image, run the Fastify server ---- +FROM node:20-bookworm-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production \ + HOST=0.0.0.0 \ + PORT=3030 \ + BOOL_DB=/data/bool.db \ + BOOL_MEDIA_DIR=/data/media + +RUN npm install -g pnpm@9 \ + && mkdir -p /data/media + +# Bring the built workspace (includes node_modules with the compiled better-sqlite3 +# binding, server dist, client dist, and the better-auth CLI used by migrate). +COPY --from=builder /app /app +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +EXPOSE 3030 +VOLUME ["/data"] + +HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=5 \ + CMD node -e "fetch('http://127.0.0.1:'+ (process.env.PORT||3030) +'/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/README.md b/README.md index e999d6b..32be602 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,24 @@ pnpm --filter @bool/server db:migrate # run migrations manually The server runs migrations automatically before starting, so a fresh deploy needs no manual DB step. +## Self-hosting with Docker + +bool runs as a single container with an embedded SQLite database — no external +services required. + +```bash +git clone bool && cd bool +docker compose up -d +``` + +Open **http://localhost:3030**. The first visit walks you through creating the +admin account — there is no config file to edit. All data (database + uploads) +persists in the `bool-data` volume. + +To run behind a domain, set `BETTER_AUTH_URL` (and, if serving the API from a +different origin, `BOOL_TRUSTED_ORIGINS`) in `docker-compose.yml`, then +`docker compose up -d --build`. + ## 🗺️ Roadmap Built milestone by milestone, each with its own design spec + plan (see `docs/`). diff --git a/design/network-directory.html b/design/network-directory.html new file mode 100644 index 0000000..d47d629 --- /dev/null +++ b/design/network-directory.html @@ -0,0 +1,114 @@ + + + + + +bool — Network Directory + + + + + + +
+ +
Directory — searchable list, advanced link
+
+
Add a network
+
+ + + + + +
+
+
+ +
Nick capture — after selecting a network
+
+
Join Libera.Chat
+
+
Connecting to irc.libera.chat:6697 over TLS
+ + +
+ + +
+
+
+ +
Empty state — no networks configured yet
+
+
Add a network
+
+
+

No networks yet

+

Pick a network from the directory and choose a nick — bool connects you automatically.

+ +
+
+
+ +
+ + diff --git a/design/onboarding-auth.html b/design/onboarding-auth.html new file mode 100644 index 0000000..04b977b --- /dev/null +++ b/design/onboarding-auth.html @@ -0,0 +1,106 @@ + + + + + + +bool — Onboarding & Auth (Direction A) + + + + + + + +
+ + +
+
+
Welcome. Create the admin account — no config files.
+
+

Create your admin account

+
+
+
+
✓ At least 8 characters• Not your username
+
+ +
+ + +
+
+
Sign in to your server.
+
+
+
Invite code looks incomplete
+
+
+ +
Have an invite? Create an account
+
+ + +
+
+
Pick your vibe — you can change this anytime.
+
+
+
Friendly
Aurora · warm & welcoming
+
Terminal
for the IRC natives
+
Editorial
calm & readable
+
+ +
+ +
+ + diff --git a/design/shots/audit/01-setup-wizard.png b/design/shots/audit/01-setup-wizard.png new file mode 100644 index 0000000..b57da2f Binary files /dev/null and b/design/shots/audit/01-setup-wizard.png differ diff --git a/design/shots/audit/02-first-empty-state.png b/design/shots/audit/02-first-empty-state.png new file mode 100644 index 0000000..fba3c58 Binary files /dev/null and b/design/shots/audit/02-first-empty-state.png differ diff --git a/design/shots/audit/03-cmdk-zero-state.png b/design/shots/audit/03-cmdk-zero-state.png new file mode 100644 index 0000000..0eb3ac3 Binary files /dev/null and b/design/shots/audit/03-cmdk-zero-state.png differ diff --git a/design/shots/audit/04-network-directory.png b/design/shots/audit/04-network-directory.png new file mode 100644 index 0000000..cd60720 Binary files /dev/null and b/design/shots/audit/04-network-directory.png differ diff --git a/design/shots/audit/05-first-message.png b/design/shots/audit/05-first-message.png new file mode 100644 index 0000000..6b8f493 Binary files /dev/null and b/design/shots/audit/05-first-message.png differ diff --git a/design/shots/audit/06-python-live.png b/design/shots/audit/06-python-live.png new file mode 100644 index 0000000..5d14693 Binary files /dev/null and b/design/shots/audit/06-python-live.png differ diff --git a/design/shots/audit/07-unreads-dm.png b/design/shots/audit/07-unreads-dm.png new file mode 100644 index 0000000..9fb94aa Binary files /dev/null and b/design/shots/audit/07-unreads-dm.png differ diff --git a/design/shots/audit/08-mobile-390.png b/design/shots/audit/08-mobile-390.png new file mode 100644 index 0000000..c038775 Binary files /dev/null and b/design/shots/audit/08-mobile-390.png differ diff --git a/design/shots/audit/09-after-restart.png b/design/shots/audit/09-after-restart.png new file mode 100644 index 0000000..eb29bab Binary files /dev/null and b/design/shots/audit/09-after-restart.png differ diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml new file mode 100644 index 0000000..cc8eca8 --- /dev/null +++ b/docker-compose.ci.yml @@ -0,0 +1,19 @@ +# docker-compose.ci.yml — bool + a throwaway IRC server, for the smoke gate. +services: + bool: + build: . + ports: + - "3030:3030" + environment: + BETTER_AUTH_URL: http://localhost:3030 + volumes: + - bool-ci-data:/data + depends_on: + - irc + irc: + image: ghcr.io/ergochat/ergo:stable + ports: + - "6667:6667" + +volumes: + bool-ci-data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..afb435e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +# docker-compose.yml +services: + bool: + build: . + image: bool:latest + ports: + - "3030:3030" + volumes: + - bool-data:/data + environment: + # Public URL users hit (change when deploying behind a domain/proxy) + BETTER_AUTH_URL: http://localhost:3030 + # Optional: pin your own secret instead of the auto-generated one + # BETTER_AUTH_SECRET: change-me-to-a-long-random-string + # Optional: extra allowed origins (same-origin needs none) + # BOOL_TRUSTED_ORIGINS: https://bool.example.com + restart: unless-stopped + +volumes: + bool-data: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..9ec60ef --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# docker/entrypoint.sh +set -euo pipefail + +mkdir -p /data/media + +# Generate and persist an auth secret on first boot so sessions/credentials +# survive restarts without the operator editing any config. +SECRET_FILE=/data/.secret +if [ -z "${BETTER_AUTH_SECRET:-}" ]; then + if [ ! -f "$SECRET_FILE" ]; then + node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" > "$SECRET_FILE" + chmod 600 "$SECRET_FILE" + echo "[bool] generated a new BETTER_AUTH_SECRET at $SECRET_FILE" + fi + export BETTER_AUTH_SECRET="$(cat "$SECRET_FILE")" +fi + +# Default the public URL to the local port if the operator didn't set one. +export BETTER_AUTH_URL="${BETTER_AUTH_URL:-http://localhost:${PORT:-3030}}" + +echo "[bool] starting on ${HOST:-0.0.0.0}:${PORT:-3030} (db: ${BOOL_DB})" +cd /app +exec pnpm --filter @bool/server start diff --git a/docs/superpowers/audits/2026-07-17-cold-start-audit.md b/docs/superpowers/audits/2026-07-17-cold-start-audit.md new file mode 100644 index 0000000..25634d4 --- /dev/null +++ b/docs/superpowers/audits/2026-07-17-cold-start-audit.md @@ -0,0 +1,157 @@ +# Cold-Start Deep Audit — 2026-07-17 + +**Question asked:** "Is it Slack-level user friendliness, or are we iterating on a piece of shit?" + +**Method:** True cold start on the shipping path. Fresh `docker compose up` (clean volume), +fresh browser profile, real signup, real TLS connection to Libera.Chat as `booldan717`, +real channels (`##bool-audit` created, `#python` at 1,432 members), a second scripted IRC +client (`boolprobe717`) to test two-sided messaging, a container restart mid-session, and +a mobile viewport pass. Branch `feat/ux-overhaul` @ 09a1ca8. Evidence: `design/shots/audit/`. + +**Verdict: neither.** The front half of the funnel (setup wizard → network directory → +first message) is genuinely close to Slack-grade, and the chat surface under real traffic +is solid. But the *daily-driver spine* — DMs, reconnect, session persistence, mobile — is +broken in ways that no amount of palette/theme/a11y polish addresses. The deeper problem +is process: multiple features are "done, tests green" but wired to nothing. The tests +verified modules; nobody verified the product. This audit was, as far as the evidence +shows, the first time bool was ever used for a real conversation. + +--- + +## BOUNCE — a newcomer or self-hoster quits here + +### B1. Incoming DMs are conversations with yourself +`boolprobe717` sent a PM → bool created the DM thread keyed to **our own nick** +(`1:booldan717`), sidebar row says your own name, and replying sends `PRIVMSG booldan717` +— **to yourself**. Libera echoes it back into the same thread so it even *looks* delivered. +The other person never receives anything; no error anywhere. Root cause: inbound PMs key +the target by `PRIVMSG` target (the recipient) instead of the sender when target == own +nick. Survived 500+ tests because fixtures always seed DM targets pre-keyed by +counterparty. **DMs with anyone new are fundamentally broken.** (shot 07) + +### B2. The client never reconnects — and the banner lies about it +`docker compose restart` → banner "⟳ Connection lost — reconnecting…" forever. +`ws-client.ts` contains **zero retry logic** (one `new WebSocket`, close fires once, done). +Server was back in ~2s; client stayed dead 60s+ until manual refresh. Every deploy or +blip kills every session. The banner promises retries that do not exist. + +### B3. A server restart erases the user's world (the IRCCloud value prop) +After restart + manual refresh: session survives, but Libera shows disconnected, **CHANNELS +0, no DMs, no history** — back to the zero-state (shot 09). Server does not resume IRC +connections on boot; client never rehydrates joined channels or history (rows exist in +SQLite; nothing loads them). bool's pitch — "your IRC session persists" — is absent as +experienced. IRCCloud exists *because* of this one feature. + +### B4. Mobile layout is unusable +At 390×844 the grid collapses to a vertical stack: rail on top, full sidebar mid-screen, +conversation squeezed into the bottom ~25% (shot 08). A drawer mechanism exists in code; +this is what renders. For "IRC for the masses," mobile is the masses. + +### B5. `docker compose up` was broken (fixed in-audit) +The documented install path failed to build: Dockerfile installs deps for +client/server/shared only, but `COPY . .` brings `packages/desktop` (added in milestone 8) +and `pnpm -r build` dies on the Electron package. The WS-1 ledger warning "DOCKER RUNTIME +UNVERIFIED" was never cashed. Fix applied: `pnpm --filter '!@bool/desktop' -r build`. +Second failure behind it: a real TS error (see B6) — meaning **the branch didn't compile +for production** while its test suite was green. + +### B6. Five slash commands are lies, end-to-end (partially fixed in-audit) +WS-6 added `/notice /away /back /invite /names` to the parser, store, and palette — and: +- **Composer never dispatched them** (missing switch cases; also the TS2366 breaking the + build). Typing `/away brb` cleared… nothing, silently. *Fixed in-audit; 364/364 green.* +- **The server has no `user:away` / `user:invite` / `chan:names` cases** — messages + validate, travel, and vanish. Verified live: `/away grabbing coffee` → input clears + like success, nothing sent to Libera, no away state anywhere. *Still broken.* +- **`chat:send` ignores the `notice` flag** → `/notice` sends a regular PRIVMSG — wrong + semantics, worse than dropping. *Still broken.* +The Cmd-K palette proudly teaches all of these. + +### B7. The zero-state gives instructions that cannot be followed +First paint after signup: "Pick a channel to start chatting — choose from the sidebar" +while the sidebar says **"No network connected"** and is empty (shot 02). The only true +next step is an unlabeled 24px `+` in the rail. ⌘K (which the copy recommends) offers +**40 items — 14 theme switchers, zero "Add network"** (shot 03). The WS-4 directory is +good, but nothing routes a newcomer to it. + +## FRICTION — they grumble and maybe survive + +- **F1. Joining doesn't open the channel.** Both the sidebar join box and `/join #python` + add the channel but leave you staring at the old pane. Slack/IRCCloud switch you. +- **F2. Mentions are not a signal.** The probe's mention produced the same gray badge as + any unread. No distinct mention badge, no tab-title `(2)`, `document.title` never + changes. In Slack the mention is *the* product. +- **F3. Ident noise counts as unreads.** The first badge a newcomer ever sees is "4" on + the rail; clicking finds `*** Checking Ident`. Server-notice targets should not badge. +- **F4. Connecting is invisible.** After the (good) directory flow, the dialog closes + instantly with no connecting/registered feedback anywhere; MOTD is dropped entirely + (only privmsg/notice forwarded). Works silently — or would fail silently. +- **F5. "Join a channel" is a blank prompt.** No popular-channel suggestions, no /list + browser (pending WS-5 — this audit confirms it matters, *after* the spine is fixed). +- **F6. Link previews never observably fire.** CORRECTION (post-audit): `fetchPreview` + IS wired (`irc/manager.ts` calls it on every URL-bearing `chat:msg`) — the initial + "imported by nothing" claim was a faulty grep (missed `src/irc/`). The observable fact + stands: a GitHub URL produced no preview (`previews: {}` client-side), and every failure + path is a silent `.catch(() => {})` — fetch error, `ok:false`, or a `preview:result` + schema mismatch dropped by `parseServerMessage` would all look identical. Needs + observability + a real fix. +- **F7. Busy-channel entry is a void.** Joining #python: one line at the bottom of a black + page, no "IRC has no history before you joined" explainer — reads as broken (shot 06). +- **F8. Topic overlaps the channel title** in the header (`# python` + "Anything Python…" + render on top of each other; shot 06). + +## POLISH + +- `##channel` renders as `# #bool-audit` (double-hash split looks like a typo). +- Permanent `+` reaction chip under every message — vertical noise at IRC message density. +- Palette `aria-activedescendant` is null on open while a row is visually highlighted. +- "Connection: open" header chip is dev-speak (and refers to the WS, not IRC — misleading + next to a disconnected network). +- "NICK" label with no explanation of visibility/collision; asks for EMAIL at setup with + no mail capability behind it. +- Stale first-boot log tells the operator to hand-run SQL for admin — the bootstrap is + actually automatic (WS-2); the log predates it. + +## What is genuinely good (verified, not vibes) + +- **Setup wizard**: one card, zero jargon, friendly copy, instant signup → app (shot 01). +- **Network directory** (WS-4): curated picker with plain-English descriptions; the + "Advanced/custom" split is exactly right (shot 04). +- **The chat surface under real load**: #python at 1,432 members renders smoothly — + grouping, timestamps, nick colors, ops sections; real TLS IRC with echo-message works; + unread badge *propagation* works (channel → section → rail). +- **First message worked**, over real Libera, ~90s from signup when you know the path. + +## Process findings (why the holes exist) + +1. **Reports lied.** WS-6 claimed "typecheck green"; `tsc` failed on the branch. Subagent + reports cannot be trusted without controller re-runs of the actual commands. +2. **Module-green ≠ product-working.** DM keying, link previews, WS-6 server cases, + reconnect: all "tested" at a boundary that never included the real path. The recurring + shape: *client speaks, server never listens; server writes, client never reads — + and failures are swallowed silently at every layer.* +3. **No CI reality checks.** Nothing builds the Docker image; nothing runs a browser + against the real server; e2e specs exist but have never executed. + +## Recommended reprioritization (vs. pending WS-3/WS-5/WS-8) + +The pending roadmap (account menu, channel browser, a11y sweep) polishes the front of a +house whose plumbing is disconnected. Proposed order: + +1. **Fix DM identity keying** (B1) — correctness, data loss, social damage. +2. **WS reconnect with backoff + resume** (B2) and **server-side IRC resume + UI + rehydration on boot/refresh** (B3) — this *is* the product promise. +3. **Mobile drawer layout** (B4). +4. **Finish or remove the five dead commands** (B6) — server cases are small; the notice + flag is one line in `say()`. +5. **Zero-state routing** (B7) + join-opens-channel (F1) + mention distinctness (F2) — + cheap, huge newcomer wins. +6. CI: docker build + one real-stack smoke e2e (signup → connect → join → message → + restart → still there). This audit, automated, is the regression gate. +7. *Then* WS-5 channel browser (F5), WS-3, WS-8. + +## In-audit changes (working tree) + +- `Dockerfile`: exclude `@bool/desktop` from container build (B5). **Committed? pending.** +- `packages/client/src/components/Composer.tsx`: dispatch the five WS-6 command kinds + (B6 client half). Typecheck clean, 364/364 client tests. **Committed? pending.** +- New evidence: `design/shots/audit/01–09*.png`, this report. diff --git a/docs/superpowers/plans/2026-07-12-bool-ux-overhaul-INDEX.md b/docs/superpowers/plans/2026-07-12-bool-ux-overhaul-INDEX.md new file mode 100644 index 0000000..f16319f --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-bool-ux-overhaul-INDEX.md @@ -0,0 +1,72 @@ +# bool UX Overhaul — Program Roadmap (INDEX) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement each workstream plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn bool from "great colors, everything else rough" into a newcomer-friendly IRC client with real onboarding, prod-grade auth, obvious auto-connect network joining, a world-class channel browser, a discoverable account menu, a full-featured Cmd-K, and mock-parity shell polish — verified by an automated visual + a11y + interaction loop. + +**Source spec:** `docs/superpowers/specs/2026-07-12-bool-ux-overhaul-design.md` + +**Architecture:** Nine independent workstreams (WS-0…WS-8). WS-0 (shared primitives + the Ralph verification harness) unblocks the UI workstreams; WS-1 (Docker) runs fully in parallel; WS-8 (a11y sweep) runs last. Each workstream is its own plan file and produces working, testable software on its own. + +**Tech Stack:** pnpm workspaces · Node ≥20 · ESM · React 19 + TypeScript + Vite + Zustand (client) · Fastify + better-sqlite3 + better-auth + irc-framework (server) · Vitest + React Testing Library + jsdom (unit) · Playwright (e2e) · chrome-devtools MCP + `a11y-debugging` skill (Ralph loop). + +--- + +## Global Constraints + +Every task in every workstream plan implicitly includes these. Values are verbatim from the repo/spec. + +- **Runtime/tooling:** Node `>=20`; `pnpm` workspaces; ESM everywhere (`"type": "module"`). **Relative imports must use the `.js` extension** (e.g. `import { X } from './x.js'`) — this is the existing convention. +- **Client commands:** unit tests `pnpm --filter @bool/client test`; single file `pnpm --filter @bool/client test -- src/components/Foo.test.tsx`; typecheck `pnpm --filter @bool/client typecheck`; e2e `pnpm --filter @bool/client test:e2e`; dev server `pnpm --filter @bool/client dev` (Vite, port 5173). +- **Server commands:** unit tests `pnpm --filter @bool/server test`; typecheck `pnpm --filter @bool/server typecheck`; build `pnpm --filter @bool/server build`; start `pnpm --filter @bool/server start` (runs `better-auth migrate` then `node dist/index.js`, port 3030). +- **Test style:** Vitest + `@testing-library/react` + jsdom; setup at `packages/client/src/test-setup.ts`; follow the existing pattern in `packages/client/src/components/Dialog.test.tsx` (use `fireEvent`, `screen.getByRole`, `vi.useFakeTimers()` where timers are involved). +- **Styling:** **No hardcoded colors.** Use CSS custom properties from `packages/client/src/styles/tokens.css` (`--bg-*`, `--ink-*`, `--green`, `--line`, `--radius`, density tokens, etc.). Every component must render correctly across all 14 themes and both densities. +- **A11y baseline (all UI):** keyboard-operable; visible focus never obscured; focus trap + restore in overlays; status conveyed by **icon + text, never color alone**; form errors wired via `aria-describedby`; message list uses `role="log"`; min target size 24px (44px on touch); 4.5:1 contrast; honor `prefers-reduced-motion`; don't block paste in auth fields. +- **Persistence:** SQLite only (better-sqlite3). Extend `packages/server/src/schema.ts` idempotently; no new DB engine. +- **Design language:** Direction A "Terminal-Modern" component structure; net-new screens **author a static mock in `/design` first** (see Ralph loop). +- **Process:** TDD (failing test → minimal impl → green → commit); frequent commits; keep the full suite green (`pnpm -r test`). +- **Branch:** all work on `feat/ux-overhaul` (already created). + +--- + +## The Ralph Loop (shared closing gate for every UI workstream) + +Every UI workstream ends with this. It is not classic unit-test TDD — it is a visual+a11y+interaction parity loop driven by chrome-devtools MCP against a mock target. **Exit only when all three pass.** + +**Prerequisite — a mock target exists.** For screens with an existing mock, use it (`/design/shots/*.png`, `/design/overlay-*.html`). For net-new screens (onboarding, network directory, channel browser, account menu), the workstream's **first task authors a static Direction-A HTML mock** in `/design/` matching the existing mock language (inline CSS using the same token names, dark default + a light toggle). That file is the parity target. + +**Seeding (no IRC stack needed):** the client exposes `window.__bool = { useChat, useAppearance }` (see `packages/client/src/main.tsx:21`). Seed deterministic state before screenshotting, e.g. via `mcp__…__evaluate_script`: +```js +const { useChat, useAppearance } = window.__bool +useAppearance.getState().setTheme('a') +useAppearance.getState().setDensity('compact') +useChat.setState({ /* seeded networks/targets/messages fixture */ }) +``` + +**Procedure (run by the executing agent, using the `chrome-devtools` and `a11y-debugging` skills):** +1. Start the client dev server (`pnpm --filter @bool/client dev`); `new_page` → `navigate_page` to `http://localhost:5173`. +2. **Seed** the target state with `evaluate_script` (fixture appropriate to the screen). +3. **Visual:** `resize_page` to desktop (1440×900) then mobile (390×844); `take_screenshot` at each; compare against the mock target. Reconcile layout/spacing/grouping/state deltas. +4. **A11y:** run the `a11y-debugging` skill checks — keyboard-only traversal, focus order + trap + restore, ARIA roles/names, 4.5:1 contrast, 24px/44px targets, reduced-motion. Optionally `lighthouse_audit` for the a11y category. +5. **Interaction:** drive the real flow (`click`, `fill`, `type_text`, `press_key`) end-to-end and assert the expected state change (via `evaluate_script` reading the store, or visible DOM via `take_snapshot`). +6. Any failure → fix in code → repeat from step 1. **Exit only when visual ≈ mock AND a11y ✓ AND interaction ✓.** Save final screenshots to `/design/shots/impl--{desktop,mobile}.png` as evidence and commit. + +--- + +## Workstreams & execution order + +| Plan | Depends on | Parallelizable with | Mock target | +|---|---|---|---| +| **WS-0 Foundation** `ws0-foundation.md` | — | WS-1 | — (infra) | +| **WS-1 Docker** `ws1-docker.md` | — | everything | — (run the container) | +| **WS-2 First-run + auth** `ws2-auth.md` | WS-0 | WS-1, WS-4, WS-6, WS-7 | author `/design/onboarding-*.html` | +| **WS-3 Account menu / logout** `ws3-account-menu.md` | WS-0, WS-2 | WS-4, WS-5, WS-6, WS-7 | author `/design/account-menu.html` | +| **WS-4 Network directory / join** `ws4-network-directory.md` | WS-0 | WS-2, WS-6, WS-7 | author `/design/network-directory.html` | +| **WS-5 Channel browser** `ws5-channel-browser.md` | WS-0, WS-4 | WS-3, WS-6, WS-7 | author `/design/channel-browser.html` | +| **WS-6 Cmd-K overhaul** `ws6-command-palette.md` | WS-0 | WS-2, WS-4, WS-7 | `/design/overlay-command-palette.html` (exists) | +| **WS-7 Chat + shell polish** `ws7-shell-polish.md` | WS-0 | WS-2, WS-4, WS-6 | `/design/shots/app-a-dark-compact*.png` | +| **WS-8 A11y + responsive sweep** `ws8-a11y-sweep.md` | WS-0…WS-7 | — | all screens | + +**Recommended dispatch:** WS-0 and WS-1 first (parallel). Then fan out WS-2, WS-4, WS-6, WS-7 in parallel; WS-3 after WS-2; WS-5 after WS-4. WS-8 last. + +**Spec-coverage map:** onboarding→WS-1+WS-2 · registration→WS-2 · join/auto-connect→WS-4 · channel browser→WS-5 · account menu/logout→WS-3 · cmd-k+shortcuts+cheatsheet→WS-6 · shell/responsive/empty-error states→WS-7 · a11y (WCAG 2.2)→WS-0 primitives + WS-8 sweep · shared primitives→WS-0. diff --git a/docs/superpowers/plans/2026-07-12-ws0-foundation.md b/docs/superpowers/plans/2026-07-12-ws0-foundation.md new file mode 100644 index 0000000..710aa82 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-ws0-foundation.md @@ -0,0 +1,642 @@ +# WS-0 Foundation — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the shared, accessible UI primitives and the deterministic seed helper that every other UI workstream depends on. + +**Architecture:** Add a `primitives/` folder under `packages/client/src/components/` with small, single-responsibility, token-styled, keyboard-accessible building blocks. Add a `seedDemo` helper to the existing `window.__bool` handle so the Ralph loop can seed state without an IRC stack. Reuse the focus-trap/restore logic already in `Dialog.tsx`. + +**Tech Stack:** React 19 + TypeScript, Zustand store (`chat-store.ts`), Vitest + React Testing Library + jsdom. + +## Global Constraints + +See `2026-07-12-bool-ux-overhaul-INDEX.md` → Global Constraints. Key: `.js` import extensions; no hardcoded colors (use tokens); a11y baseline; tests via `pnpm --filter @bool/client test`; commit per task. + +**Files created by this workstream:** +- `packages/client/src/components/primitives/Field.tsx` + test — labeled input with on-blur inline validation, icon+text error, `aria-describedby`, optional password show/hide. +- `packages/client/src/components/primitives/EmptyState.tsx` + test — status + teach + one CTA. +- `packages/client/src/components/primitives/Kbd.tsx` + test — keyboard-shortcut hint rendering. +- `packages/client/src/components/primitives/LiveLog.tsx` + test — `role="log"` live-region wrapper. +- `packages/client/src/components/primitives/Menu.tsx` + test — accessible dropdown (`role="menu"`, arrow nav, Esc, focus restore). +- `packages/client/src/dev-seed.ts` + test — deterministic fixture + `seedDemo()`, exposed on `window.__bool`. + +**Interfaces produced (consumed by WS-2…WS-8):** +- `Field(props: FieldProps)` — `FieldProps { label: string; value: string; onChange: (v: string) => void; type?: string; validate?: (v: string) => string | null; required?: boolean; autoComplete?: string; placeholder?: string; passwordToggle?: boolean; id?: string }` +- `EmptyState(props: EmptyStateProps)` — `EmptyStateProps { title: string; description?: string; icon?: React.ReactNode; action?: { label: string; onClick: () => void } }` +- `Kbd(props: { keys: string[] })` +- `LiveLog(props: { label: string; children: React.ReactNode })` +- `Menu(props: MenuProps)` — `MenuProps { open: boolean; onClose: () => void; trigger: React.RefObject; label: string; children: React.ReactNode }`; items are ` + )} + + {error && ( + + )} + + ) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @bool/client test -- src/components/primitives/Field.test.tsx` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/client/src/components/primitives/Field.tsx packages/client/src/components/primitives/Field.test.tsx +git commit -m "feat(client): accessible Field primitive with on-blur inline validation" +``` + +--- + +### Task 2: `EmptyState` primitive + +**Files:** +- Create: `packages/client/src/components/primitives/EmptyState.tsx` +- Test: `packages/client/src/components/primitives/EmptyState.test.tsx` + +**Interfaces:** +- Produces: `EmptyState`, `EmptyStateProps` (see header). + +- [ ] **Step 1: Write the failing test** + +```tsx +// packages/client/src/components/primitives/EmptyState.test.tsx +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { EmptyState } from './EmptyState.js' + +describe('EmptyState', () => { + it('renders title and description', () => { + render() + expect(screen.getByRole('heading', { name: 'No networks yet' })).toBeInTheDocument() + expect(screen.getByText('Connect one to start chatting')).toBeInTheDocument() + }) + + it('renders a single primary CTA and calls its handler', () => { + const onClick = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Connect a network' })) + expect(onClick).toHaveBeenCalledOnce() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @bool/client test -- src/components/primitives/EmptyState.test.tsx` +Expected: FAIL — cannot resolve `./EmptyState.js`. + +- [ ] **Step 3: Write minimal implementation** + +```tsx +// packages/client/src/components/primitives/EmptyState.tsx +export interface EmptyStateProps { + title: string + description?: string + icon?: React.ReactNode + action?: { label: string; onClick: () => void } +} + +export function EmptyState({ title, description, icon, action }: EmptyStateProps) { + return ( +
+ {icon &&
{icon}
} +

{title}

+ {description && ( +

+ {description} +

+ )} + {action && ( + + )} +
+ ) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @bool/client test -- src/components/primitives/EmptyState.test.tsx` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/client/src/components/primitives/EmptyState.* +git commit -m "feat(client): EmptyState primitive (status + teach + single CTA)" +``` + +--- + +### Task 3: `Kbd` primitive + +**Files:** +- Create: `packages/client/src/components/primitives/Kbd.tsx` +- Test: `packages/client/src/components/primitives/Kbd.test.tsx` + +**Interfaces:** +- Produces: `Kbd({ keys: string[] })`. + +- [ ] **Step 1: Write the failing test** + +```tsx +// packages/client/src/components/primitives/Kbd.test.tsx +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { Kbd } from './Kbd.js' + +describe('Kbd', () => { + it('renders each key inside a element', () => { + render() + const kbds = screen.getAllByText(/⌘|K/) + expect(kbds.length).toBe(2) + kbds.forEach((el) => expect(el.tagName.toLowerCase()).toBe('kbd')) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @bool/client test -- src/components/primitives/Kbd.test.tsx` +Expected: FAIL — cannot resolve `./Kbd.js`. + +- [ ] **Step 3: Write minimal implementation** + +```tsx +// packages/client/src/components/primitives/Kbd.tsx +export function Kbd({ keys }: { keys: string[] }) { + return ( + + {keys.map((k, i) => ( + + {k} + + ))} + + ) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @bool/client test -- src/components/primitives/Kbd.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/client/src/components/primitives/Kbd.* +git commit -m "feat(client): Kbd shortcut-hint primitive" +``` + +--- + +### Task 4: `LiveLog` primitive (`role="log"` live region) + +**Files:** +- Create: `packages/client/src/components/primitives/LiveLog.tsx` +- Test: `packages/client/src/components/primitives/LiveLog.test.tsx` + +**Interfaces:** +- Produces: `LiveLog({ label: string; children: React.ReactNode })`. + +- [ ] **Step 1: Write the failing test** + +```tsx +// packages/client/src/components/primitives/LiveLog.test.tsx +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { LiveLog } from './LiveLog.js' + +describe('LiveLog', () => { + it('renders a role=log region with an accessible name', () => { + render(
hi
) + const log = screen.getByRole('log', { name: 'Messages' }) + expect(log).toBeInTheDocument() + // role=log implies aria-live=polite; we set it explicitly for older AT + expect(log).toHaveAttribute('aria-live', 'polite') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @bool/client test -- src/components/primitives/LiveLog.test.tsx` +Expected: FAIL — cannot resolve `./LiveLog.js`. + +- [ ] **Step 3: Write minimal implementation** + +```tsx +// packages/client/src/components/primitives/LiveLog.tsx +export function LiveLog({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @bool/client test -- src/components/primitives/LiveLog.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/client/src/components/primitives/LiveLog.* +git commit -m "feat(client): LiveLog role=log live-region primitive" +``` + +--- + +### Task 5: `Menu` primitive (accessible dropdown) + +**Files:** +- Create: `packages/client/src/components/primitives/Menu.tsx` +- Test: `packages/client/src/components/primitives/Menu.test.tsx` + +**Interfaces:** +- Consumes: focus-restore idea from `Dialog.tsx`. +- Produces: `Menu`, `MenuProps` (see header). Menu items are plain ` + + + + + ) +} + +describe('Menu', () => { + it('renders a role=menu with an accessible name and its items', () => { + render( {}} />) + const menu = screen.getByRole('menu', { name: 'Account' }) + expect(menu).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: 'Sign out' })).toBeInTheDocument() + }) + + it('closes on Escape', () => { + const onClose = vi.fn() + render() + fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' }) + expect(onClose).toHaveBeenCalledOnce() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @bool/client test -- src/components/primitives/Menu.test.tsx` +Expected: FAIL — cannot resolve `./Menu.js`. + +- [ ] **Step 3: Write minimal implementation** + +```tsx +// packages/client/src/components/primitives/Menu.tsx +import { useEffect, useRef, useCallback } from 'react' + +export interface MenuProps { + open: boolean + onClose: () => void + trigger: React.RefObject + label: string + children: React.ReactNode +} + +export function Menu({ open, onClose, trigger, label, children }: MenuProps) { + const menuRef = useRef(null) + + // Focus first item on open; restore focus to trigger on close. + useEffect(() => { + if (!open) return + const items = menuRef.current?.querySelectorAll('[role="menuitem"]') + items?.[0]?.focus() + return () => trigger.current?.focus() + }, [open, trigger]) + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const items = Array.from( + menuRef.current?.querySelectorAll('[role="menuitem"]') ?? [], + ) + const idx = items.indexOf(document.activeElement as HTMLElement) + if (e.key === 'Escape') { e.preventDefault(); onClose() } + else if (e.key === 'ArrowDown') { e.preventDefault(); items[(idx + 1) % items.length]?.focus() } + else if (e.key === 'ArrowUp') { e.preventDefault(); items[(idx - 1 + items.length) % items.length]?.focus() } + }, + [onClose], + ) + + if (!open) return null + return ( + <> +
+
+ {children} +
+ + ) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @bool/client test -- src/components/primitives/Menu.test.tsx` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/client/src/components/primitives/Menu.* +git commit -m "feat(client): accessible Menu dropdown primitive (role=menu, arrow nav, Esc, focus restore)" +``` + +--- + +### Task 6: Deterministic seed helper for the Ralph loop + +**Files:** +- Create: `packages/client/src/dev-seed.ts` +- Test: `packages/client/src/dev-seed.test.ts` +- Modify: `packages/client/src/main.tsx:21` (add `seedDemo` to the `window.__bool` handle) + +**Interfaces:** +- Consumes: `useChat` from `store/chat-store.ts`, `useAppearance` from `theme.ts`. +- Produces: `DEMO_FIXTURE` (typed to the store shape) and `seedDemo(): void`. After `seedDemo()`, `useChat.getState().networks` and `.targets` are non-empty and one target is `selected`. + +- [ ] **Step 1: Confirm the store shape** + +Run: `sed -n '1,80p' packages/client/src/store/types.ts` +Expected: shows `NetworkState`, `TargetState`, and the store's `networks`/`targets`/`selected` fields. Use these exact field names in the fixture below (adjust the fixture to match the real types if they differ). + +- [ ] **Step 2: Write the failing test** + +```ts +// packages/client/src/dev-seed.test.ts +import { describe, it, expect } from 'vitest' +import { seedDemo, DEMO_FIXTURE } from './dev-seed.js' +import { useChat } from './store/chat-store.js' + +describe('seedDemo', () => { + it('populates the chat store with at least one network, target, and a selection', () => { + seedDemo() + const s = useChat.getState() + expect(Object.keys(s.networks).length).toBeGreaterThan(0) + expect(Object.keys(s.targets).length).toBeGreaterThan(0) + expect(s.selected).not.toBeNull() + }) + + it('DEMO_FIXTURE is deterministic (no random/date usage)', () => { + const a = JSON.stringify(DEMO_FIXTURE) + const b = JSON.stringify(DEMO_FIXTURE) + expect(a).toBe(b) + }) +}) +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `pnpm --filter @bool/client test -- src/dev-seed.test.ts` +Expected: FAIL — cannot resolve `./dev-seed.js`. + +- [ ] **Step 4: Write minimal implementation** + +Using the field names confirmed in Step 1, create a fixture with one network (`Libera.Chat`), two channels and one DM as targets, and a couple of seeded messages, then commit it to the store. Example shape (adapt keys to the real `NetworkState`/`TargetState`): + +```ts +// packages/client/src/dev-seed.ts +import { useChat, targetKey } from './store/chat-store.js' + +export const DEMO_FIXTURE = { + networks: { + 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', connected: true }, + }, + targets: { + '1:#bool': { networkId: 1, target: '#bool', kind: 'channel', unread: 0, names: ['@ada', '+lin', 'kai'] }, + '1:#general': { networkId: 1, target: '#general', kind: 'channel', unread: 3, names: ['ada', 'kai'] }, + '1:ada': { networkId: 1, target: 'ada', kind: 'pm', unread: 0, names: [] }, + }, + selected: '1:#bool', +} as const + +export function seedDemo(): void { + useChat.setState({ + networks: structuredClone(DEMO_FIXTURE.networks) as never, + targets: structuredClone(DEMO_FIXTURE.targets) as never, + selected: DEMO_FIXTURE.selected as never, + }) + void targetKey // keep import if store requires key normalization; remove if unused +} +``` + +> If the real store types require more fields (e.g. `messages`, `topic`), extend the fixture to satisfy `NetworkState`/`TargetState` exactly so `pnpm --filter @bool/client typecheck` stays green. + +- [ ] **Step 5: Run test to verify it passes** + +Run: `pnpm --filter @bool/client test -- src/dev-seed.test.ts` +Expected: PASS (2 tests). + +- [ ] **Step 6: Expose on `window.__bool`** + +Edit `packages/client/src/main.tsx` line 21 area: + +```ts +import { seedDemo } from './dev-seed.js' +// ... +;(window as unknown as { __bool?: unknown }).__bool = { useChat, useAppearance, seedDemo } +``` + +- [ ] **Step 7: Typecheck + full client suite** + +Run: `pnpm --filter @bool/client typecheck && pnpm --filter @bool/client test` +Expected: PASS (all green, including existing tests). + +- [ ] **Step 8: Commit** + +```bash +git add packages/client/src/dev-seed.ts packages/client/src/dev-seed.test.ts packages/client/src/main.tsx +git commit -m "feat(client): deterministic seedDemo helper on window.__bool for the Ralph loop" +``` + +--- + +## Self-Review + +- **Spec coverage:** WS-0 delivers the shared primitives (Field, EmptyState, Kbd, LiveLog, Menu) and the seed harness named in spec §4 WS-0 and §6. ✓ +- **Placeholders:** none — every step has real code and exact commands. The one conditional (fixture field names) is gated behind an explicit Step-1 confirmation. ✓ +- **Type consistency:** `FieldProps`, `EmptyStateProps`, `MenuProps`, `seedDemo`, `DEMO_FIXTURE` names match the INDEX interface table and are reused by later workstreams. ✓ diff --git a/docs/superpowers/plans/2026-07-12-ws1-docker.md b/docs/superpowers/plans/2026-07-12-ws1-docker.md new file mode 100644 index 0000000..fb74018 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-ws1-docker.md @@ -0,0 +1,287 @@ +# WS-1 Docker Onboarding — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `docker compose up` brings up a fully working bool instance in minutes — single container, embedded SQLite, one persistent volume, zero external services, no config-file editing. + +**Architecture:** Multi-stage Dockerfile: a `bookworm` builder compiles all pnpm workspace packages (client → static assets, server → dist, native `better-sqlite3`), then a `bookworm-slim` runtime runs the Fastify server, which already serves the built client statically. An entrypoint auto-generates and persists `BETTER_AUTH_SECRET` on first boot, runs migrations, and starts the server. All mutable state (SQLite DB + uploaded media) lives under a single `/data` volume. + +**Tech Stack:** Docker multi-stage build · node:20-bookworm(-slim) · pnpm · Fastify + better-sqlite3 + better-auth. + +## Global Constraints + +See `2026-07-12-bool-ux-overhaul-INDEX.md` → Global Constraints. Relevant: SQLite only; server serves the client (one port, 3030); start path already runs `better-auth migrate`. This workstream has no UI, so its acceptance is "the container boots and serves a working app," not the Ralph loop. + +**Files created:** +- `.dockerignore` +- `Dockerfile` +- `docker/entrypoint.sh` +- `docker-compose.yml` +- README quickstart section (append to root `README.md`, or create it if absent) + +**Verified backend facts this relies on:** +- Server entry `packages/server/dist/index.js`; listens on `PORT` (default 3030), `HOST` (default `0.0.0.0`). +- `pnpm --filter @bool/server start` = `better-auth migrate --config ./src/auth.ts --yes && node dist/index.js`. +- SQLite path from `BOOL_DB` (default `./bool.db`); media from `BOOL_MEDIA_DIR` (default `./media`). +- Required env: `BETTER_AUTH_SECRET`. Others: `BETTER_AUTH_URL`, `BOOL_TRUSTED_ORIGINS`, `PORT`, `HOST`. +- Client build output `packages/client/dist` is served by `@fastify/static`. + +--- + +### Task 1: `.dockerignore` and multi-stage `Dockerfile` + +**Files:** +- Create: `.dockerignore` +- Create: `Dockerfile` + +- [ ] **Step 1: Create `.dockerignore`** + +``` +# .dockerignore +**/node_modules +**/dist +**/.turbo +**/coverage +**/*.log +.git +.github +docs +design +*.db +*.db-* +media +.env +.env.* +``` + +- [ ] **Step 2: Create the `Dockerfile`** + +```dockerfile +# syntax=docker/dockerfile:1 + +# ---- Builder: compile all workspace packages (needs toolchain for better-sqlite3) ---- +FROM node:20-bookworm AS builder +WORKDIR /app +RUN npm install -g pnpm@9 + +# Install deps with the full workspace manifest set for better layer caching +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./ +COPY packages/client/package.json packages/client/ +COPY packages/server/package.json packages/server/ +COPY packages/shared/package.json packages/shared/ +RUN pnpm install --frozen-lockfile || pnpm install + +# Copy sources and build everything (shared → server dist, client → static assets) +COPY . . +RUN pnpm -r build + +# ---- Runtime: slim image, run the Fastify server ---- +FROM node:20-bookworm-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production \ + HOST=0.0.0.0 \ + PORT=3030 \ + BOOL_DB=/data/bool.db \ + BOOL_MEDIA_DIR=/data/media + +RUN npm install -g pnpm@9 \ + && mkdir -p /data/media + +# Bring the built workspace (includes node_modules with the compiled better-sqlite3 +# binding, server dist, client dist, and the better-auth CLI used by migrate). +COPY --from=builder /app /app +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +EXPOSE 3030 +VOLUME ["/data"] + +HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=5 \ + CMD node -e "fetch('http://127.0.0.1:'+ (process.env.PORT||3030) +'/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +``` + +- [ ] **Step 3: Commit** + +```bash +git add .dockerignore Dockerfile +git commit -m "build(docker): multi-stage Dockerfile (bookworm builder → slim runtime)" +``` + +--- + +### Task 2: Entrypoint (persist secret, migrate, start) + +**Files:** +- Create: `docker/entrypoint.sh` + +**Interfaces:** +- Produces: a boot sequence that guarantees `BETTER_AUTH_SECRET` is set (generating+persisting to `/data/.secret` on first run), `BETTER_AUTH_URL` has a default, then execs the server start. + +- [ ] **Step 1: Create the entrypoint** + +```bash +#!/usr/bin/env bash +# docker/entrypoint.sh +set -euo pipefail + +mkdir -p /data/media + +# Generate and persist an auth secret on first boot so sessions/credentials +# survive restarts without the operator editing any config. +SECRET_FILE=/data/.secret +if [ -z "${BETTER_AUTH_SECRET:-}" ]; then + if [ ! -f "$SECRET_FILE" ]; then + node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" > "$SECRET_FILE" + chmod 600 "$SECRET_FILE" + echo "[bool] generated a new BETTER_AUTH_SECRET at $SECRET_FILE" + fi + export BETTER_AUTH_SECRET="$(cat "$SECRET_FILE")" +fi + +# Default the public URL to the local port if the operator didn't set one. +export BETTER_AUTH_URL="${BETTER_AUTH_URL:-http://localhost:${PORT:-3030}}" + +echo "[bool] starting on ${HOST:-0.0.0.0}:${PORT:-3030} (db: ${BOOL_DB})" +cd /app +exec pnpm --filter @bool/server start +``` + +- [ ] **Step 2: Make it executable and commit** + +```bash +chmod +x docker/entrypoint.sh +git add docker/entrypoint.sh +git commit -m "build(docker): entrypoint auto-generates+persists auth secret, migrates, starts" +``` + +--- + +### Task 3: `docker-compose.yml` (single service + one volume) + +**Files:** +- Create: `docker-compose.yml` + +- [ ] **Step 1: Create the compose file** + +```yaml +# docker-compose.yml +services: + bool: + build: . + image: bool:latest + ports: + - "3030:3030" + volumes: + - bool-data:/data + environment: + # Public URL users hit (change when deploying behind a domain/proxy) + BETTER_AUTH_URL: http://localhost:3030 + # Optional: pin your own secret instead of the auto-generated one + # BETTER_AUTH_SECRET: change-me-to-a-long-random-string + # Optional: extra allowed origins (same-origin needs none) + # BOOL_TRUSTED_ORIGINS: https://bool.example.com + restart: unless-stopped + +volumes: + bool-data: +``` + +- [ ] **Step 2: Commit** + +```bash +git add docker-compose.yml +git commit -m "build(docker): single-service compose with one persistent data volume" +``` + +--- + +### Task 4: README quickstart + +**Files:** +- Modify or create: `README.md` (append a "Self-hosting with Docker" section) + +- [ ] **Step 1: Add the quickstart section** + +```markdown +## Self-hosting with Docker + +bool runs as a single container with an embedded SQLite database — no external +services required. + +```bash +git clone bool && cd bool +docker compose up -d +``` + +Open **http://localhost:3030**. The first visit walks you through creating the +admin account — there is no config file to edit. All data (database + uploads) +persists in the `bool-data` volume. + +To run behind a domain, set `BETTER_AUTH_URL` (and, if serving the API from a +different origin, `BOOL_TRUSTED_ORIGINS`) in `docker-compose.yml`, then +`docker compose up -d --build`. +``` +``` + +> Note: the fenced block above contains a nested code fence — when writing the file, ensure the outer section is added as normal Markdown (the inner ```bash block is literal content of the README). + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: Docker quickstart (docker compose up → :3030)" +``` + +--- + +### Task 5: Build & boot verification (acceptance) + +**Files:** none (verification only). + +- [ ] **Step 1: Build the image** + +Run: `docker compose build` +Expected: build completes; both stages succeed; no `better-sqlite3` compile error. + +- [ ] **Step 2: Boot on a clean volume** + +Run: `docker compose up -d && sleep 25 && docker compose ps` +Expected: the `bool` service is `running` and `healthy`. + +- [ ] **Step 3: Verify it serves the app and reaches first-run** + +Run: `curl -fsS http://localhost:3030/ | head -c 200` +Expected: HTML for the client shell is returned (non-empty, `` or similar). + +Run: `curl -fsS http://localhost:3030/api/me` (or the health/session endpoint) +Expected: a JSON response (unauthenticated session is fine — proves the API is live). + +- [ ] **Step 4: Verify persistence across restart** + +Run: `docker compose restart bool && sleep 20 && docker compose exec bool ls -la /data` +Expected: `/data/bool.db`, `/data/.secret`, and `/data/media` exist and persist. + +- [ ] **Step 5: Tear down** + +Run: `docker compose down` +Expected: container stops; the `bool-data` volume remains (data preserved). + +- [ ] **Step 6: Commit any fixes** + +If Steps 1–4 required Dockerfile/entrypoint tweaks, commit them: + +```bash +git add Dockerfile docker/entrypoint.sh docker-compose.yml +git commit -m "build(docker): fixes from boot verification" +``` + +--- + +## Self-Review + +- **Spec coverage:** delivers spec §4 WS-1 (single container, SQLite, one volume, auto secret, healthcheck, README quickstart, `docker compose up`). ✓ +- **Placeholders:** none — real Dockerfile, entrypoint, compose, README, and concrete verification commands with expected output. ✓ +- **Consistency:** env var names (`BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `BOOL_DB`, `BOOL_MEDIA_DIR`, `PORT`, `HOST`, `BOOL_TRUSTED_ORIGINS`) match the server's actual reads per the spec §2 backend facts. ✓ +- **Risk noted:** `better-sqlite3` is a native module — it is compiled in the `bookworm` builder and run on `bookworm-slim` (matching glibc). If a binding mismatch appears at boot, the fix is to run `pnpm rebuild better-sqlite3` in the runtime stage; add that only if Step 2 fails. diff --git a/docs/superpowers/plans/2026-07-12-ws2-auth.md b/docs/superpowers/plans/2026-07-12-ws2-auth.md new file mode 100644 index 0000000..c7d36ae --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-ws2-auth.md @@ -0,0 +1,1249 @@ +# WS-2 First-run Setup + Auth Screens — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deliver prod-grade first contact for bool: a `/setup` first-run wizard that turns the very first user into the admin (no config-file editing), a redesigned branded sign-in/register **card** with real labels + on-blur inline validation + icon+text errors + show/hide password + visible rules + paste allowed, **invite-code** gated registration after the first admin, and a "pick your vibe" onboarding theme step with live preview persisted via `useAppearance.setTheme`. + +**Architecture:** Server side adds a small first-run/invite layer that lives alongside Better Auth (which owns the `user`/`session`/`account` tables). A new idempotent `invites` table in `schema.ts` plus an `invites.ts` store (mirroring `networks.ts`) supply issue/validate/consume logic. New unauthenticated route `GET /api/setup-status` reports `{ needsSetup: boolean }` by counting rows in Better Auth's `user` table; `POST /api/invites` (admin-only) issues codes; `GET /api/invites/:code` validates one. The client's `AuthGate` fetches setup-status first: zero users → render `SetupWizard` (which drives Better Auth `signUp.email` for the admin, then the pick-your-vibe step); otherwise render the redesigned `LoginForm` card whose register mode requires an invite code. All new UI reuses the WS-0 `Field` primitive and token classes — no hardcoded colors. + +**Tech Stack:** React 19 + TypeScript, Zustand appearance store (`theme.ts`), Better Auth (`better-auth/react` client, `username`+`admin` plugins), Fastify + better-sqlite3 (server), Vitest + React Testing Library + jsdom (client), Vitest + `app.inject` (server). + +## Global Constraints + +See `2026-07-12-bool-ux-overhaul-INDEX.md` → Global Constraints. Key for this workstream: `.js` import extensions everywhere; **no hardcoded colors** — use tokens from `packages/client/src/styles/tokens.css`; a11y baseline (keyboard-operable, focus never obscured, icon+text status never color-only, errors wired via `aria-describedby`, 24px min targets, 4.5:1 contrast, `prefers-reduced-motion`, **never block paste in auth fields — WCAG 2.2 §3.3.8**); SQLite only, extend `schema.ts` idempotently; client tests `pnpm --filter @bool/client test`, server tests `pnpm --filter @bool/server test`; TDD + commit per task; all work on `feat/ux-overhaul`. + +**Dependency:** WS-0 must be merged first — this workstream **consumes** `packages/client/src/components/primitives/Field.tsx`. + +--- + +## Files created / modified + +**Created:** +- `design/onboarding-auth.html` — static Direction-A mock (Ralph parity target): setup wizard, sign-in/register card, pick-your-vibe step; dark default + a light toggle. +- `packages/server/src/invites.ts` + `packages/server/src/invites.test.ts` — `invites` store (issue / validate / consume), idempotent schema extension. +- `packages/server/src/setup-routes.ts` + `packages/server/src/setup-routes.test.ts` — `GET /api/setup-status`, `POST /api/invites`, `GET /api/invites/:code`. +- `packages/client/src/setup-client.ts` + `packages/client/src/setup-client.test.ts` — typed fetch wrappers (`fetchSetupStatus`, `validateInvite`, `createInvite`). +- `packages/client/src/OnboardingThemeStep.tsx` + `packages/client/src/OnboardingThemeStep.test.tsx` — pick-your-vibe step. +- `packages/client/src/SetupWizard.tsx` + `packages/client/src/SetupWizard.test.tsx` — first-run admin wizard. + +**Modified:** +- `packages/server/src/schema.ts` — add `invites` table + index (idempotent). +- `packages/client/src/LoginForm.tsx` — rewrite as a branded card using `Field`, invite-code register path. +- `packages/client/src/AuthGate.tsx` — fetch setup-status; branch to `SetupWizard` vs `LoginForm`. + +## Interfaces produced / consumed + +**Consumed (from WS-0):** +- `Field` from `./components/primitives/Field.js` — `FieldProps { label; value; onChange; type?; validate?; required?; autoComplete?; placeholder?; passwordToggle?; id? }`. Used by `LoginForm`, `SetupWizard` for every text/password input (real `
`: + +```tsx +
+
Notifications
+ +
+ + ) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @bool/client test -- src/components/AppearanceMenu.test.tsx` +Expected: PASS (2 tests). + +- [ ] **Step 5: Typecheck (ensure no unused-import error)** + +Run: `pnpm --filter @bool/client typecheck` +Expected: PASS (no `signOut` unused/undefined errors). + +- [ ] **Step 6: Commit** + +```bash +git add packages/client/src/components/AppearanceMenu.tsx packages/client/src/components/AppearanceMenu.test.tsx +git commit -m "refactor(client): remove buried sign-out from AppearanceMenu (moves to account menu)" +``` + +--- + +### Task 3: Build `AccountMenu` (trigger + WS-0 `Menu` dropdown) + +**Files:** +- Create: `packages/client/src/components/AccountMenu.tsx` +- Create: `packages/client/src/components/AccountMenu.test.tsx` + +**Interfaces:** +- Consumes: `Menu` (WS-0), `useSession`/`signOut` (`auth-client.js`), `AppearanceMenu`, `NotificationsToggle`, `Dialog`. +- Produces: `AccountMenu`, `AccountMenuProps`. + +- [ ] **Step 1: Write the failing test** + +Mock `useSession` and `signOut` from `../auth-client.js` so the test runs without a real Better Auth session (mirrors the mocking style needed for session-backed components). Assert: the trigger shows the nick; opening it yields a `role="menu"` named "Account" with the four menu items (Invites shown for admin, hidden for non-admin); Esc closes it; clicking **Sign out** calls `signOut`. + +```tsx +// packages/client/src/components/AccountMenu.test.tsx +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' + +// --- mock the auth client (no real Better Auth session in jsdom) --- +const signOut = vi.fn() +let sessionData: unknown = { + user: { id: 'u1', username: 'ada', name: 'Ada', role: 'admin' }, +} +vi.mock('../auth-client.js', () => ({ + useSession: () => ({ data: sessionData, isPending: false }), + signOut: (...args: unknown[]) => signOut(...args), +})) + +import { AccountMenu } from './AccountMenu.js' + +beforeEach(() => { + signOut.mockReset() + sessionData = { user: { id: 'u1', username: 'ada', name: 'Ada', role: 'admin' } } +}) + +describe('AccountMenu', () => { + it('renders a trigger button showing the current nick', () => { + render() + expect(screen.getByRole('button', { name: /account: ada/i })).toBeInTheDocument() + }) + + it('opens an accessible role=menu with the four items (admin sees Invites)', () => { + render() + fireEvent.click(screen.getByRole('button', { name: /account: ada/i })) + const menu = screen.getByRole('menu', { name: 'Account' }) + expect(menu).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: /appearance/i })).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: /notifications/i })).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: /invites/i })).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: /sign out/i })).toBeInTheDocument() + }) + + it('hides the Invites item for non-admin users', () => { + sessionData = { user: { id: 'u2', username: 'kai', name: 'Kai', role: 'user' } } + render() + fireEvent.click(screen.getByRole('button', { name: /account: kai/i })) + expect(screen.queryByRole('menuitem', { name: /invites/i })).not.toBeInTheDocument() + }) + + it('calls signOut when Sign out is activated', () => { + render() + fireEvent.click(screen.getByRole('button', { name: /account: ada/i })) + fireEvent.click(screen.getByRole('menuitem', { name: /sign out/i })) + expect(signOut).toHaveBeenCalledOnce() + }) + + it('closes the menu on Escape', () => { + render() + fireEvent.click(screen.getByRole('button', { name: /account: ada/i })) + fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' }) + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @bool/client test -- src/components/AccountMenu.test.tsx` +Expected: FAIL — cannot resolve `./AccountMenu.js`. + +- [ ] **Step 3: Write the implementation** + +```tsx +// packages/client/src/components/AccountMenu.tsx +import { useRef, useState } from 'react' +import { useSession, signOut } from '../auth-client.js' +import { Menu } from './primitives/Menu.js' +import { Dialog } from './Dialog.js' +import { AppearanceMenu } from './AppearanceMenu.js' +import { NotificationsToggle } from './NotificationsToggle.js' + +export interface AccountMenuProps { + onOpenNetworkSettings?: () => void +} + +type Panel = 'appearance' | 'notifications' | 'invites' | null + +export function AccountMenu(_props: AccountMenuProps = {}) { + const { data: session } = useSession() + const triggerRef = useRef(null) + const [open, setOpen] = useState(false) + const [panel, setPanel] = useState(null) + + const user = session?.user as + | { username?: string | null; name?: string | null; role?: string | null } + | undefined + // Not signed in → the AuthGate is showing LoginForm; nothing to render. + if (!user) return null + + const nick = user.username ?? user.name ?? 'account' + const isAdmin = user.role === 'admin' + const initial = nick.charAt(0).toUpperCase() + + function close() { + setOpen(false) + } + + return ( +
+ + + +
+
{nick}
+
+ online{isAdmin ? ' · admin' : ''} +
+
+ + + + {isAdmin && ( + + )} +
+ + setPanel(null)} title="Appearance"> + + + setPanel(null)} title="Notifications"> +
+ +
+
+ setPanel(null)} title="Invites"> +
+ Invite codes are managed here (admin only). Wired to the invite flow in WS-2. +
+
+
+ ) +} + +const ITEM: React.CSSProperties = { + display: 'flex', alignItems: 'center', gap: 10, width: '100%', textAlign: 'left', + minHeight: 36, padding: '8px 10px', background: 'none', border: 'none', cursor: 'pointer', + color: 'var(--ink-1)', fontFamily: 'var(--mono)', fontSize: 13, borderRadius: 'var(--radius-sm)', +} +``` + +> Note: `min-height` on the trigger is 44px (touch target) and each menu item is 36px (> the 24px WCAG 2.2 minimum). Sign out uses `--red` **plus** the "Sign out" text, so status is never color-only. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @bool/client test -- src/components/AccountMenu.test.tsx` +Expected: PASS (5 tests). + +- [ ] **Step 5: Typecheck** + +Run: `pnpm --filter @bool/client typecheck` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add packages/client/src/components/AccountMenu.tsx packages/client/src/components/AccountMenu.test.tsx +git commit -m "feat(client): AccountMenu — rail-footer account button + accessible dropdown (appearance/notifications/invites/sign out)" +``` + +--- + +### Task 4: Mount `AccountMenu` in the rail footer + +**Files:** +- Modify: `packages/client/src/components/Sidebar.tsx` + +**Interfaces:** +- Consumes: `AccountMenu` from `./AccountMenu.js`. + +- [ ] **Step 1: Write the failing test** + +Add a test that renders `Sidebar` and asserts the account button is present in the rail. Mock `useSession`/`signOut` the same way as Task 3 (Sidebar transitively renders `AccountMenu`, which reads the session). Seed the chat store so the rail renders. + +```tsx +// packages/client/src/components/Sidebar.test.tsx (create if absent; else append the describe block) +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' + +vi.mock('../auth-client.js', () => ({ + useSession: () => ({ data: { user: { id: 'u1', username: 'ada', role: 'admin' } }, isPending: false }), + signOut: vi.fn(), +})) + +import { Sidebar } from './Sidebar.js' +import { useChat } from '../store/chat-store.js' + +beforeEach(() => { + useChat.setState({ + networks: { 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', connected: true } } as never, + targets: { '1:#bool': { networkId: 1, target: '#bool', kind: 'channel', unread: 0, names: [] } } as never, + selected: '1:#bool' as never, + }) +}) + +describe('Sidebar rail footer', () => { + it('renders the account button in the rail', () => { + render() + expect(screen.getByRole('button', { name: /account: ada/i })).toBeInTheDocument() + }) +}) +``` + +> If `Sidebar.test.tsx` already exists, add only the `vi.mock` + this `describe` block, and keep existing tests green. Adjust the seeded store keys to match the real `NetworkState`/`TargetState` (confirm against `packages/client/src/store/types.ts` — the same shape WS-0's `dev-seed.ts` uses). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @bool/client test -- src/components/Sidebar.test.tsx` +Expected: FAIL — no `Account: ada` button (Sidebar doesn't render `AccountMenu` yet). + +- [ ] **Step 3: Add the import and render `AccountMenu` in `.rail-foot`** + +In `packages/client/src/components/Sidebar.tsx`, add the import near the top: + +```tsx +import { AccountMenu } from './AccountMenu.js' +``` + +Then, inside the ``, add the footer: + +```tsx + {/* Add network button — always visible, opens Network Settings */} + + + {/* Account button pinned to the rail footer */} +
+ +
+ +``` + +(`.rail-foot { margin-top: auto }` already exists in `styles/app.css`, so this pins to the bottom.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @bool/client test -- src/components/Sidebar.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Full client suite + typecheck (nothing regressed)** + +Run: `pnpm --filter @bool/client typecheck && pnpm --filter @bool/client test` +Expected: PASS (all green — AppearanceMenu, AccountMenu, Sidebar, and existing tests). + +- [ ] **Step 6: Commit** + +```bash +git add packages/client/src/components/Sidebar.tsx packages/client/src/components/Sidebar.test.tsx +git commit -m "feat(client): mount AccountMenu in the network-rail footer" +``` + +--- + +### Task 5: Ralph loop — visual + a11y + interaction parity gate + +**Files:** none (verification only). Save evidence screenshots under `design/shots/`. + +Follow the **Ralph Loop** procedure in `2026-07-12-bool-ux-overhaul-INDEX.md` verbatim. Mock target: `design/account-menu.html` (Task 1). Use the `chrome-devtools` and `a11y-debugging` skills. **Exit only when visual ≈ mock AND a11y ✓ AND interaction ✓.** + +- [ ] **Step 1: Start the dev server and open the app** + +Run: `pnpm --filter @bool/client dev` (Vite, port 5173). +Then via chrome-devtools MCP: `new_page` → `navigate_page` to `http://localhost:5173`. If the app shows the login screen, sign in as the seeded admin (created by WS-2's `/setup`) so `useSession()` has `session.user` — the account button only renders when signed in. + +- [ ] **Step 2: Seed deterministic state** + +With `evaluate_script`, seed via the WS-0 helper so the rail renders: + +```js +window.__bool.seedDemo() +window.__bool.useAppearance.getState().setTheme('a') +window.__bool.useAppearance.getState().setDensity('compact') +``` + +- [ ] **Step 3: Visual parity (desktop + mobile)** + +- `resize_page` to **1440×900**, `take_screenshot` → compare against `design/account-menu.html`: account button pinned to the rail footer showing nick + presence dot. +- `click` the account button, `take_screenshot` → dropdown shows Appearance, Notifications, Invites… (admin), separator, red Sign out — order and grouping matching the mock. +- `resize_page` to **390×844** (mobile), repeat: the rail + account button + open menu remain reachable and legible (desktop+mobile parity). +- Reconcile any spacing/token/state deltas in `AccountMenu.tsx` until it matches. + +- [ ] **Step 4: A11y audit (run the `a11y-debugging` skill)** + +- **Keyboard reach:** Tab to the account trigger, activate with Enter/Space → menu opens and **focus lands on the first `menuitem`**. +- **Menu semantics:** confirm `role="menu"` named "Account" and each item is `role="menuitem"` (via `take_snapshot`). +- **Arrow nav:** ArrowDown/ArrowUp move between items and wrap. +- **Esc closes** the menu **and focus returns to the trigger** (assert `document.activeElement` via `evaluate_script`). +- **Target size:** trigger ≥ 44px min-height; each menuitem ≥ 24px (measure with `evaluate_script` `getBoundingClientRect()`). +- **Contrast:** 4.5:1 for nick, item labels, and the red "Sign out" across theme `a` and `a-light`. +- **Color-not-alone:** the presence dot has an accessible name ("online" in the trigger's `aria-label`); Sign out has text, not color only. +- Optionally `lighthouse_audit` (accessibility category) → no new violations. + +- [ ] **Step 5: Interaction — sign-out ends the session** + +- Open the menu, `click` **Sign out**. +- Assert the session ends: the app returns to the login screen (`take_snapshot` shows the LoginForm) and `evaluate_script` reading `window.__bool` / a fresh `useSession()` shows no `user`. This proves `signOut` wired end-to-end. +- Re-open, click **Appearance** → the `AppearanceMenu` dialog opens with Theme/Density/Notifications and **no** sign-out button; Esc restores focus to the trigger. +- Admin-only: with a non-admin session, confirm the **Invites…** item is absent. + +- [ ] **Step 6: Save evidence and commit** + +Save final screenshots to `design/shots/impl-ws3-desktop.png` and `design/shots/impl-ws3-mobile.png`. + +```bash +git add design/shots/impl-ws3-desktop.png design/shots/impl-ws3-mobile.png +git commit -m "test(ws3): Ralph loop evidence — account menu visual + a11y + sign-out interaction" +``` + +- [ ] **Step 7: Full suite green before hand-off** + +Run: `pnpm -r test` +Expected: PASS (client + server + shared all green). + +--- + +## Self-Review + +- **Spec coverage:** delivers spec §4 WS-3 and INDEX row WS-3 — persistent account button (nick + presence dot) in the rail footer; accessible dropdown on the WS-0 `Menu` with Appearance / Notifications / Invites… (admin only) / **Sign out** as the primary logout home; buried sign-out removed from `AppearanceMenu`; current user (nick + admin) read from `useSession()`; Ralph acceptance (keyboard-reachable, `role=menu`, Esc closes, focus returns to trigger, sign-out ends session, 24px targets, desktop+mobile parity). ✓ +- **WS-0 consumption:** `Menu` is consumed **by name** with the exact INDEX props (`open`, `onClose`, `trigger`, `label`, `children`); items are ` +
+ +
Directory — searchable list, advanced link
+
+
Add a network
+
+ + + + + +
+
+
+ +
Nick capture — after selecting a network
+
+
Join Libera.Chat
+
+
Connecting to irc.libera.chat:6697 over TLS
+ + +
+ + +
+
+
+ +
Empty state — no networks configured yet
+
+
Add a network
+
+
+

No networks yet

+

Pick a network from the directory and choose a nick — bool connects you automatically.

+ +
+
+
+ +
+ + +``` + +- [ ] **Step 2: Sanity-check it opens** + +Run: `test -f design/network-directory.html && grep -c 'data-theme' design/network-directory.html` +Expected: prints a number `>= 2` (dark default + light override + toggle), confirming the file exists with both themes. + +- [ ] **Step 3: Commit** + +```bash +git add design/network-directory.html +git commit -m "design(ws4): Direction-A network directory mock (dark+light, search/nick/advanced/empty)" +``` + +--- + +### Task 2: Curated network catalog module + +**Files:** +- Create: `packages/client/src/network-catalog.ts` +- Test: `packages/client/src/network-catalog.test.ts` + +**Interfaces:** +- Produces: `NETWORK_CATALOG`, `CatalogNetwork` (see header). Deterministic — no `Date`, no `Math.random`. + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/client/src/network-catalog.test.ts +import { describe, it, expect } from 'vitest' +import { NETWORK_CATALOG, type CatalogNetwork } from './network-catalog.js' + +describe('NETWORK_CATALOG', () => { + it('contains the eight curated well-known networks', () => { + const names = NETWORK_CATALOG.map((n) => n.name) + expect(names).toEqual( + expect.arrayContaining([ + 'Libera.Chat', 'OFTC', 'EFNet', 'Undernet', 'Rizon', 'QuakeNet', 'DALnet', 'Snoonet', + ]), + ) + expect(NETWORK_CATALOG.length).toBe(8) + }) + + it('every entry is fully specified with a TLS port and no color/host placeholders', () => { + for (const n of NETWORK_CATALOG) { + expect(n.id).toMatch(/^[a-z0-9-]+$/) + expect(n.name.length).toBeGreaterThan(0) + expect(n.description.length).toBeGreaterThan(0) + expect(n.host).toMatch(/\./) // looks like a hostname + expect(n.port).toBeGreaterThan(0) + expect(n.port).toBeLessThan(65536) + expect(n.tls).toBe(true) + expect(n.tag.length).toBeGreaterThan(0) + } + }) + + it('has unique ids and hosts', () => { + const ids = new Set(NETWORK_CATALOG.map((n) => n.id)) + const hosts = new Set(NETWORK_CATALOG.map((n) => n.host)) + expect(ids.size).toBe(NETWORK_CATALOG.length) + expect(hosts.size).toBe(NETWORK_CATALOG.length) + }) + + it('is deterministic (stable across reads — no Date/random)', () => { + const a = JSON.stringify(NETWORK_CATALOG) + const b = JSON.stringify(NETWORK_CATALOG) + expect(a).toBe(b) + }) + + it('is typed as CatalogNetwork[]', () => { + const first: CatalogNetwork = NETWORK_CATALOG[0]! + expect(first).toHaveProperty('host') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @bool/client test -- src/network-catalog.test.ts` +Expected: FAIL — cannot resolve `./network-catalog.js`. + +- [ ] **Step 3: Write minimal implementation** + +Hosts and TLS ports below are the standard public endpoints for each network (all TLS `6697`, which is the well-known convention). Marks are two-letter abbreviations matching `netAbbr` behaviour in `Sidebar.tsx`. + +```ts +// packages/client/src/network-catalog.ts + +/** A curated, well-known IRC network the directory can one-click connect to. */ +export interface CatalogNetwork { + /** Stable slug used as a React key and for lookups. */ + id: string + /** Display name (also passed as the network `name` to addNetwork). */ + name: string + /** One-line description shown under the name. */ + description: string + /** TLS hostname. */ + host: string + /** TLS port (6697 by convention). */ + port: number + /** Always TLS for curated entries. */ + tls: true + /** Short tag/mark shown as a pill. */ + tag: string +} + +/** + * Curated directory of well-known IRC networks. Deterministic pure data — + * no Date/random, safe to snapshot. All entries use TLS on 6697. + */ +export const NETWORK_CATALOG: readonly CatalogNetwork[] = [ + { + id: 'libera', + name: 'Libera.Chat', + description: 'Home of free/open-source software projects', + host: 'irc.libera.chat', + port: 6697, + tls: true, + tag: 'FOSS', + }, + { + id: 'oftc', + name: 'OFTC', + description: 'Open and Free Technology Community', + host: 'irc.oftc.net', + port: 6697, + tls: true, + tag: 'FOSS', + }, + { + id: 'efnet', + name: 'EFNet', + description: 'One of the oldest original IRC networks', + host: 'irc.efnet.org', + port: 6697, + tls: true, + tag: 'Classic', + }, + { + id: 'undernet', + name: 'Undernet', + description: 'Large long-running general-purpose network', + host: 'irc.undernet.org', + port: 6697, + tls: true, + tag: 'General', + }, + { + id: 'rizon', + name: 'Rizon', + description: 'General chat, anime and gaming communities', + host: 'irc.rizon.net', + port: 6697, + tls: true, + tag: 'Community', + }, + { + id: 'quakenet', + name: 'QuakeNet', + description: 'Gaming-focused network from the Quake community', + host: 'irc.quakenet.org', + port: 6697, + tls: true, + tag: 'Gaming', + }, + { + id: 'dalnet', + name: 'DALnet', + description: 'Established network known for its services', + host: 'irc.dal.net', + port: 6697, + tls: true, + tag: 'General', + }, + { + id: 'snoonet', + name: 'Snoonet', + description: 'Reddit-affiliated general-purpose network', + host: 'irc.snoonet.org', + port: 6697, + tls: true, + tag: 'Community', + }, +] as const +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @bool/client test -- src/network-catalog.test.ts` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/client/src/network-catalog.ts packages/client/src/network-catalog.test.ts +git commit -m "feat(ws4): curated deterministic IRC network catalog (8 well-known networks)" +``` + +--- + +### Task 3: Refactor `NetworkSettings` into the advanced leaf (add `onBack`) + +**Files:** +- Modify: `packages/client/src/components/NetworkSettings.tsx` + +**Interfaces:** +- Produces: `NetworkSettings({ onClose, onBack? })`. When `onBack` is provided, a "← Back to directory" button renders above the form. No change to `addNetwork`/`connectNetwork`/`removeNetwork` behaviour, so the existing `NetworkSettings.test.tsx` stays green. + +- [ ] **Step 1: Add a failing test for the back affordance** + +Append to `packages/client/src/components/NetworkSettings.test.tsx`: + +```tsx + it('renders a Back button that calls onBack when provided', () => { + const onBack = vi.fn() + render( {}} onBack={onBack} />) + fireEvent.click(screen.getByRole('button', { name: /back to directory/i })) + expect(onBack).toHaveBeenCalledOnce() + }) + + it('renders no Back button when onBack is absent', () => { + render( {}} />) + expect(screen.queryByRole('button', { name: /back to directory/i })).not.toBeInTheDocument() + }) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @bool/client test -- src/components/NetworkSettings.test.tsx` +Expected: FAIL — no button named "Back to directory". + +- [ ] **Step 3: Implement** + +Edit the props interface and render. Change: + +```tsx +export interface NetworkSettingsProps { + onClose: () => void +} +``` +to: +```tsx +export interface NetworkSettingsProps { + onClose: () => void + /** When provided, render a "Back to directory" affordance (advanced-leaf mode). */ + onBack?: () => void +} +``` + +Change the function signature `export function NetworkSettings({ onClose }: NetworkSettingsProps)` to `export function NetworkSettings({ onClose, onBack }: NetworkSettingsProps)`, and insert this as the first child inside the top-level `return (
` (before the `{/* Add Network Form */}` comment): + +```tsx + {onBack && ( + + )} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @bool/client test -- src/components/NetworkSettings.test.tsx` +Expected: PASS — all prior tests plus the two new ones. + +- [ ] **Step 5: Commit** + +```bash +git add packages/client/src/components/NetworkSettings.tsx packages/client/src/components/NetworkSettings.test.tsx +git commit -m "feat(ws4): NetworkSettings gains optional onBack for advanced-leaf mode" +``` + +--- + +### Task 4: `NetworkDirectory` — search + nick capture + auto-connect + advanced path + +**Files:** +- Create: `packages/client/src/components/NetworkDirectory.tsx` +- Test: `packages/client/src/components/NetworkDirectory.test.tsx` + +**Interfaces:** +- Consumes: WS-0 `EmptyState` (`./primitives/EmptyState.js`), WS-0 `Field` (`./primitives/Field.js`), `NETWORK_CATALOG` (`../network-catalog.js`), `useChat` (`../store/chat-store.js`), `NetworkSettings` (`./NetworkSettings.js`). +- Produces: `NetworkDirectory({ onClose })`. + +- [ ] **Step 1: Write the failing test** + +This test mocks the store the same way `NetworkSettings.test.tsx` does (reset via `getInitialState`, spy on actions via `vi.spyOn(useChat.getState(), …)`). The auto-connect test drives the honest mechanism: submit the nick → assert `addNetwork` is called → simulate the server reply by pushing the network into the store (`useChat.setState`) → assert `connectNetwork` is called with that id. + +```tsx +// packages/client/src/components/NetworkDirectory.test.tsx +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, fireEvent, act, waitFor } from '@testing-library/react' +import { useChat } from '../store/chat-store.js' +import { NetworkDirectory } from './NetworkDirectory.js' + +beforeEach(() => { + useChat.setState((useChat as any).getInitialState?.() ?? {}, true) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('NetworkDirectory', () => { + it('lists curated networks from the catalog', () => { + render( {}} />) + expect(screen.getByRole('button', { name: /libera\.chat/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /oftc/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /snoonet/i })).toBeInTheDocument() + }) + + it('filters the list by name/description as the user types', () => { + render( {}} />) + fireEvent.change(screen.getByRole('searchbox', { name: /search networks/i }), { + target: { value: 'anime' }, // matches Rizon's description + }) + expect(screen.getByRole('button', { name: /rizon/i })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /libera\.chat/i })).not.toBeInTheDocument() + }) + + it('shows an EmptyState with a single CTA when the search matches nothing', () => { + render( {}} />) + fireEvent.change(screen.getByRole('searchbox', { name: /search networks/i }), { + target: { value: 'zzzzzz-no-match' }, + }) + expect(screen.getByRole('heading', { name: /no networks/i })).toBeInTheDocument() + // exactly one CTA in the empty state + expect(screen.getByRole('button', { name: /clear search|browse/i })).toBeInTheDocument() + }) + + it('selecting a network reveals the nick capture step for that network', () => { + render( {}} />) + fireEvent.click(screen.getByRole('button', { name: /libera\.chat/i })) + expect(screen.getByText(/irc\.libera\.chat:6697/i)).toBeInTheDocument() + expect(screen.getByLabelText(/nick/i)).toBeInTheDocument() + }) + + it('calls addNetwork with the catalog host/port/tls and the chosen nick', () => { + const addNetwork = vi.spyOn(useChat.getState(), 'addNetwork').mockImplementation(() => {}) + render( {}} />) + fireEvent.click(screen.getByRole('button', { name: /libera\.chat/i })) + fireEvent.change(screen.getByLabelText(/nick/i), { target: { value: 'ada' } }) + fireEvent.click(screen.getByRole('button', { name: /^connect$/i })) + expect(addNetwork).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Libera.Chat', + host: 'irc.libera.chat', + port: 6697, + tls: true, + nick: 'ada', + }), + ) + }) + + it('AUTO-CONNECTS: calls connectNetwork once the added network appears in the store', async () => { + const addNetwork = vi.spyOn(useChat.getState(), 'addNetwork').mockImplementation(() => {}) + const connectNetwork = vi.spyOn(useChat.getState(), 'connectNetwork').mockImplementation(() => {}) + render( {}} />) + + fireEvent.click(screen.getByRole('button', { name: /libera\.chat/i })) + fireEvent.change(screen.getByLabelText(/nick/i), { target: { value: 'ada' } }) + fireEvent.click(screen.getByRole('button', { name: /^connect$/i })) + + expect(addNetwork).toHaveBeenCalled() + // connect has NOT fired yet — the network id is unknown until the server replies + expect(connectNetwork).not.toHaveBeenCalled() + + // Simulate the server's net:list reply landing the network in the store + act(() => { + useChat.setState({ + networks: { + 7: { id: 7, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'ada', connected: false }, + }, + }) + }) + + await waitFor(() => expect(connectNetwork).toHaveBeenCalledWith(7)) + }) + + it('connects immediately if the host already exists in the store when selected', async () => { + // Pre-existing (previously added, disconnected) Libera network + useChat.setState({ + networks: { + 3: { id: 3, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'ada', connected: false }, + }, + }) + const connectNetwork = vi.spyOn(useChat.getState(), 'connectNetwork').mockImplementation(() => {}) + render( {}} />) + fireEvent.click(screen.getByRole('button', { name: /libera\.chat/i })) + fireEvent.change(screen.getByLabelText(/nick/i), { target: { value: 'ada' } }) + fireEvent.click(screen.getByRole('button', { name: /^connect$/i })) + await waitFor(() => expect(connectNetwork).toHaveBeenCalledWith(3)) + }) + + it('reveals the advanced NetworkSettings form and can return to the directory', () => { + render( {}} />) + fireEvent.click(screen.getByRole('button', { name: /advanced/i })) + // The advanced form has a Host field the directory does not + expect(screen.getByLabelText(/^host$/i)).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /back to directory/i })) + // Back in the directory + expect(screen.getByRole('button', { name: /libera\.chat/i })).toBeInTheDocument() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @bool/client test -- src/components/NetworkDirectory.test.tsx` +Expected: FAIL — cannot resolve `./NetworkDirectory.js`. + +- [ ] **Step 3: Write minimal implementation** + +Three view modes: `'list'` (directory + advanced link), `'nick'` (capture + auto-connect), `'advanced'` (renders `NetworkSettings` with `onBack`). Auto-connect is a one-shot `useChat.subscribe` set up when `handleConnect` runs; if a matching host already exists we connect synchronously. + +```tsx +// packages/client/src/components/NetworkDirectory.tsx +import { useState, useMemo, useRef, useEffect } from 'react' +import { useChat } from '../store/chat-store.js' +import { NETWORK_CATALOG, type CatalogNetwork } from '../network-catalog.js' +import { EmptyState } from './primitives/EmptyState.js' +import { Field } from './primitives/Field.js' +import { NetworkSettings } from './NetworkSettings.js' + +export interface NetworkDirectoryProps { + onClose: () => void +} + +/** Two-letter mark, mirroring Sidebar's netAbbr. */ +function mark(name: string): string { + const words = name.split(/[\s.-]+/).filter(Boolean) + if (words.length >= 2) return (words[0]![0]! + words[1]![0]!).toUpperCase() + return name.slice(0, 2).toUpperCase() +} + +type Mode = 'list' | 'nick' | 'advanced' + +export function NetworkDirectory({ onClose }: NetworkDirectoryProps) { + const [mode, setMode] = useState('list') + const [query, setQuery] = useState('') + const [selected, setSelected] = useState(null) + const [nick, setNick] = useState('') + // Track a pending auto-connect subscription so we can clean it up on unmount. + const unsubRef = useRef<(() => void) | null>(null) + useEffect(() => () => unsubRef.current?.(), []) + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase() + if (!q) return NETWORK_CATALOG + return NETWORK_CATALOG.filter( + (n) => n.name.toLowerCase().includes(q) || n.description.toLowerCase().includes(q), + ) + }, [query]) + + function pick(net: CatalogNetwork) { + setSelected(net) + setMode('nick') + } + + /** Fire connectNetwork for the network whose host matches `host`, once it exists. */ + function autoConnect(host: string) { + const findId = (nets: Record) => + Object.values(nets).find((n) => n.host === host)?.id ?? null + + // Already present (re-adding a known host)? Connect immediately. + const existing = findId(useChat.getState().networks) + if (existing != null) { + useChat.getState().connectNetwork(existing) + return + } + // Otherwise wait for the server's net:list/net:state to land it in the store. + unsubRef.current = useChat.subscribe((state) => { + const id = findId(state.networks) + if (id != null) { + unsubRef.current?.() + unsubRef.current = null + useChat.getState().connectNetwork(id) + } + }) + } + + function handleConnect() { + if (!selected) return + const n = nick.trim() + if (!n) return + useChat.getState().addNetwork({ + name: selected.name, + host: selected.host, + port: selected.port, + tls: selected.tls, + nick: n, + }) + autoConnect(selected.host) + onClose() + } + + // ---- Advanced (custom server) leaf ---- + if (mode === 'advanced') { + return setMode('list')} /> + } + + // ---- Nick capture ---- + if (mode === 'nick' && selected) { + return ( +
+

+ Connecting to{' '} + {selected.host}:{selected.port} over TLS +

+ (v.trim().length === 0 ? 'Pick a nick to continue' : null)} + /> +
+ + +
+
+ ) + } + + // ---- Directory list ---- + return ( +
+
+ / + setQuery(e.target.value)} + autoFocus + style={{ + flex: 1, background: 'none', border: 'none', outline: 'none', + color: 'var(--ink-0)', fontFamily: 'var(--sans)', fontSize: 14, + }} + /> +
+ + {filtered.length === 0 ? ( + setQuery('') }} + /> + ) : ( +
+ {filtered.map((net) => ( + + ))} +
+ )} + +
+ +
+
+ ) +} +``` + +> Note on the empty-state test: the `EmptyState` `title` "No networks match" satisfies `getByRole('heading', { name: /no networks/i })`, and its single CTA "Clear search" satisfies `getByRole('button', { name: /clear search|browse/i })`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @bool/client test -- src/components/NetworkDirectory.test.tsx` +Expected: PASS (8 tests) — including the auto-connect assertion. + +- [ ] **Step 5: Typecheck** + +Run: `pnpm --filter @bool/client typecheck` +Expected: PASS. (If `useChat.subscribe`'s selector typing complains, the `findId` helper's param type keeps it structural; adjust only the local annotation, never the store.) + +- [ ] **Step 6: Commit** + +```bash +git add packages/client/src/components/NetworkDirectory.tsx packages/client/src/components/NetworkDirectory.test.tsx +git commit -m "feat(ws4): NetworkDirectory — searchable catalog, nick capture, auto-connect, advanced path" +``` + +--- + +### Task 5: Wire the directory as the primary entry point (rail "+" + AppShell) + +**Files:** +- Modify: `packages/client/src/components/AppShell.tsx` + +**Interfaces:** +- Consumes: `NetworkDirectory` (`./NetworkDirectory.js`). +- The rail "+" in `Sidebar.tsx` already calls `onOpenNetworkSettings`; we keep that prop/callback name (it opens the directory now). No `Sidebar.tsx` change required for the entry point. + +- [ ] **Step 1: Add a failing test for AppShell wiring** + +Create `packages/client/src/components/AppShell.networkDirectory.test.tsx`: + +```tsx +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, screen, fireEvent, act } from '@testing-library/react' +import { useChat } from '../store/chat-store.js' +import { AppShell } from './AppShell.js' + +beforeEach(() => { + vi.useFakeTimers() + useChat.setState((useChat as any).getInitialState?.() ?? {}, true) +}) +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('AppShell add-network entry point', () => { + it('opening "Add network" from the rail shows the network directory (not the raw form)', () => { + render() + fireEvent.click(screen.getByRole('button', { name: /add network/i })) + act(() => vi.runAllTimers()) + // Directory front door: the curated catalog is visible… + expect(screen.getByRole('button', { name: /libera\.chat/i })).toBeInTheDocument() + // …and the raw Host field is NOT the front door (it's behind Advanced) + expect(screen.queryByLabelText(/^host$/i)).not.toBeInTheDocument() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @bool/client test -- src/components/AppShell.networkDirectory.test.tsx` +Expected: FAIL — the dialog still renders `NetworkSettings` (Host field present, no catalog buttons). + +- [ ] **Step 3: Implement — swap the dialog contents** + +In `packages/client/src/components/AppShell.tsx`: +- Replace the import `import { NetworkSettings } from './NetworkSettings.js'` with `import { NetworkDirectory } from './NetworkDirectory.js'`. +- Change the dialog block: + +```tsx + {/* Network settings dialog */} + + + +``` +to: +```tsx + {/* Add-network dialog — directory is the front door; advanced form lives inside it */} + + + +``` + +(The `networkSettingsOpen`/`openNetworkSettings`/`closeNetworkSettings` state names and the `Sidebar` `onOpenNetworkSettings` prop are left unchanged — only the rendered contents and title change.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @bool/client test -- src/components/AppShell.networkDirectory.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Full client suite + typecheck (regression gate)** + +Run: `pnpm --filter @bool/client typecheck && pnpm --filter @bool/client test` +Expected: PASS — all green, including the pre-existing `NetworkSettings.test.tsx` and `Sidebar`/`AppShell` tests. The advanced path still reaches the original form; onboarding (WS-2) can now render ``. + +- [ ] **Step 6: Commit** + +```bash +git add packages/client/src/components/AppShell.tsx packages/client/src/components/AppShell.networkDirectory.test.tsx +git commit -m "feat(ws4): rail + and AppShell open the network directory as the front door" +``` + +--- + +### Task 6: Ralph loop — visual + a11y + interaction parity gate + +**Files:** none (verification only). Evidence screenshots saved to `design/shots/impl-ws4-{desktop,mobile}.png`. + +This is the shared closing gate from `2026-07-12-bool-ux-overhaul-INDEX.md` → **The Ralph Loop**. Exit only when **visual ≈ mock AND a11y ✓ AND interaction ✓**. Use the `chrome-devtools` and `a11y-debugging` skills. Parity target: `design/network-directory.html` (Task 1). + +- [ ] **Step 1: Start the dev server and open the app** + +Run: `pnpm --filter @bool/client dev` (Vite, port 5173). +Then via chrome-devtools MCP: `new_page` → `navigate_page` to `http://localhost:5173`. + +- [ ] **Step 2: Seed deterministic state and open the directory** + +Use `mcp__…__evaluate_script`: +```js +const { useChat, useAppearance, seedDemo } = window.__bool +useAppearance.getState().setTheme('a') +useAppearance.getState().setDensity('compact') +seedDemo() // WS-0 helper: seeds networks/targets so the rail "+" is present +``` +Then `click` the rail **"Add network"** button (`aria-label="Add network"`) to open the directory dialog. + +- [ ] **Step 3: Visual parity (desktop + mobile)** + +- `resize_page` to **1440×900**; `take_screenshot`. Compare against `design/network-directory.html` (directory panel): search bar, catalog rows (mark + name + description + tag), advanced link at the bottom. Reconcile spacing/typography/token deltas. +- `resize_page` to **390×844**; `take_screenshot`. Confirm the dialog is usable at mobile width (card `width: min(560px, 92vw)` from `Dialog`), rows wrap/truncate cleanly, tap targets ≥ 44px on touch. +- Save the two screenshots to `design/shots/impl-ws4-desktop.png` and `design/shots/impl-ws4-mobile.png`. + +- [ ] **Step 4: A11y audit (`a11y-debugging` skill)** + +- Keyboard-only: Tab from the search box through the catalog buttons to the advanced link; every item reachable and focus-visible; focus never obscured. +- Focus trap + restore provided by the surrounding `Dialog` — Esc closes and returns focus to the rail "+". +- Roles/names: search input is `role="searchbox"` with an accessible name; each network is a `button` with its name in the accessible name; the empty-state CTA is a single button; nick `Field` wires its error via `aria-describedby` (WS-0 primitive). +- Contrast 4.5:1 across themes (spot-check theme `a` dark and a light theme via `useAppearance.getState().setTheme(...)`); targets ≥ 24px (≥ 44px touch); `prefers-reduced-motion` honored (no essential animation). +- Optionally `lighthouse_audit` (a11y category) — no critical violations. + +- [ ] **Step 5: Interaction — the full flow end-to-end** + +Assert each acceptance criterion by driving the real UI: +1. **Search filters:** `fill` the searchbox with `anime` → only Rizon remains; clear → all eight return. +2. **Empty state teaches + CTA:** `fill` with `zzzzzz` → `EmptyState` heading + single "Clear search" CTA; click it → list returns. +3. **Pick → nick → auto-connect (the core):** `click` **Libera.Chat** → nick step shows `irc.libera.chat:6697`; `type_text` a nick; `click` **Connect**. Then assert via `evaluate_script` that `addNetwork` fired and, once the network lands in the store, `connectNetwork` was called — read `useChat.getState()` to confirm a Libera network exists and that a `net:connect` was issued (in a no-IRC seed you can instead spy: before clicking, wrap `useChat.getState().connectNetwork` and record the call). Acceptance: **the user did not click a separate Connect on the network list** — connection is initiated by the directory flow itself. +4. **Advanced path works:** click **Advanced / custom server** → the raw Host/Port/TLS/SASL form appears; the **← Back to directory** button returns to the catalog. +5. **Keyboard-complete:** repeat step 3 using only Tab/Enter/typing (no mouse) and confirm it completes. +6. **Desktop + mobile parity:** confirm steps 1–4 work at both `1440×900` and `390×844`. + +- [ ] **Step 6: Reconcile any deltas, then commit evidence** + +Any visual/a11y/interaction failure → fix in code → repeat from Step 1. When all three pass: + +```bash +git add design/shots/impl-ws4-desktop.png design/shots/impl-ws4-mobile.png +git commit -m "test(ws4): Ralph loop evidence — directory parity + a11y + auto-connect interaction" +``` + +--- + +## Self-Review + +- **Spec coverage:** delivers spec §4 WS-4 and §1.3 in full — curated directory (`network-catalog.ts`, 8 networks) as the front door, search, **auto-connect on selection** (§1.3 "auto-connect on selection," §1.6 empty-state teach+CTA), advanced/custom server behind it (`NetworkSettings` relocated via `onBack`), and the rail "+"/onboarding entry point (`AppShell` swap; `NetworkDirectory({ onClose })` is a drop-in `Dialog` child WS-2 can reuse). ✓ +- **Grounded in real APIs:** `addNetwork(input)` is called with the exact `{ name, host, port, tls, nick }` shape from `store/types.ts`; it returns `void` and does not surface the new id, so auto-connect uses a one-shot `useChat.subscribe` on `state.networks` keyed by `host`, calling `connectNetwork(id: number)` — both signatures verified against `store/chat-store.ts`. The immediate-connect branch handles a pre-existing host. ✓ +- **WS-0 consumption by name:** `EmptyState` (no-match case) and `Field` (nick input) are imported from `./primitives/*.js` with the exact prop shapes from the WS-0 plan. ✓ +- **Conventions:** `.js` import extensions throughout; tokens only (no hex — `--green`, `--bg-*`, `--ink-*`, `--line*`, `--radius*`, `--mono`, `--sans`, `--on-accent`); a11y baseline (searchbox role+name, button names, `Field` `aria-describedby`, 24/44px targets, `Dialog` focus trap/restore, reduced motion). ✓ +- **Tests are real:** Vitest + `@testing-library/react`, store mocked exactly as `NetworkSettings.test.tsx` does (reset via `getInitialState`, `vi.spyOn(useChat.getState(), …)`); the **auto-connect test asserts `connectNetwork` is called after `addNetwork`** by simulating the server's `net:list` reply via `useChat.setState`. Every task has exact commands + expected output + a commit step. ✓ +- **Mock-first + Ralph-last:** Task 1 authors `design/network-directory.html` (Direction A, dark default + light toggle, showing directory + nick capture + advanced link + empty state); the final task is the Ralph gate with concrete chrome-devtools MCP steps, `window.__bool.seedDemo()` seeding, desktop+mobile viewports, and the full acceptance list. ✓ +- **No placeholders:** every code block is complete and self-consistent; the one honest conditional (auto-connect immediate vs. deferred) is fully implemented in both branches. ✓ diff --git a/docs/superpowers/plans/2026-07-12-ws5-channel-browser.md b/docs/superpowers/plans/2026-07-12-ws5-channel-browser.md new file mode 100644 index 0000000..ad580d9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-ws5-channel-browser.md @@ -0,0 +1,1144 @@ +# WS-5 Channel Browser — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A world-class, searchable/sortable channel directory backed by IRC `/LIST` — each row shows channel name, topic, and member count; results are filterable, sortable by member count / name, virtualized for large lists, previewable (topic + count) before joining, and joinable directly from a result. End the "Server"/status-group cram in the sidebar by relocating the status buffer to a small "server log" affordance in the network header. + +**Architecture:** The server already *issues* `LIST` (`IrcConnection.listChannels()` → `client.raw('LIST …')`) and the client already has a `chan:list` request message, a `listChannels(networkId, filter?)` store action, and a `/list` command parse. **But the RPL_LIST (322) rows are never collected as structured data** — they fall through the generic `raw` handler → `formatNumeric()` → get dumped into the `*` status buffer as plain `chat:msg` notices. There is **no `chan:list:result` server message, no `323` (RPL_LISTEND) handling, no reducer case, and no client state to hold results.** This workstream **adds the streaming LIST plumbing** (collect 322 rows in the connection, emit a structured `chan:list:result` batch on 323, add the shared message type + client reducer + store state), then builds `ChannelBrowser.tsx` on top of it, and refactors `Sidebar.tsx` to drop the status group. + +**Tech Stack:** React 19 + TypeScript + Zustand (client) · Fastify + irc-framework (server) · `react-virtuoso` (already a client dependency, used in `MessageList.tsx`) · Vitest + React Testing Library + jsdom (client) · Vitest (server) · shared Zod protocol (`packages/shared`). + +## Global Constraints + +See `2026-07-12-bool-ux-overhaul-INDEX.md` → Global Constraints. Key for this workstream: `.js` relative import extensions; **no hardcoded colors** (use `tokens.css` custom properties); a11y baseline (keyboard-operable, focus trap+restore in the overlay, `role`/labels correct, 24px/44px targets, 4.5:1 contrast, `prefers-reduced-motion`); large lists **virtualized**; SQLite only (this workstream needs **no** DB changes — `/LIST` is fetched live, resolving spec §8 open-question 3 in favor of live fetch); TDD (failing test → minimal impl → green → commit); keep `pnpm -r test` green; all work on `feat/ux-overhaul`. + +### Prior-art trace (what exists vs. what must be added) + +Read end-to-end before implementing. Current reality: + +| Layer | File | State today | +|---|---|---| +| Command parse | `packages/client/src/commands.ts` | ✅ `/list` → `{ kind: 'list'; filter? }` **exists** | +| Store action | `packages/client/src/store/chat-store.ts:406` | ✅ `listChannels(networkId, filter?)` sends `{ type: 'chan:list', networkId, filter? }` **exists** | +| Client request schema | `packages/shared/src/protocol.ts:34` | ✅ `chanListSchema` (`chan:list`) **exists** | +| WS route | `packages/server/src/ws.ts:110` | ✅ `case 'chan:list'` → `ircManager.listChannels(...)` **exists** | +| Manager | `packages/server/src/irc/manager.ts:162` | ✅ `listChannels(userId, networkId, filter?)` **exists** | +| Connection LIST send | `packages/server/src/irc/connection.ts:204` | ✅ `listChannels(filter?)` → `client.raw('LIST …')` **exists** | +| **322 row collection** | `packages/server/src/irc/connection.ts:174` (`raw` handler) | ❌ **MISSING** — 322 lines go to `formatNumeric()` → dumped as `*` status `chat:msg`, never structured | +| **`chan:list:result` server msg** | `packages/shared/src/protocol.ts` | ❌ **MISSING** — no server→client result schema | +| **Client reducer + state** | `packages/client/src/store/chat-store.ts` | ❌ **MISSING** — no `chan:list:result` case, no `channelList` state | +| **ChannelBrowser UI** | — | ❌ **MISSING** — no component; only the tiny inline `#channel` join input in `Sidebar.tsx` | +| Sidebar "Server" cram | `packages/client/src/components/Sidebar.tsx:153-172` | ❌ status buffer jammed into a "Server" group — **to be removed** | + +**Conclusion:** the request path (client→server→`LIST`) exists; the **result path (322/323 → structured stream → client state) does NOT and must be added.** This plan adds it. + +### Files created / modified + +**Server (add streaming LIST result):** +- Modify: `packages/server/src/irc/connection.ts` — collect `322` rows, emit `chan:list:result` on `323`; suppress 322/321/323 from the `*` status dump. +- Modify: `packages/server/src/irc/connection.test.ts` — tests for 322 collection + 323 flush + no status leak. +- Modify: `packages/server/src/irc/numerics.ts` — add `321`/`322`/`323` to `SUPPRESS`. +- Modify: `packages/server/src/irc/numerics.test.ts` — assert LIST numerics are suppressed. + +**Shared (new server→client message type):** +- Modify: `packages/shared/src/protocol.ts` — add `chanListResultSchema` (`chan:list:result`) + include in `serverMessageSchema`; bump `PROTOCOL_VERSION`. +- Modify: `packages/shared/src/protocol-commands.test.ts` — assert `chan:list:result` parses. + +**Client (state, reducer, UI, sidebar refactor):** +- Modify: `packages/client/src/store/types.ts` — add `ChannelListEntry`, `channelList` state field, `channelListLoading`. +- Modify: `packages/client/src/store/chat-store.ts` — `chan:list:result` reducer; set `channelListLoading` in `listChannels()`. +- Modify: `packages/client/src/store/chat-store.test.ts` — reducer + action tests. +- Create: `packages/client/src/components/ChannelBrowser.tsx` + `ChannelBrowser.test.tsx`. +- Modify: `packages/client/src/components/Sidebar.tsx` — drop the "Server" group; add a "Browse channels" affordance and a small "server log" button in the network header. +- Modify: `packages/client/src/components/Sidebar.test.tsx` (create if absent) — assert no "Server" group; status log affordance present. +- Modify: `packages/client/src/components/AppShell.tsx` — mount `ChannelBrowser` in a `Dialog`, wire open/close + a `bool:open-channel-browser` event. +- Create: `design/channel-browser.html` — static Direction-A mock (Ralph target). + +### Interfaces produced + +- **Shared:** `chan:list:result` server message: `{ type: 'chan:list:result'; networkId: number; channels: Array<{ channel: string; members: number; topic: string }> }`. +- **Store state:** `ChannelListEntry = { channel: string; members: number; topic: string }`; `channelList: Record` (keyed by `networkId`); `channelListLoading: Record`. +- **Store action (unchanged signature — already exists):** `listChannels(networkId: number, filter?: string): void`. +- **Component:** `ChannelBrowser({ networkId, onClose }: { networkId: number; onClose: () => void })`. + +### Interfaces consumed + +- **WS-0 primitives:** `EmptyState` from `packages/client/src/components/primitives/EmptyState.js` (no-results state) — `EmptyStateProps { title; description?; icon?; action? }`. `Field` from `.../primitives/Field.js` is available for the search box, but the browser's search input is a simple labeled `` (see Task 6); use `Field` only if a validated field is wanted. +- **chat-store — EXACT signatures (verified in `store/types.ts` / `chat-store.ts`):** + - `join(networkId: number, channel: string): void` — call as `join(networkId, entry.channel)` (channels from `/LIST` already include the `#` prefix). + - `select(targetKey: string): void`; `targetKey(networkId, channel)` from `store/chat-store.js`. + - `listChannels(networkId: number, filter?: string): void`. +- **Dialog:** `packages/client/src/components/Dialog.js` (`Dialog open onClose title` — provides `role="dialog"`, `aria-modal`, Esc, scrim, focus trap+restore). The browser renders **inside** a `Dialog`, so it does not re-implement focus trapping. +- **Seed harness:** `window.__bool.seedDemo()` + `useChat`/`useAppearance` (WS-0). + +--- + +### Task 1: Shared `chan:list:result` message type + +**Files:** +- Modify: `packages/shared/src/protocol.ts` +- Modify: `packages/shared/src/protocol-commands.test.ts` + +**Interfaces:** +- Produces: `chanListResultSchema` + `chan:list:result` in `ServerMessage`. + +- [ ] **Step 1: Write the failing test** + +Add to `packages/shared/src/protocol-commands.test.ts` (inside the existing top-level `describe`): + +```ts + it('chan:list:result (server)', () => { + const msg = parseServerMessage( + JSON.stringify({ + type: 'chan:list:result', + networkId: 1, + channels: [{ channel: '#bool', members: 42, topic: 'the friendly IRC client' }], + }), + ) + expect(msg.type).toBe('chan:list:result') + if (msg.type === 'chan:list:result') { + expect(msg.channels[0]!.members).toBe(42) + expect(msg.channels[0]!.channel).toBe('#bool') + } + }) +``` + +Ensure the file imports `parseServerMessage` (it already imports `parseClientMessage`; add `parseServerMessage` to the import from `'./protocol.js'` if missing). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @bool/shared test -- src/protocol-commands.test.ts` +Expected: FAIL — `chan:list:result` is not a member of the discriminated union (Zod throws `Invalid discriminator value`). + +- [ ] **Step 3: Add the schema** + +In `packages/shared/src/protocol.ts`, after `previewResultSchema` (~line 151), add: + +```ts +export const chanListResultSchema = z.object({ + type: z.literal('chan:list:result'), + networkId: z.number().int(), + channels: z.array( + z.object({ + channel: z.string(), + members: z.number().int(), + topic: z.string(), + }), + ), +}) +``` + +Add `chanListResultSchema` to the `serverMessageSchema` discriminated union array (append after `previewResultSchema`): + +```ts +export const serverMessageSchema = z.discriminatedUnion('type', [ + welcomeSchema, pongSchema, errorSchema, + netStateSchema, netListSchema, netErrorSchema, + chatMsgSchema, chanNamesSchema, chanJoinEvtSchema, chanPartEvtSchema, chanTopicSchema, + presenceQuitSchema, presenceNickSchema, + searchResultsSchema, historyBatchSchema, + readUpdateSchema, readListSchema, + typingUpdateSchema, reactUpdateSchema, + previewResultSchema, chanListResultSchema, +]) +``` + +Bump the protocol version (it is a new server message): change `export const PROTOCOL_VERSION = 7` to `export const PROTOCOL_VERSION = 8`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @bool/shared test -- src/protocol-commands.test.ts && pnpm --filter @bool/shared typecheck` +Expected: PASS (new test green; typecheck clean). + +- [ ] **Step 5: Commit** + +```bash +git add packages/shared/src/protocol.ts packages/shared/src/protocol-commands.test.ts +git commit -m "feat(shared): chan:list:result server message + bump PROTOCOL_VERSION to 8" +``` + +--- + +### Task 2: Suppress LIST numerics from the status buffer + +**Files:** +- Modify: `packages/server/src/irc/numerics.ts` +- Modify: `packages/server/src/irc/numerics.test.ts` + +**Rationale:** Today `322` lines are formatted into readable status-buffer notices (see `connection.test.ts` "routes numeric replies into the * status buffer"). Once we collect them structurally (Task 3), they must **not** also spam the `*` buffer. Suppress `321` (RPL_LISTSTART), `322` (RPL_LIST), `323` (RPL_LISTEND). + +- [ ] **Step 1: Write the failing test** + +Add to `packages/server/src/irc/numerics.test.ts`: + +```ts + it('suppresses LIST numerics (321/322/323)', () => { + expect(formatNumeric(':server 321 bool Channel :Users Name', 'bool')).toBeNull() + expect(formatNumeric(':server 322 bool #chan 42 :a topic', 'bool')).toBeNull() + expect(formatNumeric(':server 323 bool :End of /LIST', 'bool')).toBeNull() + }) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @bool/server test -- src/irc/numerics.test.ts` +Expected: FAIL — `322` currently formats to `'#chan 42 a topic'` (see the existing "formats a LIST reply" test) instead of `null`. + +- [ ] **Step 3: Update SUPPRESS and the existing test** + +In `packages/server/src/irc/numerics.ts`, extend the set: + +```ts +// Numerics already represented as structured events (NAMES / TOPIC / LIST); skip to avoid duplication. +const SUPPRESS = new Set(['353', '366', '332', '333', '321', '322', '323']) +``` + +The existing test at `numerics.test.ts:7-8` asserts `322` formats to `'#chan 42 a topic'` — that behavior is now intentionally removed. Update it to assert suppression: + +```ts + it('suppresses a LIST (322) reply — now collected structurally, not dumped to status', () => + expect(formatNumeric(':server 322 bool #chan 42 :a topic', 'bool')).toBeNull()) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @bool/server test -- src/irc/numerics.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/server/src/irc/numerics.ts packages/server/src/irc/numerics.test.ts +git commit -m "feat(server): suppress LIST numerics (321/322/323) from status buffer" +``` + +--- + +### Task 3: Collect 322 rows and emit `chan:list:result` on 323 + +**Files:** +- Modify: `packages/server/src/irc/connection.ts` +- Modify: `packages/server/src/irc/connection.test.ts` + +**Interfaces:** +- Produces: the `sink` now emits `{ type: 'chan:list:result', networkId, channels }` when a `323` line arrives, containing all `322` rows accumulated since the last `321`/flush. + +**IRC numeric shapes (RFC 1459 / modern ircd):** +- `321` RPL_LISTSTART — ` Channel :Users Name` (marks start; reset accumulator). +- `322` RPL_LIST — ` <#channel> :`. +- `323` RPL_LISTEND — ` :End of /LIST` (flush accumulator). + +The existing `raw` handler at `connection.ts:174` already receives every server line with `e.from_server && typeof e.line === 'string'`. We parse LIST numerics there **before** falling through to `formatNumeric`. + +- [ ] **Step 1: Write the failing test** + +Add to `packages/server/src/irc/connection.test.ts` (uses the existing `makeConn()` helper that returns `{ conn, client, out }` where `out` collects sink messages): + +```ts + it('collects 322 rows and emits chan:list:result on 323', () => { + const { client, out } = makeConn() + client.emit('raw', { line: ':server 321 bool Channel :Users Name', from_server: true }) + client.emit('raw', { line: ':server 322 bool #bool 42 :the friendly IRC client', from_server: true }) + client.emit('raw', { line: ':server 322 bool #general 7 :', from_server: true }) + // No result yet — still streaming + expect(out.some((m) => m.type === 'chan:list:result')).toBe(false) + client.emit('raw', { line: ':server 323 bool :End of /LIST', from_server: true }) + + const result = out.find((m) => m.type === 'chan:list:result') as any + expect(result).toBeDefined() + expect(result.channels).toEqual([ + { channel: '#bool', members: 42, topic: 'the friendly IRC client' }, + { channel: '#general', members: 7, topic: '' }, + ]) + }) + + it('does not leak 322 rows into the * status buffer', () => { + const { client, out } = makeConn() + client.emit('raw', { line: ':server 322 bool #bool 42 :hi', from_server: true }) + client.emit('raw', { line: ':server 323 bool :End of /LIST', from_server: true }) + const statusMsgs = out.filter((m) => m.type === 'chat:msg' && (m as any).target === '*') + expect(statusMsgs.every((m) => !(m as any).text.includes('#bool'))).toBe(true) + }) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @bool/server test -- src/irc/connection.test.ts` +Expected: FAIL — no `chan:list:result` is emitted (accumulator/flush not implemented). Note: the "does not leak" test passes only once Task 2 suppression + this collection both land; run both tasks' code before re-running. + +- [ ] **Step 3: Implement 322 accumulation + 323 flush** + +In `packages/server/src/irc/connection.ts`, add a private accumulator field to the class (near `private connected = false`): + +```ts + private listBuffer: Array<{ channel: string; members: number; topic: string }> = [] +``` + +Add a parser helper (module scope, alongside `stripCrlf`): + +```ts +/** Parse a raw LIST numeric line. Returns 'start'|'end' markers or a 322 row. */ +function parseListLine( + rawLine: string, +): { kind: 'start' } | { kind: 'end' } | { kind: 'row'; channel: string; members: number; topic: string } | null { + let line = rawLine.trim() + if (line.startsWith(':')) { + const sp = line.indexOf(' ') + if (sp === -1) return null + line = line.slice(sp + 1) + } + const sp = line.indexOf(' ') + const code = sp === -1 ? line : line.slice(0, sp) + if (code === '321') return { kind: 'start' } + if (code === '323') return { kind: 'end' } + if (code !== '322') return null + + // 322: " <#channel> :" + const rest = sp === -1 ? '' : line.slice(sp + 1) + const ti = rest.indexOf(' :') + const head = ti === -1 ? rest : rest.slice(0, ti) + const topic = ti === -1 ? '' : rest.slice(ti + 2) + const parts = head.split(' ').filter(Boolean) + // parts: [client, #channel, count] (some ircds omit the leading client token) + const idx = parts.length >= 3 ? 1 : 0 + const channel = parts[idx] + const members = Number(parts[idx + 1]) + if (!channel || Number.isNaN(members)) return null + return { kind: 'row', channel, members, topic } +} +``` + +Then, in the `raw` handler (currently starting at `connection.ts:174`), intercept LIST numerics **before** `formatNumeric`: + +```ts + client.on('raw', (e: any) => { + if (!e?.from_server || typeof e.line !== 'string') return + + const listEvt = parseListLine(e.line) + if (listEvt) { + if (listEvt.kind === 'start') { + this.listBuffer = [] + } else if (listEvt.kind === 'row') { + this.listBuffer.push({ channel: listEvt.channel, members: listEvt.members, topic: listEvt.topic }) + } else { + // 'end' — flush + this.sink({ type: 'chan:list:result', networkId: nid, channels: this.listBuffer }) + this.listBuffer = [] + } + return + } + + const text = formatNumeric(e.line, client.user.nick) + if (text) { + this.sink({ type: 'chat:msg', networkId: nid, target: '*', from: '*', kind: 'notice', text, self: false, time: Date.now() }) + } + }) +``` + +> Note: some servers send `322` without a preceding `321`. The accumulator starts empty at connection and is only cleared on `321`/`323`, so a bare `322 … 323` sequence still works (rows accumulate then flush). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @bool/server test -- src/irc/connection.test.ts` +Expected: PASS (new tests green; existing "routes numeric replies into the * status buffer" test still passes because it uses a non-LIST numeric or the 322 case — verify: that test at `connection.test.ts:146` uses `322`; **update it** to a non-LIST numeric, e.g. `:server 251 bool :There are 42 users`, asserting `text: 'There are 42 users'`, since 322 no longer reaches the status buffer). + +Concretely, change the existing `connection.test.ts:146-151` test to: + +```ts + it('routes non-LIST numeric replies into the * status buffer', () => { + const { client, out } = makeConn() + client.emit('raw', { line: ':server 251 bool :There are 42 users online', from_server: true }) + const msg = out.find((m) => m.type === 'chat:msg' && (m as any).target === '*') as any + expect(msg).toMatchObject({ target: '*', from: '*', kind: 'notice', text: 'There are 42 users online' }) + }) +``` + +Re-run: `pnpm --filter @bool/server test -- src/irc/connection.test.ts` → PASS. + +- [ ] **Step 5: Full server suite + typecheck** + +Run: `pnpm --filter @bool/server test && pnpm --filter @bool/server typecheck` +Expected: all green. + +- [ ] **Step 6: Commit** + +```bash +git add packages/server/src/irc/connection.ts packages/server/src/irc/connection.test.ts +git commit -m "feat(server): collect RPL_LIST (322) rows and stream chan:list:result on 323" +``` + +--- + +### Task 4: Client store state + `chan:list:result` reducer + +**Files:** +- Modify: `packages/client/src/store/types.ts` +- Modify: `packages/client/src/store/chat-store.ts` +- Modify: `packages/client/src/store/chat-store.test.ts` + +**Interfaces:** +- Produces: `ChannelListEntry`, `channelList: Record`, `channelListLoading: Record`. +- Consumes: existing `listChannels(networkId, filter?)` action. + +- [ ] **Step 1: Write the failing test** + +Add to `packages/client/src/store/chat-store.test.ts`: + +```ts + it('listChannels() marks loading and sends chan:list', () => { + const { sock, open } = fakeSocket() + useChat.getState().init(() => sock) + open() + useChat.getState().listChannels(1, '#ru*') + expect(useChat.getState().channelListLoading[1]).toBe(true) + expect(sock.send).toHaveBeenCalledWith(JSON.stringify({ type: 'chan:list', networkId: 1, filter: '#ru*' })) + }) + + it('stores chan:list:result and clears loading', () => { + const { sock, emitServer, open } = fakeSocket() + useChat.getState().init(() => sock) + open() + useChat.getState().listChannels(1) + emitServer({ + type: 'chan:list:result', + networkId: 1, + channels: [ + { channel: '#bool', members: 42, topic: 'friendly IRC' }, + { channel: '#general', members: 7, topic: '' }, + ], + }) + const list = useChat.getState().channelList[1]! + expect(list.map((c) => c.channel)).toEqual(['#bool', '#general']) + expect(list[0]!.members).toBe(42) + expect(useChat.getState().channelListLoading[1]).toBe(false) + }) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @bool/client test -- src/store/chat-store.test.ts` +Expected: FAIL — `channelListLoading` / `channelList` are `undefined`; reducer has no `chan:list:result` case. + +- [ ] **Step 3: Extend types** + +In `packages/client/src/store/types.ts`, add the entry type and state fields: + +```ts +export interface ChannelListEntry { + channel: string + members: number + topic: string +} +``` + +In `interface ChatState`, add: + +```ts + /** networkId → channel directory from the last /LIST */ + channelList: Record + /** networkId → true while a /LIST is in flight */ + channelListLoading: Record +``` + +- [ ] **Step 4: Implement in the store** + +In `packages/client/src/store/chat-store.ts`: + +1. Add to `emptyState`: + +```ts + channelList: {}, + channelListLoading: {}, +``` + +2. Add the reducer case in `reduceServerMessage` (before `default:`): + +```ts + case 'chan:list:result': { + return { + channelList: { ...state.channelList, [msg.networkId]: msg.channels }, + channelListLoading: { ...state.channelListLoading, [msg.networkId]: false }, + } + } +``` + +3. Update the existing `listChannels` action to set the loading flag: + +```ts + listChannels(networkId: number, filter?: string) { + set((state) => ({ + channelListLoading: { ...state.channelListLoading, [networkId]: true }, + })) + _send?.({ type: 'chan:list', networkId, ...(filter ? { filter } : {}) }) + }, +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `pnpm --filter @bool/client test -- src/store/chat-store.test.ts && pnpm --filter @bool/client typecheck` +Expected: PASS (new tests green; typecheck clean — `emptyState` satisfies `ChatState`). + +- [ ] **Step 6: Commit** + +```bash +git add packages/client/src/store/types.ts packages/client/src/store/chat-store.ts packages/client/src/store/chat-store.test.ts +git commit -m "feat(client): channelList state + chan:list:result reducer + loading flag" +``` + +--- + +### Task 5: Author the Direction-A mock (Ralph target) + +**Files:** +- Create: `design/channel-browser.html` + +**Interfaces:** none (static mock; the parity target for the Ralph loop). Must reuse `tokens.css` token *names* inline, default to dark, expose a light toggle, and render: search box, a sortable column control (Members / Name), a virtualized-looking list with **name + topic + member-count** columns, per-row **Join** button, a preview affordance (topic + count), plus **loading** and **empty** states. + +- [ ] **Step 1: Create `design/channel-browser.html`** + +A single self-contained HTML file. Inline CSS must use the same token names as `packages/client/src/styles/tokens.css` (`--bg-0…4`, `--ink-0…3`, `--line`, `--line-2`, `--green`, `--red`, `--blue`, `--on-accent`, `--radius`, `--radius-sm`, `--mono`, `--sans`), defined on `:root` for dark and overridden under `.light`. The light toggle is a button that toggles `document.documentElement.classList`. + +```html + + + + + +bool — Channel Browser (Direction A) + + + + + + +``` + +- [ ] **Step 2: Eyeball it** + +Run: `open design/channel-browser.html` (or view in the Ralph loop browser). Confirm dark default, light toggle works, name+topic+members columns, per-row Join, sort control, and the two commented state blocks read correctly. + +- [ ] **Step 3: Commit** + +```bash +git add design/channel-browser.html +git commit -m "design: Direction-A channel browser mock (Ralph target)" +``` + +--- + +### Task 6: `ChannelBrowser` component + +**Files:** +- Create: `packages/client/src/components/ChannelBrowser.tsx` +- Create: `packages/client/src/components/ChannelBrowser.test.tsx` + +**Interfaces:** +- Produces: `ChannelBrowser({ networkId, onClose }: { networkId: number; onClose: () => void })`. +- Consumes: `useChat` selectors `channelList[networkId]`, `channelListLoading[networkId]`, actions `listChannels(networkId)`, `join(networkId, channel)`, `select(targetKey(...))`; `EmptyState` (WS-0); `Virtuoso` from `react-virtuoso`. + +**Behavior:** +- On mount, if no cached list for `networkId`, call `listChannels(networkId)`. +- Search box filters rows by `channel` **or** `topic` (case-insensitive substring). +- Sort control toggles Members-desc ↔ Name-asc. +- **Loading:** when `channelListLoading[networkId]` and no rows yet → spinner state. +- **Empty:** filtered result empty → `EmptyState` (title "No channels match …" / "No channels found", CTA re-runs `listChannels`). +- **Virtualized:** the rows list uses `` (matches `MessageList.tsx`) so 10k+ channels stay smooth. +- **Join from a result:** each row's "Join" button calls `join(networkId, entry.channel)` then `select(targetKey(networkId, entry.channel))` then `onClose()`. Keyboard-reachable (native ` +
+ ) + + return ( +
+
+ setQuery(e.target.value)} + style={{ flex: 1, fontFamily: 'var(--mono)', fontSize: 13, color: 'var(--ink-0)', + background: 'var(--bg-3)', border: '1px solid var(--line-2)', + borderRadius: 'var(--radius-sm)', padding: '8px 10px', minHeight: 36 }} + /> + +
+ + {loading && rows.length === 0 ? ( +
+
+ ) : filtered.length === 0 ? ( + listChannels(networkId) }} + /> + ) : ( + + )} +
+ ) +} +``` + +Add matching token-driven styles to `packages/client/src/styles/app.css` (no hardcoded colors) — the `.cb-row` grid (`1fr auto auto`), `.cb-main`, `.cb-name` (mono), `.cb-topic` (ellipsis, `--ink-2`), `.cb-members` (right-aligned, `--ink-1`), `.cb-join` (accent bg `--green`, `--on-accent` text, `min-height:28px`), and a `.cb-spinner` keyframe gated behind `@media (prefers-reduced-motion: reduce)`. Mirror the mock in `design/channel-browser.html`. Inline styles above cover the controls; the row styles live in CSS so the grid + hover match the mock. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @bool/client test -- src/components/ChannelBrowser.test.tsx` +Expected: PASS (7 tests). If Virtuoso renders 0 rows under jsdom, apply the `MODE === 'test'` plain-map fallback noted in Step 1 and re-run. + +- [ ] **Step 5: Typecheck** + +Run: `pnpm --filter @bool/client typecheck` +Expected: clean. + +- [ ] **Step 6: Commit** + +```bash +git add packages/client/src/components/ChannelBrowser.tsx packages/client/src/components/ChannelBrowser.test.tsx packages/client/src/styles/app.css +git commit -m "feat(client): ChannelBrowser — searchable/sortable /LIST directory with virtualized rows, preview + join" +``` + +--- + +### Task 7: Refactor `Sidebar.tsx` — drop the "Server" group, add browse + server-log affordances + +**Files:** +- Modify: `packages/client/src/components/Sidebar.tsx` +- Create: `packages/client/src/components/Sidebar.test.tsx` + +**Behavior changes:** +1. **Remove** the `statusTargets` "Server" group block (`Sidebar.tsx:153-172`) from the channel list entirely. +2. Relocate the status buffer to a small **"server log"** button in the network header (`.side-head`), next to `network.host`. It has `aria-label="Server log"`, and on click calls `onSelect(targetKey(network.id, '*'))` (the status target key) so the status buffer is still reachable — just out of the channel list. +3. Add a **"Browse channels"** affordance in the Channels group header (next to the existing `+` join input toggle) that dispatches `window.dispatchEvent(new CustomEvent('bool:open-channel-browser', { detail: { networkId: network.id } }))` — AppShell listens (Task 8). + +- [ ] **Step 1: Write the failing test** + +```tsx +// packages/client/src/components/Sidebar.test.tsx +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { Sidebar } from './Sidebar.js' +import { useChat, targetKey } from '../store/chat-store.js' + +beforeEach(() => useChat.setState(useChat.getInitialState?.() ?? {}, true) as any) + +function seed() { + useChat.setState({ + networks: { 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'me', connected: true } }, + targets: { + [targetKey(1, '#bool')]: { networkId: 1, target: '#bool', kind: 'channel', unread: 0 }, + [targetKey(1, '*')]: { networkId: 1, target: '*', kind: 'status', unread: 0 }, + }, + selected: targetKey(1, '#bool'), + }) +} + +describe('Sidebar', () => { + it('does not render a "Server" status group in the channel list', () => { + seed() + render() + expect(screen.queryByText('Server')).not.toBeInTheDocument() + }) + + it('exposes a server-log affordance in the network header', () => { + seed() + render() + expect(screen.getByRole('button', { name: /server log/i })).toBeInTheDocument() + }) + + it('exposes a browse-channels affordance', () => { + seed() + render() + expect(screen.getByRole('button', { name: /browse channels/i })).toBeInTheDocument() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @bool/client test -- src/components/Sidebar.test.tsx` +Expected: FAIL — "Server" text still present; no server-log / browse buttons. + +- [ ] **Step 3: Edit `Sidebar.tsx`** + +In `NetworkGroup`: +- Delete the entire `statusTargets` block (the `{statusTargets.length > 0 && ( … )}` JSX). Keep the `statusTargets` filter line only if used for the header button count; otherwise remove it too. +- In `.side-head`, after `
{network.host}
`, add a server-log button: + +```tsx + +``` + + (Wrap the `.net-title`/`.net-sub`/button in a flex row so the button sits at the end of the header. `NetworkGroup` already receives `onSelect` and `network`.) + +- In the Channels `grp-h`, next to the existing `+` button, add a browse button: + +```tsx + +``` + + Place it before or after the existing `+` toggle inside the same `grp-h` (adjust `marginLeft: 'auto'` so the two buttons group at the right). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @bool/client test -- src/components/Sidebar.test.tsx` +Expected: PASS (3 tests). + +- [ ] **Step 5: Typecheck + full client suite** + +Run: `pnpm --filter @bool/client typecheck && pnpm --filter @bool/client test` +Expected: all green (no other test depended on the "Server" group text). + +- [ ] **Step 6: Commit** + +```bash +git add packages/client/src/components/Sidebar.tsx packages/client/src/components/Sidebar.test.tsx +git commit -m "refactor(client): drop Sidebar Server-group cram; add server-log + browse-channels affordances" +``` + +--- + +### Task 8: Mount `ChannelBrowser` in `AppShell` and wire the open event + +**Files:** +- Modify: `packages/client/src/components/AppShell.tsx` + +**Behavior:** AppShell listens for `bool:open-channel-browser` (dispatched by the Sidebar browse button and, later, WS-6 `/list`), stores the `networkId` from `event.detail`, and renders `` inside a `Dialog` titled "Browse channels". Close resets state. + +- [ ] **Step 1: Add state + listener + dialog** + +Mirror the existing `networkSettings` pattern (`AppShell.tsx:96-115`). Add: + +```tsx + const [browserNetId, setBrowserNetId] = useState(null) + const closeBrowser = useCallback(() => setBrowserNetId(null), []) + + useEffect(() => { + const onOpen = (e: Event) => { + const detail = (e as CustomEvent).detail as { networkId?: number } | undefined + if (typeof detail?.networkId === 'number') setBrowserNetId(detail.networkId) + } + window.addEventListener('bool:open-channel-browser', onOpen) + return () => window.removeEventListener('bool:open-channel-browser', onOpen) + }, []) +``` + +Render, alongside the existing Network Settings `Dialog`: + +```tsx + + {browserNetId !== null && ( + + )} + +``` + +Add the import: `import { ChannelBrowser } from './ChannelBrowser.js'`. + +- [ ] **Step 2: Typecheck + full client suite** + +Run: `pnpm --filter @bool/client typecheck && pnpm --filter @bool/client test` +Expected: all green. + +- [ ] **Step 3: Commit** + +```bash +git add packages/client/src/components/AppShell.tsx +git commit -m "feat(client): mount ChannelBrowser in a Dialog via bool:open-channel-browser" +``` + +--- + +### Task 9: Full-suite green + protocol version sanity + +**Files:** none (verification). + +- [ ] **Step 1: Run the whole workspace suite** + +Run: `pnpm -r test` +Expected: all packages green (shared, server, client). In particular the `hello`/`welcome` handshake still works with `PROTOCOL_VERSION = 8` — check `packages/server` and `packages/client` don't hardcode `7` anywhere: + +Run: `grep -rn "PROTOCOL_VERSION\|clientVersion" packages/server/src packages/client/src` +Expected: all references import the constant (no literal `7`). If any test asserts the literal `7`, update it to `8`. + +- [ ] **Step 2: Typecheck all** + +Run: `pnpm -r typecheck` +Expected: clean. + +- [ ] **Step 3: Commit any fixes** + +```bash +git add -A +git commit -m "test: keep full suite green after chan:list:result protocol bump" +``` + +--- + +### Task 10: Ralph loop gate (visual + a11y + interaction) + +**REQUIRED SUB-SKILL:** use the `chrome-devtools` and `a11y-debugging` skills. This is the closing gate defined in `2026-07-12-bool-ux-overhaul-INDEX.md` → "The Ralph Loop". **Exit only when visual ≈ mock AND a11y ✓ AND interaction ✓.** Parity target: `design/channel-browser.html`. + +- [ ] **Step 1: Start the client dev server and open a page** + +Run: `pnpm --filter @bool/client dev` (Vite, port 5173). +Then, via chrome-devtools MCP: `new_page` → `navigate_page` to `http://localhost:5173`. + +- [ ] **Step 2: Seed deterministic state (no IRC stack)** + +`evaluate_script`: + +```js +const { useChat, useAppearance } = window.__bool +useAppearance.getState().setTheme('a') +useAppearance.getState().setDensity('compact') +window.__bool.seedDemo() // one connected network + channels + a selection (WS-0) +``` + +Then seed a **fixture LIST result** directly (simulating a `chan:list:result` arrival) so the browser has a large, virtualization-worthy directory: + +```js +const { useChat } = window.__bool +const netId = Number(Object.keys(useChat.getState().networks)[0]) +const channels = Array.from({ length: 2500 }, (_, i) => ({ + channel: '#' + ['bool','linux','rust','general','music','games','ops','dev'][i % 8] + (i > 7 ? '-' + i : ''), + members: Math.max(1, 5000 - i * 2), + topic: i % 5 === 0 ? '' : 'topic for channel ' + i, +})) +useChat.setState({ + channelList: { ...useChat.getState().channelList, [netId]: channels }, + channelListLoading: { ...useChat.getState().channelListLoading, [netId]: false }, +}) +window.dispatchEvent(new CustomEvent('bool:open-channel-browser', { detail: { networkId: netId } })) +``` + +- [ ] **Step 3: Visual parity (desktop + mobile)** + +- `resize_page` 1440×900 → `take_screenshot` → compare against `design/channel-browser.html` (open the mock in a second page). Reconcile: header title, search + sort controls row, row grid (name+topic+members+Join), spacing, accent Join button, dark theme. +- `resize_page` 390×844 → `take_screenshot` → confirm the panel is usable on mobile: controls stack/fit, rows remain legible, Join reachable (44px touch target on mobile). +- Save evidence: `/design/shots/impl-ws5-desktop.png` and `/design/shots/impl-ws5-mobile.png`. + +- [ ] **Step 4: A11y audit (`a11y-debugging` skill)** + +- Keyboard-only: Tab into the Dialog → search input → sort button → first Join button → subsequent rows; confirm focus is visible and never obscured; Esc closes the Dialog and **restores focus** to the browse trigger (Dialog provides trap+restore). +- ARIA: search input has an accessible name ("Search channels"); sort is `aria-pressed`; each Join has a unique accessible name ("Join #bool"); the panel is a `role="dialog"` (from `Dialog`) with a title. +- Contrast: verify 4.5:1 for `--ink-1`/`--ink-2` on row backgrounds across a couple of themes (switch `setTheme('a')` then a light theme via `useAppearance`). +- Targets: Join buttons ≥24px (≥44px at mobile viewport). Reduced motion: with `prefers-reduced-motion`, the loading spinner does not animate. +- Optionally `lighthouse_audit` (a11y category) on the page. + +- [ ] **Step 5: Interaction (drive the real flow)** + +- **Search:** `fill` the search box with `rust` → assert only `#rust*` rows show (`take_snapshot` or `evaluate_script` reading the DOM). +- **Sort:** `click` the sort toggle → assert order flips to name-asc (first visible row name changes). +- **Preview:** confirm each row shows topic + member count inline before joining (visible in snapshot). +- **Loading state:** `evaluate_script` set `channelList` empty + `channelListLoading[netId]=true`, re-open → assert the "Fetching the channel directory…" status shows. +- **Empty state:** restore rows, type `zzz-nope` → assert the `EmptyState` heading ("No channels match …") + Refresh CTA render. +- **Join updates sidebar:** clear the filter, `click` "Join #bool" → assert (a) the Dialog closes, (b) the store now has `#bool` selected and a target for it (`evaluate_script`: `useChat.getState().selected === netId + ':#bool'`), and (c) the sidebar Channels group shows `#bool`. +- **Large-list virtualization:** with 2500 rows, confirm the DOM contains only a windowed subset of `.cb-row` nodes (`evaluate_script`: `document.querySelectorAll('.cb-row').length` is far less than 2500), proving virtualization. +- **Keyboard-complete:** repeat the join flow using only Tab/Enter. +- **Desktop + mobile parity:** re-run the join at 390×844 to confirm it works on mobile. + +- [ ] **Step 6: Reconcile any deltas, then commit evidence** + +Any visual/a11y/interaction failure → fix in code → repeat from Step 1. On exit: + +```bash +git add design/shots/impl-ws5-desktop.png design/shots/impl-ws5-mobile.png +git commit -m "test(ws5): Ralph loop evidence — channel browser visual + a11y + interaction pass" +``` + +--- + +## Self-Review + +- **Prior-art honesty:** the plan explicitly states the request path (`chan:list` → server `LIST`) **already exists**, and the **result path (322/323 → structured `chan:list:result` → client state) does NOT and is added** (Tasks 1–4), based on reading `commands.ts`, `chat-store.ts`, `protocol.ts`, `ws.ts`, `manager.ts`, `connection.ts`, `numerics.ts`. ✓ +- **Spec coverage:** delivers spec §1.5 (rank by member count, filter, preview topic+count before joining, join from results), §1.6 (EmptyState), §4 WS-5 (browser + server `/LIST` plumbing + drop the "Server" group + relocate status buffer), §6 (tokens, a11y baseline, virtualized large lists, live fetch resolving §8 Q3). ✓ +- **Exact signatures:** consumes chat-store `join(networkId, channel)`, `select(targetKey(...))`, `listChannels(networkId, filter?)` verbatim from `store/types.ts`; consumes WS-0 `EmptyState`; renders inside the existing `Dialog` (focus trap+restore reused, not re-implemented). ✓ +- **Conventions:** `.js` relative imports; tokens only (no hardcoded colors); Vitest + RTL client tests mirror `Dialog.test.tsx`/`chat-store.test.ts` style; server tests mirror `connection.test.ts`/`numerics.test.ts`; real code + exact commands + expected output + commit per task; no placeholders. ✓ +- **First task = mock** (`design/channel-browser.html`, Direction A, dark default + light toggle, name/topic/members columns, sort, Join, loading + empty states) as the Ralph target; **final task = Ralph gate** with concrete chrome-devtools MCP steps, `window.__bool.seedDemo()` + an `evaluate_script` LIST fixture, and acceptance covering open→search/sort→preview→join-updates-sidebar, loading + empty states, virtualization, keyboard-completeness, and desktop+mobile parity. ✓ +- **Risk noted:** `react-virtuoso` reports 0-height in jsdom; the component uses `initialItemCount={filtered.length}` (with a documented `MODE==='test'` plain-map fallback) so unit tests render rows deterministically while production stays virtualized. ✓ diff --git a/docs/superpowers/plans/2026-07-12-ws6-command-palette.md b/docs/superpowers/plans/2026-07-12-ws6-command-palette.md new file mode 100644 index 0000000..e9cbd6d --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-ws6-command-palette.md @@ -0,0 +1,1033 @@ +# WS-6 Cmd-K Overhaul — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the command palette into the keyboard home for everything: scope chips that **actually filter** the result set, a proper **ARIA combobox** (focus stays in the input, `aria-activedescendant` tracks a virtual highlight), the **full IRC command set** as runnable palette commands with inline argument hints, **inline shortcut teaching** on action rows, and a **`?` cheatsheet overlay** listing every shortcut and command. + +**Architecture:** The current palette (`CommandPalette.tsx`) is built on [`cmdk`](https://github.com/pacocoursey/cmdk). cmdk already does two things the APG combobox needs — it keeps DOM focus in the input while Arrow keys move a virtual highlight, and it fuzzy-filters items — **but it does NOT emit the APG ARIA attributes**: cmdk's `CommandInput` renders a bare `` (no `role="combobox"`, no `aria-expanded`, no `aria-controls`, no `aria-activedescendant`), and `CommandList`/`CommandItem` carry `cmdk-*` data-attributes rather than `role="listbox"`/`role="option"`. **So this workstream keeps cmdk's filtering/keyboard engine and layers the missing ARIA on top**: wire `role`s and the `aria-*` attributes explicitly, driving `aria-activedescendant` off cmdk's selected-value (`[cmdk-item][data-selected="true"]`). Scope filtering is done by narrowing which `CommandGroup`s/`CommandItem`s render for the active chip. The full IRC command set is added as `CommandItem`s that reuse the existing `parseCommand` → `chat-store` dispatch path (extended in `commands.ts` where a command is missing). A new `ShortcutCheatsheet.tsx` overlay is opened by a global `?` handler. + +**Tech Stack:** React 19 + TypeScript, `cmdk`, Zustand store (`chat-store.ts`), Vitest + React Testing Library + jsdom. Consumes the WS-0 `Kbd` primitive. + +## Global Constraints + +See `2026-07-12-bool-ux-overhaul-INDEX.md` → Global Constraints. Key for this workstream: `.js` import extensions; **no hardcoded colors** (tokens from `styles/tokens.css`; the palette's existing `cp-*` classes already resolve to tokens — keep using them, and reuse the `Kbd` primitive rather than the ad-hoc inline `.cp-kbd`); a11y baseline (keyboard-operable, focus trap + restore, icon+text status, 24px targets, 4.5:1 contrast, `prefers-reduced-motion`); TDD (failing test → minimal impl → green → commit); tests via `pnpm --filter @bool/client test`; keep the full suite green (`pnpm -r test`); work on branch `feat/ux-overhaul`. + +**Depends on:** WS-0 (consumes `Kbd`). **Parallelizable with:** WS-2, WS-4, WS-7. **Mock target:** `design/overlay-command-palette.html` (already exists — do **not** author a new one). + +**Files created:** +- `packages/client/src/components/ShortcutCheatsheet.tsx` + `ShortcutCheatsheet.test.tsx` — the `?` overlay listing every shortcut and command. +- `packages/client/src/palette-commands.ts` + `palette-commands.test.ts` — the single source of truth for the IRC command catalog (name, syntax/arg hint, scope, keywords, and how it maps to a `chat-store` action). Consumed by both the palette and the cheatsheet. + +**Files modified:** +- `packages/client/src/components/CommandPalette.tsx` — scope chips filter; ARIA combobox wiring; render the IRC command catalog with inline `Kbd` hints; open the cheatsheet on `?`. +- `packages/client/src/components/CommandPalette.test.tsx` — extend with scope-filter, ARIA, and command-dispatch tests (keep existing tests green). +- `packages/client/src/commands.ts` + `commands.test.ts` — extend the parser with the missing commands (`/ban /unban /op /deop /voice /devoice /query /notice /away /back /invite /names`). +- `packages/client/src/store/chat-store.ts` + a store test — add the small store actions the new commands need (`notice`, `setAway`, `invite`, `names`; ban/unban/op/deop/voice/devoice reuse the existing `setMode`; `query` reuses `messageUser`/`select`). + +**Interfaces produced (consumed within this workstream + by WS-8 a11y sweep):** +- `PALETTE_COMMANDS: PaletteCommand[]` and `type PaletteCommand = { id: string; name: string; syntax: string; scope: 'Commands'; keywords: string[]; run: (ctx: PaletteRunContext) => void }` from `palette-commands.ts`. +- `type PaletteRunContext = { networkId: number | null; target: string | null; isChannel: boolean; store: ReturnType; prefill: (text: string) => void; close: () => void }`. +- `ShortcutCheatsheet(props: { open: boolean; onClose: () => void })`. + +**Interfaces consumed:** +- **WS-0 `Kbd`** — `Kbd({ keys: string[] })` from `components/primitives/Kbd.js`. Used for every inline shortcut hint on action rows and throughout the cheatsheet. **Replaces** the ad-hoc local `Kbd`/`.cp-kbd` currently defined inside `CommandPalette.tsx`. +- `parseCommand`, `normalizeChannel`, `ParsedCommand` from `commands.js`. +- `useChat` (store actions) from `store/chat-store.js`; `useAppearance` from `theme.js`. +- `seedDemo` from `dev-seed.js` (WS-0) — for the Ralph loop. + +**Verified current-state facts this plan relies on:** +- `CommandPalette.tsx` uses cmdk (`CommandRoot`/`CommandInput`/`CommandList`/`CommandGroup`/`CommandItem`/`CommandEmpty`) with a controlled `query`/`onValueChange`; scope chips are a static `['All','Channels','People','Actions']` `role="group"` with the first hard-coded `active` — **decorative only**. +- The palette defines its own local `Kbd` (renders `.cp-kbd`); this is replaced by the WS-0 primitive. +- `commands.ts` `parseCommand` handles: `/me /join /part /msg /query(→msg) /nick /topic /whois /kick /mode /list`; `ParsedCommand` union covers those. `normalizeChannel` prefixes `#`. +- `chat-store.ts` actions that exist: `join(networkId, channel)`, `part(networkId, channel)`, `messageUser(networkId, nick, text)`, `setNick(networkId, nick)`, `setTopic(networkId, channel, topic)`, `whois(networkId, nick)`, `kick(networkId, channel, nick, reason?)`, `setMode(networkId, target, modes, args?)`, `listChannels(networkId, filter?)`, `connectNetwork(id)`, `select(key)`. Each `_send`s a `chat:...`/`user:...`/`chan:...` WS message via the shared protocol. +- Composer already dispatches parsed commands through those actions (`Composer.tsx` `dispatchInput`) — the palette will reuse the **same** mapping so command behavior stays consistent. +- The mock (`design/overlay-command-palette.html`) shows scope chips (`All / Channels / People / Actions / Recent`), section headers, rows with `.kbd` hints, an inline "Join a channel… /join #channel" action row, and a footer with `↑↓ navigate · ↵ select · Tab filter scope`. + +**Design decision — cmdk vs. APG combobox (resolves the spec §1.4 requirement):** +cmdk gives us the *behavior* (focus-stays-in-input + virtual highlight + fuzzy filter) but **not the ARIA**. We therefore **keep cmdk and add the ARIA** (Task 4): set `role="combobox"` + `aria-expanded`/`aria-controls`/`aria-activedescendant` on the input, `role="listbox"` on the list, `role="option"` + stable `id`s on items, and derive `aria-activedescendant` from cmdk's `[data-selected="true"]` item. This is less risk than reimplementing the listbox by hand and satisfies the APG pattern. + +--- + +### Task 1: Extend the slash-command parser with the missing IRC commands + +**Files:** +- Modify: `packages/client/src/commands.ts` +- Modify: `packages/client/src/commands.test.ts` + +**Interfaces:** +- Produces: new `ParsedCommand` variants — `notice`, `away`, `back`, `invite`, `names`, and mode-shortcut variants (`ban`/`unban`/`op`/`deop`/`voice`/`devoice`) surfaced as `{ kind: 'mode', modes, args }` so they reuse the existing `setMode` dispatch. `/query` already parses to `msg`. +- Consumes: nothing new. + +- [ ] **Step 1: Write the failing test** + +Append to `packages/client/src/commands.test.ts`: + +```ts +import { describe, it, expect } from 'vitest' +import { parseCommand } from './commands.js' + +const chan = { activeTarget: '#dev', isChannel: true } +const pm = { activeTarget: 'ada', isChannel: false } + +describe('parseCommand — extended IRC command set (WS-6)', () => { + it('/notice needs a target and text', () => { + expect(parseCommand('/notice ada hi there', chan)).toEqual({ + kind: 'notice', target: 'ada', text: 'hi there', + }) + expect(parseCommand('/notice ada', chan)).toEqual({ + kind: 'error', message: expect.stringContaining('/notice'), + }) + }) + + it('/away sets a message; /back clears it', () => { + expect(parseCommand('/away brb lunch', chan)).toEqual({ kind: 'away', message: 'brb lunch' }) + expect(parseCommand('/away', chan)).toEqual({ kind: 'away' }) + expect(parseCommand('/back', chan)).toEqual({ kind: 'back' }) + }) + + it('/invite needs a nick (channel defaults to the active channel)', () => { + expect(parseCommand('/invite ada', chan)).toEqual({ kind: 'invite', nick: 'ada', channel: '#dev' }) + expect(parseCommand('/invite ada #other', chan)).toEqual({ kind: 'invite', nick: 'ada', channel: '#other' }) + expect(parseCommand('/invite', chan)).toEqual({ kind: 'error', message: expect.stringContaining('/invite') }) + }) + + it('/names lists members (channel-only)', () => { + expect(parseCommand('/names', chan)).toEqual({ kind: 'names', channel: '#dev' }) + expect(parseCommand('/names #other', chan)).toEqual({ kind: 'names', channel: '#other' }) + expect(parseCommand('/names', pm)).toEqual({ kind: 'error', message: expect.stringContaining('/names') }) + }) + + it('mode-shortcut commands compile to a /mode dispatch', () => { + expect(parseCommand('/op ada', chan)).toEqual({ kind: 'mode', modes: '+o', args: 'ada' }) + expect(parseCommand('/deop ada', chan)).toEqual({ kind: 'mode', modes: '-o', args: 'ada' }) + expect(parseCommand('/voice ada', chan)).toEqual({ kind: 'mode', modes: '+v', args: 'ada' }) + expect(parseCommand('/devoice ada', chan)).toEqual({ kind: 'mode', modes: '-v', args: 'ada' }) + expect(parseCommand('/ban ada!*@*', chan)).toEqual({ kind: 'mode', modes: '+b', args: 'ada!*@*' }) + expect(parseCommand('/unban ada!*@*', chan)).toEqual({ kind: 'mode', modes: '-b', args: 'ada!*@*' }) + }) + + it('mode-shortcut commands require a channel and an argument', () => { + expect(parseCommand('/op', chan)).toEqual({ kind: 'error', message: expect.stringContaining('/op') }) + expect(parseCommand('/op ada', pm)).toEqual({ kind: 'error', message: expect.stringContaining('channel') }) + }) + + it('/query still parses to a msg (existing behavior preserved)', () => { + expect(parseCommand('/query ada hello', chan)).toEqual({ kind: 'msg', nick: 'ada', text: 'hello' }) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @bool/client test -- src/commands.test.ts` +Expected: FAIL — the new `notice`/`away`/`back`/`invite`/`names`/mode-shortcut cases are not handled (they hit the `default` → `Unknown command`). + +- [ ] **Step 3: Write minimal implementation** + +In `packages/client/src/commands.ts`, extend the `ParsedCommand` union: + +```ts +export type ParsedCommand = + | { kind: 'text'; text: string } + | { kind: 'action'; text: string } + | { kind: 'join'; channel: string } + | { kind: 'part'; reason?: string } + | { kind: 'msg'; nick: string; text: string } + | { kind: 'nick'; nick: string } + | { kind: 'topic'; topic: string } + | { kind: 'whois'; nick: string } + | { kind: 'kick'; nick: string; reason?: string } + | { kind: 'mode'; modes: string; args?: string } + | { kind: 'list'; filter?: string } + | { kind: 'notice'; target: string; text: string } + | { kind: 'away'; message?: string } + | { kind: 'back' } + | { kind: 'invite'; nick: string; channel: string } + | { kind: 'names'; channel: string } + | { kind: 'error'; message: string } +``` + +Add these `case`s to the `switch` (before `default`). Note `needChannel(name)` and the active channel come from `ctx`; the active channel target is `ctx.activeTarget` when `ctx.isChannel`: + +```ts + case '/notice': { + const idx = rest.indexOf(' ') + if (idx === -1) return { kind: 'error', message: '/notice needs a target and a message' } + return { kind: 'notice', target: rest.slice(0, idx), text: rest.slice(idx + 1).trim() } + } + case '/away': + return rest ? { kind: 'away', message: rest } : { kind: 'away' } + case '/back': + return { kind: 'back' } + case '/invite': { + const parts = rest.split(/\s+/).filter(Boolean) + const nick = parts[0] + if (!nick) return { kind: 'error', message: '/invite needs a nick' } + const channel = parts[1] + ? normalizeChannel(parts[1]) + : ctx.isChannel && ctx.activeTarget + ? ctx.activeTarget + : null + if (!channel) return { kind: 'error', message: '/invite needs a channel' } + return { kind: 'invite', nick, channel } + } + case '/names': { + const channelErr = needChannel('/names') + const channel = rest ? normalizeChannel(rest.split(' ')[0]!) : ctx.activeTarget + if (!channel) return channelErr ?? { kind: 'error', message: '/names needs a channel' } + return { kind: 'names', channel } + } + case '/op': + case '/deop': + case '/voice': + case '/devoice': + case '/ban': + case '/unban': { + const channelErr = needChannel(cmd) + if (channelErr) return channelErr + const arg = rest.split(' ')[0] + if (!arg) return { kind: 'error', message: `${cmd} needs a target` } + const flag = { + '/op': '+o', '/deop': '-o', '/voice': '+v', + '/devoice': '-v', '/ban': '+b', '/unban': '-b', + }[cmd]! + return { kind: 'mode', modes: flag, args: arg } + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @bool/client test -- src/commands.test.ts` +Expected: PASS (existing command tests + the 7 new WS-6 cases). + +- [ ] **Step 5: Commit** + +```bash +git add packages/client/src/commands.ts packages/client/src/commands.test.ts +git commit -m "feat(client): parse the full IRC command set (/notice /away /back /invite /names /op /deop /voice /devoice /ban /unban)" +``` + +--- + +### Task 2: Add the small store actions the new commands dispatch + +**Files:** +- Modify: `packages/client/src/store/chat-store.ts` +- Test: add cases to `packages/client/src/store/chat-store.test.ts` (create if absent — check `ls packages/client/src/store/` first) + +**Interfaces:** +- Produces store actions: `notice(networkId, target, text)`, `setAway(networkId, message?)`, `invite(networkId, nick, channel)`, `names(networkId, channel)`. `ban/unban/op/deop/voice/devoice` reuse `setMode`; `query` reuses `messageUser` + `select`. +- Consumes: the internal `_send` transport already used by every other action. + +- [ ] **Step 0: Confirm the store test file + `_send` capture pattern** + +Run: `ls packages/client/src/store/ && grep -n "notice\|setAway\|invite\|names\|_send\|bindSend\|setSend" packages/client/src/store/chat-store.ts | head` +Expected: shows whether a `chat-store.test.ts` exists and how tests inject/capture `_send` (there is an internal `_send?.(...)` transport). Mirror the existing test's `_send`-capture pattern; if no store test exists, capture sends via the same mechanism the store exposes (e.g. a `setSend`/bind function) — **use the exact hook the store already provides; do not invent one.** + +- [ ] **Step 1: Write the failing test** + +Add (matching the existing `_send`-capture style confirmed in Step 0). Illustrative shape: + +```ts +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { useChat } from './chat-store.js' + +// Capture outgoing WS messages via the store's send hook (name confirmed in Step 0). +let sent: any[] +beforeEach(() => { + sent = [] + // e.g. useChat.getState().__setSend((m) => sent.push(m)) — use the real binder. +}) + +describe('chat-store — WS-6 command actions', () => { + it('notice() sends a chat:send with notice=true', () => { + useChat.getState().notice(1, 'ada', 'heads up') + expect(sent).toContainEqual({ type: 'chat:send', networkId: 1, target: 'ada', text: 'heads up', notice: true }) + }) + it('setAway() sends user:away with the message; empty clears it', () => { + useChat.getState().setAway(1, 'brb') + expect(sent).toContainEqual({ type: 'user:away', networkId: 1, message: 'brb' }) + useChat.getState().setAway(1) + expect(sent).toContainEqual({ type: 'user:away', networkId: 1 }) + }) + it('invite() sends user:invite', () => { + useChat.getState().invite(1, 'ada', '#dev') + expect(sent).toContainEqual({ type: 'user:invite', networkId: 1, nick: 'ada', channel: '#dev' }) + }) + it('names() requests the member list', () => { + useChat.getState().names(1, '#dev') + expect(sent).toContainEqual({ type: 'chan:names', networkId: 1, channel: '#dev' }) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @bool/client test -- src/store/chat-store.test.ts` +Expected: FAIL — `notice`/`setAway`/`invite`/`names` are not functions on the store. + +- [ ] **Step 3: Write minimal implementation** + +In `packages/client/src/store/chat-store.ts`, next to the existing `whois`/`kick`/`setMode` actions: + +```ts + notice(networkId: number, target: string, text: string) { + _send?.({ type: 'chat:send', networkId, target, text, notice: true }) + }, + + setAway(networkId: number, message?: string) { + _send?.({ type: 'user:away', networkId, ...(message ? { message } : {}) }) + }, + + invite(networkId: number, nick: string, channel: string) { + _send?.({ type: 'user:invite', networkId, nick, channel }) + }, + + names(networkId: number, channel: string) { + _send?.({ type: 'chan:names', networkId, channel }) + }, +``` + +> **Protocol note (do this in the same task):** `chat:send` already exists but its schema has no `notice` flag, and `user:away` / `user:invite` are net-new client→server messages; the client-side `chan:names` request also needs a schema. Extend `packages/shared/src/protocol.ts`: add `notice: z.boolean().optional()` to `chatSendSchema`; add `userAwaySchema` (`{ type: 'user:away', networkId, message?: string }`), `userInviteSchema` (`{ type: 'user:invite', networkId, nick, channel }`), and a client-request `chanNamesReqSchema` (`{ type: 'chan:names:req', networkId, channel }` — use `chan:names:req` if `chan:names` is already taken as a server→client event; adjust the store action's `type` string to match). Add each to `clientMessageSchema`'s `discriminatedUnion`. Server handling of these can be a thin passthrough (a follow-up; not required for the palette to be runnable and tested at the store boundary). **Confirm the exact server→client `chan:names` direction before choosing the request type string.** + +Add the four new methods to the store's TypeScript interface/type as well so `typecheck` passes. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @bool/client test -- src/store/chat-store.test.ts && pnpm --filter @bool/shared test` +Expected: PASS (store actions + protocol schemas parse). + +- [ ] **Step 5: Typecheck + commit** + +Run: `pnpm --filter @bool/client typecheck && pnpm --filter @bool/shared typecheck` +Expected: green. + +```bash +git add packages/client/src/store/chat-store.ts packages/client/src/store/chat-store.test.ts packages/shared/src/protocol.ts +git commit -m "feat(client): store actions + protocol for notice/away/invite/names (WS-6 commands)" +``` + +--- + +### Task 3: The IRC command catalog (`palette-commands.ts`) + +**Files:** +- Create: `packages/client/src/palette-commands.ts` +- Test: `packages/client/src/palette-commands.test.ts` + +**Interfaces:** +- Produces: `PALETTE_COMMANDS`, `PaletteCommand`, `PaletteRunContext` (see header). This is the single source of truth consumed by both the palette (Task 5) and the cheatsheet (Task 6), so the two can never drift. +- Consumes: `parseCommand` from `commands.js`; store action names. + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/client/src/palette-commands.test.ts +import { describe, it, expect, vi } from 'vitest' +import { PALETTE_COMMANDS } from './palette-commands.js' +import type { PaletteRunContext } from './palette-commands.js' + +const CMDS = [ + '/join', '/part', '/msg', '/me', '/nick', '/topic', '/whois', '/kick', + '/ban', '/unban', '/mode', '/query', '/notice', '/away', '/back', + '/invite', '/list', '/names', '/op', '/deop', '/voice', '/devoice', +] + +function makeCtx(store: Record): PaletteRunContext { + return { + networkId: 1, target: '#dev', isChannel: true, + store: store as any, prefill: vi.fn(), close: vi.fn(), + } +} + +describe('PALETTE_COMMANDS', () => { + it('includes every required IRC command exactly once', () => { + const names = PALETTE_COMMANDS.map((c) => c.name) + for (const c of CMDS) expect(names).toContain(c) + // no duplicates + expect(new Set(names).size).toBe(names.length) + }) + + it('every command has a syntax/arg hint and is scoped to Commands', () => { + for (const c of PALETTE_COMMANDS) { + expect(c.syntax.length).toBeGreaterThan(0) + expect(c.scope).toBe('Commands') + expect(typeof c.run).toBe('function') + } + }) + + it('/topic runs store.setTopic with the active channel and prefilled text', () => { + // Commands that take free-form args prefill the composer/input; ones that are + // directly runnable dispatch. /topic with no arg should prefill "/topic ". + const setTopic = vi.fn() + const prefill = vi.fn() + const cmd = PALETTE_COMMANDS.find((c) => c.name === '/topic')! + cmd.run({ networkId: 1, target: '#dev', isChannel: true, + store: { setTopic } as any, prefill, close: vi.fn() }) + // With no argument captured in the palette, /topic teaches by prefilling the input. + expect(prefill).toHaveBeenCalledWith('/topic ') + }) + + it('/names is directly runnable and calls store.names', () => { + const names = vi.fn() + const close = vi.fn() + const cmd = PALETTE_COMMANDS.find((c) => c.name === '/names')! + cmd.run({ networkId: 1, target: '#dev', isChannel: true, + store: { names } as any, prefill: vi.fn(), close }) + expect(names).toHaveBeenCalledWith(1, '#dev') + expect(close).toHaveBeenCalled() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @bool/client test -- src/palette-commands.test.ts` +Expected: FAIL — cannot resolve `./palette-commands.js`. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// packages/client/src/palette-commands.ts +import type { useChat } from './store/chat-store.js' + +export interface PaletteRunContext { + networkId: number | null + target: string | null + isChannel: boolean + store: ReturnType + prefill: (text: string) => void // put "/cmd " into the input so the user completes args + close: () => void +} + +export interface PaletteCommand { + id: string + name: string // "/topic" + syntax: string // "/topic " (inline arg hint, shown as the row subtitle) + scope: 'Commands' + keywords: string[] + run: (ctx: PaletteRunContext) => void +} + +// Commands that need free-form arguments prefill the input (teaching the syntax); +// argument-free / channel-context commands dispatch immediately and close. +const prefill = (text: string) => (c: PaletteRunContext) => c.prefill(text) + +export const PALETTE_COMMANDS: PaletteCommand[] = [ + { id: 'cmd-join', name: '/join', syntax: '/join <#channel>', scope: 'Commands', + keywords: ['join', 'channel', 'enter'], run: prefill('/join #') }, + { id: 'cmd-part', name: '/part', syntax: '/part [reason]', scope: 'Commands', + keywords: ['part', 'leave', 'close'], + run: (c) => { if (c.networkId && c.target) c.store.part(c.networkId, c.target); c.close() } }, + { id: 'cmd-msg', name: '/msg', syntax: '/msg ', scope: 'Commands', + keywords: ['msg', 'message', 'dm', 'pm'], run: prefill('/msg ') }, + { id: 'cmd-me', name: '/me', syntax: '/me ', scope: 'Commands', + keywords: ['me', 'action', 'emote'], run: prefill('/me ') }, + { id: 'cmd-nick', name: '/nick', syntax: '/nick ', scope: 'Commands', + keywords: ['nick', 'rename', 'name'], run: prefill('/nick ') }, + { id: 'cmd-topic', name: '/topic', syntax: '/topic ', scope: 'Commands', + keywords: ['topic', 'subject'], run: prefill('/topic ') }, + { id: 'cmd-whois', name: '/whois', syntax: '/whois ', scope: 'Commands', + keywords: ['whois', 'info', 'user'], run: prefill('/whois ') }, + { id: 'cmd-kick', name: '/kick', syntax: '/kick [reason]', scope: 'Commands', + keywords: ['kick', 'remove'], run: prefill('/kick ') }, + { id: 'cmd-ban', name: '/ban', syntax: '/ban ', scope: 'Commands', + keywords: ['ban', 'block'], run: prefill('/ban ') }, + { id: 'cmd-unban', name: '/unban', syntax: '/unban ', scope: 'Commands', + keywords: ['unban', 'allow'], run: prefill('/unban ') }, + { id: 'cmd-mode', name: '/mode', syntax: '/mode ', scope: 'Commands', + keywords: ['mode', 'flags'], run: prefill('/mode ') }, + { id: 'cmd-query', name: '/query', syntax: '/query ', scope: 'Commands', + keywords: ['query', 'dm', 'pm', 'message'], run: prefill('/query ') }, + { id: 'cmd-notice', name: '/notice', syntax: '/notice ', scope: 'Commands', + keywords: ['notice'], run: prefill('/notice ') }, + { id: 'cmd-away', name: '/away', syntax: '/away [message]', scope: 'Commands', + keywords: ['away', 'afk'], run: prefill('/away ') }, + { id: 'cmd-back', name: '/back', syntax: '/back', scope: 'Commands', + keywords: ['back', 'here', 'return'], + run: (c) => { if (c.networkId) c.store.setAway(c.networkId); c.close() } }, + { id: 'cmd-invite', name: '/invite', syntax: '/invite [#channel]', scope: 'Commands', + keywords: ['invite'], run: prefill('/invite ') }, + { id: 'cmd-list', name: '/list', syntax: '/list [filter]', scope: 'Commands', + keywords: ['list', 'channels', 'browse'], + run: (c) => { if (c.networkId) c.store.listChannels(c.networkId); c.close() } }, + { id: 'cmd-names', name: '/names', syntax: '/names [#channel]', scope: 'Commands', + keywords: ['names', 'members', 'users'], + run: (c) => { if (c.networkId && c.target) c.store.names(c.networkId, c.target); c.close() } }, + { id: 'cmd-op', name: '/op', syntax: '/op ', scope: 'Commands', + keywords: ['op', 'operator'], run: prefill('/op ') }, + { id: 'cmd-deop', name: '/deop', syntax: '/deop ', scope: 'Commands', + keywords: ['deop'], run: prefill('/deop ') }, + { id: 'cmd-voice', name: '/voice', syntax: '/voice ', scope: 'Commands', + keywords: ['voice'], run: prefill('/voice ') }, + { id: 'cmd-devoice', name: '/devoice', syntax: '/devoice ', scope: 'Commands', + keywords: ['devoice'], run: prefill('/devoice ') }, +] +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @bool/client test -- src/palette-commands.test.ts` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/client/src/palette-commands.ts packages/client/src/palette-commands.test.ts +git commit -m "feat(client): IRC command catalog (single source for palette + cheatsheet)" +``` + +--- + +### Task 4: Make scope chips filter + wire the APG combobox ARIA + +**Files:** +- Modify: `packages/client/src/components/CommandPalette.tsx` +- Modify: `packages/client/src/components/CommandPalette.test.tsx` + +**Interfaces:** +- Consumes: WS-0 `Kbd` (`components/primitives/Kbd.js`) — replace the local `Kbd`. + +- [ ] **Step 1: Write the failing tests** (append to `CommandPalette.test.tsx`) + +```tsx +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent, within } from '@testing-library/react' +import { CommandPalette } from './CommandPalette.js' +// (reuse the existing seedStore()/beforeEach in this file) + +describe('CommandPalette — scope chips filter (WS-6)', () => { + it('chips are tabs/buttons and default to All', () => { + seedStore() + render( {}} />) + const chips = screen.getByRole('group', { name: /filter scope/i }) + expect(within(chips).getByRole('button', { name: 'All' })).toHaveAttribute('aria-pressed', 'true') + // the five scopes exist + for (const s of ['All', 'Channels', 'People', 'Actions', 'Commands']) { + expect(within(chips).getByRole('button', { name: s })).toBeInTheDocument() + } + }) + + it('selecting the Channels chip hides People (DMs) and Commands', () => { + seedStore() + render( {}} />) + fireEvent.click(screen.getByRole('button', { name: 'Channels' })) + expect(screen.getByText('#python')).toBeInTheDocument() // channel kept + expect(screen.queryByText('alice')).not.toBeInTheDocument() // DM hidden + expect(screen.queryByText('/topic')).not.toBeInTheDocument() // command hidden + }) + + it('selecting the People chip shows DMs and hides channels', () => { + seedStore() + render( {}} />) + fireEvent.click(screen.getByRole('button', { name: 'People' })) + expect(screen.getByText('alice')).toBeInTheDocument() + expect(screen.queryByText('#python')).not.toBeInTheDocument() + }) + + it('selecting the Commands chip shows every IRC command and hides channels/DMs', () => { + seedStore() + render( {}} />) + fireEvent.click(screen.getByRole('button', { name: 'Commands' })) + expect(screen.getByText('/topic')).toBeInTheDocument() + expect(screen.getByText('/kick')).toBeInTheDocument() + expect(screen.queryByText('#python')).not.toBeInTheDocument() + expect(screen.queryByText('alice')).not.toBeInTheDocument() + }) +}) + +describe('CommandPalette — ARIA combobox (WS-6, W3C APG)', () => { + it('input is a combobox wired to the listbox', () => { + seedStore() + render( {}} />) + const input = screen.getByRole('combobox') + expect(input).toHaveAttribute('aria-expanded', 'true') + const listId = input.getAttribute('aria-controls') + expect(listId).toBeTruthy() + const list = document.getElementById(listId!) + expect(list).toHaveAttribute('role', 'listbox') + }) + + it('result rows are options with ids, and arrows move aria-activedescendant while focus stays in the input', () => { + seedStore() + render( {}} />) + const input = screen.getByRole('combobox') as HTMLInputElement + expect(document.activeElement).toBe(input) + const options = screen.getAllByRole('option') + expect(options.length).toBeGreaterThan(0) + options.forEach((o) => expect(o.id).toBeTruthy()) + // ArrowDown moves the virtual highlight; DOM focus must NOT leave the input. + fireEvent.keyDown(input, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(input) + const active = input.getAttribute('aria-activedescendant') + expect(active).toBeTruthy() + expect(document.getElementById(active!)).toHaveAttribute('role', 'option') + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm --filter @bool/client test -- src/components/CommandPalette.test.tsx` +Expected: FAIL — chips are static ``s (no `role="button"`/`aria-pressed`), no `role="combobox"`/`listbox"`/`option"`, no `aria-activedescendant`. + +- [ ] **Step 3: Implement — scope state + chip buttons + ARIA wiring** + +In `CommandPalette.tsx`: + +1. **Remove the local `Kbd`** and import the WS-0 primitive: + ```ts + import { Kbd } from './primitives/Kbd.js' + ``` +2. **Scope state:** + ```ts + type Scope = 'All' | 'Channels' | 'People' | 'Actions' | 'Commands' + const [scope, setScope] = useState('All') + // reset scope on open, alongside the existing query reset: + if (open && !wasOpen.current) { setQuery(''); setScope('All') } + const showChannels = scope === 'All' || scope === 'Channels' + const showPeople = scope === 'All' || scope === 'People' + const showActions = scope === 'All' || scope === 'Actions' + const showCommands = scope === 'All' || scope === 'Commands' + ``` +3. **Chip row → buttons** (replace the decorative ``s): + ```tsx +
+ {(['All','Channels','People','Actions','Commands'] as const).map((chip) => ( + + ))} +
+ ``` +4. **Gate each group** on the scope flag: wrap the Channels `CommandGroup` in `{showChannels && (…)}`, People in `{showPeople && (…)}`, the density/theme/search/sign-out group in `{showActions && (…)}`, and (Task 5) the Commands group in `{showCommands && (…)}`. The inline "Join" group counts as an action → gate on `showActions || showCommands` as appropriate (keep visible under All). +5. **APG ARIA on the input** (cmdk's `CommandInput` forwards arbitrary props to the underlying ``), and give the list a stable id: + ```tsx + const listId = useId() + const [activeId, setActiveId] = useState(null) + // … + + // … + + ``` +6. **`role="option"` + stable ids on items + track activedescendant.** cmdk marks the current item with `data-selected="true"`; mirror that into `aria-activedescendant`. Give every `CommandItem` `role="option"` and an `id`, and after each render/keystroke read the selected item's id: + ```tsx + const rootRef = useRef(null) + useEffect(() => { + const el = rootRef.current?.querySelector('[cmdk-item][data-selected="true"]') + setActiveId(el?.id ?? null) + }) + // on ; on each + ``` + Use a deterministic `optionId(value)` helper (e.g. `` `cp-opt-${value.replace(/[^a-z0-9]+/gi,'-')}` ``) so tests can resolve `getElementById(activeId)`. + +> If cmdk's typings reject `role`/`aria-*` on `CommandItem`/`CommandInput`, spread them via a typed `{...({ role: 'option', id } as any)}` shim **only where the DOM attribute is correct** — the attributes land on the real elements. Keep the `as any` local and commented. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm --filter @bool/client test -- src/components/CommandPalette.test.tsx` +Expected: PASS — including the pre-existing tests (channels/DMs render, ESC closes, inline join, theme drift guard) **and** the new scope-filter + ARIA tests. + +- [ ] **Step 5: Typecheck + commit** + +Run: `pnpm --filter @bool/client typecheck` +Expected: green. + +```bash +git add packages/client/src/components/CommandPalette.tsx packages/client/src/components/CommandPalette.test.tsx +git commit -m "feat(client): scope chips filter results + APG combobox ARIA (aria-activedescendant, focus stays in input)" +``` + +--- + +### Task 5: Render the IRC command catalog with inline shortcut hints; dispatch verified + +**Files:** +- Modify: `packages/client/src/components/CommandPalette.tsx` +- Modify: `packages/client/src/components/CommandPalette.test.tsx` + +**Interfaces:** +- Consumes: `PALETTE_COMMANDS`/`PaletteRunContext` (Task 3); WS-0 `Kbd`. + +- [ ] **Step 1: Write the failing tests** (append) + +```tsx +describe('CommandPalette — IRC commands are runnable (WS-6)', () => { + it('renders a Commands group listing every catalog command with its syntax hint', () => { + seedStore() + render( {}} />) + expect(screen.getByText('/topic')).toBeInTheDocument() + expect(screen.getByText('/topic ')).toBeInTheDocument() // inline arg hint + expect(screen.getByText('/whois')).toBeInTheDocument() + }) + + it('running /names dispatches store.names for the active channel and closes', () => { + seedStore() + // make #python the active channel + useChat.getState().select(targetKey(1, '#python')) + const namesSpy = vi.spyOn(useChat.getState(), 'names').mockImplementation(() => {}) + const onClose = vi.fn() + render() + fireEvent.click(screen.getByText('/names')) + expect(namesSpy).toHaveBeenCalledWith(1, '#python') + expect(onClose).toHaveBeenCalled() + namesSpy.mockRestore() + }) + + it('running /topic (needs an argument) prefills the input rather than dispatching', () => { + seedStore() + useChat.getState().select(targetKey(1, '#python')) + render( {}} />) + fireEvent.click(screen.getByText('/topic')) + const input = screen.getByRole('combobox') as HTMLInputElement + expect(input.value).toBe('/topic ') + }) + + it('teaches the palette shortcut inline on an action row via Kbd', () => { + seedStore() + render( {}} />) + // Search row carries the ⌘⇧F hint rendered inside elements (WS-0 Kbd) + const kbds = screen.getAllByText((_t, el) => el?.tagName.toLowerCase() === 'kbd') + expect(kbds.length).toBeGreaterThan(0) + }) +}) +``` + +- [ ] **Step 2: Run to verify fail** + +Run: `pnpm --filter @bool/client test -- src/components/CommandPalette.test.tsx` +Expected: FAIL — no Commands group yet; `/topic` etc. absent. + +- [ ] **Step 3: Implement — Commands group** + +In `CommandPalette.tsx`, build a run context and render the catalog inside a scope-gated group: + +```tsx +import { PALETTE_COMMANDS } from '../palette-commands.js' + +// derive network/target/isChannel from `selected` (same logic Composer uses) +const nid = selected ? Number(selected.slice(0, selected.indexOf(':'))) : null +const curTarget = selected ? selected.slice(selected.indexOf(':') + 1) : null +const isChannel = selected != null && targets[selected]?.kind === 'channel' + +function runCommand(cmd: (typeof PALETTE_COMMANDS)[number]) { + cmd.run({ + networkId: nid, target: curTarget, isChannel, + store: useChat.getState(), + prefill: (text) => setQuery(text), // teaches syntax; user completes args + Enter re-dispatches via Composer parity + close: onClose, + }) +} +``` + +```tsx +{showCommands && ( + + {PALETTE_COMMANDS.map((cmd) => ( + runCommand(cmd)} + className="cp-item" + > + / + + {cmd.name} + {cmd.syntax} + + + ))} + +)} +``` + +Also convert the existing action-row shortcut hints to the WS-0 `Kbd` (already imported in Task 4): the Search row keeps ``, density keeps ``, and add `` to a new "Keyboard shortcuts" action row (Task 6) so shortcuts are taught inline. + +> **Prefill semantics:** when a command needs free-form args, `prefill` writes `"/cmd "` into the palette input (teaching the syntax). Pressing Enter on a slash-prefixed input runs it through the same `parseCommand` path the Composer uses. If wiring Enter-to-dispatch inside the palette is out of scope for this pass, `prefill` may instead push the text into the composer and close — **pick one and keep it consistent; the test asserts `input.value === '/topic '`, i.e. the palette-input prefill variant.** + +- [ ] **Step 4: Run to verify pass** + +Run: `pnpm --filter @bool/client test -- src/components/CommandPalette.test.tsx` +Expected: PASS (commands render with hints; `/names` dispatches; `/topic` prefills; inline `Kbd` present). + +- [ ] **Step 5: Full client suite + typecheck + commit** + +Run: `pnpm --filter @bool/client typecheck && pnpm --filter @bool/client test` +Expected: all green (existing + new). + +```bash +git add packages/client/src/components/CommandPalette.tsx packages/client/src/components/CommandPalette.test.tsx +git commit -m "feat(client): full IRC command set in Cmd-K with inline arg hints + Kbd shortcut teaching" +``` + +--- + +### Task 6: `ShortcutCheatsheet` overlay, opened by `?` + +**Files:** +- Create: `packages/client/src/components/ShortcutCheatsheet.tsx` +- Test: `packages/client/src/components/ShortcutCheatsheet.test.tsx` +- Modify: `packages/client/src/components/CommandPalette.tsx` (add a global `?` handler + a "Keyboard shortcuts" action row that opens it) + +**Interfaces:** +- Produces: `ShortcutCheatsheet({ open, onClose })`. +- Consumes: WS-0 `Kbd`; `PALETTE_COMMANDS` (so the command list can't drift); `useFocusRestore` from `Dialog.js`. + +- [ ] **Step 1: Write the failing test** + +```tsx +// packages/client/src/components/ShortcutCheatsheet.test.tsx +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent, renderHook, act } from '@testing-library/react' +import { ShortcutCheatsheet, useCheatsheetShortcut } from './ShortcutCheatsheet.js' +import { PALETTE_COMMANDS } from '../palette-commands.js' + +describe('ShortcutCheatsheet', () => { + it('is a labelled modal dialog listing keyboard shortcuts', () => { + render( {}} />) + const dialog = screen.getByRole('dialog', { name: /keyboard shortcuts|cheatsheet/i }) + expect(dialog).toHaveAttribute('aria-modal') + // The palette shortcut is documented, rendered inside . + expect(screen.getAllByText((_t, el) => el?.tagName.toLowerCase() === 'kbd').length).toBeGreaterThan(0) + }) + + it('lists every command from the catalog (no drift)', () => { + render( {}} />) + for (const c of PALETTE_COMMANDS) { + expect(screen.getByText(c.name)).toBeInTheDocument() + } + }) + + it('closes on Escape', () => { + const onClose = vi.fn() + render() + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) + expect(onClose).toHaveBeenCalled() + }) + + it('renders nothing when closed', () => { + render( {}} />) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) +}) + +describe('useCheatsheetShortcut', () => { + it('opens on "?" when not typing in an input/textarea', () => { + const { result } = renderHook(() => useCheatsheetShortcut()) + expect(result.current[0]).toBe(false) + act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: '?' })) }) + expect(result.current[0]).toBe(true) + }) + + it('does NOT open when the event target is an input', () => { + const { result } = renderHook(() => useCheatsheetShortcut()) + const input = document.createElement('input') + document.body.appendChild(input) + input.focus() + act(() => { + input.dispatchEvent(new KeyboardEvent('keydown', { key: '?', bubbles: true })) + }) + expect(result.current[0]).toBe(false) + input.remove() + }) +}) +``` + +- [ ] **Step 2: Run to verify fail** + +Run: `pnpm --filter @bool/client test -- src/components/ShortcutCheatsheet.test.tsx` +Expected: FAIL — cannot resolve `./ShortcutCheatsheet.js`. + +- [ ] **Step 3: Implement** + +```tsx +// packages/client/src/components/ShortcutCheatsheet.tsx +import { useState, useEffect, useCallback } from 'react' +import { Kbd } from './primitives/Kbd.js' +import { useFocusRestore } from './Dialog.js' +import { PALETTE_COMMANDS } from '../palette-commands.js' + +// Global "?" opener (ignored while typing in a field). +export function useCheatsheetShortcut(): [boolean, () => void, () => void] { + const [open, setOpen] = useState(false) + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if (e.key !== '?') return + const t = e.target as HTMLElement | null + const tag = t?.tagName.toLowerCase() + if (tag === 'input' || tag === 'textarea' || t?.isContentEditable) return + e.preventDefault() + setOpen(true) + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, []) + return [open, () => setOpen(true), () => setOpen(false)] +} + +const SHORTCUTS: { keys: string[]; label: string }[] = [ + { keys: ['⌘', 'K'], label: 'Open the command palette' }, + { keys: ['⌘', '⇧', 'F'], label: 'Search all message history' }, + { keys: ['⌘', 'D'], label: 'Toggle density (compact ⇄ comfortable)' }, + { keys: ['?'], label: 'Open this cheatsheet' }, + { keys: ['Esc'], label: 'Close the palette / overlay' }, + { keys: ['↑', '↓'], label: 'Move the highlighted result' }, + { keys: ['↵'], label: 'Run the highlighted result' }, +] + +export function ShortcutCheatsheet({ open, onClose }: { open: boolean; onClose: () => void }) { + useFocusRestore(open) + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); onClose() } }, + [onClose], + ) + if (!open) return null + return ( + <> +
+
+

Keyboard shortcuts

+
    + {SHORTCUTS.map((s) => ( +
  • + {s.label} + +
  • + ))} +
+

Commands

+
    + {PALETTE_COMMANDS.map((c) => ( +
  • + {c.name} + {c.syntax} +
  • + ))} +
+
+ + ) +} +``` + +Add token-driven styles for `.cheat-*` to the palette's stylesheet (reuse `--bg-*`/`--ink-*`/`--line`/`--radius`/`--mono` — **no hardcoded colors**; min 24px row targets). Locate the existing `.cp-*` rules (grep `cp-palette` under `packages/client/src`) and add the `.cheat-*` block alongside them. + +- [ ] **Step 4: Wire into the palette / app** + +- In `CommandPalette.tsx`, add a "Keyboard shortcuts" action row (under `showActions`) that calls a passed-in `onOpenCheatsheet` prop (or dispatches `new CustomEvent('bool:open-cheatsheet')`, mirroring the existing `bool:open-search` pattern), with an inline ``. +- Mount `useCheatsheetShortcut()` + `` in the same place `usePaletteShortcut()`/`` are mounted (grep for `usePaletteShortcut(` to find the host — likely `AppShell.tsx`/`App.tsx`). Confirm the host before editing. + +- [ ] **Step 5: Run to verify pass** + +Run: `pnpm --filter @bool/client test -- src/components/ShortcutCheatsheet.test.tsx` +Expected: PASS (6 tests). + +- [ ] **Step 6: Full client suite + typecheck + commit** + +Run: `pnpm --filter @bool/client typecheck && pnpm --filter @bool/client test` +Expected: all green. + +```bash +git add packages/client/src/components/ShortcutCheatsheet.tsx packages/client/src/components/ShortcutCheatsheet.test.tsx packages/client/src/components/CommandPalette.tsx +git commit -m "feat(client): ? cheatsheet overlay listing every shortcut + command" +``` + +--- + +### Task 7: Ralph loop — parity + a11y + interaction gate + +**Files:** none (verification only); save evidence screenshots. + +> **Prerequisite — mock exists:** the parity target is `design/overlay-command-palette.html` (already present — do **not** author a new one). Read it once to re-confirm the target: scope chips (`All / Channels / People / Actions / …`), section headers, rows with inline `.kbd` hints, an inline join action row, and the footer. + +Follow **The Ralph Loop** in `2026-07-12-bool-ux-overhaul-INDEX.md`. Use the `chrome-devtools` and `a11y-debugging` skills. + +- [ ] **Step 1: Start dev server + open** + +Run: `pnpm --filter @bool/client dev` (Vite, port 5173). +Then `new_page` → `navigate_page` to `http://localhost:5173`. + +- [ ] **Step 2: Seed deterministic state** + +`evaluate_script`: +```js +const { useChat, useAppearance } = window.__bool +useAppearance.getState().setTheme('a') +useAppearance.getState().setDensity('compact') +window.__bool.seedDemo() // WS-0 fixture: Libera.Chat + channels + a DM, one selected +``` + +- [ ] **Step 3: Open the palette + Visual parity** + +Open with `press_key` Cmd/Ctrl-K (or `evaluate_script` toggling `usePaletteShortcut`). +`resize_page` to desktop (1440×900) then mobile (390×844); `take_screenshot` at each. +Compare against `design/overlay-command-palette.html`: chips row present and styled, section headers, rows with inline `Kbd` hints, footer. Reconcile spacing/grouping deltas. Confirm the palette is usable at 390px (no horizontal scroll; 44px touch targets on chips/rows). + +- [ ] **Step 4: A11y audit** (`a11y-debugging` skill) + +- Input has `role="combobox"`, `aria-expanded`, `aria-controls` → the `role="listbox"`; rows are `role="option"` with ids. +- **Focus stays in the input** while ArrowUp/Down move the highlight; `aria-activedescendant` updates to the highlighted option's id (verify via `evaluate_script` reading `document.activeElement` + the input's `aria-activedescendant`). +- Focus trap while open; focus **restores** to the prior element on close (Esc). +- 4.5:1 contrast across themes; 24px/44px targets; `prefers-reduced-motion` disables the pop/fade animations. +- Optionally `lighthouse_audit` (a11y category) → no critical violations. + +- [ ] **Step 5: Interaction — chips filter, commands run, `?` opens cheatsheet** + +- Click **Channels** chip → only channels remain (no DMs/commands). Click **People** → only DMs. Click **Commands** → every `/command` shows with its syntax hint; channels/DMs gone. Assert via `take_snapshot`/`evaluate_script`. +- Type `arch` → results narrow to `#archlinux`; focus never leaves the input. +- Run a representative command: click `/names` (with a channel selected) → assert `chan:names` was dispatched (spy on `window.__bool.useChat.getState()._send` if exposed, or assert via observable store/UI effect); click `/topic` → the input prefills `"/topic "`. +- Close the palette; press `?` (with nothing focused) → the cheatsheet dialog opens, lists shortcuts + every command; Esc closes it and restores focus. + +- [ ] **Step 6: Save evidence + commit** + +Save final screenshots to `design/shots/impl-ws6-desktop.png` and `design/shots/impl-ws6-mobile.png`. + +```bash +git add design/shots/impl-ws6-desktop.png design/shots/impl-ws6-mobile.png +git commit -m "test(client): WS-6 Ralph evidence — chips filter, combobox a11y, commands, cheatsheet (desktop+mobile)" +``` + +**Exit only when:** visual ≈ mock **AND** a11y ✓ (focus stays in input, `aria-activedescendant` tracks, trap+restore, contrast, targets) **AND** interaction ✓ (chips filter; every command runnable; `?` opens the cheatsheet; shortcuts shown inline; desktop + mobile parity). + +- [ ] **Step 7: Final full suite** + +Run: `pnpm -r test` +Expected: client + server + shared all green. + +--- + +## Self-Review + +- **Spec coverage (§1.4 / §4 WS-6 / §6):** scope chips actually filter (Task 4) ✓; ARIA combobox with `aria-activedescendant` and focus-stays-in-input (Task 4) ✓; full IRC command set with inline arg hints — `/join /part /msg /me /nick /topic /whois /kick /ban /unban /mode /query /notice /away /back /invite /list /names /op /deop /voice /devoice` (Tasks 1–3, 5) ✓; inline shortcut teaching via WS-0 `Kbd` (Tasks 4–5) ✓; `?` cheatsheet overlay in new `ShortcutCheatsheet.tsx` (Task 6) ✓; Ralph loop against the existing mock (Task 7) ✓. +- **cmdk decision, grounded in the real file:** cmdk gives focus-in-input + virtual highlight + fuzzy filter but emits **none** of the APG ARIA (`CommandInput` is a bare ``; items carry `cmdk-*` data-attrs, not `role`s). The plan therefore **keeps cmdk and adds** `role="combobox"`/`aria-expanded`/`aria-controls`/`aria-activedescendant` + `role="listbox"`/`role="option"`, deriving `aria-activedescendant` from cmdk's `[data-selected="true"]`. ✓ +- **Interfaces:** consumes WS-0 `Kbd` by name (replacing the ad-hoc local `Kbd`/`.cp-kbd`); produces `PALETTE_COMMANDS`/`PaletteCommand`/`PaletteRunContext` (shared by palette + cheatsheet so they can't drift) and `ShortcutCheatsheet`. ✓ +- **Grounded in reality:** every store action mapped to one that exists (`join/part/setTopic/whois/kick/setMode/listChannels/messageUser/select`) or a small new one (`notice/setAway/invite/names`) with the protocol extension called out; mode-shortcut commands compile to the existing `setMode` path; `/query` reuses the existing `msg` parse. ✓ +- **Format parity with WS-0/WS-1:** exact header block; Global Constraints → INDEX; Files created/modified + Interfaces produced/consumed; right-sized TDD tasks with real Vitest + @testing-library/react code, exact commands, expected output, commit steps; includes the required scope-filter test, combobox-ARIA test, and a representative command-dispatch test (`/names`, plus `/topic` in commands.test.ts); `.js` imports; tokens only; a11y baseline; final Ralph gate referencing INDEX. ✓ +- **Placeholders:** none — the only conditionals are gated behind explicit confirmation steps (store `_send` capture in Task 2 Step 0; cmdk typing shim in Task 4 Step 3; palette host lookup in Task 6 Step 4), each with a concrete `grep`/`ls` to resolve. ✓ +- **Risks noted:** (1) cmdk may need an `as any` shim to accept DOM `role`/`aria-*` — kept local and commented. (2) `notice`/`away`/`invite`/`names` need shared-protocol schemas + eventual server passthrough; the client is tested at the store/dispatch boundary now, server wiring is a small follow-up that does not block the palette being runnable and verified. diff --git a/docs/superpowers/plans/2026-07-12-ws7-shell-polish.md b/docs/superpowers/plans/2026-07-12-ws7-shell-polish.md new file mode 100644 index 0000000..c178f46 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-ws7-shell-polish.md @@ -0,0 +1,891 @@ +# WS-7 Chat View + Shell Polish — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bring the running shell — `MainPane` / `MessageList` / `MessageRow` / `Sidebar` / `MemberList` — to Direction-A mock parity (message grouping, left timestamp gutter, density-token-driven spacing), wrap the message list in the WS-0 `role="log"` announcer, make the layout responsive (collapsible rail + member list, mobile sidebar drawer, 44px touch targets), and add first-class **empty / loading / error** states throughout. + +**Architecture:** The three-pane grid already exists (`.app` in `styles/base.css`: `56px 232px 1fr 200px`; `.members` hides `<1180px` in `styles/app.css`). Message grouping and the timestamp gutter already exist in `MessageList.renderRow` (5-min same-sender threshold → `grouped`) and `MessageRow` (`.msg` full-header rows vs `.cont` continuation rows whose `.gutter` reveals on hover). This workstream **hardens and completes** that foundation rather than rebuilding it: it swaps the ad-hoc empty/loading/error markup for the WS-0 `EmptyState` primitive, wraps the Virtuoso list in the WS-0 `LiveLog` so new messages are announced (`role="log"`, `aria-live="polite"`), extends the responsive CSS with a network-rail toggle + a mobile sidebar drawer + 44px touch targets, and adds explicit loading (history skeleton) and connection-error states. All spacing stays driven by the existing density tokens (`--row-py`, `--group-gap`, `--msg-fs`, `--msg-lh`, `--meta-fs`, `--side-row-h`) in `styles/tokens.css`. + +**Tech Stack:** React 19 + TypeScript, Zustand store (`store/chat-store.ts`), `react-virtuoso`, Vitest + React Testing Library + jsdom. Ralph loop via chrome-devtools MCP. + +## Global Constraints + +See `2026-07-12-bool-ux-overhaul-INDEX.md` → Global Constraints. Key for this workstream: `.js` import extensions on all relative imports; **no hardcoded colors** — spacing driven by density tokens, color/geometry from `styles/tokens.css`; a11y baseline (`role="log"` message list, visible focus, 24px min / 44px touch targets, `prefers-reduced-motion` honored — the `@media (prefers-reduced-motion: reduce)` block already exists at `styles/app.css:1582`); tests via `pnpm --filter @bool/client test`; typecheck via `pnpm --filter @bool/client typecheck`; TDD (failing test → minimal impl → green → commit); commit per task; keep the full client suite green. + +**Depends on:** WS-0 (`LiveLog`, `EmptyState`, `seedDemo`). This plan **consumes** those by name; do not re-implement them. + +**Files created by this workstream:** +- `packages/client/src/components/ConnectionBanner.tsx` + test — connection-error / reconnecting banner using icon+text (extracted & upgraded from the inline `ErrorBanner` in `AppShell.tsx`). +- `packages/client/src/components/MessageList.test.tsx` — role=log + empty-state + grouping coverage (no test exists today). +- `packages/client/src/components/HistorySkeleton.tsx` + test — reduced-motion-aware loading skeleton for history fetches. + +**Files modified by this workstream:** +- `packages/client/src/components/MessageList.tsx` — wrap Virtuoso in `LiveLog`; replace the inline "No messages yet" block with `EmptyState`; add a `loadingHistory` skeleton path. +- `packages/client/src/components/MainPane.tsx` — replace the `.msgs-empty` "Select a channel" block with `EmptyState`; add a mobile "open channels" drawer toggle button + rail/member toggles. +- `packages/client/src/components/MessageRow.tsx` — grouping parity polish (ensure continuation rows omit the nick header; keep density-token spacing). +- `packages/client/src/components/Sidebar.tsx` — mobile drawer semantics (`aria-hidden`/`inert` when closed at narrow widths) + collapsible network rail. +- `packages/client/src/components/MemberList.tsx` — collapsible; `EmptyState` for "no members". +- `packages/client/src/components/AppShell.tsx` — mount `ConnectionBanner`; wire the mobile drawer open/close state and a member-list toggle. +- `packages/client/src/styles/app.css` + `packages/client/src/styles/base.css` — responsive breakpoints (rail collapse, member-list collapse, mobile sidebar drawer), 44px touch targets under `@media (pointer: coarse)`, skeleton styles. + +**Interfaces produced (for WS-8 sweep):** +- `ConnectionBanner()` — self-contained; reads `useChat` `connection` + `lastError`; renders nothing when healthy. +- `HistorySkeleton({ rows?: number })` — placeholder shimmer rows; static under reduced-motion. +- CSS: `.drawer-open` on `.app` (mobile sidebar visible), `.rail-collapsed`, `.members-collapsed` state classes. + +**Interfaces consumed (from WS-0):** +- `LiveLog({ label, children })` from `./primitives/LiveLog.js` — `role="log"`, `aria-live="polite"`, `aria-label={label}`. +- `EmptyState({ title, description?, icon?, action? })` from `./primitives/EmptyState.js`. +- `seedDemo()` / `DEMO_FIXTURE` from `../dev-seed.js` (Ralph loop seeding). + +**Mock parity targets (PNG screenshots — do NOT author new HTML for this workstream):** +- `design/shots/app-a-dark-compact.png` — Direction-A, dark, **compact** density (primary target). +- `design/shots/app-a-dark-comfortable.png` — Direction-A, dark, **comfortable** density. +- `design/shots/app-media.png` — inline media / rich message rows. +- `design/shots/app-rich-features.png` — reactions / replies / join-quit / rich chrome. + +**Store shape (verified — use these exact fields in fixtures/tests):** +- `NetworkState { id: number; name: string; host: string; nick: string; connected: boolean }` +- `TargetState { networkId: number; target: string; kind: 'channel'|'pm'|'status'; unread: number; names?: NameEntry[]; topic?: string }` +- `ChatState.connection: 'connecting'|'open'|'closed'`; `ChatState.messages: Record`; `ChatState.selected: string | null`; `ChatState.lastError: { networkId: number; message: string } | null`. +- `targetKey(networkId, target)` from `../store/chat-store.js` builds the `"1:#chan"` key. +- Reset a store between tests with `useChat.setState(useChat.getInitialState?.() ?? {}, true)` (see existing `Sidebar.test.tsx` / `MainPane.test.tsx`). + +--- + +### Task 1: Record the exact mock filenames to diff against + +**Files:** none (discovery only — the mocks already exist; do not author new HTML). + +- [ ] **Step 1: List the shots directory and record the exact filenames** + +Run: `ls design/shots/` +Expected output includes (record these — they are the Ralph parity targets for the final task): +``` +app-a-dark-comfortable.png +app-a-dark-compact.png +app-media.png +app-rich-features.png +``` +> If any filename differs from the above, use the actual name from `ls` output verbatim in Task 9. Do NOT create or edit any HTML mock — WS-7's targets are these PNG screenshots. + +- [ ] **Step 2: Open the two primary shots to fix the visual target in mind** + +Read (as images): `design/shots/app-a-dark-compact.png` and `design/shots/app-a-dark-comfortable.png`. Note: left timestamp gutter on every row; consecutive same-author messages grouped (full nick header on the first row only, continuation rows share the gutter and reveal the timestamp on hover); density difference between compact and comfortable is spacing only (row padding, group gap, line-height), never a layout change. + +No commit (discovery task). + +--- + +### Task 2: Message list is a `role="log"` live region (wrap Virtuoso in WS-0 `LiveLog`) + +**Files:** +- Create: `packages/client/src/components/MessageList.test.tsx` +- Modify: `packages/client/src/components/MessageList.tsx` + +**Interfaces:** +- Consumes: `LiveLog` from `./primitives/LiveLog.js`. + +- [ ] **Step 1: Write the failing test** + +```tsx +// packages/client/src/components/MessageList.test.tsx +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { useChat, targetKey } from '../store/chat-store.js' +import type { StoredMsg } from '@bool/shared' +import { MessageList } from './MessageList.js' + +function msg(over: Partial & Pick): StoredMsg { + return { kind: 'privmsg', target: '#bool', ...over } as StoredMsg +} + +function seedChannel(msgs: StoredMsg[]) { + const key = targetKey(1, '#bool') + useChat.setState({ + networks: { 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'dmf', connected: true } }, + targets: { [key]: { networkId: 1, target: '#bool', kind: 'channel', unread: 0, names: [] } }, + messages: { [key]: msgs }, + selected: key, + connection: 'open', + }) +} + +beforeEach(() => { + useChat.setState(useChat.getInitialState?.() ?? {}, true) +}) + +describe('MessageList — role=log announcer', () => { + it('wraps messages in a role=log live region with an accessible name', () => { + seedChannel([msg({ id: 'a1', sender: 'ada', body: 'hello', ts: 1_700_000_000_000 })]) + render() + const log = screen.getByRole('log', { name: /messages/i }) + expect(log).toBeInTheDocument() + expect(log).toHaveAttribute('aria-live', 'polite') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @bool/client test -- src/components/MessageList.test.tsx` +Expected: FAIL — no element with `role="log"` (Virtuoso currently renders a plain scroll container). + +- [ ] **Step 3: Wrap the Virtuoso output in `LiveLog`** + +In `MessageList.tsx`, import the primitive and wrap the returned `` so the scroll region is the live region. `LiveLog` renders a `
`; make it fill the pane and host the virtualized list: + +```tsx +import { LiveLog } from './primitives/LiveLog.js' +// ... +return ( + + + +) +``` + +> `LiveLog`'s root div must stretch. Add to `styles/app.css` (token/geometry only, no color): +> ```css +> [role="log"] { flex: 1; display: flex; flex-direction: column; min-height: 0; } +> ``` +> This keeps Virtuoso's `flex: 1` height behavior intact inside the log wrapper. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @bool/client test -- src/components/MessageList.test.tsx` +Expected: PASS (1 test). + +- [ ] **Step 5: Commit** + +```bash +git add packages/client/src/components/MessageList.tsx packages/client/src/components/MessageList.test.tsx packages/client/src/styles/app.css +git commit -m "feat(client): message list is a role=log live region via WS-0 LiveLog" +``` + +--- + +### Task 3: Message grouping parity — continuation rows omit the repeated nick header + +**Files:** +- Modify: `packages/client/src/components/MessageList.test.tsx` (add cases) +- Modify: `packages/client/src/components/MessageRow.tsx` (only if the test exposes a gap) + +**Interfaces:** none new. + +- [ ] **Step 1: Add the failing grouping tests** + +Append to `MessageList.test.tsx`: + +```tsx +describe('MessageList — grouping', () => { + it('shows the nick header on the first message of a run but omits it on the continuation', () => { + const t = 1_700_000_000_000 + seedChannel([ + msg({ id: 'a1', sender: 'ada', body: 'first', ts: t }), + msg({ id: 'a2', sender: 'ada', body: 'second', ts: t + 1000 }), + ]) + const { container } = render() + // The nick "ada" appears exactly once — the continuation row has no header. + const nicks = Array.from(container.querySelectorAll('.msg-head .nick')) + .filter((el) => el.textContent === 'ada') + expect(nicks).toHaveLength(1) + // There is exactly one full row (.msg) and one continuation row (.cont). + expect(container.querySelectorAll('.msg').length).toBe(1) + expect(container.querySelectorAll('.cont').length).toBe(1) + }) + + it('does NOT group when a different sender interleaves', () => { + const t = 1_700_000_000_000 + seedChannel([ + msg({ id: 'a1', sender: 'ada', body: 'hi', ts: t }), + msg({ id: 'b1', sender: 'kai', body: 'yo', ts: t + 1000 }), + ]) + const { container } = render() + expect(container.querySelectorAll('.msg').length).toBe(2) + expect(container.querySelectorAll('.cont').length).toBe(0) + }) + + it('does NOT group when messages are more than 5 minutes apart', () => { + const t = 1_700_000_000_000 + seedChannel([ + msg({ id: 'a1', sender: 'ada', body: 'early', ts: t }), + msg({ id: 'a2', sender: 'ada', body: 'later', ts: t + 6 * 60 * 1000 }), + ]) + const { container } = render() + expect(container.querySelectorAll('.msg').length).toBe(2) + }) +}) +``` + +> Note: `react-virtuoso` renders all items in jsdom (no real viewport), so both rows are queryable. If Virtuoso's jsdom stub renders zero rows in this environment, fall back to unit-testing `MessageRow` directly — render `` and assert `container.querySelector('.msg-head')` is null and the root has class `cont`. Keep whichever variant makes the assertion real. + +- [ ] **Step 2: Run tests to verify status** + +Run: `pnpm --filter @bool/client test -- src/components/MessageList.test.tsx` +Expected: The grouping logic already exists (`MessageList.renderRow` sets `grouped` for same-sender, same-kind, `<5min`; `MessageRow` renders `.cont` without `.msg-head` when `grouped`). These tests should PASS as written. **If any FAIL**, the parity gap is real — fix it: + +- [ ] **Step 3: Fix `MessageRow` only if a test fails** + +If a continuation row still emits a `.msg-head`/`.nick`, ensure the `grouped` privmsg/action branches render `
` with **no** `.msg-head` block (the `action` and `privmsg` grouped branches in `MessageRow.tsx` already do this — verify the `notice` branch and any new branch match). Keep all spacing driven by `.msg`/`.cont` density-token padding in `styles/app.css` (`--row-py`, `--row-gap`); do not hardcode pixels in the component. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm --filter @bool/client test -- src/components/MessageList.test.tsx` +Expected: PASS (all grouping cases + the role=log case from Task 2). + +- [ ] **Step 5: Commit** + +```bash +git add packages/client/src/components/MessageList.test.tsx packages/client/src/components/MessageRow.tsx +git commit -m "test(client): lock message-grouping parity (first row keeps nick, continuations omit it)" +``` + +--- + +### Task 4: "No messages yet" empty state via WS-0 `EmptyState` + +**Files:** +- Modify: `packages/client/src/components/MessageList.tsx` +- Modify: `packages/client/src/components/MessageList.test.tsx` (add case) + +**Interfaces:** +- Consumes: `EmptyState` from `./primitives/EmptyState.js`. + +- [ ] **Step 1: Write the failing test** + +Append to `MessageList.test.tsx`: + +```tsx +describe('MessageList — empty state', () => { + it('shows a "no messages yet" EmptyState for a selected channel with zero messages', () => { + const key = targetKey(1, '#bool') + useChat.setState({ + networks: { 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'dmf', connected: true } }, + targets: { [key]: { networkId: 1, target: '#bool', kind: 'channel', unread: 0, names: [] } }, + messages: { [key]: [] }, + selected: key, + connection: 'open', + }) + render() + expect(screen.getByRole('heading', { name: /no messages yet/i })).toBeInTheDocument() + expect(screen.getByText(/be the first to say something/i)).toBeInTheDocument() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @bool/client test -- src/components/MessageList.test.tsx` +Expected: FAIL — the current empty block is a plain `
` with text "No messages yet" and no `role="heading"`. + +- [ ] **Step 3: Replace the inline empty block with `EmptyState`** + +In `MessageList.tsx`, replace the `if (msgs.length === 0) { return (
No messages yet
) }` block with: + +```tsx +import { EmptyState } from './primitives/EmptyState.js' +// ... +if (msgs.length === 0) { + return ( +
+ +
+ ) +} +``` + +Add to `styles/app.css` (layout only): +```css +.msgs-empty-wrap { flex: 1; display: flex; align-items: center; justify-content: center; } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @bool/client test -- src/components/MessageList.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/client/src/components/MessageList.tsx packages/client/src/components/MessageList.test.tsx packages/client/src/styles/app.css +git commit -m "feat(client): 'no messages yet' empty state via WS-0 EmptyState" +``` + +--- + +### Task 5: "No network selected" empty state in `MainPane` + +**Files:** +- Modify: `packages/client/src/components/MainPane.tsx` +- Create: `packages/client/src/components/MainPane.emptystate.test.tsx` + +**Interfaces:** +- Consumes: `EmptyState` from `./primitives/EmptyState.js`. + +- [ ] **Step 1: Write the failing test** + +```tsx +// packages/client/src/components/MainPane.emptystate.test.tsx +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { useChat } from '../store/chat-store.js' +import { MainPane } from './MainPane.js' + +beforeEach(() => { + useChat.setState(useChat.getInitialState?.() ?? {}, true) +}) + +describe('MainPane — no target selected', () => { + it('renders an EmptyState heading prompting the user to pick a channel', () => { + // selected is null in initial state + render() + expect(screen.getByRole('heading', { name: /pick a channel/i })).toBeInTheDocument() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @bool/client test -- src/components/MainPane.emptystate.test.tsx` +Expected: FAIL — the current no-target branch renders `.msgs-empty` with a `#` symbol and "Select a channel" text, no heading role. + +- [ ] **Step 3: Replace the `.msgs-empty` branch with `EmptyState`** + +In `MainPane.tsx`, replace the `else` branch (`
…Select a channel…
`) with: + +```tsx +import { EmptyState } from './primitives/EmptyState.js' +// ... +) : ( +
+ +
+)} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @bool/client test -- src/components/MainPane.emptystate.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Confirm the existing MainPane test still passes** + +Run: `pnpm --filter @bool/client test -- src/components/MainPane.test.tsx` +Expected: PASS (the ConnDot aria-label test is unaffected). + +- [ ] **Step 6: Commit** + +```bash +git add packages/client/src/components/MainPane.tsx packages/client/src/components/MainPane.emptystate.test.tsx +git commit -m "feat(client): 'pick a channel' empty state in MainPane via WS-0 EmptyState" +``` + +--- + +### Task 6: History-loading skeleton (loading state) + +**Files:** +- Create: `packages/client/src/components/HistorySkeleton.tsx` +- Create: `packages/client/src/components/HistorySkeleton.test.tsx` +- Modify: `packages/client/src/components/MessageList.tsx` (render skeleton while first history page is loading) +- Modify: `packages/client/src/styles/app.css` (skeleton styles + reduced-motion) + +**Interfaces:** +- Produces: `HistorySkeleton({ rows?: number })`. + +- [ ] **Step 1: Write the failing test** + +```tsx +// packages/client/src/components/HistorySkeleton.test.tsx +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { HistorySkeleton } from './HistorySkeleton.js' + +describe('HistorySkeleton', () => { + it('is a busy status region announcing that history is loading', () => { + render() + const status = screen.getByRole('status', { name: /loading/i }) + expect(status).toHaveAttribute('aria-busy', 'true') + }) + + it('renders the requested number of placeholder rows', () => { + const { container } = render() + expect(container.querySelectorAll('.skel-row').length).toBe(5) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @bool/client test -- src/components/HistorySkeleton.test.tsx` +Expected: FAIL — cannot resolve `./HistorySkeleton.js`. + +- [ ] **Step 3: Implement the skeleton (token-styled, reduced-motion aware)** + +```tsx +// packages/client/src/components/HistorySkeleton.tsx +export function HistorySkeleton({ rows = 6 }: { rows?: number }) { + return ( +
+ {Array.from({ length: rows }).map((_, i) => ( + + ))} +
+ ) +} +``` + +Add to `styles/app.css` (colors from tokens; shimmer honored by the existing reduced-motion block at line ~1582, but add an explicit static fallback): +```css +.skel { display: flex; flex-direction: column; gap: var(--group-gap); padding: 10px 16px; } +.skel-row { display: grid; grid-template-columns: 52px 1fr; gap: 0 10px; align-items: center; } +.skel-gutter, .skel-line { + height: var(--msg-fs); + border-radius: var(--radius-sm); + background: linear-gradient(90deg, var(--bg-3), var(--bg-4), var(--bg-3)); + background-size: 200% 100%; + animation: skel-shimmer 1.4s ease-in-out infinite; +} +.skel-gutter { width: 34px; justify-self: end; } +@keyframes skel-shimmer { 0% { background-position: 200% 0 } 100% { background-position: -200% 0 } } +@media (prefers-reduced-motion: reduce) { + .skel-gutter, .skel-line { animation: none; background: var(--bg-3); } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @bool/client test -- src/components/HistorySkeleton.test.tsx` +Expected: PASS (2 tests). + +- [ ] **Step 5: Show the skeleton on first history load in `MessageList`** + +Gate the skeleton on "a target is selected, we have zero messages for it, and a history request is in flight." The store loads history in `loadHistory`; there is no `loadingHistory` flag today, so derive it locally: when `selected` changes and `msgs.length === 0`, show the skeleton until either messages arrive or a short settle timeout elapses. + +```tsx +import { HistorySkeleton } from './HistorySkeleton.js' +// inside MessageList, after computing `msgs`: +const [loadingHistory, setLoadingHistory] = useState(false) +useEffect(() => { + if (!selected) return + if (msgs.length === 0) { + setLoadingHistory(true) + const id = setTimeout(() => setLoadingHistory(false), 4000) // settle: empty channel, not loading + return () => clearTimeout(id) + } + setLoadingHistory(false) + return +}, [selected, msgs.length]) +// render order: skeleton (loading) → EmptyState (settled + empty) → Virtuoso (has messages) +if (msgs.length === 0 && loadingHistory) { + return
+} +``` + +> If WS-0 or a later store change adds an explicit `loadingHistory: Record` to `ChatState`, prefer reading that over the timeout heuristic — swap the `loadingHistory` local for `useChat((s) => s.loadingHistory[selected] ?? false)` and delete the timeout effect. Keep the render order identical. + +- [ ] **Step 6: Typecheck + run the MessageList suite** + +Run: `pnpm --filter @bool/client typecheck && pnpm --filter @bool/client test -- src/components/MessageList.test.tsx` +Expected: PASS. (The empty-state test from Task 4 must still pass — it seeds messages `[]` and, because the settle timeout resolves synchronously under `vi` fake timers only when advanced, verify the test does not spuriously hit the skeleton path. If it does, seed a non-empty message array in the empty-state test's setup and instead assert the settled empty state after `act(() => vi.advanceTimersByTime(4000))`, or keep Task 4's test on the "settled" branch by not enabling fake timers — the timeout runs in a real 4s window that the synchronous render never reaches, so the settled `EmptyState` renders first. Confirm by running the suite.) + +- [ ] **Step 7: Commit** + +```bash +git add packages/client/src/components/HistorySkeleton.tsx packages/client/src/components/HistorySkeleton.test.tsx packages/client/src/components/MessageList.tsx packages/client/src/styles/app.css +git commit -m "feat(client): history-loading skeleton (reduced-motion aware) in MessageList" +``` + +--- + +### Task 7: Connection-error / reconnecting banner (error state + recovery) + +**Files:** +- Create: `packages/client/src/components/ConnectionBanner.tsx` +- Create: `packages/client/src/components/ConnectionBanner.test.tsx` +- Modify: `packages/client/src/components/AppShell.tsx` (mount `ConnectionBanner`; remove the inline `ErrorBanner` in favor of it) + +**Interfaces:** +- Produces: `ConnectionBanner()`. + +- [ ] **Step 1: Write the failing test** + +```tsx +// packages/client/src/components/ConnectionBanner.test.tsx +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { useChat } from '../store/chat-store.js' +import { ConnectionBanner } from './ConnectionBanner.js' + +beforeEach(() => { + useChat.setState(useChat.getInitialState?.() ?? {}, true) +}) + +describe('ConnectionBanner', () => { + it('renders nothing when the connection is open and there is no error', () => { + useChat.setState({ connection: 'open', lastError: null }) + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('shows a reconnecting alert with icon+text when the connection is closed', () => { + useChat.setState({ connection: 'closed', lastError: null }) + render() + const alert = screen.getByRole('alert') + expect(alert).toHaveTextContent(/reconnect/i) + // icon is decorative; the status is also conveyed by text (not color alone) + expect(alert.querySelector('[aria-hidden="true"]')).toBeInTheDocument() + }) + + it('shows the network error message and is dismissible', () => { + useChat.setState({ connection: 'open', lastError: { networkId: 1, message: 'Nick in use' } }) + render() + expect(screen.getByRole('alert')).toHaveTextContent(/nick in use/i) + fireEvent.click(screen.getByRole('button', { name: /dismiss/i })) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @bool/client test -- src/components/ConnectionBanner.test.tsx` +Expected: FAIL — cannot resolve `./ConnectionBanner.js`. + +- [ ] **Step 3: Implement `ConnectionBanner` (icon+text, dismissible, token-styled)** + +Extract and upgrade the inline `ErrorBanner` from `AppShell.tsx`: cover both `connection === 'closed'` (reconnecting) and `lastError` (network error), always pairing an `aria-hidden` icon with text so status is never color-only. + +```tsx +// packages/client/src/components/ConnectionBanner.tsx +import { useState } from 'react' +import { useChat } from '../store/chat-store.js' + +export function ConnectionBanner() { + const connection = useChat((s) => s.connection) + const lastError = useChat((s) => s.lastError) + const [dismissed, setDismissed] = useState(null) + + const showError = lastError && lastError !== dismissed + const showReconnecting = connection === 'closed' && !showError + if (!showError && !showReconnecting) return null + + const kind = showError ? 'error' : 'warn' + const icon = showError ? '⚠' : '⟳' + const text = showError ? `Network error: ${lastError!.message}` : 'Connection lost — reconnecting…' + + return ( +
+ + {text} + {showError && ( + + )} +
+ ) +} +``` + +Add token-styled CSS to `styles/app.css` (no hardcoded colors; 24px min target on the dismiss button): +```css +.conn-banner { + position: fixed; top: 8px; left: 50%; transform: translateX(-50%); z-index: 1000; + display: flex; align-items: center; gap: 10px; max-width: min(600px, 90vw); + padding: 8px 14px; border-radius: var(--radius); + background: var(--bg-3); font-family: var(--mono); font-size: 12px; + box-shadow: 0 4px 20px -4px var(--shadow-overlay, rgba(0,0,0,0.5)); +} +.conn-banner.error { border: 1px solid var(--red); color: var(--red); } +.conn-banner.warn { border: 1px solid var(--amber); color: var(--amber); } +.conn-banner-text { flex: 1; } +.conn-banner-dismiss { + min-width: 24px; min-height: 24px; background: none; border: none; + color: var(--ink-2); cursor: pointer; font-family: var(--mono); font-size: 13px; line-height: 1; +} +.conn-banner-icon { font-size: 13px; } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @bool/client test -- src/components/ConnectionBanner.test.tsx` +Expected: PASS (3 tests). + +- [ ] **Step 5: Swap the inline `ErrorBanner` for `ConnectionBanner` in `AppShell`** + +In `AppShell.tsx`, delete the local `ErrorBanner` function and its `` usage; import and mount `` in its place. + +- [ ] **Step 6: Typecheck + full client suite** + +Run: `pnpm --filter @bool/client typecheck && pnpm --filter @bool/client test` +Expected: PASS (all green; no orphaned `ErrorBanner` reference). + +- [ ] **Step 7: Commit** + +```bash +git add packages/client/src/components/ConnectionBanner.tsx packages/client/src/components/ConnectionBanner.test.tsx packages/client/src/components/AppShell.tsx packages/client/src/styles/app.css +git commit -m "feat(client): connection-error + reconnecting banner (icon+text, dismissible)" +``` + +--- + +### Task 8: Responsive shell — rail/member collapse, mobile sidebar drawer, 44px touch targets + +**Files:** +- Modify: `packages/client/src/components/MainPane.tsx` (drawer + member toggle buttons in the topbar) +- Modify: `packages/client/src/components/AppShell.tsx` (drawer + member-visible state) +- Modify: `packages/client/src/components/MemberList.tsx` (`EmptyState` for no members; collapsible) +- Modify: `packages/client/src/components/Sidebar.tsx` (drawer semantics when closed) +- Modify: `packages/client/src/styles/base.css` + `packages/client/src/styles/app.css` (breakpoints, drawer, touch targets) +- Create: `packages/client/src/components/Responsive.test.tsx` + +**Interfaces:** +- Consumes: `EmptyState` from `./primitives/EmptyState.js`. +- Produces CSS state classes: `.drawer-open`, `.members-collapsed`. + +- [ ] **Step 1: Write the failing tests** + +```tsx +// packages/client/src/components/Responsive.test.tsx +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { useChat, targetKey } from '../store/chat-store.js' +import { AppShell } from './AppShell.js' +import { MemberList } from './MemberList.js' + +beforeEach(() => { + useChat.setState(useChat.getInitialState?.() ?? {}, true) +}) + +function seedChannel() { + const key = targetKey(1, '#bool') + useChat.setState({ + networks: { 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'dmf', connected: true } }, + targets: { [key]: { networkId: 1, target: '#bool', kind: 'channel', unread: 0, names: [] } }, + messages: { [key]: [] }, + selected: key, + connection: 'open', + }) +} + +describe('Responsive shell', () => { + it('exposes a mobile "channels" drawer toggle that opens the sidebar drawer', () => { + seedChannel() + const { container } = render() + const toggle = screen.getByRole('button', { name: /channels/i }) + expect(toggle).toHaveAttribute('aria-expanded', 'false') + fireEvent.click(toggle) + expect(toggle).toHaveAttribute('aria-expanded', 'true') + expect(container.querySelector('.app.drawer-open')).toBeInTheDocument() + }) + + it('member list shows an EmptyState when the channel has no members', () => { + seedChannel() + render() + expect(screen.getByRole('heading', { name: /no one here yet/i })).toBeInTheDocument() + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm --filter @bool/client test -- src/components/Responsive.test.tsx` +Expected: FAIL — no "channels" drawer toggle; `MemberList` renders bare "No members" text (no heading role). + +- [ ] **Step 3: Add the drawer state + toggle** + +In `AppShell.tsx`, add `const [drawerOpen, setDrawerOpen] = useState(false)`; apply the class on the frame: `
`; pass `drawerOpen` / `onToggleDrawer` down to `MainPane` (topbar toggle) and `Sidebar` (for `aria-hidden`/`inert` when closed at narrow widths). Close the drawer whenever `selected` changes (picking a channel dismisses it on mobile). + +In `MainPane.tsx`, add a leading topbar button (before the `.ch` title) that is only visible at narrow widths via CSS: +```tsx + +``` + +- [ ] **Step 4: `MemberList` empty state + collapsible** + +Replace the bare `selected == null ? null : 'No members'` block with `EmptyState` when a channel is selected but has zero names: +```tsx +import { EmptyState } from './primitives/EmptyState.js' +// ... +{names.length === 0 ? ( + selected == null ? null : ( + + ) +) : ( /* existing MemberGroup rendering */ )} +``` + +- [ ] **Step 5: Responsive CSS (breakpoints + drawer + touch targets)** + +In `styles/base.css`, extend the `.app` grid rules. Keep the existing desktop grid (`56px 232px 1fr 200px`) and the existing `<1180px` rule (drop the member column). Add a narrow tier where the sidebar becomes an off-canvas drawer: +```css +/* Tablet: member list already hidden at <1180px (styles/app.css). */ + +/* Narrow / mobile: sidebar + rail collapse into a drawer overlay. */ +@media (max-width: 760px) { + .app { grid-template-columns: 1fr; } + .app > .rail, + .app > .side { + position: fixed; top: 0; bottom: 0; left: 0; z-index: 900; + transform: translateX(-100%); + transition: transform 0.2s cubic-bezier(.4,.9,.3,1.2); + } + .app > .rail { width: 56px; } + .app > .side { left: 56px; width: 232px; } + .app.drawer-open > .rail, + .app.drawer-open > .side { transform: none; box-shadow: 0 0 40px -4px var(--shadow-overlay, rgba(0,0,0,.6)); } + .app.drawer-open::after { + content: ""; position: fixed; inset: 0; z-index: 850; background: rgba(0,0,0,.45); + } + .drawer-toggle { display: inline-flex; } +} +/* Drawer toggle hidden on wide screens (sidebar always present). */ +.drawer-toggle { display: none; } + +/* Touch: bump interactive targets to 44px on coarse pointers. */ +@media (pointer: coarse) { + .net-btn { width: 44px; height: 44px; } + .chan { min-height: 44px; } + .tb-btn, .drawer-toggle, .conn-banner-dismiss, .grp-add { min-width: 44px; min-height: 44px; } + .mem { min-height: 44px; } +} +``` + +> `Sidebar.tsx` renders `.rail` and `.side` as siblings via `display: contents` — the `.app > .rail` / `.app > .side` selectors above target them directly since `display: contents` promotes children to grid items. Verify with the Ralph loop; if the direct-child selectors miss, switch to `.app .rail` / `.app .side`. + +- [ ] **Step 6: Reduced-motion** + +The global `@media (prefers-reduced-motion: reduce)` block (`styles/app.css:1582`) already neutralizes `transition-duration` for `*`, so the drawer slide is instant under reduced motion automatically. No extra rule needed — note this in the commit. + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `pnpm --filter @bool/client test -- src/components/Responsive.test.tsx` +Expected: PASS (2 tests). + +- [ ] **Step 8: Typecheck + full client suite** + +Run: `pnpm --filter @bool/client typecheck && pnpm --filter @bool/client test` +Expected: PASS (all green — existing Sidebar/MemberList/MainPane tests unaffected). + +- [ ] **Step 9: Commit** + +```bash +git add packages/client/src/components/MainPane.tsx packages/client/src/components/AppShell.tsx packages/client/src/components/MemberList.tsx packages/client/src/components/Sidebar.tsx packages/client/src/components/Responsive.test.tsx packages/client/src/styles/base.css packages/client/src/styles/app.css +git commit -m "feat(client): responsive shell — mobile sidebar drawer, rail/member collapse, 44px touch targets" +``` + +--- + +### Task 9: Ralph loop gate (visual parity + a11y + interaction) + +> See `2026-07-12-bool-ux-overhaul-INDEX.md` → **The Ralph Loop**. This is the strict closing gate: **exit only when visual ≈ mock AND a11y ✓ AND interaction ✓.** Use the `chrome-devtools` and `a11y-debugging` skills. Seed via `window.__bool.seedDemo()` (WS-0). Do not author new HTML mocks — diff against the PNG shots recorded in Task 1. + +**Files:** evidence screenshots only → `design/shots/impl-ws7-{desktop-compact,desktop-comfortable,mobile}.png`. + +- [ ] **Step 1: Start the dev server and open the app** + +Run (background): `pnpm --filter @bool/client dev` (Vite, port 5173). +Then `new_page` → `navigate_page` → `http://localhost:5173`. + +- [ ] **Step 2: Seed deterministic state** + +`mcp__…__evaluate_script`: +```js +const { useAppearance } = window.__bool +useAppearance.getState().setTheme('a') // Direction A +useAppearance.getState().setDensity('compact') +window.__bool.seedDemo() // WS-0 fixture: networks + channels/DM + messages, one selected +``` + +- [ ] **Step 3: Visual parity — desktop, compact** + +`resize_page` 1440×900 → `take_screenshot`. Compare against `design/shots/app-a-dark-compact.png`. **Reconcile:** left timestamp gutter present on every row; consecutive same-author messages grouped (full nick header on first row only; continuation rows share the gutter, timestamp revealed on hover); three-pane layout (rail · sidebar · messages · member list) matches; spacing reads "compact". Save → `design/shots/impl-ws7-desktop-compact.png`. + +- [ ] **Step 4: Visual parity — desktop, comfortable** + +`evaluate_script`: `window.__bool.useAppearance.getState().setDensity('comfortable')`. `take_screenshot`. Compare against `design/shots/app-a-dark-comfortable.png`. **Assert the difference is spacing only** — row padding, group gap, and line-height grow (density tokens), layout unchanged. Save → `design/shots/impl-ws7-desktop-comfortable.png`. Optionally cross-check rich rows (reactions/replies/media) against `design/shots/app-rich-features.png` and `design/shots/app-media.png`. + +- [ ] **Step 5: Visual parity — narrow / mobile drawer** + +`resize_page` 390×844 → `take_screenshot`. **Assert:** member list is gone; the messages pane is full-width; the "Channels" drawer toggle is visible in the topbar. Click it (`click` the button labeled "Channels") → screenshot → **assert the sidebar drawer slides in over a scrim** and channel rows are reachable; picking a channel dismisses the drawer. Save → `design/shots/impl-ws7-mobile.png`. + +- [ ] **Step 6: Empty / loading / error states present** + +- Empty (no selection): `evaluate_script` `window.__bool.useChat.setState({ selected: null })` → screenshot → assert the "Pick a channel to start chatting" `EmptyState` renders. +- Empty (channel, no messages): select a channel whose `messages` array is empty → assert "No messages yet". +- Loading: not required to reproduce live if the skeleton path is timeout-gated; confirm the `HistorySkeleton` renders by temporarily setting a selected channel with zero messages immediately after seed (the skeleton shows before settle) — screenshot the shimmer. +- Error: `evaluate_script` `window.__bool.useChat.setState({ lastError: { networkId: 1, message: 'Nick in use' } })` → assert the `ConnectionBanner` alert appears with icon+text and a dismiss button; `setState({ connection: 'closed' })` → assert the "reconnecting" warn banner. + +- [ ] **Step 7: `role=log` announces new messages** + +After the initial render (with a channel selected), push a message onto the store via `evaluate_script`: +```js +const { useChat } = window.__bool +const key = useChat.getState().selected +const prev = useChat.getState().messages[key] ?? [] +useChat.setState({ + messages: { + ...useChat.getState().messages, + [key]: [...prev, { id: 'live-1', kind: 'privmsg', target: key.split(':')[1], sender: 'ada', body: 'live announcement test', ts: Date.now() }], + }, +}) +``` +`take_snapshot` and assert the new row is inside the `role="log"` region (its `aria-live="polite"` means AT announces the addition). Confirm the log region has an accessible name ("Messages"). + +- [ ] **Step 8: A11y audit (`a11y-debugging` skill)** + +Run keyboard-only traversal (Tab reaches rail buttons, channel rows, composer, topbar toggles; visible focus never obscured); confirm ARIA roles/names (`role="log"` on messages, `role="alert"` on the banner, `EmptyState` headings, drawer toggle `aria-expanded`); check 4.5:1 contrast on message text/nicks/gutter across theme A dark **and** light; verify 24px/44px targets (rail buttons, channel rows, dismiss, drawer toggle under `pointer: coarse`); toggle `prefers-reduced-motion` (`emulate` reduced motion) → confirm the drawer slide and skeleton shimmer are static. Optionally `lighthouse_audit` (a11y category) and record the score. + +- [ ] **Step 9: Interaction end-to-end** + +Drive the real flow: click a network in the rail → sidebar updates; click a channel → messages load and the member list populates; type in the composer and send → the new row appears in the `role="log"`; on mobile, open the drawer, pick a channel, confirm it dismisses. Assert each via `take_snapshot` or `evaluate_script` reading `useChat.getState()`. + +- [ ] **Step 10: Reconcile any deltas, then commit evidence** + +Any visual/a11y/interaction failure → fix in code (respecting tokens + `.js` imports + tests) → re-run the full client suite (`pnpm --filter @bool/client test`) → repeat from Step 1. Exit only when all three pass. Then: +```bash +git add design/shots/impl-ws7-desktop-compact.png design/shots/impl-ws7-desktop-comfortable.png design/shots/impl-ws7-mobile.png +git commit -m "test(client): WS-7 Ralph loop evidence — desktop compact+comfortable parity, mobile drawer, empty/loading/error, role=log announce, a11y" +``` + +--- + +## Self-Review + +- **Spec coverage:** Delivers spec §4 WS-7 and the §2 "Shell polish" gap in full — Direction-A mock parity (grouping via first-row-header/continuation-row rules, left timestamp gutter, density-token-driven spacing), `role="log"` message list (WS-0 `LiveLog`, §6 + §1.7 verified claim), responsive collapsible rail + member list + mobile sidebar drawer + 44px touch targets, and first-class empty (no selection / no messages) / loading (history skeleton) / error (connection banner) states via WS-0 `EmptyState`. ✓ +- **Grounded in reality:** Grouping and the timestamp gutter already exist in `MessageList.renderRow` + `MessageRow` (`.msg`/`.cont`), so Task 3 **locks** parity with tests rather than rebuilding; the responsive grid extends the existing `.app` grid (`base.css`) and the existing `<1180px` member-hide rule (`app.css`); reduced-motion reuses the existing `@media (prefers-reduced-motion: reduce)` block (`app.css:1582`). ✓ +- **WS-0 consumption by name:** `LiveLog` (Task 2), `EmptyState` (Tasks 4, 5, 8), `seedDemo` (Task 9) — imported from `./primitives/*.js` / `../dev-seed.js`, not re-implemented. ✓ +- **Required tests present, no placeholders:** role=log (Task 2), grouping continuation-omits-header (Task 3), "no messages yet" empty state (Task 4) — plus MainPane empty, skeleton, connection banner, responsive drawer, member empty. Each is real Vitest + `@testing-library/react` in the repo's `beforeEach(() => useChat.setState(getInitialState, true))` style, with exact commands and expected FAIL→PASS transitions. ✓ +- **Conventions:** `.js` relative imports throughout; spacing from density tokens (`--row-py`, `--group-gap`, `--msg-fs`, `--msg-lh`, `--meta-fs`, `--side-row-h`), color/geometry from `tokens.css`, no hardcoded colors; a11y baseline (icon+text status, 24/44px targets, reduced-motion); store fields (`connection`, `lastError`, `NetworkState`/`TargetState`) verified against `store/types.ts`. ✓ +- **First task** records the exact mock filenames via `ls design/shots/` (no HTML authored). **Final task** is the Ralph loop gate referencing INDEX, seeding via `window.__bool.seedDemo()`, pushing a live message via `evaluate_script` to prove `role=log` announcement, checking compact+comfortable parity against the `app-a-dark-*` shots, mobile drawer, empty/loading/error, and reduced motion — saving evidence to `design/shots/impl-ws7-*.png`. ✓ diff --git a/docs/superpowers/plans/2026-07-12-ws8-a11y-sweep.md b/docs/superpowers/plans/2026-07-12-ws8-a11y-sweep.md new file mode 100644 index 0000000..c99639f --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-ws8-a11y-sweep.md @@ -0,0 +1,673 @@ +# WS-8 A11y + Responsive Sweep — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** One strict WCAG 2.2 verification-and-remediation pass over **every** screen produced by WS-2…WS-7 (first-run setup + auth, account menu, network directory, channel browser, command palette + `?` cheatsheet, chat shell) **plus** the pre-existing search panel and network-settings dialog. Fix every straggler; land global CSS guards for reduced-motion and `:focus-visible`; ship a documented shortcut + a11y matrix (`docs/a11y.md`). + +**Architecture:** This is a **cross-cutting verification+remediation sweep**, not a feature build. Its tasks are per-screen **Ralph-style audit loops** (seed → screenshot → a11y checks → fix in code → re-verify) rather than pure TDD. Two bookend tasks are testable: Task 1 lands global CSS guards with a css-string regression test (following the existing `styles/css-tokens.test.ts` pattern); the final task lands the a11y matrix doc plus a full-suite green check and a Playwright pass. Every per-screen fix that is unit-testable (an added `role`, `aria-*`, `aria-describedby`, or a target-size class) ships a Vitest regression test with real code and an exact command. + +**Tech Stack:** React 19 + TypeScript · Vitest + React Testing Library + jsdom (unit) · Playwright (e2e, `packages/client/e2e/`) · chrome-devtools MCP + `chrome-devtools-mcp:a11y-debugging` skill (Ralph loop) · `optionally lighthouse_audit` for the a11y category. + +## Global Constraints + +See `2026-07-12-bool-ux-overhaul-INDEX.md` → Global Constraints. Key for this workstream: +- **`.js` import extensions** on every relative import; **no hardcoded colors** — use tokens from `packages/client/src/styles/tokens.css` (`--focus`, `--bg-*`, `--ink-*`, `--line*`, `--red`, `--radius*`). +- **A11y baseline (all UI):** keyboard-operable; visible focus never obscured (2.4.11); focus trap + restore in overlays; status by **icon + text, never color alone** (1.4.1); form errors via `aria-describedby` (3.3.x); message list `role="log"`; min target 24px / 44px touch (2.5.8); **4.5:1 contrast (1.4.3) verified across all 14 themes**; honor `prefers-reduced-motion`; **don't block paste** in auth fields (3.3.8). +- **Tests:** Vitest + `@testing-library/react` + jsdom; setup `packages/client/src/test-setup.ts`; follow `packages/client/src/components/Dialog.test.tsx` and `packages/client/src/styles/css-tokens.test.ts`. Keep the full suite green (`pnpm -r test`). +- **Branch:** all work on `feat/ux-overhaul` (already created). Commit per task. + +**This workstream depends on WS-0…WS-7 being complete** — it audits and remediates the screens those workstreams produced. It must run **last** (INDEX order). Do not start until WS-2…WS-7 have merged their screens onto `feat/ux-overhaul`. + +**Files modified (cross-cutting — the exact set depends on findings; this is the expected surface):** +- `packages/client/src/styles/app.css` — global `:focus-visible` treatment; audit/extend the existing `prefers-reduced-motion` guard (already at ~line 1582) and per-component focus-ring list (~line 1196). +- `packages/client/src/styles/tokens.css` — only if a per-theme `--focus` value fails the visible-focus check on some theme (the token contract already requires `--focus` in every block — see `css-tokens.test.ts`). +- `packages/client/src/components/primitives/*` (Field, EmptyState, Menu, LiveLog, Kbd from WS-0) — remediation if any primitive regresses under audit. +- Screen components as remediation requires: `LoginForm.tsx`, `SetupWizard.tsx`, `OnboardingThemeStep.tsx` (WS-2); `AccountMenu.tsx` (WS-3); `NetworkDirectory.tsx`, `NetworkSettings.tsx` (WS-4); `ChannelBrowser.tsx` (WS-5); `CommandPalette.tsx`, `ShortcutCheatsheet.tsx` (WS-6); `MainPane.tsx`, `MessageList.tsx`, `MessageRow.tsx`, `Sidebar.tsx`, `MemberList.tsx` (WS-7); `SearchPanel.tsx` (pre-existing). +- New: `packages/client/src/styles/css-a11y.test.ts` (Task 1 regression), `docs/a11y.md` (final task), `packages/client/e2e/a11y.spec.ts` (final task, axe pass). +- Evidence screenshots under `/design/shots/impl-ws8--{desktop,mobile}.png`. + +**Screen inventory audited (12 screens):** + +| # | Screen | Component(s) | Source WS | Overlay? | +|---|---|---|---|---| +| S1 | First-run setup wizard | `SetupWizard.tsx`, `OnboardingThemeStep.tsx` | WS-2 | full-page | +| S2 | Sign-in / register card | `LoginForm.tsx` | WS-2 | full-page | +| S3 | Account menu | `AccountMenu.tsx` (Menu primitive) | WS-3 | dropdown | +| S4 | Network directory | `NetworkDirectory.tsx` | WS-4 | dialog | +| S5 | Advanced network settings | `NetworkSettings.tsx` (Dialog) | WS-4 | dialog | +| S6 | Channel browser | `ChannelBrowser.tsx` | WS-5 | dialog | +| S7 | Command palette | `CommandPalette.tsx` | WS-6 | overlay | +| S8 | Shortcut cheatsheet (`?`) | `ShortcutCheatsheet.tsx` | WS-6 | overlay | +| S9 | Search panel | `SearchPanel.tsx` | pre-existing | overlay | +| S10 | Chat shell (sidebar + rail) | `Sidebar.tsx` | WS-7 | in-shell | +| S11 | Message list | `MainPane.tsx`, `MessageList.tsx`, `MessageRow.tsx` | WS-7 | in-shell | +| S12 | Member list | `MemberList.tsx` | WS-7 | in-shell | + +--- + +## Ralph loop reference (per-screen audit procedure) + +See `2026-07-12-bool-ux-overhaul-INDEX.md` → **The Ralph Loop**. For WS-8 each per-screen task runs the loop in **audit mode**: + +1. **Start** the dev server once for the whole task run: `pnpm --filter @bool/client dev` (Vite, port 5173). `new_page` → `navigate_page` to `http://localhost:5173`. +2. **Seed** deterministic state with `mcp__…__evaluate_script` using the WS-0 helper: + ```js + window.__bool.seedDemo() + ``` + Then set the theme under audit and open the target overlay/route (per-task steps say which). +3. **Contrast across all 14 themes:** loop the theme id through every entry in `THEME_CATALOG` + (`a, a-light, b, b-light, c, c-light, shimmer, solarized-dark, solarized-light, gruvbox-dark, gruvbox-light, dracula, nord, monokai`) via `window.__bool.useAppearance.getState().setTheme(id)` and re-run the contrast check each time. +4. **A11y:** run the `chrome-devtools-mcp:a11y-debugging` skill checks — keyboard-only traversal, focus order + trap + restore, ARIA roles/names, 4.5:1 contrast, 24px/44px targets, focus-not-obscured, reduced-motion. Optionally `mcp__…__lighthouse_audit` (a11y category) as a cross-check. +5. **Interaction:** drive the real flow (`click`, `fill`, `type_text`, `press_key`) end-to-end; assert the expected state change (read the store via `evaluate_script` or the DOM via `take_snapshot`). +6. **Record → Fix → Re-verify:** write findings into the task's checklist, fix in code (token classes only; `.js` imports), add a Vitest regression where the fix is unit-testable, re-run the loop. **Exit only when a11y ✓ AND interaction ✓** at desktop (1440×900) **and** mobile (390×844). Save evidence to `/design/shots/impl-ws8--{desktop,mobile}.png` and commit. + +**WCAG 2.2 criteria checked on every screen** (§6 baseline; note per-screen which apply): 1.4.1 Use of Color · 1.4.3 Contrast (Minimum) · 2.1.1 Keyboard · 2.1.2 No Keyboard Trap · 2.4.3 Focus Order · 2.4.7 Focus Visible · **2.4.11 Focus Not Obscured (Minimum)** · **2.5.8 Target Size (Minimum)** · 3.3.1/3.3.3 Error Identification/Suggestion · **3.3.8 Accessible Authentication** · 4.1.2 Name, Role, Value · 4.1.3 Status Messages (`role="log"`/`role="alert"`). + +--- + +### Task 1: Global CSS guards — reduced-motion + `:focus-visible` (with regression test) + +**Files:** +- Modify: `packages/client/src/styles/app.css` +- Create: `packages/client/src/styles/css-a11y.test.ts` + +**What exists already (verify, do not duplicate):** +- A global reduced-motion guard already lives at `packages/client/src/styles/app.css` (~line 1582): `@media (prefers-reduced-motion: reduce)` zeroing `animation-duration`/`transition-duration`/`scroll-behavior` on `*, *::before, *::after`. +- A **scoped** `:focus-visible` ring list exists (~line 1196) covering `.net-btn, .reply-btn, .rx-pill, .rx-add, .reply-chip-cancel, .chan, .tb-btn, .appearance-btn`, plus `.theme-swatch:focus-visible` (~line 1609). +- **Gap:** there is **no global `:focus-visible` fallback** — new WS-2…WS-7 elements (setup/auth inputs, account-menu items, directory/browser rows, cheatsheet) rely on per-component rules or inline `outline: none`. Land a global default so nothing is left without a visible ring. + +- [ ] **Step 1: Confirm the current state** + +Run: `grep -n "prefers-reduced-motion\|:focus-visible\|--focus" packages/client/src/styles/app.css` +Expected: shows the reduced-motion block (~1582) and the scoped focus-visible list (~1196, ~1609). Confirms there is **no** bare `*:focus-visible` / `:focus-visible` universal rule. + +- [ ] **Step 2: Write the failing regression test** + +```ts +// packages/client/src/styles/css-a11y.test.ts +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' + +const __dirname = dirname(new URL(import.meta.url).pathname) +const appCss = readFileSync(join(__dirname, 'app.css'), 'utf8') + +describe('global a11y CSS guards', () => { + it('honors prefers-reduced-motion globally', () => { + // A universal reduced-motion guard must exist and zero animation + transition. + const block = appCss.match( + /@media\s*\(prefers-reduced-motion:\s*reduce\)\s*\{[\s\S]*?\}\s*\}/, + )?.[0] + expect(block, 'no prefers-reduced-motion block found').toBeTruthy() + expect(block).toMatch(/\*\s*,/) // targets the universal selector + expect(block).toMatch(/animation-duration:\s*0?\.?0*1?ms\s*!important/) + expect(block).toMatch(/transition-duration:\s*0?\.?0*1?ms\s*!important/) + }) + + it('provides a global :focus-visible ring driven by the --focus token', () => { + // A universal fallback ring (not just per-component classes) must exist. + expect(appCss).toMatch(/:where\([^)]*\):focus-visible|:focus-visible[^{]*\{[^}]*outline/) + // The global rule must use the theme-driven --focus token, never a hardcoded color. + const globalRule = appCss.match( + /\/\*\s*global focus[\s\S]*?\}/i, + )?.[0] + expect(globalRule, 'no "global focus" rule block found').toBeTruthy() + expect(globalRule).toMatch(/var\(--focus\)/) + }) +}) +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `pnpm --filter @bool/client test -- src/styles/css-a11y.test.ts` +Expected: FAIL — the `global focus` rule block does not yet exist (the reduced-motion assertion passes; the focus-visible assertion fails). + +- [ ] **Step 4: Add the global `:focus-visible` treatment** + +Append near the existing focus-ring section (~line 1196) in `packages/client/src/styles/app.css`: + +```css +/* ---- global focus: theme-driven visible ring for anything not covered above ---- */ +:where(a, button, input, select, textarea, summary, [tabindex], [role="menuitem"], [role="option"], [role="tab"]):focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; + /* keep the ring above sticky headers/toolbars so it is never obscured (WCAG 2.4.11) */ + position: relative; + z-index: 1; +} +/* Remove UA default focus (non-visible) but never suppress :focus-visible. */ +:where(a, button, input, select, textarea, summary, [tabindex]):focus:not(:focus-visible) { + outline: none; +} +``` + +> Uses `:where()` (zero specificity) so per-component overrides (`.chan:focus-visible`, `.theme-swatch:focus-visible`, `.input-wrap:focus-within`) still win. The `--focus` token is defined in every theme block (guaranteed by `css-tokens.test.ts`), so the ring is visible on all 14 themes. + +- [ ] **Step 5: Confirm the reduced-motion guard covers the new overlays** + +Verify the existing `@media (prefers-reduced-motion: reduce)` block at ~line 1582 uses the universal selector (`*, *::before, *::after`) — it does, so it already covers WS-2…WS-7 animations (`cp-fade`, `sp-drop`, `msg-in`, etc.). No change needed unless Step-1 grep shows a non-universal selector; if so, widen it to `*, *::before, *::after`. + +- [ ] **Step 6: Run test to verify it passes + typecheck** + +Run: `pnpm --filter @bool/client test -- src/styles/css-a11y.test.ts && pnpm --filter @bool/client typecheck` +Expected: PASS (2 tests); typecheck clean. + +- [ ] **Step 7: Commit** + +```bash +git add packages/client/src/styles/app.css packages/client/src/styles/css-a11y.test.ts +git commit -m "fix(client): global :focus-visible ring + reduced-motion guard regression test" +``` + +--- + +### Task 2: Audit + fix — S1 First-run setup wizard (`SetupWizard`, `OnboardingThemeStep`) + +**Screen:** the zero-user `/setup` wizard (create admin) + pick-your-vibe theme step. +**WCAG focus:** 2.1.1 Keyboard · 2.4.3 Focus Order · 2.4.7/2.4.11 Focus Visible/Not Obscured · 2.5.8 Target Size · 1.4.1 Use of Color · 1.4.3 Contrast (14 themes) · 3.3.1/3.3.3 Errors via `aria-describedby` · 3.3.8 paste allowed · 4.1.2 Name/Role/Value. + +- [ ] **Step 1: Seed + route to the screen** + +Dev server running (Task 1 Step-1 process). `evaluate_script`: +```js +// Force the zero-user setup route. If the wizard is gated on a store flag, set it: +window.__bool.useChat?.setState?.({ needsSetup: true }) +location.hash = '#/setup' // adjust to the real route the wizard mounts on +``` +`take_snapshot` to confirm the wizard rendered. + +- [ ] **Step 2: Keyboard + focus loop** + +Using `chrome-devtools-mcp:a11y-debugging`: Tab through every step; assert (a) focus order matches visual order, (b) each interactive control shows the global ring from Task 1, (c) the wizard advances via Enter/`Next` button, (d) no focus trap dead-ends, (e) step transitions restore focus to the new step's first control. + +- [ ] **Step 3: Errors + auth** + +`fill` the admin form with an invalid value (e.g. mismatched password), blur, and assert via `take_snapshot` that the error is icon+text (not color-only) and the input has `aria-invalid="true"` + `aria-describedby` pointing at the visible error id. Then `fill` a valid value by **pasting** (`evaluate_script` sets `.value` + dispatches `input`/`paste`) and assert the field accepts it — WCAG 3.3.8, paste never blocked. + +- [ ] **Step 4: Theme step + contrast × 14** + +Advance to `OnboardingThemeStep`. For each theme id in `THEME_CATALOG`, `setTheme(id)` and run the contrast check on labels, live-preview cards, and the primary CTA (≥4.5:1 normal text). Record any theme that fails. + +- [ ] **Step 5: Target size + mobile** + +`resize_page` 1440×900 then 390×844. Assert every button/swatch/next control ≥24px (≥44px at mobile). `take_screenshot` at both → `/design/shots/impl-ws8-setup-{desktop,mobile}.png`. + +- [ ] **Step 6: Fix findings in code** + +Remediate in `SetupWizard.tsx` / `OnboardingThemeStep.tsx` using WS-0 primitives (`Field` already wires `aria-describedby` + icon+text; reuse it rather than raw inputs) and token classes only. Where a fix is a discrete attribute/role/size, add a Vitest regression (pattern below) in the component's existing test file. + +Example unit regression (adapt to the real component/props): +```tsx +// in packages/client/src/components/SetupWizard.test.tsx +import { render, screen, fireEvent } from '@testing-library/react' +import { SetupWizard } from './SetupWizard.js' + +it('wires the admin-password error to the input via aria-describedby (icon+text)', () => { + render() + const pw = screen.getByLabelText(/password/i) + fireEvent.change(pw, { target: { value: 'x' } }) + fireEvent.blur(pw) + const err = screen.getByRole('alert') + expect(pw).toHaveAttribute('aria-invalid', 'true') + expect(pw.getAttribute('aria-describedby')).toBe(err.getAttribute('id')) +}) +``` + +**Acceptance:** keyboard-only completes admin creation + theme pick; errors are icon+text via `aria-describedby`; paste works; all 14 themes ≥4.5:1; targets ≥24/44px; focus never obscured; screenshots saved. + +- [ ] **Step 7: Verify + commit** + +Run: `pnpm --filter @bool/client test -- src/components/SetupWizard.test.tsx src/components/OnboardingThemeStep.test.tsx` +Expected: PASS. +```bash +git add packages/client/src/components/SetupWizard.* packages/client/src/components/OnboardingThemeStep.* design/shots/impl-ws8-setup-*.png +git commit -m "fix(client): a11y sweep — first-run setup wizard (WCAG 2.2)" +``` + +--- + +### Task 3: Audit + fix — S2 Sign-in / register card (`LoginForm`) + +**WCAG focus:** 1.4.1 · 1.4.3 (14 themes) · 2.1.1 · 2.4.7/2.4.11 · 2.5.8 · 3.3.1/3.3.3 (`aria-describedby`) · **3.3.8 paste allowed** · 4.1.2. Register form is `role="form"` name "Register" per `e2e/helpers.ts`. + +- [ ] **Step 1: Seed + route** + +`navigate_page` to `http://localhost:5173` while logged out (clear session via `evaluate_script`: `window.__bool` store reset / `localStorage.clear(); location.reload()`). Toggle to Register via the "need an account? register" button. + +- [ ] **Step 2: A11y loop** + +`a11y-debugging` skill: Tab order (username → email → password → show/hide → submit → mode toggle); global focus ring visible; the show/hide password control has an `aria-label` and a ≥24px target; submit-error and per-field on-blur errors are icon+text with `aria-invalid` + `aria-describedby`. + +- [ ] **Step 3: Accessible auth (3.3.8)** + +Assert the password field allows paste: `evaluate_script` to set value via a simulated paste and confirm it lands (no `onPaste` preventDefault). Confirm `autoComplete` is `username`/`current-password`/`new-password` appropriately so password managers work. + +- [ ] **Step 4: Contrast × 14 + mobile** + +Loop all 14 themes, contrast-check labels/inputs/error text/CTA. `resize_page` 390×844; assert single-column card, targets ≥44px. Screenshots → `/design/shots/impl-ws8-login-{desktop,mobile}.png`. + +- [ ] **Step 5: Fix + regression** + +Remediate `LoginForm.tsx` (prefer the WS-0 `Field` primitive; token classes only). Add/extend a Vitest regression asserting: no `onPaste` blocking; error `aria-describedby` wiring; show/hide `aria-label`. + +```tsx +// in packages/client/src/components/... (LoginForm test file) +it('does not block paste in the password field (WCAG 3.3.8)', () => { + render() + const pw = screen.getByLabelText(/password/i) + const evt = new Event('paste', { bubbles: true, cancelable: true }) + pw.dispatchEvent(evt) + expect(evt.defaultPrevented).toBe(false) +}) +``` + +**Acceptance:** keyboard-only sign-in + register; errors adjacent + `aria-describedby` + icon+text; paste + password managers work; all 14 themes ≥4.5:1; targets ≥24/44px. + +- [ ] **Step 6: Verify + commit** + +Run: `pnpm --filter @bool/client test -- src/components/LoginForm` *(use the real test filename)* +Expected: PASS. +```bash +git add packages/client/src/LoginForm.tsx packages/client/src/components/LoginForm* design/shots/impl-ws8-login-*.png +git commit -m "fix(client): a11y sweep — sign-in/register card (paste allowed, aria errors)" +``` + +--- + +### Task 4: Audit + fix — S3 Account menu (`AccountMenu`) + +**WCAG focus:** 2.1.1 · 2.1.2 no trap · 2.4.3 focus order · **2.4.7/2.4.11** · 2.5.8 · 1.4.3 (14 themes) · 4.1.2 (`role="menu"`/`menuitem`, focus restore). + +- [ ] **Step 1: Seed + open** + +`window.__bool.seedDemo()` then `click` the account button in the sidebar/rail footer (or dispatch its open event). `take_snapshot` → confirm `role="menu"` with an accessible name and `role="menuitem"` children (built on the WS-0 `Menu` primitive). + +- [ ] **Step 2: Keyboard semantics** + +`press_key` ArrowDown/ArrowUp to move between items (focus stays within the menu), Escape to close, and assert focus **returns to the trigger** (`evaluate_script` reads `document.activeElement`). Assert Enter/Space activates the focused item. Confirm the trigger and each item are ≥24px (≥44px touch). + +- [ ] **Step 3: Sign out is the primary home + status** + +Assert a visible "Sign out" `menuitem` exists (kills "we have no logout"); presence indicator uses icon+text, not color alone (1.4.1). + +- [ ] **Step 4: Contrast × 14 + mobile** + +Loop 14 themes, contrast-check menu surface vs items vs the scrim. `resize_page` 390×844 (menu should remain reachable/scrollable). Screenshots → `/design/shots/impl-ws8-account-menu-{desktop,mobile}.png`. + +- [ ] **Step 5: Fix + regression** + +Remediate `AccountMenu.tsx` (reuse `Menu` primitive; token classes). Extend `AccountMenu.test.tsx` (or the `Menu` primitive test) asserting focus restore + `role` semantics: +```tsx +it('restores focus to the trigger when the menu closes on Escape', () => { + render() // opens the menu, trigger has a testid + fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' }) + expect(screen.getByTestId('account-trigger')).toHaveFocus() +}) +``` + +**Acceptance:** menu reachable by keyboard; `role=menu`/`menuitem`; arrow nav; Escape closes and restores focus; Sign out present; targets ≥24/44px; 14-theme contrast. + +- [ ] **Step 6: Verify + commit** + +Run: `pnpm --filter @bool/client test -- src/components/AccountMenu.test.tsx` +Expected: PASS. +```bash +git add packages/client/src/components/AccountMenu.* design/shots/impl-ws8-account-menu-*.png +git commit -m "fix(client): a11y sweep — account menu (role=menu, focus restore, 44px)" +``` + +--- + +### Task 5: Audit + fix — S4 Network directory + S5 Advanced network settings (`NetworkDirectory`, `NetworkSettings`) + +**WCAG focus:** 2.1.1 · 2.4.3 · 2.4.7/2.4.11 · 2.5.8 · 1.4.1 (status icon+text) · 1.4.3 (14 themes) · 3.3.1 (advanced-form errors via `aria-describedby`) · 4.1.2 · 4.1.3 (empty-state `role`). Both mount inside a `Dialog` (focus trap/restore already provided by `Dialog.tsx`). + +- [ ] **Step 1: Seed + open the directory** + +`window.__bool.seedDemo()`; open the "add network" entry (rail "+") → `NetworkDirectory`. `take_snapshot`: confirm the search input has a label, each directory row is a focusable control with an accessible name (network + description), and the empty state ("No networks yet") is a heading + one CTA (WS-0 `EmptyState`). + +- [ ] **Step 2: Keyboard + interaction** + +Tab to the search box; type to filter (assert results shrink); Arrow/Tab to a network row; Enter selects → nick capture → auto-connect. Assert focus order and that the Dialog focus-trap keeps Tab inside the modal; Escape closes and restores focus to the trigger. + +- [ ] **Step 3: Advanced path (S5)** + +Open the "Advanced / custom server" path (`NetworkSettings`). Since the spec flags `NetworkSettings` as inline-styled, verify **no hardcoded colors** remain on touched elements (move to token classes as touched, per §6) and that host/port/SASL fields have real `
diff --git a/packages/client/src/components/ConnDot.tsx b/packages/client/src/components/ConnDot.tsx index 0db0b2f..963c7bb 100644 --- a/packages/client/src/components/ConnDot.tsx +++ b/packages/client/src/components/ConnDot.tsx @@ -1,13 +1,26 @@ import { useChat } from '../store/chat-store.js' +/** Human copy for the raw WS connection state — no dev-speak in the UI. */ +function connectionLabel(connection: 'open' | 'connecting' | 'closed'): string { + switch (connection) { + case 'open': + return 'Connected' + case 'connecting': + return 'Connecting…' + case 'closed': + return 'Offline — reconnecting' + } +} + /** Connection status dot — driven by useChat().connection */ export function ConnDot() { const connection = useChat((s) => s.connection) + const label = connectionLabel(connection) return ( ) } diff --git a/packages/client/src/components/ConnectionBanner.test.tsx b/packages/client/src/components/ConnectionBanner.test.tsx new file mode 100644 index 0000000..41a1357 --- /dev/null +++ b/packages/client/src/components/ConnectionBanner.test.tsx @@ -0,0 +1,33 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { useChat } from '../store/chat-store.js' +import { ConnectionBanner } from './ConnectionBanner.js' + +beforeEach(() => { + useChat.setState(useChat.getInitialState?.() ?? {}, true) +}) + +describe('ConnectionBanner', () => { + it('renders nothing when the connection is open and there is no error', () => { + useChat.setState({ connection: 'open', lastError: null }) + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('shows a reconnecting alert with icon+text when the connection is closed', () => { + useChat.setState({ connection: 'closed', lastError: null }) + render() + const alert = screen.getByRole('alert') + expect(alert).toHaveTextContent(/reconnect/i) + // icon is decorative; the status is also conveyed by text (not color alone) + expect(alert.querySelector('[aria-hidden="true"]')).toBeInTheDocument() + }) + + it('shows the network error message and is dismissible', () => { + useChat.setState({ connection: 'open', lastError: { networkId: 1, message: 'Nick in use' } }) + render() + expect(screen.getByRole('alert')).toHaveTextContent(/nick in use/i) + fireEvent.click(screen.getByRole('button', { name: /dismiss/i })) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) +}) diff --git a/packages/client/src/components/ConnectionBanner.tsx b/packages/client/src/components/ConnectionBanner.tsx new file mode 100644 index 0000000..35b4205 --- /dev/null +++ b/packages/client/src/components/ConnectionBanner.tsx @@ -0,0 +1,34 @@ +import { useState } from 'react' +import { useChat } from '../store/chat-store.js' + +export function ConnectionBanner() { + const connection = useChat((s) => s.connection) + const lastError = useChat((s) => s.lastError) + const [dismissed, setDismissed] = useState(null) + + const showError = lastError && lastError !== dismissed + // The reconnecting (warn) banner is intentionally non-dismissible — it auto-clears when the connection reopens. + const showReconnecting = connection === 'closed' && !showError + if (!showError && !showReconnecting) return null + + const kind = showError ? 'error' : 'warn' + const icon = showError ? '⚠' : '⟳' + const text = showError ? `Network error: ${lastError!.message}` : 'Connection lost — reconnecting…' + + return ( +
+ + {text} + {showError && ( + + )} +
+ ) +} diff --git a/packages/client/src/components/HistorySkeleton.test.tsx b/packages/client/src/components/HistorySkeleton.test.tsx new file mode 100644 index 0000000..4c5ca22 --- /dev/null +++ b/packages/client/src/components/HistorySkeleton.test.tsx @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { HistorySkeleton } from './HistorySkeleton.js' + +describe('HistorySkeleton', () => { + it('is a busy status region announcing that history is loading', () => { + render() + const status = screen.getByRole('status', { name: /loading/i }) + expect(status).toHaveAttribute('aria-busy', 'true') + }) + + it('renders the requested number of placeholder rows', () => { + const { container } = render() + expect(container.querySelectorAll('.skel-row').length).toBe(5) + }) +}) diff --git a/packages/client/src/components/HistorySkeleton.tsx b/packages/client/src/components/HistorySkeleton.tsx new file mode 100644 index 0000000..30955bc --- /dev/null +++ b/packages/client/src/components/HistorySkeleton.tsx @@ -0,0 +1,12 @@ +export function HistorySkeleton({ rows = 6 }: { rows?: number }) { + return ( +
+ {Array.from({ length: rows }).map((_, i) => ( + + ))} +
+ ) +} diff --git a/packages/client/src/components/IrcBody.tsx b/packages/client/src/components/IrcBody.tsx index bbd0712..f82d21c 100644 --- a/packages/client/src/components/IrcBody.tsx +++ b/packages/client/src/components/IrcBody.tsx @@ -1,53 +1,73 @@ -import { parseIrcText, linkify } from './irc-format.js' +import { parseIrc } from './irc-format.js' -// Renders IRC-formatted text + linkifies URLs as React elements. -// linkify() splits into [text | url] runs (raw text, no HTML escaping). -// parseIrcText() splits text runs into styled sub-segments (raw text). -// React escapes all text content automatically — no dangerouslySetInnerHTML. +// Renders a message body through the unified parseIrc() pipeline: mIRC colors, +// URL links, code fences/spans, and (when selfNick is supplied) an inline +// @mention highlight — all in one raw-text segment list. +// parseIrc() returns RAW (unescaped) text — React escapes all text content +// automatically; never use dangerouslySetInnerHTML. interface IrcBodyProps { text: string + /** Our own nick — occurrences at a nick boundary get the mention chip. */ + selfNick?: string } -export function IrcBody({ text }: IrcBodyProps) { - // First linkify on raw text, then parse each non-link segment for IRC codes. - const linkSegs = linkify(text) +export function IrcBody({ text, selfNick }: IrcBodyProps) { + const segs = parseIrc(text, { selfNick }) return ( <> - {linkSegs.map((ls, li) => { - if (ls.url) { + {segs.map((seg, i) => { + if (seg.kind === 'link') { return ( - - {ls.text} + + {seg.text} ) } - // ls.text is raw (unescaped); parseIrcText returns raw segments too. - // React escapes seg.text when rendered as a child — no double-escaping. - const ircSegs = parseIrcText(ls.text) - return ircSegs.map((seg, si) => { - const style: React.CSSProperties = {} - if (seg.color) style.color = seg.color - if (seg.bg) style.backgroundColor = seg.bg - if (seg.bold) style.fontWeight = 700 - if (seg.italic) style.fontStyle = 'italic' - if (seg.underline) style.textDecoration = 'underline' + + if (seg.kind === 'mention') { return ( - 0 ? style : undefined} - > + {seg.text} ) - }) + } + + if (seg.kind === 'code') { + if (seg.block) { + return ( +
+ {seg.lang && ( +
+ {seg.lang} +
+ )} +
+                  {seg.text}
+                
+
+ ) + } + return ( + + {seg.text} + + ) + } + + // kind === 'text' — raw text with optional mIRC formatting. + const style: React.CSSProperties = {} + if (seg.color) style.color = seg.color + if (seg.bg) style.backgroundColor = seg.bg + if (seg.bold) style.fontWeight = 700 + if (seg.italic) style.fontStyle = 'italic' + if (seg.underline) style.textDecoration = 'underline' + return ( + 0 ? style : undefined}> + {seg.text} + + ) })} ) diff --git a/packages/client/src/components/MainPane.emptystate.test.tsx b/packages/client/src/components/MainPane.emptystate.test.tsx new file mode 100644 index 0000000..2e874c3 --- /dev/null +++ b/packages/client/src/components/MainPane.emptystate.test.tsx @@ -0,0 +1,22 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { useChat } from '../store/chat-store.js' +import { MainPane } from './MainPane.js' + +beforeEach(() => { + useChat.setState(useChat.getInitialState?.() ?? {}, true) +}) + +describe('MainPane — no target selected', () => { + it('renders an EmptyState heading prompting the user to pick a channel', () => { + // selected is null, but a network exists — zero-state routing (B7) only + // kicks in when there are NO networks at all. + useChat.setState({ + networks: { + 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'dmf', connected: true, state: 'registered' }, + }, + }) + render() + expect(screen.getByRole('heading', { name: /pick a channel/i })).toBeInTheDocument() + }) +}) diff --git a/packages/client/src/components/MainPane.test.tsx b/packages/client/src/components/MainPane.test.tsx index ffc5843..7175f83 100644 --- a/packages/client/src/components/MainPane.test.tsx +++ b/packages/client/src/components/MainPane.test.tsx @@ -1,13 +1,118 @@ -import { render, screen } from '@testing-library/react' -import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, beforeEach, vi } from 'vitest' import { MainPane } from './MainPane.js' import { useChat } from '../store/chat-store.js' beforeEach(() => useChat.setState(useChat.getInitialState?.() ?? {}, true) as any) +// Seed store helper — a single connected network, nothing selected +function seedStore() { + useChat.setState({ + networks: { + 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'dmf', connected: true, state: 'registered' }, + }, + targets: {}, + selected: null, + connection: 'open', + messages: {}, + readMarkers: {}, + searchResults: null, + }) +} + describe('MainPane connection indicator', () => { it('renders the ConnDot with a connection aria-label', () => { render() expect(screen.getByLabelText(/connection:/i)).toBeInTheDocument() }) + + it('shows human-readable connection copy, not raw WS states (audit polish)', () => { + useChat.setState({ connection: 'open' }) + const { rerender } = render() + expect(screen.getByLabelText(/connection: connected/i)).toBeInTheDocument() + + useChat.setState({ connection: 'connecting' }) + rerender() + expect(screen.getByLabelText(/connection: connecting…/i)).toBeInTheDocument() + + useChat.setState({ connection: 'closed' }) + rerender() + expect(screen.getByLabelText(/connection: offline — reconnecting/i)).toBeInTheDocument() + }) +}) + +describe('MainPane — "##" channel header (audit polish)', () => { + it('renders the full "##" sigil and bare name, not split as "# #name"', () => { + useChat.setState({ + networks: { + 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'dmf', connected: true, state: 'registered' }, + }, + targets: { + '1:##bool-audit': { + networkId: 1, + target: '##bool-audit', + kind: 'channel', + unread: 0, + }, + }, + selected: '1:##bool-audit', + connection: 'open', + }) + render() + const h1 = screen.getByRole('heading', { level: 1 }) + expect(h1.querySelector('.hash')).toHaveTextContent('##') + expect(h1).toHaveTextContent('##bool-audit') + }) +}) + +describe('MainPane — caps statline (Task 16)', () => { + function seedChannel(caps?: string[]) { + useChat.setState({ + networks: { + 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'dmf', connected: true, state: 'registered', caps }, + }, + targets: { + '1:#python': { networkId: 1, target: '#python', kind: 'channel', unread: 0 }, + }, + selected: '1:#python', + connection: 'open', + }) + } + + it('shows the negotiated caps (filtered to the known set) as a quiet statline', () => { + seedChannel(['away-notify', 'message-tags', 'echo-message', 'labeled-response', 'sasl', 'chghost']) + render() + const stat = document.querySelector('.stat-caps') + expect(stat).toBeInTheDocument() + expect(stat!.textContent).toContain('echo-message') + expect(stat!.textContent).toContain('message-tags') + expect(stat!.textContent).toContain('away-notify') + expect(stat!.textContent).toContain('labeled-response') + // Not in the known set — excluded even though it's a real negotiated cap + expect(stat!.textContent).not.toContain('chghost') + expect(stat!.textContent).not.toContain('sasl') + }) + + it('renders nothing when caps are unknown', () => { + seedChannel(undefined) + render() + expect(document.querySelector('.stat-caps')).not.toBeInTheDocument() + }) +}) + +describe('MainPane — zero-state routing (audit B7)', () => { + it('with zero networks, the empty state routes to the network directory', () => { + // store with NO networks + const onOpen = vi.fn() + render() + expect(screen.getByText(/connect to a network/i)).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /browse networks/i })) + expect(onOpen).toHaveBeenCalled() + }) + + it('with networks but nothing selected, keeps the pick-a-channel copy', () => { + seedStore() // has a network + render( {}} />) + expect(screen.getByText(/pick a channel/i)).toBeInTheDocument() + }) }) diff --git a/packages/client/src/components/MainPane.tsx b/packages/client/src/components/MainPane.tsx index 1fca0a3..4baf40f 100644 --- a/packages/client/src/components/MainPane.tsx +++ b/packages/client/src/components/MainPane.tsx @@ -6,10 +6,18 @@ import { Composer } from './Composer.js' import { AppearanceMenu } from './AppearanceMenu.js' import { ConnDot } from './ConnDot.js' import { TypingLine } from './TypingLine.js' +import { EmptyState } from './primitives/EmptyState.js' -export function MainPane() { +export interface MainPaneProps { + drawerOpen?: boolean + onToggleDrawer?: () => void + onOpenNetworkSettings?: () => void +} + +export function MainPane({ drawerOpen = false, onToggleDrawer, onOpenNetworkSettings }: MainPaneProps = {}) { const selected = useChat((s) => s.selected) const targets = useChat((s) => s.targets) + const networks = useChat((s) => s.networks) const send = useChat((s) => s.send) const [showAppearance, setShowAppearance] = useState(false) const appearanceRef = useRef(null) @@ -40,14 +48,36 @@ export function MainPane() { const topic = target?.topic ?? null const memberCount = target?.names?.length ?? null + const KNOWN_CAPS = ['echo-message', 'message-tags', 'away-notify', 'labeled-response'] + const netId = selected != null ? Number(selected.slice(0, selected.indexOf(':'))) : null + const caps = netId != null ? networks[netId]?.caps : undefined + const shownCaps = caps?.filter((c) => KNOWN_CAPS.includes(c)) ?? [] + + // Channel sigils can be more than one character (e.g. "##bool-audit"); match + // the full run of #/& so the header renders one glyph group, not "# #name". + const sigil = isChannel && name ? (name.match(/^[#&]+/)?.[0] ?? '') : '' + const bareName = isChannel && name ? name.slice(sigil.length) : name + return (
+ {/* Mobile drawer toggle — hidden on wide screens via CSS */} +
{name != null ? (

- {isChannel && #} - {isChannel ? name.slice(1) : name} + {isChannel && {sigil}} + {bareName}

) : (

bool

@@ -59,6 +89,9 @@ export function MainPane() {
)}
+ {isChannel && shownCaps.length > 0 && ( +
{shownCaps.join(' · ')}
+ )} {memberCount != null && (
@@ -105,9 +138,21 @@ export function MainPane() {
) : ( -
- # - Select a channel +
+ {Object.keys(networks).length === 0 ? ( + + ) : ( + + )}
)}
diff --git a/packages/client/src/components/MemberList.test.tsx b/packages/client/src/components/MemberList.test.tsx index a91f12b..1ef62e1 100644 --- a/packages/client/src/components/MemberList.test.tsx +++ b/packages/client/src/components/MemberList.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect, beforeEach } from 'vitest' -import { render, screen } from '@testing-library/react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen, fireEvent, act } from '@testing-library/react' import { useChat, targetKey } from '../store/chat-store.js' import { MemberList } from './MemberList.js' @@ -8,7 +8,7 @@ const KEY = targetKey(1, '#python') function seedWithNames() { useChat.setState({ networks: { - 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'dmf', connected: true }, + 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'dmf', connected: true, state: 'registered' }, }, targets: { [KEY]: { @@ -83,4 +83,80 @@ describe('MemberList', () => { render() expect(screen.getByText('3')).toBeInTheDocument() }) + + it('click a member row opens the UserPopover; Message selects a DM with them', () => { + seedWithNames() + render() + fireEvent.click(screen.getByRole('button', { name: /phillmac/i })) + expect(screen.getByRole('menu', { name: /phillmac/i })).toBeInTheDocument() + fireEvent.click(screen.getByRole('menuitem', { name: 'Message' })) + expect(useChat.getState().selected).toBe(targetKey(1, 'phillmac')) + }) + + // Task 9 review fix: the popover's channel context is frozen at click time from the CURRENT + // selected target — Kick must fire with the concrete channel, never an empty/stale one. + it('op action from the popover dispatches with the concrete selected channel', () => { + seedWithNames() + useChat.setState({ networks: { 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'ChanServ', connected: true, state: 'registered' } } }) + const kick = vi.spyOn(useChat.getState(), 'kick').mockImplementation(() => {}) + render() + fireEvent.click(screen.getByRole('button', { name: /phillmac/i })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Kick' })) + expect(kick).toHaveBeenCalledWith(1, '#python', 'phillmac') + kick.mockRestore() + }) + + it('closes the popover when the selected target changes (e.g. a keyboard-driven selection)', () => { + seedWithNames() + render() + fireEvent.click(screen.getByRole('button', { name: /phillmac/i })) + expect(screen.getByRole('menu', { name: /phillmac/i })).toBeInTheDocument() + act(() => { useChat.setState({ selected: targetKey(1, '#other') }) }) + expect(screen.queryByRole('menu', { name: /phillmac/i })).not.toBeInTheDocument() + }) + + it('dims away members with the .away class (Task 16)', () => { + useChat.setState({ + networks: { + 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'dmf', connected: true, state: 'registered' }, + }, + targets: { + [KEY]: { + networkId: 1, + target: '#python', + kind: 'channel', + unread: 0, + names: [ + { nick: 'ChanServ', modes: ['o', '@'] }, + { nick: 'phillmac', modes: [], away: true }, + ], + }, + }, + selected: KEY, + connection: 'open', + messages: {}, + readMarkers: {}, + searchResults: null, + }) + render() + const away = screen.getByRole('button', { name: /phillmac/i }) + const notAway = screen.getByRole('button', { name: /chanserv/i }) + expect(away.className).toContain('away') + expect(notAway.className).not.toContain('away') + }) + + it('collapse toggle button hides the member list body', () => { + seedWithNames() + let collapsed = false + const onToggle = () => { collapsed = !collapsed } + const { rerender } = render() + // Members are visible initially + expect(screen.getByText('ChanServ')).toBeInTheDocument() + // Click the collapse toggle + fireEvent.click(screen.getByRole('button', { name: /collapse members/i })) + // Re-render with collapsed=true + rerender() + // Members should no longer be visible + expect(screen.queryByText('ChanServ')).not.toBeInTheDocument() + }) }) diff --git a/packages/client/src/components/MemberList.tsx b/packages/client/src/components/MemberList.tsx index 454981d..e40c1fc 100644 --- a/packages/client/src/components/MemberList.tsx +++ b/packages/client/src/components/MemberList.tsx @@ -1,5 +1,8 @@ +import { useEffect, useState, type MouseEvent } from 'react' import { useChat } from '../store/chat-store.js' import type { NameEntry } from '../store/types.js' +import { EmptyState } from './primitives/EmptyState.js' +import { UserPopover } from './UserPopover.js' // ---- Nick color hashing (mirrors MessageRow.tsx) ---- const NICK_PALETTE = [ @@ -32,14 +35,15 @@ interface MemberRowProps { entry: NameEntry sigil?: string sigilColor?: string + onClick: (e: MouseEvent) => void } -function MemberRow({ entry, sigil, sigilColor }: MemberRowProps) { +function MemberRow({ entry, sigil, sigilColor, onClick }: MemberRowProps) { const color = hashNick(entry.nick) const initial = entry.nick[0]?.toUpperCase() ?? '?' return ( -
+ ) } @@ -64,9 +68,10 @@ interface MemberGroupProps { members: NameEntry[] sigil?: string sigilColor?: string + onSelect: (entry: NameEntry, e: MouseEvent) => void } -function MemberGroup({ label, members, sigil, sigilColor }: MemberGroupProps) { +function MemberGroup({ label, members, sigil, sigilColor, onSelect }: MemberGroupProps) { if (members.length === 0) return null return (
@@ -80,15 +85,25 @@ function MemberGroup({ label, members, sigil, sigilColor }: MemberGroupProps) { entry={entry} sigil={sigil} sigilColor={sigilColor} + onClick={(e) => onSelect(entry, e)} /> ))}
) } -export function MemberList() { +interface MemberListProps { + collapsed?: boolean + onToggleCollapse?: () => void +} + +export function MemberList({ collapsed = false, onToggleCollapse }: MemberListProps = {}) { const selected = useChat((s) => s.selected) const targets = useChat((s) => s.targets) + const networks = useChat((s) => s.networks) + const [pop, setPop] = useState< + { networkId: number; channel: string; isChannel: boolean; canOp: boolean; nick: string; x: number; y: number } | null + >(null) const target = selected != null ? targets[selected] : null const names = target?.names ?? [] @@ -99,6 +114,23 @@ export function MemberList() { const totalCount = names.length + const nid = selected != null ? Number(selected.slice(0, selected.indexOf(':'))) : null + const isChannel = target?.kind === 'channel' + const ownNick = nid != null ? networks[nid]?.nick : undefined + const canOp = names.find((n) => n.nick === ownNick)?.modes.includes('o') ?? false + + // Close a stale popover the instant the selected target changes — belt-and-suspenders on top + // of the frozen click-time snapshot below (matches MessageList). + useEffect(() => { + setPop(null) + }, [selected]) + + const openPopover = (entry: NameEntry, e: MouseEvent) => { + if (nid == null || target == null) return + // Freeze the full channel context at click time from the CURRENT selected target. + setPop({ networkId: nid, channel: target.target, isChannel, canOp, nick: entry.nick, x: e.clientX, y: e.clientY }) + } + return ( <>
Members - {totalCount > 0 && ( - {totalCount} - )} +
+ {totalCount > 0 && ( + {totalCount} + )} + +
- {names.length === 0 ? ( -
- {selected == null ? null : 'No members'} -
+ {!collapsed && (names.length === 0 ? ( + selected == null ? null : ( + + ) ) : (
+ ))} + {pop && ( + setPop(null)} + /> )} ) diff --git a/packages/client/src/components/MessageList.test.tsx b/packages/client/src/components/MessageList.test.tsx new file mode 100644 index 0000000..a801f93 --- /dev/null +++ b/packages/client/src/components/MessageList.test.tsx @@ -0,0 +1,248 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { render, screen, act, fireEvent } from '@testing-library/react' +import { useChat, targetKey } from '../store/chat-store.js' +import type { StoredMsg } from '@bool/shared' +import type { WebSocketLike } from '../ws-client.js' +import { MessageList, SKELETON_SETTLE_MS, dayDividerLabel } from './MessageList.js' +import { MessageRow } from './MessageRow.js' + +function fakeSocket() { + const listeners: Record void)[]> = {} + const sock: WebSocketLike = { + send: vi.fn(), + close: vi.fn(), + addEventListener: (t, cb) => ((listeners[t] ??= []).push(cb as any)), + } + const open = () => (listeners['open'] ?? []).forEach((cb) => cb({})) + return { sock, open } +} + +function msg(over: Partial & Pick): StoredMsg { + return { kind: 'privmsg', target: '#bool', ...over } as StoredMsg +} + +function seedChannel(msgs: StoredMsg[]) { + const key = targetKey(1, '#bool') + useChat.setState({ + networks: { 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'dmf', connected: true, state: 'registered' } }, + targets: { [key]: { networkId: 1, target: '#bool', kind: 'channel', unread: 0, names: [] } }, + messages: { [key]: msgs }, + selected: key, + connection: 'open', + }) +} + +beforeEach(() => { + useChat.setState(useChat.getInitialState?.() ?? {}, true) +}) + +describe('MessageList — role=log announcer', () => { + it('wraps messages in a role=log live region with an accessible name', () => { + seedChannel([msg({ id: 1, sender: 'ada', body: 'hello', ts: 1_700_000_000_000 })]) + render() + const log = screen.getByRole('log', { name: /messages/i }) + expect(log).toBeInTheDocument() + expect(log).toHaveAttribute('aria-live', 'polite') + }) +}) + +// Note: react-virtuoso doesn't render list items in jsdom. Grouping logic is +// tested directly via MessageRow, exercising the .msg / .cont rendering paths +// that MessageList.renderRow selects via the same-sender / time-window heuristic. +describe('MessageList — grouping (MessageRow direct)', () => { + const t = 1_700_000_000_000 + + // Case (a): two consecutive same-sender messages within 5 min → second is a grouped continuation. + it('(a) shows the nick header on the first (non-grouped) row', () => { + const m = msg({ id: 1, sender: 'ada', body: 'first', ts: t }) + const { container } = render() + const nicks = Array.from(container.querySelectorAll('.msg-head .nick')) + .filter((el) => el.textContent === 'ada') + expect(nicks).toHaveLength(1) + expect(container.querySelectorAll('.msg').length).toBe(1) + expect(container.querySelectorAll('.cont').length).toBe(0) + }) + + it('(a) omits the nick header on the continuation (grouped) row', () => { + const m = msg({ id: 2, sender: 'ada', body: 'second', ts: t + 1000 }) + const { container } = render() + const nicks = Array.from(container.querySelectorAll('.msg-head .nick')) + .filter((el) => el.textContent === 'ada') + expect(nicks).toHaveLength(0) + expect(container.querySelectorAll('.cont').length).toBe(1) + expect(container.querySelectorAll('.msg').length).toBe(0) + }) + + // Case (b): different senders → NOT grouped — both rows are full .msg rows. + it('(b) does NOT group rows from different senders — both are full .msg rows', () => { + const ada = msg({ id: 10, sender: 'ada', body: 'hi', ts: t }) + const kai = msg({ id: 11, sender: 'kai', body: 'yo', ts: t + 1000 }) + // MessageList.renderRow passes grouped=false when prev.sender !== msg.sender. + // Verify each row renders as a standalone .msg (no .cont). + const { container: c1 } = render() + const { container: c2 } = render() + expect(c1.querySelectorAll('.msg').length).toBe(1) + expect(c1.querySelectorAll('.cont').length).toBe(0) + expect(c2.querySelectorAll('.msg').length).toBe(1) + expect(c2.querySelectorAll('.cont').length).toBe(0) + // Each row shows its own nick header. + expect(c1.querySelector('.msg-head .nick')?.textContent).toBe('ada') + expect(c2.querySelector('.msg-head .nick')?.textContent).toBe('kai') + }) + + // Case (c): same sender but >5 min apart → NOT grouped — renderRow emits grouped=false. + it('(c) does NOT group same-sender rows that are more than 5 minutes apart', () => { + const early = msg({ id: 20, sender: 'ada', body: 'early', ts: t }) + const late = msg({ id: 21, sender: 'ada', body: 'later', ts: t + 6 * 60 * 1000 }) + // ts diff = 360_000 ms > 5 * 60 * 1000 (300_000 ms), so renderRow passes grouped=false. + const { container: c1 } = render() + const { container: c2 } = render() + expect(c1.querySelectorAll('.msg').length).toBe(1) + expect(c2.querySelectorAll('.msg').length).toBe(1) + expect(c2.querySelectorAll('.cont').length).toBe(0) + }) +}) + +// react-virtuoso renders zero itemContent rows in jsdom (0-height container — +// verified empirically), so day dividers are tested the same way grouping is +// above: dayDividerLabel() as a pure function, and MessageRow's `dayDivider` +// prop directly (the same seam MessageList.renderRow feeds at render time). +describe('MessageList — day dividers (Task 15)', () => { + const day1 = new Date('2024-03-14T10:00:00Z').getTime() + const day2 = day1 + 24 * 60 * 60 * 1000 // next calendar day + + it('returns a label for the first row (no prevTs)', () => { + expect(dayDividerLabel(day1, undefined)).toBe('Mar 14') + }) + + it('returns null when ts is the same calendar day as prevTs', () => { + expect(dayDividerLabel(day1 + 60_000, day1)).toBeNull() + }) + + it('returns a (different) label when ts crosses to a new calendar day', () => { + const label1 = dayDividerLabel(day1, undefined) + const label2 = dayDividerLabel(day2, day1) + expect(label2).not.toBeNull() + expect(label2).not.toBe(label1) + }) + + it('labels the current calendar day "Today"', () => { + expect(dayDividerLabel(Date.now(), undefined)).toBe('Today') + }) + + it('MessageRow renders a .daybar before the row when dayDivider is set', () => { + const m = msg({ id: 1, sender: 'ada', body: 'hi', ts: day1 }) + const { container } = render() + const bar = container.querySelector('.daybar') + expect(bar).not.toBeNull() + expect(bar?.textContent).toBe('Mar 14') + }) + + it('MessageRow renders no .daybar when dayDivider is unset', () => { + const m = msg({ id: 1, sender: 'ada', body: 'hi', ts: day1 }) + const { container } = render() + expect(container.querySelector('.daybar')).toBeNull() + }) +}) + +describe('MessageRow — click nick opens UserPopover (Task 9)', () => { + const t = 1_700_000_000_000 + + it('renders the nick as a clickable button and reports the sender + click coords', () => { + const m = msg({ id: 1, sender: 'ada', body: 'hi', ts: t, networkId: 1 }) + const onNickClick = vi.fn() + const { container } = render() + const btn = container.querySelector('.msg-nick-btn') + expect(btn).not.toBeNull() + fireEvent.click(btn!, { clientX: 42, clientY: 7 }) + expect(onNickClick).toHaveBeenCalledWith('ada', 42, 7) + }) + + it('does not render a clickable nick for our own sent messages', () => { + useChat.setState({ + networks: { 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'dmf', connected: true, state: 'registered' } }, + }) + const m = msg({ id: 2, sender: 'dmf', body: 'hi', ts: t, networkId: 1 }) + const { container } = render() + expect(container.querySelector('.msg-nick-btn')).toBeNull() + }) + + it('does not render a clickable nick for the "*" server pseudo-sender', () => { + const m = msg({ id: 3, sender: '*', body: 'MOTD', ts: t, networkId: 1 }) + const { container } = render() + expect(container.querySelector('.msg-nick-btn')).toBeNull() + }) +}) + +describe('MessageList — empty state', () => { + beforeEach(() => { vi.useFakeTimers() }) + afterEach(() => { vi.useRealTimers() }) + + it('shows a "no messages yet" EmptyState for a selected channel with zero messages (after skeleton settles)', () => { + const key = targetKey(1, '#bool') + useChat.setState({ + networks: { 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'dmf', connected: true, state: 'registered' } }, + targets: { [key]: { networkId: 1, target: '#bool', kind: 'channel', unread: 0, names: [] } }, + messages: { [key]: [] }, + selected: key, + connection: 'open', + }) + render() + // Advance past the skeleton settle timeout + act(() => { vi.advanceTimersByTime(SKELETON_SETTLE_MS + 1) }) + expect(screen.getByRole('heading', { name: /no messages yet/i })).toBeInTheDocument() + expect(screen.getByText(/be the first to say something/i)).toBeInTheDocument() + }) +}) + +describe('MessageList — history-start orientation note (audit F7)', () => { + it('shows a history-start note at the top of a channel with messages', () => { + seedChannel([msg({ id: 1, sender: 'ada', body: 'hello', ts: 1_700_000_000_000 })]) + render() + expect(screen.getByText(/beginning of your history in/i)).toBeInTheDocument() + }) + + it('names the channel and explains why history starts here', () => { + seedChannel([msg({ id: 1, sender: 'ada', body: 'hello', ts: 1_700_000_000_000 })]) + render() + expect(screen.getByText(/beginning of your history in #bool/i)).toBeInTheDocument() + expect(screen.getByText(/irc shows messages from the moment you joined/i)).toBeInTheDocument() + }) + + // Final review: the note's copy is channel-framed ("since you joined"), which is + // meaningless for a DM (no join event) — the note must not render for pm targets. + it('does not show the history-start note for a DM (final review)', () => { + const key = targetKey(1, 'ada') + useChat.setState({ + networks: { 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'dmf', connected: true, state: 'registered' } }, + targets: { [key]: { networkId: 1, target: 'ada', kind: 'pm', unread: 0 } }, + messages: { [key]: [msg({ id: 1, sender: 'ada', body: 'hi', ts: 1_700_000_000_000, target: 'ada' })] }, + selected: key, + connection: 'open', + }) + render() + expect(screen.queryByText(/beginning of your history in/i)).not.toBeInTheDocument() + }) +}) + +describe('MessageList — history load on select (restored/session:targets targets)', () => { + it('selecting a target with zero messages sends history:request', () => { + const { sock, open } = fakeSocket() + useChat.getState().init(() => sock) + open() + // Simulate a target restored from session:targets: it exists but has no messages loaded yet. + const key = targetKey(1, '#restored') + useChat.setState({ + networks: { 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'dmf', connected: true, state: 'registered' } }, + targets: { [key]: { networkId: 1, target: '#restored', kind: 'channel', unread: 2 } }, + connection: 'open', + }) + + render() + act(() => { useChat.getState().select(key) }) + + expect(sock.send).toHaveBeenCalledWith( + JSON.stringify({ type: 'history:request', networkId: 1, target: '#restored' }), + ) + }) +}) diff --git a/packages/client/src/components/MessageList.tsx b/packages/client/src/components/MessageList.tsx index 0720162..3141db2 100644 --- a/packages/client/src/components/MessageList.tsx +++ b/packages/client/src/components/MessageList.tsx @@ -1,12 +1,42 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' import type { StoredMsg } from '@bool/shared' import { useChat } from '../store/chat-store.js' import { MessageRow } from './MessageRow.js' +import { LiveLog } from './primitives/LiveLog.js' +import { EmptyState } from './primitives/EmptyState.js' +import { HistorySkeleton } from './HistorySkeleton.js' +import { UserPopover } from './UserPopover.js' + +/** Heuristic settle time (ms) for detecting an empty channel vs. one still loading history. + * Replace with a real `loadingHistory` store flag when the store exposes one. */ +export const SKELETON_SETTLE_MS = 4000 + +/** Day-divider label for `ts`, given the previous row's `ts` (undefined for the + * first row). Returns null when `ts` falls on the same calendar day as `prevTs` + * (no divider needed). Exported for direct testing — react-virtuoso doesn't + * render itemContent rows in jsdom (0-height container), so this pure function + * (and MessageRow's `dayDivider` prop) are the testable seam; see + * MessageList.test.tsx's "grouping (MessageRow direct)" tests for the same pattern. */ +export function dayDividerLabel(ts: number, prevTs: number | undefined): string | null { + const d = new Date(ts) + if (prevTs != null && d.toDateString() === new Date(prevTs).toDateString()) return null + + const today = new Date() + if (d.toDateString() === today.toDateString()) return 'Today' + + const yesterday = new Date(today) + yesterday.setDate(yesterday.getDate() - 1) + if (d.toDateString() === yesterday.toDateString()) return 'Yesterday' + + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) +} export function MessageList() { const selected = useChat((s) => s.selected) const messages = useChat((s) => s.messages) + const targets = useChat((s) => s.targets) + const networks = useChat((s) => s.networks) const loadHistory = useChat((s) => s.loadHistory) const markRead = useChat((s) => s.markRead) @@ -15,6 +45,23 @@ export function MessageList() { const prevSelected = useRef(null) // Debounce timer for marking read on incoming messages const markReadTimer = useRef | null>(null) + const [loadingHistory, setLoadingHistory] = useState(false) + const [pop, setPop] = useState< + { networkId: number; channel: string; isChannel: boolean; canOp: boolean; nick: string; x: number; y: number } | null + >(null) + + const nid = selected != null ? Number(selected.slice(0, selected.indexOf(':'))) : null + const selectedTarget = selected != null ? targets[selected] : null + const ownNick = nid != null ? networks[nid]?.nick : undefined + const isChannel = selectedTarget?.kind === 'channel' + const canOp = (selectedTarget?.names ?? []).find((n) => n.nick === ownNick)?.modes.includes('o') ?? false + + // A stale popover must never act on a context that no longer matches what the user clicked — + // close it the instant the selected target changes (covers keyboard-driven selection changes, + // e.g. Cmd-K, which fire no mousedown and so never trigger the popover's outside-click-close). + useEffect(() => { + setPop(null) + }, [selected]) // When selected target changes: load history + mark read immediately (no debounce) useEffect(() => { @@ -43,6 +90,18 @@ export function MessageList() { } }, [msgs.length, selected, markRead]) + // Show history skeleton when a target is selected and we have zero messages (loading) + useEffect(() => { + if (!selected) return + if (msgs.length === 0) { + setLoadingHistory(true) + const id = setTimeout(() => setLoadingHistory(false), SKELETON_SETTLE_MS) + return () => clearTimeout(id) + } + setLoadingHistory(false) + return + }, [selected, msgs.length]) + if (!selected) return null function renderRow(index: number) { @@ -54,54 +113,89 @@ export function MessageList() { prev.sender === msg.sender && prev.kind === msg.kind && msg.kind !== 'notice' && + msg.kind !== 'system' && msg.ts - prev.ts < 5 * 60 * 1000 // 5 min threshold for grouping - return + const dayDivider = dayDividerLabel(msg.ts, prev?.ts) + return ( + { + if (nid == null) return + // Freeze the full channel context (networkId, channel, isChannel, canOp) at click time + // from the CURRENT selected target — never from the clicked message's own networkId, + // and never re-derived later from a live store value. + setPop({ networkId: nid, channel: selectedTarget?.target ?? '', isChannel, canOp, nick, x, y }) + }} + /> + ) + } + + if (msgs.length === 0 && loadingHistory) { + return
} if (msgs.length === 0) { return ( -
- No messages yet +
+
) } return ( - { - if (atTop && selected) { - loadHistory(selected) - } - }} - components={{ - // Scroll container padding - List: ({ style, children, ...props }) => ( -
- {children} -
- ), - }} - /> + + { + if (atTop && selected) { + loadHistory(selected) + } + }} + components={{ + // Scroll container padding + List: ({ style, children, ...props }) => ( +
+ {children} +
+ ), + Header: () => + isChannel ? ( +
+ + Beginning of your history in {selectedTarget?.target ?? selected} — IRC shows messages + from the moment you joined. + +
+ ) : null, + }} + /> + {pop && ( + setPop(null)} + /> + )} +
) } diff --git a/packages/client/src/components/MessageRow.test.tsx b/packages/client/src/components/MessageRow.test.tsx new file mode 100644 index 0000000..7ff552d --- /dev/null +++ b/packages/client/src/components/MessageRow.test.tsx @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render } from '@testing-library/react' +import { useChat } from '../store/chat-store.js' +import type { StoredMsg } from '@bool/shared' +import { MessageRow } from './MessageRow.js' + +function msg(over: Partial & Pick): StoredMsg { + return { kind: 'privmsg', target: '#bool', networkId: 1, ...over } as StoredMsg +} + +beforeEach(() => { + useChat.setState(useChat.getInitialState?.() ?? {}, true) +}) + +describe('MessageRow — system lines (Task 15)', () => { + it('renders a kind:"system" message as a single muted .sys line', () => { + const m = msg({ id: 1, sender: '', body: '→ ada joined', ts: Date.now(), kind: 'system' }) + const { container } = render() + const sys = container.querySelector('.sys') + expect(sys).not.toBeNull() + expect(sys?.textContent).toContain('ada joined') + }) + + it('renders no avatar/nick-head and no reply/reactions affordances for a system line', () => { + const m = msg({ id: 1, sender: '', body: '← bob left', ts: Date.now(), kind: 'system', msgid: 'm1' }) + const { container } = render() + expect(container.querySelector('.msg-head')).toBeNull() + expect(container.querySelector('.reply-btn')).toBeNull() + expect(container.querySelector('.msg')).toBeNull() + expect(container.querySelector('.cont')).toBeNull() + }) +}) + +describe('MessageRow — inline mention highlight + self-mention row tint (Task 15)', () => { + it('wraps our nick in a .mention-inline chip', () => { + useChat.setState({ + networks: { 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'dmf', connected: true, state: 'registered' } }, + }) + const m = msg({ id: 1, sender: 'ada', body: 'hey dmf: look at this', ts: Date.now() }) + const { container } = render() + const chip = container.querySelector('.mention-inline') + expect(chip).not.toBeNull() + expect(chip?.textContent).toBe('dmf') + }) + + it('adds "self-mention" to the row class when the body mentions our nick', () => { + useChat.setState({ + networks: { 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'dmf', connected: true, state: 'registered' } }, + }) + const m = msg({ id: 1, sender: 'ada', body: 'hey dmf: look at this', ts: Date.now() }) + const { container } = render() + expect(container.querySelector('.msg.self-mention')).not.toBeNull() + }) + + it('does NOT add self-mention when the body does not mention our nick', () => { + useChat.setState({ + networks: { 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'dmf', connected: true, state: 'registered' } }, + }) + const m = msg({ id: 1, sender: 'ada', body: 'no mention here', ts: Date.now() }) + const { container } = render() + expect(container.querySelector('.self-mention')).toBeNull() + expect(container.querySelector('.mention-inline')).toBeNull() + }) +}) + +describe('MessageRow — code fences (Task 15)', () => { + it('renders a triple-backtick fence as a .code block with a .code-h language header', () => { + const m = msg({ id: 1, sender: 'ada', body: '```python\nprint(1)\n```', ts: Date.now() }) + const { container } = render() + const block = container.querySelector('.code') + expect(block).not.toBeNull() + const header = container.querySelector('.code-h .lang') + expect(header?.textContent).toBe('python') + expect(block?.textContent).toContain('print(1)') + }) + + it('renders inline `code` spans as a monospace code chip (not a .code block)', () => { + const m = msg({ id: 1, sender: 'ada', body: 'use `git rebase` carefully', ts: Date.now() }) + const { container } = render() + const inline = container.querySelector('code.code-inline') + expect(inline).not.toBeNull() + expect(inline?.textContent).toBe('git rebase') + expect(container.querySelector('.code')).toBeNull() + }) +}) diff --git a/packages/client/src/components/MessageRow.tsx b/packages/client/src/components/MessageRow.tsx index cd64b21..19a66df 100644 --- a/packages/client/src/components/MessageRow.tsx +++ b/packages/client/src/components/MessageRow.tsx @@ -2,7 +2,7 @@ import type { StoredMsg } from '@bool/shared' import { IrcBody } from './IrcBody.js' import { Reactions } from './Reactions.js' import { ReplyContext } from './ReplyContext.js' -import { useChat } from '../store/chat-store.js' +import { useChat, mentionsNick } from '../store/chat-store.js' import { linkify } from './irc-format.js' import { InlineMedia, isImageUrl } from './InlineMedia.js' import { LinkPreview } from './LinkPreview.js' @@ -95,53 +95,118 @@ export interface MessageRowProps { msg: StoredMsg /** When true, this is a consecutive message from the same sender; hide nick/meta */ grouped?: boolean + /** Called with (sender, clientX, clientY) when the sender nick is clicked. Opens a UserPopover. */ + onNickClick?: (nick: string, x: number, y: number) => void + /** When set, a `.daybar` divider (e.g. "Today", "Yesterday", "Mar 14") is rendered + * above this row — piggybacked on MessageList's row mapping, not a fake data row. */ + dayDivider?: string | null } -export function MessageRow({ msg, grouped }: MessageRowProps) { +export function MessageRow({ msg, grouped, onNickClick, dayDivider }: MessageRowProps) { const time = formatTime(msg.ts) const nickColor = hashNick(msg.sender) + const ownNick = useChat((s) => s.networks[msg.networkId]?.nick) + const nickClickable = onNickClick != null && msg.sender !== '' && msg.sender !== '*' && msg.sender !== ownNick + const isSelfMention = msg.kind !== 'system' && mentionsNick(msg.body, ownNick) + + const daybar = dayDivider ? ( +
+ {dayDivider} +
+ ) : null + + if (msg.kind === 'system') { + return ( + <> + {daybar} +
+ {time} + {msg.body} +
+ + ) + } if (msg.kind === 'action') { if (grouped) { return ( -
+ <> + {daybar} +
+
{time}
+
+ {msg.replyTo && } +
+ * {msg.sender}{' '} + +
+ + {msg.msgid && } +
+ {msg.msgid && } +
+ + ) + } + return ( + <> + {daybar} +
{time}
{msg.replyTo && }
* {msg.sender}{' '} - +
{msg.msgid && }
{msg.msgid && }
- ) - } - return ( -
-
{time}
-
- {msg.replyTo && } -
- * {msg.sender}{' '} - -
- - {msg.msgid && } -
- {msg.msgid && } -
+ ) } if (msg.kind === 'notice') { if (grouped) { return ( -
+ <> + {daybar} +
+
{time}
+
+ {msg.replyTo && } +
+ +
+ + {msg.msgid && } +
+ {msg.msgid && } +
+ + ) + } + return ( + <> + {daybar} +
{time}
+
+ + -{msg.sender}- + +
{msg.replyTo && }
- +
{msg.msgid && }
{msg.msgid && }
- ) - } - return ( -
-
{time}
-
-
- - -{msg.sender}- - -
- {msg.replyTo && } -
- -
- - {msg.msgid && } -
- {msg.msgid && } -
+ ) } // Default: regular privmsg if (grouped) { return ( -
+ <> + {daybar} +
+
{time}
+
+ {msg.replyTo && } +
+ +
+ + {msg.msgid && } +
+ {msg.msgid && } +
+ + ) + } + + return ( + <> + {daybar} +
{time}
+
+ {nickClickable ? ( + + ) : ( + + {msg.sender} + + )} +
{msg.replyTo && }
- +
{msg.msgid && }
{msg.msgid && }
- ) - } - - return ( -
-
{time}
-
-
- - {msg.sender} - -
- {msg.replyTo && } -
- -
- - {msg.msgid && } -
- {msg.msgid && } -
+ ) } diff --git a/packages/client/src/components/NetworkDirectory.test.tsx b/packages/client/src/components/NetworkDirectory.test.tsx new file mode 100644 index 0000000..5d410ef --- /dev/null +++ b/packages/client/src/components/NetworkDirectory.test.tsx @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, fireEvent, act, waitFor } from '@testing-library/react' +import { useChat } from '../store/chat-store.js' +import { NetworkDirectory } from './NetworkDirectory.js' + +beforeEach(() => { + useChat.setState((useChat as any).getInitialState?.() ?? {}, true) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('NetworkDirectory', () => { + it('lists curated networks from the catalog', () => { + render( {}} />) + expect(screen.getByRole('button', { name: /libera\.chat/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /oftc/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /snoonet/i })).toBeInTheDocument() + }) + + it('filters the list by name/description as the user types', () => { + render( {}} />) + fireEvent.change(screen.getByRole('searchbox', { name: /search networks/i }), { + target: { value: 'anime' }, // matches Rizon's description + }) + expect(screen.getByRole('button', { name: /rizon/i })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /libera\.chat/i })).not.toBeInTheDocument() + }) + + it('shows an EmptyState with a single CTA when the search matches nothing', () => { + render( {}} />) + fireEvent.change(screen.getByRole('searchbox', { name: /search networks/i }), { + target: { value: 'zzzzzz-no-match' }, + }) + expect(screen.getByRole('heading', { name: /no networks/i })).toBeInTheDocument() + // exactly one CTA in the empty state + expect(screen.getByRole('button', { name: /clear search|browse/i })).toBeInTheDocument() + }) + + it('selecting a network reveals the nick capture step for that network', () => { + render( {}} />) + fireEvent.click(screen.getByRole('button', { name: /libera\.chat/i })) + expect(screen.getByText(/irc\.libera\.chat:6697/i)).toBeInTheDocument() + expect(screen.getByLabelText(/nick/i)).toBeInTheDocument() + }) + + it('labels the nick field "Nickname" with a plain-English helper line (audit polish)', () => { + render( {}} />) + fireEvent.click(screen.getByRole('button', { name: /libera\.chat/i })) + expect(screen.getByLabelText(/nickname/i)).toBeInTheDocument() + expect( + screen.getByText(/this is your public name on this network/i), + ).toBeInTheDocument() + }) + + it('calls addNetwork with the catalog host/port/tls and the chosen nick', () => { + const addNetwork = vi.spyOn(useChat.getState(), 'addNetwork').mockImplementation(() => {}) + render( {}} />) + fireEvent.click(screen.getByRole('button', { name: /libera\.chat/i })) + fireEvent.change(screen.getByLabelText(/nick/i), { target: { value: 'ada' } }) + fireEvent.click(screen.getByRole('button', { name: /^connect$/i })) + expect(addNetwork).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Libera.Chat', + host: 'irc.libera.chat', + port: 6697, + tls: true, + nick: 'ada', + }), + ) + }) + + it('AUTO-CONNECTS: calls connectNetwork once the added network appears in the store — even after the component unmounts', async () => { + const addNetwork = vi.spyOn(useChat.getState(), 'addNetwork').mockImplementation(() => {}) + const connectNetwork = vi.spyOn(useChat.getState(), 'connectNetwork').mockImplementation(() => {}) + + // onClose is a no-op here; we unmount manually below to simulate the real + // Dialog behaviour (the dialog unmounts NetworkDirectory on close). + const { unmount } = render( {}} />) + + fireEvent.click(screen.getByRole('button', { name: /libera\.chat/i })) + fireEvent.change(screen.getByLabelText(/nick/i), { target: { value: 'ada' } }) + fireEvent.click(screen.getByRole('button', { name: /^connect$/i })) + + expect(addNetwork).toHaveBeenCalled() + // connect has NOT fired yet — the network id is unknown until the server replies + expect(connectNetwork).not.toHaveBeenCalled() + + // Simulate the Dialog unmounting NetworkDirectory (the real production flow). + // The subscription must survive this unmount — that's the bug being fixed. + unmount() + + // Simulate the server's net:list reply landing the network in the store + // AFTER the component is gone. + act(() => { + useChat.setState({ + networks: { + 7: { id: 7, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'ada', connected: false, state: 'closed' }, + }, + }) + }) + + await waitFor(() => expect(connectNetwork).toHaveBeenCalledWith(7)) + }) + + it('connects immediately if the host already exists in the store when selected', async () => { + // Pre-existing (previously added, disconnected) Libera network + useChat.setState({ + networks: { + 3: { id: 3, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'ada', connected: false, state: 'closed' }, + }, + }) + const connectNetwork = vi.spyOn(useChat.getState(), 'connectNetwork').mockImplementation(() => {}) + render( {}} />) + fireEvent.click(screen.getByRole('button', { name: /libera\.chat/i })) + fireEvent.change(screen.getByLabelText(/nick/i), { target: { value: 'ada' } }) + fireEvent.click(screen.getByRole('button', { name: /^connect$/i })) + await waitFor(() => expect(connectNetwork).toHaveBeenCalledWith(3)) + }) + + it('reveals the advanced NetworkSettings form and can return to the directory', () => { + render( {}} />) + fireEvent.click(screen.getByRole('button', { name: /advanced/i })) + // The advanced form has a Host field the directory does not + expect(screen.getByLabelText(/^host$/i)).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /back to directory/i })) + // Back in the directory + expect(screen.getByRole('button', { name: /libera\.chat/i })).toBeInTheDocument() + }) +}) diff --git a/packages/client/src/components/NetworkDirectory.tsx b/packages/client/src/components/NetworkDirectory.tsx new file mode 100644 index 0000000..697a755 --- /dev/null +++ b/packages/client/src/components/NetworkDirectory.tsx @@ -0,0 +1,249 @@ +import { useState, useMemo } from 'react' +import { useChat } from '../store/chat-store.js' +import { NETWORK_CATALOG, type CatalogNetwork } from '../network-catalog.js' +import { EmptyState } from './primitives/EmptyState.js' +import { Field } from './primitives/Field.js' +import { NetworkSettings } from './NetworkSettings.js' + +export interface NetworkDirectoryProps { + onClose: () => void +} + +/** Two-letter mark, mirroring Sidebar's netAbbr. */ +function mark(name: string): string { + const words = name.split(/[\s.-]+/).filter(Boolean) + if (words.length >= 2) return (words[0]![0]! + words[1]![0]!).toUpperCase() + return name.slice(0, 2).toUpperCase() +} + +type Mode = 'list' | 'nick' | 'advanced' + +const AUTO_CONNECT_TIMEOUT_MS = 30_000 + +/** + * Set up a one-shot subscription that calls connectNetwork once a network + * whose host matches `host` appears in the store. Lives entirely outside the + * component lifecycle so it survives the Dialog unmount that happens right + * after addNetwork() is called. + * + * If the network never arrives within AUTO_CONNECT_TIMEOUT_MS the subscription + * is silently dropped to avoid a permanent leak. + */ +function scheduleAutoConnect(host: string): void { + const findId = (nets: Record) => + Object.values(nets).find((n) => n.host === host)?.id ?? null + + // Fast path: already present in the store right now. + const existing = findId(useChat.getState().networks) + if (existing != null) { + useChat.getState().connectNetwork(existing) + return + } + + // Deferred path: wait for the server's net:list/net:state reply. + let unsub: (() => void) | null = null + + const timer = setTimeout(() => { + unsub?.() + unsub = null + }, AUTO_CONNECT_TIMEOUT_MS) + + unsub = useChat.subscribe((state) => { + const id = findId(state.networks) + if (id != null) { + clearTimeout(timer) + unsub?.() + unsub = null + useChat.getState().connectNetwork(id) + } + }) +} + +export function NetworkDirectory({ onClose }: NetworkDirectoryProps) { + const [mode, setMode] = useState('list') + const [query, setQuery] = useState('') + const [selected, setSelected] = useState(null) + const [nick, setNick] = useState('') + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase() + if (!q) return NETWORK_CATALOG + return NETWORK_CATALOG.filter( + (n) => n.name.toLowerCase().includes(q) || n.description.toLowerCase().includes(q), + ) + }, [query]) + + function pick(net: CatalogNetwork) { + setSelected(net) + setMode('nick') + } + + function handleConnect() { + if (!selected) return + const n = nick.trim() + if (!n) return + useChat.getState().addNetwork({ + name: selected.name, + host: selected.host, + port: selected.port, + tls: selected.tls, + nick: n, + }) + scheduleAutoConnect(selected.host) + onClose() + } + + // ---- Advanced (custom server) leaf ---- + if (mode === 'advanced') { + return setMode('list')} /> + } + + // ---- Nick capture ---- + if (mode === 'nick' && selected) { + return ( +
+

+ Connecting to{' '} + {selected.host}:{selected.port} over TLS +

+ (v.trim().length === 0 ? 'Pick a nick to continue' : null)} + /> +

+ This is your public name on this network. If it's taken, we'll add a number. +

+
+ + +
+
+ ) + } + + // ---- Directory list ---- + return ( +
+
+ / + setQuery(e.target.value)} + autoFocus + style={{ + flex: 1, background: 'none', border: 'none', outline: 'none', + color: 'var(--ink-0)', fontFamily: 'var(--sans)', fontSize: 14, + }} + /> +
+ + {filtered.length === 0 ? ( + setQuery('') }} + /> + ) : ( +
+ {filtered.map((net) => ( + + ))} +
+ )} + +
+ +
+
+ ) +} diff --git a/packages/client/src/components/NetworkSettings.test.tsx b/packages/client/src/components/NetworkSettings.test.tsx index d4ef3b0..25792ad 100644 --- a/packages/client/src/components/NetworkSettings.test.tsx +++ b/packages/client/src/components/NetworkSettings.test.tsx @@ -15,7 +15,7 @@ afterEach(() => { function seedNetwork() { useChat.setState({ networks: { - 42: { id: 42, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'dmf', connected: true }, + 42: { id: 42, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'dmf', connected: true, state: 'registered' }, }, }) } @@ -111,7 +111,7 @@ describe('NetworkSettings', () => { it('shows a Connect button for disconnected networks', () => { useChat.setState({ networks: { - 5: { id: 5, name: 'TestNet', host: 'irc.test.net', nick: 'me', connected: false }, + 5: { id: 5, name: 'TestNet', host: 'irc.test.net', nick: 'me', connected: false, state: 'closed' }, }, }) render( {}} />) @@ -131,4 +131,16 @@ describe('NetworkSettings', () => { expect(screen.getByLabelText(/sasl account/i)).toBeInTheDocument() expect(screen.getByLabelText(/sasl password/i)).toBeInTheDocument() }) + + it('renders a Back button that calls onBack when provided', () => { + const onBack = vi.fn() + render( {}} onBack={onBack} />) + fireEvent.click(screen.getByRole('button', { name: /back to directory/i })) + expect(onBack).toHaveBeenCalledOnce() + }) + + it('renders no Back button when onBack is absent', () => { + render( {}} />) + expect(screen.queryByRole('button', { name: /back to directory/i })).not.toBeInTheDocument() + }) }) diff --git a/packages/client/src/components/NetworkSettings.tsx b/packages/client/src/components/NetworkSettings.tsx index 54d6385..e78e97f 100644 --- a/packages/client/src/components/NetworkSettings.tsx +++ b/packages/client/src/components/NetworkSettings.tsx @@ -7,6 +7,8 @@ import { useChat } from '../store/chat-store.js' export interface NetworkSettingsProps { onClose: () => void + /** When provided, render a "Back to directory" affordance (advanced-leaf mode). */ + onBack?: () => void } // --------------------------------------------------------------------------- @@ -99,7 +101,7 @@ const submitBtnStyle: React.CSSProperties = { // NetworkSettings // --------------------------------------------------------------------------- -export function NetworkSettings({ onClose }: NetworkSettingsProps) { +export function NetworkSettings({ onClose, onBack }: NetworkSettingsProps) { const networks = useChat((s) => s.networks) // Form state @@ -145,6 +147,20 @@ export function NetworkSettings({ onClose }: NetworkSettingsProps) { return (
+ {onBack && ( + + )} {/* Add Network Form */}
diff --git a/packages/client/src/components/Reactions.test.tsx b/packages/client/src/components/Reactions.test.tsx index 97df1ec..02eea71 100644 --- a/packages/client/src/components/Reactions.test.tsx +++ b/packages/client/src/components/Reactions.test.tsx @@ -24,7 +24,7 @@ describe('Reactions', () => { function seed(reactionMap: Record) { useChat.setState({ selected: SELECTED, - networks: { 1: { id: 1, name: 'Freenode', host: 'irc.freenode.net', nick: 'me', connected: true } }, + networks: { 1: { id: 1, name: 'Freenode', host: 'irc.freenode.net', nick: 'me', connected: true, state: 'registered' } }, reactions: { [MSGID]: reactionMap }, }) } @@ -106,10 +106,20 @@ describe('Reactions', () => { expect(reactMock).toHaveBeenCalledWith(MSGID, '🎉', true) }) + it('the add-reaction button stays in the DOM for hover/focus-reveal via CSS, not JS (audit polish)', () => { + seed({ '👍': ['alice'] }) + render() + const addBtn = screen.getByRole('button', { name: /add reaction/i }) + // Visibility must be CSS-controlled (.rx-add hover/focus-visible rules) — the + // button itself is always present so keyboard/AT users can reach it. + expect(addBtn).toHaveClass('rx-add') + expect(addBtn).toBeInTheDocument() + }) + it('renders the + button even when there are no reactions (and msgid is provided)', () => { useChat.setState({ selected: SELECTED, - networks: { 1: { id: 1, name: 'Freenode', host: 'irc.freenode.net', nick: 'me', connected: true } }, + networks: { 1: { id: 1, name: 'Freenode', host: 'irc.freenode.net', nick: 'me', connected: true, state: 'registered' } }, reactions: {}, }) render() diff --git a/packages/client/src/components/Responsive.test.tsx b/packages/client/src/components/Responsive.test.tsx new file mode 100644 index 0000000..8535864 --- /dev/null +++ b/packages/client/src/components/Responsive.test.tsx @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen, fireEvent, act } from '@testing-library/react' +import { useChat, targetKey } from '../store/chat-store.js' +import { AppShell } from './AppShell.js' +import { MemberList } from './MemberList.js' + +beforeEach(() => { + useChat.setState(useChat.getInitialState?.() ?? {}, true) +}) + +function seedChannel(target = '#bool') { + const key = targetKey(1, target) + useChat.setState({ + networks: { 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'dmf', connected: true, state: 'registered' } }, + targets: { [key]: { networkId: 1, target, kind: 'channel', unread: 0, names: [] } }, + messages: { [key]: [] }, + selected: key, + connection: 'open', + }) + return key +} + +describe('Responsive shell', () => { + it('exposes a mobile "channels" drawer toggle that opens the sidebar drawer', () => { + seedChannel() + const { container } = render() + const toggle = screen.getByRole('button', { name: /channels/i }) + expect(toggle).toHaveAttribute('aria-expanded', 'false') + fireEvent.click(toggle) + expect(toggle).toHaveAttribute('aria-expanded', 'true') + expect(container.querySelector('.app.drawer-open')).toBeInTheDocument() + }) + + it('drawer auto-closes when the selected channel changes', () => { + seedChannel('#bool') + const { container } = render() + // Open the drawer + fireEvent.click(screen.getByRole('button', { name: /channels/i })) + expect(container.querySelector('.app.drawer-open')).toBeInTheDocument() + // Simulate selecting a different channel (useEffect on [selected] closes the drawer) + act(() => { + const key2 = targetKey(1, '#other') + useChat.setState({ + targets: { + ...useChat.getState().targets, + [key2]: { networkId: 1, target: '#other', kind: 'channel', unread: 0, names: [] }, + }, + messages: { ...useChat.getState().messages, [key2]: [] }, + selected: key2, + }) + }) + expect(container.querySelector('.app.drawer-open')).not.toBeInTheDocument() + }) + + it('member list shows an EmptyState when the channel has no members', () => { + seedChannel() + render() + expect(screen.getByRole('heading', { name: /no one here yet/i })).toBeInTheDocument() + }) +}) diff --git a/packages/client/src/components/SearchPanel.test.tsx b/packages/client/src/components/SearchPanel.test.tsx index e908420..bef4a0a 100644 --- a/packages/client/src/components/SearchPanel.test.tsx +++ b/packages/client/src/components/SearchPanel.test.tsx @@ -29,8 +29,8 @@ function seedResults() { ], }, networks: { - 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'me', connected: true }, - 2: { id: 2, name: 'OFTC', host: 'irc.oftc.net', nick: 'me', connected: true }, + 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'me', connected: true, state: 'registered' }, + 2: { id: 2, name: 'OFTC', host: 'irc.oftc.net', nick: 'me', connected: true, state: 'registered' }, }, targets: {}, messages: {}, diff --git a/packages/client/src/components/ShortcutCheatsheet.test.tsx b/packages/client/src/components/ShortcutCheatsheet.test.tsx new file mode 100644 index 0000000..4a13fbe --- /dev/null +++ b/packages/client/src/components/ShortcutCheatsheet.test.tsx @@ -0,0 +1,55 @@ +// packages/client/src/components/ShortcutCheatsheet.test.tsx +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent, renderHook, act } from '@testing-library/react' +import { ShortcutCheatsheet, useCheatsheetShortcut } from './ShortcutCheatsheet.js' +import { PALETTE_COMMANDS } from '../palette-commands.js' + +describe('ShortcutCheatsheet', () => { + it('is a labelled modal dialog listing keyboard shortcuts', () => { + render( {}} />) + const dialog = screen.getByRole('dialog', { name: /keyboard shortcuts|cheatsheet/i }) + expect(dialog).toHaveAttribute('aria-modal') + // The palette shortcut is documented, rendered inside . + expect(screen.getAllByText((_t, el) => el?.tagName.toLowerCase() === 'kbd').length).toBeGreaterThan(0) + }) + + it('lists every command from the catalog (no drift)', () => { + render( {}} />) + for (const c of PALETTE_COMMANDS) { + expect(screen.getByText(c.name)).toBeInTheDocument() + } + }) + + it('closes on Escape', () => { + const onClose = vi.fn() + render() + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) + expect(onClose).toHaveBeenCalled() + }) + + it('renders nothing when closed', () => { + render( {}} />) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) +}) + +describe('useCheatsheetShortcut', () => { + it('opens on "?" when not typing in an input/textarea', () => { + const { result } = renderHook(() => useCheatsheetShortcut()) + expect(result.current[0]).toBe(false) + act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: '?' })) }) + expect(result.current[0]).toBe(true) + }) + + it('does NOT open when the event target is an input', () => { + const { result } = renderHook(() => useCheatsheetShortcut()) + const input = document.createElement('input') + document.body.appendChild(input) + input.focus() + act(() => { + input.dispatchEvent(new KeyboardEvent('keydown', { key: '?', bubbles: true })) + }) + expect(result.current[0]).toBe(false) + input.remove() + }) +}) diff --git a/packages/client/src/components/ShortcutCheatsheet.tsx b/packages/client/src/components/ShortcutCheatsheet.tsx new file mode 100644 index 0000000..248443d --- /dev/null +++ b/packages/client/src/components/ShortcutCheatsheet.tsx @@ -0,0 +1,82 @@ +// packages/client/src/components/ShortcutCheatsheet.tsx +import { useState, useEffect, useCallback } from 'react' +import { Kbd } from './primitives/Kbd.js' +import { useFocusRestore } from './Dialog.js' +import { PALETTE_COMMANDS } from '../palette-commands.js' + +// Global "?" opener (ignored while typing in a field). Also listens for the +// bool:open-cheatsheet document event so the palette's "Keyboard shortcuts" +// row (which dispatches it) opens the same overlay. +export function useCheatsheetShortcut(): [boolean, () => void, () => void] { + const [open, setOpen] = useState(false) + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if (e.key !== '?') return + const t = e.target as HTMLElement | null + const tag = t?.tagName?.toLowerCase() + if (tag === 'input' || tag === 'textarea' || t?.isContentEditable) return + e.preventDefault() + setOpen(true) + } + function onOpenCheatsheet() { + setOpen(true) + } + window.addEventListener('keydown', onKeyDown) + document.addEventListener('bool:open-cheatsheet', onOpenCheatsheet) + return () => { + window.removeEventListener('keydown', onKeyDown) + document.removeEventListener('bool:open-cheatsheet', onOpenCheatsheet) + } + }, []) + return [open, () => setOpen(true), () => setOpen(false)] +} + +const SHORTCUTS: { keys: string[]; label: string }[] = [ + { keys: ['⌘', 'K'], label: 'Open the command palette' }, + { keys: ['⌘', '⇧', 'F'], label: 'Search all message history' }, + { keys: ['⌘', 'D'], label: 'Toggle density (compact ⇄ comfortable)' }, + { keys: ['?'], label: 'Open this cheatsheet' }, + { keys: ['Esc'], label: 'Close the palette / overlay' }, + { keys: ['↑', '↓'], label: 'Move the highlighted result' }, + { keys: ['↵'], label: 'Run the highlighted result' }, +] + +export function ShortcutCheatsheet({ open, onClose }: { open: boolean; onClose: () => void }) { + useFocusRestore(open) + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); onClose() } }, + [onClose], + ) + if (!open) return null + return ( + <> +
+
+

Keyboard shortcuts

+
    + {SHORTCUTS.map((s) => ( +
  • + {s.label} + +
  • + ))} +
+

Commands

+
    + {PALETTE_COMMANDS.map((c) => ( +
  • + {c.name} + {c.syntax} +
  • + ))} +
+
+ + ) +} diff --git a/packages/client/src/components/Sidebar.test.tsx b/packages/client/src/components/Sidebar.test.tsx index c068657..fcf9a20 100644 --- a/packages/client/src/components/Sidebar.test.tsx +++ b/packages/client/src/components/Sidebar.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' +import { render, screen, fireEvent, act } from '@testing-library/react' import { useChat, targetKey } from '../store/chat-store.js' import type { WebSocketLike } from '../ws-client.js' import { Sidebar } from './Sidebar.js' @@ -8,7 +8,7 @@ import { Sidebar } from './Sidebar.js' function seed() { useChat.setState({ networks: { - 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'dmf', connected: true }, + 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'dmf', connected: true, state: 'registered' }, }, targets: { [targetKey(1, '#python')]: { @@ -73,6 +73,25 @@ describe('Sidebar', () => { const pythonBtn = screen.getByTitle('#python') expect(pythonBtn).toHaveClass('active') }) + + it('renders a "##" channel with its full sigil, not split as "# #name" (audit polish)', () => { + seed() + useChat.setState({ + targets: { + ...useChat.getState().targets, + [targetKey(1, '##bool-audit')]: { + networkId: 1, + target: '##bool-audit', + kind: 'channel', + unread: 0, + }, + }, + }) + render() + const row = screen.getByTitle('##bool-audit') + expect(row.querySelector('.hash')).toHaveTextContent('##') + expect(row.querySelector('.name')).toHaveTextContent('bool-audit') + }) }) // --- Task 8: channel-join affordance + Server (status) group --- @@ -94,7 +113,7 @@ function seedWithSocket() { open() useChat.setState({ networks: { - 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'dmf', connected: true }, + 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'dmf', connected: true, state: 'registered' }, }, targets: { [targetKey(1, '#python')]: { @@ -115,6 +134,53 @@ function renderSidebar() { render() } +describe('Sidebar — connection state (audit F4)', () => { + it('shows "Disconnected" + a Reconnect button when the network is closed, which calls connectNetwork', () => { + seed() + useChat.setState({ + networks: { + 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'dmf', connected: false, state: 'closed' }, + }, + }) + const connectNetwork = vi.spyOn(useChat.getState(), 'connectNetwork').mockImplementation(() => {}) + render() + expect(screen.getByText('Disconnected')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /reconnect/i })) + expect(connectNetwork).toHaveBeenCalledWith(1) + }) + + it('shows "Connecting…" while the network is connecting, with no Reconnect button', () => { + seed() + useChat.setState({ + networks: { + 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'dmf', connected: false, state: 'connecting' }, + }, + }) + render() + expect(screen.getByText('Connecting…')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /reconnect/i })).not.toBeInTheDocument() + }) + + it('shows "Reconnecting…" while the network is reconnecting', () => { + seed() + useChat.setState({ + networks: { + 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'dmf', connected: false, state: 'reconnecting' }, + }, + }) + render() + expect(screen.getByText('Reconnecting…')).toBeInTheDocument() + }) + + it('shows no status line when registered (normal)', () => { + seed() + render() + expect(screen.queryByText('Disconnected')).not.toBeInTheDocument() + expect(screen.queryByText('Connecting…')).not.toBeInTheDocument() + expect(screen.queryByText('Reconnecting…')).not.toBeInTheDocument() + }) +}) + describe('Sidebar — join affordance + server group', () => { it('joins a channel from the sidebar + affordance', () => { const sockSend = seedWithSocket().send as ReturnType @@ -126,6 +192,145 @@ describe('Sidebar — join affordance + server group', () => { expect(sockSend).toHaveBeenCalledWith(JSON.stringify({ type: 'chan:join', networkId: 1, channel: '#newchan' })) }) + it('rail badge excludes server/status noise', () => { + seedWithSocket() + useChat.setState({ + targets: { + ...useChat.getState().targets, + [targetKey(1, '*')]: { + networkId: 1, + target: '*', + kind: 'status', + unread: 4, + }, + [targetKey(1, '#dev')]: { + networkId: 1, + target: '#dev', + kind: 'channel', + unread: 2, + }, + }, + }) + renderSidebar() + // Rail badge and channel-row badge both render "2" — disambiguate by class, + // mirroring the "shows an unread badge" test's convention above. + const twos = screen.getAllByText('2') + const railBadge = twos.find((el) => el.classList.contains('badge')) + expect(railBadge).toBeInTheDocument() // rail badge = 2, not 6 + expect(screen.queryByText('6')).not.toBeInTheDocument() + }) + + it('a channel with mentions gets the mention badge class', () => { + seedWithSocket() + useChat.setState({ + targets: { + ...useChat.getState().targets, + [targetKey(1, '#dev')]: { + networkId: 1, + target: '#dev', + kind: 'channel', + unread: 2, + mentions: 1, + }, + }, + }) + renderSidebar() + const twos = screen.getAllByText('2') + const channelBadge = twos.find((el) => el.classList.contains('b')) + expect(channelBadge).toBeInTheDocument() + expect(channelBadge!.className).toContain('mention') + }) + + it('opens the channel browser dialog and lists /LIST results (audit F5)', () => { + seedWithSocket() + renderSidebar() + fireEvent.click(screen.getByRole('button', { name: /browse channel list/i })) + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByRole('searchbox', { name: /search channels/i })).toBeInTheDocument() + + act(() => { + useChat.setState({ + channelList: { networkId: 1, channels: [{ name: '#rust', users: 5, topic: 'systems' }], done: true }, + } as any) + }) + expect(screen.getByText('#rust')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /#rust/i })) + // join() is a server round-trip (Task 7 auto-selects on confirm) — selection is unchanged here. + expect(useChat.getState().selected).toBe(targetKey(1, '#python')) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('shows a green "on" presence dot on a DM row when the counterparty is in a shared channel (Task 16)', () => { + seedWithSocket() + useChat.setState({ + targets: { + ...useChat.getState().targets, + [targetKey(1, '#python')]: { + networkId: 1, + target: '#python', + kind: 'channel', + unread: 0, + names: [{ nick: 'ada', modes: [] }], + }, + [targetKey(1, 'ada')]: { + networkId: 1, + target: 'ada', + kind: 'pm', + unread: 0, + }, + }, + }) + renderSidebar() + const row = screen.getByTitle('ada') + const dot = row.querySelector('.pres') + expect(dot).toBeInTheDocument() + expect(dot).toHaveClass('on') + }) + + it('shows an idle presence dot when the counterparty is away in a shared channel', () => { + seedWithSocket() + useChat.setState({ + targets: { + ...useChat.getState().targets, + [targetKey(1, '#python')]: { + networkId: 1, + target: '#python', + kind: 'channel', + unread: 0, + names: [{ nick: 'ada', modes: [], away: true }], + }, + [targetKey(1, 'ada')]: { + networkId: 1, + target: 'ada', + kind: 'pm', + unread: 0, + }, + }, + }) + renderSidebar() + const row = screen.getByTitle('ada') + expect(row.querySelector('.pres')).toHaveClass('idle') + }) + + it('shows no presence dot when the DM counterparty is not in any shared channel', () => { + seedWithSocket() + useChat.setState({ + targets: { + ...useChat.getState().targets, + [targetKey(1, 'ghost')]: { + networkId: 1, + target: 'ghost', + kind: 'pm', + unread: 0, + }, + }, + }) + renderSidebar() + const row = screen.getByTitle('ghost') + expect(row.querySelector('.pres')).not.toBeInTheDocument() + }) + it('renders a Server group for the * status target', () => { seedWithSocket() useChat.setState({ @@ -143,3 +348,51 @@ describe('Sidebar — join affordance + server group', () => { expect(screen.getByText(/server/i)).toBeInTheDocument() }) }) + +describe('Sidebar — filter box (Task 16)', () => { + function seedManyTargets() { + const { sock, open } = fakeSocket() + useChat.getState().init(() => sock) + open() + const targets: Record = {} + const names = ['#python', '#archlinux', '#rust', '#golang', '#haskell', '#erlang', '##bool-audit', '#emacs', '#vim'] + for (const n of names) { + targets[targetKey(1, n)] = { networkId: 1, target: n, kind: 'channel', unread: 0 } + } + useChat.setState({ + networks: { + 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'dmf', connected: true, state: 'registered' }, + }, + targets, + selected: targetKey(1, '#python'), + connection: 'open', + }) + return sock + } + + it('does not render the filter box when the network has 8 or fewer targets', () => { + seed() + renderSidebar() + expect(screen.queryByPlaceholderText(/filter|search/i)).not.toBeInTheDocument() + }) + + it('renders the filter box when the network has more than 8 targets, and filters by substring', () => { + seedManyTargets() + renderSidebar() + const input = screen.getByRole('searchbox', { name: /filter channels/i }) + fireEvent.change(input, { target: { value: 'py' } }) + expect(screen.getByTitle('#python')).toBeInTheDocument() + expect(screen.queryByTitle('##bool-audit')).not.toBeInTheDocument() + }) + + it('Escape clears the filter box', () => { + seedManyTargets() + renderSidebar() + const input = screen.getByRole('searchbox', { name: /filter channels/i }) as HTMLInputElement + fireEvent.change(input, { target: { value: 'py' } }) + expect(input.value).toBe('py') + fireEvent.keyDown(input, { key: 'Escape' }) + expect(input.value).toBe('') + expect(screen.getByTitle('##bool-audit')).toBeInTheDocument() + }) +}) diff --git a/packages/client/src/components/Sidebar.tsx b/packages/client/src/components/Sidebar.tsx index d7c6931..eeb05d8 100644 --- a/packages/client/src/components/Sidebar.tsx +++ b/packages/client/src/components/Sidebar.tsx @@ -3,6 +3,8 @@ import '../styles/app.css' import { useChat, targetKey } from '../store/chat-store.js' import type { NetworkState, TargetState } from '../store/types.js' import { normalizeChannel } from '../commands.js' +import { Dialog } from './Dialog.js' +import { ChannelBrowser } from './ChannelBrowser.js' /** Derive a two-letter abbreviation from a network name */ function netAbbr(name: string): string { @@ -11,14 +13,27 @@ function netAbbr(name: string): string { return name.slice(0, 2).toUpperCase() } +/** Presence of a DM counterparty, derived from shared-channel `names` (no MONITOR yet — + * see Task 16 deferred list). `on` = present, `idle` = present but away, null = no signal. */ +function presenceForNick(targets: TargetState[], nick: string): 'on' | 'idle' | null { + for (const t of targets) { + if (t.kind !== 'channel' || !t.names) continue + const entry = t.names.find((n) => n.nick === nick) + if (entry) return entry.away ? 'idle' : 'on' + } + return null +} + function ChannelRow({ target, isActive, onSelect, + presence, }: { target: TargetState isActive: boolean onSelect: () => void + presence?: 'on' | 'idle' | null }) { const isChannel = target.kind === 'channel' const hasUnread = target.unread > 0 @@ -26,6 +41,12 @@ function ChannelRow({ .filter(Boolean) .join(' ') + // Channel sigils can be more than one character (e.g. "##bool-audit" on + // Libera, "&local"); match the full run of #/& so it renders as one glyph + // group instead of splitting into "# #name". + const sigil = target.target.match(/^[#&]+/)?.[0] ?? '' + const bare = target.target.slice(sigil.length) + return ( ) @@ -63,13 +87,23 @@ function NetworkGroup({ selected: string | null onSelect: (key: string) => void }) { - const channels = targets.filter((t) => t.kind === 'channel') - const dms = targets.filter((t) => t.kind === 'pm') + const allChannels = targets.filter((t) => t.kind === 'channel') + const allDms = targets.filter((t) => t.kind === 'pm') const statusTargets = targets.filter((t) => t.kind === 'status') const [joinOpen, setJoinOpen] = useState(false) const [joinValue, setJoinValue] = useState('') + const [browserOpen, setBrowserOpen] = useState(false) + const [filter, setFilter] = useState('') const join = useChat((s) => s.join) + const connectNetwork = useChat((s) => s.connectNetwork) + + // Divergence from the mock (which always shows the filter): empty-state noise matters more + // for small networks, so it's gated to >8 targets — noted in the Task 16 report. + const showFilter = targets.length > 8 + const q = filter.trim().toLowerCase() + const channels = showFilter && q ? allChannels.filter((t) => t.target.toLowerCase().includes(q)) : allChannels + const dms = showFilter && q ? allDms.filter((t) => t.target.toLowerCase().includes(q)) : allDms function submitJoin() { const v = joinValue.trim() @@ -78,6 +112,12 @@ function NetworkGroup({ setJoinOpen(false) } + const statusText = + network.state === 'connecting' ? 'Connecting…' + : network.state === 'reconnecting' ? 'Reconnecting…' + : network.state === 'closed' ? 'Disconnected' + : null + return ( <>
@@ -86,6 +126,38 @@ function NetworkGroup({ {network.name}
{network.host}
+ {statusText && ( +
+ {statusText} + {network.state === 'closed' && ( + + )} +
+ )} + {showFilter && ( +
+ + setFilter(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') setFilter('') + }} + /> +
+ )}
{( @@ -93,13 +165,23 @@ function NetworkGroup({
Channels {channels.length} + @@ -118,6 +200,9 @@ function NetworkGroup({ }} /> )} + setBrowserOpen(false)} title={`Browse channels — ${network.name}`}> + setBrowserOpen(false)} /> + {channels.length > 0 && channels.map((t) => { const key = targetKey(t.networkId, t.target) return ( @@ -145,6 +230,7 @@ function NetworkGroup({ target={t} isActive={selected === key} onSelect={() => onSelect(key)} + presence={presenceForNick(allChannels, t.target)} /> ) })} @@ -191,7 +277,7 @@ export function Sidebar({ onOpenNetworkSettings }: SidebarProps = {}) { // Total unread per network for rail badges function networkUnread(netId: number): number { return targetList - .filter((t) => t.networkId === netId) + .filter((t) => t.networkId === netId && t.kind !== 'status') .reduce((sum, t) => sum + t.unread, 0) } @@ -208,7 +294,7 @@ export function Sidebar({ onOpenNetworkSettings }: SidebarProps = {}) { : [] return ( -
+ <> {/* Network rail */}
)} -
+ ) } diff --git a/packages/client/src/components/TypingLine.test.tsx b/packages/client/src/components/TypingLine.test.tsx index 852e45b..3f765fe 100644 --- a/packages/client/src/components/TypingLine.test.tsx +++ b/packages/client/src/components/TypingLine.test.tsx @@ -14,7 +14,7 @@ describe('TypingLine', () => { function seed(typers: Record) { useChat.setState({ selected: SELECTED, - networks: { 1: { id: 1, name: 'Freenode', host: 'irc.freenode.net', nick: 'me', connected: true } }, + networks: { 1: { id: 1, name: 'Freenode', host: 'irc.freenode.net', nick: 'me', connected: true, state: 'registered' } }, typing: { [SELECTED]: typers }, }) } diff --git a/packages/client/src/components/UserPopover.test.tsx b/packages/client/src/components/UserPopover.test.tsx new file mode 100644 index 0000000..734b61d --- /dev/null +++ b/packages/client/src/components/UserPopover.test.tsx @@ -0,0 +1,94 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { UserPopover } from './UserPopover.js' +import { useChat } from '../store/chat-store.js' + +// Base context: a real selected CHANNEL, frozen at "click time" — mirrors what MessageList / +// MemberList now pass after Task 9 review fixes (networkId/channel/isChannel/canOp are all +// captured together from the live `selected` at the instant the popover opens, never re-derived +// inside UserPopover itself). +const base = { networkId: 1, channel: '#bool', isChannel: true, nick: 'ada', anchor: { x: 10, y: 10 }, onClose: vi.fn() } + +describe('UserPopover', () => { + it('is a menu with the always-available actions', () => { + render() + const menu = screen.getByRole('menu', { name: /ada/i }) + expect(menu).toBeInTheDocument() + for (const label of ['Message', 'Whois']) { + expect(screen.getByRole('menuitem', { name: label })).toBeInTheDocument() + } + expect(screen.queryByRole('menuitem', { name: 'Kick' })).not.toBeInTheDocument() + }) + + it('shows op actions only when canOp', () => { + render() + for (const label of ['Op', 'Deop', 'Voice', 'Devoice', 'Kick', 'Ban']) { + expect(screen.getByRole('menuitem', { name: label })).toBeInTheDocument() + } + }) + + it('Message opens a DM and closes', () => { + const onClose = vi.fn() + const openDm = vi.spyOn(useChat.getState(), 'openDm').mockImplementation(() => {}) + render() + fireEvent.click(screen.getByRole('menuitem', { name: 'Message' })) + expect(openDm).toHaveBeenCalledWith(1, 'ada') + expect(onClose).toHaveBeenCalled() + openDm.mockRestore() + }) + + // Regression test for Task 9 review Bug 1: op actions must dispatch against the CONCRETE + // frozen channel, not whatever the live `selected` store value happens to be at click time. + // Asserting the exact string (not expect.any(String)) is what catches a regression back to + // deriving `channel` live inside UserPopover instead of receiving it as a frozen prop. + it('op actions dispatch with the concrete frozen channel (not a live-derived one)', () => { + const setMode = vi.spyOn(useChat.getState(), 'setMode').mockImplementation(() => {}) + const kick = vi.spyOn(useChat.getState(), 'kick').mockImplementation(() => {}) + render() + + fireEvent.click(screen.getByRole('menuitem', { name: 'Op' })) + expect(setMode).toHaveBeenCalledWith(1, '#real-channel', '+o', 'ada') + + setMode.mockClear() + fireEvent.click(screen.getByRole('menuitem', { name: 'Kick' })) + expect(kick).toHaveBeenCalledWith(1, '#real-channel', 'ada') + + setMode.mockRestore() + kick.mockRestore() + }) + + it('Ban sends +b with a nick mask (no syntax typed by the user)', () => { + const setMode = vi.spyOn(useChat.getState(), 'setMode').mockImplementation(() => {}) + render() + fireEvent.click(screen.getByRole('menuitem', { name: 'Ban' })) + expect(setMode).toHaveBeenCalledWith(1, '#real-channel', '+b', 'ada!*@*') + setMode.mockRestore() + }) + + it('Invite to channel… uses the frozen channel', () => { + const invite = vi.spyOn(useChat.getState(), 'invite').mockImplementation(() => {}) + render() + fireEvent.click(screen.getByRole('menuitem', { name: 'Invite to channel…' })) + expect(invite).toHaveBeenCalledWith(1, 'ada', '#real-channel') + invite.mockRestore() + }) + + // Regression test for Task 9 review Bug 2: a popover opened against a PM (isChannel=false) + // must never expose channel-scoped actions — neither the op subset nor "Invite to channel…" — + // even when canOp is (nonsensically) true, since a PM target has no channel to invite/op in. + it('a PM context (isChannel=false) shows only Message + Whois — no op actions, no invite', () => { + render() + expect(screen.getByRole('menuitem', { name: 'Message' })).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: 'Whois' })).toBeInTheDocument() + for (const label of ['Invite to channel…', 'Op', 'Deop', 'Voice', 'Devoice', 'Kick', 'Ban']) { + expect(screen.queryByRole('menuitem', { name: label })).not.toBeInTheDocument() + } + }) + + it('closes on Escape and on outside click', () => { + const onClose = vi.fn() + render() + fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' }) + expect(onClose).toHaveBeenCalled() + }) +}) diff --git a/packages/client/src/components/UserPopover.tsx b/packages/client/src/components/UserPopover.tsx new file mode 100644 index 0000000..161a5f2 --- /dev/null +++ b/packages/client/src/components/UserPopover.tsx @@ -0,0 +1,67 @@ +import { useEffect, useRef } from 'react' +import { useChat } from '../store/chat-store.js' + +export interface UserPopoverProps { + /** Frozen at click time from the live `selected` target — never re-derived inside this component. */ + networkId: number + /** Frozen channel name (empty string for non-channel targets). */ + channel: string + /** Frozen: was the target a channel (vs. a PM/status) at click time? Gates all channel-only actions. */ + isChannel: boolean + /** Frozen: did we hold op in that channel at click time? */ + canOp: boolean + nick: string + anchor: { x: number; y: number } + onClose: () => void +} + +export function UserPopover({ networkId, channel, isChannel, canOp, nick, anchor, onClose }: UserPopoverProps) { + const ref = useRef(null) + + useEffect(() => { + const onDown = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onClose() + } + document.addEventListener('mousedown', onDown) + return () => document.removeEventListener('mousedown', onDown) + }, [onClose]) + + const store = () => useChat.getState() + const run = (fn: () => void) => () => { fn(); onClose() } + + const items: Array<{ label: string; onClick: () => void }> = [ + { label: 'Message', onClick: run(() => store().openDm(networkId, nick)) }, + { label: 'Whois', onClick: run(() => store().whois(networkId, nick)) }, + ] + if (isChannel) { + items.push({ label: 'Invite to channel…', onClick: run(() => store().invite(networkId, nick, channel)) }) + } + if (isChannel && canOp) { + items.push( + { label: 'Op', onClick: run(() => store().setMode(networkId, channel, '+o', nick)) }, + { label: 'Deop', onClick: run(() => store().setMode(networkId, channel, '-o', nick)) }, + { label: 'Voice', onClick: run(() => store().setMode(networkId, channel, '+v', nick)) }, + { label: 'Devoice', onClick: run(() => store().setMode(networkId, channel, '-v', nick)) }, + { label: 'Kick', onClick: run(() => store().kick(networkId, channel, nick)) }, + { label: 'Ban', onClick: run(() => store().setMode(networkId, channel, '+b', `${nick}!*@*`)) }, + ) + } + + return ( +
{ if (e.key === 'Escape') { e.preventDefault(); onClose() } }} + > +
{nick}
+ {items.map((it) => ( + + ))} +
+ ) +} diff --git a/packages/client/src/components/irc-format.test.ts b/packages/client/src/components/irc-format.test.ts index cec81ef..c5ed85e 100644 --- a/packages/client/src/components/irc-format.test.ts +++ b/packages/client/src/components/irc-format.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest' import { render } from '@testing-library/react' import React from 'react' -import { parseIrcText, linkify } from './irc-format.js' +import { parseIrcText, linkify, parseIrc } from './irc-format.js' import { MessageRow } from './MessageRow.js' import type { StoredMsg } from '@bool/shared' @@ -114,6 +114,70 @@ describe('linkify', () => { }) }) +describe('parseIrc — inline mentions (Task 15)', () => { + it('wraps our nick in a mention segment', () => { + const segs = parseIrc('hey dmf: look at this', { selfNick: 'dmf' }) + expect(segs.some((s) => s.kind === 'mention' && s.text === 'dmf')).toBe(true) + }) + + it('does not treat a nick-like substring as a mention (boundary check)', () => { + const segs = parseIrc('dmfoo said hi', { selfNick: 'dmf' }) + expect(segs.some((s) => s.kind === 'mention')).toBe(false) + }) + + it('with no selfNick, never emits a mention segment', () => { + const segs = parseIrc('hey dmf: look at this') + expect(segs.some((s) => s.kind === 'mention')).toBe(false) + }) + + it('preserves the surrounding text around the mention', () => { + const segs = parseIrc('hey dmf: look', { selfNick: 'dmf' }) + const text = segs.map((s) => s.text).join('') + expect(text).toBe('hey dmf: look') + }) +}) + +describe('parseIrc — code fences (Task 15)', () => { + it('parses a triple-backtick fence into a code segment with language', () => { + const segs = parseIrc('```python\nprint(1)\n```') + const code = segs.find((s) => s.kind === 'code') as any + expect(code.lang).toBe('python') + expect(code.text).toBe('print(1)') + expect(code.block).toBe(true) + }) + + it('inline `code` spans become code segments', () => { + const segs = parseIrc('use `git rebase` carefully') + const code = segs.find((s) => s.kind === 'code' && s.text === 'git rebase') as any + expect(code).toBeDefined() + expect(code.block).toBeFalsy() + expect(code.lang).toBeUndefined() + }) + + it('surrounding plain text around a fence is preserved as text/link segments', () => { + const segs = parseIrc('before\n```\ncode here\n```\nafter') + const before = segs.find((s) => s.kind === 'text' && s.text.includes('before')) + const after = segs.find((s) => s.kind === 'text' && s.text.includes('after')) + expect(before).toBeDefined() + expect(after).toBeDefined() + }) +}) + +describe('parseIrc — link + mIRC color parity with linkify/parseIrcText', () => { + it('still linkifies URLs as link segments', () => { + const segs = parseIrc('check https://x.com out') + const link = segs.find((s) => s.kind === 'link') + expect(link).toBeDefined() + expect((link as any).url).toBe('https://x.com') + }) + + it('still applies mIRC color codes to text segments', () => { + const segs = parseIrc('\x034red text\x03') + const seg = segs.find((s) => s.kind === 'text' && s.text === 'red text') as any + expect(seg?.color).toBe('var(--m-red)') + }) +}) + // ---- XSS safety — render-layer tests ---- function makeMsg(body: string): StoredMsg { diff --git a/packages/client/src/components/irc-format.ts b/packages/client/src/components/irc-format.ts index 8a141c3..3a74894 100644 --- a/packages/client/src/components/irc-format.ts +++ b/packages/client/src/components/irc-format.ts @@ -6,6 +6,7 @@ */ import LinkifyIt from 'linkify-it' +import { nickBoundaryPattern } from '../store/chat-store.js' // ---- mIRC color map ---- // Maps mIRC color numbers (0–15) to CSS variables from tokens.css. @@ -183,3 +184,132 @@ export function linkify(text: string): LinkSegment[] { return result } + +// ---- parseIrc — unified pipeline: fences → linkify → mentions → mIRC codes ---- +// +// Combines linkify() + parseIrcText() (both still exported/used standalone +// elsewhere) with two additions: code fences (block ``` and inline `code`) +// and an optional self-nick inline-mention highlight. Returns raw text in +// every segment — render as React elements, never dangerouslySetInnerHTML. + +export type IrcSegment = + | ({ kind: 'text' } & IrcTextSegment) + | { kind: 'link'; text: string; url: string } + | { kind: 'mention'; text: string } + | { kind: 'code'; text: string; lang?: string; block: boolean } + +export interface ParseIrcOptions { + /** Our own nick — occurrences at an IRC nick boundary become `{ kind: 'mention' }` + * segments. Omit (default) to skip mention parsing entirely. */ + selfNick?: string +} + +interface RawPart { + kind: 'code' | 'plain' + text: string + lang?: string + block?: boolean +} + +const FENCE_RE = /```(\w*)\n?([\s\S]*?)```/g +const INLINE_CODE_RE = /`([^`\n]+)`/g + +/** Split raw inline (non-fenced) text on single-backtick spans. */ +function splitInlineCode(text: string, out: RawPart[]): void { + INLINE_CODE_RE.lastIndex = 0 + let last = 0 + let m: RegExpExecArray | null + while ((m = INLINE_CODE_RE.exec(text))) { + if (m.index > last) out.push({ kind: 'plain', text: text.slice(last, m.index) }) + out.push({ kind: 'code', text: m[1]!, block: false }) + last = INLINE_CODE_RE.lastIndex + } + if (last < text.length) out.push({ kind: 'plain', text: text.slice(last) }) +} + +/** Split raw body on ``` fences first (block code), then split the plain + * remainder on inline `code` spans. Fence content never gets linkified, + * mention-highlighted, or mIRC-formatted — it's rendered verbatim. */ +function splitFences(body: string): RawPart[] { + const out: RawPart[] = [] + FENCE_RE.lastIndex = 0 + let last = 0 + let m: RegExpExecArray | null + while ((m = FENCE_RE.exec(body))) { + if (m.index > last) splitInlineCode(body.slice(last, m.index), out) + const lang = m[1] || undefined + const text = (m[2] ?? '').replace(/\n$/, '') + out.push({ kind: 'code', text, block: true, ...(lang ? { lang } : {}) }) + last = FENCE_RE.lastIndex + } + if (last < body.length) splitInlineCode(body.slice(last), out) + return out +} + +interface MentionPart { + text: string + mention?: boolean +} + +/** Split `text` around every boundary-matched occurrence of `nick`, reusing + * the exact boundary pattern chat-store's mentionsNick() tests with — single + * source of truth for what counts as "a mention". */ +function splitMentions(text: string, nick: string): MentionPart[] { + const re = new RegExp(nickBoundaryPattern(nick), 'gi') + const parts: MentionPart[] = [] + let last = 0 + let m: RegExpExecArray | null + while ((m = re.exec(text))) { + const lead = m[1] ?? '' + const nickMatch = m[2] ?? '' + const matchStart = m.index + lead.length + if (matchStart > last) parts.push({ text: text.slice(last, matchStart) }) + parts.push({ text: nickMatch, mention: true }) + last = matchStart + nickMatch.length + if (re.lastIndex <= last) re.lastIndex = last // guard against zero-width stalls + } + if (last < text.length) parts.push({ text: text.slice(last) }) + return parts +} + +/** + * Parse a raw IRC message body into a flat, render-ready segment list: + * code fences/spans, URLs, an optional self-mention highlight, and mIRC + * formatting codes — in that precedence order. Existing callers of + * linkify()/parseIrcText() are untouched; this is an additive, opt-in API. + */ +export function parseIrc(body: string, opts: ParseIrcOptions = {}): IrcSegment[] { + const segments: IrcSegment[] = [] + + for (const part of splitFences(body)) { + if (part.kind === 'code') { + segments.push({ + kind: 'code', + text: part.text, + block: part.block ?? false, + ...(part.lang ? { lang: part.lang } : {}), + }) + continue + } + + for (const ls of linkify(part.text)) { + if (ls.url) { + segments.push({ kind: 'link', text: ls.text, url: ls.url }) + continue + } + + const mentionParts = opts.selfNick ? splitMentions(ls.text, opts.selfNick) : [{ text: ls.text }] + for (const mp of mentionParts) { + if (mp.mention) { + segments.push({ kind: 'mention', text: mp.text }) + continue + } + for (const seg of parseIrcText(mp.text)) { + segments.push({ kind: 'text', ...seg }) + } + } + } + } + + return segments +} diff --git a/packages/client/src/components/primitives/EmptyState.test.tsx b/packages/client/src/components/primitives/EmptyState.test.tsx new file mode 100644 index 0000000..1bce855 --- /dev/null +++ b/packages/client/src/components/primitives/EmptyState.test.tsx @@ -0,0 +1,19 @@ +// packages/client/src/components/primitives/EmptyState.test.tsx +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { EmptyState } from './EmptyState.js' + +describe('EmptyState', () => { + it('renders title and description', () => { + render() + expect(screen.getByRole('heading', { name: 'No networks yet' })).toBeInTheDocument() + expect(screen.getByText('Connect one to start chatting')).toBeInTheDocument() + }) + + it('renders a single primary CTA and calls its handler', () => { + const onClick = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Connect a network' })) + expect(onClick).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/client/src/components/primitives/EmptyState.tsx b/packages/client/src/components/primitives/EmptyState.tsx new file mode 100644 index 0000000..baca05a --- /dev/null +++ b/packages/client/src/components/primitives/EmptyState.tsx @@ -0,0 +1,34 @@ +// packages/client/src/components/primitives/EmptyState.tsx +export interface EmptyStateProps { + title: string + description?: string + icon?: React.ReactNode + action?: { label: string; onClick: () => void } +} + +export function EmptyState({ title, description, icon, action }: EmptyStateProps) { + return ( +
+ {icon &&
{icon}
} +

{title}

+ {description && ( +

+ {description} +

+ )} + {action && ( + + )} +
+ ) +} diff --git a/packages/client/src/components/primitives/Field.test.tsx b/packages/client/src/components/primitives/Field.test.tsx new file mode 100644 index 0000000..11a1e0e --- /dev/null +++ b/packages/client/src/components/primitives/Field.test.tsx @@ -0,0 +1,44 @@ +// packages/client/src/components/primitives/Field.test.tsx +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { Field } from './Field.js' + +describe('Field', () => { + it('associates the label with the input', () => { + render( {}} />) + expect(screen.getByLabelText('Username')).toBeInTheDocument() + }) + + it('shows an inline error with text (not color-only) after blur when validation fails', () => { + render( + {}} + validate={(v) => (v.length < 3 ? 'Must be at least 3 characters' : null)} + />, + ) + const input = screen.getByLabelText('Username') + fireEvent.blur(input) + const err = screen.getByText('Must be at least 3 characters') + expect(err).toBeInTheDocument() + // error is wired to the input for screen readers + expect(input).toHaveAttribute('aria-invalid', 'true') + expect(input.getAttribute('aria-describedby')).toBe(err.getAttribute('id')) + }) + + it('does not show the error before blur', () => { + render( + {}} validate={() => 'bad'} />, + ) + expect(screen.queryByText('bad')).not.toBeInTheDocument() + }) + + it('toggles password visibility when passwordToggle is set', () => { + render( {}} passwordToggle />) + const input = screen.getByLabelText('Password') + expect(input).toHaveAttribute('type', 'password') + fireEvent.click(screen.getByRole('button', { name: /show password/i })) + expect(input).toHaveAttribute('type', 'text') + }) +}) diff --git a/packages/client/src/components/primitives/Field.tsx b/packages/client/src/components/primitives/Field.tsx new file mode 100644 index 0000000..4141288 --- /dev/null +++ b/packages/client/src/components/primitives/Field.tsx @@ -0,0 +1,82 @@ +// packages/client/src/components/primitives/Field.tsx +import { useState, useId } from 'react' + +export interface FieldProps { + label: string + value: string + onChange: (v: string) => void + type?: string + validate?: (v: string) => string | null + required?: boolean + autoComplete?: string + placeholder?: string + passwordToggle?: boolean + id?: string +} + +export function Field({ + label, value, onChange, type = 'text', validate, + required, autoComplete, placeholder, passwordToggle, id, +}: FieldProps) { + const autoId = useId() + const inputId = id ?? autoId + const errId = `${inputId}-err` + const [touched, setTouched] = useState(false) + const [reveal, setReveal] = useState(false) + const error = touched && validate ? validate(value) : null + const effectiveType = passwordToggle ? (reveal ? 'text' : 'password') : type + + return ( +
+
+ + {required && *} +
+
+ onChange(e.target.value)} + onBlur={() => setTouched(true)} + style={{ + flex: 1, fontFamily: 'var(--mono)', fontSize: 13, color: 'var(--ink-0)', + background: 'var(--bg-3)', border: `1px solid ${error ? 'var(--red)' : 'var(--line-2)'}`, + borderRadius: 'var(--radius-sm)', padding: '8px 9px', outline: 'none', width: '100%', + boxSizing: 'border-box', minHeight: 24, + }} + /> + {passwordToggle && ( + + )} +
+ {error && ( + + )} +
+ ) +} diff --git a/packages/client/src/components/primitives/Kbd.test.tsx b/packages/client/src/components/primitives/Kbd.test.tsx new file mode 100644 index 0000000..31a9526 --- /dev/null +++ b/packages/client/src/components/primitives/Kbd.test.tsx @@ -0,0 +1,13 @@ +// packages/client/src/components/primitives/Kbd.test.tsx +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { Kbd } from './Kbd.js' + +describe('Kbd', () => { + it('renders each key inside a element', () => { + render() + const kbds = screen.getAllByText(/⌘|K/) + expect(kbds.length).toBe(2) + kbds.forEach((el) => expect(el.tagName.toLowerCase()).toBe('kbd')) + }) +}) diff --git a/packages/client/src/components/primitives/Kbd.tsx b/packages/client/src/components/primitives/Kbd.tsx new file mode 100644 index 0000000..87500c1 --- /dev/null +++ b/packages/client/src/components/primitives/Kbd.tsx @@ -0,0 +1,14 @@ +// packages/client/src/components/primitives/Kbd.tsx +export function Kbd({ keys }: { keys: string[] }) { + return ( + + {keys.map((k, i) => ( + + {k} + + ))} + + ) +} diff --git a/packages/client/src/components/primitives/LiveLog.test.tsx b/packages/client/src/components/primitives/LiveLog.test.tsx new file mode 100644 index 0000000..e01d67e --- /dev/null +++ b/packages/client/src/components/primitives/LiveLog.test.tsx @@ -0,0 +1,14 @@ +// packages/client/src/components/primitives/LiveLog.test.tsx +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { LiveLog } from './LiveLog.js' + +describe('LiveLog', () => { + it('renders a role=log region with an accessible name', () => { + render(
hi
) + const log = screen.getByRole('log', { name: 'Messages' }) + expect(log).toBeInTheDocument() + // role=log implies aria-live=polite; we set it explicitly for older AT + expect(log).toHaveAttribute('aria-live', 'polite') + }) +}) diff --git a/packages/client/src/components/primitives/LiveLog.tsx b/packages/client/src/components/primitives/LiveLog.tsx new file mode 100644 index 0000000..d7a3018 --- /dev/null +++ b/packages/client/src/components/primitives/LiveLog.tsx @@ -0,0 +1,8 @@ +// packages/client/src/components/primitives/LiveLog.tsx +export function LiveLog({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} diff --git a/packages/client/src/components/primitives/Menu.test.tsx b/packages/client/src/components/primitives/Menu.test.tsx new file mode 100644 index 0000000..0d18a1e --- /dev/null +++ b/packages/client/src/components/primitives/Menu.test.tsx @@ -0,0 +1,72 @@ +// packages/client/src/components/primitives/Menu.test.tsx +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { useRef } from 'react' +import { Menu } from './Menu.js' + +function Harness({ onClose }: { onClose: () => void }) { + const trigger = useRef(null) + return ( + <> + + + + + + ) +} + +function TwoItemHarness({ onClose }: { onClose: () => void }) { + const trigger = useRef(null) + return ( + <> + + + + + + + ) +} + +describe('Menu', () => { + it('renders a role=menu with an accessible name and its items', () => { + render( {}} />) + const menu = screen.getByRole('menu', { name: 'Account' }) + expect(menu).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: 'Sign out' })).toBeInTheDocument() + }) + + it('closes on Escape', () => { + const onClose = vi.fn() + render() + fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' }) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('ArrowDown moves focus to next item and wraps; ArrowUp moves to previous and wraps', () => { + render( {}} />) + const menu = screen.getByRole('menu', { name: 'Nav' }) + const itemA = screen.getByRole('menuitem', { name: 'A' }) + const itemB = screen.getByRole('menuitem', { name: 'B' }) + + // On open, first item receives focus + expect(document.activeElement).toBe(itemA) + + // ArrowDown from A → focus B + fireEvent.keyDown(menu, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(itemB) + + // ArrowDown from B → wraps back to A + fireEvent.keyDown(menu, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(itemA) + + // ArrowUp from A → wraps to B + fireEvent.keyDown(menu, { key: 'ArrowUp' }) + expect(document.activeElement).toBe(itemB) + + // ArrowUp from B → moves to A + fireEvent.keyDown(menu, { key: 'ArrowUp' }) + expect(document.activeElement).toBe(itemA) + }) +}) diff --git a/packages/client/src/components/primitives/Menu.tsx b/packages/client/src/components/primitives/Menu.tsx new file mode 100644 index 0000000..6b3c50d --- /dev/null +++ b/packages/client/src/components/primitives/Menu.tsx @@ -0,0 +1,58 @@ +// packages/client/src/components/primitives/Menu.tsx +import { useEffect, useRef, useCallback } from 'react' + +export interface MenuProps { + open: boolean + onClose: () => void + trigger: React.RefObject + label: string + children: React.ReactNode +} + +export function Menu({ open, onClose, trigger, label, children }: MenuProps) { + const menuRef = useRef(null) + // Track whether the menu was previously open so we only restore focus on a + // deliberate open→closed transition, not on unmount from an already-closed state. + const wasOpen = useRef(false) + + // Focus first item on open; restore focus to trigger on deliberate close. + useEffect(() => { + if (open) { + wasOpen.current = true + const items = menuRef.current?.querySelectorAll('[role="menuitem"]') + items?.[0]?.focus() + } else { + if (wasOpen.current) { + wasOpen.current = false + trigger.current?.focus() + } + } + }, [open, trigger]) + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const items = Array.from( + menuRef.current?.querySelectorAll('[role="menuitem"]') ?? [], + ) + const idx = items.indexOf(document.activeElement as HTMLElement) + if (e.key === 'Escape') { e.preventDefault(); onClose() } + else if (e.key === 'ArrowDown') { e.preventDefault(); items[(idx + 1) % items.length]?.focus() } + else if (e.key === 'ArrowUp') { e.preventDefault(); items[(idx - 1 + items.length) % items.length]?.focus() } + }, + [onClose], + ) + + if (!open) return null + return ( + <> +
+
+ {children} +
+ + ) +} diff --git a/packages/client/src/dev-seed.test.ts b/packages/client/src/dev-seed.test.ts new file mode 100644 index 0000000..cf7ac90 --- /dev/null +++ b/packages/client/src/dev-seed.test.ts @@ -0,0 +1,34 @@ +// packages/client/src/dev-seed.test.ts +import { describe, it, expect } from 'vitest' +import { seedDemo, DEMO_FIXTURE } from './dev-seed.js' +import { useChat } from './store/chat-store.js' + +describe('seedDemo', () => { + it('populates the chat store with at least one network, target, and a selection', () => { + seedDemo() + const s = useChat.getState() + expect(Object.keys(s.networks).length).toBeGreaterThan(0) + expect(Object.keys(s.targets).length).toBeGreaterThan(0) + expect(s.selected).not.toBeNull() + }) + + it('DEMO_FIXTURE has concrete, expected values', () => { + expect(DEMO_FIXTURE.selected).toBe('1:#bool') + expect(DEMO_FIXTURE.networks[1]?.name).toBe('Libera.Chat') + expect('1:#bool' in DEMO_FIXTURE.targets).toBe(true) + expect('1:#general' in DEMO_FIXTURE.targets).toBe(true) + expect('1:ada' in DEMO_FIXTURE.targets).toBe(true) + }) + + it('seeds messages for the selected target into the store', () => { + seedDemo() + const s = useChat.getState() + const msgs = s.messages['1:#bool'] ?? [] + expect(msgs.length).toBeGreaterThan(0) + // Verify first message has the expected deterministic fields + const first = msgs[0] + expect(first?.sender).toBe('ada') + expect(first?.ts).toBe(1_700_000_000_000) + expect(first?.body).toBe('Welcome to #bool — the open-source IRC client!') + }) +}) diff --git a/packages/client/src/dev-seed.ts b/packages/client/src/dev-seed.ts new file mode 100644 index 0000000..c70a5d3 --- /dev/null +++ b/packages/client/src/dev-seed.ts @@ -0,0 +1,63 @@ +// packages/client/src/dev-seed.ts +import { useChat } from './store/chat-store.js' +import type { NetworkState, TargetState } from './store/types.js' +import type { StoredMsg } from '@bool/shared' + +export const DEMO_FIXTURE: { + networks: Record + targets: Record + selected: string + messages: Record +} = { + networks: { + 1: { id: 1, name: 'Libera.Chat', host: 'irc.libera.chat', nick: 'you', connected: true, state: 'registered' }, + }, + targets: { + '1:#bool': { networkId: 1, target: '#bool', kind: 'channel', unread: 0, + names: [{ nick: 'ada', modes: ['@'] }, { nick: 'lin', modes: ['+'] }, { nick: 'kai', modes: [] }] }, + '1:#general': { networkId: 1, target: '#general', kind: 'channel', unread: 3, + names: [{ nick: 'ada', modes: [] }, { nick: 'kai', modes: [] }] }, + '1:ada': { networkId: 1, target: 'ada', kind: 'pm', unread: 0, names: [] }, + }, + selected: '1:#bool', + messages: { + '1:#bool': [ + { + id: 1, + networkId: 1, + target: '#bool', + sender: 'ada', + kind: 'privmsg', + body: 'Welcome to #bool — the open-source IRC client!', + ts: 1_700_000_000_000, + }, + { + id: 2, + networkId: 1, + target: '#bool', + sender: 'lin', + kind: 'privmsg', + body: 'Hey all! Just pushed the new theme switcher.', + ts: 1_700_000_060_000, + }, + { + id: 3, + networkId: 1, + target: '#bool', + sender: 'you', + kind: 'privmsg', + body: 'Looks great. Dark mode is chef\'s kiss.', + ts: 1_700_000_120_000, + }, + ], + }, +} + +export function seedDemo(): void { + useChat.setState({ + networks: structuredClone(DEMO_FIXTURE.networks) as Record, + targets: structuredClone(DEMO_FIXTURE.targets) as Record, + selected: DEMO_FIXTURE.selected, + messages: structuredClone(DEMO_FIXTURE.messages) as Record, + }) +} diff --git a/packages/client/src/main.tsx b/packages/client/src/main.tsx index 245bad6..fee40dd 100644 --- a/packages/client/src/main.tsx +++ b/packages/client/src/main.tsx @@ -6,6 +6,7 @@ import './styles/base.css' import { applyAppearance, useAppearance } from './theme.js' import { useChat } from './store/chat-store.js' import { initDeepLinks } from './deeplink.js' +import { seedDemo } from './dev-seed.js' import { App } from './App.js' // Register the service worker (vite-plugin-pwa; no-ops where unsupported) @@ -18,7 +19,7 @@ initDeepLinks() applyAppearance(useAppearance.getState().theme, useAppearance.getState().density) // Debug/e2e hook: expose the stores for tooling (harmless; not a data source). -;(window as unknown as { __bool?: unknown }).__bool = { useChat, useAppearance } +;(window as unknown as { __bool?: unknown }).__bool = { useChat, useAppearance, seedDemo } createRoot(document.getElementById('root')!).render( diff --git a/packages/client/src/network-catalog.test.ts b/packages/client/src/network-catalog.test.ts new file mode 100644 index 0000000..d342808 --- /dev/null +++ b/packages/client/src/network-catalog.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest' +import { NETWORK_CATALOG, type CatalogNetwork } from './network-catalog.js' + +describe('NETWORK_CATALOG', () => { + it('contains the eight curated well-known networks', () => { + const names = NETWORK_CATALOG.map((n) => n.name) + expect(names).toEqual( + expect.arrayContaining([ + 'Libera.Chat', 'OFTC', 'EFNet', 'Undernet', 'Rizon', 'QuakeNet', 'DALnet', 'Snoonet', + ]), + ) + expect(NETWORK_CATALOG.length).toBe(8) + }) + + it('every entry is fully specified with a TLS port and no color/host placeholders', () => { + for (const n of NETWORK_CATALOG) { + expect(n.id).toMatch(/^[a-z0-9-]+$/) + expect(n.name.length).toBeGreaterThan(0) + expect(n.description.length).toBeGreaterThan(0) + expect(n.host).toMatch(/\./) // looks like a hostname + expect(n.port).toBeGreaterThan(0) + expect(n.port).toBeLessThan(65536) + expect(n.tls).toBe(true) + expect(n.tag.length).toBeGreaterThan(0) + } + }) + + it('has unique ids and hosts', () => { + const ids = new Set(NETWORK_CATALOG.map((n) => n.id)) + const hosts = new Set(NETWORK_CATALOG.map((n) => n.host)) + expect(ids.size).toBe(NETWORK_CATALOG.length) + expect(hosts.size).toBe(NETWORK_CATALOG.length) + }) + + it('is deterministic (stable across reads — no Date/random)', () => { + const a = JSON.stringify(NETWORK_CATALOG) + const b = JSON.stringify(NETWORK_CATALOG) + expect(a).toBe(b) + }) + + it('is typed as CatalogNetwork[]', () => { + const first: CatalogNetwork = NETWORK_CATALOG[0]! + expect(first).toHaveProperty('host') + }) +}) diff --git a/packages/client/src/network-catalog.ts b/packages/client/src/network-catalog.ts new file mode 100644 index 0000000..3d08b76 --- /dev/null +++ b/packages/client/src/network-catalog.ts @@ -0,0 +1,96 @@ +/** A curated, well-known IRC network the directory can one-click connect to. */ +export interface CatalogNetwork { + /** Stable slug used as a React key and for lookups. */ + id: string + /** Display name (also passed as the network `name` to addNetwork). */ + name: string + /** One-line description shown under the name. */ + description: string + /** TLS hostname. */ + host: string + /** TLS port (6697 by convention). */ + port: number + /** Always TLS for curated entries. */ + tls: true + /** Short tag/mark shown as a pill. */ + tag: string +} + +/** + * Curated directory of well-known IRC networks. Deterministic pure data — + * no Date/random, safe to snapshot. All entries use TLS on 6697. + */ +export const NETWORK_CATALOG: readonly CatalogNetwork[] = [ + { + id: 'libera', + name: 'Libera.Chat', + description: 'Home of free/open-source software projects', + host: 'irc.libera.chat', + port: 6697, + tls: true, + tag: 'FOSS', + }, + { + id: 'oftc', + name: 'OFTC', + description: 'Open and Free Technology Community', + host: 'irc.oftc.net', + port: 6697, + tls: true, + tag: 'FOSS', + }, + { + id: 'efnet', + name: 'EFNet', + description: 'One of the oldest original IRC networks', + host: 'irc.efnet.org', + port: 6697, + tls: true, + tag: 'Classic', + }, + { + id: 'undernet', + name: 'Undernet', + description: 'Large long-running general-purpose network', + host: 'irc.undernet.org', + port: 6697, + tls: true, + tag: 'General', + }, + { + id: 'rizon', + name: 'Rizon', + description: 'General chat, anime and gaming communities', + host: 'irc.rizon.net', + port: 6697, + tls: true, + tag: 'Community', + }, + { + id: 'quakenet', + name: 'QuakeNet', + description: 'Gaming-focused network from the Quake community', + host: 'irc.quakenet.org', + port: 6697, + tls: true, + tag: 'Gaming', + }, + { + id: 'dalnet', + name: 'DALnet', + description: 'Established network known for its services', + host: 'irc.dal.net', + port: 6697, + tls: true, + tag: 'General', + }, + { + id: 'snoonet', + name: 'Snoonet', + description: 'Reddit-affiliated general-purpose network', + host: 'irc.snoonet.org', + port: 6697, + tls: true, + tag: 'Community', + }, +] as const diff --git a/packages/client/src/palette-commands.test.ts b/packages/client/src/palette-commands.test.ts new file mode 100644 index 0000000..e8c527a --- /dev/null +++ b/packages/client/src/palette-commands.test.ts @@ -0,0 +1,56 @@ +// packages/client/src/palette-commands.test.ts +import { describe, it, expect, vi } from 'vitest' +import { PALETTE_COMMANDS } from './palette-commands.js' +import type { PaletteRunContext } from './palette-commands.js' + +const CMDS = [ + '/join', '/part', '/msg', '/me', '/nick', '/topic', '/whois', '/kick', + '/ban', '/unban', '/mode', '/query', '/notice', '/away', '/back', + '/invite', '/list', '/names', '/op', '/deop', '/voice', '/devoice', +] + +function makeCtx(store: Record): PaletteRunContext { + return { + networkId: 1, target: '#dev', isChannel: true, + store: store as any, prefill: vi.fn(), close: vi.fn(), + } +} + +describe('PALETTE_COMMANDS', () => { + it('includes every required IRC command exactly once', () => { + const names = PALETTE_COMMANDS.map((c) => c.name) + for (const c of CMDS) expect(names).toContain(c) + // no duplicates + expect(new Set(names).size).toBe(names.length) + }) + + it('every command has a syntax/arg hint and is scoped to Commands', () => { + for (const c of PALETTE_COMMANDS) { + expect(c.syntax.length).toBeGreaterThan(0) + expect(c.scope).toBe('Commands') + expect(typeof c.run).toBe('function') + } + }) + + it('/topic runs store.setTopic with the active channel and prefilled text', () => { + // Commands that take free-form args prefill the input (teaching the syntax); + // argument-free / channel-context commands dispatch. /topic with no arg should prefill "/topic ". + const setTopic = vi.fn() + const prefill = vi.fn() + const cmd = PALETTE_COMMANDS.find((c) => c.name === '/topic')! + cmd.run({ networkId: 1, target: '#dev', isChannel: true, + store: { setTopic } as any, prefill, close: vi.fn() }) + // With no argument captured in the palette, /topic teaches by prefilling the input. + expect(prefill).toHaveBeenCalledWith('/topic ') + }) + + it('/names is directly runnable and calls store.names', () => { + const names = vi.fn() + const close = vi.fn() + const cmd = PALETTE_COMMANDS.find((c) => c.name === '/names')! + cmd.run({ networkId: 1, target: '#dev', isChannel: true, + store: { names } as any, prefill: vi.fn(), close }) + expect(names).toHaveBeenCalledWith(1, '#dev') + expect(close).toHaveBeenCalled() + }) +}) diff --git a/packages/client/src/palette-commands.ts b/packages/client/src/palette-commands.ts new file mode 100644 index 0000000..e339555 --- /dev/null +++ b/packages/client/src/palette-commands.ts @@ -0,0 +1,75 @@ +// packages/client/src/palette-commands.ts +import type { useChat } from './store/chat-store.js' + +export interface PaletteRunContext { + networkId: number | null + target: string | null + isChannel: boolean + store: ReturnType + prefill: (text: string) => void // put "/cmd " into the input so the user completes args + close: () => void +} + +export interface PaletteCommand { + id: string + name: string // "/topic" + syntax: string // "/topic " (inline arg hint, shown as the row subtitle) + scope: 'Commands' + keywords: string[] + run: (ctx: PaletteRunContext) => void +} + +// Commands that need free-form arguments prefill the input (teaching the syntax); +// argument-free / channel-context commands dispatch immediately and close. +const prefillCmd = (text: string) => (c: PaletteRunContext) => c.prefill(text) + +export const PALETTE_COMMANDS: PaletteCommand[] = [ + { id: 'cmd-join', name: '/join', syntax: '/join <#channel>', scope: 'Commands', + keywords: ['join', 'channel', 'enter'], run: prefillCmd('/join #') }, + { id: 'cmd-part', name: '/part', syntax: '/part [reason]', scope: 'Commands', + keywords: ['part', 'leave', 'close'], + run: (c) => { if (c.networkId && c.target) c.store.part(c.networkId, c.target); c.close() } }, + { id: 'cmd-msg', name: '/msg', syntax: '/msg ', scope: 'Commands', + keywords: ['msg', 'message', 'dm', 'pm'], run: prefillCmd('/msg ') }, + { id: 'cmd-me', name: '/me', syntax: '/me ', scope: 'Commands', + keywords: ['me', 'action', 'emote'], run: prefillCmd('/me ') }, + { id: 'cmd-nick', name: '/nick', syntax: '/nick ', scope: 'Commands', + keywords: ['nick', 'rename', 'name'], run: prefillCmd('/nick ') }, + { id: 'cmd-topic', name: '/topic', syntax: '/topic ', scope: 'Commands', + keywords: ['topic', 'subject'], run: prefillCmd('/topic ') }, + { id: 'cmd-whois', name: '/whois', syntax: '/whois ', scope: 'Commands', + keywords: ['whois', 'info', 'user'], run: prefillCmd('/whois ') }, + { id: 'cmd-kick', name: '/kick', syntax: '/kick [reason]', scope: 'Commands', + keywords: ['kick', 'remove'], run: prefillCmd('/kick ') }, + { id: 'cmd-ban', name: '/ban', syntax: '/ban ', scope: 'Commands', + keywords: ['ban', 'block'], run: prefillCmd('/ban ') }, + { id: 'cmd-unban', name: '/unban', syntax: '/unban ', scope: 'Commands', + keywords: ['unban', 'allow'], run: prefillCmd('/unban ') }, + { id: 'cmd-mode', name: '/mode', syntax: '/mode ', scope: 'Commands', + keywords: ['mode', 'flags'], run: prefillCmd('/mode ') }, + { id: 'cmd-query', name: '/query', syntax: '/query ', scope: 'Commands', + keywords: ['query', 'dm', 'pm', 'message'], run: prefillCmd('/query ') }, + { id: 'cmd-notice', name: '/notice', syntax: '/notice ', scope: 'Commands', + keywords: ['notice'], run: prefillCmd('/notice ') }, + { id: 'cmd-away', name: '/away', syntax: '/away [message]', scope: 'Commands', + keywords: ['away', 'afk'], run: prefillCmd('/away ') }, + { id: 'cmd-back', name: '/back', syntax: '/back — clear away status', scope: 'Commands', + keywords: ['back', 'here', 'return'], + run: (c) => { if (c.networkId) c.store.setAway(c.networkId); c.close() } }, + { id: 'cmd-invite', name: '/invite', syntax: '/invite [#channel]', scope: 'Commands', + keywords: ['invite'], run: prefillCmd('/invite ') }, + { id: 'cmd-list', name: '/list', syntax: '/list [filter]', scope: 'Commands', + keywords: ['list', 'channels', 'browse'], + run: (c) => { if (c.networkId) c.store.listChannels(c.networkId); c.close() } }, + { id: 'cmd-names', name: '/names', syntax: '/names [#channel]', scope: 'Commands', + keywords: ['names', 'members', 'users'], + run: (c) => { if (c.networkId && c.target) c.store.names(c.networkId, c.target); c.close() } }, + { id: 'cmd-op', name: '/op', syntax: '/op ', scope: 'Commands', + keywords: ['op', 'operator'], run: prefillCmd('/op ') }, + { id: 'cmd-deop', name: '/deop', syntax: '/deop ', scope: 'Commands', + keywords: ['deop'], run: prefillCmd('/deop ') }, + { id: 'cmd-voice', name: '/voice', syntax: '/voice ', scope: 'Commands', + keywords: ['voice'], run: prefillCmd('/voice ') }, + { id: 'cmd-devoice', name: '/devoice', syntax: '/devoice ', scope: 'Commands', + keywords: ['devoice'], run: prefillCmd('/devoice ') }, +] diff --git a/packages/client/src/setup-client.test.ts b/packages/client/src/setup-client.test.ts new file mode 100644 index 0000000..ccdf57c --- /dev/null +++ b/packages/client/src/setup-client.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { fetchSetupStatus, validateInvite, createInvite } from './setup-client.js' + +afterEach(() => vi.restoreAllMocks()) + +function mockFetch(status: number, body: unknown) { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: status < 400, status, json: async () => body })) as unknown as typeof fetch, + ) +} + +describe('setup-client', () => { + it('fetchSetupStatus returns needsSetup from the server', async () => { + mockFetch(200, { needsSetup: true }) + await expect(fetchSetupStatus()).resolves.toEqual({ needsSetup: true }) + }) + + it('validateInvite hits /api/invites/:code and returns validity', async () => { + const f = vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ valid: true }) })) + vi.stubGlobal('fetch', f as unknown as typeof fetch) + await expect(validateInvite('ABCD-EFGH-JKLM')).resolves.toEqual({ valid: true }) + expect(f).toHaveBeenCalledWith('/api/invites/ABCD-EFGH-JKLM', expect.objectContaining({ credentials: 'include' })) + }) + + it('createInvite POSTs to /api/invites and returns the code', async () => { + mockFetch(200, { code: 'ABCD-EFGH-JKLM' }) + await expect(createInvite()).resolves.toEqual({ code: 'ABCD-EFGH-JKLM' }) + }) +}) diff --git a/packages/client/src/setup-client.ts b/packages/client/src/setup-client.ts new file mode 100644 index 0000000..d075b73 --- /dev/null +++ b/packages/client/src/setup-client.ts @@ -0,0 +1,17 @@ +export async function fetchSetupStatus(): Promise<{ needsSetup: boolean }> { + const res = await fetch('/api/setup-status', { credentials: 'include' }) + if (!res.ok) return { needsSetup: false } + return res.json() +} + +export async function validateInvite(code: string): Promise<{ valid: boolean }> { + const res = await fetch(`/api/invites/${encodeURIComponent(code)}`, { credentials: 'include' }) + if (!res.ok) return { valid: false } + return res.json() +} + +export async function createInvite(): Promise<{ code: string }> { + const res = await fetch('/api/invites', { method: 'POST', credentials: 'include' }) + if (!res.ok) throw new Error('Could not create invite') + return res.json() +} diff --git a/packages/client/src/store/chat-store.test.ts b/packages/client/src/store/chat-store.test.ts index d0e9ced..c01fd3e 100644 --- a/packages/client/src/store/chat-store.test.ts +++ b/packages/client/src/store/chat-store.test.ts @@ -29,6 +29,63 @@ describe('chat store', () => { expect(useChat.getState().messages[key]!.map((m) => m.body)).toEqual(['hi']) }) + it('net:state carries the full state and derives connected', () => { + const { sock, emitServer, open } = fakeSocket() + useChat.getState().init(() => sock) + open() + emitServer({ type: 'net:list', networks: [{ id: 1, name: 'Libera', host: 'h', port: 6697, tls: true, nick: 'me', connected: false }] }) + + emitServer({ type: 'net:state', networkId: 1, name: 'Libera', state: 'connecting', nick: 'me' }) + expect(useChat.getState().networks[1]!.state).toBe('connecting') + expect(useChat.getState().networks[1]!.connected).toBe(false) + + emitServer({ type: 'net:state', networkId: 1, name: 'Libera', state: 'registered', nick: 'me' }) + expect(useChat.getState().networks[1]!.state).toBe('registered') + expect(useChat.getState().networks[1]!.connected).toBe(true) + + emitServer({ type: 'net:state', networkId: 1, name: 'Libera', state: 'reconnecting' }) + expect(useChat.getState().networks[1]!.state).toBe('reconnecting') + expect(useChat.getState().networks[1]!.connected).toBe(false) + + emitServer({ type: 'net:state', networkId: 1, name: 'Libera', state: 'closed' }) + expect(useChat.getState().networks[1]!.state).toBe('closed') + expect(useChat.getState().networks[1]!.connected).toBe(false) + }) + + it('net:state stores the negotiated caps on the network (Task 16)', () => { + const { sock, emitServer, open } = fakeSocket() + useChat.getState().init(() => sock) + open() + emitServer({ type: 'net:list', networks: [{ id: 1, name: 'Libera', host: 'h', port: 6697, tls: true, nick: 'me', connected: false }] }) + emitServer({ type: 'net:state', networkId: 1, name: 'Libera', state: 'registered', nick: 'me', caps: ['away-notify', 'message-tags'] }) + expect(useChat.getState().networks[1]!.caps).toEqual(['away-notify', 'message-tags']) + }) + + it('net:list on reconnect preserves caps and does not downgrade a live state (final review)', () => { + const { sock, emitServer, open } = fakeSocket() + useChat.getState().init(() => sock) + open() + emitServer({ type: 'net:list', networks: [{ id: 1, name: 'Libera', host: 'h', port: 6697, tls: true, nick: 'me', connected: false }] }) + emitServer({ type: 'net:state', networkId: 1, name: 'Libera', state: 'registered', nick: 'me', caps: ['away-notify', 'message-tags'] }) + expect(useChat.getState().networks[1]!.caps).toEqual(['away-notify', 'message-tags']) + expect(useChat.getState().networks[1]!.state).toBe('registered') + + // Server sends net:list on every WS (re)subscribe — including internal + // reconnects that never reset Zustand state (T3). A still-registered + // network should keep its caps and 'registered' state, not get wiped. + emitServer({ type: 'net:list', networks: [{ id: 1, name: 'Libera', host: 'h', port: 6697, tls: true, nick: 'me', connected: true }] }) + expect(useChat.getState().networks[1]!.caps).toEqual(['away-notify', 'message-tags']) + expect(useChat.getState().networks[1]!.state).toBe('registered') + expect(useChat.getState().networks[1]!.connected).toBe(true) + + // A network mid-'connecting' must not be downgraded to 'closed' by a + // net:list that only reports connected:false (it carries no fine-grained state). + emitServer({ type: 'net:state', networkId: 1, name: 'Libera', state: 'connecting', nick: 'me' }) + expect(useChat.getState().networks[1]!.state).toBe('connecting') + emitServer({ type: 'net:list', networks: [{ id: 1, name: 'Libera', host: 'h', port: 6697, tls: true, nick: 'me', connected: false }] }) + expect(useChat.getState().networks[1]!.state).toBe('connecting') + }) + it('send() posts a chat:send for the selected target', () => { const { sock, open } = fakeSocket() useChat.getState().init(() => sock) @@ -50,13 +107,18 @@ describe('chat store', () => { }) it('sets connection to "open" when socket opens and "closed" when it closes', () => { - const { sock, open, close } = fakeSocket() - useChat.getState().init(() => sock) - expect(useChat.getState().connection).toBe('connecting') - open() - expect(useChat.getState().connection).toBe('open') - close() - expect(useChat.getState().connection).toBe('closed') + vi.useFakeTimers() + try { + const { sock, open, close } = fakeSocket() + useChat.getState().init(() => sock) + expect(useChat.getState().connection).toBe('connecting') + open() + expect(useChat.getState().connection).toBe('open') + close() + expect(useChat.getState().connection).toBe('closed') + } finally { + vi.useRealTimers() + } }) it('sets lastError on net:error', () => { @@ -283,4 +345,306 @@ describe('chat store', () => { expect(useChat.getState().selected).toBe(key) expect(sock.send).toHaveBeenCalledWith(JSON.stringify({ type: 'chat:send', networkId: 1, target: 'bob', text: 'hey' })) }) + + it('openDm creates + selects the PM target without sending anything', () => { + const { sock, open } = fakeSocket() + useChat.getState().init(() => sock) + open() + vi.mocked(sock.send).mockClear() // drop the 'hello' handshake frame sent on open + useChat.getState().openDm(1, 'ada') + expect(useChat.getState().selected).toBe('1:ada') + expect(useChat.getState().targets['1:ada']).toMatchObject({ kind: 'pm', target: 'ada' }) + expect(sock.send).not.toHaveBeenCalled() + }) +}) + +describe('chat-store — WS-6 command actions', () => { + it('notice() sends a chat:send with notice=true', () => { + const { sock, open } = fakeSocket() + useChat.getState().init(() => sock) + open() + useChat.getState().notice(1, 'ada', 'heads up') + expect(sock.send).toHaveBeenCalledWith(JSON.stringify({ type: 'chat:send', networkId: 1, target: 'ada', text: 'heads up', notice: true })) + }) + + it('setAway() sends user:away with the message; empty clears it', () => { + const { sock, open } = fakeSocket() + useChat.getState().init(() => sock) + open() + useChat.getState().setAway(1, 'brb') + expect(sock.send).toHaveBeenCalledWith(JSON.stringify({ type: 'user:away', networkId: 1, message: 'brb' })) + useChat.getState().setAway(1) + expect(sock.send).toHaveBeenCalledWith(JSON.stringify({ type: 'user:away', networkId: 1 })) + }) + + it('invite() sends user:invite', () => { + const { sock, open } = fakeSocket() + useChat.getState().init(() => sock) + open() + useChat.getState().invite(1, 'ada', '#dev') + expect(sock.send).toHaveBeenCalledWith(JSON.stringify({ type: 'user:invite', networkId: 1, nick: 'ada', channel: '#dev' })) + }) + + it('names() requests the member list', () => { + const { sock, open } = fakeSocket() + useChat.getState().init(() => sock) + open() + useChat.getState().names(1, '#dev') + expect(sock.send).toHaveBeenCalledWith(JSON.stringify({ type: 'chan:names:req', networkId: 1, channel: '#dev' })) + }) + + // --- join opens channel (F1) --- + it('join() selects the channel once the server confirms our join', () => { + const { sock, emitServer, open } = fakeSocket() + useChat.getState().init(() => sock) + open() + useChat.getState().join(1, '#dev') + // server confirms: + emitServer({ type: 'chan:join', networkId: 1, channel: '#dev', nick: 'me', self: true }) + expect(useChat.getState().selected).toBe('1:#dev') + }) + + it('a rejoin replayed by the server does NOT yank selection', () => { + const { sock, emitServer, open } = fakeSocket() + useChat.getState().init(() => sock) + open() + useChat.getState().select('1:#other') + emitServer({ type: 'chan:join', networkId: 1, channel: '#dev', nick: 'me', self: true }) + expect(useChat.getState().selected).toBe('1:#other') + }) + + // --- mentions (F2) --- + it('counts a mention when an unselected channel message contains our nick', () => { + const { sock, emitServer, open } = fakeSocket() + useChat.getState().init(() => sock) + open() + emitServer({ type: 'net:list', networks: [{ id: 1, name: 'Libera', host: 'h', port: 6697, tls: true, nick: 'me', connected: true }] }) + emitServer({ type: 'chat:msg', networkId: 1, target: '#dev', from: 'ada', kind: 'privmsg', text: 'hey me: look', self: false }) + const t = useChat.getState().targets['1:#dev']! + expect(t.unread).toBe(1) + expect(t.mentions).toBe(1) + }) + + it('does not count substring false-positives (nick "me" inside "some")', () => { + const { sock, emitServer, open } = fakeSocket() + useChat.getState().init(() => sock) + open() + emitServer({ type: 'net:list', networks: [{ id: 1, name: 'Libera', host: 'h', port: 6697, tls: true, nick: 'me', connected: true }] }) + emitServer({ type: 'chat:msg', networkId: 1, target: '#dev', from: 'ada', kind: 'privmsg', text: 'awesome stuff', self: false }) + expect(useChat.getState().targets['1:#dev']!.mentions ?? 0).toBe(0) + }) + + it('select() clears mentions along with unread', () => { + const { sock, emitServer, open } = fakeSocket() + useChat.getState().init(() => sock) + open() + emitServer({ type: 'net:list', networks: [{ id: 1, name: 'Libera', host: 'h', port: 6697, tls: true, nick: 'me', connected: true }] }) + emitServer({ type: 'chat:msg', networkId: 1, target: '#dev', from: 'ada', kind: 'privmsg', text: 'hey me: look', self: false }) + expect(useChat.getState().targets['1:#dev']!.mentions).toBe(1) + useChat.getState().select('1:#dev') + expect(useChat.getState().targets['1:#dev']!.unread).toBe(0) + expect(useChat.getState().targets['1:#dev']!.mentions).toBe(0) + }) + + // --- session:targets --- + it('session:targets creates missing targets but never clobbers live ones', () => { + const { sock, emitServer, open } = fakeSocket() + useChat.getState().init(() => sock) + open() + // Live target with unread 5 already exists (e.g. from a chat:msg received this session). + useChat.setState({ + targets: { + [targetKey(1, '#dev')]: { networkId: 1, target: '#dev', kind: 'channel', unread: 5 }, + }, + } as any) + + emitServer({ + type: 'session:targets', + targets: [ + { networkId: 1, target: '#dev', kind: 'channel', unread: 1 }, + { networkId: 1, target: 'ada', kind: 'pm', unread: 2 }, + ], + }) + + const targets = useChat.getState().targets + // Live target keeps its unread count — the snapshot must not clobber it. + expect(targets[targetKey(1, '#dev')]!.unread).toBe(5) + // Missing target is created from the snapshot. + expect(targets[targetKey(1, 'ada')]).toEqual({ networkId: 1, target: 'ada', kind: 'pm', unread: 2 }) + }) +}) + +describe('chat-store — join/part/quit render as system lines (Task 15)', () => { + it('a join renders as a system line in the channel messages', () => { + const { sock, emitServer, open } = fakeSocket() + useChat.getState().init(() => sock) + open() + const key = targetKey(1, '#dev') + useChat.setState({ targets: { [key]: { networkId: 1, target: '#dev', kind: 'channel', unread: 0, names: [] } } }) + emitServer({ type: 'chan:join', networkId: 1, channel: '#dev', nick: 'ada', self: false }) + const msgs = useChat.getState().messages[key] ?? [] + expect(msgs.some((m) => m.kind === 'system' && /ada joined/.test(m.body))).toBe(true) + }) + + it('system lines never bump unread or mentions', () => { + const { sock, emitServer, open } = fakeSocket() + useChat.getState().init(() => sock) + open() + const key = targetKey(1, '#dev') + useChat.setState({ targets: { [key]: { networkId: 1, target: '#dev', kind: 'channel', unread: 0, names: [] } } }) + emitServer({ type: 'chan:join', networkId: 1, channel: '#dev', nick: 'ada', self: false }) + expect(useChat.getState().targets[key]?.unread ?? 0).toBe(0) + expect(useChat.getState().targets[key]?.mentions ?? 0).toBe(0) + }) + + it('a system join line has an empty sender — never bumps unread/mentions by construction', () => { + const { sock, emitServer, open } = fakeSocket() + useChat.getState().init(() => sock) + open() + const key = targetKey(1, '#dev') + useChat.setState({ targets: { [key]: { networkId: 1, target: '#dev', kind: 'channel', unread: 0, names: [] } } }) + emitServer({ type: 'chan:join', networkId: 1, channel: '#dev', nick: 'ada', self: false }) + const sys = (useChat.getState().messages[key] ?? []).find((m) => m.kind === 'system') + expect(sys?.sender).toBe('') + }) + + it('our own (self) join also appends a system line', () => { + const { sock, emitServer, open } = fakeSocket() + useChat.getState().init(() => sock) + open() + const key = targetKey(1, '#dev') + emitServer({ type: 'chan:join', networkId: 1, channel: '#dev', nick: 'me', self: true }) + const msgs = useChat.getState().messages[key] ?? [] + expect(msgs.some((m) => m.kind === 'system' && /joined/.test(m.body))).toBe(true) + }) + + it('a replayed self join (target already exists, e.g. session resume / reconnect) does NOT append another system line', () => { + const { sock, emitServer, open } = fakeSocket() + useChat.getState().init(() => sock) + open() + const key = targetKey(1, '#dev') + // First join — genuinely new this session, appends exactly one system line. + emitServer({ type: 'chan:join', networkId: 1, channel: '#dev', nick: 'me', self: true }) + const afterFirst = useChat.getState().messages[key] ?? [] + expect(afterFirst.filter((m) => m.kind === 'system' && /joined/.test(m.body))).toHaveLength(1) + + // Server re-fires chan:join self:true for the same target — session resume rejoin + // or irc-framework auto_reconnect replay. The target already exists, so this must + // be a no-op for the transcript. + emitServer({ type: 'chan:join', networkId: 1, channel: '#dev', nick: 'me', self: true }) + const afterReplay = useChat.getState().messages[key] ?? [] + expect(afterReplay.filter((m) => m.kind === 'system' && /joined/.test(m.body))).toHaveLength(1) + }) + + it('a part renders a system "left" line, including the reason when present', () => { + const { sock, emitServer, open } = fakeSocket() + useChat.getState().init(() => sock) + open() + const key = targetKey(1, '#dev') + useChat.setState({ targets: { [key]: { networkId: 1, target: '#dev', kind: 'channel', unread: 0, names: [{ nick: 'ada', modes: [] }] } } }) + emitServer({ type: 'chan:part', networkId: 1, channel: '#dev', nick: 'ada', reason: 'bye' }) + const msgs = useChat.getState().messages[key] ?? [] + expect(msgs.some((m) => m.kind === 'system' && /ada left \(bye\)/.test(m.body))).toBe(true) + }) + + it('a quit renders a system "quit" line only in channels the nick was actually in', () => { + const { sock, emitServer, open } = fakeSocket() + useChat.getState().init(() => sock) + open() + const inKey = targetKey(1, '#dev') + const notInKey = targetKey(1, '#other') + useChat.setState({ + targets: { + [inKey]: { networkId: 1, target: '#dev', kind: 'channel', unread: 0, names: [{ nick: 'ada', modes: [] }] }, + [notInKey]: { networkId: 1, target: '#other', kind: 'channel', unread: 0, names: [{ nick: 'bob', modes: [] }] }, + }, + }) + emitServer({ type: 'presence:quit', networkId: 1, nick: 'ada', reason: 'Ping timeout' }) + const inMsgs = useChat.getState().messages[inKey] ?? [] + const notInMsgs = useChat.getState().messages[notInKey] ?? [] + expect(inMsgs.some((m) => m.kind === 'system' && /ada quit \(Ping timeout\)/.test(m.body))).toBe(true) + expect(notInMsgs.some((m) => m.kind === 'system')).toBe(false) + }) + + it('presence:away flips the away flag on every matching names entry across the network (Task 16)', () => { + const { sock, emitServer, open } = fakeSocket() + useChat.getState().init(() => sock) + open() + const key1 = targetKey(1, '#dev') + const key2 = targetKey(1, '#other') + useChat.setState({ + targets: { + [key1]: { networkId: 1, target: '#dev', kind: 'channel', unread: 0, names: [{ nick: 'ada', modes: [] }, { nick: 'bob', modes: [] }] }, + [key2]: { networkId: 1, target: '#other', kind: 'channel', unread: 0, names: [{ nick: 'ada', modes: [] }] }, + }, + }) + emitServer({ type: 'presence:away', networkId: 1, nick: 'ada', away: true }) + expect(useChat.getState().targets[key1]!.names).toEqual([{ nick: 'ada', modes: [], away: true }, { nick: 'bob', modes: [] }]) + expect(useChat.getState().targets[key2]!.names).toEqual([{ nick: 'ada', modes: [], away: true }]) + + emitServer({ type: 'presence:away', networkId: 1, nick: 'ada', away: false }) + expect(useChat.getState().targets[key1]!.names).toEqual([{ nick: 'ada', modes: [], away: false }, { nick: 'bob', modes: [] }]) + }) + + it('presence:away does not touch targets on other networks', () => { + const { sock, emitServer, open } = fakeSocket() + useChat.getState().init(() => sock) + open() + const otherNetKey = targetKey(2, '#dev') + useChat.setState({ + targets: { + [otherNetKey]: { networkId: 2, target: '#dev', kind: 'channel', unread: 0, names: [{ nick: 'ada', modes: [] }] }, + }, + }) + emitServer({ type: 'presence:away', networkId: 1, nick: 'ada', away: true }) + expect(useChat.getState().targets[otherNetKey]!.names).toEqual([{ nick: 'ada', modes: [] }]) + }) +}) + +describe('chat-store — channel browser (audit F5)', () => { + it('listChannels() resets channelList before sending chan:list', () => { + const { sock, open } = fakeSocket() + useChat.getState().init(() => sock) + open() + useChat.getState().listChannels(1) + expect(useChat.getState().channelList).toEqual({ networkId: 1, channels: [], done: false }) + expect(sock.send).toHaveBeenCalledWith(JSON.stringify({ type: 'chan:list', networkId: 1 })) + }) + + it('accumulates chan:list:results batches for the same network and flips done', () => { + const { sock, emitServer, open } = fakeSocket() + useChat.getState().init(() => sock) + open() + useChat.getState().listChannels(1) + + emitServer({ type: 'chan:list:results', networkId: 1, channels: [{ name: '#a', users: 3, topic: 'first' }], done: false }) + expect(useChat.getState().channelList).toEqual({ + networkId: 1, + channels: [{ name: '#a', users: 3, topic: 'first' }], + done: false, + }) + + emitServer({ type: 'chan:list:results', networkId: 1, channels: [{ name: '#b', users: 1, topic: 'second' }], done: false }) + expect(useChat.getState().channelList!.channels).toEqual([ + { name: '#a', users: 3, topic: 'first' }, + { name: '#b', users: 1, topic: 'second' }, + ]) + expect(useChat.getState().channelList!.done).toBe(false) + + emitServer({ type: 'chan:list:results', networkId: 1, channels: [], done: true }) + expect(useChat.getState().channelList!.done).toBe(true) + expect(useChat.getState().channelList!.channels).toHaveLength(2) + }) + + it('a new listChannels() call clears previous results', () => { + const { sock, emitServer, open } = fakeSocket() + useChat.getState().init(() => sock) + open() + useChat.getState().listChannels(1) + emitServer({ type: 'chan:list:results', networkId: 1, channels: [{ name: '#a', users: 3, topic: 'first' }], done: true }) + expect(useChat.getState().channelList!.channels).toHaveLength(1) + + useChat.getState().listChannels(1) + expect(useChat.getState().channelList).toEqual({ networkId: 1, channels: [], done: false }) + }) }) diff --git a/packages/client/src/store/chat-store.ts b/packages/client/src/store/chat-store.ts index f77ca1d..6cf1239 100644 --- a/packages/client/src/store/chat-store.ts +++ b/packages/client/src/store/chat-store.ts @@ -21,6 +21,7 @@ const emptyState: ChatState = { reactions: {}, replyingTo: null, previews: {}, + channelList: null, } let _msgIdCounter = 0 @@ -54,22 +55,74 @@ function mergeMessages(existing: StoredMsg[], incoming: StoredMsg[]): StoredMsg[ return all.length > 500 ? all.slice(all.length - 500) : all } +// Set by the join() action when the user initiates a join; matched (and +// cleared) by the chan:join reducer once the server confirms it, so the +// resulting select() only fires for user-initiated joins — not for a +// server-replayed rejoin on reconnect/boot (Task 4's session resume). +let _pendingJoin: string | null = null + function inferKind(target: string): TargetKind { if (target === '*') return 'status' return target.startsWith('#') || target.startsWith('&') ? 'channel' : 'pm' } +export function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +// IRC nick boundary chars: a nick occurrence must not be preceded/followed by +// nick-ish characters ([A-Za-z0-9_\-\[\]\\^{}|`]) — otherwise "dmfoo" would +// falsely match nick "dmf". Single source of truth: both mentionsNick() (unread/ +// mentions bump) and irc-format's inline-mention parser (rendering) build their +// regex from this pattern so the definition of "a mention" never drifts apart. +const NICK_BOUNDARY_CHARS = 'A-Za-z0-9_\\-\\[\\]\\\\^{}|`' + +/** Regex SOURCE (no flags) for `nick` at an IRC boundary. Group 1 is the + * (possibly empty) leading boundary char; group 2 is the nick match itself, + * cased as found in the text. The trailing boundary is a zero-width lookahead + * so callers can splice segments back together without losing a character. */ +export function nickBoundaryPattern(nick: string): string { + return `(^|[^${NICK_BOUNDARY_CHARS}])(${escapeRegExp(nick)})(?=[^${NICK_BOUNDARY_CHARS}]|$)` +} + +export function mentionsNick(body: string, nick: string | undefined): boolean { + if (!nick) return false + return new RegExp(nickBoundaryPattern(nick), 'i').test(body) +} + +/** Build a client-synthesized, sender-less 'system' line (join/part/quit) for + * the message stream. Never persisted/queried server-side and must never bump + * unread/mentions — callers merge it into state.messages only. */ +function systemMsg(networkId: number, target: string, body: string): StoredMsg { + return { id: nextMsgId(), networkId, target, sender: '', kind: 'system', body, ts: Date.now() } +} + function reduceServerMessage(state: ChatState, msg: ServerMessage): Partial { switch (msg.type) { case 'net:list': { const networks = { ...state.networks } for (const net of msg.networks) { + const existing = state.networks[net.id] + // net:list is sent on every WS subscribe (including internal reconnects + // that don't reset Zustand state — T3). It only carries a coarse + // `connected` boolean, not the fine-grained state or caps, so we must + // preserve prior per-network fields across the rebuild rather than + // clobbering them: caps (set by net:state on registration) and a live + // transient 'connecting'/'reconnecting' state must survive. + const netState = + net.connected + ? 'registered' + : existing?.state === 'connecting' || existing?.state === 'reconnecting' + ? existing.state + : 'closed' networks[net.id] = { id: net.id, name: net.name, host: net.host, nick: net.nick, - connected: net.connected, + connected: netState === 'registered', + state: netState, + caps: existing?.caps, } } return { networks } @@ -85,7 +138,9 @@ function reduceServerMessage(state: ChatState, msg: ServerMessage): Partial n.nick !== msg.nick) + const sys = systemMsg( + msg.networkId, + msg.channel, + `← ${msg.nick} left${msg.reason ? ` (${msg.reason})` : ''}`, + ) return { targets: { ...state.targets, [key]: { ...existing, names }, }, + messages: { ...state.messages, [key]: mergeMessages(state.messages[key] ?? [], [sys]) }, } } case 'presence:quit': { - // Remove the nick from all targets in this network + // Remove the nick from every target in this network where they were a member — + // and append a system quit line to each of those channels' message streams. const targets = { ...state.targets } + const messages = { ...state.messages } for (const [key, t] of Object.entries(targets)) { - if (t.networkId === msg.networkId && t.names) { - targets[key] = { ...t, names: t.names.filter((n) => n.nick !== msg.nick) } + if (t.networkId === msg.networkId && t.names?.some((n) => n.nick === msg.nick)) { + targets[key] = { ...t, names: t.names!.filter((n) => n.nick !== msg.nick) } + const sys = systemMsg( + msg.networkId, + t.target, + `⇐ ${msg.nick} quit${msg.reason ? ` (${msg.reason})` : ''}`, + ) + messages[key] = mergeMessages(messages[key] ?? [], [sys]) } } - return { targets } + return { targets, messages } } case 'presence:nick': { @@ -231,6 +320,21 @@ function reduceServerMessage(state: ChatState, msg: ServerMessage): Partial n.nick === msg.nick)) { + targets[key] = { + ...t, + names: t.names!.map((n) => n.nick === msg.nick ? { ...n, away: msg.away } : n), + } + } + } + return { targets } + } + case 'read:list': { const readMarkers = { ...state.readMarkers } for (const m of msg.markers) { @@ -303,6 +407,17 @@ function reduceServerMessage(state: ChatState, msg: ServerMessage): Partial client.send(msg) - client.onOpen(() => set({ connection: 'open' })) + client.onOpen(() => { + set({ connection: 'open' }) + // Re-sent on every (re)connect: the server subscribes + replays state per socket. + client.send({ type: 'hello', clientVersion: PROTOCOL_VERSION }) + }) client.onClose(() => set({ connection: 'closed' })) client.onMessage((msg: ServerMessage) => { set((state) => reduceServerMessage(state, msg)) }) - - // Send hello (ws-client queues it until open) - client.send({ type: 'hello', clientVersion: PROTOCOL_VERSION }) }, addNetwork(input) { @@ -366,6 +496,7 @@ function createActions( }, join(networkId: number, channel: string) { + _pendingJoin = targetKey(networkId, channel).toLowerCase() _send?.({ type: 'chan:join', networkId, channel }) }, @@ -404,9 +535,26 @@ function createActions( }, listChannels(networkId: number, filter?: string) { + set({ channelList: { networkId, channels: [], done: false } }) _send?.({ type: 'chan:list', networkId, ...(filter ? { filter } : {}) }) }, + notice(networkId: number, target: string, text: string) { + _send?.({ type: 'chat:send', networkId, target, text, notice: true }) + }, + + setAway(networkId: number, message?: string) { + _send?.({ type: 'user:away', networkId, ...(message ? { message } : {}) }) + }, + + invite(networkId: number, nick: string, channel: string) { + _send?.({ type: 'user:invite', networkId, nick, channel }) + }, + + names(networkId: number, channel: string) { + _send?.({ type: 'chan:names:req', networkId, channel }) + }, + messageUser(networkId: number, nick: string, text: string) { const key = targetKey(networkId, nick) set((state) => ({ @@ -419,13 +567,24 @@ function createActions( _send?.({ type: 'chat:send', networkId, target: nick, text }) }, + openDm(networkId: number, nick: string) { + const key = targetKey(networkId, nick) + set((state) => ({ + selected: key, + targets: { + ...state.targets, + [key]: state.targets[key] ?? { networkId, target: nick, kind: 'pm', unread: 0 }, + }, + })) + }, + select(key: string) { set({ selected: key }) - // Clear unread for the selected target + // Clear unread (and mentions) for the selected target const targets = get().targets const t = targets[key] - if (t && t.unread > 0) { - set({ targets: { ...targets, [key]: { ...t, unread: 0 } } }) + if (t && (t.unread > 0 || (t.mentions ?? 0) > 0)) { + set({ targets: { ...targets, [key]: { ...t, unread: 0, mentions: 0 } } }) } }, @@ -465,7 +624,7 @@ function createActions( return { readMarkers: { ...state.readMarkers, [key]: ts }, targets: t - ? { ...state.targets, [key]: { ...t, unread: 0 } } + ? { ...state.targets, [key]: { ...t, unread: 0, mentions: 0 } } : state.targets, } }) diff --git a/packages/client/src/store/types.ts b/packages/client/src/store/types.ts index b5615f0..7ac13b2 100644 --- a/packages/client/src/store/types.ts +++ b/packages/client/src/store/types.ts @@ -17,6 +17,8 @@ export type TargetKind = 'channel' | 'pm' | 'status' export interface NameEntry { nick: string modes: string[] + /** Client-side only: set from presence:away, not present on the wire from chan:names. */ + away?: boolean } export interface TargetState { @@ -26,14 +28,22 @@ export interface TargetState { unread: number names?: NameEntry[] topic?: string + /** Count of unread messages that mention our nick (subset of `unread`). */ + mentions?: number } +export type NetworkConnState = 'connecting' | 'registered' | 'reconnecting' | 'closed' + export interface NetworkState { id: number name: string host: string nick: string + /** Derived: state === 'registered'. Kept for existing consumers. */ connected: boolean + state: NetworkConnState + /** Negotiated IRCv3 caps, set from net:state on registration. */ + caps?: string[] } export interface SearchResults { @@ -58,6 +68,8 @@ export interface ChatState { replyingTo: string | null /** url → PreviewResult (only ok:true results stored) */ previews: Record + /** Accumulated /LIST results for the channel browser, or null before a listChannels() call. */ + channelList: { networkId: number; channels: Array<{ name: string; users: number; topic: string }>; done: boolean } | null } export interface ChatActions { @@ -101,6 +113,16 @@ export interface ChatActions { listChannels(networkId: number, filter?: string): void /** Create/select a PM with `nick` on `networkId` and send `text`. */ messageUser(networkId: number, nick: string, text: string): void + /** Create the PM target with `nick` on `networkId` if missing and select it. Sends nothing. */ + openDm(networkId: number, nick: string): void + /** Send a NOTICE to `target` (uses chat:send with notice flag). */ + notice(networkId: number, target: string, text: string): void + /** Set away status with an optional message (omit message to clear). */ + setAway(networkId: number, message?: string): void + /** Invite `nick` to `channel` on `networkId`. */ + invite(networkId: number, nick: string, channel: string): void + /** Request the member list for `channel` on `networkId`. */ + names(networkId: number, channel: string): void } export type ChatStore = ChatState & ChatActions diff --git a/packages/client/src/styles/app.css b/packages/client/src/styles/app.css index 503e29d..5c0d31a 100644 --- a/packages/client/src/styles/app.css +++ b/packages/client/src/styles/app.css @@ -5,6 +5,69 @@ Uses CSS variables from tokens.css — no hardcoded colors. ============================================================ */ +/* =========================================================================== + Live-log wrapper — keeps Virtuoso height behavior intact inside role=log. + =========================================================================== */ +[role="log"] { flex: 1; display: flex; flex-direction: column; min-height: 0; } + +/* =========================================================================== + Empty state wrapper — centers EmptyState in the message pane. + =========================================================================== */ +.msgs-empty-wrap { flex: 1; display: flex; align-items: center; justify-content: center; } + +/* =========================================================================== + History-loading skeleton. + =========================================================================== */ +.skel { display: flex; flex-direction: column; gap: var(--group-gap); padding: 10px 16px; } +.skel-row { display: grid; grid-template-columns: 52px 1fr; gap: 0 10px; align-items: center; } +.skel-gutter, .skel-line { + height: var(--msg-fs); + border-radius: var(--radius-sm); + background: linear-gradient(90deg, var(--bg-3), var(--bg-4), var(--bg-3)); + background-size: 200% 100%; + animation: skel-shimmer 1.4s ease-in-out infinite; +} +.skel-gutter { width: 34px; justify-self: end; } +@keyframes skel-shimmer { 0% { background-position: 200% 0 } 100% { background-position: -200% 0 } } +@media (prefers-reduced-motion: reduce) { + .skel-gutter, .skel-line { animation: none; background: var(--bg-3); } +} + +/* =========================================================================== + Connection error / reconnecting banner. + =========================================================================== */ +.conn-banner { + position: fixed; top: 8px; left: 50%; transform: translateX(-50%); z-index: 1000; + display: flex; align-items: center; gap: 10px; max-width: min(600px, 90vw); + padding: 8px 14px; border-radius: var(--radius); + background: var(--bg-3); font-family: var(--mono); font-size: 12px; + box-shadow: 0 4px 20px -4px var(--shadow-overlay, rgba(0,0,0,0.5)); +} +.conn-banner.error { border: 1px solid var(--red); color: var(--red); } +.conn-banner.warn { border: 1px solid var(--amber); color: var(--amber); } +.conn-banner-text { flex: 1; } +.conn-banner-dismiss { + min-width: 24px; min-height: 24px; background: none; border: none; + color: var(--ink-2); cursor: pointer; font-family: var(--mono); font-size: 13px; line-height: 1; +} +.conn-banner-icon { font-size: 13px; } + +/* =========================================================================== + Mobile drawer toggle — hidden on wide screens (sidebar always present). + Shown at narrow widths via the responsive block in base.css. + =========================================================================== */ +.drawer-toggle { display: none; } + +/* =========================================================================== + Touch target enlargement on coarse pointers (44px). + =========================================================================== */ +@media (pointer: coarse) { + .net-btn { width: 44px; height: 44px; } + .chan { min-height: 44px; } + .tb-btn, .drawer-toggle, .conn-banner-dismiss, .grp-add { min-width: 44px; min-height: 44px; } + .mem { min-height: 44px; } +} + /* ---- Network rail ---- */ .rail { background: var(--bg-1); @@ -152,6 +215,56 @@ letter-spacing: .2px; } +.net-status { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--amber); + margin-top: 4px; +} + +.net-reconnect { + font-family: var(--mono); + font-size: 11px; + color: var(--ink-0); + background: none; + border: 1px solid var(--line); + border-radius: 4px; + padding: 1px 6px; + cursor: pointer; +} + +.net-reconnect:hover { + border-color: var(--ink-3); +} + +.side-search { + margin-top: 10px; + display: flex; + align-items: center; + gap: 7px; + background: var(--bg-3); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + padding: 5px 8px; + color: var(--ink-2); +} +.side-search svg { + width: 13px; + height: 13px; + flex: none; +} +.side-search input { + background: none; + border: none; + outline: none; + color: var(--ink-0); + font-family: var(--sans); + font-size: 12px; + width: 100%; +} + .side-scroll { overflow-y: auto; padding: 8px 6px; @@ -318,7 +431,9 @@ display: flex; align-items: baseline; gap: 8px; + flex: none; min-width: 0; + max-width: 40%; } .topbar .ch h1 { @@ -343,6 +458,7 @@ white-space: nowrap; border-left: 1px solid var(--line); padding-left: 12px; + flex: 1; min-width: 0; } @@ -735,6 +851,44 @@ color: var(--ink-2); } +/* ---- "?" shortcut cheatsheet overlay ---- */ +.cheat-panel { + position: fixed; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: min(560px, 92vw); + max-height: 80vh; + overflow-y: auto; + background: var(--bg-1); + border: 1px solid var(--line-2); + border-radius: 14px; + box-shadow: 0 40px 120px -20px var(--shadow-overlay, rgba(0,0,0,.8)), 0 0 0 1px var(--glow); + z-index: 901; + padding: 20px 22px; +} +.cheat-title { + font-family: var(--mono); + font-size: 11px; + letter-spacing: 1.2px; + text-transform: uppercase; + color: var(--ink-3); + margin: 14px 0 6px; +} +.cheat-title:first-child { margin-top: 0; } +.cheat-list { list-style: none; margin: 0; padding: 0; } +.cheat-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 28px; + padding: 2px 0; +} +.cheat-desc { font-size: 13px; color: var(--ink-1); } +.cheat-cmd { font-family: var(--mono); font-size: 13px; color: var(--green); } +.cheat-syntax { font-family: var(--mono); font-size: 12px; color: var(--ink-2); } + /* =========================================================================== Search Panel (Cmd-Shift-F / bool:open-search) Ported from design/overlay-search.html — Direction A theme. @@ -1033,6 +1187,11 @@ font-size: 13px; color: var(--ink-1); transition: background 0.12s, color 0.12s; + width: 100%; + text-align: left; + background: none; + border: none; + font-family: inherit; } .mem:hover { @@ -1040,6 +1199,10 @@ color: var(--ink-0); } +.mem.away { + opacity: 0.5; +} + .mem .av { width: 22px; height: 22px; @@ -1076,6 +1239,52 @@ flex: none; } +/* ---- User popover (click a nick anywhere) ---- */ +.user-pop { + position: fixed; + z-index: 950; + min-width: 160px; + background: var(--bg-2); + border: 1px solid var(--line-2); + border-radius: var(--radius); + box-shadow: 0 12px 40px -8px var(--shadow-overlay, rgba(0,0,0,.6)); + padding: 4px; + display: flex; + flex-direction: column; +} +.user-pop-nick { + font-family: var(--mono); + font-size: 11px; + color: var(--ink-2); + padding: 6px 10px 4px; + border-bottom: 1px solid var(--line); + margin-bottom: 4px; +} +.user-pop-item { + text-align: left; + font-size: 13px; + color: var(--ink-0); + background: none; + border: none; + border-radius: var(--radius-sm); + padding: 6px 10px; + min-height: 28px; + cursor: pointer; +} +.user-pop-item:hover, +.user-pop-item:focus-visible { + background: var(--bg-4); +} + +.msg-nick-btn { + background: none; + border: none; + padding: 0; + font: inherit; + color: inherit; + cursor: pointer; +} + /* =========================================================================== Appearance Menu =========================================================================== */ @@ -1193,6 +1402,16 @@ white-space: nowrap; } +/* Negotiated IRCv3 caps, channel view only — quiet statline (Task 16) */ +.stat-caps { + font-family: var(--mono); + font-size: 11px; + color: var(--ink-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + /* ---- Focus rings — shared :focus-visible indicator ---- */ .net-btn:focus-visible, .reply-btn:focus-visible, @@ -1248,6 +1467,20 @@ border-color: var(--line-2); } +/* ---- Reaction "+" trigger (shown on .msg/.cont hover or focus, same as reply-btn) ---- */ +.rx-add { + opacity: 0; + transition: opacity 0.12s, background 0.12s; +} + +.msg:hover .rx-add, +.cont:hover .rx-add, +.msg:focus-within .rx-add, +.cont:focus-within .rx-add, +.rx-add:focus-visible { + opacity: 1; +} + /* ---- Reply chip in Composer ---- */ .reply-chip { display: flex; @@ -1317,6 +1550,20 @@ to { opacity: 1; } } +/* Channel-entry orientation note (audit F7): rendered as the Virtuoso header + so it sits above the first message without faking a data row. */ +.msgs-start { + display: flex; + align-items: center; + gap: 12px; + padding: 18px 16px 10px; + font-family: var(--mono); + font-size: 11px; + color: var(--ink-3); + text-align: center; +} +.msgs-start-line { flex: 1; height: 1px; background: var(--line); } + /* ---- Message rows ---- */ .msg, .cont { @@ -1401,6 +1648,101 @@ a.link:hover { border-bottom-color: var(--blue); } +/* ---- Day dividers (Task 15; ported from design/direction-a .daybar) ---- */ +.daybar { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px 8px; + color: var(--ink-3); +} +.daybar::before, +.daybar::after { + content: ''; + height: 1px; + background: var(--line); + flex: 1; +} +.daybar span { + font-family: var(--mono); + font-size: 10px; + letter-spacing: 1px; + text-transform: uppercase; +} + +/* ---- System / join-part-quit lines (Task 15; ported from design/direction-a .sys) ---- */ +.sys { + display: flex; + align-items: center; + gap: 8px; + padding: 1px 16px 1px 8px; + font-size: 11px; + color: var(--ink-3); + font-family: var(--mono); +} +.sys > .gutter { + width: 52px; + flex: none; + text-align: right; + font-size: 10px; + color: var(--ink-3); + user-select: none; +} + +/* ---- Inline mention chip + self-mention row tint (Task 15) ---- */ +.mention-inline { + color: var(--amber); + background: var(--amber-dim); + border-radius: 3px; + padding: 0 3px; + font-weight: 500; +} +.msg.self-mention, +.cont.self-mention { + background: color-mix(in srgb, var(--amber) 14%, transparent); + box-shadow: inset 2px 0 0 var(--amber); +} + +/* ---- Code blocks + inline code spans (Task 15; ported from design/direction-a .code/.code-h) ---- */ +.code { + margin: 5px 0; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--bg-0); + overflow: hidden; + font-family: var(--mono); + font-size: 12px; +} +.code-h { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 10px; + border-bottom: 1px solid var(--line); + background: var(--bg-1); +} +.code-h .lang { + font-size: 10px; + color: var(--green); + letter-spacing: 0.5px; + text-transform: uppercase; +} +.code pre { + padding: 9px 12px; + overflow-x: auto; + line-height: 1.55; + color: var(--ink-1); + margin: 0; +} +.code-inline { + font-family: var(--mono); + font-size: 12px; + color: var(--m-magenta); + background: var(--bg-3); + padding: 1px 4px; + border-radius: 3px; +} + /* =========================================================================== Composer =========================================================================== */ @@ -1436,6 +1778,22 @@ a.link:hover { line-height: 1.4; } +.comp-tools { + display: flex; + gap: 2px; +} +.comp-tools .ct { + width: 28px; + height: 28px; + border-radius: var(--radius-sm); + display: grid; + place-items: center; +} +.comp-tools .ct:hover { + color: var(--ink-0); + background: var(--bg-4); +} + .comp-foot { display: flex; align-items: center; @@ -1567,6 +1925,108 @@ a.link:hover { color: var(--ink-3); } +/* =========================================================================== + ChannelBrowser — live /LIST results (audit F5). Hosted inside a , + so this only styles the body content, not scrim/panel chrome. + =========================================================================== */ + +.chb-search { + display: flex; + align-items: center; + gap: 10px; + background: var(--bg-3); + border: 1px solid var(--line-2); + border-radius: var(--radius-sm); + padding: 9px 12px; + margin-bottom: 12px; +} + +.chb-search-mark { + font-family: var(--mono); + color: var(--green); + font-weight: 700; +} + +.chb-search-input { + flex: 1; + background: none; + border: none; + outline: none; + color: var(--ink-0); + font-family: var(--sans); + font-size: 14px; +} + +.chb-search-input::placeholder { + color: var(--ink-3); +} + +.chb-status { + margin: 0 0 12px; + font-family: var(--mono); + font-size: 12px; + color: var(--ink-2); +} + +.chb-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.chb-row { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + text-align: left; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--bg-3); + color: inherit; + cursor: pointer; + min-height: 24px; + transition: background 0.12s, border-color 0.12s; +} + +.chb-row:hover, +.chb-row:focus-visible { + background: var(--bg-4); + border-color: var(--green-dim); + outline: none; +} + +.chb-row-name { + flex: none; + font-family: var(--mono); + font-size: 13px; + font-weight: 600; + color: var(--ink-0); +} + +.chb-row-users { + flex: none; + font-family: var(--mono); + font-size: 11px; + color: var(--green); + background: var(--bg-4); + border: 1px solid var(--line); + border-radius: 4px; + padding: 2px 7px; +} + +.chb-row-topic { + flex: 1; + min-width: 0; + font-family: var(--sans); + font-size: 12px; + color: var(--ink-2); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* =========================================================================== Typing indicator — the keyframe was referenced by TypingLine but never defined, so the blinking dot was dead. Define it here. diff --git a/packages/client/src/styles/base.css b/packages/client/src/styles/base.css index 5cb5406..058af6b 100644 --- a/packages/client/src/styles/base.css +++ b/packages/client/src/styles/base.css @@ -64,3 +64,22 @@ body { grid-template-columns: 56px 232px 1fr; } } + +/* Narrow / mobile: sidebar + rail collapse into a drawer overlay. */ +@media (max-width: 760px) { + .app { grid-template-columns: 1fr; } + .app .rail, + .app .side { + position: fixed; top: 0; bottom: 0; left: 0; z-index: 900; + transform: translateX(-100%); + transition: transform 0.2s cubic-bezier(.4,.9,.3,1.2); + } + .app .rail { width: 56px; } + .app .side { left: 56px; width: 232px; } + .app.drawer-open .rail, + .app.drawer-open .side { transform: none; box-shadow: 0 0 40px -4px var(--shadow-overlay, rgba(0,0,0,.6)); } + .app.drawer-open::after { + content: ""; position: fixed; inset: 0; z-index: 850; background: var(--shadow-overlay, rgba(0,0,0,.45)); + } + .drawer-toggle { display: inline-flex; } +} diff --git a/packages/client/src/ws-client.test.ts b/packages/client/src/ws-client.test.ts index 0ad42e7..22b75e2 100644 --- a/packages/client/src/ws-client.test.ts +++ b/packages/client/src/ws-client.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { createWsClient, type WebSocketLike } from './ws-client.js' function fakeSocket() { @@ -78,3 +78,89 @@ describe('ws-client', () => { expect(closeCb).toHaveBeenCalledTimes(1) }) }) + +function fakeSocketFactory() { + const sockets: Array void)[]>; sent: string[]; fire: (t: string, ev?: unknown) => void }> = [] + const factory = vi.fn((_url: string) => { + const listeners: Record void)[]> = { open: [], message: [], close: [] } + const sock = { + listeners, + sent: [] as string[], + send: (d: string) => sock.sent.push(d), + close: () => sock.fire('close'), + addEventListener: (t: 'open' | 'message' | 'close', cb: (ev: unknown) => void) => listeners[t]!.push(cb), + fire: (t: string, ev?: unknown) => listeners[t]!.forEach((cb) => cb(ev)), + } + sockets.push(sock) + return sock + }) + return { factory, sockets } +} + +describe('createWsClient — auto-reconnect (audit B2)', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('recreates the socket after close, with backoff', () => { + const { factory, sockets } = fakeSocketFactory() + createWsClient({ url: '/ws', socketFactory: factory }) + expect(factory).toHaveBeenCalledTimes(1) + sockets[0]!.fire('open') + sockets[0]!.fire('close') + // first retry after ~baseMs (1000ms default + jitter <250ms) + vi.advanceTimersByTime(1300) + expect(factory).toHaveBeenCalledTimes(2) + // second drop → longer wait (2000ms + jitter) + sockets[1]!.fire('close') + vi.advanceTimersByTime(1300) + expect(factory).toHaveBeenCalledTimes(2) // not yet + vi.advanceTimersByTime(1200) + expect(factory).toHaveBeenCalledTimes(3) + }) + + it('fires onOpen on every reconnect and resets backoff', () => { + const { factory, sockets } = fakeSocketFactory() + const client = createWsClient({ url: '/ws', socketFactory: factory }) + const opens = vi.fn() + client.onOpen(opens) + sockets[0]!.fire('open') + sockets[0]!.fire('close') + vi.advanceTimersByTime(1300) + sockets[1]!.fire('open') + expect(opens).toHaveBeenCalledTimes(2) + // after a successful open, the next drop retries at base delay again + sockets[1]!.fire('close') + vi.advanceTimersByTime(1300) + expect(factory).toHaveBeenCalledTimes(3) + }) + + it('flushes queued sends on the NEW socket after reconnect', () => { + const { factory, sockets } = fakeSocketFactory() + const client = createWsClient({ url: '/ws', socketFactory: factory }) + sockets[0]!.fire('open') + sockets[0]!.fire('close') + client.send({ type: 'ping', t: 1 } as any) // queued while down + vi.advanceTimersByTime(1300) + sockets[1]!.fire('open') + expect(sockets[1]!.sent).toContainEqual(JSON.stringify({ type: 'ping', t: 1 })) + }) + + it('close() stops reconnection for good', () => { + const { factory, sockets } = fakeSocketFactory() + const client = createWsClient({ url: '/ws', socketFactory: factory }) + sockets[0]!.fire('open') + client.close() + vi.advanceTimersByTime(60_000) + expect(factory).toHaveBeenCalledTimes(1) + }) + + it('caps backoff at maxMs', () => { + const { factory, sockets } = fakeSocketFactory() + createWsClient({ url: '/ws', socketFactory: factory, reconnect: { baseMs: 1000, maxMs: 4000 } }) + for (let i = 0; i < 6; i++) { + sockets[i]!.fire('close') + vi.advanceTimersByTime(4300) // maxMs + jitter always suffices + expect(factory).toHaveBeenCalledTimes(i + 2) + } + }) +}) diff --git a/packages/client/src/ws-client.ts b/packages/client/src/ws-client.ts index b521dbb..2d4c50a 100644 --- a/packages/client/src/ws-client.ts +++ b/packages/client/src/ws-client.ts @@ -17,44 +17,66 @@ export interface WsClient { export function createWsClient(opts: { url: string socketFactory?: (url: string) => WebSocketLike + reconnect?: { baseMs?: number; maxMs?: number } }): WsClient { const factory = opts.socketFactory ?? ((u: string) => new WebSocket(u) as unknown as WebSocketLike) - const sock = factory(opts.url) + const baseMs = opts.reconnect?.baseMs ?? 1000 + const maxMs = opts.reconnect?.maxMs ?? 30_000 + const handlers: ((msg: ServerMessage) => void)[] = [] const openHandlers: (() => void)[] = [] const closeHandlers: (() => void)[] = [] - let isOpen = false const queue: string[] = [] + let sock: WebSocketLike | null = null + let isOpen = false + let closedByUser = false + let attempt = 0 + let retryTimer: ReturnType | null = null + + function connect(): void { + retryTimer = null + const s = factory(opts.url) + sock = s - sock.addEventListener('open', () => { - isOpen = true - for (const json of queue) { - sock.send(json) - } - queue.length = 0 - openHandlers.forEach((h) => h()) - }) + s.addEventListener('open', () => { + if (sock !== s) return // a stale socket from a superseded attempt + isOpen = true + attempt = 0 + for (const json of queue) s.send(json) + queue.length = 0 + openHandlers.forEach((h) => h()) + }) - sock.addEventListener('close', () => { - isOpen = false - closeHandlers.forEach((h) => h()) - }) + s.addEventListener('close', () => { + if (sock !== s) return + isOpen = false + closeHandlers.forEach((h) => h()) + if (closedByUser) return + // Exponential backoff with a little jitter so a fleet of tabs doesn't stampede. + const delay = Math.min(baseMs * 2 ** attempt, maxMs) + Math.random() * 250 + attempt += 1 + retryTimer = setTimeout(connect, delay) + }) - sock.addEventListener('message', (ev) => { - const data = (ev as { data: string }).data - let msg: ServerMessage - try { - msg = parseServerMessage(data) - } catch { - return - } - handlers.forEach((h) => h(msg)) - }) + s.addEventListener('message', (ev) => { + if (sock !== s) return + const data = (ev as { data: string }).data + let msg: ServerMessage + try { + msg = parseServerMessage(data) + } catch { + return + } + handlers.forEach((h) => h(msg)) + }) + } + + connect() return { send: (msg) => { const json = JSON.stringify(msg) - if (isOpen) { + if (isOpen && sock) { sock.send(json) } else { queue.push(json) @@ -63,6 +85,10 @@ export function createWsClient(opts: { onMessage: (cb) => handlers.push(cb), onOpen: (cb) => openHandlers.push(cb), onClose: (cb) => closeHandlers.push(cb), - close: () => sock.close(), + close: () => { + closedByUser = true + if (retryTimer) clearTimeout(retryTimer) + sock?.close() + }, } } diff --git a/packages/server/src/admin-security.test.ts b/packages/server/src/admin-security.test.ts index 52676a0..4f8b3e1 100644 --- a/packages/server/src/admin-security.test.ts +++ b/packages/server/src/admin-security.test.ts @@ -10,6 +10,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest' import type { FastifyInstance } from 'fastify' import { buildApp } from './app.js' import { getDb } from './db.js' +import { signUpUser } from '../test/signup.js' let app: FastifyInstance @@ -27,11 +28,7 @@ const uniq = () => `s${Math.floor(performance.now() * 1000) % 1_000_000_000}` async function signUp( name: string, ): Promise<{ cookie: string; userId: string }> { - const res = await app.inject({ - method: 'POST', - url: '/api/auth/sign-up/email', - payload: { email: `${name}@example.test`, password: 'password1234', name, username: name }, - }) + const res = await signUpUser(app, name) expect(res.statusCode).toBeLessThan(400) const c = res.headers['set-cookie'] const cookie = Array.isArray(c) ? c.join('; ') : String(c) @@ -55,6 +52,10 @@ describe('admin security', () => { }) it('returns 403 for a normal (non-admin) user', async () => { + // The very first account in a fresh DB is auto-promoted to admin by the + // sign-up hook, so seed (and discard) that admin first; the user under + // test is then a genuine normal (non-admin) account. + await signUp(uniq()) const name = uniq() const { cookie } = await signUp(name) const res = await app.inject({ diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index e2348c9..0081bc3 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -9,6 +9,7 @@ import { registerStatic } from './static.js' import { registerAuth } from './auth-routes.js' import { registerPush } from './push-routes.js' import { registerMedia } from './media-routes.js' +import { registerSetup } from './setup-routes.js' const here = dirname(fileURLToPath(import.meta.url)) const clientDist = join(here, '../../client/dist') @@ -35,6 +36,10 @@ export function buildApp(): FastifyInstance { await registerMedia(instance) }) + app.register(async (instance) => { + await registerSetup(instance) + }) + if (existsSync(join(clientDist, 'index.html'))) { app.register(fstatic, { root: clientDist }) app.register(async (instance) => { diff --git a/packages/server/src/auth-invite-enforcement.test.ts b/packages/server/src/auth-invite-enforcement.test.ts new file mode 100644 index 0000000..86ea8e1 --- /dev/null +++ b/packages/server/src/auth-invite-enforcement.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import type { FastifyInstance } from 'fastify' +import { buildApp } from './app.js' +import { getDb } from './db.js' +import { issueInvite } from './invites.js' + +let app: FastifyInstance + +beforeAll(async () => { + app = buildApp() + await app.ready() +}) +afterAll(async () => { + await app.close() +}) + +const uniq = () => `u${Math.floor(performance.now() * 1000) % 1_000_000}` + +async function signUp(payload: Record) { + return app.inject({ + method: 'POST', + url: '/api/auth/sign-up/email', + payload, + }) +} + +function roleOf(username: string): string | null { + const row = getDb() + .prepare('SELECT role FROM user WHERE username = ?') + .get(username) as { role: string | null } | undefined + return row?.role ?? null +} + +// These tests share one file-scoped DB (0 users at the start). They run top-to-bottom. +describe('server-side invite-gated registration enforcement', () => { + it('the FIRST created user becomes admin (no invite required)', async () => { + const name = uniq() + const res = await signUp({ + email: `${name}@example.test`, + password: 'password1234', + name, + username: name, + }) + expect(res.statusCode).toBeLessThan(400) + expect(roleOf(name)).toBe('admin') + + const count = ( + getDb().prepare('SELECT COUNT(*) AS c FROM user').get() as { c: number } + ).c + expect(count).toBe(1) + }) + + it('a SECOND registration with NO invite is rejected (403) via the direct better-auth endpoint', async () => { + const name = uniq() + const res = await signUp({ + email: `${name}@example.test`, + password: 'password1234', + name, + username: name, + }) + expect(res.statusCode).toBe(403) + // and no row was created + expect(roleOf(name)).toBeNull() + }) + + it('a registration with an INVALID invite code is rejected (403)', async () => { + const name = uniq() + const res = await signUp({ + email: `${name}@example.test`, + password: 'password1234', + name, + username: name, + inviteCode: 'NOPE-NOPE-NOPE', + }) + expect(res.statusCode).toBe(403) + expect(roleOf(name)).toBeNull() + }) + + it('a registration with a VALID invite succeeds and marks the invite used (single-use)', async () => { + // Admin (user #1) issues an invite. + const adminId = ( + getDb().prepare("SELECT id FROM user WHERE role = 'admin' LIMIT 1").get() as { id: string } + ).id + const { code } = issueInvite(adminId) + + const name = uniq() + const first = await signUp({ + email: `${name}@example.test`, + password: 'password1234', + name, + username: name, + inviteCode: code, + }) + expect(first.statusCode).toBeLessThan(400) + expect(roleOf(name)).toBe('user') // NOT admin — only the first user is admin + + // The invite is now consumed; reusing it must be rejected. + const name2 = uniq() + const second = await signUp({ + email: `${name2}@example.test`, + password: 'password1234', + name: name2, + username: name2, + inviteCode: code, + }) + expect(second.statusCode).toBe(403) + expect(roleOf(name2)).toBeNull() + }) +}) diff --git a/packages/server/src/auth-routes.test.ts b/packages/server/src/auth-routes.test.ts index 86431c2..502cfd6 100644 --- a/packages/server/src/auth-routes.test.ts +++ b/packages/server/src/auth-routes.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest' import type { FastifyInstance } from 'fastify' import { buildApp } from './app.js' +import { signUpUser } from '../test/signup.js' let app: FastifyInstance @@ -22,11 +23,7 @@ describe('auth routes', () => { it('signs up, then /api/me returns the user via the session cookie', async () => { const name = uniq() - const signup = await app.inject({ - method: 'POST', - url: '/api/auth/sign-up/email', - payload: { email: `${name}@example.test`, password: 'password1234', name, username: name }, - }) + const signup = await signUpUser(app, name) expect(signup.statusCode).toBeLessThan(400) const cookie = signup.headers['set-cookie'] expect(cookie).toBeTruthy() @@ -50,11 +47,7 @@ describe('auth routes', () => { it('sign-out round-trip: session is invalidated afterwards, and sign-out carries multiple Set-Cookie headers', async () => { // Step 1 – sign up and obtain a session cookie const name = uniq() - const signup = await app.inject({ - method: 'POST', - url: '/api/auth/sign-up/email', - payload: { email: `${name}@example.test`, password: 'password1234', name, username: name }, - }) + const signup = await signUpUser(app, name) expect(signup.statusCode).toBeLessThan(400) const signupCookies = signup.headers['set-cookie'] expect(signupCookies).toBeTruthy() diff --git a/packages/server/src/auth.ts b/packages/server/src/auth.ts index 55edc58..cc322f7 100644 --- a/packages/server/src/auth.ts +++ b/packages/server/src/auth.ts @@ -1,6 +1,9 @@ import { betterAuth } from 'better-auth' import { username, admin } from 'better-auth/plugins' +import { APIError } from 'better-auth/api' import { getDb } from './db.js' +import { userCount } from './bootstrap-admin.js' +import { validateInvite, consumeInvite } from './invites.js' const adminUserIds = (process.env.BOOL_ADMIN_IDS ?? '') .split(',') @@ -12,9 +15,56 @@ const trustedOrigins = (process.env.BOOL_TRUSTED_ORIGINS ?? 'http://localhost:51 .map((s) => s.trim()) .filter(Boolean) +/** Pull the invite code out of the sign-up request body, if present. */ +function inviteCodeFromContext(ctx: unknown): string | null { + const body = (ctx as { body?: Record } | null | undefined)?.body + const raw = body?.inviteCode + return typeof raw === 'string' && raw.trim() ? raw.trim() : null +} + export const auth = betterAuth({ database: getDb(), emailAndPassword: { enabled: true }, trustedOrigins, + databaseHooks: { + user: { + create: { + // SERVER-SIDE enforcement of the invite gate + first-user→admin bootstrap. + // This fires for EVERY user creation, including direct POSTs to + // `/api/auth/sign-up/email`, so it cannot be bypassed from the client. + before: async (user, ctx) => { + // (a) The very first account bootstraps the admin. Re-checked here at + // creation time against the real user table (not the UI, not a probe). + if (userCount() === 0) { + return { data: { ...user, role: 'admin' } } + } + // The invite gate only governs PUBLIC self-registration. Users created + // by an authenticated admin (POST /api/auth/admin/create-user) are + // already authorized and must not require an invite. + const path = (ctx as { path?: string } | null | undefined)?.path + if (path !== '/sign-up/email') { + return { data: user } + } + // (b) Every subsequent public registration MUST present a valid, + // unused invite — enforced here so it cannot be bypassed by calling + // /api/auth/sign-up/email directly. + const code = inviteCodeFromContext(ctx) + if (!code || !validateInvite(code)) { + throw new APIError('FORBIDDEN', { + message: 'A valid invite code is required to register.', + }) + } + return { data: user } + }, + // Mark the invite used atomically once the user row is committed. The + // before-hook already guaranteed the code is valid+unused; consumeInvite + // is a single-row conditional UPDATE so it is safe under races. + after: async (user, ctx) => { + const code = inviteCodeFromContext(ctx) + if (code) consumeInvite(code, (user as { id: string }).id) + }, + }, + }, + }, plugins: [username(), admin({ defaultRole: 'user', adminRoles: ['admin'], adminUserIds })], }) diff --git a/packages/server/src/bootstrap-admin.test.ts b/packages/server/src/bootstrap-admin.test.ts index 37c2a0f..fe7fffa 100644 --- a/packages/server/src/bootstrap-admin.test.ts +++ b/packages/server/src/bootstrap-admin.test.ts @@ -1,5 +1,16 @@ import { describe, it, expect } from 'vitest' -import { isAdmin } from './bootstrap-admin.js' +import { isAdmin, firstAdminHint } from './bootstrap-admin.js' + +describe('firstAdminHint', () => { + it('tells operators to open the URL and let the setup wizard create the admin (audit polish)', () => { + const hint = firstAdminHint() + expect(hint).toMatch(/open http:\/\/localhost:/i) + expect(hint).toMatch(/setup wizard will create the admin account/i) + // No more hand-rolled SQL instructions. + expect(hint).not.toMatch(/UPDATE user SET role/i) + expect(hint).not.toMatch(/BOOL_ADMIN_IDS/i) + }) +}) describe('isAdmin', () => { it('is true for role admin', () => { diff --git a/packages/server/src/bootstrap-admin.ts b/packages/server/src/bootstrap-admin.ts index 58521a4..8eb8070 100644 --- a/packages/server/src/bootstrap-admin.ts +++ b/packages/server/src/bootstrap-admin.ts @@ -1,5 +1,22 @@ +import { getDb } from './db.js' + type SessionLike = { user: { id: string; role?: string | null } } +/** + * Number of rows in Better Auth's `user` table. Guarded so it still returns 0 + * before migrations have run against a fresh DB. This is the single source of + * truth for "is this the very first account?" — used both by the sign-up + * database hook (first user → admin) and by the server-gated `/api/setup` route. + */ +export function userCount(): number { + try { + const row = getDb().prepare('SELECT COUNT(*) AS cnt FROM user').get() as { cnt: number } + return row.cnt + } catch { + return 0 + } +} + function adminIds(): string[] { return (process.env.BOOL_ADMIN_IDS ?? '') .split(',') @@ -14,10 +31,5 @@ export function isAdmin(session: SessionLike | null): boolean { } export function firstAdminHint(): string { - return [ - 'No admin configured yet.', - 'Register the first user, then either:', - ' - set BOOL_ADMIN_IDS= and restart, or', - " - run: UPDATE user SET role='admin' WHERE username=''; in bool.db", - ].join('\n') + return 'No admin yet — open http://localhost: and the setup wizard will create the admin account.' } diff --git a/packages/server/src/channel-state.test.ts b/packages/server/src/channel-state.test.ts new file mode 100644 index 0000000..8feb0b1 --- /dev/null +++ b/packages/server/src/channel-state.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import { getDb } from './db.js' +import { ensureAppSchema } from './schema.js' +import { createNetwork } from './networks.js' +import { upsertJoined, markParted, listJoined } from './channel-state.js' + +beforeAll(() => ensureAppSchema(getDb())) + +// channels.network_id has a FK to networks(id) (enforced: db.ts sets `foreign_keys = ON`), +// and this file's DB is shared across all `it` blocks (per-file, not per-test — see +// test/setup.ts). So each test creates its own real network(s) via createNetwork, +// both to satisfy the FK and to keep tests isolated from each other's channel rows. +describe('channel-state', () => { + it('upsert + list round-trips and is idempotent', () => { + const net = createNetwork('csUser1', { name: 'N1', host: 'h', port: 6697, tls: true, nick: 'n' }) + upsertJoined(net.id, '#dev') + upsertJoined(net.id, '#dev') + upsertJoined(net.id, '##ops') + expect(listJoined(net.id).sort()).toEqual(['##ops', '#dev'].sort()) + }) + it('markParted removes from the joined list but keeps the row', () => { + const net = createNetwork('csUser2', { name: 'N2', host: 'h', port: 6697, tls: true, nick: 'n' }) + upsertJoined(net.id, '#dev') + markParted(net.id, '#dev') + expect(listJoined(net.id)).toEqual([]) + upsertJoined(net.id, '#dev') // rejoin flips it back + expect(listJoined(net.id)).toEqual(['#dev']) + }) + it('scopes by network', () => { + const net1 = createNetwork('csUser3', { name: 'N3', host: 'h', port: 6697, tls: true, nick: 'n' }) + const net2 = createNetwork('csUser3', { name: 'N4', host: 'h', port: 6697, tls: true, nick: 'n' }) + upsertJoined(net1.id, '#dev') + upsertJoined(net2.id, '#dev') + markParted(net1.id, '#dev') + expect(listJoined(net2.id)).toEqual(['#dev']) + }) +}) diff --git a/packages/server/src/channel-state.ts b/packages/server/src/channel-state.ts new file mode 100644 index 0000000..5552976 --- /dev/null +++ b/packages/server/src/channel-state.ts @@ -0,0 +1,21 @@ +import { getDb } from './db.js' + +export function upsertJoined(networkId: number, name: string): void { + getDb() + .prepare( + `INSERT INTO channels (network_id, name, joined) VALUES (?, ?, 1) + ON CONFLICT(network_id, name) DO UPDATE SET joined = 1`, + ) + .run(networkId, name) +} + +export function markParted(networkId: number, name: string): void { + getDb().prepare(`UPDATE channels SET joined = 0 WHERE network_id = ? AND name = ?`).run(networkId, name) +} + +export function listJoined(networkId: number): string[] { + const rows = getDb() + .prepare(`SELECT name FROM channels WHERE network_id = ? AND joined = 1 ORDER BY name`) + .all(networkId) as { name: string }[] + return rows.map((r) => r.name) +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 85d049a..caa8ea5 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -3,6 +3,7 @@ import { firstAdminHint } from './bootstrap-admin.js' import { getDb } from './db.js' import { ensureAppSchema } from './schema.js' import { runRetention } from './retention.js' +import { ircManager } from './irc/singleton.js' export function startRetention(intervalMs = 6 * 60 * 60 * 1000): NodeJS.Timeout { const tick = () => { @@ -39,6 +40,7 @@ app console.log(firstAdminHint()) } startRetention() + ircManager.resumeAll() } catch { // eslint-disable-next-line no-console console.log( diff --git a/packages/server/src/invites.test.ts b/packages/server/src/invites.test.ts new file mode 100644 index 0000000..f28c8ac --- /dev/null +++ b/packages/server/src/invites.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import { getDb } from './db.js' +import { ensureAppSchema } from './schema.js' +import { issueInvite, validateInvite, consumeInvite } from './invites.js' + +beforeAll(() => ensureAppSchema(getDb())) + +describe('invites', () => { + it('issues a non-empty code that then validates', () => { + const { code } = issueInvite('admin-1') + expect(code.length).toBeGreaterThanOrEqual(8) + expect(validateInvite(code)).toBe(true) + }) + + it('does not validate an unknown code', () => { + expect(validateInvite('does-not-exist')).toBe(false) + }) + + it('consumes a code exactly once (single-use)', () => { + const { code } = issueInvite('admin-1') + expect(consumeInvite(code, 'user-9')).toBe(true) + // already used → no longer valid, cannot be consumed again + expect(validateInvite(code)).toBe(false) + expect(consumeInvite(code, 'user-10')).toBe(false) + }) + + it('consuming an unknown code returns false', () => { + expect(consumeInvite('nope', 'user-1')).toBe(false) + }) +}) diff --git a/packages/server/src/invites.ts b/packages/server/src/invites.ts new file mode 100644 index 0000000..0d1746e --- /dev/null +++ b/packages/server/src/invites.ts @@ -0,0 +1,36 @@ +import { randomBytes } from 'node:crypto' +import { getDb } from './db.js' + +// The `invites` table is created idempotently by `ensureAppSchema` in schema.ts — +// there is deliberately no separate schema helper here. + +/** Unambiguous base32-ish alphabet (no 0/O/1/I) for human-typeable codes. */ +function makeCode(): string { + const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + const bytes = randomBytes(12) + let out = '' + for (const b of bytes) out += alphabet[b % alphabet.length] + return `${out.slice(0, 4)}-${out.slice(4, 8)}-${out.slice(8, 12)}` +} + +export function issueInvite(userId: string): { code: string } { + const code = makeCode() + getDb() + .prepare('INSERT INTO invites (code, created_by, created_at) VALUES (?, ?, ?)') + .run(code, userId, Date.now()) + return { code } +} + +export function validateInvite(code: string): boolean { + const row = getDb() + .prepare('SELECT used_by FROM invites WHERE code = ?') + .get(code) as { used_by: string | null } | undefined + return !!row && row.used_by === null +} + +export function consumeInvite(code: string, usedBy: string): boolean { + const result = getDb() + .prepare('UPDATE invites SET used_by = ?, used_at = ? WHERE code = ? AND used_by IS NULL') + .run(usedBy, Date.now(), code) + return result.changes === 1 +} diff --git a/packages/server/src/irc/connection.test.ts b/packages/server/src/irc/connection.test.ts index 665e9f8..d876ac4 100644 --- a/packages/server/src/irc/connection.test.ts +++ b/packages/server/src/irc/connection.test.ts @@ -150,6 +150,24 @@ describe('IrcConnection', () => { expect(msg).toMatchObject({ target: '*', from: '*', kind: 'notice', text: '#chan 42 a topic' }) }) + // audit F4: post-registration numerics (001-005 welcome, 372/375/376 MOTD, + // 251-255 LUSERS) never reached the `*` status buffer in production even + // though formatNumeric handles them. Root cause: irc-framework requests + // message-tags + server-time by default and virtually every modern server + // grants them, so the 'raw' event's `line` carries a leading IRCv3 + // "@key=val;... " tag blob that formatNumeric didn't strip — verified live + // against a synthetic ircd with CAP negotiation, which delivered exactly + // this shape on client.on('raw', ...). This is the exact event shape observed. + it('routes a tagged welcome/MOTD numeric into the * status buffer (audit F4)', () => { + const { client, out } = makeConn() + client.emit('raw', { + line: '@time=2026-07-18T12:00:00.000Z;msgid=abc123 :server 001 bool :Welcome to the Network bool\r\n', + from_server: true, + }) + const msg = out.find((m) => m.type === 'chat:msg' && (m as any).target === '*') as any + expect(msg).toMatchObject({ target: '*', from: '*', kind: 'notice', text: 'Welcome to the Network bool' }) + }) + it('ignores client-originated raw lines', () => { const { client, out } = makeConn() client.emit('raw', { line: ':server 322 bool #chan 42 :a topic', from_server: false }) @@ -184,3 +202,142 @@ describe('IrcConnection', () => { expect(client.say).toHaveBeenCalledWith('#x', '\x01ACTION waves\x01') }) }) + +describe('IrcConnection — DM target normalization (audit B1)', () => { + it('keys an incoming PM by the sender, not by our own nick', () => { + const { client, out } = makeConn() + // boolprobe717 sends us a PM: raw PRIVMSG target is OUR nick. + client.emit('message', { type: 'privmsg', nick: 'ada', target: 'bool', message: 'psst', tags: {} }) + const msg = out.find((m) => m.type === 'chat:msg') as any + expect(msg.target).toBe('ada') // thread key = counterparty + expect(msg.from).toBe('ada') + expect(msg.self).toBe(false) + }) + + it('keys our own echoed PM by the recipient (unchanged)', () => { + const { client, out } = makeConn() + // echo-message: we sent a PM to ada; server echoes it back to us. + client.emit('message', { type: 'privmsg', nick: 'bool', target: 'ada', message: 'hi', tags: {} }) + const msg = out.find((m) => m.type === 'chat:msg') as any + expect(msg.target).toBe('ada') + expect(msg.self).toBe(true) + }) + + it('normalizes case-insensitively (IRC nicks are caseless)', () => { + const { client, out } = makeConn() + client.emit('message', { type: 'privmsg', nick: 'ada', target: 'BOOL', message: 'yo', tags: {} }) + const msg = out.find((m) => m.type === 'chat:msg') as any + expect(msg.target).toBe('ada') + }) + + it('leaves channel messages untouched', () => { + const { client, out } = makeConn() + client.emit('message', { type: 'privmsg', nick: 'ada', target: '#x', message: 'hi', tags: {} }) + const msg = out.find((m) => m.type === 'chat:msg') as any + expect(msg.target).toBe('#x') + }) + + it('keys a NickServ notice by the service nick', () => { + const { client, out } = makeConn() + client.emit('message', { type: 'notice', nick: 'NickServ', target: 'bool', message: 'identify pls', tags: {} }) + const msg = out.find((m) => m.type === 'chat:msg') as any + expect(msg.target).toBe('NickServ') + expect(msg.kind).toBe('notice') + }) +}) + +describe('IrcConnection — notice/away/invite/names (audit B6)', () => { + it('notice() sends a NOTICE, not a PRIVMSG', () => { + const { conn, client } = makeConn() + conn.notice('ada', 'heads up') + expect(client.raw).toHaveBeenCalledWith('NOTICE ada :heads up') + expect(client.say).not.toHaveBeenCalled() + }) + it('setAway() sends AWAY with and without a message', () => { + const { conn, client } = makeConn() + conn.setAway('brb lunch') + expect(client.raw).toHaveBeenCalledWith('AWAY :brb lunch') + conn.setAway() + expect(client.raw).toHaveBeenCalledWith('AWAY') + }) + it('invite() sends INVITE nick channel', () => { + const { conn, client } = makeConn() + conn.invite('ada', '#dev') + expect(client.raw).toHaveBeenCalledWith('INVITE ada #dev') + }) + it('requestNames() sends NAMES channel', () => { + const { conn, client } = makeConn() + conn.requestNames('#dev') + expect(client.raw).toHaveBeenCalledWith('NAMES #dev') + }) + it('CRLF is stripped from all raw args (no command injection)', () => { + const { conn, client } = makeConn() + conn.notice('ada\r\nQUIT', 'x\r\ny') + expect(client.raw).toHaveBeenCalledWith('NOTICE adaQUIT :xy') + conn.setAway('a\r\nb') + expect(client.raw).toHaveBeenCalledWith('AWAY :ab') + conn.invite('a\rb', '#c\nd') + expect(client.raw).toHaveBeenCalledWith('INVITE ab #cd') + }) +}) + +describe('IrcConnection — channel list (audit F5)', () => { + it('forwards a channel list batch as chan:list:results with done:false', () => { + const { client, out } = makeConn() + client.emit('channel list', [{ channel: '#a', num_users: 42, topic: 't' }]) + const msg = out.find((m) => m.type === 'chan:list:results') as any + expect(msg).toMatchObject({ + type: 'chan:list:results', + networkId: 1, + channels: [{ name: '#a', users: 42, topic: 't' }], + done: false, + }) + }) + + it('forwards channel list end as an empty done:true message', () => { + const { client, out } = makeConn() + client.emit('channel list', [{ channel: '#a', num_users: 42, topic: 't' }]) + client.emit('channel list end') + const msgs = out.filter((m) => m.type === 'chan:list:results') + expect(msgs).toHaveLength(2) + expect(msgs[1]).toMatchObject({ type: 'chan:list:results', networkId: 1, channels: [], done: true }) + }) + + it('defaults missing channel list fields', () => { + const { client, out } = makeConn() + client.emit('channel list', [{}]) + const msg = out.find((m) => m.type === 'chan:list:results') as any + expect(msg.channels).toEqual([{ name: '', users: 0, topic: '' }]) + }) +}) + +describe('IrcConnection — away-notify presence + caps (Task 16)', () => { + it('forwards an away event as presence:away with away:true', () => { + const { client, out } = makeConn() + client.emit('away', { nick: 'ada' }) + const msg = out.find((m) => m.type === 'presence:away') as any + expect(msg).toMatchObject({ type: 'presence:away', networkId: 1, nick: 'ada', away: true }) + }) + + it('forwards a back event as presence:away with away:false', () => { + const { client, out } = makeConn() + client.emit('back', { nick: 'ada' }) + const msg = out.find((m) => m.type === 'presence:away') as any + expect(msg).toMatchObject({ type: 'presence:away', networkId: 1, nick: 'ada', away: false }) + }) + + it('skips away/back events for our own nick', () => { + const { client, out } = makeConn() + client.emit('away', { nick: 'bool' }) + client.emit('back', { nick: 'bool' }) + expect(out.some((m) => m.type === 'presence:away')).toBe(false) + }) + + it('includes negotiated caps in net:state on registered', () => { + const { client, out } = makeConn() + client.network.cap.enabled = ['away-notify', 'server-time', 'message-tags'] + client.emit('registered', { nick: 'bool' }) + const msg = out.find((m) => m.type === 'net:state' && (m as any).state === 'registered') as any + expect(msg).toMatchObject({ caps: ['away-notify', 'server-time', 'message-tags'] }) + }) +}) diff --git a/packages/server/src/irc/connection.ts b/packages/server/src/irc/connection.ts index 7c78f53..90b546a 100644 --- a/packages/server/src/irc/connection.ts +++ b/packages/server/src/irc/connection.ts @@ -89,7 +89,7 @@ export class IrcConnection { client.on('registered', () => { this.connected = true - this.sink({ type: 'net:state', networkId: nid, name: this.config.name, state: 'registered', nick: client.user.nick }) + this.sink({ type: 'net:state', networkId: nid, name: this.config.name, state: 'registered', nick: client.user.nick, caps: client.network.cap.enabled }) }) client.on('reconnecting', () => { this.connected = false @@ -102,14 +102,22 @@ export class IrcConnection { client.on('message', (e: any) => { if (e.type !== 'privmsg' && e.type !== 'notice' && e.type !== 'action') return + const self = !!e.nick && e.nick === client.user.nick + const isChannel = typeof e.target === 'string' && (e.target.startsWith('#') || e.target.startsWith('&')) + // A PM's raw target is the RECIPIENT. For incoming PMs that's our own + // nick — useless as a thread key. Key PM threads by the counterparty. + const target = !isChannel && !self && typeof e.target === 'string' + && e.target.toLowerCase() === client.user.nick.toLowerCase() + ? (e.nick ?? e.target) + : e.target this.sink({ type: 'chat:msg', networkId: nid, - target: e.target, + target, from: e.nick ?? '', kind: e.type, text: e.message ?? '', - self: !!e.nick && e.nick === client.user.nick, + self, ...(e.tags?.msgid ? { msgid: e.tags.msgid } : {}), ...(typeof e.time === 'number' ? { time: e.time } : {}), ...(e.account ? { account: e.account } : {}), @@ -159,6 +167,14 @@ export class IrcConnection { client.on('nick', (e: any) => { this.sink({ type: 'presence:nick', networkId: nid, oldNick: e.nick, newNick: e.new_nick }) }) + client.on('away', (e: any) => { + if (e.nick && e.nick !== client.user.nick) + this.sink({ type: 'presence:away', networkId: nid, nick: e.nick, away: true }) + }) + client.on('back', (e: any) => { + if (e.nick && e.nick !== client.user.nick) + this.sink({ type: 'presence:away', networkId: nid, nick: e.nick, away: false }) + }) client.on('topic', (e: any) => { this.sink({ type: 'chan:topic', networkId: nid, channel: e.channel, topic: e.topic ?? '' }) }) @@ -171,6 +187,22 @@ export class IrcConnection { }) }) + client.on('channel list', (channels: any[]) => { + this.sink({ + type: 'chan:list:results', + networkId: nid, + channels: (channels ?? []).map((c: any) => ({ + name: String(c.channel ?? ''), + users: Number(c.num_users ?? 0), + topic: String(c.topic ?? ''), + })), + done: false, + }) + }) + client.on('channel list end', () => { + this.sink({ type: 'chan:list:results', networkId: nid, channels: [], done: true }) + }) + client.on('raw', (e: any) => { if (!e?.from_server || typeof e.line !== 'string') return const text = formatNumeric(e.line, client.user.nick) @@ -201,6 +233,18 @@ export class IrcConnection { whois(nick: string): void { this.client?.raw(`WHOIS ${stripCrlf(nick)}`) } + notice(target: string, text: string): void { + this.client?.raw(`NOTICE ${stripCrlf(target)} :${stripCrlf(text)}`) + } + setAway(message?: string): void { + this.client?.raw(message ? `AWAY :${stripCrlf(message)}` : 'AWAY') + } + invite(nick: string, channel: string): void { + this.client?.raw(`INVITE ${stripCrlf(nick)} ${stripCrlf(channel)}`) + } + requestNames(channel: string): void { + this.client?.raw(`NAMES ${stripCrlf(channel)}`) + } listChannels(filter?: string): void { this.client?.raw(`LIST${filter ? ` ${stripCrlf(filter)}` : ''}`) } diff --git a/packages/server/src/irc/manager.test.ts b/packages/server/src/irc/manager.test.ts index f9e0ecc..1b48255 100644 --- a/packages/server/src/irc/manager.test.ts +++ b/packages/server/src/irc/manager.test.ts @@ -1,12 +1,32 @@ import { describe, it, expect, vi, beforeAll } from 'vitest' import { EventEmitter } from 'node:events' +import * as http from 'node:http' +import type { AddressInfo } from 'node:net' import { getDb } from '../db.js' import { ensureAppSchema } from '../schema.js' -import { createNetwork } from '../networks.js' +import { createNetwork, setAutoConnect } from '../networks.js' +import { listJoined } from '../channel-state.js' import { IrcManager } from './manager.js' import type { IrcClientLike } from './connection.js' import type { ServerMessage } from '@bool/shared' +// The link-preview pipeline (fetchPreview -> assertPublicUrl) correctly +// blocks loopback/private addresses (SSRF protection) — but that means a +// local http.createServer test fixture on 127.0.0.1 can never be reached +// through the REAL assertPublicUrl. To exercise the REAL fetch/redirect/ +// meta-extraction path end to end (the part that was actually broken — see +// link-preview.ts pinnedGet), we stub only the SSRF resolution step to +// treat the loopback fixture as "validated", exactly like the guard would +// for a real public host. Everything downstream (the HTTP GET, the pinned +// `lookup` option, HTML parsing, hub publish) runs unmocked. +vi.mock('../ssrf.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + assertPublicUrl: vi.fn(async (rawUrl: string) => ({ url: new URL(rawUrl), ip: '127.0.0.1' })), + } +}) + function fakeClient(): IrcClientLike { const ee = new EventEmitter() as any ee.user = { nick: 'bool' } @@ -86,4 +106,118 @@ describe('IrcManager', () => { expect(mgr.getConnection('userDN', net.id)).toBeUndefined() expect(mgr.isConnected('userDN', net.id)).toBe(false) }) + + it('persists self-joins/parts and rejoins persisted channels on (re)registration', () => { + const clients: any[] = [] + const mgr = new IrcManager(() => { + const c = fakeClient() + clients.push(c) + return c + }) + const net = createNetwork('userResume', { name: 'TestNet', host: 'h', port: 6697, tls: true, nick: 'bool' }) + mgr.connectNetwork('userResume', net.id) + const client = clients[0] + + // Self-join #a is persisted. + client.emit('join', { channel: '#a', nick: 'bool' }) + expect(listJoined(net.id)).toContain('#a') + + // Self-part #a removes it from the persisted set. + client.emit('part', { channel: '#a', nick: 'bool' }) + expect(listJoined(net.id)).not.toContain('#a') + + // Rejoin #a, then simulate a reconnect (a second 'registered' emission) — + // the manager should replay every persisted channel by calling join() again. + client.emit('join', { channel: '#a', nick: 'bool' }) + client.join.mockClear() + client.emit('registered', { nick: 'bool' }) + expect(client.join).toHaveBeenCalledWith('#a') + }) + + it('resumeAll reconnects every network flagged auto_connect', () => { + const clients: any[] = [] + const mgr = new IrcManager(() => { + const c = fakeClient() + clients.push(c) + return c + }) + const net = createNetwork('userAutoConn', { name: 'TestNet', host: 'h', port: 6697, tls: true, nick: 'bool' }) + setAutoConnect('userAutoConn', net.id, true) + + mgr.resumeAll() + + expect(mgr.isConnected('userAutoConn', net.id)).toBe(true) + }) + + // Audit F6: a URL in a chat message produced NO preview client-side + // (`previews: {}`), even though fetchPreview is wired up and publishes + // preview:result on success. Reproduce through the REAL pipeline: a real + // local HTTP fixture, the real fetchPreview -> pinnedGet -> extractMeta + // path, and the real hub.publish — nothing about the fetch/parse/publish + // mechanics mocked (only SSRF resolution of the loopback fixture, per the + // vi.mock('../ssrf.js') above). + // + // The fixture URL deliberately uses the hostname `localhost` rather than + // the literal IP `127.0.0.1`: Node's HTTP client skips DNS resolution + // entirely for a literal IP host, so a literal-IP fixture would never + // exercise pinnedGet's custom `lookup` option — the exact code path that + // was silently broken (it didn't handle Node's Happy-Eyeballs + // `{ all: true }` lookup calling convention, active by default since + // Node 20, for any non-literal hostname). + it('fires a preview:result through the real fetch pipeline for a URL in chat:msg (audit F6)', async () => { + const html = ` + + + + hi + ` + const server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/html' }) + res.end(html) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())) + const port = (server.address() as AddressInfo).port + const fixtureUrl = `http://localhost:${port}/page` + + try { + const clients: any[] = [] + const mgr = new IrcManager(() => { + const c = fakeClient() + clients.push(c) + return c + }) + const net = createNetwork('userPreview', { name: 'TestNet', host: 'h', port: 6697, tls: true, nick: 'bool' }) + + const previewPromise = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('timed out waiting for preview:result')), 5000) + mgr.getHub().subscribe('userPreview', (m) => { + if (m.type === 'preview:result') { + clearTimeout(timer) + resolve(m) + } + }) + }) + + mgr.connectNetwork('userPreview', net.id) + const client = clients[0] + client.emit('message', { + type: 'privmsg', + nick: 'alice', + target: '#chan', + message: `check this out ${fixtureUrl}`, + }) + + const preview = await previewPromise + + expect(preview).toMatchObject({ + type: 'preview:result', + url: fixtureUrl, + ok: true, + title: 'Fixture Title', + image: 'https://example.com/img.png', + }) + } finally { + server.close() + } + }) }) diff --git a/packages/server/src/irc/manager.ts b/packages/server/src/irc/manager.ts index 6bfc98c..6ce0c29 100644 --- a/packages/server/src/irc/manager.ts +++ b/packages/server/src/irc/manager.ts @@ -1,7 +1,8 @@ import type { ServerMessage } from '@bool/shared' import { IrcConnection, type IrcClientFactory } from './connection.js' -import { getNetwork } from '../networks.js' +import { getNetwork, listAutoConnect } from '../networks.js' import { storeMessage } from '../messages.js' +import { upsertJoined, markParted, listJoined } from '../channel-state.js' import { Hub } from './hub.js' import { maybeNotify } from '../notifier.js' import { fetchPreview } from '../link-preview.js' @@ -52,6 +53,14 @@ export class IrcManager { console.error('[IrcManager] persistingSink: storeMessage failed', err) } } + if (msg.type === 'chan:join' && msg.self) upsertJoined(msg.networkId, msg.channel) + if (msg.type === 'chan:part' && msg.nick === conn.currentNick()) markParted(msg.networkId, msg.channel) + if (msg.type === 'net:state' && msg.state === 'registered') { + // Rejoin every persisted channel after (re)registration — this is what + // makes an IRC session resume across server restarts (audit B3). + for (const ch of listJoined(msg.networkId)) conn.join(ch) + } + this.hub.publish(userId, msg) if (msg.type === 'chat:msg') { maybeNotify(userId, msg, { @@ -75,10 +84,13 @@ export class IrcManager { ...(res.image !== undefined ? { image: res.image } : {}), ...(res.siteName !== undefined ? { siteName: res.siteName } : {}), }) + } else { + console.warn('[preview] not ok', res.url, res.reason) } }) - .catch(() => { - // Silently swallow — preview failure is non-fatal + .catch((err) => { + // Preview failure must stay non-fatal — log, never throw. + console.warn('[preview] failed', urlMatch[0], err?.message) }) } } @@ -159,6 +171,30 @@ export class IrcManager { c.whois(nick) return true } + notice(userId: string, networkId: number, target: string, text: string): boolean { + const c = this.conns.get(keyOf(userId, networkId)) + if (!c || !c.isConnected()) return false + c.notice(target, text) + return true + } + setAway(userId: string, networkId: number, message?: string): boolean { + const c = this.conns.get(keyOf(userId, networkId)) + if (!c || !c.isConnected()) return false + c.setAway(message) + return true + } + invite(userId: string, networkId: number, nick: string, channel: string): boolean { + const c = this.conns.get(keyOf(userId, networkId)) + if (!c || !c.isConnected()) return false + c.invite(nick, channel) + return true + } + requestNames(userId: string, networkId: number, channel: string): boolean { + const c = this.conns.get(keyOf(userId, networkId)) + if (!c || !c.isConnected()) return false + c.requestNames(channel) + return true + } listChannels(userId: string, networkId: number, filter?: string): boolean { const c = this.conns.get(keyOf(userId, networkId)) if (!c || !c.isConnected()) return false @@ -178,4 +214,15 @@ export class IrcManager { isConnected(userId: string, networkId: number): boolean { return this.conns.get(keyOf(userId, networkId))?.isConnected() ?? false } + + /** Reconnect every network the operator had running (called once at boot). */ + resumeAll(): void { + for (const { userId, networkId } of listAutoConnect()) { + try { + this.connectNetwork(userId, networkId) + } catch (err) { + console.error(`[IrcManager] resume failed for network ${networkId}`, err) + } + } + } } diff --git a/packages/server/src/irc/numerics.test.ts b/packages/server/src/irc/numerics.test.ts index aac8249..1a1b122 100644 --- a/packages/server/src/irc/numerics.test.ts +++ b/packages/server/src/irc/numerics.test.ts @@ -16,4 +16,25 @@ describe('formatNumeric', () => { }) it('handles a line with no prefix', () => expect(formatNumeric('421 bool FOO :Unknown command', 'bool')).toBe('FOO Unknown command')) + + // audit F4: irc-framework requests message-tags + server-time by default and + // virtually every modern server (Libera, ergo, ...) grants them, so real + // inbound lines — including the post-registration welcome/MOTD/LUSERS burst — + // arrive with a leading "@key=val;... " tag blob. Confirmed live against a + // synthetic ircd with CAP negotiation: irc-framework's raw event delivers + // exactly this shape (e.g. "@time=...;msgid=... :server 001 nick :Welcome..."). + it('strips a leading IRCv3 message-tags blob before parsing the numeric (audit F4)', () => { + expect( + formatNumeric('@time=2026-07-18T12:00:00.000Z;msgid=abc123 :server 001 bool :Welcome to the Network bool', 'bool'), + ).toBe('Welcome to the Network bool') + }) + + it('strips tags for MOTD (375/372/376) and LUSERS (251) numerics', () => { + const tag = '@time=2026-07-18T12:00:00.000Z ' + expect(formatNumeric(`${tag}:server 375 bool :- server Message of the Day -`, 'bool')) + .toBe('- server Message of the Day -') + expect(formatNumeric(`${tag}:server 372 bool :- Welcome!`, 'bool')).toBe('- Welcome!') + expect(formatNumeric(`${tag}:server 376 bool :End of /MOTD command.`, 'bool')).toBe('End of /MOTD command.') + expect(formatNumeric(`${tag}:server 251 bool :There are 1 users`, 'bool')).toBe('There are 1 users') + }) }) diff --git a/packages/server/src/irc/numerics.ts b/packages/server/src/irc/numerics.ts index 1338bc8..1146bcd 100644 --- a/packages/server/src/irc/numerics.ts +++ b/packages/server/src/irc/numerics.ts @@ -7,6 +7,18 @@ const SUPPRESS = new Set(['353', '366', '332', '333']) */ export function formatNumeric(rawLine: string, selfNick: string): string | null { let line = rawLine.trim() + // IRCv3 message-tags: a leading "@key=val;key2=val2 " tag blob before the + // server prefix. irc-framework requests message-tags + server-time by + // default and virtually every modern server (Libera, ergo, ...) supports + // them, so nearly every inbound line — including the post-registration + // numeric burst (001-005, MOTD, LUSERS) — arrives tagged. Strip it first, + // otherwise the numeric regex below never matches and every line is + // silently dropped. + if (line.startsWith('@')) { + const tagSp = line.indexOf(' ') + if (tagSp === -1) return null + line = line.slice(tagSp + 1) + } if (line.startsWith(':')) { const sp = line.indexOf(' ') if (sp === -1) return null diff --git a/packages/server/src/link-preview.test.ts b/packages/server/src/link-preview.test.ts index c715fd9..b9b61f6 100644 --- a/packages/server/src/link-preview.test.ts +++ b/packages/server/src/link-preview.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import * as http from 'node:http' +import type { AddressInfo } from 'node:net' import { extractMeta, fetchPreview } from './link-preview.js' import { getDb } from './db.js' import { ensureAppSchema } from './schema.js' @@ -199,4 +201,68 @@ describe('fetchPreview', () => { expect(result.ok).toBe(false) expect(result.url).toBe('https://timeout.com/') }) + + it('populates an internal `reason` string when fetchHtml throws (not sent to clients, used for server-side diagnostics)', async () => { + const mockFetchHtml = vi.fn().mockRejectedValue(new Error('HTTP 404')) + const mockAssertPublicUrl = vi.fn().mockResolvedValue({ url: new URL('https://not-found.com/'), ip: '1.1.1.1' }) + + const result = await fetchPreview('https://not-found.com/', { + fetchHtml: mockFetchHtml, + assertPublicUrl: mockAssertPublicUrl, + }) + + expect(result.ok).toBe(false) + expect(result.reason).toBe('HTTP 404') + }) + + it('populates an internal `reason` string when assertPublicUrl (the SSRF guard) throws', async () => { + const mockFetchHtml = vi.fn() + const mockAssertPublicUrl = vi.fn().mockRejectedValue(new Error('SSRF: blocked address 10.0.0.1 for internal.local')) + + const result = await fetchPreview('https://internal2.local/', { + fetchHtml: mockFetchHtml, + assertPublicUrl: mockAssertPublicUrl, + }) + + expect(result.ok).toBe(false) + expect(result.reason).toBe('SSRF: blocked address 10.0.0.1 for internal.local') + expect(mockFetchHtml).not.toHaveBeenCalled() + }) + + // Audit F6 root cause: pinnedGet's custom `lookup` option (used to pin the + // fetch to the SSRF-validated IP) didn't handle Node's Happy-Eyeballs + // calling convention (`{ all: true }`, default-on since Node 20, invoked + // for any non-literal-IP hostname). Node's http/https client would call + // back with a single address/family pair, which Node's internals reject + // as an invalid response to an `all: true` request — throwing + // ERR_INVALID_IP_ADDRESS deep inside connection setup. fetchPreview's + // outer try/catch swallowed this as an ordinary fetch failure, so EVERY + // real public URL silently produced { ok: false }, and manager.ts's + // .catch(() => {}) then hid it a second time. This exercises the REAL + // fetchHtml (realFetchHtml -> pinnedGet) against a real local HTTP + // server via a non-literal hostname, only stubbing assertPublicUrl so the + // loopback fixture passes the (correct, and intentionally untouched) + // SSRF guard. + it('fetches via a hostname (not a literal IP) through the real pinnedGet path — regression for the Happy-Eyeballs lookup bug', async () => { + const server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/html' }) + res.end(``) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())) + const port = (server.address() as AddressInfo).port + const url = `http://localhost:${port}/` + + try { + const mockAssertPublicUrl = vi.fn().mockResolvedValue({ url: new URL(url), ip: '127.0.0.1' }) + + // No fetchHtml override — this runs the real realFetchHtml/pinnedGet + // code, including the pinned `lookup` option under test. + const result = await fetchPreview(url, { assertPublicUrl: mockAssertPublicUrl }) + + expect(result.ok).toBe(true) + expect(result.title).toBe('Hostname Fixture') + } finally { + server.close() + } + }) }) diff --git a/packages/server/src/link-preview.ts b/packages/server/src/link-preview.ts index a8e40ce..ff98dba 100644 --- a/packages/server/src/link-preview.ts +++ b/packages/server/src/link-preview.ts @@ -153,6 +153,13 @@ export interface PreviewResult { description?: string image?: string siteName?: string + /** + * Internal-only failure classification (e.g. "HTTP 404", "timeout", "body + * too large", or an SSRF-guard message). Never sent to clients — the wire + * contract is `previewResultSchema` in @bool/shared, which has no such + * field. Present only when `ok` is false, for server-side diagnostics. + */ + reason?: string } // --------------------------------------------------------------------------- @@ -176,6 +183,20 @@ function pinnedGet(url: URL, ip: string): Promise<{ body: string; location?: str const timer = setTimeout(() => reject(new Error('timeout')), FETCH_TIMEOUT_MS) + // Pin to the pre-validated IP to prevent DNS rebinding: never re-resolve + // the hostname, always hand back the closure-captured `ip`. Node's + // `net.LookupFunction` always calls back as (hostname, options, callback); + // `options.all` (set by Happy Eyeballs, default-on since Node 20) selects + // between the array-returning and single-address callback forms. + const pinnedLookup: import('node:net').LookupFunction = (_hostname, lookupOptions, callback) => { + const family = ip.includes(':') ? 6 : 4 + if (lookupOptions.all === true) { + callback(null, [{ address: ip, family }]) + } else { + callback(null, ip, family) + } + } + const options: http.RequestOptions = { hostname: url.hostname, port: url.port || (url.protocol === 'https:' ? 443 : 80), @@ -186,10 +207,7 @@ function pinnedGet(url: URL, ip: string): Promise<{ body: string; location?: str Accept: 'text/html,application/xhtml+xml', 'Accept-Language': 'en-US,en;q=0.5', }, - // Pin to the pre-validated IP to prevent DNS rebinding - lookup: (_hostname: string, _opts: unknown, callback: (err: Error | null, address: string, family: number) => void) => { - callback(null, ip, ip.includes(':') ? 6 : 4) - }, + lookup: pinnedLookup, } const req = mod.get(options as any, (res) => { @@ -323,8 +341,8 @@ export async function fetchPreview( let validated: { url: URL; ip: string } try { validated = await assertFn(url, lookupFn ? { lookup: lookupFn } : undefined) - } catch { - const result: PreviewResult = { url, ok: false } + } catch (err) { + const result: PreviewResult = { url, ok: false, reason: err instanceof Error ? err.message : String(err) } upsertCache(result) return result } @@ -343,8 +361,8 @@ export async function fetchPreview( const result: PreviewResult = { url, ok: true, ...meta } upsertCache(result) return result - } catch { - const result: PreviewResult = { url, ok: false } + } catch (err) { + const result: PreviewResult = { url, ok: false, reason: err instanceof Error ? err.message : String(err) } upsertCache(result) return result } diff --git a/packages/server/src/media-routes.test.ts b/packages/server/src/media-routes.test.ts index 0e045f3..f409978 100644 --- a/packages/server/src/media-routes.test.ts +++ b/packages/server/src/media-routes.test.ts @@ -12,6 +12,7 @@ import type { FastifyInstance } from 'fastify' import { buildApp } from './app.js' import { getDb } from './db.js' import { ensureAppSchema } from './schema.js' +import { signUpUser } from '../test/signup.js' // A minimal valid 1×1 transparent PNG (67 bytes) const TINY_PNG = Buffer.from( @@ -57,11 +58,7 @@ afterEach(() => { const uniq = () => `u${Math.floor(performance.now() * 1000) % 1_000_000_000}` async function signUpAndGetCookie(name: string): Promise { - const signup = await app.inject({ - method: 'POST', - url: '/api/auth/sign-up/email', - payload: { email: `${name}@example.test`, password: 'password1234', name, username: name }, - }) + const signup = await signUpUser(app, name) expect(signup.statusCode).toBeLessThan(400) const cookie = signup.headers['set-cookie'] return Array.isArray(cookie) ? cookie.join('; ') : String(cookie) diff --git a/packages/server/src/messages.test.ts b/packages/server/src/messages.test.ts index e7c7b21..fb775e4 100644 --- a/packages/server/src/messages.test.ts +++ b/packages/server/src/messages.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, beforeAll } from 'vitest' import { getDb } from './db.js' import { ensureAppSchema } from './schema.js' -import { storeMessage, searchMessages, getHistory, pruneOlderThan, parseSearch } from './messages.js' +import { setReadMarker } from './read-markers.js' +import { storeMessage, searchMessages, getHistory, pruneOlderThan, parseSearch, listRecentTargets } from './messages.js' beforeAll(() => ensureAppSchema(getDb())) @@ -95,6 +96,23 @@ describe('messages store', () => { expect((msg as any).replyTo).toBe('m1') }) + it('listRecentTargets: distinct targets with unread counts, ordered by recency', () => { + const now = Date.now() + const ts1 = now - 3000 // first #a message (will be marked read) + const ts2 = now - 1000 // second #a message (unread, most recent overall) + const ts3 = now - 2000 // ada message (unread) + storeMessage('uTargets', base({ target: '#a', body: 'hello', ts: ts1 })) + storeMessage('uTargets', base({ target: '#a', body: 'world', ts: ts2 })) + storeMessage('uTargets', base({ target: 'ada', body: 'hi there', ts: ts3 })) + setReadMarker('uTargets', 1, '#a', ts1) + + const result = listRecentTargets('uTargets') + expect(result).toEqual([ + { networkId: 1, target: '#a', kind: 'channel', unread: 1 }, + { networkId: 1, target: 'ada', kind: 'pm', unread: 1 }, + ]) + }) + it('user-scope injection: adversarial queries as uY cannot see messages stored for uX', () => { storeMessage('uX', base({ body: 'private data for uX', ts: 9000 })) // Adversarial queries that could attempt to escape user scope via FTS or SQL diff --git a/packages/server/src/messages.ts b/packages/server/src/messages.ts index 11455c6..ef6c435 100644 --- a/packages/server/src/messages.ts +++ b/packages/server/src/messages.ts @@ -98,6 +98,29 @@ export function getHistory(userId: string, networkId: number, target: string, li return rows.map(toStored).reverse() } +export function listRecentTargets( + userId: string, + limit = 50, +): Array<{ networkId: number; target: string; kind: 'channel' | 'pm' | 'status'; unread: number }> { + const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000 + const rows = getDb().prepare(` + SELECT m.network_id AS networkId, m.target AS target, + SUM(CASE WHEN m.ts > COALESCE(r.ts, 0) AND m.sender != '' THEN 1 ELSE 0 END) AS unread + FROM messages m + LEFT JOIN read_markers r + ON r.user_id = m.user_id AND r.network_id = m.network_id AND r.target = m.target + WHERE m.user_id = ? AND m.ts > ? + GROUP BY m.network_id, m.target + ORDER BY MAX(m.ts) DESC + LIMIT ? + `).all(userId, cutoff, limit) as Array<{ networkId: number; target: string; unread: number }> + return rows.map((r) => ({ + ...r, + kind: r.target === '*' ? 'status' as const + : r.target.startsWith('#') || r.target.startsWith('&') ? 'channel' as const : 'pm' as const, + })) +} + export function pruneOlderThan(cutoffTs: number): number { const db = getDb() const deleteFts = db.prepare('DELETE FROM messages_fts WHERE rowid IN (SELECT id FROM messages WHERE ts < ?)') diff --git a/packages/server/src/networks.ts b/packages/server/src/networks.ts index c487a9b..8dac699 100644 --- a/packages/server/src/networks.ts +++ b/packages/server/src/networks.ts @@ -83,3 +83,12 @@ export function removeNetwork(userId: string, id: number): number { const result = getDb().prepare('DELETE FROM networks WHERE user_id = ? AND id = ?').run(userId, id) return result.changes } + +export function setAutoConnect(userId: string, networkId: number, on: boolean): void { + getDb().prepare(`UPDATE networks SET auto_connect = ? WHERE user_id = ? AND id = ?`).run(on ? 1 : 0, userId, networkId) +} + +export function listAutoConnect(): Array<{ userId: string; networkId: number }> { + const rows = getDb().prepare(`SELECT user_id, id FROM networks WHERE auto_connect = 1`).all() as { user_id: string; id: number }[] + return rows.map((r) => ({ userId: r.user_id, networkId: r.id })) +} diff --git a/packages/server/src/push-routes.test.ts b/packages/server/src/push-routes.test.ts index 187e2c2..370e34b 100644 --- a/packages/server/src/push-routes.test.ts +++ b/packages/server/src/push-routes.test.ts @@ -3,6 +3,7 @@ import type { FastifyInstance } from 'fastify' import { buildApp } from './app.js' import { getDb } from './db.js' import { ensureAppSchema } from './schema.js' +import { signUpUser } from '../test/signup.js' const FAKE_PUBLIC = 'BFakePublicKeyForRoutesTest1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef' const FAKE_PRIVATE = 'FakePrivateKeyForRoutesTest1234567890ABCDEFG' @@ -28,11 +29,7 @@ afterEach(() => { const uniq = () => `u${Math.floor(performance.now() * 1000) % 1_000_000_000}` async function signUpAndGetCookie(name: string): Promise { - const signup = await app.inject({ - method: 'POST', - url: '/api/auth/sign-up/email', - payload: { email: `${name}@example.test`, password: 'password1234', name, username: name }, - }) + const signup = await signUpUser(app, name) expect(signup.statusCode).toBeLessThan(400) const cookie = signup.headers['set-cookie'] return Array.isArray(cookie) ? cookie.join('; ') : String(cookie) diff --git a/packages/server/src/schema.ts b/packages/server/src/schema.ts index 99ccc9c..9cfdaa7 100644 --- a/packages/server/src/schema.ts +++ b/packages/server/src/schema.ts @@ -71,6 +71,14 @@ export function ensureAppSchema(db: Database.Database): void { site_name TEXT, fetched_at INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS invites ( + code TEXT PRIMARY KEY, + created_by TEXT NOT NULL, + created_at INTEGER NOT NULL, + used_by TEXT, + used_at INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_invites_creator ON invites(created_by); `) // Idempotent migration: add reply_to column to existing databases. @@ -79,4 +87,10 @@ export function ensureAppSchema(db: Database.Database): void { } catch (e: any) { if (!e?.message?.includes('duplicate column name')) throw e } + + // auto_connect: 1 = operator had this network connected; resume it on boot. + const cols = db.prepare(`PRAGMA table_info(networks)`).all() as { name: string }[] + if (!cols.some((c) => c.name === 'auto_connect')) { + db.exec(`ALTER TABLE networks ADD COLUMN auto_connect INTEGER NOT NULL DEFAULT 0`) + } } diff --git a/packages/server/src/setup-admin.test.ts b/packages/server/src/setup-admin.test.ts new file mode 100644 index 0000000..914ac8a --- /dev/null +++ b/packages/server/src/setup-admin.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import type { FastifyInstance } from 'fastify' +import { buildApp } from './app.js' +import { getDb } from './db.js' + +let app: FastifyInstance + +beforeAll(async () => { + app = buildApp() + await app.ready() +}) +afterAll(async () => { + await app.close() +}) + +const uniq = () => `a${Math.floor(performance.now() * 1000) % 1_000_000}` + +// Clean file-scoped DB (0 users). Tests run top-to-bottom. +describe('POST /api/setup (first-admin bootstrap, server-gated on userCount === 0)', () => { + it('creates the first admin when no users exist', async () => { + const name = uniq() + const res = await app.inject({ + method: 'POST', + url: '/api/setup', + payload: { + email: `${name}@example.test`, + password: 'password1234', + name, + username: name, + }, + }) + expect(res.statusCode).toBeLessThan(400) + + const row = getDb() + .prepare('SELECT role FROM user WHERE username = ?') + .get(name) as { role: string | null } | undefined + expect(row?.role).toBe('admin') + }) + + it('returns 409 when users already exist (re-checked at creation time)', async () => { + const name = uniq() + const res = await app.inject({ + method: 'POST', + url: '/api/setup', + payload: { + email: `${name}@example.test`, + password: 'password1234', + name, + username: name, + }, + }) + expect(res.statusCode).toBe(409) + + // No extra user was created. + const row = getDb() + .prepare('SELECT id FROM user WHERE username = ?') + .get(name) as { id: string } | undefined + expect(row).toBeUndefined() + }) +}) diff --git a/packages/server/src/setup-routes.test.ts b/packages/server/src/setup-routes.test.ts new file mode 100644 index 0000000..21e44c7 --- /dev/null +++ b/packages/server/src/setup-routes.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import type { FastifyInstance } from 'fastify' +import { buildApp } from './app.js' +import { issueInvite } from './invites.js' + +let app: FastifyInstance + +beforeAll(async () => { + app = buildApp() + await app.ready() +}) +afterAll(async () => { + await app.close() +}) + +describe('setup routes', () => { + it('GET /api/setup-status returns a boolean needsSetup', async () => { + const res = await app.inject({ method: 'GET', url: '/api/setup-status' }) + expect(res.statusCode).toBe(200) + expect(typeof res.json().needsSetup).toBe('boolean') + }) + + it('GET /api/invites/:code reports validity without auth', async () => { + const { code } = issueInvite('admin-seed') + const ok = await app.inject({ method: 'GET', url: `/api/invites/${code}` }) + expect(ok.statusCode).toBe(200) + expect(ok.json().valid).toBe(true) + + const bad = await app.inject({ method: 'GET', url: '/api/invites/NOPE-NOPE-NOPE' }) + expect(bad.statusCode).toBe(200) + expect(bad.json().valid).toBe(false) + }) + + it('POST /api/invites requires an admin session (401 when anonymous)', async () => { + const res = await app.inject({ method: 'POST', url: '/api/invites' }) + expect(res.statusCode).toBe(401) + }) +}) diff --git a/packages/server/src/setup-routes.ts b/packages/server/src/setup-routes.ts new file mode 100644 index 0000000..4292570 --- /dev/null +++ b/packages/server/src/setup-routes.ts @@ -0,0 +1,61 @@ +import type { FastifyInstance } from 'fastify' +import { getDb } from './db.js' +import { ensureAppSchema } from './schema.js' +import { auth } from './auth.js' +import { getSessionFromHeaders } from './auth-routes.js' +import { isAdmin, userCount } from './bootstrap-admin.js' +import { issueInvite, validateInvite } from './invites.js' + +interface SetupBody { + email?: string + password?: string + name?: string + username?: string +} + +export async function registerSetup(app: FastifyInstance): Promise { + ensureAppSchema(getDb()) + + app.get('/api/setup-status', async () => ({ needsSetup: userCount() === 0 })) + + app.get<{ Params: { code: string } }>('/api/invites/:code', async (request) => ({ + valid: validateInvite(request.params.code), + })) + + app.post('/api/invites', async (request, reply) => { + const session = await getSessionFromHeaders(request.headers) + if (!session || !isAdmin(session)) return reply.status(401).send({ error: 'unauthorized' }) + return reply.send(issueInvite(session.user.id)) + }) + + // Server-gated first-admin bootstrap. This is the only endpoint that may create + // an account WITHOUT an invite, and it is gated on `userCount() === 0` + // re-checked at creation time. If any user already exists it returns 409 — it + // never falls back to the UI branch or the setup-status probe. The account it + // creates is promoted to admin by the `user.create.before` hook in auth.ts. + app.post<{ Body: SetupBody }>('/api/setup', async (request, reply) => { + if (userCount() !== 0) { + return reply.status(409).send({ error: 'setup_already_completed' }) + } + const { email, password, name, username } = request.body ?? {} + if (!email || !password || !username) { + return reply.status(400).send({ error: 'missing_fields' }) + } + try { + await auth.api.signUpEmail({ + body: { email, password, name: name ?? username, username }, + }) + } catch (err) { + const status = + typeof err === 'object' && err && 'statusCode' in err + ? Number((err as { statusCode: unknown }).statusCode) || 400 + : 400 + const message = + typeof err === 'object' && err && 'message' in err + ? String((err as { message: unknown }).message) + : 'Could not create the admin account' + return reply.status(status).send({ error: message }) + } + return reply.send({ ok: true }) + }) +} diff --git a/packages/server/src/ws-irc.test.ts b/packages/server/src/ws-irc.test.ts index 4320c08..dd09d52 100644 --- a/packages/server/src/ws-irc.test.ts +++ b/packages/server/src/ws-irc.test.ts @@ -110,9 +110,16 @@ function collect( } describe('ws irc integration', () => { - it('adds a network, connects, joins, sends — over the WS protocol', async () => { - const cookie = await signUpCookie() + // The invite gate only exempts the very first account in the DB (bootstrap + // admin) — every subsequent public sign-up needs an invite code. So this + // whole describe block shares one signed-up session across its tests + // rather than calling signUpCookie() again per test. + let cookie: string + beforeAll(async () => { + cookie = await signUpCookie() + }) + it('adds a network, connects, joins, sends — over the WS protocol', async () => { // Step 1: add network, wait for net:list with at least one entry. // The server now sends an initial net:list on connect (may be empty), so // we wait for the one that arrives after net:add (which has networks.length >= 1). @@ -147,4 +154,71 @@ describe('ws irc integration', () => { expect(after.some((m) => m.type === 'chan:join' && m.channel === '#x')).toBe(true) expect(lastClient.say).toHaveBeenCalledWith('#x', 'hello') }, 10000) + + it('chat:send{notice:true}, user:away, chan:names:req, and user:invite reach the IRC client (audit B6)', async () => { + const msgs = await collect( + cookie, + (ws) => { + ws.send(JSON.stringify({ type: 'hello', clientVersion: 1 })) + ws.send(JSON.stringify({ type: 'net:add', name: 'B6Net', host: 'h', port: 6697, tls: true, nick: 'bool' })) + }, + (m) => m.some((x) => x.type === 'net:list' && x.networks.some((n: any) => n.name === 'B6Net')), + ) + const list = msgs.filter((m) => m.type === 'net:list').find((m) => m.networks.some((n: any) => n.name === 'B6Net')) + const netId = list.networks.find((n: any) => n.name === 'B6Net').id + + const after = await collect( + cookie, + async (ws, waitFor) => { + ws.send(JSON.stringify({ type: 'hello', clientVersion: 1 })) + ws.send(JSON.stringify({ type: 'net:connect', networkId: netId })) + await waitFor((m) => m.some((x) => x.type === 'net:state' && x.state === 'registered')) + ws.send(JSON.stringify({ type: 'chat:send', networkId: netId, target: 'ada', text: 'heads up', notice: true })) + ws.send(JSON.stringify({ type: 'user:away', networkId: netId, message: 'brb' })) + ws.send(JSON.stringify({ type: 'chan:names:req', networkId: netId, channel: '#x' })) + ws.send(JSON.stringify({ type: 'user:invite', networkId: netId, nick: 'ada', channel: '#x' })) + // No server->client ack for the above; gate on a subsequent join event instead. + ws.send(JSON.stringify({ type: 'chan:join', networkId: netId, channel: '#x' })) + }, + (m) => m.some((x) => x.type === 'chan:join' && x.channel === '#x'), + ) + expect(after.some((m) => m.type === 'chan:join' && m.channel === '#x')).toBe(true) + expect(lastClient.raw).toHaveBeenCalledWith('NOTICE ada :heads up') + expect(lastClient.say).not.toHaveBeenCalledWith('ada', 'heads up') + expect(lastClient.raw).toHaveBeenCalledWith('AWAY :brb') + expect(lastClient.raw).toHaveBeenCalledWith('NAMES #x') + expect(lastClient.raw).toHaveBeenCalledWith('INVITE ada #x') + }, 10000) + + it('replies net:error for notice/away/names/invite when the network is not connected (audit B6)', async () => { + const msgs = await collect( + cookie, + (ws) => { + ws.send(JSON.stringify({ type: 'hello', clientVersion: 1 })) + ws.send(JSON.stringify({ type: 'net:add', name: 'UnconnectedNet', host: 'h', port: 6697, tls: true, nick: 'bool' })) + }, + (m) => m.some((x) => x.type === 'net:list' && x.networks.some((n: any) => n.name === 'UnconnectedNet')), + ) + const list = msgs.filter((m) => m.type === 'net:list').find((m) => m.networks.some((n: any) => n.name === 'UnconnectedNet')) + const netId = list.networks.find((n: any) => n.name === 'UnconnectedNet').id + + // Never send net:connect — the manager has no live connection for this network. + const after = await collect( + cookie, + (ws) => { + ws.send(JSON.stringify({ type: 'hello', clientVersion: 1 })) + ws.send(JSON.stringify({ type: 'chat:send', networkId: netId, target: 'ada', text: 'heads up', notice: true })) + ws.send(JSON.stringify({ type: 'user:away', networkId: netId, message: 'brb' })) + ws.send(JSON.stringify({ type: 'chan:names:req', networkId: netId, channel: '#x' })) + ws.send(JSON.stringify({ type: 'user:invite', networkId: netId, nick: 'ada', channel: '#x' })) + }, + (m) => m.filter((x) => x.type === 'net:error' && x.networkId === netId).length >= 4, + ) + const errors = after.filter((m) => m.type === 'net:error' && m.networkId === netId) + expect(errors).toHaveLength(4) + for (const e of errors) { + expect(e.networkId).toBe(netId) + expect(e.message).toBe('not connected') + } + }, 10000) }) diff --git a/packages/server/src/ws-rich.test.ts b/packages/server/src/ws-rich.test.ts index 4c720f7..2012574 100644 --- a/packages/server/src/ws-rich.test.ts +++ b/packages/server/src/ws-rich.test.ts @@ -6,6 +6,7 @@ import type { FastifyInstance } from 'fastify' import { buildApp } from './app.js' import { ircManager } from './irc/singleton.js' import type { IrcClientLike } from './irc/connection.js' +import { signUpUser } from '../test/signup.js' let app: FastifyInstance let base: string @@ -56,11 +57,7 @@ afterAll(async () => { async function signUpCookie(): Promise { const name = 'rich' + randomUUID().slice(0, 8) - const res = await app.inject({ - method: 'POST', - url: '/api/auth/sign-up/email', - payload: { email: `${name}@example.test`, password: 'password1234', name, username: name }, - }) + const res = await signUpUser(app, name) const c = res.headers['set-cookie'] return Array.isArray(c) ? c.join('; ') : String(c) } diff --git a/packages/server/src/ws.ts b/packages/server/src/ws.ts index 8c1314d..f7cee57 100644 --- a/packages/server/src/ws.ts +++ b/packages/server/src/ws.ts @@ -2,8 +2,8 @@ import type { FastifyInstance } from 'fastify' import { parseClientMessage, PROTOCOL_VERSION, type ServerMessage } from '@bool/shared' import { getSessionFromHeaders } from './auth-routes.js' import { hub, ircManager } from './irc/singleton.js' -import { createNetwork, removeNetwork, listNetworks } from './networks.js' -import { searchMessages, getHistory } from './messages.js' +import { createNetwork, removeNetwork, listNetworks, setAutoConnect } from './networks.js' +import { searchMessages, getHistory, listRecentTargets } from './messages.js' import { setReadMarker, listReadMarkers } from './read-markers.js' export function serverTimeNow(): number { @@ -43,6 +43,7 @@ export function registerWebSocket(app: FastifyInstance): void { // Send current state to the freshly-subscribed socket. sendNetList() send({ type: 'read:list', markers: listReadMarkers(userId) }) + send({ type: 'session:targets', targets: listRecentTargets(userId) }) const handleMessage = (msg: ReturnType) => { switch (msg.type) { @@ -76,9 +77,11 @@ export function registerWebSocket(app: FastifyInstance): void { break case 'net:connect': ircManager.connectNetwork(userId, msg.networkId) + setAutoConnect(userId, msg.networkId, true) break case 'net:disconnect': ircManager.disconnectNetwork(userId, msg.networkId) + setAutoConnect(userId, msg.networkId, false) sendNetList() break case 'chan:join': @@ -114,7 +117,24 @@ export function registerWebSocket(app: FastifyInstance): void { case 'chat:send': { const ok = msg.action ? ircManager.sendAction(userId, msg.networkId, msg.target, msg.text) - : ircManager.say(userId, msg.networkId, msg.target, msg.text, msg.replyTo) + : msg.notice + ? ircManager.notice(userId, msg.networkId, msg.target, msg.text) + : ircManager.say(userId, msg.networkId, msg.target, msg.text, msg.replyTo) + if (!ok) send({ type: 'net:error', networkId: msg.networkId, message: 'not connected' }) + break + } + case 'user:away': { + const ok = ircManager.setAway(userId, msg.networkId, msg.message) + if (!ok) send({ type: 'net:error', networkId: msg.networkId, message: 'not connected' }) + break + } + case 'user:invite': { + const ok = ircManager.invite(userId, msg.networkId, msg.nick, msg.channel) + if (!ok) send({ type: 'net:error', networkId: msg.networkId, message: 'not connected' }) + break + } + case 'chan:names:req': { + const ok = ircManager.requestNames(userId, msg.networkId, msg.channel) if (!ok) send({ type: 'net:error', networkId: msg.networkId, message: 'not connected' }) break } diff --git a/packages/server/test/signup.ts b/packages/server/test/signup.ts new file mode 100644 index 0000000..cc504a5 --- /dev/null +++ b/packages/server/test/signup.ts @@ -0,0 +1,30 @@ +import type { FastifyInstance } from 'fastify' +import { userCount } from '../src/bootstrap-admin.js' +import { issueInvite } from '../src/invites.js' + +/** + * Test helper: register a user through the REAL sign-up endpoint while respecting + * the production invite gate. The first user in a fresh test DB is created without + * an invite (and is promoted to admin by the sign-up hook); every subsequent user + * is given a freshly-issued single-use invite so the server-side enforcement path + * is exercised exactly as in production. Returns the raw inject result. + */ +export async function signUpUser( + app: FastifyInstance, + name: string, + opts: { password?: string } = {}, +) { + const password = opts.password ?? 'password1234' + const inviteCode = userCount() === 0 ? undefined : issueInvite('test-seed').code + return app.inject({ + method: 'POST', + url: '/api/auth/sign-up/email', + payload: { + email: `${name}@example.test`, + password, + name, + username: name, + ...(inviteCode ? { inviteCode } : {}), + }, + }) +} diff --git a/packages/shared/src/protocol-chanlist.test.ts b/packages/shared/src/protocol-chanlist.test.ts new file mode 100644 index 0000000..d780b6d --- /dev/null +++ b/packages/shared/src/protocol-chanlist.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest' +import { parseServerMessage } from './protocol.js' + +describe('chan:list:results protocol', () => { + it('parses a batch with channels and done:false', () => { + const msg = parseServerMessage(JSON.stringify({ + type: 'chan:list:results', + networkId: 1, + channels: [{ name: '#a', users: 42, topic: 'welcome' }], + done: false, + })) + expect(msg).toMatchObject({ + type: 'chan:list:results', + networkId: 1, + channels: [{ name: '#a', users: 42, topic: 'welcome' }], + done: false, + }) + }) + + it('parses the terminal done:true message with an empty channels array', () => { + const msg = parseServerMessage(JSON.stringify({ + type: 'chan:list:results', + networkId: 1, + channels: [], + done: true, + })) + expect(msg).toMatchObject({ type: 'chan:list:results', channels: [], done: true }) + }) + + it('rejects a batch missing required fields', () => { + expect(() => + parseServerMessage(JSON.stringify({ type: 'chan:list:results', networkId: 1, channels: [] })), + ).toThrow() + }) +}) diff --git a/packages/shared/src/protocol-irc.test.ts b/packages/shared/src/protocol-irc.test.ts index 7df9081..c29b33c 100644 --- a/packages/shared/src/protocol-irc.test.ts +++ b/packages/shared/src/protocol-irc.test.ts @@ -36,4 +36,22 @@ describe('irc protocol messages', () => { expect(() => parseClientMessage(JSON.stringify({ type: 'net:nope' }))).toThrow() expect(() => parseClientMessage(JSON.stringify({ type: 'net:add', name: 'x' }))).toThrow() }) + + it('parses presence:away for both away and back', () => { + expect( + parseServerMessage(JSON.stringify({ type: 'presence:away', networkId: 1, nick: 'ada', away: true })), + ).toMatchObject({ type: 'presence:away', nick: 'ada', away: true }) + expect( + parseServerMessage(JSON.stringify({ type: 'presence:away', networkId: 1, nick: 'ada', away: false })), + ).toMatchObject({ type: 'presence:away', nick: 'ada', away: false }) + }) + + it('parses net:state with optional caps', () => { + const m = parseServerMessage( + JSON.stringify({ type: 'net:state', networkId: 1, name: 'Libera', state: 'registered', nick: 'bool', caps: ['away-notify', 'server-time'] }), + ) + expect(m).toMatchObject({ type: 'net:state', caps: ['away-notify', 'server-time'] }) + // caps stays optional — net:state without it still parses + expect(parseServerMessage(JSON.stringify({ type: 'net:state', networkId: 1, name: 'Libera', state: 'connecting' })).type).toBe('net:state') + }) }) diff --git a/packages/shared/src/protocol.test.ts b/packages/shared/src/protocol.test.ts index d8f56dc..227233c 100644 --- a/packages/shared/src/protocol.test.ts +++ b/packages/shared/src/protocol.test.ts @@ -25,4 +25,12 @@ describe('protocol', () => { it('rejects a hello missing clientVersion', () => { expect(() => parseClientMessage(JSON.stringify({ type: 'hello' }))).toThrow() }) + + it('parses session:targets', () => { + const msg = parseServerMessage(JSON.stringify({ + type: 'session:targets', + targets: [{ networkId: 1, target: '#dev', kind: 'channel', unread: 3 }], + })) + expect(msg.type).toBe('session:targets') + }) }) diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index 4b93d89..15269e6 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -25,7 +25,7 @@ export const netDisconnectSchema = z.object({ type: z.literal('net:disconnect'), export const netListReqSchema = z.object({ type: z.literal('net:list') }) export const chanJoinSchema = z.object({ type: z.literal('chan:join'), networkId: z.number().int(), channel: z.string() }) export const chanPartSchema = z.object({ type: z.literal('chan:part'), networkId: z.number().int(), channel: z.string() }) -export const chatSendSchema = z.object({ type: z.literal('chat:send'), networkId: z.number().int(), target: z.string(), text: z.string(), replyTo: z.string().optional(), action: z.boolean().optional() }) +export const chatSendSchema = z.object({ type: z.literal('chat:send'), networkId: z.number().int(), target: z.string(), text: z.string(), replyTo: z.string().optional(), action: z.boolean().optional(), notice: z.boolean().optional() }) export const userNickSchema = z.object({ type: z.literal('user:nick'), networkId: z.number().int(), nick: z.string() }) export const chanTopicSetSchema = z.object({ type: z.literal('chan:topic:set'), networkId: z.number().int(), channel: z.string(), topic: z.string() }) export const chanKickSchema = z.object({ type: z.literal('chan:kick'), networkId: z.number().int(), channel: z.string(), nick: z.string(), reason: z.string().optional() }) @@ -48,6 +48,11 @@ export const historyRequestSchema = z.object({ export const readSetSchema = z.object({ type: z.literal('read:set'), networkId: z.number().int(), target: z.string(), ts: z.number() }) export const readListReqSchema = z.object({ type: z.literal('read:list') }) +// --- WS-6: new client->server messages --- +export const userAwaySchema = z.object({ type: z.literal('user:away'), networkId: z.number().int(), message: z.string().optional() }) +export const userInviteSchema = z.object({ type: z.literal('user:invite'), networkId: z.number().int(), nick: z.string(), channel: z.string() }) +export const chanNamesReqSchema = z.object({ type: z.literal('chan:names:req'), networkId: z.number().int(), channel: z.string() }) + export const clientMessageSchema = z.discriminatedUnion('type', [ helloSchema, pingSchema, netAddSchema, netRemoveSchema, netConnectSchema, netDisconnectSchema, netListReqSchema, @@ -55,6 +60,7 @@ export const clientMessageSchema = z.discriminatedUnion('type', [ readSetSchema, readListReqSchema, typingSetSchema, reactAddSchema, reactRemoveSchema, userNickSchema, chanTopicSetSchema, chanKickSchema, chanModeSchema, userWhoisSchema, chanListSchema, + userAwaySchema, userInviteSchema, chanNamesReqSchema, ]) export type ClientMessage = z.infer @@ -74,6 +80,8 @@ export const netStateSchema = z.object({ name: z.string(), state: z.enum(['connecting', 'registered', 'reconnecting', 'closed']), nick: z.string().optional(), + /** Negotiated IRCv3 caps (client.network.cap.enabled), set on registration. */ + caps: z.array(z.string()).optional(), }) export const netListSchema = z.object({ type: z.literal('net:list'), @@ -114,13 +122,17 @@ export const chanPartEvtSchema = z.object({ type: z.literal('chan:part'), networ export const chanTopicSchema = z.object({ type: z.literal('chan:topic'), networkId: z.number().int(), channel: z.string(), topic: z.string() }) export const presenceQuitSchema = z.object({ type: z.literal('presence:quit'), networkId: z.number().int(), nick: z.string(), reason: z.string().optional() }) export const presenceNickSchema = z.object({ type: z.literal('presence:nick'), networkId: z.number().int(), oldNick: z.string(), newNick: z.string() }) +export const presenceAwaySchema = z.object({ type: z.literal('presence:away'), networkId: z.number().int(), nick: z.string(), away: z.boolean() }) export const storedMsgSchema = z.object({ id: z.number().int(), networkId: z.number().int(), target: z.string(), sender: z.string(), - kind: z.enum(['privmsg', 'notice', 'action']), + // 'system' is client-synthesized only (join/part/quit lines) — never sent by the + // server over chat:msg (see chatMsgSchema above, which keeps the 3-value enum) and + // never persisted/queried via listRecentTargets. + kind: z.enum(['privmsg', 'notice', 'action', 'system']), body: z.string(), ts: z.number(), msgid: z.string().optional(), @@ -140,6 +152,23 @@ export const readListSchema = z.object({ export const typingUpdateSchema = z.object({ type: z.literal('typing:update'), networkId: z.number().int(), target: z.string(), nick: z.string(), state: z.enum(['active', 'done']) }) export const reactUpdateSchema = z.object({ type: z.literal('react:update'), networkId: z.number().int(), target: z.string(), msgid: z.string(), emoji: z.string(), nick: z.string(), action: z.enum(['add', 'remove']) }) +export const sessionTargetsSchema = z.object({ + type: z.literal('session:targets'), + targets: z.array(z.object({ + networkId: z.number().int(), + target: z.string(), + kind: z.enum(['channel', 'pm', 'status']), + unread: z.number().int(), + })), +}) + +export const chanListResultsSchema = z.object({ + type: z.literal('chan:list:results'), + networkId: z.number().int(), + channels: z.array(z.object({ name: z.string(), users: z.number().int(), topic: z.string() })), + done: z.boolean(), +}) + export const previewResultSchema = z.object({ type: z.literal('preview:result'), url: z.string(), @@ -154,11 +183,13 @@ export const serverMessageSchema = z.discriminatedUnion('type', [ welcomeSchema, pongSchema, errorSchema, netStateSchema, netListSchema, netErrorSchema, chatMsgSchema, chanNamesSchema, chanJoinEvtSchema, chanPartEvtSchema, chanTopicSchema, - presenceQuitSchema, presenceNickSchema, + presenceQuitSchema, presenceNickSchema, presenceAwaySchema, searchResultsSchema, historyBatchSchema, readUpdateSchema, readListSchema, typingUpdateSchema, reactUpdateSchema, previewResultSchema, + sessionTargetsSchema, + chanListResultsSchema, ]) export type ServerMessage = z.infer diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 748ecf9..3e8652e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: vitest: specifier: ^3.0.0 version: 3.2.7(@types/node@20.19.43)(jiti@2.7.0)(jsdom@25.0.1)(terser@5.49.0)(tsx@4.23.0) + ws: + specifier: ^8.18.0 + version: 8.21.0 packages/client: dependencies: diff --git a/tools/ci-smoke.mjs b/tools/ci-smoke.mjs new file mode 100644 index 0000000..4ce76e3 --- /dev/null +++ b/tools/ci-smoke.mjs @@ -0,0 +1,121 @@ +// tools/ci-smoke.mjs — the cold-start audit, automated. +// signup → WS → add+connect network (ergo) → join → message → echo → +// restart bool → reconnect → networks/channels/history all still there. +import { execSync } from 'node:child_process' +import WebSocket from 'ws' + +const BASE = process.env.BOOL_URL ?? 'http://localhost:3030' +const IRC_HOST = process.env.BOOL_IRC_HOST ?? 'irc' // service name inside the compose network +const die = (msg) => { console.error(`SMOKE FAIL: ${msg}`); process.exit(1) } +const step = (msg) => console.log(`✓ ${msg}`) + +// Verified against packages/shared/src/protocol.ts — no bundler here, so this is +// hardcoded to match. Bump this if PROTOCOL_VERSION changes. +const CLIENT_VERSION = 7 + +async function waitFor(fn, what, ms = 60_000) { + const t0 = Date.now() + while (Date.now() - t0 < ms) { + try { const v = await fn(); if (v) return v } catch {} + await new Promise((r) => setTimeout(r, 500)) + } + die(`timeout waiting for ${what}`) +} + +// -- 1. server up -- +await waitFor(async () => (await fetch(BASE)).ok, 'server http') +step('server is up') + +// -- 2. first-user signup (better-auth email+password endpoint) -- +// Verified against packages/client/src/SetupWizard.tsx (authClient.signUp.email +// posts { email, password, name, username } to POST /api/auth/sign-up/email, +// which packages/server/src/auth-routes.ts catches via the /api/auth/* proxy +// route). The very first account bootstraps as admin with no invite code +// required (packages/server/src/auth.ts: userCount() === 0 short-circuits the +// invite gate). +const email = `smoke-${Date.now()}@ci.local` +const res = await fetch(`${BASE}/api/auth/sign-up/email`, { + method: 'POST', + // better-auth enforces a CSRF Origin check against BETTER_AUTH_URL / trusted + // origins; a browser sends Origin automatically, so mirror that here (BASE === + // BETTER_AUTH_URL === http://localhost:3030 in the CI compose stack). + headers: { 'content-type': 'application/json', origin: BASE }, + body: JSON.stringify({ email, password: 'smoke-password-1', name: 'smoke', username: `smoke${Date.now() % 1e6}` }), +}) +if (!res.ok) die(`signup ${res.status}: ${await res.text()}`) +const cookie = res.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ') +if (!cookie) die('no session cookie from signup') +step('signed up + got session cookie') + +// -- 3. WS session helper -- +function connectWs() { + return new Promise((resolve, reject) => { + const sock = new WebSocket(`${BASE.replace('http', 'ws')}/ws`, { headers: { cookie } }) + const inbox = [] + sock.on('message', (d) => { + const msg = JSON.parse(d.toString()) + inbox.push(msg) + }) + sock.on('open', () => { + sock.send(JSON.stringify({ type: 'hello', clientVersion: CLIENT_VERSION })) + resolve({ + sock, + inbox, + send: (m) => sock.send(JSON.stringify(m)), + expect: (pred, what, ms = 30_000) => waitFor(() => inbox.find(pred), what, ms), + }) + }) + sock.on('error', reject) + }) +} + +let ws = await connectWs() +// The server attaches its WS message listener only AFTER an async getSession(), +// and it pushes an initial net:list snapshot in that same synchronous tick right +// before attaching. Waiting for that snapshot proves the listener is live, so the +// net:add below can't race ahead of it and get dropped. +await ws.expect((m) => m.type === 'net:list', 'initial net:list snapshot') +step('ws connected') + +// -- 4. add + connect the CI IRC network -- +ws.send({ type: 'net:add', name: 'ci', host: IRC_HOST, port: 6667, tls: false, nick: 'smokebot' }) +const netList = await ws.expect((m) => m.type === 'net:list' && m.networks.length > 0, 'net:list with network') +const nid = netList.networks[0].id +ws.send({ type: 'net:connect', networkId: nid }) +await ws.expect((m) => m.type === 'net:state' && m.state === 'registered', 'IRC registered') +step('registered on IRC') + +// -- 5. join + message + echo -- +ws.send({ type: 'chan:join', networkId: nid, channel: '#smoke' }) +await ws.expect((m) => m.type === 'chan:join' && m.channel === '#smoke' && m.self, 'self join') +ws.send({ type: 'chat:send', networkId: nid, target: '#smoke', text: 'hello from ci' }) +await ws.expect((m) => m.type === 'chat:msg' && m.text === 'hello from ci', 'message echo') +step('joined #smoke and message echoed') + +// -- 6. restart the app container (the audit's killer test) -- +ws.sock.close() +execSync('docker compose -f docker-compose.ci.yml restart bool', { stdio: 'inherit' }) +await waitFor(async () => (await fetch(BASE)).ok, 'server back up') +step('bool restarted') + +// -- 7. the world must come back -- +ws = await connectWs() +await ws.expect((m) => m.type === 'net:list', 'net:list snapshot after restart') // listener live + +// The server's boot resume (resumeAll) reconnects IRC BEFORE this fresh WS +// subscribes, so the one-shot `net:state: registered` push has already fired and +// is gone — it is never replayed on subscribe. The durable signal is net:list's +// `connected` flag (true only after IRC 'registered'), so poll for that instead. +await waitFor(async () => { + ws.send({ type: 'net:list' }) + await new Promise((r) => setTimeout(r, 500)) + return ws.inbox.some((m) => m.type === 'net:list' && m.networks.some((n) => n.connected)) +}, 'IRC resumed + registered after restart', 90_000) + +await ws.expect((m) => m.type === 'session:targets' && m.targets.some((t) => t.target === '#smoke'), 'session snapshot lists #smoke') +ws.send({ type: 'history:request', networkId: nid, target: '#smoke' }) +await ws.expect((m) => m.type === 'history:batch' && m.messages.some((x) => x.body === 'hello from ci'), 'history survived restart') +step('world intact after restart — session resumed, channel listed, history preserved') + +console.log('SMOKE PASS') +process.exit(0)