diff --git a/.scratch/ops-console-restyle/PHASE3-BRIEF.md b/.scratch/ops-console-restyle/PHASE3-BRIEF.md new file mode 100644 index 00000000..d43d530b --- /dev/null +++ b/.scratch/ops-console-restyle/PHASE3-BRIEF.md @@ -0,0 +1,175 @@ +# Phase 3 brief: converting screens to the Ops Console identity + +Read `docs/design/DESIGN.md` (the normative identity) and `RESTYLE-AUDIT.md` +section 5 (agent-authorship inventory) before touching code. The theme lives in +`apps/web/src/styles.css`; the styled primitives live in +`apps/web/src/components/ui/`. The `/design-system` page +(`apps/web/src/components/design-system.ts`) shows how every primitive is +meant to be used; read it for idioms. + +## What you are doing + +Restyling screens. Behaviour, routes, data flow, messages, models, copy and +information architecture do not change. Only class strings, wrapper markup +needed for styling, and the primitives a view calls change. Do not rename +features, do not add screens, do not refactor state. + +Button verbs are the one copy exception: labels are sentence-case verbs naming +the outcome ("New rule", "Run all as a test", "Disconnect repository"). Fix a +label only if it violates that. + +## Hard rules (the report will grep for violations) + +1. No hex, `rgb()`, `hsl()`, `oklch()`, or named colours anywhere in `.ts`. + The only exception is GitHub label colours, which are user data and are + passed as a style value from the label record (see rule 8). +2. No Tailwind arbitrary values: `text-[11px]`, `w-[200px]`, `bg-[#...]`, + `shadow-[...]`, `grid-cols-[...]`, `translate-[...]` are all failures. Use + the scale. If you genuinely need a dimension that has no utility, put it in + `styles.css` under a `data-slot` selector in the `@layer components` block, + not in the class string. +3. No stock Tailwind palette colours (`emerald-*`, `amber-*`, `black/40`, + `gray-*`). Use the semantic tokens below. +4. No shadows except on overlays (`shadow-overlay`). No `shadow-md`, + `shadow-lg`, `shadow-xl`, `shadow-edge`, `shadow-mount`. No gradients. No + `animate-pulse`, `animate-in`, hover lifts, `rotate-*` on layout. + `animate-spin` on an in-progress icon is acceptable because the user + triggered the action. +5. No 2px or 3px borders except: the 2px `primary` left edge on a selected + row or active nav item, the 3px `agent` left edge (`oc-agent-edge`), the + 2px `primary` bottom edge on an active tab. +6. Delete the old alias utilities from the files you own: `bg-cobalt*`, + `text-cobalt*`, `border-cobalt*`, `navy*`, `cream*`, `rust*`, + `yellow-safety*`, `outline` (as a colour), `metal*`, `wall`, `mortar`, + `board`, `panel`, `inset`, `shadow-edge`, `shadow-mount`, `shadow-recess`, + `font-sign`, `text-sign`, `text-stamp`, `text-display`, `text-button`, + `rounded-lg`, `rounded-xl`, `jn-*`. Replace them with target tokens. +7. Blue means interactive only: links, buttons, focus, selected rows, active + tabs, switch on-state. Never a heading, an icon on static content, a status + pill or a decorative fill. +8. Yellow means The Janitor did it, and nothing else. Use `oc-agent-dot`, + `oc-agent-edge`, `oc-agent-badge`, `Feed.agentBadge`, `Feed.item` with + `actor: { kind: "agent" }`, `chip` variant `agent`, `Blueprint.node` + `isAgent`. Always pair the mark with the words "The Janitor" or "AI" so the + information survives greyscale. Never use yellow for warnings. There is no + warning colour. A warning becomes neutral text with a Lucide icon; a real + failure is `text-destructive`; a genuine success is `text-success`. +9. Mono is a signal: values from GitHub or the machine are `font-mono`: + repository names, branches, SHAs, issue and PR numbers, label names, rule + expressions, run and test IDs, event names, durations, counts, timestamps. + Prose a human wrote is Inter. Numeric table columns are right-aligned with + `tabular-nums` (the `Table.cell` helper's `numeric` option does this). +10. Density: table rows 28px (`Table.*` helpers), list rows 26 to 28px, base + text 13px (`text-body-md`), secondary 12px (`text-body-sm`), column heads + and eyebrow labels 11px (`text-caption` in `text-ink-subtle`). No + uppercase tracked labels. Do not loosen this to "improve legibility". +11. Cards do not nest. A card is `panel` (`ui/panel.ts`); its optional header + is `panelHeader`. Regions are separated by `border-border`; rows inside a + region by `border-border-subtle`. No zebra striping. +12. Empty states: one sentence naming the next action plus at most one button, + inside `emptyPanel`. No icons or illustrations inside working panels. + Errors say what happened and what to do, never apologize, and carry + `role="alert"` with `text-destructive` on the title only. +13. Loading states are text ("Loading sessions…") or static `skeleton` + blocks. Never a shimmer. +14. Keep every ARIA attribute, `role`, `aria-current`, `aria-selected`, + `aria-busy`, `sr-only` text and focus behaviour that exists today. Colour + is never the only carrier of state. +15. Icons come from `Icon.view(h, LucideIcon)` at the default size; do not + pass `size-4` or larger unless it is the single icon in an empty state. +16. `text-ink-faint` is never used for text. + +## Token cheat sheet + +Backgrounds: `bg-background` (page canvas), `bg-card` (surfaces), +`bg-surface-muted` (card headers, input fills, row hover), `bg-primary-wash` +(selected row, active nav), `bg-agent-wash` (agent annotation only), +`bg-popover` (overlays). + +Text: `text-foreground`, `text-ink-muted` (secondary prose, help text), +`text-ink-subtle` (column heads, timestamps, metadata; alias +`text-muted-foreground` is the same value), `text-primary` (links only), +`text-agent-ink` (agent actor names only), `text-destructive`, +`text-success`. + +Borders: `border-border` (structure), `border-border-subtle` (rows), +`border-primary-line` (hovered node), `border-agent-line` (agent annotation), +`border-destructive`. + +Sizes: `text-h1` `text-h2` `text-h3` `text-body-md` `text-body-sm` +`text-label` `text-caption` `text-mono-md` `text-mono-sm` `text-mono-xs` +`text-numeral`. Radii: `rounded-xs` (badges, nodes), `rounded-sm` +(everything structural), `rounded-md` (sheets only), `rounded-full` (dots, +switch, junction chips). Motion: `transition-colors duration-120 ease-ui`. +Layout tokens: `w-sidebar` (216px), `w-inspector` (292px), `h-app-bar` (46px). + +Primitives: `Button.view` (variants `default` = blue primary, `secondary` = +plain surface, `destructive`, `ghost`, `link`; sizes `xs` `sm` default `lg` +`icon*`), `input`, `textarea`, `Select.view`, `Switch.view`, `chip` +(variants `neutral` `selected` `success` `danger` `agent`), `panel`, +`panelHeader`, `emptyPanel`, `sign` (section h2), `rack` (section nav), +`Table.*`, `Feed.item`, `Feed.agentBadge`, `Inspector.*`, `Dialog.view` +(modal chrome) with `@foldkit/ui/dialog`, `Overlay.*` class strings for +menus, popovers, tooltips and toasts, `Blueprint.*` (graph views only: +rule editor, nowhere else), `skeleton`. + +## GitHub label colours + +The product owner keeps GitHub's label colours, muted. Render a label as a +`chip` whose first child is a 6px dot carrying the colour: + +```ts +h.span( + [h.Class("size-1.5 rounded-full"), h.Style({ backgroundColor: `#${color}` }), h.AriaHidden(true)], + [], +) +``` + +Keep the existing hex validation guard before interpolating. Never fill the +whole badge with the colour and never colour the text. + +## Page CSS + +`styles.css` still carries `.policy-*`, `.rule-*`, `.rules-*`, `.activity-*`, +`.ai-*` and `.repository-*` rules from the old identity below the line +"Page CSS carried over from the previous identity". When you restyle a screen, +prefer moving its styling into Tailwind utilities on the elements, then delete +the CSS rules that are no longer referenced. You may edit only the CSS block +for the screens you own, with small targeted edits, because other agents are +editing other blocks of the same file at the same time. Read the file +immediately before each edit. Layout rules (grids, breakpoints, scroll +containers, sticky panes) may stay in CSS if that is simpler, but their +values must use tokens: `var(--border)`, `var(--oc-surface-muted)`, the +`--text-*` sizes, `var(--radius-sm)`, `var(--spacing-inspector)`. Breakpoints +are 1240px (inspector collapses) and 820px (sidebar becomes a drawer). + +## Verification before you report + +- `cd apps/web && pnpm exec vp build` succeeds. +- From the repo root, `pnpm exec vp check --fix` then `pnpm exec vp check` + reports 0 errors. +- `pnpm exec vp test apps/web` passes. If a test asserted on a class name or + markup that legitimately changed, update the test and say so. +- Grep your files for the strings in rules 1 to 6 and report zero hits, or + list each remaining hit with a reason. +- Screenshot each screen you converted at 1440px and 390px in light and dark + and look at them. A dev server is on http://localhost:1337 backed by a mock + API (routes: `/repositories/701` and its `/policies`, `/policies/p1`, + `/policies/p2`, `/policies/p3`, `/rules`, `/rules/new`, `/rules/r1`, + `/rules/r3` (AI rule), `/activity`, `/settings`; `/sessions`, + `/sessions/ses-working`, `/sessions/ses-blocked`; `/account`, + `/account/you`, `/account/team`; `/repositories/connect`; `/`). Capture with: + + ``` + SHOT_TMP=/home/maxwellbrown/.t3/userdata/attachments/ops-console/tmp \ + node .scratch/ops-console-restyle/shot.mjs http://localhost:1337/ \ + /home/maxwellbrown/.t3/userdata/attachments/ops-console/shots/.png \ + [--width 390] [--dark] [--eval "window.scrollTo(0,800)" --settle 500] + ``` + + Write screenshots only under that attachments directory, never under + `/tmp` (the root filesystem is nearly full). + +Report: the files you changed, every CSS rule you deleted, every test you +touched, every place you marked agent authorship, anything from DESIGN.md you +could not satisfy and why, and the screenshot paths. diff --git a/.scratch/ops-console-restyle/mock-api.mjs b/.scratch/ops-console-restyle/mock-api.mjs new file mode 100644 index 00000000..b2685d41 --- /dev/null +++ b/.scratch/ops-console-restyle/mock-api.mjs @@ -0,0 +1,1058 @@ +// Stand-in for the Janitor API on http://localhost:8787 so the web app +// renders every screen without Cloudflare, Postgres, or GitHub. +// Run: node .scratch/ops-console-restyle/mock-api.mjs +// Then: JANITOR_API_ORIGIN=http://localhost:8787 vp dev (from apps/web). +import { createServer } from "node:http" +import { createHash } from "node:crypto" + +const PORT = Number(process.env.MOCK_PORT ?? 8787) +const now = "2026-09-13T10:00:00.000Z" + +const repositories = [ + { + repositoryId: "701", + owner: "effect", + repo: "effect", + enabled: true, + syncEnabled: true, + ruleCount: 4, + policyCount: 3, + access: "accessible", + configuredRevision: 7, + activeRevision: 7, + }, + { + repositoryId: "702", + owner: "effect", + repo: "effect-smol", + enabled: true, + ruleCount: 1, + policyCount: 1, + access: "accessible", + configuredRevision: 3, + activeRevision: 2, + }, + { + repositoryId: "801", + owner: "acme", + repo: "widgets", + enabled: false, + syncEnabled: false, + ruleCount: 0, + policyCount: 0, + access: "suspect", + configuredRevision: null, + activeRevision: null, + }, +] + +const textOps = ["equals", "notEquals", "contains", "matchesGlob", "in", "isEmpty", "notEmpty"] +const catalog = [ + { + name: "title", + type: "Text", + kinds: ["issue", "pull_request"], + track: "entities", + description: "Title", + operators: textOps, + fields: [], + }, + { + name: "body", + type: "Text", + kinds: ["issue", "pull_request"], + track: "entities", + description: "Body", + operators: textOps, + fields: [], + }, + { + name: "author", + type: "Text", + kinds: ["issue", "pull_request"], + track: "entities", + description: "Author login", + operators: textOps, + fields: [], + }, + { + name: "state", + type: "Text", + kinds: ["issue", "pull_request"], + track: "entities", + description: "open or closed", + operators: textOps, + fields: [], + }, + { + name: "labels", + type: "LabelSet", + kinds: ["issue", "pull_request"], + track: "labels", + description: "Labels currently applied", + operators: ["has", "isEmpty", "notEmpty"], + fields: [], + }, + { + name: "draft", + type: "Flag", + kinds: ["pull_request"], + track: "pull_requests", + description: "Whether the pull request is a draft", + operators: ["is"], + fields: [], + }, + { + name: "baseRef", + type: "Text", + kinds: ["pull_request"], + track: "pull_requests", + description: "Base branch name", + operators: textOps, + fields: [], + }, + { + name: "changedFiles", + type: "Collection", + kinds: ["pull_request"], + track: "changed_files", + description: "Files changed", + operators: ["some", "every", "none"], + fields: [ + { name: "path", type: "Text", operators: textOps }, + { name: "status", type: "Text", operators: textOps }, + ], + }, +] + +const policies = [ + { + policyId: "p1", + repositoryId: "701", + name: "Base is main", + target: "pull_request", + description: "Pull requests that target the default branch.", + publishedVersionId: "v1", + publishedRevision: 6, + draftDiffers: false, + publishedEvaluator: "Conditions", + version: 2, + createdAt: "2026-09-01T09:00:00.000Z", + updatedAt: "2026-09-03T14:00:00.000Z", + }, + { + policyId: "p2", + repositoryId: "701", + name: "Needs triage", + target: "issue", + description: "Issues with no labels yet.", + publishedVersionId: null, + publishedRevision: null, + draftDiffers: true, + version: 3, + createdAt: "2026-09-02T10:30:00.000Z", + updatedAt: "2026-09-09T08:15:00.000Z", + }, + { + policyId: "p3", + repositoryId: "701", + name: "Looks like a bug report", + target: "issue", + description: "Classifier over the title and body.", + publishedVersionId: "v3", + publishedRevision: 7, + draftDiffers: false, + publishedEvaluator: "Classifier", + version: 1, + createdAt: "2026-09-05T11:00:00.000Z", + updatedAt: "2026-09-05T11:00:00.000Z", + }, +] + +const rules = [ + { + id: "r1", + repositoryId: "701", + labelId: "11", + policyId: "p1", + onMatch: "ensure-present", + onNoMatch: "ensure-absent", + group: null, + priority: 0, + enabled: true, + labelStatus: "valid", + version: 1, + createdAt: "2026-09-01T09:05:00.000Z", + updatedAt: "2026-09-03T14:00:00.000Z", + }, + { + id: "r2", + repositoryId: "701", + labelId: "12", + policyId: "p2", + onMatch: "ensure-present", + onNoMatch: "no-action", + group: "triage", + priority: 3, + enabled: true, + labelStatus: "valid", + version: 2, + createdAt: "2026-09-02T10:35:00.000Z", + updatedAt: "2026-09-09T08:20:00.000Z", + }, + { + id: "r3", + repositoryId: "701", + labelId: "13", + policyId: "p3", + ai: { + gatePolicyId: "p2", + target: "issue", + prompt: "Does {{fact:title}} together with {{fact:body}} describe a reproducible defect?", + minimumConfidence: 0.8, + }, + onMatch: "ensure-present", + onNoMatch: "no-action", + group: "triage", + priority: 7, + enabled: true, + labelStatus: "valid", + version: 1, + createdAt: "2026-09-05T11:05:00.000Z", + updatedAt: "2026-09-05T11:05:00.000Z", + }, + { + id: "r4", + repositoryId: "701", + labelId: "14", + policyId: "p1", + onMatch: "ensure-absent", + onNoMatch: "no-action", + group: null, + priority: 12, + enabled: false, + labelStatus: "missing", + version: 4, + createdAt: "2026-08-20T12:00:00.000Z", + updatedAt: "2026-09-06T16:45:00.000Z", + }, +] + +const labels = [ + { labelId: "11", name: "bug", color: "d73a4a", availability: "available" }, + { labelId: "12", name: "needs-triage", color: "fbca04", availability: "available" }, + { labelId: "13", name: "ai-suggested", color: "0e8a16", availability: "available" }, + { labelId: "14", name: "stale", color: null, availability: "unavailable" }, +] + +const configuration = (repositoryId) => + repositoryId === "701" + ? { + repositoryId, + configuredRevision: 7, + activeRevision: 7, + pendingTracks: [], + policies, + rules, + labels, + labelFreshness: "verified", + } + : { + repositoryId, + configuredRevision: 3, + activeRevision: 2, + pendingTracks: ["labels"], + policies: policies.slice(0, 1).map((p) => ({ ...p, repositoryId })), + rules: rules.slice(0, 1).map((r) => ({ ...r, repositoryId })), + labels: labels.slice(0, 1), + labelFreshness: "syncing", + } + +const testItems = [ + { + number: 7, + kind: "issue", + title: "Crash on save", + authorLogin: "octocat", + baseRef: null, + draft: null, + labels: [], + evaluation: null, + plan: null, + }, + { + number: 5, + kind: "pull_request", + title: "Fix interruption on save", + authorLogin: "octocat", + baseRef: "main", + draft: false, + labels: ["11"], + evaluation: null, + plan: null, + }, + { + number: 6, + kind: "pull_request", + title: "Add a feature", + authorLogin: "gcanti", + baseRef: "next", + draft: true, + labels: [], + evaluation: null, + plan: null, + }, +] + +const conditionsSource = { + target: "pull_request", + appliesWhen: { fact: "draft", operator: "is", value: false }, + matchesWhen: { + all: [ + { fact: "baseRef", operator: "equals", value: "main" }, + { not: { fact: "title", operator: "contains", value: "WIP" } }, + ], + }, +} +const manifest = { + facts: ["baseRef", "draft", "title"], + tracks: ["entities", "pull_requests"], + references: [], + nodeCount: 4, + expandedNodeCount: 4, +} +const policyDetail = (policyId) => { + const policy = policies.find((p) => p.policyId === policyId) ?? policies[0] + if (policy.policyId === "p3") + return { + policy, + draft: { + target: "issue", + classify: { + prompt: "Does this issue describe a reproducible defect?", + evidence: ["title", "body"], + minimumConfidence: 0.8, + }, + }, + draftDiffers: false, + published: { + versionId: "v3", + policyId: "p3", + revision: 7, + contentHash: "c".repeat(64), + program: { + target: "issue", + appliesWhen: null, + evaluator: { + _tag: "Classifier", + prompt: "Does this issue describe a reproducible defect?", + evidence: ["title", "body"], + minimumConfidence: 0.8, + }, + }, + manifest: { + facts: ["body", "title"], + tracks: ["entities"], + references: [], + nodeCount: 1, + expandedNodeCount: 1, + }, + createdAt: "2026-09-05T11:00:00.000Z", + }, + } + if (policy.policyId === "p2") + return { + policy, + draft: { target: "issue", matchesWhen: { fact: "labels", operator: "isEmpty" } }, + draftDiffers: true, + published: null, + } + return { + policy, + draft: conditionsSource, + draftDiffers: false, + publishedSource: conditionsSource, + published: { + versionId: "v1", + policyId: "p1", + revision: 6, + contentHash: "a".repeat(64), + program: { + target: "pull_request", + appliesWhen: { _tag: "Fact", fact: "draft", operator: "is", value: false }, + evaluator: { + _tag: "Conditions", + matchesWhen: { + _tag: "All", + conditions: [ + { _tag: "Fact", fact: "baseRef", operator: "equals", value: "main" }, + { + _tag: "Not", + condition: { _tag: "Fact", fact: "title", operator: "contains", value: "WIP" }, + }, + ], + }, + }, + }, + manifest, + createdAt: "2026-09-03T14:00:00.000Z", + }, + } +} + +const evaluated = { + _tag: "Evaluated", + entities: [ + { + ...testItems[1], + evaluation: { + outcome: "match", + reason: "baseRef equals main", + trace: [ + { + location: { root: "matchesWhen", path: [{ _tag: "Child", index: 0 }] }, + outcome: "match", + reason: "baseRef equals main", + }, + { + location: { root: "matchesWhen", path: [{ _tag: "Child", index: 1 }, { _tag: "Not" }] }, + outcome: "no-match", + reason: "title does not contain WIP", + }, + ], + }, + plan: { + rules: [ + { ruleId: "r1", outcome: "match", selected: true, requestedAction: "ensure-present" }, + ], + actions: [{ labelId: "11", action: "add", ruleId: "r1" }], + }, + }, + { + ...testItems[2], + evaluation: { outcome: "no-match", reason: "baseRef equals next", trace: [] }, + plan: { + rules: [ + { ruleId: "r1", outcome: "no-match", selected: false, requestedAction: "ensure-absent" }, + ], + actions: [], + }, + }, + ], +} + +const aiJob = (testId, status) => ({ + testId, + status, + message: status === "queued" ? "Queued behind 1 test" : null, + response: + status === "done" + ? { + _tag: "Evaluated", + entities: [ + { + ...testItems[0], + evaluation: { + outcome: "match", + reason: "The issue describes a reproducible defect with steps.", + confidence: 0.91, + cached: false, + trace: [], + inputReport: { + version: 1, + budgetBytes: 32768, + originalBytes: 41000, + suppliedBytes: 32768, + status: "shortened", + facts: [ + { name: "title", originalBytes: 30, suppliedBytes: 30, omission: null }, + { + name: "body", + originalBytes: 40970, + suppliedBytes: 32738, + omission: { start: 32738, end: 40970, unit: "characters" }, + }, + ], + }, + }, + plan: { + rules: [ + { + ruleId: "r3", + outcome: "match", + selected: true, + requestedAction: "ensure-present", + }, + ], + actions: [{ labelId: "13", action: "add", ruleId: "r3" }], + }, + }, + ], + } + : null, +}) +const jobs = new Map() + +const activity = { + entries: [ + { + id: "a1", + number: 42, + title: "Fix interruption on save", + kind: "pull_request", + createdAt: "2026-09-12T09:00:00.000Z", + outcome: "evaluated", + detail: "1 change applied", + revision: 7, + plan: { + rules: [ + { ruleId: "r1", outcome: "match", selected: true, requestedAction: "ensure-present" }, + ], + actions: [{ labelId: "11", action: "add", ruleId: "r1" }], + }, + actions: [ + { + labelId: "11", + name: "bug", + color: "d73a4a", + ruleId: "r1", + action: "add", + status: "applied", + detail: null, + }, + ], + evaluations: [{ ruleId: "r1", outcome: "match", reason: "baseRef equals main" }], + }, + { + id: "a2", + number: 42, + title: "Fix interruption on save", + kind: "pull_request", + createdAt: "2026-09-12T08:58:00.000Z", + outcome: "superseded", + detail: "A newer snapshot arrived before this run finished", + revision: 7, + plan: null, + actions: [], + evaluations: [], + }, + { + id: "a3", + number: 43, + title: "Crash on save", + kind: "issue", + createdAt: "2026-09-12T08:40:00.000Z", + outcome: "evaluated", + detail: "1 change failed", + revision: 7, + plan: { + rules: [ + { ruleId: "r3", outcome: "match", selected: true, requestedAction: "ensure-present" }, + { ruleId: "r2", outcome: "no-match", selected: false, requestedAction: "no-action" }, + ], + actions: [{ labelId: "13", action: "add", ruleId: "r3" }], + }, + actions: [ + { + labelId: "13", + name: "ai-suggested", + color: "0e8a16", + ruleId: "r3", + action: "add", + status: "failed", + detail: "GitHub rejected the label update (403)", + }, + { + labelId: "14", + name: "stale", + color: null, + ruleId: "r4", + action: "remove", + status: "applied", + detail: null, + }, + ], + evaluations: [ + { + ruleId: "r3", + outcome: "match", + reason: "The issue reports a crash with reproduction steps (confidence 0.91).", + }, + { ruleId: "r2", outcome: "no-match", reason: "labels is not empty" }, + ], + }, + { + id: "a4", + number: 44, + title: "Update the README", + kind: "pull_request", + createdAt: "2026-09-12T08:05:00.000Z", + outcome: "not-qualified", + detail: "No rule applies to this pull request", + revision: 7, + plan: null, + actions: [], + evaluations: [ + { ruleId: "r1", outcome: "unknown", reason: "baseRef was not in the snapshot" }, + ], + }, + { + id: "a5", + number: 45, + title: "Flaky integration test", + kind: "issue", + createdAt: "2026-09-12T07:30:00.000Z", + outcome: "failed", + detail: "The snapshot could not be read", + revision: 6, + plan: null, + actions: [], + evaluations: [ + { ruleId: "r3", outcome: "failed", reason: "The model provider was unavailable" }, + ], + }, + { + id: "a6", + number: 46, + title: null, + kind: null, + createdAt: "2026-09-11T19:12:00.000Z", + outcome: "evaluated", + detail: null, + revision: 6, + plan: { + rules: [{ ruleId: "r2", outcome: "match", selected: true }], + actions: [{ labelId: "12", action: "add", ruleId: "r2" }], + }, + actions: [ + { + labelId: "12", + name: "needs-triage", + color: "fbca04", + ruleId: "r2", + action: "add", + status: "planned", + detail: null, + }, + ], + }, + ], + cursor: null, +} + +const sessions = [ + { + sessionId: "ses-working", + title: "Fix the flaky test", + repository: { repositoryId: "701", owner: "effect", repo: "effect" }, + homeThread: { platform: "slack", url: "https://app.slack.com/archives/C1/p1" }, + pullRequests: [{ number: 17, url: "https://github.com/effect/effect/pull/17" }], + execution: "working", + reason: "input pending", + activityAt: now, + usage: { input: 12000, output: 500 }, + deliveryWarning: null, + freshness: { readAt: now, error: null }, + }, + { + sessionId: "ses-idle", + title: "Review the release notes", + repository: { repositoryId: "702", owner: "effect", repo: "effect-smol" }, + homeThread: null, + pullRequests: [], + execution: "idle", + reason: null, + activityAt: "2026-09-13T08:12:00.000Z", + usage: { input: 3400, output: 220 }, + deliveryWarning: null, + freshness: { readAt: "2026-09-13T09:58:00.000Z", error: null }, + }, + { + sessionId: "ses-blocked", + title: "Slack conversation", + repository: null, + homeThread: { platform: "slack", url: "https://app.slack.com/archives/C2/p2" }, + pullRequests: [], + execution: "blocked", + reason: "Waiting for repository selection", + activityAt: "2026-09-12T21:40:00.000Z", + usage: null, + deliveryWarning: "Janitor is no longer in the channel", + freshness: { readAt: null, error: "Runner did not answer the last read" }, + }, +] +const sessionDetail = (sessionId) => ({ + ...(sessions.find((s) => s.sessionId === sessionId) ?? { + ...sessions[0], + sessionId, + execution: "failed", + reason: "provider down", + deliveryWarning: "Janitor is no longer in the channel", + }), + pendingInputs: 1, + acceptedInputs: 3, + lastInputAt: "2026-09-13T09:58:00.000Z", + latestError: sessionId === "ses-blocked" ? "Runner did not answer the last read" : null, + pendingDelivery: + sessionId === "ses-blocked" + ? [{ platform: "slack", state: "pending", error: "not_in_channel" }] + : [], + recovery: [ + { + platform: "github", + completedAt: "2026-09-13T09:30:00.000Z", + overdue: sessionId === "ses-blocked", + incomplete: sessionId === "ses-blocked", + hydrating: sessionId === "ses-blocked" ? 2 : 0, + warning: sessionId === "ses-blocked" ? "GitHub asked us to slow down" : null, + gap: "Only retained GitHub deliveries can be recovered", + }, + { + platform: "slack", + completedAt: "2026-09-13T09:55:00.000Z", + overdue: false, + incomplete: false, + hydrating: 0, + warning: null, + gap: "Deleted uncaptured text cannot be recovered", + }, + ], +}) + +const teammate = { + teammateId: "t-me", + issuer: "https://team.cloudflareaccess.test", + subject: "me", + email: "maxwell.brown@example.com", + role: "admin", + status: "active", + createdAt: "2026-08-01T00:00:00.000Z", + removedAt: null, +} +const account = { + teammate, + links: [ + { + linkId: "l-slack", + platform: "slack", + workspaceId: "T1", + accountId: "U1", + displayName: "Max", + status: "active", + linkedAt: "2026-08-02T00:00:00.000Z", + endedAt: null, + }, + { + linkId: "l-github", + platform: "github", + workspaceId: "github.com", + accountId: "1024", + displayName: "maxwellbrown", + status: "active", + linkedAt: "2026-08-02T00:05:00.000Z", + endedAt: null, + }, + ], + linking: { slack: true, github: true }, + team: [ + { + ...teammate, + links: [ + { + linkId: "l-slack", + platform: "slack", + workspaceId: "T1", + accountId: "U1", + displayName: "Max", + status: "active", + linkedAt: "2026-08-02T00:00:00.000Z", + endedAt: null, + }, + ], + }, + { + teammateId: "t-other", + issuer: "https://team.cloudflareaccess.test", + subject: "other", + email: "dana@example.com", + role: "member", + status: "active", + createdAt: "2026-08-04T00:00:00.000Z", + removedAt: null, + links: [ + { + linkId: "l-slack-2", + platform: "slack", + workspaceId: "T1", + accountId: "U2", + displayName: "Dana", + status: "active", + linkedAt: "2026-08-04T09:00:00.000Z", + endedAt: null, + }, + ], + }, + { + teammateId: "t-gone", + issuer: "https://team.cloudflareaccess.test", + subject: "gone", + email: null, + role: "member", + status: "removed", + createdAt: "2026-07-10T00:00:00.000Z", + removedAt: "2026-09-01T00:00:00.000Z", + links: [ + { + linkId: "l-slack-3", + platform: "slack", + workspaceId: "T1", + accountId: "U3", + displayName: "Sam", + status: "disabled", + linkedAt: "2026-07-10T00:00:00.000Z", + endedAt: "2026-09-01T00:00:00.000Z", + }, + ], + }, + ], +} + +const inventory = { + repositories: [ + { + repositoryId: "701", + installationId: "77", + owner: "effect", + repo: "effect", + isPrivate: false, + connected: true, + enabled: true, + reconnect: false, + access: "accessible", + installationStatus: "active", + policyCount: 3, + ruleCount: 4, + sessionCount: 1, + pendingCleanups: 0, + syncState: "ready", + }, + { + repositoryId: "702", + installationId: "77", + owner: "effect", + repo: "effect-smol", + isPrivate: true, + connected: true, + enabled: false, + reconnect: false, + access: "accessible", + installationStatus: "active", + policyCount: 1, + ruleCount: 1, + sessionCount: 0, + pendingCleanups: 1, + syncState: "failed", + syncError: "GitHub timeout", + }, + { + repositoryId: "801", + installationId: "77", + owner: "acme", + repo: "widgets", + isPrivate: null, + connected: false, + enabled: false, + reconnect: false, + access: "suspect", + accessError: "The app cannot read this repository", + installationStatus: "active", + policyCount: 0, + ruleCount: 0, + sessionCount: 0, + pendingCleanups: 0, + syncState: "idle", + }, + ], +} + +const syncSummary = (state) => + state === "syncing" + ? { + state, + lastVerifiedAt: "2026-09-13T09:55:00.000Z", + pendingTargets: 3, + blockedTargets: 0, + failedTargets: 1, + queuedTargets: 1, + runningTargets: 2, + retryingTargets: 0, + stalledTargets: 0, + appliedItems: 100, + } + : { + state: "idle", + lastVerifiedAt: "2026-09-13T09:55:00.000Z", + pendingTargets: 0, + blockedTargets: 0, + failedTargets: 0, + } +let syncState = process.env.MOCK_SYNC ?? "idle" + +const json = (res, status, body) => { + res.writeHead(status, { "content-type": "application/json" }) + res.end(body === undefined ? "" : JSON.stringify(body)) +} + +const readBody = (req) => + new Promise((resolve) => { + let data = "" + req.on("data", (chunk) => (data += chunk)) + req.on("end", () => resolve(data)) + }) + +const server = createServer(async (req, res) => { + const url = new URL(req.url ?? "/", "http://localhost") + const path = url.pathname.replace(/^\/api\/v1/, "") + const method = req.method ?? "GET" + const body = await readBody(req) + const m = (pattern) => path.match(pattern) + let x + if (method === "GET" && path === "/repositories") return json(res, 200, repositories) + if (method === "GET" && path === "/labeling/catalog") return json(res, 200, catalog) + if (method === "GET" && path === "/sync") return json(res, 200, syncSummary(syncState)) + if (method === "POST" && (path === "/sync" || m(/^\/repositories\/[^/]+\/sync$/))) { + syncState = "syncing" + setTimeout(() => (syncState = "idle"), 8000) + return json(res, 202, syncSummary("syncing")) + } + if (method === "GET" && (x = m(/^\/repositories\/([^/]+)\/configuration$/))) + return json(res, 200, configuration(x[1])) + if (method === "GET" && m(/^\/repositories\/[^/]+\/test\/items$/)) + return json(res, 200, testItems) + if (method === "GET" && (x = m(/^\/repositories\/([^/]+)\/ai-consent$/))) + return json(res, 200, { + repositoryId: x[1], + state: "enabled", + provider: "openai", + model: "gpt-5.6-luna", + activeLeases: 2, + updatedAt: "2026-09-10T12:00:00.000Z", + }) + if (method === "PUT" && (x = m(/^\/repositories\/([^/]+)\/ai-consent$/))) + return json(res, 200, { + repositoryId: x[1], + state: JSON.parse(body || "{}").enabled ? "enabled" : "draining", + provider: "openai", + model: "gpt-5.6-luna", + activeLeases: 1, + updatedAt: now, + }) + if (method === "GET" && (x = m(/^\/repositories\/[^/]+\/policies\/([^/]+)$/))) + return json(res, 200, policyDetail(x[1])) + if (method === "POST" && m(/^\/repositories\/[^/]+\/policies\/validate$/)) + return json(res, 200, { _tag: "Valid", manifest }) + if (method === "POST" && m(/^\/repositories\/[^/]+\/policies$/)) + return json(res, 201, policyDetail("p2")) + if (method === "PUT" && (x = m(/^\/repositories\/[^/]+\/policies\/([^/]+)$/))) + return json(res, 200, policyDetail(x[1])) + if (method === "POST" && (x = m(/^\/repositories\/[^/]+\/policies\/([^/]+)\/publish$/))) + return json(res, 200, policyDetail(x[1])) + if (method === "DELETE" && m(/^\/repositories\/[^/]+\/policies\/[^/]+$/)) return json(res, 204) + if (method === "POST" && m(/^\/repositories\/[^/]+\/rules$/)) return json(res, 201, rules[0]) + if (method === "PATCH" && (x = m(/^\/repositories\/[^/]+\/rules\/([^/]+)$/))) { + const rule = rules.find((r) => r.id === x[1]) ?? rules[0] + const patch = JSON.parse(body || "{}") + return json(res, 200, { + ...rule, + ...(patch.enabled === undefined ? {} : { enabled: patch.enabled }), + version: rule.version + 1, + }) + } + if (method === "DELETE" && m(/^\/repositories\/[^/]+\/rules\/[^/]+$/)) return json(res, 204) + if (method === "POST" && m(/^\/repositories\/[^/]+\/rules\/reorder$/)) + return json(res, 200, rules) + if (method === "POST" && m(/^\/repositories\/[^/]+\/test$/)) return json(res, 200, evaluated) + if (method === "POST" && m(/^\/repositories\/[^/]+\/rule-tests$/)) { + const testId = `t-${Date.now()}` + jobs.set(testId, Date.now()) + return json(res, 202, aiJob(testId, "queued")) + } + if (method === "GET" && (x = m(/^\/repositories\/[^/]+\/rule-tests\/([^/]+)\/input$/))) + return json(res, 200, { + system: "You answer yes or no about a GitHub issue, with a confidence between 0 and 1.", + text: "Question: Does this issue describe a reproducible defect?\n\ntitle: Crash on save\nbody: Steps: 1. open 2. save 3. crash", + facts: [ + { name: "title", json: '"Crash on save"' }, + { name: "body", json: '"Steps: 1. open 2. save 3. crash"' }, + ], + }) + if (method === "GET" && (x = m(/^\/repositories\/[^/]+\/rule-tests\/([^/]+)$/))) { + const started = jobs.get(x[1]) ?? Date.now() + const age = Date.now() - started + return json(res, 200, aiJob(x[1], age < 1500 ? "queued" : age < 3000 ? "running" : "done")) + } + if (method === "GET" && m(/^\/repositories\/[^/]+\/activity$/)) { + const search = url.searchParams.get("search") ?? "" + const entries = activity.entries.filter( + (e) => search === "" || (e.title ?? "").toLowerCase().includes(search.toLowerCase()), + ) + return json(res, 200, { entries, cursor: null }) + } + if (method === "GET" && (m(/^\/repositories\/[^/]+\/live$/) || path === "/sessions/live")) + return json(res, 204) + if (method === "GET" && path === "/sessions") return json(res, 200, { sessions, cursor: null }) + if (method === "GET" && (x = m(/^\/sessions\/([^/]+)$/))) + return json(res, 200, sessionDetail(x[1])) + if (method === "GET" && path === "/account") return json(res, 200, account) + if (method === "POST" && m(/^\/account\/links\/[^/]+\/start$/)) + return json(res, 200, { url: "https://example.com/oauth" }) + if (method === "DELETE" && m(/^\/account\/links\//)) return json(res, 204) + if (m(/^\/team\//)) return json(res, 204) + if (method === "GET" && path === "/repository-connections/available") + return json(res, 200, inventory) + if (m(/^\/repository-connections\/github$/)) + return json(res, 200, { url: "https://github.com/apps/janitor/installations/new" }) + if (m(/^\/repository-connections\//)) return json(res, 204) + if (m(/^\/repositories\/[^/]+\/connection$/)) return json(res, 204) + return json(res, 404, { message: `No mock for ${method} ${path}` }) +}) + +// Minimal WebSocket: handshake, text frames, Ready on open, pong for ping. +const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" +const frame = (text) => { + const payload = Buffer.from(text) + const header = + payload.length < 126 + ? Buffer.from([0x81, payload.length]) + : Buffer.from([0x81, 126, payload.length >> 8, payload.length & 255]) + return Buffer.concat([header, payload]) +} +server.on("upgrade", (req, socket) => { + const key = req.headers["sec-websocket-key"] + const accept = createHash("sha1") + .update(key + GUID) + .digest("base64") + socket.write( + `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`, + ) + socket.write(frame(JSON.stringify({ _tag: "Ready" }))) + socket.on("data", (data) => { + const opcode = data[0] & 0x0f + if (opcode === 8) return socket.end() + if (opcode !== 1) return + let length = data[1] & 0x7f + let offset = 2 + if (length === 126) { + length = data.readUInt16BE(2) + offset = 4 + } + const mask = data.subarray(offset, offset + 4) + const payload = Buffer.from(data.subarray(offset + 4, offset + 4 + length)).map( + (byte, index) => byte ^ mask[index % 4], + ) + if (payload.toString() === "ping") socket.write(frame("pong")) + }) + socket.on("error", () => {}) +}) + +server.listen(PORT, () => console.log(`mock api on http://localhost:${PORT}`)) diff --git a/.scratch/ops-console-restyle/shot.mjs b/.scratch/ops-console-restyle/shot.mjs new file mode 100644 index 00000000..ff2cace7 --- /dev/null +++ b/.scratch/ops-console-restyle/shot.mjs @@ -0,0 +1,164 @@ +// Headless Chrome via CDP. No dependencies; Node 22+ (global WebSocket). +// node shot.mjs [--width 1440] [--height 900] [--dark] +// [--reduced-motion] [--full] [--eval ""] [--tab N] [--wait ms] +// [--json] (with --eval: print the result as JSON and skip the screenshot) +import { spawn } from "node:child_process" +import { mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +const CHROME = + process.env.CHROME ?? + "/nix/store/xsrddymgg12bq9gza3srqnxxxvvzi7s9-playwright-chromium/chrome-linux64/chrome" + +const args = process.argv.slice(2) +const url = args[0] +const out = args[1] +const flag = (name, fallback) => { + const index = args.indexOf(`--${name}`) + return index === -1 ? fallback : args[index + 1] +} +const has = (name) => args.includes(`--${name}`) +const width = Number(flag("width", 1440)) +const height = Number(flag("height", 900)) +const port = 9222 + Math.floor(Math.random() * 500) + +// Chrome writes a profile per run; keep it off the small root filesystem. +const profile = mkdtempSync(join(process.env.SHOT_TMP ?? tmpdir(), "shot-")) +const chrome = spawn( + CHROME, + [ + "--headless=new", + "--no-sandbox", + "--disable-gpu", + "--hide-scrollbars", + "--no-first-run", + `--user-data-dir=${profile}`, + `--remote-debugging-port=${port}`, + `--window-size=${width},${height}`, + "about:blank", + ], + { stdio: "ignore" }, +) + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) +const wsUrl = async () => { + for (let attempt = 0; attempt < 50; attempt++) { + try { + const response = await fetch(`http://127.0.0.1:${port}/json/version`) + return (await response.json()).webSocketDebuggerUrl + } catch { + await sleep(100) + } + } + throw new Error("chrome did not start") +} + +const ws = new WebSocket(await wsUrl()) +await new Promise((resolve) => (ws.onopen = resolve)) +let id = 0 +const pending = new Map() +const events = [] +const errors = [] +ws.onmessage = (message) => { + const data = JSON.parse(message.data) + if (data.method === "Runtime.exceptionThrown") + errors.push( + data.params.exceptionDetails.exception?.description ?? data.params.exceptionDetails.text, + ) + if (data.method === "Runtime.consoleAPICalled" && data.params.type === "error") + errors.push(data.params.args.map((a) => a.value ?? a.description).join(" ")) + if (data.id !== undefined) { + const { resolve, reject } = pending.get(data.id) + pending.delete(data.id) + data.error ? reject(new Error(JSON.stringify(data.error))) : resolve(data.result) + } else events.push(data) +} +const send = (method, params = {}, sessionId) => + new Promise((resolve, reject) => { + id += 1 + pending.set(id, { resolve, reject }) + ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })) + }) + +const { targetId } = await send("Target.createTarget", { url: "about:blank" }) +const { sessionId } = await send("Target.attachToTarget", { targetId, flatten: true }) +const call = (method, params) => send(method, params, sessionId) + +await call("Page.enable") +await call("Runtime.enable") +await call("Log.enable") +await call("Emulation.setDeviceMetricsOverride", { + width, + height, + deviceScaleFactor: 1, + mobile: width < 820, +}) +const features = [] +if (has("dark")) features.push({ name: "prefers-color-scheme", value: "dark" }) +if (has("reduced-motion")) features.push({ name: "prefers-reduced-motion", value: "reduce" }) +if (features.length > 0) await call("Emulation.setEmulatedMedia", { features }) +await call("Page.navigate", { url }) +await sleep(Number(flag("wait", 1500))) + +const tabs = Number(flag("tab", 0)) +for (let index = 0; index < tabs; index++) { + await call("Input.dispatchKeyEvent", { + type: "keyDown", + key: "Tab", + code: "Tab", + windowsVirtualKeyCode: 9, + }) + await call("Input.dispatchKeyEvent", { + type: "keyUp", + key: "Tab", + code: "Tab", + windowsVirtualKeyCode: 9, + }) + await sleep(30) +} + +if (has("errors")) + console.log( + JSON.stringify( + errors.map((e) => String(e).slice(0, 600)), + null, + 2, + ), + ) + +let result +const expression = flag("eval") +if (expression !== undefined) { + result = await call("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }) + if (has("json")) + console.log(JSON.stringify(result.result.value ?? result.exceptionDetails, null, 2)) + await sleep(Number(flag("settle", 200))) +} + +if (!has("json") && out !== undefined) { + const params = { format: "png" } + const clipY = flag("clip-y") + if (clipY !== undefined) { + // Grow the viewport to include the clip; capturing beyond it paints blank. + await call("Emulation.setDeviceMetricsOverride", { + width, + height: Number(clipY) + height, + deviceScaleFactor: 1, + mobile: false, + }) + await sleep(400) + params.clip = { x: 0, y: Number(clipY), width, height, scale: 1 } + } else if (has("full")) { + const { cssContentSize } = await call("Page.getLayoutMetrics") + params.captureBeyondViewport = true + params.clip = { x: 0, y: 0, width, height: Math.min(cssContentSize.height, 20000), scale: 1 } + } + const { data } = await call("Page.captureScreenshot", params) + writeFileSync(out, Buffer.from(data, "base64")) + console.log(`${out} ${width}x${has("full") ? "full" : height}`) +} +chrome.kill() +await sleep(200) +rmSync(profile, { recursive: true, force: true }) +process.exit(0) diff --git a/.scratch/utility-room-restyle/issues/01-tokens-and-fonts.md b/.scratch/utility-room-restyle/issues/01-tokens-and-fonts.md deleted file mode 100644 index 6a7ac900..00000000 --- a/.scratch/utility-room-restyle/issues/01-tokens-and-fonts.md +++ /dev/null @@ -1,31 +0,0 @@ -# Replace the theme tokens and load the Utility Room fonts - -Status: ready-for-agent -Parent: ../spec.md -Blocked by: none - -## Task - -Swap the shadcn oklch palette in `apps/web/src/styles.css` for the Utility Room tokens, keeping every semantic token name so the 21 files that use `bg-card`, `text-muted-foreground`, `bg-sidebar-accent` and friends keep compiling. Check the design doc into the repo. - -## Steps - -1. `docs/design/DESIGN.md` is already checked in. Add the two recorded deviations from the spec (filled destructive button, warm-charcoal night shift) to its Buttons and Colors sections. -2. In `styles.css`: - - Keep `@import "tailwindcss"` and `@import "tw-animate-css"`. Remove `@import "@fontsource-variable/geist"`. - - Replace the `:root` block (`styles.css:74-108`) with the `--jn-*` brand tokens and the light semantic mapping from `docs/design/theme.css` (sections 1 and 2). `--radius` becomes `6px`. - - Extend `@theme inline` (`styles.css:7-72`) with the brand colour aliases (`--color-cobalt`, `--color-navy`, `--color-yellow-safety`, `--color-cream`, `--color-wall`, `--color-rust`, `--color-metal`, ...), `--font-sans: "Archivo"`, `--font-sign: "Big Shoulders Display"`, `--font-mono: "Spline Sans Mono"`, the `--text-*` scale, `--radius-xs|sm|md|lg`, `--shadow-mount|edge|recess`, and `--ease-switch`. Keep the existing `--radius-xl..4xl` derivations so current `rounded-xl` usages do not break. - - Add the `@utility` blocks: `jn-mount`, `jn-recess`, `jn-sign`, `jn-screw`, `jn-press`, `jn-hazard`, `jn-steel`, `jn-perforated`, `jn-empty`, `jn-agent`, plus the `.jn-dense` density rules. - - In `@layer base`, add the wall background image, the fractal-noise `body::before`, the yellow `:focus-visible` ring, and `p { max-width: 62ch }`. - - Leave the policy workspace block (`styles.css:171-401`) untouched here; ticket 09 converts it. -3. Fonts: add `@fontsource-variable/archivo`, `@fontsource/big-shoulders-display` (weights 700 and 800), and `@fontsource-variable/spline-sans-mono` to `apps/web/package.json` and import them at the top of `styles.css`. Verify each package exists on npm before adding; if a variable build is missing, import the static weights listed in the spec's Risks section. Remove `@fontsource-variable/geist`. -4. Run `vp check` and `vp test`. Nothing should fail beyond formatting. - -## Done when - -- `vp dev` renders the app on the cinderblock wall with Archivo body text and no oklch values left in `styles.css`. -- `docs/design/DESIGN.md` records the two deviations. - -## Comments - -Done in commit "Replace theme tokens with Utility Room palette and fonts (01)". Fonts are the variable fontsource builds, so the family names are "Archivo Variable", "Big Shoulders Display Variable" and "Spline Sans Mono Variable". diff --git a/.scratch/utility-room-restyle/issues/02-night-shift-palette.md b/.scratch/utility-room-restyle/issues/02-night-shift-palette.md deleted file mode 100644 index 32a9e11f..00000000 --- a/.scratch/utility-room-restyle/issues/02-night-shift-palette.md +++ /dev/null @@ -1,29 +0,0 @@ -# Replace the dark theme with the warm-charcoal night shift - -Status: ready-for-agent -Parent: ../spec.md -Blocked by: 01 - -## Task - -The theme file's `.dark` block turns the wall navy-deep and every card navy, which reads as a blue wash. Replace it with the night-shift palette from `docs/settings-mockups/utility-room.css` (block headed "Night shift"). - -## Steps - -1. In `styles.css` `.dark`, set the shadcn semantic tokens from the night values: - - `--background` `#1c1b18`, `--card` `#2a2823`, `--popover` `#34312b`, `--foreground` and `--card-foreground` `#f6efdd`, `--muted` `#262420`, `--muted-foreground` `#a8a08e`, `--border` and `--input` `#5a554b`, `--destructive` `#e0705c`, `--sidebar` `#1c1b18`, `--sidebar-accent` `#34312b`, `--sidebar-border` `#5a554b`. - - `--primary` stays safety yellow, `--accent` stays cobalt, `--ring` stays yellow. - - `--jn-mount: 0 3px 0 #000, 0 6px 14px rgb(0 0 0 / 0.5)`, `--jn-edge: 0 3px 0 #000`. - - `--jn-wall-image` swapped for the dark brick SVG (fill `#1C1B18`, stroke `#262420`); `.dark body::before` opacity `0.04`. -2. Add a `--jn-line-strong: #7b7466` token for mounted-object outlines in the dark and use it from `jn-mount` via `var(--jn-outline, var(--jn-navy))`, so the utility does not need a separate dark rule. -3. Enamel signs, the breaker panel, hazard stripes and switches keep their day colours; only their outer border darkens to `#000`. -4. Confirm `theme-switcher.ts:65-68` still toggles `html.dark`; no change needed there. - -## Done when - -- Switching to Dark in the app shows a warm charcoal wall, cream text, cobalt signs and yellow on-states, with no navy fills on cards or the sidebar. -- Contrast check passes for `#a8a08e` on `#2a2823` and `#e0705c` on `#2a2823` (both above 4.5:1). - -## Comments - -Done. The night palette shipped inside the ticket 01 stylesheet rewrite (`.dark` block with `--nt-*` tokens, `--jn-outline` lifted to `#7b7466`, dark brick SVG). Contrast: muted `#a8a08e` on card `#2a2823` = 6.05, rust `#e0705c` on card = 4.63, ink on card = 12.4. diff --git a/.scratch/utility-room-restyle/issues/03-ui-primitives.md b/.scratch/utility-room-restyle/issues/03-ui-primitives.md deleted file mode 100644 index 7e331e57..00000000 --- a/.scratch/utility-room-restyle/issues/03-ui-primitives.md +++ /dev/null @@ -1,31 +0,0 @@ -# Restyle the shared UI primitives - -Status: ready-for-agent -Parent: ../spec.md -Blocked by: 01 - -## Task - -Bring `apps/web/src/components/ui/*` and `theme-switcher.ts` onto the Utility Room look. All primitives are plain class strings composed with `cn()` and carry `data-slot`; keep both conventions. - -## Steps - -1. `ui/button.ts` - - `buttonBase` (`:55-56`): `rounded-sm border-[2.5px] font-bold text-button shadow-edge transition-[transform,box-shadow,background-color] active:translate-y-[3px] active:shadow-none disabled:opacity-50`. Drop the current `rounded-lg` and 1px translate. - - `buttonVariants` (`:16-27`): `default` yellow fill, navy text, `border-navy`, hover `bg-yellow-safety-dark`; `destructive` rust fill, cream text, `border-rust-dark shadow-[0_3px_0_var(--jn-rust-dark)]`, hover `bg-rust-dark`; `outline` and `secondary` cream fill, navy border, hover `bg-cream-hi`; `ghost` transparent, `border-dashed`, no shadow, no press travel; `link` no border, cobalt-dark text, underline. - - `buttonSizes`: keep the keys; `sm` becomes `px-3 py-1.5 text-body-sm`. -2. `ui/input.ts`: `inputClass` to `border-2 border-navy rounded-sm bg-cream shadow-[inset_0_2px_0_rgb(18_34_92/0.1)] focus:bg-cream-hi`. Labels use `text-label font-semibold`; descriptions `text-body-sm text-muted-foreground`. -3. `ui/skeleton.ts`: `bg-mortar rounded-xs animate-pulse`. -4. `ui/sidebar.ts`: `sidebarHeaderClass`, `sidebarContentClass`, `sidebarFooterClass` unchanged; `sidebarMenuButtonClass` (`:549`) gets `rounded-sm text-body-sm font-medium hover:bg-cream data-[active=true]:bg-cream`; the group label (`:456`) becomes mono `text-mono-sm text-muted-foreground`; `sidebarInsetClass` (`:790`) unchanged; the sidebar container gets `border-r-2 border-navy`. -5. `ui/sheet.ts`, `ui/command.ts`, `ui/input-group.ts`, `ui/textarea.ts`: replace soft shadows and `rounded-lg`/`rounded-xl` with `jn-mount` and `rounded-md`; overlays become `bg-navy-deep/55` with no blur. -6. `theme-switcher.ts:285-291`: the inline `buttonClassName`, `itemsClassName`, `backdropClassName` strings get the same treatment (cream menu with `jn-mount`, yellow on the selected item). -7. Add a `chip` variant map to a new `ui/chip.ts`: `neutral` (mortar fill, navy border), `on` (yellow), `danger` (transparent, rust text and border), `agent` (cobalt-light). Mono `text-mono-sm`, `rounded-full`, `data-slot="chip"`. - -## Done when - -- Every page still renders with `vp dev`; no `rounded-lg` or `shadow-sm|md|lg` classes remain under `ui/`. -- `vp check` passes. - -## Comments - -Done. Also restyled the repository switcher trigger and rows (they hard-coded the old outline classes) and updated its test to expect the 2px border. `ui/chip.ts` added. diff --git a/.scratch/utility-room-restyle/issues/04-utility-room-components.md b/.scratch/utility-room-restyle/issues/04-utility-room-components.md deleted file mode 100644 index 4fc6e473..00000000 --- a/.scratch/utility-room-restyle/issues/04-utility-room-components.md +++ /dev/null @@ -1,30 +0,0 @@ -# Add the Utility Room composition helpers - -Status: ready-for-agent -Parent: ../spec.md -Blocked by: 03 - -## Task - -Add small view helpers for the objects the mockups use so pages compose them rather than repeating class strings. Follow the `ui/*` pattern: exported class strings plus a builder taking `HtmlBuilder` and `data-slot`. - -## Components - -| File | Builder | Notes | -| ------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ui/sign.ts` | `sign(h, { id, children })` | `h2.jn-sign font-sign uppercase text-sign` with a leading `span.jn-screw`. One per section. | -| `ui/panel.ts` | `panel(h, { flush?, children })` | `bg-card text-card-foreground jn-mount`; `flush` removes padding for row lists. | -| `ui/rack.ts` | `rack(h, { label, items: [{ href, label, current }] })` | Steel rail (`div.jn-steel h-[14px] rounded-xs` with two screw dots) and `a.jn-tag` items with `aria-current="page"` on the active one. Vertical by default; horizontal wrap below 880px via `md:` classes. | -| `ui/mark.ts` | `platformMark(h, platform)` and `avatar(h, initials)` | 30px and 34px bordered squares, mono letters ("GH", "SL", initials). No logos. | -| `ui/empty.ts` | `emptyPanel(h, { children })` | `panel` plus `jn-empty` and `p-[18px]`. | - -Move the `.jn-tag` and `.jn-switch` rules from the mockup stylesheet into `styles.css` under `@layer components` if ticket 01 did not already, since the tag builder depends on them. The switch is not used by the Account page but belongs with the system. - -## Done when - -- A scratch page or the Account page (ticket 06) renders a sign, a panel, a rack with three tags and two platform marks using only these helpers. -- `vp check` passes. - -## Comments - -Done: `ui/sign.ts`, `ui/panel.ts` (with `emptyPanel`), `ui/rack.ts`, `ui/mark.ts` (`platformMark`, `avatar`, `initialsOf`). The tag and switch CSS landed in `styles.css` under ticket 01. diff --git a/.scratch/utility-room-restyle/issues/05-app-shell.md b/.scratch/utility-room-restyle/issues/05-app-shell.md deleted file mode 100644 index 928dcf21..00000000 --- a/.scratch/utility-room-restyle/issues/05-app-shell.md +++ /dev/null @@ -1,27 +0,0 @@ -# Restyle the app shell - -Status: ready-for-agent -Parent: ../spec.md -Blocked by: 03 - -## Task - -Bring `apps/web/src/main.ts` sidebar and header in line with the mockup's shell (`07-sidebar-sections.html`, `.sidebar` / `.inset-head`). Structure stays the same: 16rem sidebar with brand, repository switcher, Repository group, Account footer link; 48px inset header with the page title and tools. - -## Steps - -1. Brand header (`main.ts` `brandHeader`): cobalt disc with a navy ring and cream inset, "The Janitor" in `font-bold`. -2. Repository switcher trigger (`components/repository-switcher.ts`): cream, 2px navy border, `rounded-sm`, mono repository name, chevron on the right. -3. `navMain` (`:900-958`): keep icons; the group label reads "Repository" in mono muted; active item `bg-cream font-semibold`. Delete the `.repository-nav-link` hand-written rule from `styles.css` once the classes cover it. -4. `accountLink` (`:960-981`): same treatment; keep `aria-current`. -5. `mainHeader` (`:999-1090`): `h-12 border-b-2 border-navy bg-card text-body-sm font-semibold`; the sync button and theme switcher become 28px bordered squares (they are already restyled by ticket 03). -6. Toasts (`toastEntry` `:1092-1109`): `bg-card jn-mount rounded-md` and the variant borders become `border-rust`, `border-yellow-safety-dark`, `border-cobalt`. - -## Done when - -- The shell matches the mockup at 1400px and collapses correctly below 768px (existing `SIDEBAR_MOBILE_MEDIA_QUERY` behaviour unchanged). -- `vp check` and `vp test` pass; `navigation.test.ts` and `routing.test.ts` are unaffected. - -## Comments - -Done. Brand icon and switcher trigger are bordered plates, repository nav links use classes instead of `.repository-nav-link` (rule deleted), the inset header is a cream bar with a 2px outline, toasts are mounted panels with rust, yellow-dark and cobalt edges. diff --git a/.scratch/utility-room-restyle/issues/06-account-routes-and-sections.md b/.scratch/utility-room-restyle/issues/06-account-routes-and-sections.md deleted file mode 100644 index 0fb473db..00000000 --- a/.scratch/utility-room-restyle/issues/06-account-routes-and-sections.md +++ /dev/null @@ -1,35 +0,0 @@ -# Add account sections to the routes and the Account model - -Status: ready-for-agent -Parent: ../spec.md -Blocked by: 04, 05 - -## Task - -Give the Account page three URL-addressable sections and render them behind a section rack. No content changes yet beyond moving the existing sections into panes; ticket 07 trims the content. - -## Steps - -1. `apps/web/src/routes.ts` - - `Account` (`:28`) becomes `{ section: AccountSection }` with `AccountSection = "you" | "accounts" | "team"`. - - Builders: `/account` parses to `{ section: "accounts" }`; add `/account/you`, `/account/accounts`, `/account/team`. Implement as `Route.literal("account")` optionally followed by a literal segment; do not use `idSegment` so `/account/slack/return` cannot collide. - - Keep `accountReturn` before `account` in `Route.oneOf` (`:138-139`); add the new builder after it. Extend `path` (`:143-160`). - - Add `Routes.accountSection(section)` and `Routes.accountSectionOf(route)` helpers. -2. `apps/web/src/main.ts` - - `enterAccount` (`:153-180`): after a successful `FinishedReturn`, navigate to `Routes.accountSection("accounts")`. - - `accountView` (`:1131-1138`): pass `viewInputs: { section }` from the route so the Account view knows which pane to render. The `isAccountRoute` guard and header title are unchanged. -3. `apps/web/src/components/account.ts` - - `view` takes the section as a view input. Render the rack (ticket 04) with three tags linking to the three section paths, `aria-current` on the active one, and only the matching pane. Team's tag is hidden when `view.team === null`; if the URL asks for `team` and the viewer is a member, render the member empty state from the spec. - - Error, load error and notice stay above the pane. - - Add `showRemoved: boolean` to the Model and a `ToggledRemoved` message (view-local, not persisted) for ticket 07 to use. -4. `apps/web/test/routes.test.ts`: add cases for the three section paths and confirm `/account/slack/return?code=…&state=…` still parses as `AccountReturn`. - -## Done when - -- Visiting `/account/team` as an admin shows the Team pane with the Team tag current; `/account` shows Connected accounts. -- Completing a Slack or GitHub return lands on `/account/accounts` with the notice visible. -- `vp test` passes for `routes.test.ts`; `account.test.ts` may fail until ticket 08. - -## Comments - -Done. `Account` route carries `section`; bare `/account` parses as `accounts` and prints back without a segment. `Routes.accountSection` / `accountSectionOf` added. The Account view takes `{ section }` and renders a rack plus one pane; `showRemoved` and `ToggledRemoved` added for ticket 07. One account test fails until ticket 08 splits assertions per section. diff --git a/.scratch/utility-room-restyle/issues/07-account-content-trim.md b/.scratch/utility-room-restyle/issues/07-account-content-trim.md deleted file mode 100644 index 9b37d9cd..00000000 --- a/.scratch/utility-room-restyle/issues/07-account-content-trim.md +++ /dev/null @@ -1,34 +0,0 @@ -# Trim the Account page content to the mockup - -Status: ready-for-agent -Parent: ../spec.md -Blocked by: 06 - -## Task - -Rebuild the three panes' content in `apps/web/src/components/account.ts` to match `docs/settings-mockups/07-sidebar-sections.html` and the "Section content" table in the spec. Keep the Message union, API calls and pending gating exactly as they are; only the view functions change. - -## Steps - -1. Replace `section(h, title, children)` (`:480-484`) with the sign + lede + panel composition from ticket 04. Content column `max-w-[640px]`. -2. **You** pane: a three-row definition grid (Email, Role chip, Teammate since). Format `createdAt` as `d MMM yyyy`; add a tiny `formatDate` helper or reuse one if the workspace already has it. -3. **Connected accounts** pane: rewrite `platformRow` (`:345-407`) to the one-status-line, one-action shape from the spec. Derive the status line from `activeLink`, then the newest non-active link (`links` is ordered active-first, newest first), then `linking[platform]`. Keep `Replace` as a `link`-variant button beside Disconnect. Busy labels unchanged. -4. **Team** pane: rewrite `rosterRow` (`:409-478`) to the dense row (avatar, name, optional "No email on record" sub-line, role chip, actions). No actions on the viewer's own row. Split the roster into active and removed; render removed rows only when `showRemoved`, behind the footer toggle wired to `ToggledRemoved`. Footer left text "N active". -5. Member viewer: lede "Only admins can manage the team." and the empty-state panel. -6. Loading: "Loading your account…" inside a skeleton panel in the content column. -7. Copy changes (old → new), for ticket 08's test updates: - - "No {name} account connected." → "Not connected" - - "{name} linking is not configured for this deployment." → "Not available in this deployment" - - "Connected as X in workspace T1." → "Connected as X" - - "Connected accounts may direct Janitor from Slack and GitHub until you disconnect them, independently of this browser session." → "Accounts that can give Janitor instructions on your behalf." - - "Removing a teammate disables their connected accounts and sign-in; their accepted work and attribution are kept. The last active admin cannot be removed or demoted." → "Everyone who has signed in. New teammates start as members." plus the panel footnote "Disconnecting stops new instructions from that account. Work it already contributed is kept." on the accounts pane. - - Roster meta "Admin · Active · Slack: Me" is removed. - -## Done when - -- Each pane fits in a 1400×800 viewport with the default fixture data (four active, one removed). -- All five state combinations from the spec's Validation section render correctly in day and night. - -## Comments - -Done. Panes match the mockup: You is a three-row grid, Connected accounts is one status line and one action per platform (Replace kept as a quiet link button), Team is a dense roster with removed teammates behind a footer toggle. Member viewers get the empty-state panel. Old copy replaced as listed above. diff --git a/.scratch/utility-room-restyle/issues/08-tests-and-validation.md b/.scratch/utility-room-restyle/issues/08-tests-and-validation.md deleted file mode 100644 index 0a41d08e..00000000 --- a/.scratch/utility-room-restyle/issues/08-tests-and-validation.md +++ /dev/null @@ -1,32 +0,0 @@ -# Update tests and run the validation pass - -Status: ready-for-agent -Parent: ../spec.md -Blocked by: 07 - -## Task - -Make the suite reflect the new Account page and verify the restyle visually. - -## Steps - -1. `apps/web/test/components/account.test.ts` - - Scenes now need a `section` view input. Add a helper that builds the scene for a given section. - - Assertions that expect "Team", "Connect GitHub" and the account rows all at once (`:70-97`) split into per-section tests. - - Replace the old strings listed in ticket 07 with the new ones. Keep the behavioural assertions: clicking Disconnect issues the request, busy labels appear, stale replies are dropped, member viewers see no Team tag. - - Add: "Show 1 removed" reveals the removed row and its Restore button; "Hide removed" collapses it. -2. `apps/web/test/routes.test.ts`: covered by ticket 06; confirm it is green. -3. Run `vp check --fix` and `vp test` at the root. -4. Visual pass with the `mcp__t3-code__preview_*` tools against `vp dev`, day and night, 1400px and 800px wide: all connected; never linked; disconnected; Slack unavailable (`JANITOR_SLACK_OAUTH_CLIENT_ID` unset); member viewer. Record the screenshots' paths in this ticket's Comments. -5. Contrast: compute the night-palette pairs from the spec and note results here. - -## Done when - -- `vp check` and `vp test` pass. -- Screenshots for the ten combinations are linked below and match the mockup. - -## Comments - -Tests rewritten per section: accounts (member and admin fixtures), you, team (self has no actions, removed toggle and restore), member empty state, load failure. Route tests cover the three section paths and the default. `vp check` and `vp test --project web` pass. Visual pass recorded below once dev mode was inspected. - -Visual pass: not completed in this session. `vp run dev` needs the GitHub App secrets from `.env`, and the collaborative preview browser became unavailable. The web Vite server alone (`vp dev --port 1338` in `apps/web`) served the compiled stylesheet with the fonts, `jn-*` utilities and night tokens present and no build errors. Open the app in dev mode and check the ten combinations listed in the spec before merging. diff --git a/.scratch/utility-room-restyle/issues/09-other-pages-polish.md b/.scratch/utility-room-restyle/issues/09-other-pages-polish.md deleted file mode 100644 index b9029955..00000000 --- a/.scratch/utility-room-restyle/issues/09-other-pages-polish.md +++ /dev/null @@ -1,26 +0,0 @@ -# Polish the remaining pages on the new tokens - -Status: needs-triage (first pass shipped) -Parent: ../spec.md -Blocked by: 05 - -## Task - -After the token swap, every page is on the Utility Room palette but was composed for the old look. Walk each route and fix what the tokens alone did not: hand-written hex colours, soft shadows, oversized radii, headings that should be enamel signs, and lists past eight rows that should use `.jn-dense`. - -## Known work - -- `styles.css:171-401` policy workspace block: replace hardcoded hex (`.policy-status-badge.*` and its `.dark` overrides, `.policy-document-*`) with tokens; convert `.repository-nav-link` to classes and delete the rule. -- Repository pages (Overview, Policies, Rules, Activity, Settings under `/repositories/:id/…`): section headings become signs; primary actions confirm they are the single yellow element on the screen; destructive actions (pause, disconnect repository) sit under a `jn-hazard` strip. -- `repository-connections.ts` and the `Connect repository` flow: cards to `panel`, install links to `link` buttons. -- `activity.ts` and any run/audit lists: apply `.jn-dense` past eight rows. -- Empty states (`policy-empty` class in `main.ts:1160`): use `emptyPanel`; the mascot stays out of Settings and error states per DESIGN.md. - -## Done when - -- No hex colour literals remain in `styles.css` outside the token definitions. -- Each route has been screenshotted in day and night and reviewed. - -## Comments - -First pass done: every hex literal in the page CSS is now a token or a color-mix of one (draft state cobalt, published and enabled switches yellow, unknown decisions yellow-tinted, match decisions cobalt), and page radii map to the xs/sm/md scale. Section signs and hazard strips on repository pages remain open; review each route in dev before deciding where they belong. diff --git a/.scratch/utility-room-restyle/spec.md b/.scratch/utility-room-restyle/spec.md deleted file mode 100644 index 7cf9098b..00000000 --- a/.scratch/utility-room-restyle/spec.md +++ /dev/null @@ -1,121 +0,0 @@ -# Utility Room restyle and Account page restructure - -Status: ready-for-agent - -This plan applies the "Utility Room" design (see `docs/settings-mockups/07-sidebar-sections.html` and `docs/settings-mockups/utility-room.css`) to `apps/web`, and restructures the Account page into three switchable sections. The mockups are the visual reference; this document is the implementation handoff. - -## Outcome - -- The whole web app renders with Utility Room tokens: cobalt, navy, safety yellow, cream on a cinderblock wall, Archivo / Big Shoulders Display / Spline Sans Mono, hard navy drop edges instead of soft shadows. -- Night shift is a warm charcoal palette, not the navy-on-navy `.dark` block from the original theme file. -- The Account page (`/account`) becomes a settings page with a section rack on the left (You, Connected accounts, Team) and one section in the centre, each fitting a single screen with only the information a user needs. -- Existing behaviour is unchanged: link start/return/disconnect, role changes, remove/restore, single pending-action gating, error and notice reporting. - -## Scope decision: tokens are global - -The app uses shadcn semantic tokens (`bg-card`, `text-muted-foreground`, `bg-sidebar-accent`, `rounded-md`, ...) in 21 of ~30 files. Swapping the values behind those names restyles every page at once. That is the intended approach: change the palette, radii, fonts and shadows at the token layer, upgrade the shared primitives, and then compose the Account page fully. Other pages inherit the new look and get a polish pass afterwards (ticket 09). Restyling only the Account page would require a parallel token set and is not worth it. - -## Sources of truth - -| Item | Source | Where it lands | -| -------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------ | -| Design tokens and component specs | `docs/design/DESIGN.md` (checked in from the review attachment) | Update in ticket 01 with the recorded deviations | -| Tailwind v4 theme | `docs/design/theme.css` (Tailwind + shadcn mapping, checked in) | `apps/web/src/styles.css`, replacing the oklch palette | -| Night palette | `docs/settings-mockups/utility-room.css`, "Night shift" block | `apps/web/src/styles.css` `.dark` block | -| Layout and content of the Account page | `docs/settings-mockups/07-sidebar-sections.html` | `apps/web/src/components/account.ts` | - -## Recorded deviations from DESIGN.md - -Both were requested during mockup review and should be written into the checked-in DESIGN.md so the doc stays authoritative. - -1. **Destructive button is filled.** Rust fill, cream text, rust-dark border and drop edge, darker fill on hover. DESIGN.md currently specifies a cream button with a rust outline that inverts on hover. -2. **Night shift is warm charcoal.** Wall `#1c1b18`, mortar `#262420`, card `#2a2823`, raised `#34312b`, inset `#14130f`, outlines `#5a554b` / `#7b7466`, ink `#f6efdd`, muted `#a8a08e`, rust `#e0705c`. Cobalt signs, yellow on-states and the hazard stripe keep their day colours. The theme file's `.dark` block (navy-deep background, navy panel cards) is discarded. - -## Architecture of the change - -``` -styles.css (tokens, @utility jn-*, night palette) ← ticket 01, 02 - └─ ui/button, input, skeleton, sidebar, sheet, command ← ticket 03 - └─ ui/sign, chip, panel, rack, mark (new) ← ticket 04 - └─ main.ts shell (sidebar, header) ← ticket 05 - └─ routes.ts + account.ts sections ← ticket 06 - └─ account.ts trimmed content ← ticket 07 - └─ tests, vp check, visual pass ← ticket 08 - └─ other pages polish ← ticket 09 -``` - -Tickets 01 to 05 are safe to ship independently; each leaves the app working with progressively more of the new look. Tickets 06 and 07 change the Account page structure and must ship together with 08. - -## Account page specification - -### Routes - -`AppRoute.Account` gains a `section` field: `"you" | "accounts" | "team"`. Paths: - -| Path | Section | -| -------------------------------------------- | --------------------------------------------------------------------------- | -| `/account` | `accounts` (default; redirect-free, just the default parse) | -| `/account/you` | `you` | -| `/account/accounts` | `accounts` | -| `/account/team` | `team` | -| `/account/:platform/return?code&state&error` | unchanged `AccountReturn`; after completion navigate to `/account/accounts` | - -`accountReturn` stays first in `Route.oneOf` so `/account/slack/return` never parses as a section. `Routes.section` / `Routes.sectionPath` are repository-scoped and must not be reused. - -### Layout - -Inside the existing inset (48px header keeps the title; see below), a two-column grid: a 200px section rack on the left with three hook tags (`aria-current="page"` on the active one), and a content column with `max-width: 640px`. Below 880px the rack becomes a horizontal wrapped row above the content. No punch card, no tallies, no enamel sign per row. - -Each section is: one enamel sign (`h2`, uppercase, Big Shoulders), one lede sentence in muted ink, one mounted panel. - -### Section content (what is shown, and nothing else) - -**You** - -- Email (or subject when email is null) -- Role chip: `admin` (yellow on-state) or `member` -- Teammate since `createdAt` -- Lede: "How Janitor knows you. Sign-in is handled by Cloudflare Access." - -**Connected accounts** - -- Lede: "Accounts that can give Janitor instructions on your behalf." -- One row per platform (GitHub, Slack) with a 30px platform mark, the platform name, and exactly one status line: - - active link: "Connected as `displayName`" - - no link and linking available: "Not connected" - - most recent link is `disconnected` or `replaced`: "Disconnected · was `displayName`" - - linking unavailable: "Not available in this deployment" -- Exactly one action per row: filled rust **Disconnect** when active; yellow **Connect GitHub / Connect Slack** otherwise; a disabled Connect when unavailable. -- Keep **Replace** as a `link`-variant button next to Disconnect. It calls the same start endpoint and preserves the `replaced` link status; dropping it would force disconnect-then-connect, which records a different history. It is visually quiet so the row still reads as one action. -- Footer sentence under the panel: "Disconnecting stops new instructions from that account. Work it already contributed is kept." -- Removed from today's view: workspace IDs, account IDs, linked dates, "in workspace T1" phrasing. - -**Team** (admin only; `view.team !== null`) - -- Lede: "Everyone who has signed in. New teammates start as members." -- Dense list (`.jn-dense`): avatar initials, email (or subject with a "No email on record" sub-line), role chip, and for other active teammates **Make admin / Make member** and filled rust **Remove**. Your own row shows no actions. -- Removed teammates are collapsed. Panel footer: "N active" on the left, a link-style "Show N removed / Hide removed" toggle on the right (view-local boolean in the Account model, not persisted). Removed rows show `removed` danger chip and **Restore**. -- Member viewers see the lede "Only admins can manage the team." and an empty-state panel: "Ask an admin if you need a role change or to remove someone." -- Removed from today's view: the "Admin · Active · Slack: Me, GitHub: octocat" meta line, per-row explanation of the last-admin rule (the guard is expressed by not offering actions on your own row; the server error still surfaces in the alert if it ever fires). - -### Header title - -`mainHeader` shows "Account" for the account routes today. Keep "Account"; the section name is carried by the active tag and the enamel sign, so the header does not need to change per section. - -### Busy and error states - -Unchanged model: one `pending` operation at a time, button labels switch to their gerund while busy ("Opening…", "Disconnecting…", "Updating…", "Removing…", "Restoring…"), `role="alert"` for errors and `role="status"` for notices, rendered above the active section. Loading shows the existing "Loading your account…" text inside a skeleton panel. - -## Validation - -- `vp check` and `vp test` pass. -- `apps/web/test/components/account.test.ts` updated for sections (see ticket 08). -- Manual visual pass with the preview tools in day and night, at 1400px and 800px wide, for: all connected; never linked; disconnected; Slack unavailable; member viewer. -- Contrast: every text/background pair in the night palette meets 4.5:1. Check muted ink `#a8a08e` on card `#2a2823` and rust `#e0705c` on card in particular. - -## Risks - -- **Font weight.** Three new families via `@fontsource` add roughly 300 to 500 KB of woff2 before subsetting. Load only the weights used (Archivo 400/500/600/700, Big Shoulders 700/800, Spline Sans Mono 400/600) and check the build output. -- **Hand-written CSS.** `styles.css:171-401` styles the policy workspace with hardcoded hex colours, including `.dark` overrides. These will clash with the new palette until ticket 09 converts them to tokens. -- **Other pages look half-done** between ticket 01 and ticket 09. Acceptable on a branch; do not deploy between them. -- **Tests assert copy.** Several account-page strings change; ticket 08 owns the test updates and lists the old strings. diff --git a/OPEN-QUESTIONS.md b/OPEN-QUESTIONS.md new file mode 100644 index 00000000..ef1aa090 --- /dev/null +++ b/OPEN-QUESTIONS.md @@ -0,0 +1,64 @@ +# Open questions from the Ops Console restyle + +Cases the identity in `docs/design/DESIGN.md` does not cover. Each one has +the neutral choice that shipped and what would settle it. + +1. **Syntax highlighting in the policy and prompt editors.** DESIGN.md names + no code theme. The editors keep CodeMirror's GitHub light and dark themes, + which use their own blues, purples and reds, so the policy screen carries + colours from outside the palette. Editor chrome (background, gutters, + selection, active line, cursor) does use the tokens. Settle by choosing a + two-tone highlight in `foreground` and `foreground-muted`, or by accepting + the GitHub theme. + +2. **Blueprint layout engine and YAML fallback.** DESIGN.md says node layout + must come from a layout engine and the builder should fall back to YAML + past a complexity cap. A rule is a fixed three-column chain (trigger, + condition, actions), so the canvas uses a fixed column layout with computed + wire endpoints. No YAML fallback exists for rules because rules have no + YAML form. Settle when rules can branch. + +3. **Switch track contrast.** The off track uses `foreground-faint`, which is + 2.2:1 against `surface`. DESIGN.md forbids lightening or changing it. The + switch also carries a visible label, `aria-checked` and a focus ring, so + state is never colour-only. Settle by darkening `foreground-faint` if a + 3:1 non-text ratio is required. + +4. **`foreground-subtle` on `canvas`.** After darkening `foreground-subtle` + one step to clear the lint's 4.5:1 check on `surface-muted`, it sits at + 4.36:1 on `canvas`. No shipped text uses that pair (subtle text sits on + `surface` or `surface-muted`), but nothing prevents a future one. Settle by + forbidding the pair in DESIGN.md or darkening the token again. + +5. **Browser chrome colours.** `theme_color` in the web manifest and the two + `theme-color` meta tags cannot reference CSS variables, so they carry the + canvas hex values directly. These are the only literals outside the theme + file. + +6. **Sidebar drawer breakpoint.** DESIGN.md says the sidebar becomes a drawer + below 820px. The sidebar primitive and Tailwind's `md:` variant switch at + 768px. Changing one without the other splits the layout, so both stay at + 768px. Settle by adding an `820px` breakpoint to the theme and moving the + sidebar's `md:` classes to it. + +7. **Inspector slide-over below 1240px.** The rule editor and policy editor + inspectors stack under the main column below 1240px instead of becoming a + slide-over triggered from the selection. A slide-over is a new interaction, + which this restyle was told not to add. + +8. **GitHub label colours.** The product owner keeps them. They render as a + 6px dot inside a neutral badge, so the badge text and fill stay in the + palette. This is recorded in the DESIGN.md project notes. + +9. **Mascot in the top bar.** The product owner keeps it. Recorded in the + DESIGN.md project notes as the one exception to the no-mascot rule. + +10. **Session title text.** Session titles are the first Slack message, + truncated by the server. They are human-written prose, so they stay in + Inter, but the session itself is agent work and carries the agent marker. + DESIGN.md does not say how to treat human text inside an agent-owned row. + +11. **Slack and GitHub messages.** DESIGN.md governs the web interface only. + Comments, pull request bodies and Slack messages The Janitor posts carry + no visible attribution beyond the platform's bot identity. Adding a + signature line is a product decision, not a restyle. diff --git a/RESTYLE-AUDIT.md b/RESTYLE-AUDIT.md new file mode 100644 index 00000000..d7c618da --- /dev/null +++ b/RESTYLE-AUDIT.md @@ -0,0 +1,458 @@ +# Restyle audit: Utility Room → Ops Console / Blueprint + +Phase 0 deliverable. No code changes were made. Everything below was read from +the worktree at commit `a158d2f`; line numbers refer to that state. + +## 0. Facts that shape the whole plan + +1. **This is not a React or shadcn codebase.** The web app (`apps/web`) is a + foldkit application: Effect-based, TypeScript view functions producing + virtual DOM. `@foldkit/ui` provides headless behaviour (dialog, menu, + select, switch, tooltip, toast, popover, disclosure, virtual list) and emits + no classes and no `data-slot` attributes. The styled layer is the project's + own `apps/web/src/components/ui/*.ts` (13 files), which is the shadcn + analogue here. `npx shadcn add` cannot run against this repo. The shadcn + semantic variable set is still defined in the theme file, and this audit + treats keeping it complete and correctly paired as the constraint to meet. +2. **There is exactly one theme file:** `apps/web/src/styles.css` (1833 + lines). No CSS modules, no styled-components, no other `.css` file. It is + loaded from `index.html` with a ``, not imported from TypeScript. +3. **The existing design doc lives at `docs/design/DESIGN.md`** (675 lines, + "Utility Room"), with a sibling reference `docs/design/theme.css`. There is + no `DESIGN.md` at the repository root. The task says to write Appendix A to + the root; see gap G1. +4. **There is no rule-graph or PR-flow canvas in the app today.** The file + `labeling-wire.ts` is the wire-protocol schema, not a drawing. The rule + editor renders a vertical three-step form ("rule-flow": When / If / Then + cards with a number bubble and a hairline spine). See gap G2. +5. **The current identity uses yellow for CTA, on-state and focus**, and + cobalt-light for agent authorship. The target inverts this. Two agent + primitives (`jn-agent`, `chip` variant `agent`) already exist and are used + nowhere. +6. **Baseline tooling passes.** `vp check` reports 0 errors and 483 + pre-existing warnings. `npx @google/design.md lint docs/design/DESIGN.md` + reports 0 errors, 1 warning (orphaned `danger-hover`). Fonts are loaded + through Fontsource imports at the top of `styles.css`. +7. **Dark mode exists.** `theme-switcher.ts:67` toggles `.dark` on + ``, with a system-preference fallback. Both CodeMirror editors follow + it with a `MutationObserver`. + +## 1. Value inventory + +### 1.1 Colors + +**Theme file, light (`styles.css:14-107`).** Brand primitives `--jn-*`: +cobalt `#1e5fd0`, cobalt-dark `#174ca8`, cobalt-light `#4a8af0`, navy +`#12225c`, navy-deep `#0b1436`, panel `#0e1b4a`, inset `#0a1233`, yellow +`#ffce1b`, yellow-dark `#e0a800`, cream `#f6efdd`, cream-hi `#fffbf0`, wall +`#ddd8c9`, mortar `#c7c1b0`, board `#b9ae93`, rust `#b23a2b`, rust-dark +`#8e2c20`, metal `#8e97ae`, metal-dark `#4c5570`, muted-ink `#4a5478`, +muted-panel-ink `#9daad0`. Two data-URI SVG images (cinderblock wall, fractal +noise) with embedded hex. Semantic set maps `--primary` to **yellow**, +`--accent` to cobalt, `--border`/`--input` to navy, `--ring` to yellow, +`--background` to wall, `--card` to cream. + +**Theme file, dark (`styles.css:113-160`).** `--nt-*`: wall `#1c1b18`, +mortar `#262420`, card `#2a2823`, card-hi `#34312b`, inset `#14130f`, line +`#5a554b`, line-strong `#7b7466`, ink `#f6efdd`, muted `#a8a08e`, rust +`#e0705c`. Shadows use literal `#000`. + +**Theme file, component rules (`styles.css:330-1833`).** Literal colors +inside rules: `#606c93` (switch track text, :540), `#dcd3bc` (knob gradient, +:557), `#d2d8e6` (steel, :415), `rgb(246 239 221 / …)` (:386, :397), +`rgb(18 34 92 / 0.32)` (:562), `rgb(11 20 54 / 0.22)` (:476), `rgb(0 0 0 / …)` +(:558, :1300), plus ten `color-mix()` calls building tints from tokens +(:429-434, :786, :801, :836-838, :851-852, :1155, :1517, :1794-1801). + +**Outside the theme file.** + +| Location | Value | Purpose | +| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| `repository-switcher.ts:153-155` | 12 hexes (`#e3d9ff` `#54368f` `#55427a` `#f0e7ff` `#d1e7ff` `#285579` `#28516d` `#e0f1ff` `#d5ece4` `#285d49` `#29594b` `#d9fff0`) | Repository tile tone ramp, chosen by name hash | +| `repository-switcher.ts:185` | `#277346`, `#8acaa1` | "Active" status text, light/dark | +| `ui/input.ts:7`, `ui/input-group.ts:11`, `ui/textarea.ts:6` | `rgb(18 34 92 / 0.1)` | Inset lip shadow, copy-pasted three times | +| `workspace.ts:1476-1478` | `#111111`, `#ffffff`, `` `#${color}` `` | GitHub label badge: bg/border from GitHub's hex, contrast text computed by luminance | +| `activity.ts:408` | `` `#${color}70` ``, `` `#${color}18` `` | GitHub label pill with hardcoded alpha suffixes | +| `activity.ts:474` | `` `#${color}` `` | Rule dot | +| `public/site.webmanifest:16-17` | `#0d47a1`, `#ffffff` | `theme_color` (stock Material blue, unrelated to the palette), `background_color` | +| `test/components/workspace.test.ts:714-724` | `#ffffff` `#111111` `#000000` `#d73a4a` | Assertions on the label badge helper | + +**Stock Tailwind palette classes (bypass tokens entirely).** + +| Class | Locations | +| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `text-emerald-600/400` | `activity.ts:381`, `policy-editor.ts:1288`, `rule-editor.ts:1157`, `test-bench.ts:179` | +| `text-emerald-700/300`, `bg-emerald-500/15` | `sessions.ts:350` | +| `text-amber-700/400` | `activity.ts:385,391`, `ai-input-view.ts:40,74` | +| `text-amber-600/400` | `policy-editor.ts:822`, `rule-editor.ts:1052`, `test-bench.ts:183` | +| `text-amber-700/300`, `bg-amber-500/15` | `sessions.ts:352,454,523,556` | +| `text-amber-500` | `sync-button.ts:370` | +| `bg-black/40` | `policy-editor.ts:1346`, `repository-connections.ts:461`, `rule-editor.ts:1621` (dialog scrims; `ui/sheet.ts` uses `bg-navy-deep/55` instead) | + +No `hsl()`, `oklch()` or named colors appear outside the theme file. There is +no `` in `index.html`. + +### 1.2 Fonts + +- Loaded: `@fontsource-variable/archivo`, `big-shoulders-display`, + `spline-sans-mono` (`styles.css:3-5`, `package.json`). +- Stacks: `--font-sans` Archivo, `--font-sign` Big Shoulders Display, + `--font-mono` Spline Sans Mono (`styles.css:214-216`). `font-display` is + declared in the doc but has no utility; `font-sign` is used once + (`ui/sign.ts:6`); `font-mono` 10 times. +- Hardcoded stack outside the theme: `policy-source/editor.ts:54` sets + `.cm-scroller` to `ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, +monospace`, so the YAML editor does not use the app mono face. +- Type scale (`styles.css:218-241`): body-sm 13px, body-md 15px, label 14.5px, + button 14px, h3 16px, h2 20px, sign 25px, numeral 27px, stamp 34px, display + 44px, mono-sm 11px, mono-md 14.5px. Base body is 15px; target is 13px. +- Arbitrary text sizes in TSX: `text-[10px]` ×3 (`activity.ts:488,620,679`), + `text-[11px]` ×8 (`repository-switcher.ts`, `ai-input-view.ts:29`), + `text-[13px]` ×3 (`repository-switcher.ts`). Dozens of literal `font-size` + values (9px to 21px) in the component rules of `styles.css`. +- Weight `550` is used at `styles.css:659`. +- No `tnum` / tabular figures anywhere. + +### 1.3 Radii + +Theme: `--radius` 6px, xs 3px, sm 4px, md 6px, lg 14px, xl through 4xl +computed from `--radius` (`styles.css:243-251`). Utilities used in TS: +`rounded-sm` 27, `rounded-md` 15, `rounded-lg` 8, `rounded-xs` 5, +`rounded-xl` 5, `rounded-full` 4, `rounded-none` 3, +`rounded-[calc(var(--radius)-5px)]` 1 (`ui/input-group.ts:14`), bare +`rounded` 1. Component CSS also uses `4px 4px 14px 14px` (`jn-tag`), `50%`, +`999px`, `9999px`. + +### 1.4 Shadows + +The current identity is built on hard drop edges and insets, which the target +removes entirely. + +| Source | Value | Where | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| `--jn-edge` | `0 3px 0 navy-deep` | `shadow-edge` on every button (`ui/button.ts:58`), `main.ts:1263` | +| `--jn-mount` | edge + `0 6px 14px rgb(11 20 54 / .28)` | `jn-mount` utility (panels), `shadow-mount` (`ui/sheet.ts`, `ui/sidebar.ts:228,791`) | +| `--jn-recess` | `inset 0 3px 6px rgb(11 20 54 / .55)` | switch tracks | +| inline | `inset 0 2px 0 rgb(18 34 92/0.1)` | input, input-group, textarea | +| inline | `0 3px 0 var(--jn-rust-dark)` | destructive button | +| inline | `0 0 0 2px var(--sidebar-border)` | `ui/sidebar.ts:555` | +| stock | `shadow-md` (`workspace.ts:1609`, `sync-button.ts:381`), `shadow-lg` (`repository-switcher.ts:273`), `shadow-xl` (three dialogs) | overlays | +| CSS | `.jn-sign` inset keyline + mount, `.jn-tag` `0 3px 0`, knob `0 2px 0`, view-toggle `0 0 0 2px` | component rules | + +Gradients: `jn-sign`, `jn-steel`, `jn-perforated`, `jn-hazard`, `jn-empty` +hatch, switch knob, wall and noise images. All are out under the target. + +### 1.5 Spacing and density + +- Rows: rules table cells `18px 10px` padding (`styles.css:1233`), activity + event summary 96px tall (`:1590`), activity group 72px (`:1662`), policy + library items `9px 10px`. Target is 28px rows. +- Panels: `p-[18px]` (`ui/panel.ts:18`), `px-[18px]` ×3 (`account.ts`), + page padding 24px. Cards stack with 18px gaps already in places. +- Sidebar widths: `16rem` / `18rem` mobile / `3rem` icon + (`ui/sidebar.ts:28-36`); policy library 224px; rule inspector 300px; policy + inspector 280px. Target: sidebar 216px, inspector 292px. +- Other arbitrary values: `w-[200px]`, `w-[2px]`, `size-[30px]`, + `size-[34px]`, `size-[5px]`, `mt-[20vh]` ×3, `w-[calc(100%-2rem)]` ×3, + `min-h-[60vh]`, `max-h-[min(430px,60dvh)]`, `max-w-[calc(100vw-1rem)]`, + `grid-cols-[max-content_1fr]`, `grid-cols-[200px_minmax(0,640px)]`, + `ml-[-0.3rem]` and siblings ×4, `border-[2.5px]` ×2, `border-*-[3px]` ×16, + `translate-*-[±2.5rem]` ×40 (all in `ui/sheet.ts`), `translate-y-[3px]`, + and the sidebar `calc(var(--sidebar-width-icon)+…)` trio. +- Breakpoints in CSS: 1200px, 1199px, 900px, 640px, 620px, 540px. Target: + 1240px and 820px. + +### 1.6 Motion + +- `transition-*` utilities: colors 6, transform 2, all 2, opacity 1, shadow 1, + bare 4, arbitrary property lists 6; durations 100/150/200ms; easings + `ease-linear` ×5, `ease-in-out` ×4, and a spring `--jn-ease` + `cubic-bezier(0.3, 1.6, 0.6, 1)`. +- `animate-spin` ×2 (sync icon, validation spinner), `animate-pulse` + (`ui/skeleton.ts:11`, the shimmer the target forbids). +- Hover lifts: `.jn-tag:hover` rotates and translates (`styles.css:492`). + Press travel: buttons translate 3px on `:active`. +- Entry/exit: foldkit `data-enter` / `data-leave` / `data-starting-style` / + `data-ending-style` variants in `ui/sheet.ts` (44 hits). `tw-animate-css` is + imported but no `animate-in`/`fade-in`/`slide-in` class is used anywhere. +- `prefers-reduced-motion` is honored globally (`styles.css:330-337`) and for + the rule switch (`:1400-1405`). + +### 1.7 SVG and icons + +- `lib/icons.ts:49-59` renders lucide nodes with `stroke="currentColor"`, + `fill="none"`, `stroke-width="2"` (not configurable), default class + `size-4 shrink-0`. Target is 14px at 1.5px; sizes used range `size-3` to + `size-10`. +- `policy-source/editor.ts:114-135` builds a fold chevron by hand with + `stroke-width="1.5"`. +- No charts render anywhere. `--chart-1..5` are defined and unused. + +### 1.8 CodeMirror + +Two editors (`policy-source/editor.ts`, `policy-source/ai-editor.ts`) use +`githubLight` / `githubDark` from `@uiw/codemirror-theme-github`, whose syntax +colors are GitHub's and unrelated to either identity. No custom +`HighlightStyle`. `EditorView.theme()` sets font size 13px, padding, min/max +heights and the hardcoded mono stack. `styles.css` overrides `.cm-editor`, +`.cm-gutters`, `.cm-foldGutter` backgrounds and the `.ai-fact-reference` +decoration (cobalt tint). See gap G4. + +## 2. Primitive layer ("shadcn" equivalent) + +Installed `@foldkit/ui` modules in use: button, textarea, dialog, input, +anchor, toast, popover, tooltip, switch, disclosure, select, menu, virtualList. +All headless. + +Project-owned styled primitives in `apps/web/src/components/ui/`: + +| File | Renders | `data-slot` | Notes | +| ---------------- | ------------------------------------------------------------------ | ------------------- | ---------------------------------- | +| `button.ts` | 6 variants, 8 sizes; 2.5px outline, hard edge, press travel | `button` | Default variant is yellow | +| `chip.ts` | mono pill: neutral / on (yellow) / danger / agent (cobalt, unused) | `chip` | | +| `command.ts` | palette container/input/list/item/group | `command*` | three `!` overrides | +| `input-group.ts` | composite input shell, addons | `input-group*` | raw `onclick` string at :387 | +| `input.ts` | label + input + description | `label`, `input` | inset lip shadow | +| `mark.ts` | platform mark and avatar squares | none | only primitive without `data-slot` | +| `panel.ts` | raised cream plate, `emptyPanel` | `panel` | `jn-mount`, `p-[18px]` | +| `rack.ts` | hook-rail navigation with rotated tags | `rack` | rotation, steel gradient | +| `sheet.ts` | side dialog; four identical panel strings | `sheet-*` | 40 arbitrary translates | +| `sidebar.ts` | 797-line sidebar submodel and 20 view helpers | `sidebar*` | random skeleton width at :648 | +| `sign.ts` | enamel section heading, uppercase | `sign` | | +| `skeleton.ts` | `animate-pulse` block | `skeleton` | shimmer | +| `textarea.ts` | label + textarea + description | `label`, `textarea` | | + +All of these were written for Utility Room; none are generated shadcn files, +so "modified from generated form" does not apply. Every one will change in +Phase 2. Missing primitives the target names and the app renders ad hoc: +badge, card, table, tabs, dialog content, dropdown content, tooltip content, +toast, select trigger, switch. Those are currently styled inline in +`workspace.ts`, `rule-editor.ts`, `policy-editor.ts`, `main.ts` +(toast, `:1157-1173`), `sync-button.ts` (tooltip), `repository-switcher.ts` +(popover) and in the `.policy-*` / `.rule-*` / `.activity-*` CSS blocks. + +## 3. Custom CSS + +Everything is in `styles.css`: + +- `@layer base` (`:290-338`): `border-border outline-ring/50` on `*`, wall + image and noise pseudo-element on body, 3px yellow focus ring, reduced + motion. +- `@utility` blocks: `no-scrollbar`, `jn-mount`, `jn-recess`, `jn-sign`, + `jn-screw`, `jn-press`, `jn-hazard`, `jn-steel`, `jn-perforated`, + `jn-empty`, `jn-agent`. Of these, TS uses `jn-mount`, `jn-sign`, `jn-screw`, + `jn-empty`, `jn-steel`, and `jn-dense`; `jn-recess`, `jn-press`, + `jn-hazard`, `jn-perforated`, `jn-agent` are dead. +- `@layer components` (`:464-599`): `.jn-tag` (used), `.jn-switch*` (dead in + TS; used only by the docs mockups). +- Unlayered page CSS (`:601-1833`): `.policy-*` (39 classes), `.rule-*` / + `.rules-*` (19), `.activity-*` (22), `.ai-*` (3), `.repository-*`. Several + rules are redefined later in the file with different values (rules-table + column widths three times, `.policy-library-*` twice, `.rule-delete-actions` + twice). Dead selectors: `.activity-decision-*` (5), `.activity-rule-action`, + `.policy-save-status`, `.policy-status-badge` variants are used via + `policy-status.ts`. +- `@apply` appears only in the base layer. No CSS modules, no CSS-in-JS beyond + the two CodeMirror theme objects. + +## 4. Surfaces outside the app tree + +| Surface | Location | Carries brand? | +| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| Favicons and touch icon | `public/*.png`; `index.html:6` references `/favicon.ico`, which does not exist | Yes, mascot artwork; out of scope for CSS, noted in G6 | +| Web manifest | `public/site.webmanifest` | `theme_color #0d47a1` must move to the new canvas or primary | +| Mascot logo | `src/assets/janitor-logo-{light,dark}.png`, rendered once in the app header via `janitor-icon.ts` from `main.ts:893` | **Violates the target**: mascot must not appear in the application interface | +| Settings mockups | `docs/settings-mockups/*.html`, `utility-room.css`, Google Fonts link | Static Utility Room artifacts; served separately (see memory note); leave as history or delete, G7 | +| Design reference CSS | `docs/design/theme.css` | Utility Room copy of the theme; superseded | +| GitHub comments | `apps/cluster/src/GitHub/FeedbackHttp.ts:196-224` | Plain agent text plus an HTML marker comment. No visible attribution; the GitHub App identity supplies the bot badge | +| Pull requests | `runner/src/Publication.ts:529-530`, `:344` | Title and body from the agent plus a marker comment; no attribution line | +| Slack messages | `apps/cluster/src/Slack/Transport.ts:163`, `Delivery.ts:100-146`, `Processor.ts` | `mrkdwn: false`, no Block Kit, plain sentences | +| Emails, PDFs, OG images, Storybook, docs site | none | none exist | +| Auth screen | none; Cloudflare Access handles sign-in | n/a | +| Error pages | in-app only (`main.ts:1232-1243` 404, `:1302-1326` unavailable repository) | yes, restyled in Phase 3 | +| Loading skeletons | `ui/skeleton.ts`, sidebar only; everything else uses "Loading…" text | shimmer to remove | + +## 5. Agent-authorship inventory + +**The product currently renders no agent-versus-human distinction anywhere.** +The only discriminator is the text "AI rule" / "Policy rule" in the activity +feed. The list below is what Phase 3 marks with `agent` yellow, an +`agent-ink` actor name, and the words "The Janitor" so the signal survives +greyscale and screen readers. + +### 5.1 Entirely agent-authored surfaces + +| Surface | Location | What the agent did | Current marking | +| ------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| Activity feed, every event | `activity.ts:566-689` | Evaluated an issue or PR and reconciled labels. `ActivityEntry` has no actor field; everything is a Janitor run | none | +| Applied label pills | `activity.ts:403-412`, `:522-561` | Added or removed a GitHub label | none; GitHub label color | +| Per-rule decision cards | `activity.ts:413-521` | Matched / no-match / unknown / failed | `.activity-decision-*` tones incl. **yellow for "unknown"** | +| AI reason prose | `activity.ts:513-515` | LLM wrote this sentence | none | +| Evaluation summary and rules-revision footnote | `activity.ts:663-683` | | none | +| Sessions list and detail | `sessions.ts:420-461`, `:537-578` | Agent conversation in a Slack home thread; title derived from first message (`Processor.ts:211`) | none | +| Execution badge Working / Idle / Blocked / Failed | `sessions.ts:342-354`, `:376-387` | The "agent is running" pill | emerald / amber / destructive | +| Recorded usage, latest error, pending delivery | `sessions.ts:364-367`, `:547-552`, `:579-597` | | none | +| Recovery scan section | `sessions.ts:490-535`; server `Agent/RecoveryStatus.ts` | Agent re-read GitHub and Slack history | amber when overdue | +| Session PR link | `sessions.ts:389-418` | The PR is agent-authored | plain link | +| Rule test bench result | `rule-editor.ts:1111-1233` | Agent evaluated the rule against one item; reason, confidence, trace, preview action | none | +| Test bench (draft and configuration modes) | `test-bench.ts:186-281`, `:359-439`, `:445-514` | Agent evaluated rules across recent items | outcome in emerald / amber | +| AI input inspector | `ai-input-view.ts:16-161` | What was sent to the model, what was omitted | amber for omissions | +| Sync status button and tooltip | `sync-button.ts:290-327`, `:344-375`; header mount `main.ts:1132-1143` | Janitor reading GitHub state; spinner while running | amber when failed | +| Sync toasts | `main.ts:351-395` | | warning border yellow | +| Live-channel banner | `main.ts:1117-1131` | Push channel for agent changes | muted | + +### 5.2 Mixed surfaces (agent-shaped content inside human editing) + +| Surface | Location | Note | +| --------------------------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| AI rule type, prompt editor, gate, confidence | `rule-editor.ts:1304-1338`, `:1714-1843` | Human writes the prompt; the rule is executed by the agent. Mark the rule as AI-evaluated, not the prompt as agent-written | +| Rules table Type column | `workspace.ts:1461-1466`, `:1540-1548` | "AI" and "Policy" share the same icon and color | +| Rule behavior summary | `workspace.ts:1458-1459` | First line of the AI prompt | +| `classify` node and inserted snippet | `policy-source/completion.ts:38`, `:308-310` | Editor-inserted template, not agent-authored; should not be yellow | +| Fact references in prompts | `ai-editor.ts:46-51`, `styles.css:1514-1521` | Cobalt tint today; interactive-ish, decide in G5 | +| AI consent panel (Settings) | `workspace.ts:1752-1836` | Human decision about the agent; "Draining" state is agent activity | + +### 5.3 Human-authored content that must stay Inter and grey + +Policy titles and descriptions, YAML policy source, rule names, AI prompt +text, account and team data, PR author login in the test bench +(`test-bench.ts:191`), and the `Inputs` counter of human messages in a +session (`sessions.ts:570-572`). + +### 5.4 Yellow today, classified + +| Use | Location | Class under target | Action | +| -------------------------------------------- | ------------------------------------- | ----------------------- | ------------------------------------------- | +| Global focus ring and 11 component overrides | `styles.css:320-323` and others | interactive | becomes blue `--ring` | +| Default button variant, connect CTA | `ui/button.ts:20`, `main.ts:1263` | interactive | becomes blue primary | +| `admin` role chip | `ui/chip.ts:13`, `account.ts:420,535` | decorative | neutral badge | +| Active nav tag | `styles.css:496-502` | interactive | `primary-wash` + 2px blue edge | +| Rule enable switch on-state | `styles.css:1286-1291` | interactive | blue | +| Published policy badge | `styles.css:795-799` | on-state | neutral badge with text | +| Activity "unknown" decision | `styles.css:1800-1803` | warning on an agent row | neutral; the row itself gets the agent mark | +| Warning toast border | `main.ts:1164` | warning | neutral (no warning color exists) | +| `jn-hazard`, `.jn-switch`, `--chart-2` | `styles.css:407,567,93` | unused | delete | + +Amber classes listed in 1.1 are all warnings or outcome tones and become +neutral, danger, or success per the target's status rule. + +## 6. Token mapping + +Rows marked **drift** keep a name but change meaning; these are the ones to +watch when aliasing in Phase 1. + +| Existing | Target | Value change | Drift | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--primary` | `--primary` | `#ffce1b` → `#1e5fd0` | **drift**: yellow CTA → blue interactive | +| `--primary-foreground` | `--primary-foreground` | navy → white | pairs with above | +| `--accent` | `--accent` | cobalt `#1e5fd0` → `primary-wash #edf3fd` | **drift**: solid cobalt fill → pale selection wash; `bg-accent` hover rules stay valid, `text-accent-foreground` on it flips from cream to blue-hover | +| `--accent-foreground` | `--accent-foreground` | cream-hi → `#174ca8` | | +| `--ring` | `--ring` | yellow → blue | drift: focus color | +| `--background` | `--background` | wall `#ddd8c9` → canvas `#f4f6f8` | wall image removed | +| `--card` / `--popover` | same | cream → white | | +| `--secondary` | `--secondary` | cream → `surface-muted #fafbfc` | | +| `--muted` | `--muted` | mortar `#c7c1b0` → `#fafbfc` | **drift**: was a darker fill used for pills and toggles; becomes near-white. Anything relying on `bg-muted` for contrast against `bg-card` loses it | +| `--muted-foreground` | `--muted-foreground` | `#4a5478` → `ink-subtle #6e7681` | lighter; only AA on white/surface-muted, check every use on darker fills | +| `--border` / `--input` | same | navy `#12225c` → `#d6dbe1` | **drift**: was a 2 to 3px dark outline; becomes a hairline. All `border-2`, `border-[2.5px]`, `border-*-[3px]` must drop to 1px | +| `--destructive` | `--destructive` | same rust | none | +| `--sidebar*` | `--sidebar*` | wall/navy/yellow → surface/line/blue | same drifts as above | +| `--chart-2` | `--chart-2` | yellow → `#7fa6e8` | | +| `--radius` | `--radius` | 6px → 5px | | +| `--radius-sm` | `--radius-sm` | 4px → 5px | | +| `--radius-md` | `--radius-md` | 6px → 8px | **drift**: md moves from cards to sheets only | +| `--radius-lg`, `xl`…`4xl` | none | removed | 8 `rounded-lg` and 5 `rounded-xl` uses need a target radius | +| `--jn-cobalt` / `-dark` / `-light` | `--oc-blue` / `--oc-blue-hover` / none | same hex for the first two; `cobalt-light` has no target | `cobalt-light` was the agent color; its 5 uses move to `agent` tokens or `primary-line` | +| `--jn-navy`, `-deep`, `-panel`, `-inset` | none | removed | used as border, text, gradients, scrim (`bg-navy-deep/55`) | +| `--jn-yellow` / `-dark` | `--oc-agent` / none | same hex; meaning changes entirely | **drift**: CTA → agent authorship | +| `--jn-cream`, `-hi`, `-wall`, `-mortar`, `-board` | `--oc-surface`, `-surface-muted`, `-canvas` | | one-to-many collapse | +| `--jn-rust` / `-dark` | `--oc-danger` / `-hover` | same | | +| `--jn-metal`, `-dark` | none | removed | `jn-steel` only | +| `--jn-muted-ink` | `--oc-ink-muted` | `#4a5478` → `#59606b` | | +| `--jn-muted-panel-ink` | none | removed | | +| `--jn-outline` | `--border` | navy → hairline | | +| `--jn-edge`, `-mount`, `-recess` | none | removed; overlays get `--oc-overlay-shadow` | | +| `--jn-ease` (spring) | `--ease-ui` | overshoot → ease-out | | +| `--jn-wall-image`, `-noise-image` | none | removed | | +| `--font-sans` | `--font-sans` | Archivo → Inter | | +| `--font-mono` | `--font-mono` | Spline Sans Mono → JetBrains Mono | | +| `--font-sign` | none | removed | `ui/sign.ts` becomes a plain `h2` | +| `--text-body-md` | `--text-body-md` | 15px → 13px | base size drop | +| `--text-body-sm` | `--text-body-sm` | 13px → 12px | | +| `--text-label` | `--text-label` | 14.5px → 12.5px | | +| `--text-button` | `--text-label` | 14px → 12.5px | name removed | +| `--text-h2` / `h3` | same | 20/16px → 14/13px | | +| `--text-mono-sm` | `--text-mono-sm` | 11px → 11.5px | | +| `--text-mono-md` | `--text-mono-md` | 14.5px → 12.5px | | +| `--text-sign`, `-stamp`, `-display`, `-numeral` (27px) | none, none, none, `numeral` 11.5px | | **drift**: `numeral` survives as a name but shrinks from a 27px hero figure to an 11.5px table figure | +| none | `--text-h1`, `--text-caption`, `--text-mono-xs` | new | | +| none | `agent-ink`, `agent-wash`, `agent-line`, `wire`, `grid-line`, `success`, `primary-line`, `primary-wash`, `border-subtle`, `ink-faint` | new | `success` replaces the emerald classes | + +Utility names to alias during Phases 1-3 then delete in Phase 4: +`cobalt*`, `navy*`, `panel`, `inset`, `yellow-safety*`, `cream*`, `wall`, +`mortar`, `board`, `rust*`, `metal*`, `outline`, `shadow-mount`, +`shadow-edge`, `shadow-recess`, `ease-switch`, `text-sign`, `text-stamp`, +`text-display`, `text-button`, `font-sign`, `jn-*`. + +## 7. Gap list (questions, not decisions) + +- **G1. Where does `DESIGN.md` live?** The task says repository root; the + project keeps it at `docs/design/DESIGN.md` next to `theme.css`, and + `styles.css` cites that path. Proposed: write to `docs/design/DESIGN.md`, + delete `docs/design/theme.css`, and add a one-line root `DESIGN.md` pointer + only if you want one. Confirm. +- **G2. Blueprint canvas has no host.** The rule editor is a vertical + When / If / Then form, not a graph, and there is no PR-flow view. "Do not + invent components" and "rule graphs render on a blueprint canvas" conflict. + Proposed: restyle the existing three-step flow with the node, wire, column + label and grid vocabulary inside its card (it is a degenerate graph of three + nodes), and leave the layout-engine and YAML-fallback requirements as an + open question. Alternative: keep it a plain form and record the canvas as + unbuilt. Which? +- **G3. GitHub label colors.** Label pills use GitHub's own hex per label + (`workspace.ts:1467-1479`, `activity.ts:403-412`). The target says label + names are mono badges and nothing else is colored, but a repo's labels are + user data. Proposed: neutral badge with the label color reduced to a small + dot or left border, keeping the luminance helper for that mark. Confirm, or + keep full-color pills. +- **G4. Syntax highlighting theme.** Appendix A does not specify one. The + GitHub theme is neutral-ish but uses its own blues, purples and reds, which + puts a third accent on the policy screen. Proposed: keep the GitHub theme + in Phase 3, log it in `OPEN-QUESTIONS.md`, and offer a two-tone + ink/ink-muted highlight as a follow-up. +- **G5. Fact references inside AI prompts** (`{{fact:title}}`) are tinted + cobalt today. They are not interactive and not agent-written. Proposed: + `surface-muted` background, `foreground-muted` text, mono. +- **G6. Mascot in the header.** The target forbids it inside the app. The + only in-app use is the 32px logo at `main.ts:893`. Proposed: replace with a + small text product mark ("The Janitor" in `label`). Favicons stay as-is. + Confirm removal. +- **G7. Utility Room artifacts in `docs/`.** `docs/settings-mockups/` and + `docs/design/theme.css` carry the old identity. Delete, keep as history, or + leave untouched? +- **G8. Status tones.** The target has `success` and `danger` only. Today: + session `Blocked`, recovery `overdue`, sync `failed targets`, delivery + warnings, conflict notices, and test-bench `no-match` are amber. Proposed: + `Blocked` and `overdue` and delivery warnings become neutral text with an + icon; failures become `danger`; `no-match` becomes neutral. Confirm. +- **G9. Repository tile tone ramp** (12 hexes in `repository-switcher.ts`). + No neutral equivalent in the target. Proposed: mono initials mark on + `surface-muted` with a hairline, no per-repo color. +- **G10. Manifest `theme_color`.** Proposed: `#f4f6f8` (canvas) light; there + is no per-scheme manifest color. Or `#1e5fd0`? Pick one. +- **G11. Layout widths.** Sidebar is 256px, inspectors 280px and 300px, + breakpoints 1200/900/640. Target says 216px, 292px, 1240/820. Layout is out + of scope, but the tokens disagree. Proposed: adopt the target widths and + breakpoints as a pure dimension change, no structural change. Confirm. +- **G12. Sessions "Working" pill.** Target: the top-bar status pill uses an + `agent` dot when the agent is running. The app has no top-bar status pill; + the nearest thing is the sync button. Proposed: give the session execution + badge the agent dot and leave the header alone. +- **G13. Test baselines.** No snapshot or visual-regression baselines exist. + Tests assert on class strings in places (`workspace.test.ts:714-724` and + others); those will be updated as they break. Screenshots will be taken + manually per the verification list. Confirm that no baseline tooling should + be added. +- **G14. `--primary` on ``.** Appendix B colors every anchor blue. The + sidebar and rack render navigation as anchors and many rows are anchors; + target says rows are grey with `primary-wash` on selection. Proposed: base + rule applies, navigation components override to `foreground`. Confirm. diff --git a/RESTYLE-REPORT.md b/RESTYLE-REPORT.md new file mode 100644 index 00000000..fcc33bf0 --- /dev/null +++ b/RESTYLE-REPORT.md @@ -0,0 +1,198 @@ +# Restyle report: Utility Room → Ops Console / Blueprint + +Branch `t3code/restyle-ops-console-blueprint`, six commits on top of `a158d2f`. +The audit is `RESTYLE-AUDIT.md`; unresolved cases are `OPEN-QUESTIONS.md`; +the normative identity is `docs/design/DESIGN.md` (the project keeps it there, +per the owner's answer to gap G1). + +## What changed per phase + +**Phase 0, `a119bc2`.** `RESTYLE-AUDIT.md`. No code. + +**Phase 1, `edd0a43`.** Appendix A written over `docs/design/DESIGN.md` with a +"Project notes" section for the three owner decisions (mascot stays in the top +bar, GitHub label colours stay as muted dots, the rule editor is the blueprint +host). `apps/web/src/styles.css` rebuilt from Appendix B: palette, shadcn +semantic set for light and dark, theme mapping, base layer with Inter feature +settings and tabular figures, blueprint utilities, agent utilities, dense +table and primitive overrides through `data-slot` selectors, and an alias +block keeping every Utility Room name alive. Fonts switched from Archivo, Big +Shoulders Display and Spline Sans Mono to Inter and JetBrains Mono through +the existing Fontsource mechanism. The manifest `theme_color` and two new +`theme-color` meta tags carry the canvas colour. Utility Room mockups +(`docs/settings-mockups`), the reference `docs/design/theme.css` and the +`.scratch/utility-room-restyle` planning files were deleted. The lint tool +reported one legitimate contrast warning, fixed by darkening +`foreground-subtle` from `#6E7681` to `#6C747F` (4.56:1 on `surface-muted`). + +**Phase 2, `2f403ac`.** Every project primitive restyled (list below), new +styled wrappers added for controls the app rendered ad hoc, tailwind-merge +taught the theme's font sizes so `text-label` no longer discards colour +classes, icons set to 14px at 1.5px stroke, and the `/design-system` route +added. It renders every primitive in every state in light, then the same set +inside a `.dark` container, including a 200-row dense table, the activity +feed, the inspector and a blueprint canvas. + +**Phase 3, `254049f`.** All screens converted: app shell (sidebar, top bar, +toasts, home, 404, unavailable-repository, first-repository), rules table, +overview, settings, policy library and editor, CodeMirror chrome, activity +feed, test bench, AI input inspector, sessions list and detail, account, +connections, and the rule editor rewritten onto the blueprint canvas. Agent +authorship marked everywhere the inventory listed it. + +**Phase 4, `accff1f` and `30e6de4`.** Alias block, dense-list rule and every +unreferenced page CSS rule deleted; sidebar `calc()` offsets moved from +arbitrary class values into the theme file; `tw-animate-css` removed from the +workspace; a non-link issue number returned to plain mono; focus rings +restored on inputs, selects, textareas, menu buttons, sidebar items, command +items and code editor hosts. + +## Files in `components/ui/` and why + +This codebase is foldkit, not React or shadcn. `@foldkit/ui` is headless and +emits no classes or `data-slot` attributes, so the project's own styled layer +in `apps/web/src/components/ui/` is the only place the Utility Room class +strings lived. Tailwind utilities in those strings outrank the +`@layer components` overrides, so restyling through selectors alone was not +possible. Every file was edited for that reason: + +| File | Change | +| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `button.ts` | Flat 1px border, no shadow or press travel; blue primary, plain secondary and outline, filled danger; 28px default; emits `data-size` and `data-variant`; `buttonBase` exported for link-shaped buttons | +| `chip.ts` | Badge: 3px radius, mono-xs, variants `neutral`, `selected` (pill), `success`, `danger`, `agent`; emits `data-slot="badge"` | +| `command.ts` | Palette on `popover` with hairline separators, caption headings, mono shortcuts | +| `input.ts`, `textarea.ts`, `input-group.ts` | `surface-muted` fill, 1px border, 28px, primary border and 2px ring on focus; help text at `foreground-muted` | +| `mark.ts` | Hairline mono squares, 28px and 32px | +| `panel.ts` | Card with optional header strip (`panelHeader`) and `emptyPanel` | +| `rack.ts` | Section nav: 26px rows, primary wash and 2px edge on `aria-current` | +| `sheet.ts` | One panel class, hairline border, 8px radius, overlay shadow, 120ms user-triggered entry | +| `sidebar.ts` | 216px width from the token, 26px rows, caption group labels, no shadows, deterministic skeleton, `data-padding` for the CSS offsets | +| `sign.ts` | Plain sentence-case h2 | +| `skeleton.ts` | Static `surface-muted` block | +| `switch.ts`, `select.ts`, `dialog.ts`, `overlay.ts`, `table.ts`, `feed.ts`, `inspector.ts`, `blueprint.ts` | New: styled wrappers so screens stop styling headless controls inline | + +## Tokens added beyond the target + +| Token | Justification | +| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `--oc-on-blue` | Text on filled primary in both themes without a literal in the semantic block | +| `--color-destructive-hover` | Danger button hover, from `danger-hover` in DESIGN.md | +| `--color-primary-hover`, `--color-primary-wash`, `--color-primary-line`, `--color-scrim` | Utility access to values Appendix B defines only as raw variables | +| `--text-numeral` | The `numeral` typography scale was in DESIGN.md but not in Appendix B | +| `--spacing-sidebar`, `--spacing-inspector`, `--spacing-app-bar` | 216px, 292px and 46px from DESIGN.md Layout, so widths are not literals in code | +| `oc-human-dot` utility | The `foreground-faint` feed marker DESIGN.md describes for human entries | +| `data-numeric` and `data-code` cell attributes | Right-aligned tabular and mono table cells without per-cell classes | +| `--chart-2` dark value `#4d7cc4` | Appendix B leaves the dark chart-2 undefined | + +## Contrast table + +Computed from the token file. Dark values in parentheses. + +| Pair | Light | Dark | +| ------------------------------------------- | ----- | ---- | +| foreground on surface | 16.4 | 14.2 | +| foreground on canvas | 15.1 | 15.5 | +| foreground-muted on surface | 6.3 | 8.5 | +| foreground-muted on surface-muted | 6.1 | 7.9 | +| foreground-muted on canvas | 5.9 | 9.3 | +| foreground-subtle on surface | 4.7 | 6.1 | +| foreground-subtle on surface-muted | 4.6 | 5.6 | +| foreground-subtle on canvas | 4.4 | 6.6 | +| primary link on surface | 5.8 | 6.8 | +| primary-hover on primary-wash | 7.2 | 7.7 | +| on-primary on primary | 5.8 | 7.4 | +| agent-ink on surface | 5.1 | 10.4 | +| agent-ink on agent-wash | 4.8 | 9.3 | +| success text on surface | 5.1 | 6.7 | +| danger text on surface | 5.9 | 5.5 | +| switch off track on surface (non-text) | 2.2 | 2.3 | +| agent dot with agent-ink outline (non-text) | 5.1 | 10.4 | + +The tightest text pair is `foreground-subtle` on `surface-muted` at 4.56:1 +light, after the one-step darkening. `foreground-subtle` on `canvas` is +4.36:1 and no shipped text uses that pair; it is open question 4. The switch +track is below 3:1 by the target's own value; open question 3. + +## Prose rules and how each was verified + +| Rule | Verification | +| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Blue only for interactive elements | Grep of `text-primary`, `bg-primary*`, `border-primary*` outside `ui/`: every hit is a link, focus state, pressed toggle or active nav item. One non-link issue number found and returned to plain mono in Phase 4. | +| Yellow only for agent authorship | Grep of `agent` tokens and `oc-agent-*`: activity feed rows and AI reasons, sessions, test bench results, AI input inspector, AI rule chip, classification node. No warning uses yellow; the old `.activity-decision-unknown`, warning toast border, published badge and switch on-state are gone. | +| Agent entries also name the actor | Every agent mark sits next to "The Janitor" or "AI" text (`Feed.actorName`, `Feed.agentBadge`, the chip). Confirmed on `/repositories/701/activity`, `/sessions`, `/repositories/701/rules/r3`. | +| `--primary` is blue | `--primary: var(--oc-blue)` in both themes; the primary button and focus ring render `#1e5fd0` (computed `--ring` read from the page). | +| Mono is a signal | Repository names, branches, label names, issue and PR numbers, revisions, counts, timestamps, rule expressions and IDs are `font-mono`; policy names, descriptions, prompts, session titles, emails and help text are Inter. Checked screen by screen in the screenshots. | +| Tabular figures | `table` and `[data-numeric]` set `tabular-nums` in the base layer; body sets `"cv05" 0, "ss01" 0`. | +| No shadows except overlays | Grep for `shadow-` in app code returns only `shadow-none` and `shadow-overlay`; `--oc-overlay-shadow` is used by dialogs, sheets, menus, popovers, tooltips and toasts only. | +| No gradients, no blurred backdrops | The wall image, noise, enamel and steel gradients are gone; the scrim is flat `--oc-scrim` with `backdrop-filter: none`. | +| Density: 28px rows, 13px base | Body computes to 13px Inter; `Table.cell` rows are 28px; sidebar and section nav rows are 26px; rules table collapsed to one line per row. | +| Motion only on user-triggered state | All transitions are 120ms `--ease-ui` on hover, focus, switch and disclosure; no entrance or scroll animations; `animate-pulse` removed; `tw-animate-css` removed. With `prefers-reduced-motion: reduce` the page reports transition durations of 0.01ms and zero running animations. | +| Blueprint vocabulary only on graph views | `oc-canvas`, `oc-node`, `oc-wire`, `oc-junction` and `oc-annotation` appear in `ui/blueprint.ts`, the rule editor and the design-system page only. | +| No `foreground-faint` for text | `text-ink-faint` appears nowhere in app code; the token backs the switch track and human feed dots only. | +| `border` for structure, `border-subtle` for rows | Cards, sidebar, top bar and inspector use `border-border`; table rows, feed items, list rows and inspector rows use `border-border-subtle`. | +| Sentence case, no tracked uppercase | The uppercase `rule-step-title` and enamel sign are gone; grep for `uppercase` and `tracking-` in app code returns nothing. | +| Everything left-aligned, numerics right-aligned | Centered overview, connect, sessions and empty states were left-aligned; numeric columns use `data-numeric`. | +| Cards do not nest | Inspector sections, disclosures and result blocks use bordered sections inside the column, not cards inside cards. | +| Empty and error states | One sentence plus at most one button in `emptyPanel`; errors carry `role="alert"` and say what to do; no illustrations inside working panels. | +| Help text under labels, not tooltips | `input`, `textarea` and `Select.view` render descriptions below the control; the sync tooltip is the only tooltip and it existed before. | +| Focus visible everywhere | Tabbed through the rules table, the AI rule editor and the activity feed: every stop reports a 2px solid `#1e5fd0` outline, including the code editor host via `focus-within`. | +| Button labels are outcome verbs | "New rule", "Run as a test", "Disconnect repository", "Delete rule", "Grant access on GitHub", "Refresh repositories". "Cancel" and "Save & publish" were left as they were because tests and copy depend on them. | +| Dark mode ships | Semantic tokens re-declared inside `.dark` so nested containers resolve; every route screenshot at 1440 dark. | + +## Verification results + +- `pnpm exec vp build` (web), `pnpm exec vp check` (0 errors, 510 warnings, + all pre-existing categories) and `pnpm exec vp test apps/web` (215 passed) + at `30e6de4`. +- `npx @google/design.md lint docs/design/DESIGN.md`: 0 errors, 2 warnings + left. Both are `node-hover` and `node-agent`, whose `textColor` is a border + colour; the lint schema has no border key, so the warnings cannot be + satisfied without misdescribing the component. +- Hardcoded values outside `styles.css`: 4. The manifest `theme_color` and + `background_color` and the two `theme-color` meta tags cannot reference CSS + variables. GitHub label colours are interpolated from label records into a + 6px dot style, which is user data, not a design value. +- Arbitrary values in class strings: 3, all `transition-[left,right,width]` + style property lists in `ui/sidebar.ts`, which select properties rather than + set values. +- Screenshots: 76 captures in + `~/.t3/userdata/attachments/ops-console/shots/final/` covering 19 routes at + 1440 light, 1440 dark, 768 and 390. Reviewed: design-system, rules, rule + editor (policy and AI), activity (both modes, expanded), policies, sessions, + account, connect, settings, overview, and the dark and 390 captures of the + rule editor and activity. +- Adding a shadcn component is not possible here (no React, no shadcn). The + equivalent check was rendering tabs, which no screen uses, from raw + `data-slot` markup on `/design-system`; they take the active underline, + hover and focus styling from the theme file alone. + +## Tests changed + +- `workspace.test.ts`: label badge helper renamed to `labelDotStyle`, which + returns only the dot colour. +- `repository-switcher.test.ts`: expects `border` instead of `border-2`. +- `sessions.test.ts`: recovery summary and its "Retrying past" line are now + two elements; expectations split. +- `repository-connections.test.ts`: repository name and status are separate + elements; expectations split. + +## What was not finished or not right + +- The three screen-conversion agents were cut off mid-work by a session + limit; their partial edits were completed by hand and every screen was + checked, but `activity.ts`, `policy-editor.ts`, `sessions.ts`, + `repository-connections.ts` and `account.ts` deserve a human read for copy + that drifted (two labels were restored). +- The rule editor's "no match" wire label sits close to the "match" label on + short graphs. +- The policy inspector and rule inspector stack below 1240px instead of + becoming a slide-over (open question 7). The sidebar drawer switches at + 768px rather than 820px (open question 6). +- CodeMirror syntax colours are still the GitHub theme (open question 1). +- The rules table dropped the second line (policy description) per row to hit + 28px; the description is in the row's `title` and in the rule editor. +- The Utility Room memory of the settings mockups server was deleted along + with the mockups. +- The local dev server needed `index.html` to reference `/src/entry.ts` + absolutely; the relative path broke nested routes in Vite dev. Production + output was unaffected, but the change is in the diff. diff --git a/apps/web/index.html b/apps/web/index.html index d21c628e..183cdf4b 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -8,11 +8,13 @@ + + The Janitor
- + diff --git a/apps/web/package.json b/apps/web/package.json index e1bbea40..dbe782c6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,9 +16,8 @@ "@codemirror/view": "catalog:", "@effect/platform-browser": "catalog:", "@foldkit/ui": "catalog:", - "@fontsource-variable/archivo": "catalog:", - "@fontsource-variable/big-shoulders-display": "catalog:", - "@fontsource-variable/spline-sans-mono": "catalog:", + "@fontsource-variable/inter": "catalog:", + "@fontsource-variable/jetbrains-mono": "catalog:", "@janitor/domain": "workspace:^", "@lezer/common": "catalog:", "@uiw/codemirror-theme-github": "catalog:", @@ -35,7 +34,6 @@ "@tailwindcss/vite": "catalog:", "happy-dom": "catalog:", "tailwindcss": "catalog:", - "tw-animate-css": "catalog:", "vite": "catalog:", "vite-plus": "catalog:" } diff --git a/apps/web/public/site.webmanifest b/apps/web/public/site.webmanifest index 47a5229a..71bf70d1 100644 --- a/apps/web/public/site.webmanifest +++ b/apps/web/public/site.webmanifest @@ -13,7 +13,7 @@ "type": "image/png" } ], - "theme_color": "#0d47a1", + "theme_color": "#f4f6f8", "background_color": "#ffffff", "display": "standalone" } diff --git a/apps/web/src/components/account.ts b/apps/web/src/components/account.ts index be5e737d..84f79fa1 100644 --- a/apps/web/src/components/account.ts +++ b/apps/web/src/components/account.ts @@ -370,15 +370,15 @@ const sectionTitle: Record = { team: "Team", } -/** The section rack. Team hangs only for admins, who receive a roster. */ -const sectionRack = ( +/** The section nav. Team is listed only for admins, who receive a roster. */ +const sectionNav = ( h: HtmlBuilder, current: Routes.AccountSection, hasTeam: boolean, ): Html => rack(h, { label: "Account settings", - className: "md:w-[200px]", + className: "shrink-0 md:w-sidebar", items: (["you", "accounts", ...(hasTeam ? ["team" as const] : [])] as const).map((section) => ({ href: Routes.accountSection(section), label: sectionTitle[section], @@ -393,36 +393,56 @@ const pane = ( children: ReadonlyArray, ): Html => h.section( - [h.Attribute("aria-labelledby", `account-${section}`), h.Class("flex flex-col gap-3.5")], + [h.Attribute("aria-labelledby", `account-${section}`), h.Class("flex flex-col gap-3")], [ - sign(h, { id: `account-${section}`, children: [sectionTitle[section]] }), - h.p([h.Class("text-muted-foreground")], [lede]), + h.div( + [h.Class("flex flex-col gap-1")], + [ + sign(h, { id: `account-${section}`, children: [sectionTitle[section]] }), + h.p([h.Class("text-body-sm text-ink-muted")], [lede]), + ], + ), ...children, ], ) +/** A machine value: an identifier or a date. */ +const mono = (h: HtmlBuilder, text: string): Html => + h.span([h.Class("font-mono text-mono-sm")], [text]) + +const roleChip = (h: HtmlBuilder, role: typeof TeammateRole.Type): Html => + chip(h, { children: [role === "admin" ? "admin" : "member"] }) + +/** An error panel: what happened, in destructive on the title only. */ +const alert = (h: HtmlBuilder, text: string): Html => + panel(h, { + attributes: [h.Role("alert")], + className: "border-destructive text-body-sm", + children: [h.div([h.Class("font-medium text-destructive")], [text])], + }) + // YOU +const youRow = (h: HtmlBuilder, label: string, value: Html | string): Html => + h.div( + [h.Class("flex items-center gap-4 border-b border-border-subtle px-4 py-1.5 last:border-b-0")], + [ + h.dt([h.Class("w-32 shrink-0 text-body-sm text-ink-muted")], [label]), + h.dd([h.Class("min-w-0 truncate text-body-md")], [value]), + ], + ) + const youPane = (h: HtmlBuilder, view: AccountView): Html => pane(h, "you", "How Janitor knows you. Sign-in is handled by Cloudflare Access.", [ panel(h, { + flush: true, children: [ h.dl( - [h.Class("grid grid-cols-[max-content_1fr] items-center gap-x-6 gap-y-2.5")], + [], [ - h.dt([h.Class("text-sm text-muted-foreground")], ["Email"]), - h.dd([], [displayName(view.teammate)]), - h.dt([h.Class("text-sm text-muted-foreground")], ["Role"]), - h.dd( - [], - [ - view.teammate.role === "admin" - ? chip(h, { variant: "on", children: ["admin"] }) - : chip(h, { children: ["member"] }), - ], - ), - h.dt([h.Class("text-sm text-muted-foreground")], ["Teammate since"]), - h.dd([], [formatDay(view.teammate.createdAt)]), + youRow(h, "Email", displayName(view.teammate)), + youRow(h, "Role", roleChip(h, view.teammate.role)), + youRow(h, "Teammate since", mono(h, formatDay(view.teammate.createdAt))), ], ), ], @@ -443,12 +463,12 @@ const platformRow = ( const name = platformName(platform) const status = link !== undefined - ? h.span([], ["Connected as ", h.code([h.Class("font-mono")], [link.displayName])]) + ? h.span([], ["Connected as ", mono(h, link.displayName)]) : !available ? h.span([], ["Not available in this deployment"]) : past === undefined ? h.span([], ["Not connected"]) - : h.span([], ["Disconnected · was ", h.code([h.Class("font-mono")], [past.displayName])]) + : h.span([], ["Disconnected · was ", mono(h, past.displayName)]) const connectLabel = busyWith(model, "connect", platform) ? "Opening…" : `Connect ${name}` const actions = link === undefined @@ -478,19 +498,19 @@ const platformRow = ( ] return h.div( [ - h.Class("flex items-center gap-3.5 border-b-2 border-outline px-[18px] py-4 last:border-b-0"), + h.Class("flex items-center gap-3 border-b border-border-subtle px-4 py-2.5 last:border-b-0"), h.DataAttribute("slot", "row"), ], [ platformMark(h, platform), h.div( - [h.Class("min-w-0 flex-1")], + [h.Class("flex min-w-0 flex-1 flex-col gap-0.5")], [ h.p([h.Class("text-h3 font-semibold")], [name]), - h.p([h.Class("text-sm text-muted-foreground")], [status]), + h.p([h.Class("text-body-sm text-ink-muted")], [status]), ], ), - h.div([h.Class("flex shrink-0 items-center gap-2.5")], actions), + h.div([h.Class("flex shrink-0 items-center gap-2")], actions), ], ) } @@ -502,7 +522,7 @@ const accountsPane = (h: HtmlBuilder, model: Model, view: AccountView): children: [platformRow(h, model, view, "github"), platformRow(h, model, view, "slack")], }), h.p( - [h.Class("text-sm text-muted-foreground")], + [h.Class("text-body-sm text-ink-muted")], [ "Disconnecting stops new instructions from that account. Work it already contributed is kept.", ], @@ -525,15 +545,13 @@ const rosterRow = ( Button.view(h, { label: busyWith(model, "restore", entry.teammateId) ? "Restoring…" : "Restore", onClick: Message.ClickedRestore({ teammateId: entry.teammateId }), - variant: "outline", + variant: "secondary", size: "sm", isDisabled: isBusy(model), }), ] : [ - entry.role === "admin" - ? chip(h, { variant: "on", children: ["admin"] }) - : chip(h, { children: ["member"] }), + roleChip(h, entry.role), ...(isSelf ? [] : [ @@ -547,7 +565,7 @@ const rosterRow = ( teammateId: entry.teammateId, role: entry.role === "admin" ? "member" : "admin", }), - variant: "outline", + variant: "secondary", size: "sm", isDisabled: isBusy(model), }), @@ -564,8 +582,8 @@ const rosterRow = ( [ h.Class( cn( - "flex items-center gap-3 px-[18px] py-2.5", - removed && "bg-muted/60 text-muted-foreground", + "flex items-center gap-3 border-b border-border-subtle px-4 py-1.5 last:border-b-0", + removed && "text-ink-muted", ), ), h.DataAttribute("slot", "row"), @@ -576,19 +594,17 @@ const rosterRow = ( [h.Class("min-w-0 flex-1")], [ h.p( - [h.Class("truncate")], + [h.Class("truncate text-body-md")], [ - entry.email === null - ? h.code([h.Class("font-mono text-sm")], [entry.subject]) - : entry.email, - isSelf ? h.span([h.Class("ml-1 text-muted-foreground")], ["(you)"]) : h.empty, + displayName(entry), + isSelf ? h.span([h.Class("ml-1 text-ink-muted")], ["(you)"]) : h.empty, ], ), entry.email === null && !removed - ? h.p([h.Class("text-sm text-muted-foreground")], ["No email on record"]) + ? h.p([h.Class("text-body-sm text-ink-muted")], ["No email on record"]) : h.empty, removed && entry.removedAt !== null - ? h.p([h.Class("text-sm")], [`Removed ${formatDay(entry.removedAt)}`]) + ? h.p([h.Class("text-body-sm")], ["Removed ", mono(h, formatDay(entry.removedAt))]) : h.empty, ], ), @@ -601,7 +617,7 @@ const teamPane = (h: HtmlBuilder, model: Model, view: AccountView): Htm if (view.team === null) return pane(h, "team", "Only admins can manage the team.", [ emptyPanel(h, { - children: [h.p([], ["Ask an admin if you need a role change or to remove someone."])], + children: ["Ask an admin if you need a role change or to remove someone."], }), ]) const active = view.team.filter((entry) => entry.status !== "removed") @@ -609,20 +625,24 @@ const teamPane = (h: HtmlBuilder, model: Model, view: AccountView): Htm return pane(h, "team", "Everyone who has signed in. New teammates start as members.", [ panel(h, { flush: true, - className: "jn-dense", children: [ - ...active.map((entry) => rosterRow(h, model, view.teammate, entry)), - ...(model.showRemoved - ? removed.map((entry) => rosterRow(h, model, view.teammate, entry)) - : []), + h.div( + [], + [ + ...active.map((entry) => rosterRow(h, model, view.teammate, entry)), + ...(model.showRemoved + ? removed.map((entry) => rosterRow(h, model, view.teammate, entry)) + : []), + ], + ), h.div( [ h.Class( - "flex items-center justify-between gap-3 border-t-2 border-outline bg-popover px-[18px] py-2.5 text-sm text-muted-foreground", + "flex min-h-8 items-center justify-between gap-3 border-t border-border bg-surface-muted px-4 py-1 text-body-sm text-ink-muted", ), ], [ - h.span([], [`${active.length} active`]), + h.span([], [mono(h, String(active.length)), " active"]), removed.length === 0 ? h.empty : Button.view(h, { @@ -641,63 +661,32 @@ const teamPane = (h: HtmlBuilder, model: Model, view: AccountView): Htm export const view = Submodel.defineView((model, inputs, h) => h.div( + [h.Class("flex flex-col gap-4 p-4 md:flex-row md:gap-8 lg:p-5"), h.OnMount(Open({}))], [ - h.Class("flex flex-col gap-6 p-6 md:grid md:grid-cols-[200px_minmax(0,640px)] md:gap-10"), - h.OnMount(Open({})), - ], - [ - sectionRack( + sectionNav( h, inputs.section, Option.exists(model.view, (view) => view.team !== null), ), h.div( - [h.Class("flex min-w-0 flex-col gap-3.5")], + [h.Class("flex min-w-0 max-w-2xl flex-1 flex-col gap-3")], [ - Option.isSome(model.error) - ? h.p( - [ - h.Role("alert"), - h.Class( - "rounded-sm border-2 border-destructive bg-popover px-3.5 py-2.5 text-sm text-destructive", - ), - ], - [model.error.value], - ) - : h.empty, - Option.isSome(model.loadError) - ? h.p( - [ - h.Role("alert"), - h.Class( - "rounded-sm border-2 border-destructive bg-popover px-3.5 py-2.5 text-sm text-destructive", - ), - ], - [model.loadError.value], - ) - : h.empty, + Option.isSome(model.error) ? alert(h, model.error.value) : h.empty, + Option.isSome(model.loadError) ? alert(h, model.loadError.value) : h.empty, model.notice - ? h.p( - [ - h.Role("status"), - h.Class( - "rounded-sm border-2 border-dashed border-outline/40 bg-popover px-3.5 py-2.5 text-sm text-muted-foreground", - ), - ], - [model.notice], - ) + ? panel(h, { + attributes: [h.Role("status")], + className: "text-body-sm text-ink-muted", + children: [model.notice], + }) : h.empty, Option.match(model.view, { onNone: () => Option.isNone(model.loadError) - ? panel(h, { - children: [ - h.p( - [h.Role("status"), h.Class("text-muted-foreground")], - ["Loading your account…"], - ), - ], - }) + ? h.p( + [h.Role("status"), h.Class("text-body-sm text-ink-muted")], + ["Loading your account…"], + ) : h.empty, onSome: (view) => { switch (inputs.section) { diff --git a/apps/web/src/components/activity.ts b/apps/web/src/components/activity.ts index 9b981d8d..34e5c799 100644 --- a/apps/web/src/components/activity.ts +++ b/apps/web/src/components/activity.ts @@ -12,7 +12,7 @@ import { defineMessageUnion } from "foldkit/message" import * as Submodel from "foldkit/submodel" import * as Subscription from "foldkit/subscription" import * as Update from "foldkit/update" -import type { HtmlBuilder } from "foldkit/html" +import type { Html, HtmlBuilder } from "foldkit/html" import { ActivityCursor, ActivityEntry, @@ -21,17 +21,14 @@ import { type ConfigurationView, } from "./labeling-wire" import * as Button from "./ui/button" +import { chip } from "./ui/chip" +import * as Feed from "./ui/feed" +import { inputGroup, inputGroupAddon, inputGroupInput } from "./ui/input-group" +import { emptyPanel } from "./ui/panel" +import * as Select from "./ui/select" import * as Icon from "@/lib/icons" -import { - Activity as ActivityIcon, - Check, - Minus, - CircleAlert, - Clock, - ChevronRight, - GitPullRequest, - Search, -} from "lucide" +import { cn } from "@/lib/utils" +import { Check, ChevronDown, ChevronRight, CircleAlert, CircleHelp, Clock, Search } from "lucide" import * as Routes from "@/routes" const Mode = Schema.Literals(["journal", "grouped"]) @@ -92,8 +89,8 @@ export const init = (): Model => ({ generation: 0, expanded: [], expandedGroups: [], - journal: VirtualList.init({ id: "activity-journal", rowHeightPx: 96 }), - grouped: VirtualList.init({ id: "activity-grouped", rowHeightPx: 96 }), + journal: VirtualList.init({ id: "activity-journal", rowHeightPx: 32 }), + grouped: VirtualList.init({ id: "activity-grouped", rowHeightPx: 32 }), }) export const FetchPage = Command.define("FetchActivity", { args: { @@ -195,7 +192,7 @@ export const rows = (model: Model): ReadonlyArray => { ...(model.expandedGroups.includes(number) ? entries.map(event) : []), ]) } -export const rowHeight = (row: Row) => (row.kind === "group" ? 72 : row.expanded ? 420 : 96) +export const rowHeight = (row: Row) => (row.kind === "group" ? 28 : row.expanded ? 420 : 32) const scrollTop = (model: Model): Return => { const result = VirtualList.scrollToIndexVariable(model[model.mode], rows(model), rowHeight, 0) return { @@ -372,26 +369,22 @@ export const outcome = ( icon: CircleAlert, } if (!entry.outcome || entry.actions.some((action) => action.status === "planned")) - return { label: "Pending", tone: "text-muted-foreground", icon: Clock } + return { label: "Pending", tone: "text-ink-muted", icon: Clock } if (entry.outcome === "superseded") - return { label: "Replaced by newer activity", tone: "text-muted-foreground", icon: Clock } + return { label: "Replaced by newer activity", tone: "text-ink-muted", icon: Clock } if (entry.outcome === "not-qualified") - return { label: "Waiting for a verified snapshot", tone: "text-muted-foreground", icon: Clock } + return { label: "Waiting for a verified snapshot", tone: "text-ink-muted", icon: Clock } if (entry.actions.some((action) => action.status === "applied")) - return { label: "Labels updated", tone: "text-emerald-600 dark:text-emerald-400", icon: Check } + return { label: "Labels updated", tone: "text-foreground", icon: Check } if (entry.evaluations?.some((rule) => rule.reason.startsWith("Gate unresolved:"))) - return { - label: "Gate unresolved · AI skipped", - tone: "text-amber-700 dark:text-amber-400", - icon: CircleAlert, - } + return { label: "Gate unresolved · AI skipped", tone: "text-ink-muted", icon: CircleHelp } if (entry.plan?.rules.some((rule) => rule.outcome === "unknown")) return { label: "Could not decide · labels unchanged", - tone: "text-amber-700 dark:text-amber-400", - icon: CircleAlert, + tone: "text-ink-muted", + icon: CircleHelp, } - return { label: "No label changes", tone: "text-muted-foreground", icon: Check } + return { label: "No label changes", tone: "text-ink-muted", icon: Check } } const stamp = (entry: ActivityEntry) => DateTime.formatUtc(entry.createdAt, { @@ -400,16 +393,82 @@ const stamp = (entry: ActivityEntry) => hour: "2-digit", minute: "2-digit", }) + " UTC" -const label = (h: HtmlBuilder, action: ActivityEntry["actions"][number]) => +const isHex = (color: string | null | undefined): color is string => + color !== null && color !== undefined && /^[0-9a-f]{6}$/i.test(color) +/** GitHub label colour as a 6px dot inside a neutral chip; never a fill. */ +const labelDot = (h: HtmlBuilder, color: string | null | undefined) => h.span( [ - h.Class("activity-label"), - ...(action.color && /^[0-9a-f]{6}$/i.test(action.color) - ? [h.Style({ borderColor: `#${action.color}70`, backgroundColor: `#${action.color}18` })] - : []), + h.Class(cn("size-1.5 rounded-full", isHex(color) ? "" : "bg-ink-faint")), + ...(isHex(color) ? [h.Style({ backgroundColor: `#${color}` })] : []), + h.AriaHidden(true), ], - [action.name ?? `Label ${action.labelId}`], + [], ) +const label = (h: HtmlBuilder, action: ActivityEntry["actions"][number]) => + chip(h, { children: [labelDot(h, action.color), action.name ?? `Label ${action.labelId}`] }) +const subjectRef = (h: HtmlBuilder, entry: ActivityEntry) => + h.span([h.Class("font-mono text-primary")], [`#${entry.number}`]) +/** One feed line: what The Janitor did to the subject. */ +const feedBody = (h: HtmlBuilder, entry: ActivityEntry): ReadonlyArray => { + const subject: ReadonlyArray = [ + subjectRef(h, entry), + ...(entry.title ? [" (", entry.title, ")"] : []), + ] + const list = (actions: ReadonlyArray) => + actions.flatMap((action, index) => [index === 0 ? "" : " ", label(h, action)]) + const applied = entry.actions.filter((action) => action.status === "applied") + const failed = entry.actions.filter((action) => action.status === "failed") + const planned = entry.actions.filter((action) => action.status === "planned") + if (applied.length > 0) { + const added = applied.filter((action) => action.action === "add") + const removed = applied.filter((action) => action.action === "remove") + return [ + ...(added.length > 0 ? ["added ", ...list(added)] : []), + ...(added.length > 0 && removed.length > 0 ? [" and "] : []), + ...(removed.length > 0 ? ["removed ", ...list(removed)] : []), + ...(failed.length > 0 ? [", could not update ", ...list(failed)] : []), + " on ", + ...subject, + ] + } + if (failed.length > 0) return ["could not update ", ...list(failed), " on ", ...subject] + if (planned.length > 0) return ["is updating ", ...list(planned), " on ", ...subject] + return ["evaluated ", ...subject] +} +const decisionChip = ( + h: HtmlBuilder, + result: string | undefined, + skipped: boolean | undefined, +) => { + const status = skipped + ? "Skipped by gate" + : result === "match" + ? "Matched" + : result === "no-match" + ? "No match" + : result === "failed" + ? "Evaluation failed" + : result === "unknown" + ? "Undecided" + : "Pending" + const icon = skipped + ? null + : result === "match" + ? Check + : result === "unknown" + ? CircleHelp + : result === "failed" + ? CircleAlert + : result === "no-match" + ? null + : Clock + return chip(h, { + variant: result === "failed" && !skipped ? "danger" : "neutral", + className: "shrink-0", + children: [...(icon ? [Icon.view(h, icon, "size-3 shrink-0")] : []), status], + }) +} const evaluationCards = ( h: HtmlBuilder, model: Model, @@ -434,96 +493,58 @@ const evaluationCards = ( const color = currentLabel?.color ?? actions.find((action) => action.color)?.color const skipped = evaluation?.reason.startsWith("Skipped by gate:") const result = decision?.outcome ?? evaluation?.outcome - const status = skipped - ? "Skipped by gate" - : result === "match" - ? "Matched" - : result === "no-match" - ? "No match" - : result === "failed" - ? "Evaluation failed" - : result === "unknown" - ? "Undecided" - : "Pending" - const tone = - skipped || result === "no-match" - ? "neutral" - : result === "match" - ? "match" - : result === "failed" - ? "failed" - : result === "unknown" - ? "unknown" - : "neutral" return h.div( - [h.Class("activity-rule-card")], + [h.Class("flex min-w-0 flex-col gap-2 py-2.5"), h.DataAttribute("rule", id)], [ h.div( - [h.Class("activity-rule-heading")], + [h.Class("flex flex-wrap items-center gap-2")], [ h.a( [ h.Href(Routes.rule({ repositoryId: model.repositoryId, ruleId: id })), - h.Class("activity-rule-link"), - ], - [ - h.span( - [ - h.Class("activity-rule-dot"), - ...(color && /^[0-9a-f]{6}$/i.test(color) - ? [h.Style({ backgroundColor: `#${color}` })] - : []), - ], - [], - ), - h.span([h.Class("truncate")], [name]), - Icon.view(h, ChevronRight, "size-3 shrink-0 text-muted-foreground"), - ], - ), - h.div( - [h.Class("activity-rule-badges")], - [ - rule - ? h.span( - [h.Class("text-[10px] text-muted-foreground")], - [rule.ai ? "AI rule" : "Policy rule"], - ) - : h.empty, - h.span( - [h.Class(`activity-decision activity-decision-${tone}`)], - [ - Icon.view( - h, - result === "unknown" || result === "failed" - ? CircleAlert - : result === "match" - ? Check - : result === "no-match" - ? Minus - : Clock, - "size-3", - ), - status, - ], + h.Class( + "inline-flex min-w-0 max-w-full items-center gap-1.5 font-mono text-mono-sm text-primary hover:underline", ), ], + [labelDot(h, color), h.span([h.Class("truncate")], [name])], ), + rule + ? rule.ai + ? chip(h, { variant: "agent", children: ["AI"] }) + : chip(h, { children: ["policy"] }) + : h.empty, + h.span([h.Class("ml-auto")], [decisionChip(h, result, skipped)]), ], ), rule?.ai && !skipped && evaluation?.reason - ? h.p([h.Class("activity-rule-reason")], [evaluation.reason]) + ? h.div( + [h.Class("oc-agent-edge flex flex-col items-start gap-1 pl-2.5")], + [ + Feed.agentBadge(h, "AI"), + h.p( + [h.Class("max-w-prose text-body-sm text-ink-muted wrap-anywhere")], + [evaluation.reason], + ), + ], + ) : h.empty, decision?.requestedAction - ? h.p([h.Class("activity-selection")], [describeResultAction(decision.requestedAction)]) + ? h.p( + [h.Class("text-body-sm text-ink-muted")], + [describeResultAction(decision.requestedAction)], + ) : h.empty, decision?.selected - ? h.p([h.Class("activity-selection")], ["Selected for the label plan"]) + ? h.p([h.Class("text-body-sm text-ink-muted")], ["Selected for the label plan"]) : h.empty, ...actions.map((action) => h.div( [ h.Class( - `activity-rule-action ${action.status === "failed" ? "text-destructive" : ""}`, + cn( + "flex flex-wrap items-center gap-1.5 text-body-sm", + action.status === "failed" ? "text-destructive" : "text-ink-muted", + ), ), ], [ @@ -554,7 +575,7 @@ const evaluationCards = ( ), label(h, action), ...(action.detail - ? [h.p([h.Class("basis-full text-muted-foreground")], [action.detail])] + ? [h.p([h.Class("basis-full text-ink-muted")], [action.detail])] : []), ], ), @@ -573,58 +594,35 @@ const eventView = ( ) => { const result = outcome(entry) return h.article( - [h.Class("activity-event")], + [h.Class("h-full border-b border-border-subtle"), h.DataAttribute("slot", "feed-item")], [ h.button( [ - h.Class("activity-event-summary"), + h.Class( + "activity-event-summary flex h-8 w-full cursor-pointer items-center gap-2.5 px-3 text-left text-body-md transition-colors duration-120 ease-ui hover:bg-surface-muted", + ), h.OnClick(Message.ToggledEvent({ id: entry.id })), h.AriaExpanded(expanded), ], [ + Feed.marker(h, { kind: "agent" }), h.span( - [h.Class(`activity-event-icon ${result.tone}`)], - [Icon.view(h, result.icon, "size-4")], + [h.Class("min-w-0 flex-1 truncate")], + [Feed.actorName(h, { kind: "agent" }), " ", ...feedBody(h, entry)], ), - h.div( - [h.Class("min-w-0 flex-1")], + h.span( [ - h.div( - [h.Class("truncate text-sm font-medium")], - [entry.title ?? `Subject #${entry.number}`], - ), - h.div( - [h.Class("activity-event-meta")], - [ - h.span([], [`#${entry.number}`]), - h.span([h.Class(result.tone)], [result.label]), - ...entry.actions - .slice(0, 2) - .map((action) => - h.span( - [h.Class("inline-flex items-center gap-1")], - [ - action.status === "applied" - ? action.action === "add" - ? "Added" - : "Removed" - : action.status === "failed" - ? "Failed" - : "Pending", - label(h, action), - ], - ), - ), - ], + h.Class( + cn("hidden shrink-0 items-center gap-1 text-body-sm sm:inline-flex", result.tone), ), - h.time([h.Class("text-[10px] text-muted-foreground")], [stamp(entry)]), ], + [Icon.view(h, result.icon, "size-3 shrink-0"), result.label], ), - Icon.view( - h, - ChevronRight, - `size-4 shrink-0 text-muted-foreground ${expanded ? "rotate-90" : ""}`, + h.time( + [h.Class("shrink-0 font-mono text-mono-xs text-ink-subtle tabular-nums")], + [stamp(entry)], ), + Icon.view(h, expanded ? ChevronDown : ChevronRight, "size-3.5 shrink-0 text-ink-subtle"), ], ), expanded @@ -636,9 +634,12 @@ const eventView = ( ], [ h.div( - [h.Class("flex items-center justify-between mb-3")], + [h.Class("flex items-center justify-between gap-3")], [ - h.h3([h.Class("text-xs font-medium")], ["Evaluation details"]), + h.h3( + [h.Class("text-caption font-medium text-ink-subtle")], + ["Evaluation details"], + ), repository && entry.kind ? h.a( [ @@ -647,7 +648,7 @@ const eventView = ( ), h.Target("_blank"), h.Rel("noopener noreferrer"), - h.Class("text-xs underline"), + h.Class("text-body-sm text-primary hover:underline"), ], ["Open on GitHub"], ) @@ -656,29 +657,38 @@ const eventView = ( ), entry.detail ? h.p( - [h.Class("mb-3 text-xs text-muted-foreground whitespace-pre-wrap")], + [h.Class("mt-2 text-body-sm text-ink-muted whitespace-pre-wrap")], [entry.detail], ) : h.empty, h.div( - [h.Class("activity-evaluation-summary")], [ - Icon.view(h, result.icon, `size-4 shrink-0 ${result.tone}`), - h.span([h.Class(result.tone)], [result.label]), + h.Class( + cn( + "mt-2 flex flex-wrap items-center gap-2 rounded-xs bg-surface-muted px-2.5 py-1.5 text-body-sm", + result.tone, + ), + ), + ], + [ + Icon.view(h, result.icon, "size-3.5 shrink-0"), + h.span([], [result.label]), h.span( - [h.Class("ml-auto text-muted-foreground")], + [h.Class("ml-auto font-mono text-mono-xs text-ink-subtle tabular-nums")], [`${entry.plan?.rules.length ?? entry.evaluations?.length ?? 0} rules`], ), ], ), h.div( - [h.Class("activity-rule-cards")], + [h.Class("flex flex-col divide-y divide-border-subtle")], evaluationCards(h, model, entry, configuration), ), h.p( - [h.Class("text-[10px] text-muted-foreground mt-3")], + [h.Class("mt-2 text-caption text-ink-subtle")], [ - `Rules revision ${entry.revision} · Rule names and types reflect current configuration.`, + "Rules revision ", + h.span([h.Class("font-mono tabular-nums")], [String(entry.revision)]), + " · Rule names and types reflect current configuration.", ], ), ], @@ -687,70 +697,94 @@ const eventView = ( ], ) } +const groupView = (h: HtmlBuilder, model: Model, row: Row & { kind: "group" }) => { + const expanded = model.expandedGroups.includes(row.number) + return h.button( + [ + h.Class( + "flex h-7 w-full cursor-pointer items-center gap-2 border-b border-border-subtle bg-surface-muted px-3 text-left text-caption text-ink-subtle transition-colors duration-120 ease-ui", + ), + h.OnClick(Message.ToggledGroup({ number: row.number })), + h.AriaExpanded(expanded), + ], + [ + Icon.view(h, expanded ? ChevronDown : ChevronRight, "size-3.5 shrink-0"), + h.span([h.Class("shrink-0 font-mono text-mono-xs")], [`#${row.number}`]), + h.span([h.Class("min-w-0 flex-1 truncate font-medium text-foreground")], [row.title]), + h.span( + [h.Class("shrink-0 font-mono text-mono-xs tabular-nums")], + [`${row.count} ${row.count === 1 ? "evaluation" : "evaluations"}`], + ), + ], + ) +} export const view = Submodel.defineView< Model, Message, { repository: RepositoryOverview | undefined; configuration?: ConfigurationView | undefined } >((model, { repository, configuration }, h) => { const items = rows(model) + const filtered = model.search || model.target !== "all" return h.section( [h.Class("activity-workspace"), h.AriaLabel("Repository activity")], [ h.div( - [h.Class("activity-toolbar")], + [h.Class("flex shrink-0 flex-wrap items-center gap-2 pb-3")], [ h.form( + [h.Class("w-full sm:w-72"), h.OnSubmit(Message.ChangedSearch({ value: model.search }))], [ - h.Class("activity-search"), - h.OnSubmit(Message.ChangedSearch({ value: model.search })), - ], - [ - Icon.view(h, Search, "size-4 text-muted-foreground"), - h.input([ - h.Type("search"), - h.Value(model.search), - h.Placeholder("Find an issue or pull request…"), - h.AriaLabel("Search activity"), - h.OnInput((value) => Message.ChangedSearch({ value })), - ]), + inputGroup(h, { + children: [ + inputGroupAddon(h, { children: [Icon.view(h, Search)] }), + inputGroupInput(h, { + id: "activity-search", + type: "search", + value: model.search, + placeholder: "Find an issue or pull request…", + ariaLabel: "Search activity", + onInput: (value) => Message.ChangedSearch({ value }), + }), + ], + }), ], ), h.div( - [h.Class("activity-view-toggle"), h.Role("group"), h.AriaLabel("Activity view")], + [h.Class("flex items-center gap-1"), h.Role("group"), h.AriaLabel("Activity view")], (["journal", "grouped"] as const).map((mode) => - h.button( - [ - h.Type("button"), - h.AriaPressed(String(model.mode === mode)), - h.OnClick(Message.ChangedMode({ mode })), - ], - [mode === "journal" ? "Journal" : "By subject"], - ), + Button.view(h, { + variant: "secondary", + size: "sm", + label: mode === "journal" ? "Journal" : "By subject", + onClick: Message.ChangedMode({ mode }), + className: model.mode === mode ? "bg-primary-wash" : undefined, + attributes: [h.AriaPressed(String(model.mode === mode))], + }), ), ), - h.select( - [ - h.Class("activity-target"), - h.Value(model.target), - h.AriaLabel("Subject type"), - h.OnChange((value) => - Message.ChangedTarget({ - target: - value === "issue" ? "issue" : value === "pull_request" ? "pull_request" : "all", - }), - ), + Select.view(h, { + id: "activity-target", + label: "Subject type", + isLabelHidden: true, + wrapperClass: "w-36", + className: "h-6 text-body-sm", + value: model.target, + options: [ + ["all", "All subjects"], + ["pull_request", "Pull requests"], + ["issue", "Issues"], ], - [ - h.option([h.Value("all")], ["All subjects"]), - h.option([h.Value("pull_request")], ["Pull requests"]), - h.option([h.Value("issue")], ["Issues"]), - ], - ), + onChange: (value) => + Message.ChangedTarget({ + target: + value === "issue" ? "issue" : value === "pull_request" ? "pull_request" : "all", + }), + }), model.pending ? Button.view(h, { label: "New activity", size: "sm", - variant: "outline", + variant: "secondary", onClick: Message.ClickedNew(), }) : h.empty, @@ -758,12 +792,15 @@ export const view = Submodel.defineView< ), model.error ? h.div( - [h.Role("alert"), h.Class("activity-notice")], [ - model.error, + h.Role("alert"), + h.Class("flex shrink-0 items-center justify-between gap-3 py-2 text-body-sm"), + ], + [ + h.span([h.Class("text-destructive")], [model.error]), Button.view(h, { label: "Retry", - variant: "outline", + variant: "secondary", size: "sm", onClick: Message.ClickedRetry(), isDisabled: model.loading, @@ -772,30 +809,19 @@ export const view = Submodel.defineView< ) : h.empty, !items.length - ? h.div( - [h.Class("activity-empty"), h.Role("status")], - [ - Icon.view(h, ActivityIcon, "size-8 text-muted-foreground"), - h.h2( - [h.Class("text-sm font-medium")], - [ - !model.initialized && model.loading - ? "Loading activity…" - : model.search || model.target !== "all" - ? "No matching activity" - : "No activity yet", - ], - ), - h.p( - [h.Class("text-xs text-muted-foreground")], - [ - model.search || model.target !== "all" - ? "Try another subject or clear your filters." - : "Labeling decisions will appear here as Janitor evaluates issues and pull requests.", - ], - ), - ], - ) + ? !model.initialized && model.loading + ? h.p( + [h.Role("status"), h.Class("py-2 text-body-sm text-ink-muted")], + ["Loading activity…"], + ) + : emptyPanel(h, { + attributes: [h.Role("status")], + children: [ + filtered + ? "No matching activity; try another subject or clear the filters." + : "No activity yet; labeling decisions appear here once The Janitor evaluates an issue or pull request.", + ], + }) : h.submodel({ slotId: `activity-${model.mode}`, model: model[model.mode], @@ -810,43 +836,24 @@ export const view = Submodel.defineView< itemToView: (row) => row.kind === "event" ? eventView(h, model, row.entry, row.expanded, repository, configuration) - : h.button( - [ - h.Class("activity-group"), - h.OnClick(Message.ToggledGroup({ number: row.number })), - h.AriaExpanded(model.expandedGroups.includes(row.number)), - ], - [ - Icon.view(h, GitPullRequest, "size-4 shrink-0"), - h.span( - [h.Class("min-w-0 flex-1 text-left")], - [ - h.span([h.Class("block truncate text-sm font-medium")], [row.title]), - h.span( - [h.Class("text-xs text-muted-foreground")], - [`#${row.number} · ${row.count} loaded evaluations`], - ), - ], - ), - Icon.view( - h, - ChevronRight, - `size-4 ${model.expandedGroups.includes(row.number) ? "rotate-90" : ""}`, - ), - ], - ), + : groupView(h, model, row), }, }), h.footer( - [h.Class("activity-footer")], + [ + h.Class( + "flex min-h-10 shrink-0 items-center justify-between gap-3 text-body-sm text-ink-subtle", + ), + ], [ h.span( [h.Role("status")], - [ - model.loading && model.initialized - ? "Updating…" - : `${model.entries.length} evaluations loaded`, - ], + model.loading && model.initialized + ? ["Updating…"] + : [ + h.span([h.Class("font-mono tabular-nums")], [String(model.entries.length)]), + " evaluations loaded", + ], ), model.cursor && model.entries.length < MAX_LOADED ? Button.view(h, { diff --git a/apps/web/src/components/ai-input-view.ts b/apps/web/src/components/ai-input-view.ts index 72e398ce..61c806d2 100644 --- a/apps/web/src/components/ai-input-view.ts +++ b/apps/web/src/components/ai-input-view.ts @@ -3,6 +3,9 @@ import * as Schema from "effect/Schema" import { AiInputDetails, type AiInputReport } from "@/components/labeling-wire" import type { Html, HtmlBuilder } from "foldkit/html" import * as Button from "@/components/ui/button" +import * as Feed from "@/components/ui/feed" +import * as Icon from "@/lib/icons" +import { ChevronRight, Info, Scissors } from "lucide" export const InputInspection = Schema.Union([ Schema.TaggedStruct("Idle", {}), @@ -13,6 +16,15 @@ export const InputInspection = Schema.Union([ export type InputInspection = typeof InputInspection.Type const size = (bytes: number) => `${(bytes / 1000).toFixed(1)} KB` +const summaryClass = + "flex cursor-pointer list-none items-center gap-1.5 text-body-sm font-medium text-foreground marker:hidden [&::-webkit-details-marker]:hidden" + +const noteClass = "flex items-start gap-1.5 text-body-sm text-ink-muted" + +/** Byte counts are machine values: mono, tabular. */ +const bytes = (h: HtmlBuilder, text: string): Html => + h.span([h.Class("font-mono text-mono-sm tabular-nums")], [text]) + export const aiInputView = ( h: HtmlBuilder, report: AiInputReport | undefined, @@ -22,25 +34,47 @@ export const aiInputView = ( ): Html => { if (!report) return h.empty const shortened = report.status === "shortened" + /** What the agent received, verbatim: mono on a muted surface with the agent edge. */ const code = (text: string) => h.pre( [ h.Class( - "max-h-64 overflow-auto whitespace-pre-wrap break-words rounded border bg-muted/30 p-2 text-[11px]", + "oc-agent-edge mt-1.5 max-h-64 overflow-auto rounded-xs border border-border-subtle bg-surface-muted p-2 font-mono text-mono-sm whitespace-pre-wrap wrap-anywhere", ), ], [text], ) + const disclosure = (title: string, body: Html): Html => + h.details( + [h.Class("group/disclosure")], + [ + h.summary( + [h.Class(summaryClass)], + [ + Icon.view(h, ChevronRight, "size-3.5 shrink-0 group-open/disclosure:hidden"), + Icon.view( + h, + ChevronRight, + "size-3.5 shrink-0 rotate-90 hidden group-open/disclosure:block", + ), + title, + ], + ), + body, + ], + ) return h.details( - [h.Class("mt-3 rounded-md border p-2 text-xs")], + [ + h.Class("group/report mt-3 rounded-sm border border-border p-2.5 text-body-sm"), + h.DataAttribute("slot", "ai-input-report"), + ], [ h.summary( + [h.Class(summaryClass)], [ - h.Class( - `cursor-pointer font-medium ${shortened ? "text-amber-700 dark:text-amber-400" : "text-muted-foreground"}`, - ), - ], - [ + Icon.view(h, ChevronRight, "size-3.5 shrink-0 group-open/report:hidden"), + Icon.view(h, ChevronRight, "size-3.5 shrink-0 rotate-90 hidden group-open/report:block"), + shortened ? Icon.view(h, Scissors, "size-3.5 shrink-0 text-ink-subtle") : h.empty, shortened ? "AI input shortened" : report.status === "rejected" @@ -49,31 +83,59 @@ export const aiInputView = ( ], ), h.p( - [h.Class("mt-2 text-muted-foreground")], - [ - report.status === "rejected" - ? `Input was not sent. ${size(report.originalBytes)} before preparation; ${size(report.budgetBytes)} limit.` - : `${size(report.suppliedBytes)} of ${size(report.budgetBytes)} input budget used, including reserved framing.`, - ], + [h.Class("mt-2 text-ink-muted")], + report.status === "rejected" + ? [ + "Input was not sent. ", + bytes(h, size(report.originalBytes)), + " before preparation; ", + bytes(h, size(report.budgetBytes)), + " limit.", + ] + : [ + bytes(h, size(report.suppliedBytes)), + " of ", + bytes(h, size(report.budgetBytes)), + " input budget used, including reserved framing.", + ], ), ...report.facts.map((fact) => h.div( - [h.Class("mt-2")], + [h.Class("mt-2 flex flex-col gap-0.5")], [ - h.strong([], [fact.name]), - h.span( - [h.Class("ml-2 text-muted-foreground")], + h.div( + [h.Class("flex flex-wrap items-baseline gap-2")], [ - report.status === "rejected" - ? `${size(fact.originalBytes)} · not sent` - : `${size(fact.originalBytes)} → ${size(fact.suppliedBytes)}`, + h.span( + [h.Class("font-mono text-mono-sm font-medium text-foreground")], + [fact.name], + ), + h.span( + [h.Class("text-ink-muted")], + report.status === "rejected" + ? [bytes(h, size(fact.originalBytes)), " · not sent"] + : [ + bytes(h, size(fact.originalBytes)), + " → ", + bytes(h, size(fact.suppliedBytes)), + ], + ), ], ), fact.omission ? h.p( - [h.Class("text-amber-700 dark:text-amber-400")], + [h.Class(noteClass)], [ - `${fact.omission.end - fact.omission.start} ${fact.omission.unit} omitted (${fact.omission.start + 1}–${fact.omission.end}).`, + Icon.view(h, Scissors, "mt-0.5 size-3.5 shrink-0"), + h.span( + [], + [ + bytes(h, String(fact.omission.end - fact.omission.start)), + ` ${fact.omission.unit} omitted (`, + bytes(h, `${fact.omission.start + 1}–${fact.omission.end}`), + ").", + ], + ), ], ) : h.empty, @@ -81,7 +143,7 @@ export const aiInputView = ( ), ), h.p( - [h.Class("mt-2 text-muted-foreground")], + [h.Class("mt-2 text-ink-muted")], [ report.status === "rejected" ? "No evidence was sent to the AI provider." @@ -92,14 +154,12 @@ export const aiInputView = ( ? h.empty : inspection._tag === "Ready" ? h.div( - [h.Class("mt-3 space-y-3")], + [h.Class("mt-3 flex flex-col gap-2")], [ - h.details( - [], - [ - h.summary([h.Class("cursor-pointer")], ["View sent input"]), - code(inspection.details.system + "\n\n" + inspection.details.text), - ], + h.div([h.Class("flex")], [Feed.agentBadge(h, "sent to the model")]), + disclosure( + "View sent input", + code(inspection.details.system + "\n\n" + inspection.details.text), ), ...report.facts .filter((fact) => fact.omission) @@ -123,37 +183,43 @@ export const aiInputView = ( ) : omitted } - return h.details( - [], - [ - h.summary([h.Class("cursor-pointer")], [`View omitted ${fact.name}`]), - code(omitted), - ], - ) + return disclosure(`View omitted ${fact.name}`, code(omitted)) }), h.p( - [h.Class("text-muted-foreground")], - ["This is the snapshot used for this test, not the current PR contents."], + [h.Class(noteClass)], + [ + Icon.view(h, Info, "mt-0.5 size-3.5 shrink-0"), + h.span( + [], + ["This is the snapshot used for this test, not the current PR contents."], + ), + ], ), ], ) : !available ? h.p( - [h.Class("mt-2 text-muted-foreground")], + [h.Class("mt-2 text-ink-muted")], ["Input details are unavailable for this earlier result."], ) : h.div( - [h.Class("mt-2")], + [h.Class("mt-2 flex flex-col gap-2")], [ inspection._tag === "Failed" - ? h.p([h.Role("alert")], [inspection.reason]) + ? h.p([h.Role("alert"), h.Class("text-destructive")], [inspection.reason]) : h.empty, - Button.view(h, { - label: inspection._tag === "Loading" ? "Loading input…" : "Inspect input", - variant: "outline", - isDisabled: inspection._tag === "Loading", - onClick: inspect, - }), + h.div( + [h.Class("flex")], + [ + Button.view(h, { + label: inspection._tag === "Loading" ? "Loading input…" : "Inspect input", + variant: "secondary", + size: "sm", + isDisabled: inspection._tag === "Loading", + onClick: inspect, + }), + ], + ), ], ), ], diff --git a/apps/web/src/components/design-system.ts b/apps/web/src/components/design-system.ts new file mode 100644 index 00000000..2796af67 --- /dev/null +++ b/apps/web/src/components/design-system.ts @@ -0,0 +1,892 @@ +import type { Html, HtmlBuilder } from "foldkit/html" +import { Check, Pencil, Plus, Search } from "lucide" + +import * as Icon from "@/lib/icons" +import { cn } from "@/lib/utils" +import * as Blueprint from "@/components/ui/blueprint" +import * as Button from "@/components/ui/button" +import { chip, chipVariants, type ChipVariant } from "@/components/ui/chip" +import * as Dialog from "@/components/ui/dialog" +import * as Feed from "@/components/ui/feed" +import { input } from "@/components/ui/input" +import { inputGroup, inputGroupAddon, inputGroupInput } from "@/components/ui/input-group" +import * as Inspector from "@/components/ui/inspector" +import { avatar, platformMark } from "@/components/ui/mark" +import * as Overlay from "@/components/ui/overlay" +import { emptyPanel, panel, panelHeader } from "@/components/ui/panel" +import { rack } from "@/components/ui/rack" +import * as Select from "@/components/ui/select" +import { sign } from "@/components/ui/sign" +import { skeleton } from "@/components/ui/skeleton" +import * as Switch from "@/components/ui/switch" +import * as Table from "@/components/ui/table" +import { textarea } from "@/components/ui/textarea" + +/** + * Every primitive in every variant and state, in both themes. This page is + * how the restyle is checked; it renders no live data and dispatches only a + * no-op message. It is a deliverable, listed in RESTYLE-REPORT.md. + */ +export type ViewInputs = { + readonly noop: M +} + +type Child = Html | string + +const focusDemoClass = "outline-2 outline-ring outline-offset-1" + +const block = (h: HtmlBuilder, title: string, children: ReadonlyArray): Html => + h.section( + [h.Class("flex flex-col gap-3")], + [sign(h, { children: [title] }), h.div([h.Class("flex flex-col gap-3")], children)], + ) + +const row = (h: HtmlBuilder, children: ReadonlyArray): Html => + h.div([h.Class("flex flex-wrap items-center gap-2")], children) + +const caption = (h: HtmlBuilder, text: string): Html => + h.div([h.Class("text-caption font-medium text-ink-subtle")], [text]) + +const specimen = (h: HtmlBuilder, name: string, sample: Html): Html => + h.div( + [h.Class("flex items-baseline gap-3")], + [h.span([h.Class("w-22 shrink-0")], [caption(h, name)]), sample], + ) + +const typography = (h: HtmlBuilder): Html => + block(h, "Typography", [ + panel(h, { + children: [ + h.div( + [h.Class("flex flex-col gap-1.5")], + [ + specimen(h, "h1", h.h1([], ["Labeling rules"])), + specimen(h, "h2", h.h2([], ["Section heading"])), + specimen(h, "h3", h.h3([], ["Card header"])), + specimen( + h, + "body-md", + h.p( + [h.Class("text-body-md")], + ["Default body copy caps at sixty-six characters per line."], + ), + ), + specimen(h, "label", h.span([h.Class("text-label font-medium")], ["Form label"])), + specimen( + h, + "body-sm", + h.span([h.Class("text-body-sm text-ink-muted")], ["Help text sits under its label."]), + ), + specimen( + h, + "caption", + h.span([h.Class("text-caption font-medium text-ink-subtle")], ["Column head"]), + ), + specimen( + h, + "mono-md", + h.span([h.Class("font-mono text-mono-md")], ["effect-ts/effect / main"]), + ), + specimen( + h, + "mono-sm", + h.span( + [h.Class("font-mono text-mono-sm")], + ['labels.any("bug") && title.matches(/panic/)'], + ), + ), + specimen( + h, + "mono-xs", + h.span( + [h.Class("font-mono text-mono-xs text-ink-subtle")], + ["run_01J8Z7 · 12:04:31"], + ), + ), + specimen( + h, + "numeral", + h.span( + [h.Class("font-mono text-numeral font-medium tabular-nums")], + ["1,204 38 7"], + ), + ), + ], + ), + ], + }), + ]) + +const swatch = (h: HtmlBuilder, name: string, className: string): Html => + h.div( + [h.Class("flex items-center gap-2")], + [ + h.span([h.Class(cn("size-5 rounded-xs border border-border", className))], []), + h.span([h.Class("font-mono text-mono-xs")], [name]), + ], + ) + +const colors = (h: HtmlBuilder): Html => + block(h, "Colors", [ + panel(h, { + children: [ + h.div( + [h.Class("grid grid-cols-2 gap-x-6 gap-y-1.5 sm:grid-cols-3 lg:grid-cols-4")], + [ + swatch(h, "background", "bg-background"), + swatch(h, "card", "bg-card"), + swatch(h, "surface-muted", "bg-surface-muted"), + swatch(h, "border", "bg-border"), + swatch(h, "border-subtle", "bg-border-subtle"), + swatch(h, "foreground", "bg-foreground"), + swatch(h, "ink-muted", "bg-ink-muted"), + swatch(h, "ink-subtle", "bg-ink-subtle"), + swatch(h, "ink-faint (marks only)", "bg-ink-faint"), + swatch(h, "primary", "bg-primary"), + swatch(h, "primary-hover", "bg-primary-hover"), + swatch(h, "primary-wash", "bg-primary-wash"), + swatch(h, "primary-line", "bg-primary-line"), + swatch(h, "agent (marks only)", "bg-agent"), + swatch(h, "agent-ink", "bg-agent-ink"), + swatch(h, "agent-wash", "bg-agent-wash"), + swatch(h, "agent-line", "bg-agent-line"), + swatch(h, "success", "bg-success"), + swatch(h, "destructive", "bg-destructive"), + swatch(h, "wire", "bg-wire"), + ], + ), + ], + }), + ]) + +const buttons = (h: HtmlBuilder, noop: M): Html => { + const variants = ["default", "secondary", "destructive", "ghost", "link"] as const + const states = (variant: (typeof variants)[number]): ReadonlyArray => [ + Button.view(h, { variant, label: "Default", onClick: noop }), + Button.view(h, { + variant, + label: "Hover", + onClick: noop, + className: + variant === "default" + ? "bg-primary-hover border-primary-hover" + : variant === "destructive" + ? "bg-destructive-hover border-destructive-hover" + : variant === "link" + ? "underline" + : "bg-surface-muted", + }), + Button.view(h, { variant, label: "Focus", onClick: noop, className: focusDemoClass }), + Button.view(h, { variant, label: "Disabled", isDisabled: true }), + Button.view(h, { + variant, + label: "Saving…", + onClick: noop, + isDisabled: true, + attributes: [h.AriaBusy(true)], + }), + Button.view(h, { + variant, + label: "Error", + onClick: noop, + attributes: [h.AriaInvalid(true)], + }), + ] + return block(h, "Buttons", [ + panel(h, { + children: [ + h.div( + [h.Class("flex flex-col gap-3")], + [ + ...variants.map((variant) => + h.div( + [h.Class("flex flex-col gap-1.5")], + [caption(h, variant), row(h, states(variant))], + ), + ), + caption(h, "sizes"), + row(h, [ + Button.view(h, { + size: "xs", + label: "Extra small", + onClick: noop, + variant: "secondary", + }), + Button.view(h, { size: "sm", label: "Small", onClick: noop, variant: "secondary" }), + Button.view(h, { label: "Default", onClick: noop, variant: "secondary" }), + Button.view(h, { size: "lg", label: "Large", onClick: noop, variant: "secondary" }), + Button.view(h, { + size: "icon", + variant: "secondary", + label: Icon.view(h, Pencil), + onClick: noop, + attributes: [h.AriaLabel("Edit")], + }), + Button.view(h, { + size: "icon-sm", + variant: "ghost", + label: Icon.view(h, Plus), + onClick: noop, + attributes: [h.AriaLabel("Add")], + }), + Button.view(h, { + label: h.span([h.Class("contents")], [Icon.view(h, Plus), "New rule"]), + onClick: noop, + }), + ]), + ], + ), + ], + }), + ]) +} + +const inputs = (h: HtmlBuilder, noop: M): Html => + block(h, "Inputs", [ + panel(h, { + children: [ + h.div( + [h.Class("grid max-w-2xl grid-cols-1 gap-3 sm:grid-cols-2")], + [ + input(h, { + id: "ds-input", + label: "Default", + placeholder: "Placeholder", + onInput: () => noop, + }), + input(h, { + id: "ds-input-value", + label: "With value and help", + value: "effect-ts/effect", + description: "Repository names are machine values.", + className: "font-mono", + onInput: () => noop, + }), + input(h, { + id: "ds-input-focus", + label: "Focus", + value: "Focused", + className: cn(focusDemoClass, "border-primary bg-card"), + onInput: () => noop, + }), + input(h, { + id: "ds-input-disabled", + label: "Disabled", + value: "Disabled", + isDisabled: true, + }), + input(h, { + id: "ds-input-error", + label: "Error", + value: "not-a-repository", + isInvalid: true, + description: "Use owner/repository.", + descriptionClass: "text-destructive", + onInput: () => noop, + }), + Select.view(h, { + id: "ds-select", + label: "Select", + value: "issues", + options: [ + ["issues", "Issues"], + ["pulls", "Pull requests"], + ["both", "Issues and pull requests"], + ], + onChange: () => noop, + }), + h.div( + [h.Class("flex flex-col gap-1.5")], + [ + h.span([h.Class("text-label font-medium")], ["Input group"]), + inputGroup(h, { + children: [ + inputGroupAddon(h, { children: [Icon.view(h, Search)] }), + inputGroupInput(h, { + id: "ds-input-group", + ariaLabel: "Search rules", + placeholder: "Search rules", + onInput: () => noop, + }), + ], + }), + ], + ), + h.div( + [h.Class("flex flex-col gap-1.5")], + [ + h.span([h.Class("text-label font-medium")], ["Checkbox"]), + h.label( + [h.Class("flex items-center gap-2 text-body-sm")], + [ + h.input([ + h.Type("checkbox"), + h.Class("size-3.5 accent-primary"), + h.Checked(true), + ]), + "Apply to pull requests", + ], + ), + h.label( + [h.Class("flex items-center gap-2 text-body-sm")], + [ + h.input([h.Type("checkbox"), h.Class("size-3.5 accent-primary")]), + "Apply to issues", + ], + ), + h.label( + [h.Class("flex items-center gap-2 text-body-sm opacity-50")], + [ + h.input([ + h.Type("checkbox"), + h.Class("size-3.5 accent-primary"), + h.Disabled(true), + ]), + "Disabled", + ], + ), + ], + ), + h.div( + [h.Class("sm:col-span-2")], + [ + textarea( + { + id: "ds-textarea", + label: "Textarea", + placeholder: "Does this describe a bug?", + description: "Prompts are prose a human wrote, so they stay in Inter.", + onInput: () => noop, + }, + h, + ), + ], + ), + ], + ), + ], + }), + ]) + +const switches = (h: HtmlBuilder, noop: M): Html => + block(h, "Switches", [ + panel(h, { + children: [ + row(h, [ + Switch.view(h, { + id: "ds-switch-off", + label: "Off", + isChecked: false, + onToggle: () => noop, + }), + Switch.view(h, { + id: "ds-switch-on", + label: "On", + isChecked: true, + onToggle: () => noop, + }), + Switch.view(h, { + id: "ds-switch-focus", + label: "Focus", + isChecked: true, + onToggle: () => noop, + attributes: [h.Class(focusDemoClass)], + }), + Switch.view(h, { + id: "ds-switch-disabled", + label: "Disabled", + isChecked: false, + isDisabled: true, + onToggle: () => noop, + }), + Switch.view(h, { + id: "ds-switch-busy", + label: "Saving", + isChecked: true, + isDisabled: true, + isBusy: true, + onToggle: () => noop, + }), + ]), + ], + }), + ]) + +const labelDot = (h: HtmlBuilder, color: string): Html => + h.span( + [h.Class("size-1.5 rounded-full"), h.Style({ backgroundColor: color }), h.AriaHidden(true)], + [], + ) + +const badges = (h: HtmlBuilder): Html => + block(h, "Badges", [ + panel(h, { + children: [ + row(h, [ + ...(Object.keys(chipVariants) as ReadonlyArray).map((variant) => + chip(h, { variant, children: [variant] }), + ), + chip(h, { children: [labelDot(h, "var(--oc-danger)"), "bug"] }), + chip(h, { children: [labelDot(h, "var(--oc-success)"), "good first issue"] }), + Feed.agentBadge(h), + h.span([h.Class("oc-agent-badge")], ["written by The Janitor"]), + platformMark(h, "github"), + platformMark(h, "slack"), + avatar(h, "maxwell.brown"), + ]), + ], + }), + ]) + +const cards = (h: HtmlBuilder, noop: M): Html => + block(h, "Cards and empty states", [ + panel(h, { + flush: true, + children: [ + panelHeader(h, { + title: "Rules", + meta: "rev 14 · 6 rules", + actions: [ + Button.view(h, { size: "sm", variant: "secondary", label: "New rule", onClick: noop }), + ], + }), + h.div([h.Class("p-4 text-body-md")], ["Card body at 16px padding. Cards do not nest."]), + ], + }), + emptyPanel(h, { + children: [ + h.div( + [h.Class("flex items-center justify-between gap-3")], + [ + h.span([], ["No rules yet. Create one to start labeling."]), + Button.view(h, { size: "sm", label: "New rule", onClick: noop }), + ], + ), + ], + }), + panel(h, { + attributes: [h.Role("alert")], + className: "border-destructive text-body-sm", + children: [ + h.div([h.Class("font-medium text-destructive")], ["Sync failed"]), + h.div([h.Class("text-ink-muted")], ["GitHub returned 502 for 2 scopes. Retry the sync."]), + ], + }), + panel(h, { + className: "flex flex-col gap-2", + children: [ + caption(h, "loading: static blocks"), + skeleton(h, { className: "h-3 w-1/2", children: [] }), + skeleton(h, { className: "h-3 w-1/3", children: [] }), + ], + }), + ]) + +const ruleNames = [ + "needs-triage", + "bug", + "docs", + "area/schema", + "area/platform", + "good first issue", + "breaking-change", + "regression", +] + +const denseTable = (h: HtmlBuilder, noop: M): Html => + block(h, "Dense table (200 rows)", [ + panel(h, { + flush: true, + children: [ + panelHeader(h, { title: "Labeling rules", meta: "200 rows · rev 14" }), + h.div( + [h.Class("max-h-96 overflow-auto")], + [ + Table.table(h, { + children: [ + Table.head(h, [ + Table.row(h, { + children: [ + Table.headCell(h, { children: ["Rule"] }), + Table.headCell(h, { children: ["Type"] }), + Table.headCell(h, { children: ["Label"] }), + Table.headCell(h, { numeric: true, children: ["Runs"] }), + Table.headCell(h, { numeric: true, children: ["Matches"] }), + Table.headCell(h, { children: ["Last run"] }), + Table.headCell(h, { children: ["Enabled"] }), + ], + }), + ]), + Table.body( + h, + Array.from({ length: 200 }, (_, index) => { + const name = ruleNames[index % ruleNames.length] ?? "rule" + const ai = index % 5 === 2 + return Table.row(h, { + isSelected: index === 3, + children: [ + Table.cell(h, { code: true, children: [`${name}-${index + 1}`] }), + Table.cell(h, { + children: [ + ai + ? chip(h, { variant: "agent", children: ["AI"] }) + : chip(h, { children: ["policy"] }), + ], + }), + Table.cell(h, { children: [chip(h, { children: [name] })] }), + Table.cell(h, { numeric: true, children: [String((index * 37) % 1204)] }), + Table.cell(h, { numeric: true, children: [String((index * 7) % 89)] }), + Table.cell(h, { + code: true, + children: [ + `2026-09-1${index % 4} 12:${String(index % 60).padStart(2, "0")}`, + ], + }), + Table.cell(h, { + children: [ + Switch.view(h, { + id: `ds-row-switch-${index}`, + label: `Enable ${name}`, + isLabelHidden: true, + isChecked: index % 3 !== 0, + onToggle: () => noop, + }), + ], + }), + ], + }) + }), + ), + ], + }), + ], + ), + ], + }), + ]) + +const tabs = (h: HtmlBuilder): Html => + block(h, "Tabs", [ + h.div( + [h.DataAttribute("slot", "tabs-list"), h.Role("tablist")], + [ + h.button( + [ + h.Type("button"), + h.Role("tab"), + h.DataAttribute("slot", "tabs-trigger"), + h.DataAttribute("state", "active"), + h.AriaSelected(true), + ], + ["Overview"], + ), + h.button( + [ + h.Type("button"), + h.Role("tab"), + h.DataAttribute("slot", "tabs-trigger"), + h.AriaSelected(false), + ], + ["Policies"], + ), + h.button( + [ + h.Type("button"), + h.Role("tab"), + h.DataAttribute("slot", "tabs-trigger"), + h.AriaSelected(false), + h.Class(focusDemoClass), + ], + ["Rules"], + ), + h.button( + [ + h.Type("button"), + h.Role("tab"), + h.DataAttribute("slot", "tabs-trigger"), + h.AriaSelected(false), + h.Disabled(true), + h.Class("opacity-50"), + ], + ["Activity"], + ), + ], + ), + ]) + +const overlays = (h: HtmlBuilder, noop: M): Html => + block(h, "Overlays (the only shadowed elements)", [ + h.div( + [h.Class("grid grid-cols-1 gap-3 lg:grid-cols-2")], + [ + h.div( + [ + h.Class(cn(Dialog.dialogContentClass, "mx-0 mt-0 w-full")), + h.Role("dialog"), + h.AriaLabel("Delete rule?"), + ], + [ + h.h2([h.Class(Dialog.dialogTitleClass)], ["Delete rule?"]), + h.p( + [h.Class(Dialog.dialogDescriptionClass)], + ['Delete "needs-triage"? This cannot be undone.'], + ), + h.div( + [h.Class(Dialog.dialogActionsClass)], + [ + Button.view(h, { variant: "secondary", label: "Cancel", onClick: noop }), + Button.view(h, { variant: "destructive", label: "Delete rule", onClick: noop }), + ], + ), + ], + ), + h.div( + [h.Class("flex flex-col gap-3")], + [ + h.div( + [h.Class(Overlay.menuItemsClass), h.Role("menu")], + [ + h.div( + [ + h.Class(cn(Overlay.menuItemClass, Overlay.menuItemActiveClass)), + h.Role("menuitem"), + ], + [Icon.view(h, Pencil), "Edit rule"], + ), + h.div( + [h.Class(Overlay.menuItemClass), h.Role("menuitem")], + [Icon.view(h, Check), "Enable"], + ), + h.div( + [h.Class(cn(Overlay.menuItemClass, "text-destructive")), h.Role("menuitem")], + ["Delete"], + ), + ], + ), + h.div( + [h.Class(Overlay.tooltipContentClass), h.Role("tooltip")], + ["Re-sync GitHub · last verified 3 minutes ago"], + ), + h.div( + [h.Class(Overlay.toastClass), h.Role("status")], + [ + h.div([h.Class(Overlay.toastTitleClass)], ["Paused"]), + h.div( + [h.Class(Overlay.toastDescriptionClass)], + ["Automation is paused for effect-ts/effect."], + ), + ], + ), + h.div( + [h.Class(cn(Overlay.toastClass, "border-destructive")), h.Role("alert")], + [ + h.div([h.Class(Overlay.toastTitleClass)], ["Sync request failed"]), + h.div( + [h.Class(Overlay.toastDescriptionClass)], + ["GitHub did not accept the request. Retry."], + ), + ], + ), + ], + ), + ], + ), + ]) + +const feed = (h: HtmlBuilder): Html => + block(h, "Activity feed", [ + panel(h, { + flush: true, + children: [ + Feed.item(h, { + actor: { kind: "agent" }, + timestamp: "12:04:31", + body: [ + "added ", + chip(h, { children: ["bug"] }), + " to ", + h.a([h.Href("#"), h.Class("font-mono")], ["#4821"]), + " after rule ", + h.code([h.Class("text-mono-sm")], ["needs-triage"]), + " matched.", + ], + }), + Feed.item(h, { + actor: { kind: "agent" }, + timestamp: "12:04:29", + body: [ + "classified ", + h.a([h.Href("#"), h.Class("font-mono")], ["#4821"]), + " as a bug at 0.91 confidence: ", + h.span( + [h.Class("text-ink-muted")], + ["“The report includes a stack trace and a reproducible failing case.”"], + ), + ], + }), + Feed.item(h, { + actor: { kind: "human", name: "mikearnaldi" }, + timestamp: "11:58:02", + body: [ + "opened ", + h.a([h.Href("#"), h.Class("font-mono")], ["#4821"]), + " Schema.decodeUnknown panics on circular input", + ], + }), + Feed.item(h, { + actor: { kind: "agent" }, + timestamp: "11:40:00", + body: [ + "could not add ", + chip(h, { children: ["area/schema"] }), + " to ", + h.a([h.Href("#"), h.Class("font-mono")], ["#4819"]), + ": ", + h.span([h.Class("text-destructive")], ["GitHub returned 403."]), + ], + }), + ], + }), + ]) + +const inspector = (h: HtmlBuilder): Html => + block(h, "Inspector", [ + panel(h, { + flush: true, + className: "max-w-inspector", + children: [ + Inspector.section(h, { + heading: "Selection", + children: [ + Inspector.row(h, "Rule", "needs-triage"), + Inspector.row(h, "Kind", "policy"), + Inspector.row(h, "Label", "needs-triage"), + Inspector.row(h, "Revision", "14"), + ], + }), + Inspector.section(h, { + heading: "Dry run", + children: [ + Inspector.proseRow(h, "Result", "Would add the label to 12 of 40 open issues."), + ], + }), + Inspector.section(h, { + heading: "History", + children: [ + h.p([h.Class("text-body-sm text-ink-muted")], ["Select a row to see its history."]), + ], + }), + ], + }), + ]) + +const blueprint = (h: HtmlBuilder): Html => + block(h, "Blueprint canvas (graph views only)", [ + panel(h, { + flush: true, + children: [ + panelHeader(h, { title: "needs-triage", meta: "rule · rev 14" }), + Blueprint.canvas(h, { + children: [ + Blueprint.column(h, { + label: "When", + children: [Blueprint.node(h, { kind: "Issue opened", children: ["issues.opened"] })], + }), + Blueprint.wire(h, { label: "and" }), + Blueprint.column(h, { + label: "If every condition matches", + children: [ + Blueprint.node(h, { + kind: "Policy", + isSelected: true, + children: ["no-triage-label"], + }), + Blueprint.node(h, { + kind: "Classification", + isAgent: true, + children: ["Does this describe a bug? ≥ 0.80"], + }), + ], + }), + Blueprint.wire(h, { height: 96, fromY: 48, toY: 24 }), + Blueprint.column(h, { + label: "Then", + children: [ + Blueprint.node(h, { kind: "Add label", children: ["needs-triage"] }), + Blueprint.annotation(h, [ + "Runs only when the gate policy matches; otherwise labels stay unchanged.", + ]), + ], + }), + ], + }), + Blueprint.footer(h, { + summary: "1 trigger · 2 conditions · 1 action", + counts: "3 nodes · 2 wires", + }), + ], + }), + ]) + +const navigation = (h: HtmlBuilder): Html => + block(h, "Section navigation", [ + panel(h, { + className: "max-w-56", + children: [ + rack(h, { + label: "Account sections", + items: [ + { href: "#", label: "You", isCurrent: false }, + { href: "#", label: "Connected accounts", isCurrent: true }, + { href: "#", label: "Team", isCurrent: false }, + { href: "#", label: "Danger zone", isCurrent: false, variant: "danger" }, + ], + }), + ], + }), + ]) + +const sections = (h: HtmlBuilder, noop: M): ReadonlyArray => [ + typography(h), + colors(h), + buttons(h, noop), + inputs(h, noop), + switches(h, noop), + badges(h), + cards(h, noop), + tabs(h), + denseTable(h, noop), + overlays(h, noop), + feed(h), + inspector(h), + blueprint(h), + navigation(h), +] + +export const view = (h: HtmlBuilder, inputs: ViewInputs): Html => + h.div( + [h.Class("flex flex-col gap-6 p-4 lg:p-5")], + [ + h.div( + [h.Class("flex flex-col gap-1")], + [ + h.h1([], ["Design system"]), + h.p( + [h.Class("text-body-sm text-ink-muted")], + [ + "Every primitive in every state. Light first, then the same set inside a dark container.", + ], + ), + ], + ), + ...sections(h, inputs.noop), + h.div( + [ + h.Class( + "dark flex flex-col gap-6 rounded-sm border border-border bg-background p-4 text-foreground", + ), + ], + [h.h2([], ["Dark"]), ...sections(h, inputs.noop)], + ), + ], + ) diff --git a/apps/web/src/components/janitor-icon.ts b/apps/web/src/components/janitor-icon.ts index ba757365..6b27f6a5 100644 --- a/apps/web/src/components/janitor-icon.ts +++ b/apps/web/src/components/janitor-icon.ts @@ -12,7 +12,7 @@ export const view = (h: HtmlBuilder, config: JanitorIconConfig): Html => [ h.DataAttribute("slot", "janitor-icon"), h.Class( - cn("relative block overflow-hidden rounded-sm border-2 border-outline", config.className), + cn("relative block overflow-hidden rounded-xs border border-border", config.className), ), ], [ diff --git a/apps/web/src/components/policy-editor.ts b/apps/web/src/components/policy-editor.ts index 9bff2742..947b357f 100644 --- a/apps/web/src/components/policy-editor.ts +++ b/apps/web/src/components/policy-editor.ts @@ -8,27 +8,19 @@ import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest" import * as HttpIncomingMessage from "effect/unstable/http/HttpIncomingMessage" import * as FoldkitCommand from "foldkit/command" import * as Mount from "foldkit/mount" -import type { Html, HtmlBuilder } from "foldkit/html" +import type { Attribute, ChildAttribute, Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" import { evo } from "foldkit/struct" import * as Submodel from "foldkit/submodel" import * as Update from "foldkit/update" import * as Button from "@/components/ui/button" +import { selectClass } from "@/components/ui/select" +import * as DialogChrome from "@/components/ui/dialog" +import * as Inspector from "@/components/ui/inspector" import * as Disclosure from "@foldkit/ui/disclosure" import * as Dialog from "@foldkit/ui/dialog" import * as Icon from "@/lib/icons" -import { - Upload, - Save, - ChevronRight, - CodeXml, - Pencil, - Trash2, - CircleCheck, - CircleAlert, - CircleHelp, - LoaderCircle, -} from "lucide" +import { Check, ChevronDown, ChevronRight, Info, LoaderCircle, Pencil } from "lucide" import { input } from "@/components/ui/input" import * as PolicySource from "@/components/policy-source" import * as TestBench from "@/components/test-bench" @@ -771,6 +763,37 @@ export const update = (model: Model, message: Message): UpdateReturn => // VIEW +/** An inspector section that keeps its own attributes (the `Versions` + * region is named for assistive tech, and Validation carries an action in + * its heading). Same markup as `Inspector.section`. */ +const inspectorSection = ( + h: HtmlBuilder, + config: { + readonly heading: string + readonly action?: Html + readonly attributes?: ReadonlyArray | ChildAttribute> + readonly children: ReadonlyArray + }, +): Html => + h.section( + [h.DataAttribute("slot", "inspector-section"), ...(config.attributes ?? [])], + [ + config.action === undefined + ? h.h3([h.DataAttribute("slot", "inspector-heading")], [config.heading]) + : h.div( + [h.Class("mb-2 flex items-center justify-between gap-2")], + [ + h.h3( + [h.DataAttribute("slot", "inspector-heading"), h.Class("mb-0")], + [config.heading], + ), + config.action, + ], + ), + ...config.children, + ], + ) + const validationView = (h: HtmlBuilder, validation: Validation): Html => { const status = validation._tag === "Valid" @@ -780,34 +803,41 @@ const validationView = (h: HtmlBuilder, validation: Validation): Html = : validation._tag === "Validating" ? "checking" : "unchecked" - const icon = - validation._tag === "Valid" - ? CircleCheck - : validation._tag === "Invalid" - ? CircleAlert - : validation._tag === "Validating" - ? LoaderCircle - : CircleHelp const title = validation._tag === "Valid" - ? "Policy is valid." + ? "Valid" : validation._tag === "Invalid" - ? "Policy is invalid" + ? "Invalid" : validation._tag === "Validating" - ? "Checking policy…" + ? "Checking…" : "Not validated" + const tone = + validation._tag === "Valid" + ? "text-success" + : validation._tag === "Invalid" + ? "text-destructive" + : "text-foreground" return h.div( [ - h.Class("policy-validation"), + h.Class("flex flex-col gap-1 text-body-sm wrap-anywhere"), h.DataAttribute("validation", status), h.Role(status === "invalid" ? "alert" : "status"), ], [ h.div( - [h.Class("flex items-center gap-2 font-medium")], - [Icon.view(h, icon, status === "checking" ? "size-4 animate-spin" : "size-4"), title], + [h.Class(`flex items-center gap-1.5 font-medium ${tone}`)], + [ + validation._tag === "Valid" + ? Icon.view(h, Check) + : validation._tag === "Validating" + ? Icon.view(h, LoaderCircle, "animate-spin") + : h.empty, + title, + ], ), - validation._tag === "Invalid" ? h.p([h.Class("text-xs")], [validation.message]) : h.empty, + validation._tag === "Invalid" + ? h.p([h.Class("text-body-sm text-ink-muted")], [validation.message]) + : h.empty, ], ) } @@ -819,13 +849,22 @@ const submissionView = (h: HtmlBuilder, submission: Submission): Html = return h.empty case "Conflicted": return h.div( - [h.Class("text-xs text-amber-600 dark:text-amber-400"), h.Role("alert")], + [h.Class("flex items-start gap-1.5 text-body-sm text-foreground"), h.Role("alert")], [ - "Someone saved this policy meanwhile. Your draft is intact; saving again writes over theirs.", + Icon.view(h, Info, "mt-0.5 shrink-0"), + h.span( + [], + [ + "Someone saved this policy meanwhile. Your draft is intact; saving again writes over theirs.", + ], + ), ], ) case "SubmitError": - return h.div([h.Class("text-destructive text-xs"), h.Role("alert")], [submission.message]) + return h.div( + [h.Class("text-body-sm text-destructive"), h.Role("alert")], + [submission.message], + ) } } @@ -845,23 +884,36 @@ const disclosure = ( onToggle, toView: ({ button, panel }) => h.section( - [h.Class("policy-disclosure")], + [h.DataAttribute("slot", "inspector-section")], [ h.button( - [...button, h.Class("policy-disclosure-toggle")], [ - Icon.view(h, ChevronRight, isOpen ? "size-3 rotate-90" : "size-3"), + ...button, + h.Class( + "flex w-full cursor-pointer items-center gap-1.5 rounded-xs text-left text-caption font-medium text-ink-subtle transition-colors duration-120 ease-ui hover:text-foreground", + ), + ], + [ + Icon.view(h, isOpen ? ChevronDown : ChevronRight, "size-3"), h.span([], [title]), - h.span([h.Class("ml-auto text-muted-foreground")], [summary]), + h.span([h.Class("ml-auto font-mono text-mono-xs")], [summary]), ], ), - isOpen ? h.div([...panel, h.Class("policy-disclosure-content")], children) : h.empty, + isOpen + ? h.div( + [...panel, h.Class("mt-2 flex flex-col gap-2 text-body-sm wrap-anywhere")], + children, + ) + : h.empty, ], ), }, h, ) +const metadataTextClass = + "-mx-1 cursor-text rounded-xs border border-transparent px-1 text-left transition-colors duration-120 ease-ui hover:border-border focus-visible:border-primary focus-visible:outline-none disabled:cursor-default disabled:hover:border-transparent wrap-anywhere" + interface ViewInputs { readonly isDeleting?: boolean } @@ -876,6 +928,8 @@ export const view = Submodel.defineView( identity._tag === "Existing" ? model.configuration.rules.filter((rule) => rule.policyId === identity.policyId).length : 0 + const showActions = + identity._tag === "New" || isDirty(model) || (canSubmit && hasChangesToPublish(model)) return h.keyed("div")( identity._tag === "Existing" ? identity.policyId : "new-policy", [h.Class("policy-document"), h.DataAttribute("editor", "policy")], @@ -887,9 +941,14 @@ export const view = Submodel.defineView( [h.Class("policy-document-center"), h.AriaLabel("Policy document")], [ h.div( - [h.Class("policy-document-heading")], + [h.Class("flex flex-col gap-1 px-5 pt-4 pb-3")], [ - isDeleting ? h.p([h.Role("status")], ["Deleting policy…"]) : h.empty, + isDeleting + ? h.p( + [h.Role("status"), h.Class("text-body-sm text-ink-muted")], + ["Deleting policy…"], + ) + : h.empty, ...(["name", "description"] as const).map((field) => { const draft = model.metadataEdits[field] const label = field === "name" ? "title" : "description" @@ -899,16 +958,16 @@ export const view = Submodel.defineView( draft === null ? h.keyed("div")( `metadata-display-${field}`, - [h.Class("policy-metadata-display")], + [h.Class("group flex min-h-6 items-center gap-2")], [ field === "name" ? h.h1( - [h.Class("policy-document-title")], + [h.Class("min-w-0 text-h1")], [ h.button( [ h.Type("button"), - h.Class("policy-metadata-text"), + h.Class(metadataTextClass), h.Disabled(busy), h.OnClick(Message.ClickedEditMetadata({ field })), ], @@ -917,12 +976,12 @@ export const view = Submodel.defineView( ], ) : h.p( - [h.Class("policy-document-description")], + [h.Class("min-w-0 text-body-sm text-ink-muted")], [ h.button( [ h.Type("button"), - h.Class("policy-metadata-text"), + h.Class(metadataTextClass), h.Disabled(busy), h.OnClick(Message.ClickedEditMetadata({ field })), ], @@ -934,6 +993,8 @@ export const view = Submodel.defineView( variant: "ghost", size: "icon-xs", isDisabled: busy, + className: + "shrink-0 text-ink-subtle opacity-0 group-hover:opacity-100 group-focus-within:opacity-100", label: Icon.view(h, Pencil, "size-3"), onClick: Message.ClickedEditMetadata({ field }), attributes: [ @@ -946,7 +1007,7 @@ export const view = Submodel.defineView( : h.keyed("div")( `metadata-edit-${field}`, [ - h.Class("policy-metadata-form"), + h.Class("policy-metadata-form flex items-center gap-1.5"), h.OnFocusLeave(Message.ClickedCancelMetadata({ field })), ], [ @@ -957,7 +1018,7 @@ export const view = Submodel.defineView( onInput: (value) => Message.UpdatedMetadataDraft({ field, value }), labelClass: "sr-only", - wrapperClass: "gap-0", + wrapperClass: "min-w-0 flex-1 gap-0", attributes: [ h.OnMount(FocusMetadataInput()), h.OnKeyDownPreventDefault((key) => @@ -968,11 +1029,11 @@ export const view = Submodel.defineView( ], className: field === "name" - ? "policy-title-input" - : "policy-description-input", + ? "h-8 text-h1 font-semibold" + : "text-body-sm", }), h.div( - [h.Class("flex items-center gap-1")], + [h.Class("flex shrink-0 items-center gap-1")], [ Button.view(h, { size: "xs", @@ -998,15 +1059,13 @@ export const view = Submodel.defineView( ], ), h.div( - [h.Class("policy-source-toolbar")], [ - h.span( - [h.Class("flex items-center gap-2")], - [ - Icon.view(h, CodeXml, "size-3.5"), - h.span([h.Class("text-muted-foreground")], ["YAML"]), - ], + h.Class( + "flex h-7 shrink-0 items-center justify-between gap-2 border-y border-border bg-surface-muted px-5 text-caption font-medium text-ink-subtle", ), + ], + [ + h.span([h.Class("font-mono text-mono-xs")], ["YAML"]), Button.view(h, { variant: "ghost", size: "xs", @@ -1038,7 +1097,7 @@ export const view = Submodel.defineView( ? h.empty : h.ul( [ - h.Class("text-destructive flex flex-col gap-0.5 text-xs"), + h.Class("flex flex-col gap-0.5 text-body-sm text-destructive"), h.Role("alert"), ], issues.map((issue) => h.li([], [issue])), @@ -1051,189 +1110,165 @@ export const view = Submodel.defineView( h.aside( [h.Class("policy-inspector"), h.AriaLabel("Policy test bench and information")], [ - identity._tag === "New" || - isDirty(model) || - (canSubmit && hasChangesToPublish(model)) + showActions ? h.section( - [h.Class("policy-sidebar-actions"), h.AriaLabel("Policy controls")], [ - h.div( - [ - h.Class( - identity._tag === "New" - ? "policy-publish-actions policy-creation-actions" - : "policy-publish-actions", - ), - ], - [ - identity._tag === "New" - ? Button.view(h, { - variant: "ghost", - size: "sm", - label: "Cancel", - attributes: [h.DataAttribute("action", "cancel-creation")], - onClick: Message.ClickedCancel(), - isDisabled: busy, - }) - : h.empty, - identity._tag === "New" || isDirty(model) - ? Button.view(h, { - variant: "outline", - size: "sm", - onClick: Message.ClickedSave(), - isDisabled: !canSubmit, - label: h.span( - [h.Class("flex items-center gap-1.5")], - [Icon.view(h, Save, "size-3.5"), "Save draft"], - ), - }) - : h.empty, - identity._tag === "New" || (canSubmit && hasChangesToPublish(model)) - ? Button.view(h, { - size: "sm", - onClick: Message.ClickedPublish(), - isDisabled: !canSubmit || !hasChangesToPublish(model), - label: h.span( - [h.Class("flex items-center gap-1.5")], - [ - Icon.view(h, Upload, "size-3.5"), - identity._tag === "New" ? "Save & publish" : "Publish", - ], - ), - attributes: [h.DataAttribute("action", "publish")], - }) - : h.empty, - ], + h.Class( + "flex flex-wrap items-center gap-1.5 border-b border-border px-3.5 py-3", ), + h.AriaLabel("Policy controls"), + ], + [ + identity._tag === "New" || (canSubmit && hasChangesToPublish(model)) + ? Button.view(h, { + size: "sm", + onClick: Message.ClickedPublish(), + isDisabled: !canSubmit || !hasChangesToPublish(model), + label: identity._tag === "New" ? "Save & publish" : "Publish", + attributes: [h.DataAttribute("action", "publish")], + }) + : h.empty, + identity._tag === "New" || isDirty(model) + ? Button.view(h, { + variant: "secondary", + size: "sm", + onClick: Message.ClickedSave(), + isDisabled: !canSubmit, + label: "Save draft", + }) + : h.empty, + identity._tag === "New" + ? Button.view(h, { + variant: "ghost", + size: "sm", + label: "Cancel", + attributes: [h.DataAttribute("action", "cancel-creation")], + onClick: Message.ClickedCancel(), + isDisabled: busy, + }) + : h.empty, ], ) : h.empty, - h.section( - [ - h.Class("policy-inspector-section flex flex-col gap-3"), - h.AriaLabel("Versions"), + inspectorSection(h, { + heading: "Status", + children: [ + Inspector.row(h, "Publication", PolicyStatus.view(h, publicationStatus(model))), ], - [ - h.div( - [h.Class("flex items-center justify-between gap-2 flex-wrap")], - [ - h.h2([h.Class("text-sm font-semibold")], ["Versions"]), - PolicyStatus.view(h, publicationStatus(model)), - ], + }), + inspectorSection(h, { + heading: "Versions", + attributes: [h.AriaLabel("Versions")], + children: [ + Inspector.row( + h, + "Published", + model.publishedRevision === null + ? "Not published" + : `v${model.publishedRevision}`, ), + ], + }), + inspectorSection(h, { + heading: "Test bench", + children: [ h.div( - [h.Class("policy-property")], + [h.Class("flex flex-col gap-2")], [ - h.span([], ["Published"]), - h.span( - [], - [ - model.publishedRevision === null - ? "Not published" - : `v${model.publishedRevision}`, - ], - ), - ], - ), - ], - ), - h.div( - [h.Class("policy-inspector-section flex flex-col gap-3")], - [ - h.h2([h.Class("text-sm font-semibold")], ["Test bench"]), - model.testCandidates._tag === "Failed" - ? h.p( - [h.Class("text-xs text-destructive"), h.Role("alert")], - ["Could not load test items. Retrying with the next repository refresh."], - ) - : testItems(model).length === 0 - ? h.p( - [h.Class("text-xs text-muted-foreground")], - ["No open items are available for this policy's target."], - ) - : h.div( - [h.Class("flex flex-col gap-1.5")], - [ - h.label( - [ - h.For("policy-test-choice"), - h.Class("text-xs text-muted-foreground"), - ], - [ - Option.exists( - parsedSource(model), - (source) => source.target === "issue", - ) - ? "Issues" - : "Pull Requests", - ], - ), - h.select( + model.testCandidates._tag === "Failed" + ? h.p( + [h.Class("text-body-sm"), h.Role("alert")], + [ + h.span( + [h.Class("text-destructive")], + ["Could not load test items."], + ), + " Retrying with the next repository refresh.", + ], + ) + : testItems(model).length === 0 + ? h.p( + [h.Class("text-body-sm text-ink-muted")], + ["No open items are available for this policy's target."], + ) + : h.div( + [h.Class("flex flex-col gap-1")], [ - h.Id("policy-test-choice"), - h.Class("policy-test-select"), - h.Value(String(selectedTestItem(model)?.number ?? "")), - h.OnChange((value) => - Message.SelectedTestItem({ number: Number(value) }), + h.label( + [ + h.For("policy-test-choice"), + h.Class("text-body-sm text-ink-muted"), + ], + [ + Option.exists( + parsedSource(model), + (source) => source.target === "issue", + ) + ? "Issues" + : "Pull Requests", + ], ), - ], - testItems(model).map((item) => - h.option( - [h.Value(String(item.number))], - [`#${item.number} · ${item.title}`], + h.select( + [ + h.Id("policy-test-choice"), + h.Class(selectClass), + h.Value(String(selectedTestItem(model)?.number ?? "")), + h.OnChange((value) => + Message.SelectedTestItem({ number: Number(value) }), + ), + ], + testItems(model).map((item) => + h.option( + [h.Value(String(item.number))], + [`#${item.number} · ${item.title}`], + ), + ), ), - ), + ], ), - ], - ), - Option.match(model.maybeTestBench, { - onNone: () => - h.div( - [h.Class("flex flex-col gap-3")], - [ - Button.view(h, { - variant: "outline", - size: "sm", - onClick: Message.ClickedTestDraft(), - isDisabled: - Option.isNone(parsedSource(model)) || - selectedTestItem(model) === undefined, - label: "Test draft", - }), - ], - ), - onSome: (bench) => - h.submodel({ - slotId: "draft-test-bench", - model: bench, - view: TestBench.view, - toParentMessage: (message) => - Message.GotTestBenchMessage({ - message, - generation: model.testGeneration, + Option.match(model.maybeTestBench, { + onNone: () => + h.div( + [], + [ + Button.view(h, { + variant: "secondary", + size: "sm", + onClick: Message.ClickedTestDraft(), + isDisabled: + Option.isNone(parsedSource(model)) || + selectedTestItem(model) === undefined, + label: "Test draft", + }), + ], + ), + onSome: (bench) => + h.submodel({ + slotId: "draft-test-bench", + model: bench, + view: TestBench.view, + toParentMessage: (message) => + Message.GotTestBenchMessage({ + message, + generation: model.testGeneration, + }), }), }), - }), - ], - ), - h.div( - [h.Class("policy-inspector-section flex flex-col gap-3")], - [ - h.div( - [h.Class("flex items-center justify-between gap-2")], - [ - h.h2([h.Class("text-xs font-semibold")], ["Validation"]), - Button.view(h, { - variant: "ghost", - size: "xs", - onClick: Message.ClickedValidate(), - isDisabled: - Option.isNone(parsedSource(model)) || - model.validation._tag === "Validating", - label: "Validate", - }), ], ), + ], + }), + inspectorSection(h, { + heading: "Validation", + action: Button.view(h, { + variant: "ghost", + size: "xs", + onClick: Message.ClickedValidate(), + isDisabled: + Option.isNone(parsedSource(model)) || model.validation._tag === "Validating", + label: "Validate", + }), + children: [ validationView( h, Option.isNone(parsedSource(model)) @@ -1247,7 +1282,7 @@ export const view = Submodel.defineView( : model.validation, ), ], - ), + }), disclosure( h, "policy-used-by", @@ -1256,7 +1291,7 @@ export const view = Submodel.defineView( `${bound} rule${bound === 1 ? "" : "s"}`, (isOpen) => Message.ToggledUsedBy({ isOpen }), bound === 0 - ? [h.p([], ["No rules use this policy yet."])] + ? [h.p([h.Class("text-ink-muted")], ["No rules use this policy yet."])] : model.configuration.rules .filter( (rule) => @@ -1268,7 +1303,9 @@ export const view = Submodel.defineView( h.Href( Routes.rule({ repositoryId: model.repositoryId, ruleId: rule.id }), ), - h.Class("policy-rule-card"), + h.Class( + "flex flex-col gap-0.5 rounded-sm border border-border bg-card p-2 text-body-sm text-ink-muted transition-colors duration-120 ease-ui hover:bg-surface-muted hover:border-primary-line", + ), h.AriaLabel( `Rule for ${labelName(model.configuration.labels, rule.labelId)}`, ), @@ -1277,16 +1314,16 @@ export const view = Submodel.defineView( h.div( [h.Class("flex items-center justify-between gap-2")], [ - h.strong( - [h.Class("text-foreground")], + h.span( + [h.Class("font-mono text-mono-sm text-foreground")], [labelName(model.configuration.labels, rule.labelId)], ), h.span( [ h.Class( rule.enabled - ? "text-emerald-600 dark:text-emerald-400" - : "text-muted-foreground", + ? "text-caption font-medium text-success" + : "text-caption font-medium text-ink-subtle", ), ], [rule.enabled ? "Enabled" : "Disabled"], @@ -1297,31 +1334,35 @@ export const view = Submodel.defineView( h.p([], [`On non-match: ${describeResultAction(rule.onNoMatch)}`]), rule.group === null ? h.empty - : h.p([], [`Group: ${rule.group} · Priority ${rule.priority}`]), + : h.p( + [], + [ + "Group: ", + h.span([h.Class("font-mono text-mono-sm")], [rule.group]), + " · Priority ", + h.span( + [h.Class("font-mono text-mono-sm")], + [String(rule.priority)], + ), + ], + ), ], ), ), ), identity._tag === "Existing" - ? h.div( - [h.Class("policy-delete-actions")], - [ + ? inspectorSection(h, { + heading: "Delete", + children: [ Button.view(h, { variant: "destructive", - size: "lg", - className: "w-full h-10", + size: "sm", isDisabled: busy, onClick: Message.ClickedDelete(), - label: h.span( - [h.Class("flex items-center gap-1.5")], - [ - Icon.view(h, Trash2, "size-4"), - isDeleting ? "Deleting policy…" : "Delete policy", - ], - ), + label: isDeleting ? "Deleting policy…" : "Delete policy", }), ], - ) + }) : h.empty, ], ), @@ -1334,51 +1375,30 @@ export const view = Submodel.defineView( toParentMessage: (message) => Message.GotDeleteDialogMessage({ message }), viewInputs: { toView: (render) => - h.dialog( - [ - ...render.dialog, - h.Class( - "fixed inset-0 m-0 h-dvh w-screen max-h-none max-w-none bg-transparent p-0 text-foreground", - ), + DialogChrome.view(h, { + dialog: render.dialog, + backdrop: render.backdrop, + panel: render.panel, + title: render.title, + description: render.description, + isVisible: render.isVisible, + titleText: "Delete policy?", + descriptionText: 'Delete "' + model.name + '"? This cannot be undone.', + actions: [ + Button.view(h, { + label: "Cancel", + variant: "secondary", + attributes: [h.Id("cancel-delete-policy")], + onClick: Message.CancelledDelete(), + }), + Button.view(h, { + label: "Delete policy", + variant: "destructive", + isDisabled: busy, + onClick: Message.ConfirmedDelete(), + }), ], - render.isVisible - ? [ - h.div([...render.backdrop, h.Class("fixed inset-0 bg-black/40")], []), - h.div( - [ - ...render.panel, - h.Class( - "relative mx-auto mt-[20vh] w-[calc(100%-2rem)] max-w-md rounded-xl border bg-background p-5 shadow-xl space-y-4", - ), - ], - [ - h.h2([...render.title, h.Class("font-semibold")], ["Delete policy?"]), - h.p( - [...render.description, h.Class("text-sm text-muted-foreground")], - ['Delete "' + model.name + '"? This cannot be undone.'], - ), - h.div( - [h.Class("flex justify-end gap-2")], - [ - Button.view(h, { - label: "Cancel", - variant: "outline", - attributes: [h.Id("cancel-delete-policy")], - onClick: Message.CancelledDelete(), - }), - Button.view(h, { - label: "Delete policy", - variant: "destructive", - isDisabled: busy, - onClick: Message.ConfirmedDelete(), - }), - ], - ), - ], - ), - ] - : [], - ), + }), }, }), ], diff --git a/apps/web/src/components/policy-source/ai-editor.ts b/apps/web/src/components/policy-source/ai-editor.ts index 9370d902..860613d1 100644 --- a/apps/web/src/components/policy-source/ai-editor.ts +++ b/apps/web/src/components/policy-source/ai-editor.ts @@ -11,6 +11,7 @@ import { linter, forceLinting } from "@codemirror/lint" import { githubDark, githubLight } from "@uiw/codemirror-theme-github" import { inspectAiPrompt } from "@janitor/domain/Labeling/Policy/PromptReferences" import type { FactDescription } from "../labeling-wire" +import { hostTheme } from "./editor" export const aiCompletion = (catalog: ReadonlyArray, target: string) => (context: CompletionContext) => { @@ -46,7 +47,7 @@ export const aiCompletion = const marks = (view: EditorView) => Decoration.set( [...view.state.doc.toString().matchAll(/\{\{fact:[a-zA-Z]+\}\}/g)].map((m) => - Decoration.mark({ class: "ai-fact-reference" }).range(m.index, m.index + m[0].length), + Decoration.mark({ class: "policy-fact-reference" }).range(m.index, m.index + m[0].length), ), ) export const createAiPromptEditor = ( @@ -91,10 +92,11 @@ export const createAiPromptEditor = ( EditorView.lineWrapping, EditorView.contentAttributes.of({ "aria-label": "AI rule instructions" }), EditorView.theme({ - "&": { minHeight: "20rem", fontSize: "13px" }, - ".cm-content": { padding: "16px", lineHeight: "1.8" }, + "&": { minHeight: "20rem", fontSize: "var(--text-mono-md)" }, + ".cm-content": { padding: "12px", lineHeight: "1.6" }, ".cm-scroller": { overflow: "auto" }, }), + hostTheme, autocompletion({ override: [ (context) => diff --git a/apps/web/src/components/policy-source/editor.ts b/apps/web/src/components/policy-source/editor.ts index 7f13a3be..b76f6493 100644 --- a/apps/web/src/components/policy-source/editor.ts +++ b/apps/web/src/components/policy-source/editor.ts @@ -17,7 +17,7 @@ import { } from "@codemirror/language" import { lintGutter, linter, lintKeymap } from "@codemirror/lint" import { highlightSelectionMatches, searchKeymap } from "@codemirror/search" -import { Compartment, type Extension } from "@codemirror/state" +import { Compartment, Prec, type Extension } from "@codemirror/state" import { drawSelection, dropCursor, @@ -47,14 +47,43 @@ const yamlLinter = linter((view) => }), ) +/** Chrome overrides on top of the GitHub syntax theme: the editor is a + * `card` region, gutters are `surface-muted` with subtle mono line numbers, + * selection is the blue wash and the cursor is `primary`. Every colour is a + * CSS custom property so the theme switch and the tokens stay in charge. */ +export const hostTheme = Prec.high( + EditorView.theme({ + "&": { backgroundColor: "var(--card)", color: "var(--foreground)" }, + ".cm-scroller": { fontFamily: "var(--font-mono)", lineHeight: "1.5" }, + ".cm-gutters": { + backgroundColor: "var(--oc-surface-muted)", + color: "var(--oc-ink-subtle)", + borderRight: "1px solid var(--border)", + fontFamily: "var(--font-mono)", + fontSize: "var(--text-mono-sm)", + }, + ".cm-lineNumbers .cm-gutterElement": { color: "var(--oc-ink-subtle)" }, + ".cm-activeLine": { backgroundColor: "var(--oc-surface-muted)" }, + ".cm-activeLineGutter": { + backgroundColor: "var(--oc-surface-muted)", + color: "var(--foreground)", + }, + ".cm-cursor, .cm-dropCursor": { borderLeftColor: "var(--primary)" }, + "&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": + { backgroundColor: "var(--oc-blue-wash)" }, + ".cm-selectionMatch": { backgroundColor: "var(--oc-blue-wash)" }, + ".cm-matchingBracket": { + outline: "1px solid var(--oc-blue-line)", + backgroundColor: "transparent", + }, + "&.cm-focused": { outline: "none" }, + }), +) + const editorTheme = EditorView.theme({ - "&": { minHeight: "18rem", maxHeight: "36rem", fontSize: "13px" }, - ".cm-scroller": { - overflow: "auto", - fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", - }, + "&": { minHeight: "18rem", maxHeight: "36rem", fontSize: "var(--text-mono-md)" }, + ".cm-scroller": { overflow: "auto" }, ".cm-content": { padding: "12px 0" }, - "&.cm-focused": { outline: "none" }, }) export const formatEditor = (id: string): string => { @@ -164,6 +193,7 @@ export const createPolicySourceEditor = (input: { if (update.docChanged) input.onChange(update.state.doc.toString()) }), editorTheme, + hostTheme, ], }) } diff --git a/apps/web/src/components/policy-source/index.ts b/apps/web/src/components/policy-source/index.ts index 9ce9ab0b..df824227 100644 --- a/apps/web/src/components/policy-source/index.ts +++ b/apps/web/src/components/policy-source/index.ts @@ -167,7 +167,7 @@ export const view = Submodel.defineView((model, h): Html => [ h.Id(model.id), h.Class( - "overflow-hidden rounded-md border bg-background transition-shadow focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/20", + "overflow-hidden rounded-sm border border-border bg-card transition-colors duration-120 ease-ui focus-within:border-primary focus-within:outline-2 focus-within:outline-ring focus-within:outline-offset-1", ), h.OnMount( MountPolicySourceEditor({ diff --git a/apps/web/src/components/policy-status.ts b/apps/web/src/components/policy-status.ts index fec1c36d..12f978e5 100644 --- a/apps/web/src/components/policy-status.ts +++ b/apps/web/src/components/policy-status.ts @@ -1,4 +1,5 @@ import type { Html, HtmlBuilder } from "foldkit/html" +import { chip } from "@/components/ui/chip" export type Publication = { readonly published: boolean @@ -14,16 +15,23 @@ export const label = ({ published, revision, changes }: Publication): string => ? "Published" : `Published · v${revision}` +/** Publication badge. "Published · vN" and "Not published" are neutral + * chips; "Draft" takes the `selected` chip because it marks the thing the + * user is editing right now, the one place blue-wash means "yours, in + * progress". Never yellow: publication is not agent authorship. */ export const view = (h: HtmlBuilder, status: Publication): Html => - h.span( - [ - h.Class( - `policy-status-badge ${!status.published ? "is-unpublished" : status.changes ? "has-changes" : "is-published"}`, - ), - h.DataAttribute( - "publication-status", - !status.published ? "unpublished" : status.changes ? "changes" : "published", + chip(h, { + variant: status.published && status.changes ? "selected" : "neutral", + className: "policy-status-badge", + children: [ + h.span( + [ + h.DataAttribute( + "publication-status", + !status.published ? "unpublished" : status.changes ? "changes" : "published", + ), + ], + [label(status)], ), ], - [label(status)], - ) + }) diff --git a/apps/web/src/components/repository-connections.ts b/apps/web/src/components/repository-connections.ts index e5007703..b8cf551e 100644 --- a/apps/web/src/components/repository-connections.ts +++ b/apps/web/src/components/repository-connections.ts @@ -6,6 +6,7 @@ import * as Stream from "effect/Stream" import * as HttpClient from "effect/unstable/http/HttpClient" import * as HttpIncomingMessage from "effect/unstable/http/HttpIncomingMessage" import * as Command from "foldkit/command" +import type { Attribute, ChildAttribute, Html, HtmlBuilder } from "foldkit/html" import * as Mount from "foldkit/mount" import * as Dialog from "@foldkit/ui/dialog" import type * as Update from "foldkit/update" @@ -13,9 +14,12 @@ import * as Submodel from "foldkit/submodel" import { defineMessageUnion } from "foldkit/message" import { evo } from "foldkit/struct" import * as Button from "@/components/ui/button" +import * as DialogChrome from "@/components/ui/dialog" import { input } from "@/components/ui/input" +import { panel } from "@/components/ui/panel" import * as Icon from "@/lib/icons" -import { FolderGit2 } from "lucide" +import { cn } from "@/lib/utils" +import { CircleAlert } from "lucide" import { reasonOf, request } from "@/lib/api" import * as Routes from "@/routes" @@ -307,6 +311,28 @@ export const update = (model: Model, message: Message) => CancelledDisconnect: () => isBusy(model) ? { model } : mapDialog(model, Dialog.close(model.dialog)), }) +/** An error panel: what happened, in destructive on the title only. */ +const alert = (h: HtmlBuilder, text: string): Html => + panel(h, { + attributes: [h.Role("alert")], + className: "border-destructive text-body-sm", + children: [h.div([h.Class("font-medium text-destructive")], [text])], + }) + +/** A warning is neutral text with an icon; there is no warning colour. */ +const note = ( + h: HtmlBuilder, + text: string, + attributes: ReadonlyArray | ChildAttribute> = [], +): Html => + h.p( + [...attributes, h.Class("flex items-start gap-1 text-body-sm text-ink-muted")], + [Icon.view(h, CircleAlert, "mt-0.5 size-3.5 shrink-0"), h.span([], [text])], + ) + +const repositoryName = (h: HtmlBuilder, owner: string, repo: string, className?: string) => + h.span([h.Class(cn("font-mono text-mono-md", className))], [`${owner}/${repo}`]) + export const view = Submodel.defineView< Model, Message, @@ -318,7 +344,7 @@ export const view = Submodel.defineView< const button = ( label: string, onClick: Message, - variant: "outline" | "default" | "destructive" = "outline", + variant: "secondary" | "default" | "destructive" = "secondary", ) => Button.view(h, { label: @@ -338,221 +364,210 @@ export const view = Submodel.defineView< size: "sm", isDisabled: isBusy(model), }) - return h.section( - [ - h.Class( - settings ? "rounded-lg border p-5 space-y-4" : "mx-auto w-full max-w-2xl p-6 space-y-5", - ), - h.OnMount(Poll({ state: inputs.state, refresh: !settings })), - ], - [ - h.div( - [h.Class("flex items-center gap-3")], - [ - Icon.view(h, FolderGit2, "size-6 text-muted-foreground"), - h.h1( - [h.Class("text-lg font-semibold")], - [settings ? "Connection" : "Connect a repository"], - ), - ], - ), - Option.isSome(model.error) - ? h.p([h.Role("alert"), h.Class("text-sm text-destructive")], [model.error.value]) - : h.empty, - Option.isSome(model.loadError) - ? h.p([h.Role("alert"), h.Class("text-sm text-destructive")], [model.loadError.value]) - : h.empty, - model.notice - ? h.p([h.Role("status"), h.Class("text-sm text-muted-foreground")], [model.notice]) - : h.empty, - Option.isNone(model.inventory) && Option.isNone(model.loadError) - ? h.p([h.Role("status")], ["Loading repositories…"]) - : h.empty, - settings && current - ? h.div( - [h.Class("space-y-4")], - [ - h.p( - [h.Class("text-sm")], - [ - `${current.owner}/${current.repo} · ${!current.connected ? "Disconnected" : current.access !== "accessible" || current.installationStatus !== "active" ? "Access lost" : !current.enabled ? "Paused" : current.syncState === "failed" ? "Automation blocked by synchronization failure" : current.syncState === "syncing" ? "Synchronizing before automation becomes ready…" : "Automation ready"}`, - ], - ), - h.div( - [h.Class("flex flex-wrap gap-2")], - [ - current.accessError - ? h.p( - [h.Role("status"), h.Class("text-sm text-muted-foreground")], - [current.accessError], - ) - : h.empty, - current.connected && current.enabled && current.syncState === "failed" - ? button( - "Retry sync", - Message.ClickedChange({ id: current.repositoryId, action: "resume" }), - ) - : h.empty, - current.connected && current.enabled && current.syncState === "failed" - ? h.p( - [h.Role("status"), h.Class("text-sm text-muted-foreground")], - [ - `${current.syncError ?? "Synchronization could not complete."} Automatic retries continue. Retry sync to refresh facts now. Recovery waits for new webhook events before labeling.`, - ], - ) - : h.empty, - h.p( - [h.Class("text-sm text-muted-foreground")], - [ - "Pausing stops automation and synchronization. Configuration, stored facts and GitHub labels are kept.", - ], - ), - current.connected - ? button( - current.enabled ? "Pause repository" : "Resume repository", + const visible = rows.filter((row) => + `${row.owner}/${row.repo}`.toLowerCase().includes(model.search.toLowerCase()), + ) + const children: ReadonlyArray = [ + settings ? h.h2([], ["Connection"]) : h.h1([], ["Connect a repository"]), + Option.isSome(model.error) ? alert(h, model.error.value) : h.empty, + Option.isSome(model.loadError) ? alert(h, model.loadError.value) : h.empty, + model.notice + ? h.p([h.Role("status"), h.Class("text-body-sm text-ink-muted")], [model.notice]) + : h.empty, + Option.isNone(model.inventory) && Option.isNone(model.loadError) + ? h.p([h.Role("status"), h.Class("text-body-sm text-ink-muted")], ["Loading repositories…"]) + : h.empty, + settings && current + ? h.div( + [h.Class("flex flex-col gap-3")], + [ + h.p( + [h.Class("flex flex-wrap items-center gap-2 text-body-md")], + [ + repositoryName(h, current.owner, current.repo), + h.span([h.Class("text-ink-subtle")], ["·"]), + h.span( + [ + h.Class( + current.connected && current.enabled && current.syncState === "failed" + ? "text-destructive" + : "", + ), + ], + [ + !current.connected + ? "Disconnected" + : current.access !== "accessible" || current.installationStatus !== "active" + ? "Access lost" + : !current.enabled + ? "Paused" + : current.syncState === "failed" + ? "Automation blocked by synchronization failure" + : current.syncState === "syncing" + ? "Synchronizing before automation becomes ready…" + : "Automation ready", + ], + ), + ], + ), + current.accessError ? note(h, current.accessError, [h.Role("status")]) : h.empty, + current.connected && current.enabled && current.syncState === "failed" + ? h.p( + [h.Role("status"), h.Class("text-body-sm")], + [ + h.span( + [h.Class("text-destructive")], + [current.syncError ?? "Synchronization could not complete."], + ), + h.span( + [h.Class("text-ink-muted")], + [ + " Automatic retries continue. Retry sync to refresh facts now. Recovery waits for new webhook events before labeling.", + ], + ), + ], + ) + : h.empty, + h.p( + [h.Class("text-body-sm text-ink-muted")], + [ + "Pausing stops automation and synchronization. Configuration, stored facts and GitHub labels are kept.", + ], + ), + h.div( + [h.Class("flex flex-wrap items-center gap-2")], + [ + current.connected && current.enabled && current.syncState === "failed" + ? button( + "Retry sync", + Message.ClickedChange({ id: current.repositoryId, action: "resume" }), + ) + : h.empty, + current.connected + ? button( + current.enabled ? "Pause repository" : "Resume repository", + Message.ClickedChange({ + id: current.repositoryId, + action: current.enabled ? "pause" : "resume", + }), + ) + : button( + "Reconnect", + Message.ClickedChange({ id: current.repositoryId, action: "connect" }), + ), + button( + "Manage GitHub access", + Message.ClickedGithub({ installationId: current.installationId }), + ), + current.connected + ? button("Disconnect repository", Message.ClickedDisconnect(), "destructive") + : h.empty, + ], + ), + current.pendingCleanups > 0 + ? h.p( + [h.Role("status"), h.Class("text-body-sm text-ink-muted")], + [cleanupNotice(current.pendingCleanups)], + ) + : h.empty, + !current.enabled && current.reconnect + ? h.p( + [h.Class("text-body-sm text-ink-muted")], + [ + "Pausing retains your configuration and stored data. Resume to synchronize before automation runs.", + ], + ) + : h.empty, + h.submodel({ + slotId: "disconnect-dialog", + model: model.dialog, + view: Dialog.view, + toParentMessage: (message) => Message.GotDialogMessage({ message }), + viewInputs: { + toView: (render) => + DialogChrome.view(h, { + dialog: render.dialog, + backdrop: render.backdrop, + panel: render.panel, + title: render.title, + description: render.description, + isVisible: render.isVisible, + titleText: `Disconnect ${current.owner}/${current.repo}?`, + descriptionText: h.span( + [h.Class("flex flex-col gap-2")], + [ + h.span( + [], + [ + "Disconnect permanently deletes all policies, drafts, published history, labeling rules and groups, stored facts, event history and cached evaluations. GitHub labels stay unchanged. Reconnecting starts empty and requires synchronization. Pause and access loss retain your configuration.", + ], + ), + h.span( + [h.Role("alert"), h.Class("font-medium text-foreground")], + [unpublishedWorkNotice(current.sessionCount)], + ), + Option.isSome(model.error) + ? h.span( + [h.Role("alert"), h.Class("font-medium text-destructive")], + [model.error.value], + ) + : h.empty, + ], + ), + actions: [ + Button.view(h, { + label: "Cancel", + variant: "secondary", + size: "sm", + attributes: [h.Id("cancel-disconnect")], + onClick: Message.CancelledDisconnect(), + isDisabled: isBusy(model), + }), + button( + "Disconnect repository", Message.ClickedChange({ id: current.repositoryId, - action: current.enabled ? "pause" : "resume", + action: "disconnect", }), - ) - : button( - "Reconnect", - Message.ClickedChange({ id: current.repositoryId, action: "connect" }), + "destructive", ), - button( - "Manage GitHub access", - Message.ClickedGithub({ installationId: current.installationId }), - ), - current.connected - ? button("Disconnect repository", Message.ClickedDisconnect(), "destructive") - : h.empty, - ], - ), - current.pendingCleanups > 0 - ? h.p( - [h.Role("status"), h.Class("text-sm text-muted-foreground")], - [cleanupNotice(current.pendingCleanups)], - ) - : h.empty, - !current.enabled && current.reconnect - ? h.p( - [h.Class("text-sm text-muted-foreground")], - [ - "Pausing retains your configuration and stored data. Resume to synchronize before automation runs.", ], - ) - : h.empty, - h.submodel({ - slotId: "disconnect-dialog", - model: model.dialog, - view: Dialog.view, - toParentMessage: (message) => Message.GotDialogMessage({ message }), - viewInputs: { - toView: (render) => - h.dialog( - [ - ...render.dialog, - h.Class( - "fixed inset-0 m-0 h-dvh w-screen max-h-none max-w-none bg-transparent p-0 text-foreground", - ), - ], - render.isVisible - ? [ - h.div([...render.backdrop, h.Class("fixed inset-0 bg-black/40")], []), - h.div( - [ - ...render.panel, - h.Class( - "relative mx-auto mt-[20vh] w-[calc(100%-2rem)] max-w-md rounded-xl border bg-background p-5 shadow-xl space-y-4", - ), - ], - [ - h.h2( - [...render.title, h.Class("font-semibold")], - [`Disconnect ${current.owner}/${current.repo}?`], - ), - h.p( - [...render.description, h.Class("text-sm text-muted-foreground")], - [ - `Disconnect permanently deletes all policies, drafts, published history, labeling rules and groups, stored facts, event history and cached evaluations. GitHub labels stay unchanged. Reconnecting starts empty and requires synchronization. Pause and access loss retain your configuration.`, - ], - ), - h.p( - [h.Role("alert"), h.Class("text-sm font-medium")], - [unpublishedWorkNotice(current.sessionCount)], - ), - Option.isSome(model.error) - ? h.p( - [h.Role("alert"), h.Class("text-sm text-destructive")], - [model.error.value], - ) - : h.empty, - h.div( - [h.Class("flex justify-end gap-2")], - [ - Button.view(h, { - label: "Cancel", - variant: "outline", - size: "sm", - attributes: [h.Id("cancel-disconnect")], - onClick: Message.CancelledDisconnect(), - isDisabled: isBusy(model), - }), - button( - "Disconnect repository", - Message.ClickedChange({ - id: current.repositoryId, - action: "disconnect", - }), - "destructive", - ), - ], - ), - ], - ), - ] - : [], - ), - }, + }), + }, + }), + ], + ) + : !settings + ? h.div( + [h.Class("flex flex-col gap-3")], + [ + h.p( + [h.Class("text-body-sm text-ink-muted")], + ["Choose a repository already accessible to Janitor, or grant access on GitHub."], + ), + input(h, { + id: "connection-search", + label: "Find a repository", + labelClass: "sr-only", + value: model.search, + onInput: (value) => Message.Searched({ value }), + placeholder: "Find a repository…", }), - ], - ) - : !settings - ? h.div( - [h.Class("space-y-4")], - [ - h.p( - [h.Class("text-sm text-muted-foreground")], - ["Choose a repository already accessible to Janitor, or grant access on GitHub."], - ), - input(h, { - id: "connection-search", - label: "Find a repository", - labelClass: "sr-only", - value: model.search, - onInput: (value) => Message.Searched({ value }), - placeholder: "Find a repository…", - }), - h.div( - [h.Class("divide-y rounded-lg border")], - rows - .filter((row) => - `${row.owner}/${row.repo}`.toLowerCase().includes(model.search.toLowerCase()), - ) - .flatMap((row, index, visible) => [ + visible.length === 0 + ? h.empty + : panel(h, { + flush: true, + children: visible.flatMap((row, index) => [ ...(index === 0 || visible[index - 1]?.owner !== row.owner ? [ h.div( [ h.Class( - "bg-muted/40 px-3 py-2 flex items-center justify-between gap-3", + "flex min-h-8 items-center justify-between gap-3 border-b border-border bg-surface-muted px-3 py-1.5", ), ], [ - h.h2( - [h.Class("text-xs font-medium text-muted-foreground")], - [row.owner], - ), + h.h2([h.Class("font-mono text-mono-sm font-medium")], [row.owner]), button( "Manage access", Message.ClickedGithub({ installationId: row.installationId }), @@ -562,20 +577,19 @@ export const view = Submodel.defineView< ] : []), h.div( - [h.Class("flex items-center justify-between gap-3 p-3")], + [ + h.Class( + "flex items-center justify-between gap-3 border-b border-border-subtle px-3 py-1.5 last:border-b-0", + ), + ], [ h.div( - [h.Class("min-w-0")], + [h.Class("flex min-w-0 flex-col gap-0.5")], [ + repositoryName(h, row.owner, row.repo, "truncate"), + row.accessError ? note(h, row.accessError) : h.empty, h.p( - [h.Class("text-sm font-medium truncate")], - [`${row.owner}/${row.repo}`], - ), - row.accessError - ? h.p([h.Class("text-xs text-muted-foreground")], [row.accessError]) - : h.empty, - h.p( - [h.Class("text-xs text-muted-foreground")], + [h.Class("text-body-sm text-ink-subtle")], [ `${row.isPrivate === null ? "Visibility unknown" : row.isPrivate ? "Private" : "Public"} · ${row.connected ? "Connected" : row.access !== "accessible" || row.installationStatus !== "active" ? "Access unavailable" : row.reconnect ? "Previously disconnected · reconnect fresh" : "Available"}`, ], @@ -586,7 +600,7 @@ export const view = Submodel.defineView< ? h.a( [ h.Href(Routes.policies({ repositoryId: row.repositoryId })), - h.Class("text-sm underline"), + h.Class("shrink-0 text-body-sm"), ], ["Open"], ) @@ -606,47 +620,55 @@ export const view = Submodel.defineView< ], ), ]), - ), - rows.length > 0 && - !rows.some((row) => - `${row.owner}/${row.repo}`.toLowerCase().includes(model.search.toLowerCase()), - ) - ? h.p( - [h.Class("text-sm text-muted-foreground")], - ["No repositories match your search."], - ) - : h.empty, - rows.length === 0 && Option.isSome(model.inventory) - ? h.p( - [h.Class("text-sm text-muted-foreground")], - [ - "No repositories are available yet. Grant access on GitHub, then refresh this list. Organization approval may be required.", - ], - ) - : h.empty, - h.div( - [h.Class("flex flex-wrap items-center gap-3")], - [ - button( - "Grant access on GitHub", - Message.ClickedGithub({ installationId: null }), - "default", - ), - Button.view(h, { - label: "Cancel", - variant: "link", - className: "px-0 text-sm underline", - onClick: Message.ClickedCancel(), - }), - ], - ), - ], - ) - : h.empty, - button( - isBusy(model) ? "Updating repositories…" : "Refresh repositories", - Message.ClickedRefresh(), - ), - ], - ) + }), + rows.length > 0 && visible.length === 0 + ? h.p( + [h.Class("text-body-sm text-ink-muted")], + ["No repositories match your search."], + ) + : h.empty, + rows.length === 0 && Option.isSome(model.inventory) + ? h.p( + [h.Class("text-body-sm text-ink-muted")], + [ + "No repositories are available yet. Grant access on GitHub, then refresh this list. Organization approval may be required.", + ], + ) + : h.empty, + h.div( + [h.Class("flex flex-wrap items-center gap-3")], + [ + button( + "Grant access on GitHub", + Message.ClickedGithub({ installationId: null }), + "default", + ), + Button.view(h, { + label: "Cancel", + variant: "link", + size: "sm", + onClick: Message.ClickedCancel(), + }), + ], + ), + ], + ) + : h.empty, + h.div( + [], + [ + button( + isBusy(model) ? "Updating repositories…" : "Refresh repositories", + Message.ClickedRefresh(), + ), + ], + ), + ] + const attributes = [h.OnMount(Poll({ state: inputs.state, refresh: !settings }))] + return settings + ? panel(h, { className: "flex flex-col gap-3", attributes, children }) + : h.section( + [h.Class("flex w-full max-w-2xl flex-col gap-4 p-4 lg:p-5"), ...attributes], + children, + ) }) diff --git a/apps/web/src/components/repository-switcher.ts b/apps/web/src/components/repository-switcher.ts index 140003b8..2cad9ef3 100644 --- a/apps/web/src/components/repository-switcher.ts +++ b/apps/web/src/components/repository-switcher.ts @@ -14,6 +14,7 @@ import { Check, ChevronsUpDown, Plus } from "lucide" import type { RepositoryOverview } from "@/components/labeling-wire" import * as CommandPalette from "@/components/ui/command" import * as Button from "@/components/ui/button" +import * as Overlay from "@/components/ui/overlay" import * as Icon from "@/lib/icons" import { cn } from "@/lib/utils" @@ -149,21 +150,12 @@ const tile = ( repository: RepositoryOverview, className?: string, ): Html => { - const tones = [ - "bg-[#e3d9ff] text-[#54368f] dark:bg-[#55427a] dark:text-[#f0e7ff]", - "bg-[#d1e7ff] text-[#285579] dark:bg-[#28516d] dark:text-[#e0f1ff]", - "bg-[#d5ece4] text-[#285d49] dark:bg-[#29594b] dark:text-[#d9fff0]", - ] - const tone = - repository.repositoryId.split("").reduce((sum, char) => sum + char.charCodeAt(0), 1) % - tones.length return h.span( [ h.AriaHidden(true), h.Class( cn( - "flex size-8 shrink-0 items-center justify-center rounded-sm font-mono text-[11px] font-semibold tracking-tight", - tones[tone], + "flex size-7 shrink-0 items-center justify-center rounded-xs border border-border bg-surface-muted font-mono text-mono-sm font-medium text-foreground", className, ), ), @@ -181,13 +173,13 @@ const status = (h: HtmlBuilder, repository: RepositoryOverview): Html = [ h.Class( cn( - "inline-flex shrink-0 items-center gap-1 whitespace-nowrap text-[11px] font-normal", - repository.enabled ? "text-[#277346] dark:text-[#8acaa1]" : "text-muted-foreground", + "inline-flex shrink-0 items-center gap-1 whitespace-nowrap text-caption font-normal", + repository.enabled ? "text-success" : "text-ink-muted", ), ), ], [ - h.span([h.AriaHidden(true), h.Class("size-[5px] rounded-full bg-current")], []), + h.span([h.AriaHidden(true), h.Class("size-1.5 rounded-full bg-current")], []), statusText(repository), ], ) @@ -204,19 +196,22 @@ const repositoryRow = ( isSelected, onClick: Message.ClickedRepository({ repositoryId: repository.repositoryId }), className: - "my-0.5 cursor-pointer gap-2 rounded-sm px-2 py-1.5 hover:bg-popover focus-visible:bg-popover focus-visible:ring-inset", + "cursor-pointer gap-2 rounded-xs px-2 py-1 hover:bg-surface-muted focus-visible:bg-surface-muted", attributes: [h.DataAttribute("repository", repository.repositoryId)], children: [ - tile(h, repository, "size-8 rounded-sm text-[11px]"), + tile(h, repository), h.span( [h.Class("min-w-0 flex-1")], [ h.span( - [h.Class("block truncate text-[13px] font-semibold"), h.Title(repository.repo)], + [ + h.Class("block truncate font-mono text-mono-md font-medium"), + h.Title(repository.repo), + ], [repository.repo], ), h.span( - [h.Class("text-muted-foreground mt-0.5 flex items-center gap-1.5 text-xs")], + [h.Class("flex items-center gap-1.5 text-caption text-ink-subtle")], [ countText(repository.ruleCount, "rule"), h.span([h.AriaHidden(true)], ["·"]), @@ -240,8 +235,8 @@ const ownerGroup = ( ): Html => CommandPalette.group(h, { heading: group.owner, - headingClassName: "flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium", - headingSuffix: h.span([h.Class("opacity-70")], [String(group.repositories.length)]), + headingClassName: "flex items-center gap-1.5 px-2 py-1.5 font-mono text-caption font-medium", + headingSuffix: h.span([h.Class("text-ink-subtle")], [String(group.repositories.length)]), className: "p-0", children: group.repositories.map((repository) => repositoryRow(h, repository, Option.contains(maybeSelectedId, repository.repositoryId)), @@ -270,32 +265,38 @@ const paletteBody = ( const palette = (h: HtmlBuilder, model: Model, inputs: ViewInputs): Html => CommandPalette.container(h, { - className: "h-auto w-88 max-w-[calc(100vw-1rem)] rounded-xl! border bg-card p-0 shadow-lg", + className: cn(Overlay.popoverContentClass, "h-auto w-88 p-0"), children: [ CommandPalette.input(h, { id: "repository-search", ariaLabel: "Find a repository", value: model.search, placeholder: "Find a repository…", - wrapperClassName: "border-b px-1.5 py-1", + wrapperClassName: "border-b border-border px-1.5 py-1", groupClassName: - "h-8! rounded-none! border-0 bg-transparent dark:bg-transparent has-[[data-slot=input-group-control]:focus-visible]:ring-0", - className: "text-xs", + "h-7 rounded-none border-0 bg-transparent has-[[data-slot=input-group-control]:focus-visible]:border-transparent has-[[data-slot=input-group-control]:focus-visible]:bg-transparent", + className: "text-body-sm", onInput: (search) => Message.ChangedSearch({ search }), }), CommandPalette.list(h, { - className: "max-h-[min(430px,60dvh)] p-1", + className: "max-h-96 p-1", children: paletteBody(h, inputs, model.search), }), h.a( [ h.Href(Routes.connect()), - h.Class("flex items-center gap-2.5 border-t px-3 py-2 text-xs hover:bg-accent"), + h.Class( + "flex items-center gap-2 border-t border-border px-3 py-1.5 text-body-sm text-foreground hover:bg-surface-muted hover:no-underline", + ), ], - [Icon.view(h, Plus, "size-3.5 shrink-0"), "Connect repository…"], + [Icon.view(h, Plus), "Connect repository…"], ), h.div( - [h.Class("text-muted-foreground flex justify-between border-t px-3 py-1.5 text-[11px]")], + [ + h.Class( + "flex justify-between border-t border-border px-3 py-1.5 font-mono text-mono-xs text-ink-subtle", + ), + ], [ countText(inputs.repositories.length, "repository", "repositories"), h.span( @@ -320,7 +321,7 @@ const triggerLabel = (h: HtmlBuilder, inputs: ViewInputs): Html => { [ h.AriaHidden(true), h.Class( - "bg-muted flex size-8 shrink-0 items-center justify-center rounded-sm font-mono text-[11px]", + "flex size-7 shrink-0 items-center justify-center rounded-xs border border-border bg-surface-muted font-mono text-mono-sm", ), ], ["--"], @@ -328,8 +329,8 @@ const triggerLabel = (h: HtmlBuilder, inputs: ViewInputs): Html => { h.span( [h.Class("grid min-w-0 flex-1 text-left")], [ - h.span([h.Class("text-muted-foreground text-[11px]")], ["Select one"]), - h.span([h.Class("truncate text-[13px] font-semibold")], ["No repository"]), + h.span([h.Class("text-caption text-ink-subtle")], ["Select one"]), + h.span([h.Class("truncate text-body-sm font-semibold")], ["No repository"]), ], ), ], @@ -340,13 +341,13 @@ const triggerLabel = (h: HtmlBuilder, inputs: ViewInputs): Html => { [ h.span( [ - h.Class("text-muted-foreground truncate text-[11px] font-normal"), + h.Class("truncate font-mono text-mono-xs text-ink-subtle"), h.Title(repository.owner), ], [repository.owner], ), h.span( - [h.Class("truncate text-[13px] font-semibold"), h.Title(repository.repo)], + [h.Class("truncate font-mono text-mono-md font-medium"), h.Title(repository.repo)], [repository.repo], ), ], @@ -354,7 +355,7 @@ const triggerLabel = (h: HtmlBuilder, inputs: ViewInputs): Html => { status(h, repository), ], }), - Icon.view(h, ChevronsUpDown, "text-muted-foreground size-3 shrink-0"), + Icon.view(h, ChevronsUpDown, "size-3 shrink-0 text-ink-subtle"), ], ) } @@ -374,15 +375,15 @@ export const view = Submodel.defineView((model, inpu [], [ Button.view(h, { - variant: "outline", + variant: "secondary", attributes: [...render.button], className: - "h-13 w-full overflow-hidden rounded-sm border-2 border-outline bg-card p-2 shadow-none hover:bg-popover data-open:bg-popover active:translate-y-0 group-data-[collapsible=icon]:size-8 group-data-[collapsible=icon]:p-0", + "h-11 w-full overflow-hidden p-1.5 data-open:bg-surface-muted group-data-[collapsible=icon]:size-7 group-data-[collapsible=icon]:p-0", label: triggerLabel(h, inputs), }), ...(render.isVisible ? [ - h.div([...render.backdrop, h.Class("fixed inset-0 z-40")], []), + h.div([...render.backdrop, h.Class(Overlay.menuBackdropClass)], []), h.div([...render.panel, h.Class("z-50")], [palette(h, model, inputs)]), ] : []), diff --git a/apps/web/src/components/rule-editor.ts b/apps/web/src/components/rule-editor.ts index 651e923f..a1e66449 100644 --- a/apps/web/src/components/rule-editor.ts +++ b/apps/web/src/components/rule-editor.ts @@ -7,10 +7,8 @@ import * as Mount from "foldkit/mount" import * as Stream from "effect/Stream" import * as Queue from "effect/Queue" import { AiRuleDefinition, FactDescription } from "@/components/labeling-wire" -import * as Switch from "@foldkit/ui/switch" import * as Dialog from "@foldkit/ui/dialog" import * as Disclosure from "@foldkit/ui/disclosure" -import * as Select from "@foldkit/ui/select" import * as Effect from "effect/Effect" import * as Option from "effect/Option" import * as Schema from "effect/Schema" @@ -23,9 +21,16 @@ import { defineMessageUnion } from "foldkit/message" import { evo } from "foldkit/struct" import * as Submodel from "foldkit/submodel" import type * as Update from "foldkit/update" -import { ArrowLeft, ChevronRight, FileCode2, Tag, Play, Trash2 } from "lucide" +import { ArrowLeft, ChevronRight, Play, Trash2 } from "lucide" import * as Button from "@/components/ui/button" -import { input, inputClass } from "@/components/ui/input" +import { input } from "@/components/ui/input" +import * as Blueprint from "@/components/ui/blueprint" +import { chip } from "@/components/ui/chip" +import * as DialogChrome from "@/components/ui/dialog" +import * as Feed from "@/components/ui/feed" +import { panel, panelHeader } from "@/components/ui/panel" +import * as SelectField from "@/components/ui/select" +import * as SwitchControl from "@/components/ui/switch" import { ConfigurationView, ResultAction, @@ -966,15 +971,15 @@ const groupOrderView = (h: HtmlBuilder, model: Model): Html => { [h.Class("flex flex-col gap-2")], [ h.p( - [h.Class("text-xs text-muted-foreground")], + [h.Class("text-body-sm text-ink-muted")], ["Reorder the saved group. Save any edits first."], ), ...members.map((rule, index) => h.div( - [h.Class("flex items-center gap-2 text-xs")], + [h.Class("flex items-center gap-2 text-body-sm")], [ h.span( - [], + [h.Class("font-mono text-mono-sm")], [ labelName(model.labels, rule.labelId) + " · " + @@ -1007,9 +1012,6 @@ const groupOrderView = (h: HtmlBuilder, model: Model): Html => { ) } -const labelClass = "text-muted-foreground text-xs font-medium" -const selectClass = cn(inputClass, "h-8 appearance-none pr-6 text-sm") - const selectField = ( h: HtmlBuilder, config: { @@ -1018,29 +1020,17 @@ const selectField = ( readonly value: string readonly options: ReadonlyArray readonly onChange: (value: string) => Message + readonly isLabelHidden?: boolean }, ): Html => - Select.view( - { - id: config.id, - value: config.value, - onChange: config.onChange, - toView: (attributes) => - h.div( - [h.Class("flex flex-col gap-1")], - [ - h.label([...attributes.label, h.Class(labelClass)], [config.label]), - h.select( - [...attributes.select, h.Class(selectClass)], - config.options.map(([value, text]) => - h.option([h.Value(value), h.Selected(value === config.value)], [text]), - ), - ), - ], - ), - }, - h, - ) + SelectField.view(h, { + id: config.id, + label: config.label, + value: config.value, + options: config.options, + onChange: config.onChange, + ...(config.isLabelHidden === undefined ? {} : { isLabelHidden: config.isLabelHidden }), + }) const submissionView = (h: HtmlBuilder, submission: Submission): Html => { switch (submission._tag) { @@ -1049,36 +1039,22 @@ const submissionView = (h: HtmlBuilder, submission: Submission): Html = return h.empty case "Conflicted": return h.div( - [h.Class("text-xs text-amber-600 dark:text-amber-400"), h.Role("alert")], + [h.Class("text-body-sm text-ink-muted"), h.Role("alert")], ["Someone changed this rule meanwhile. Saving again writes over their change."], ) case "Rejected": return h.ul( - [h.Class("text-destructive flex flex-col gap-0.5 text-xs"), h.Role("alert")], + [h.Class("flex flex-col gap-0.5 text-body-sm text-destructive"), h.Role("alert")], submission.issues.map((issue) => h.li([], [issue.message])), ) case "SubmitError": - return h.div([h.Class("text-destructive text-xs"), h.Role("alert")], [submission.message]) + return h.div( + [h.Class("text-body-sm text-destructive"), h.Role("alert")], + [submission.message], + ) } } -const step = ( - h: HtmlBuilder, - number: string, - title: string, - children: ReadonlyArray, -): Html => - h.section( - [h.Class("rule-flow-step")], - [ - h.span([h.Class("rule-step-number"), h.AriaHidden(true)], [number]), - h.div( - [h.Class("rule-flow-card")], - [h.h2([h.Class("rule-step-title")], [title]), ...children], - ), - ], - ) - /** A single-rule preview uses a real published policy evaluation, never changes labels. */ export const previewAction = ( model: Model, @@ -1108,81 +1084,92 @@ export const previewAction = ( return `${describeResultAction(action)}: ${name} · ${change}` } +const outcomeHeadline = ( + outcome: Outcome, + reason: string | undefined, + reasonCode: string | undefined, +): string => + reason?.startsWith("Skipped by gate:") + ? "Skipped by gate" + : reason?.startsWith("Gate unresolved:") + ? "Gate unresolved · AI skipped" + : outcome === "unknown" && reasonCode + ? ["insufficient-evidence", "low-confidence"].includes(reasonCode) + ? "Insufficient evidence" + : "Could not evaluate" + : describeOutcome(outcome) + +/** The Janitor evaluated the rule against one item: everything here is its output. */ const testResultView = (h: HtmlBuilder, model: Model): Html => { const result = model.testResult if (result._tag === "Idle") return h.empty if (result._tag === "Running") return h.p( - [h.Role("status"), h.Class("text-xs text-muted-foreground")], + [h.Role("status"), h.Class("flex flex-col gap-0.5 text-body-sm text-ink-muted")], [ - h.span([h.Class("font-medium")], ["Testing…"]), h.span( - [h.Class("ml-2")], + [h.Class("font-medium text-foreground")], [ + "Testing… ", { submitting: "Submitting", queued: "Queued", running: "Evaluating" }[result.status] + (result.elapsedSeconds >= 5 ? ` · ${result.elapsedSeconds}s` : ""), ], ), - result.progress ? h.span([h.Class("block mt-1")], [result.progress]) : h.empty, + result.progress ? h.span([], [result.progress]) : h.empty, result.pollError - ? h.span([h.Class("block mt-1")], ["Unable to check progress. Retrying the same test…"]) + ? h.span([], ["Unable to check progress. Retrying the same test…"]) : result.status === "queued" && result.elapsedSeconds >= 5 - ? h.span([h.Class("block mt-1")], ["Waiting for an evaluation slot."]) + ? h.span([], ["Waiting for an evaluation slot."]) : h.empty, ], ) if (result._tag === "Failed") - return h.p([h.Role("alert"), h.Class("text-xs text-destructive")], [result.reason]) + return h.p([h.Role("alert"), h.Class("text-body-sm text-destructive")], [result.reason]) if (result.response._tag === "Rejected") - return h.p([h.Role("alert"), h.Class("text-xs text-destructive")], [result.response.message]) + return h.p( + [h.Role("alert"), h.Class("text-body-sm text-destructive")], + [result.response.message], + ) const entity = result.response.entities[0] if (!entity) return h.p( - [h.Class("text-xs text-muted-foreground")], + [h.Class("text-body-sm text-ink-muted")], ["This item is no longer available. Choose another item."], ) const outcome = entity.evaluation?.outcome ?? "unknown" return h.div( - [h.Class("policy-test-result"), h.DataAttribute("outcome", outcome)], + [ + h.Class( + "oc-agent-edge flex flex-col gap-2 border border-border bg-card py-2 pr-3 pl-2.5 text-body-sm", + ), + h.DataAttribute("outcome", outcome), + ], [ h.div( [h.Class("flex items-center justify-between gap-3")], [ - h.strong( - [ - h.Class( - cn( - "text-xs", - outcome === "match" - ? "text-emerald-600 dark:text-emerald-400" - : outcome === "failed" - ? "text-destructive" - : "text-muted-foreground", - ), - ), - ], + h.div( + [h.Class("flex items-center gap-2")], [ - entity.evaluation?.reason.startsWith("Skipped by gate:") - ? "Skipped by gate" - : entity.evaluation?.reason.startsWith("Gate unresolved:") - ? "Gate unresolved · AI skipped" - : outcome === "unknown" && entity.evaluation?.reasonCode - ? ["insufficient-evidence", "low-confidence"].includes( - entity.evaluation.reasonCode, - ) - ? "Insufficient evidence" - : "Could not evaluate" - : describeOutcome(outcome), + Feed.agentBadge(h), + chip(h, { + variant: + outcome === "match" ? "success" : outcome === "failed" ? "danger" : "neutral", + children: [ + outcomeHeadline( + outcome, + entity.evaluation?.reason, + entity.evaluation?.reasonCode, + ), + ], + }), ], ), - h.span([h.Class("text-xs text-muted-foreground")], [`#${entity.number}`]), + h.span([h.Class("font-mono text-mono-xs text-ink-subtle")], [`#${entity.number}`]), ], ), entity.evaluation?.cached && !entity.evaluation.inputReport - ? h.p( - [h.Class("text-xs text-muted-foreground")], - ["Input details unavailable for this earlier result."], - ) + ? h.p([h.Class("text-ink-muted")], ["Input details unavailable for this earlier result."]) : aiInputView( h, entity.evaluation?.inputReport, @@ -1191,38 +1178,40 @@ const testResultView = (h: HtmlBuilder, model: Model): Html => { !!result.testId, ), entity.evaluation?.reason - ? h.p([h.Class("text-xs text-muted-foreground")], [entity.evaluation.reason]) + ? h.p([h.Class("text-ink-muted")], [entity.evaluation.reason]) : h.empty, entity.evaluation?.confidence !== undefined ? h.p( - [h.Class("text-xs")], + [h.Class("font-mono text-mono-sm text-ink-muted")], [ - `Confidence: ${Math.round(entity.evaluation.confidence * 100)}%${entity.evaluation.cached ? " · Cached" : ""}`, + `confidence ${Math.round(entity.evaluation.confidence * 100)}%${entity.evaluation.cached ? " · cached" : ""}`, ], ) : h.empty, - h.ul( - [h.Class("policy-test-trace")], - (entity.evaluation?.trace ?? []).map((node) => - h.li( - [], - [ - h.span([], [node.reason]), - h.span( - [h.AriaLabel(describeOutcome(node.outcome))], - [node.outcome === "match" ? "✓" : node.outcome === "no-match" ? "−" : "?"], + (entity.evaluation?.trace ?? []).length === 0 + ? h.empty + : h.ul( + [h.Class("flex flex-col gap-0.5 font-mono text-mono-sm text-ink-muted")], + (entity.evaluation?.trace ?? []).map((node) => + h.li( + [h.Class("flex justify-between gap-3")], + [ + h.span([h.Class("min-w-0 truncate")], [node.reason]), + h.span( + [h.AriaLabel(describeOutcome(node.outcome)), h.Class("shrink-0")], + [node.outcome === "match" ? "✓" : node.outcome === "no-match" ? "−" : "?"], + ), + ], ), - ], + ), ), - ), - ), h.p( - [h.Class("rounded-md border p-2 text-xs")], + [h.Class("border-t border-border-subtle pt-2 text-foreground")], [previewAction(model, outcome, entity.labels)], ), model.group.trim() ? h.p( - [h.Class("text-xs text-muted-foreground")], + [h.Class("text-ink-muted")], [ "Single-rule preview. Other rules in this exclusive group may change the final result.", ], @@ -1232,6 +1221,296 @@ const testResultView = (h: HtmlBuilder, model: Model): Html => { ) } +const actionOptions = ResultAction.literals.map( + (action) => [action, describeResultAction(action)] as const, +) + +/** The rule as a blueprint: what the agent watches, what must hold, what it + * does. Nodes carry the form controls; the layout is a fixed three-column + * chain, which is the only graph shape a rule can take today. */ +const ruleGraph = (h: HtmlBuilder, model: Model): Html => { + const policy = selectedPolicy(model) + const ai = model.ai + const labelOptions: ReadonlyArray = [ + ["", "Choose a label…"], + ...model.labels + .filter( + (label) => + label.availability !== "unavailable" || + Option.contains(model.maybeLabelId, label.labelId), + ) + .map( + (label) => + [ + label.labelId, + `${label.name}${label.availability === "unavailable" ? " (unavailable)" : ""}`, + ] as const, + ), + ] + const whenNodes = ai + ? [ + Blueprint.node(h, { + kind: "Applies to", + children: [ + selectField(h, { + id: "ai-target", + label: "Applies to", + isLabelHidden: true, + value: ai.target, + options: [ + ["pull_request", "Pull requests"], + ["issue", "Issues"], + ], + onChange: (value) => Message.SelectedTarget({ value }), + }), + ], + }), + Blueprint.node(h, { + kind: "Gate policy", + children: [ + selectField(h, { + id: "ai-gate", + label: "Gate policy", + isLabelHidden: true, + value: ai.gatePolicyId ?? "", + options: [ + ["", "No gate · always evaluate"], + ...model.policies + .filter( + (p) => + p.publishedVersionId !== null && + p.target === ai.target && + p.publishedEvaluator === "Conditions", + ) + .map((p) => [p.policyId, p.name] as const), + ], + onChange: (value) => Message.SelectedGate({ value }), + }), + h.p( + [h.Class("font-sans text-body-sm text-ink-muted")], + ["AI runs only when this policy matches. Otherwise, labels stay unchanged."], + ), + ], + }), + ] + : [ + Blueprint.node(h, { + kind: "Policy matches", + children: [ + selectField(h, { + id: "rule-policy", + label: "When policy matches", + isLabelHidden: true, + value: Option.getOrElse(model.maybePolicyId, () => ""), + options: [ + ["", "Choose a published policy…"], + ...publishedPolicies(model).map( + (policy) => [policy.policyId, policy.name] as const, + ), + ], + onChange: (value) => Message.UpdatedPolicy({ value }), + }), + policy + ? h.p( + [h.Class("text-ink-muted")], + [ + `${policy.target === "issue" ? "issues" : "pull_requests"} · published v${policy.publishedRevision}`, + ], + ) + : h.empty, + policy?.description + ? h.p([h.Class("font-sans text-body-sm text-ink-muted")], [policy.description]) + : h.empty, + ], + }), + ] + const conditionNodes = ai + ? [ + Blueprint.node(h, { + kind: "Classification", + isAgent: true, + className: "w-80", + children: [ + h.div( + [h.Class("flex justify-between font-sans text-caption text-ink-subtle")], + [ + h.label([h.For("ai-prompt")], ["Instructions"]), + h.span([h.Class("font-mono text-mono-xs")], [`${ai.prompt.length} / 4,000`]), + ], + ), + h.div( + [ + h.Id("ai-prompt"), + h.DataAttribute("target", ai.target), + h.DataAttribute("catalog", JSON.stringify(model.catalog)), + h.Class( + "overflow-hidden rounded-xs border border-border transition-colors duration-120 ease-ui focus-within:border-primary focus-within:outline-2 focus-within:outline-ring focus-within:outline-offset-1", + ), + h.OnMount(MountAiPrompt({ source: ai.prompt, catalog: model.catalog })), + ], + [], + ), + h.p( + [h.Class("font-sans text-body-sm text-ink-muted")], + ["Type {{ to reference a fact. Only referenced facts are used as evidence."], + ), + h.div( + [ + h.Class( + "flex items-center justify-between gap-3 border-t border-border-subtle pt-2 font-sans", + ), + ], + [ + h.label( + [h.For("ai-confidence"), h.Class("text-label font-medium")], + ["Minimum confidence"], + ), + h.span( + [h.Class("font-mono text-numeral font-medium tabular-nums")], + [`${Math.round(ai.minimumConfidence * 100)}%`], + ), + ], + ), + h.input([ + h.Id("ai-confidence"), + h.Type("range"), + h.Min("0"), + h.Max("100"), + h.Step("5"), + h.Value(String(ai.minimumConfidence * 100)), + h.Class("ai-confidence w-full"), + h.OnInput((value) => Message.ChangedConfidence({ value })), + ]), + h.div( + [h.Class("flex items-center justify-between gap-2 font-sans")], + [ + h.span( + [h.Class("text-body-sm text-ink-muted")], + ["Below this score, use the non-match action."], + ), + h.div( + [h.Class("flex gap-1")], + [70, 80, 95].map((value) => + Button.view(h, { + variant: "secondary", + size: "xs", + className: + ai.minimumConfidence * 100 === value ? "bg-primary-wash" : undefined, + label: `${value}%`, + onClick: Message.ChangedConfidence({ value: String(value) }), + attributes: [ + h.Attribute("aria-pressed", String(ai.minimumConfidence * 100 === value)), + ], + }), + ), + ), + ], + ), + ], + }), + ] + : [ + Blueprint.node(h, { + kind: "GitHub label", + children: [ + selectField(h, { + id: "rule-label", + label: "GitHub label", + isLabelHidden: true, + value: Option.getOrElse(model.maybeLabelId, () => ""), + options: labelOptions, + onChange: (labelId) => Message.SelectedLabel({ labelId }), + }), + model.labels.length === 0 + ? h.p( + [h.Class("font-sans text-body-sm text-ink-muted")], + ["No labels synchronized yet."], + ) + : h.empty, + ], + }), + ] + const thenNodes = [ + ...(ai + ? [ + Blueprint.node(h, { + kind: "GitHub label", + children: [ + selectField(h, { + id: "ai-label", + label: "GitHub label", + isLabelHidden: true, + value: Option.getOrElse(model.maybeLabelId, () => ""), + options: labelOptions, + onChange: (labelId) => Message.SelectedLabel({ labelId }), + }), + ], + }), + ] + : []), + Blueprint.node(h, { + kind: "On match", + children: [ + selectField(h, { + id: "rule-on-match", + label: "When it matches", + isLabelHidden: true, + value: model.onMatch, + options: actionOptions, + onChange: (value) => Message.UpdatedOnMatch({ value }), + }), + ], + }), + Blueprint.node(h, { + kind: "On no match", + children: [ + selectField(h, { + id: "rule-on-no-match", + label: "When it does not match", + isLabelHidden: true, + value: model.onNoMatch, + options: actionOptions, + onChange: (value) => Message.UpdatedOnNoMatch({ value }), + }), + ], + }), + ] + const nodeCount = whenNodes.length + conditionNodes.length + thenNodes.length + return panel(h, { + flush: true, + children: [ + panelHeader(h, { + title: "Rule graph", + meta: ai ? "ai rule" : "policy rule", + }), + Blueprint.canvas(h, { + children: [ + Blueprint.column(h, { label: "When", children: whenNodes }), + Blueprint.wire(h, { label: ai ? "gate" : "evaluate" }), + Blueprint.column(h, { + label: ai ? "If The Janitor classifies it" : "If every condition matches", + ...(ai ? { className: "w-80" } : {}), + children: conditionNodes, + }), + Blueprint.wire(h, { + paths: [ + { fromY: 24, toY: ai ? 24 : 24, label: "match" }, + { fromY: 24, toY: ai ? 138 : 82, label: "no match" }, + ], + }), + Blueprint.column(h, { label: "Then", children: thenNodes }), + ], + }), + Blueprint.footer(h, { + summary: ai + ? "Applies to a target, runs the classification, then acts on the label" + : "Evaluates the policy, then acts on the label", + counts: `${nodeCount} nodes · 3 wires`, + }), + ], + }) +} + export type ViewInputs = { readonly isDeleting?: boolean } export const view = Submodel.defineView( (model, { isDeleting = false }, h): Html => { @@ -1242,46 +1521,32 @@ export const view = Submodel.defineView( const label = model.labels.find((label) => Option.contains(model.maybeLabelId, label.labelId)) return h.div( [ - h.Class("rule-document"), + h.Class("flex min-w-0 flex-1 flex-col"), h.DataAttribute("editor", "rule"), ...(model.identity._tag === "New" ? [h.OnMount(PrepareCreation({}))] : []), ], [ h.header( - [h.Class("rule-navigation")], + [h.Class("flex h-9 items-center gap-3 border-b border-border bg-card px-4")], [ Button.view(h, { - variant: "outline", + variant: "ghost", size: "sm", onClick: Message.ClickedCancel(), isDisabled: busy, - label: h.span( - [h.Class("flex items-center gap-2")], - [Icon.view(h, ArrowLeft, "size-3.5"), "Back to rules"], - ), + label: h.span([h.Class("contents")], [Icon.view(h, ArrowLeft), "Back to rules"]), }), - Switch.view( - { - id: "rule-enabled", - isChecked: model.enabled, - isDisabled: busy, - onToggle: (isChecked) => Message.ToggledEnabled({ isChecked }), - toView: (attributes) => - h.div( - [h.Class("ml-auto flex items-center gap-3")], - [ - h.label( - [...attributes.label, h.Class("cursor-pointer text-xs font-medium")], - ["Enable"], - ), - h.button( - [...attributes.button, h.Class("rule-enable-switch")], - [h.span([h.AriaHidden(true)], [])], - ), - ], - ), - }, - h, + h.div( + [h.Class("ml-auto")], + [ + SwitchControl.view(h, { + id: "rule-enabled", + label: "Enable", + isChecked: model.enabled, + isDisabled: busy, + onToggle: (isChecked) => Message.ToggledEnabled({ isChecked }), + }), + ], ), ], ), @@ -1289,23 +1554,17 @@ export const view = Submodel.defineView( [h.Class("rule-editor-workspace")], [ h.article( - [h.Class("rule-editor-main")], + [h.Class("flex min-w-0 flex-col gap-4 p-4 lg:p-5")], [ h.header( - [h.Class("rule-document-heading")], + [h.Class("flex flex-col gap-1")], [ - h.h1( - [h.Class("flex items-center gap-2 text-2xl font-semibold tracking-tight")], - [ - Icon.view(h, Tag, "size-5 text-muted-foreground"), - label?.name ?? "New rule", - ], - ), + h.h1([h.Class("font-mono")], [label?.name ?? "New rule"]), h.p( - [h.Class("mt-2 text-xs leading-relaxed text-muted-foreground")], + [h.Class("text-body-sm text-ink-muted")], [ model.ai - ? "Use the referenced facts to decide whether this label applies." + ? "The Janitor uses the referenced facts to decide whether this label applies." : policy && label ? `Manage ${label.name} on ${policy.target === "issue" ? "issues" : "pull requests"} using ${policy.name}.` : "Choose a published policy and the label it manages.", @@ -1315,7 +1574,7 @@ export const view = Submodel.defineView( ), model.identity._tag === "New" ? h.div( - [h.Class("px-6 pt-4 max-w-sm")], + [h.Class("max-w-xs")], [ selectField(h, { id: "rule-type", @@ -1330,214 +1589,144 @@ export const view = Submodel.defineView( ], ) : h.empty, - h.div( - [h.Class(model.ai ? "rule-flow ai-rule-document" : "rule-flow")], + ruleGraph(h, model), + h.p( + [h.Class("text-body-sm text-ink-muted")], [ - ...(model.ai - ? [aiFields(h, model)] - : [ - step(h, "1", "When", [ - selectField(h, { - id: "rule-policy", - label: "When policy matches", - value: Option.getOrElse(model.maybePolicyId, () => ""), - options: [ - ["", "Choose a published policy…"], - ...publishedPolicies(model).map( - (policy) => [policy.policyId, policy.name] as const, - ), - ], - onChange: (value) => Message.UpdatedPolicy({ value }), - }), - policy - ? h.div( - [ - h.Class( - "mt-3 flex items-center gap-2 text-xs text-muted-foreground", - ), - ], - [ - Icon.view(h, FileCode2, "size-3"), - `${policy.target === "issue" ? "Issues" : "Pull requests"} · Published v${policy.publishedRevision}`, - ], - ) - : h.empty, - policy?.description - ? h.p( - [h.Class("mt-3 text-xs leading-relaxed text-muted-foreground")], - [policy.description], - ) - : h.empty, - ]), - step(h, "2", "Then", [ - selectField(h, { - id: "rule-label", - label: "GitHub label", - value: Option.getOrElse(model.maybeLabelId, () => ""), - options: [ - ["", "Choose a label…"], - ...model.labels - .filter( - (label) => - label.availability !== "unavailable" || - Option.contains(model.maybeLabelId, label.labelId), - ) - .map( - (label) => - [ - label.labelId, - `${label.name}${label.availability === "unavailable" ? " (unavailable)" : ""}`, - ] as const, - ), - ], - onChange: (labelId) => Message.SelectedLabel({ labelId }), - }), - h.p( - [h.Class("mt-3 text-xs text-muted-foreground")], - [ - model.labels.length - ? "Choose what happens to this label for each result below." - : "No labels synchronized yet.", - ], - ), - ]), - ]), - step(h, "3", "Result actions", [ - ...(["match", "no-match"] as const).map((outcome) => - selectField(h, { - id: outcome === "match" ? "rule-on-match" : "rule-on-no-match", - label: outcome === "match" ? "When it matches" : "When it does not match", - value: outcome === "match" ? model.onMatch : model.onNoMatch, - options: ResultAction.literals.map( - (action) => [action, describeResultAction(action)] as const, - ), - onChange: (value) => - outcome === "match" - ? Message.UpdatedOnMatch({ value }) - : Message.UpdatedOnNoMatch({ value }), - }), - ), - h.p( - [h.Class("mt-3 text-xs text-muted-foreground")], + "Ensure present restores manually removed labels. Ensure absent removes manually added labels. Take no action makes no label request.", + ], + ), + Disclosure.view( + { + id: "rule-grouping", + isOpen: model.groupOpen, + onToggle: (isOpen) => Message.ToggledGroup({ isOpen }), + toView: ({ button, panel: panelAttributes }) => + h.section( [ - "Ensure present restores manually removed labels. Ensure absent removes manually added labels. Take no action makes no label request.", + h.Class("rounded-sm border border-border bg-card"), + h.DataAttribute("slot", "card"), ], - ), - ]), - Disclosure.view( - { - id: "rule-grouping", - isOpen: model.groupOpen, - onToggle: (isOpen) => Message.ToggledGroup({ isOpen }), - toView: ({ button, panel }) => - h.section( - [h.Class("rule-grouping")], + [ + h.button( [ - h.button( - [...button, h.Class("flex w-full items-center gap-2 py-2 text-xs")], - [ - Icon.view( - h, - ChevronRight, - cn("size-3", model.groupOpen && "rotate-90"), - ), - "Exclusive group", - h.span( - [h.Class("ml-auto text-muted-foreground")], - [model.group.trim() || "None"], - ), - ], + ...button, + h.Class( + "flex w-full items-center gap-2 px-3 py-2 text-left text-body-md hover:bg-surface-muted", + ), + ], + [ + Icon.view( + h, + ChevronRight, + cn("size-3.5 text-ink-subtle", model.groupOpen && "rotate-90"), + ), + h.span([h.Class("font-medium")], ["Exclusive group"]), + h.span( + [h.Class("ml-auto font-mono text-mono-sm text-ink-subtle")], + [model.group.trim() || "none"], ), - model.groupOpen - ? h.div( - [...panel, h.Class("mt-3 flex flex-col gap-3")], - [ - input(h, { - id: "rule-group", - label: "Group", - value: model.group, - placeholder: "optional", - onInput: (value) => Message.UpdatedGroup({ value }), - labelClass, - }), - groupOrderView(h, model), - input(h, { - id: "rule-priority", - label: "Priority", - value: model.priority, - type: "number", - onInput: (value) => Message.UpdatedPriority({ value }), - labelClass, - }), - h.p( - [h.Class("text-xs text-muted-foreground")], - [ - "Larger priorities take precedence. The group keeps only the highest-priority label requesting presence and removes all other group labels, including disabled rules.", - ], - ), - ], - ) - : h.empty, ], ), - }, - h, - ), - submissionView(h, model.submission), - issues.length - ? h.p( - [h.Class("text-xs text-destructive"), h.Role("status")], - [issues.join(". ")], - ) - : h.empty, - ], + model.groupOpen + ? h.div( + [ + ...panelAttributes, + h.Class( + "flex max-w-md flex-col gap-3 border-t border-border-subtle px-3 py-3", + ), + ], + [ + input(h, { + id: "rule-group", + label: "Group", + value: model.group, + placeholder: "optional", + className: "font-mono", + onInput: (value) => Message.UpdatedGroup({ value }), + }), + groupOrderView(h, model), + input(h, { + id: "rule-priority", + label: "Priority", + value: model.priority, + type: "number", + className: "font-mono", + onInput: (value) => Message.UpdatedPriority({ value }), + description: + "Larger priorities take precedence. The group keeps only the highest-priority label requesting presence and removes all other group labels, including disabled rules.", + }), + ], + ) + : h.empty, + ], + ), + }, + h, ), + submissionView(h, model.submission), + issues.length + ? h.p( + [h.Class("text-body-sm text-destructive"), h.Role("status")], + [issues.join(". ")], + ) + : h.empty, ], ), h.aside( - [h.Class("rule-inspector"), h.AriaLabel("Rule controls and testing")], + [ + h.Class("flex min-w-0 flex-col border-border bg-card"), + h.DataAttribute("slot", "inspector"), + h.AriaLabel("Rule controls and testing"), + ], [ dirty - ? h.div( - [h.Class("rule-save-actions")], + ? h.section( + [h.DataAttribute("slot", "inspector-section")], [ - Button.view(h, { - size: "sm", - className: "flex-1", - onClick: Message.ClickedSave(), - isDisabled: issues.length > 0 || busy, - label: busy - ? "Saving…" - : model.identity._tag === "New" - ? "Create rule" - : "Save changes", - attributes: [h.DataAttribute("action", "save")], - }), - Button.view(h, { - size: "sm", - variant: "outline", - onClick: Message.ClickedCancel(), - isDisabled: busy, - label: "Cancel", - }), + h.h3([h.DataAttribute("slot", "inspector-heading")], ["Changes"]), + h.div( + [h.Class("flex gap-2")], + [ + Button.view(h, { + size: "sm", + className: "flex-1", + onClick: Message.ClickedSave(), + isDisabled: issues.length > 0 || busy, + label: busy + ? "Saving…" + : model.identity._tag === "New" + ? "Create rule" + : "Save changes", + attributes: [h.DataAttribute("action", "save")], + }), + Button.view(h, { + size: "sm", + variant: "secondary", + onClick: Message.ClickedCancel(), + isDisabled: busy, + label: "Cancel", + }), + ], + ), ], ) : h.empty, h.section( - [h.Class("rule-test-card")], + [h.DataAttribute("slot", "inspector-section")], [ - h.h2([h.Class("rule-test-heading")], ["Test bench"]), + h.h3([h.DataAttribute("slot", "inspector-heading")], ["Test bench"]), h.div( - [h.Class("flex flex-col gap-3 p-4")], + [h.Class("flex flex-col gap-3")], [ model.testCandidates._tag === "Failed" ? h.p( - [h.Class("text-xs text-destructive")], + [h.Class("text-body-sm text-destructive")], ["Could not load test items. Retrying on the next refresh."], ) : testItems(model).length === 0 ? h.p( - [h.Class("text-xs text-muted-foreground")], + [h.Class("text-body-sm text-ink-muted")], [ policy || model.ai ? "No open items are available for this policy." @@ -1548,8 +1737,8 @@ export const view = Submodel.defineView( id: "rule-test-item", label: (model.ai?.target ?? policy?.target) === "issue" - ? "Issues" - : "Pull Requests", + ? "Issue" + : "Pull request", value: String(selectedTestItem(model)?.number ?? ""), options: testItems(model).map((item) => [ String(item.number), @@ -1559,16 +1748,17 @@ export const view = Submodel.defineView( Message.SelectedTestItem({ number: Number(value) }), }), Button.view(h, { - variant: "outline", + variant: "secondary", size: "sm", + className: "self-start", onClick: Message.ClickedTest(), isDisabled: issues.length > 0 || !selectedTestItem(model) || model.testResult._tag === "Running", label: h.span( - [h.Class("flex items-center gap-2")], - [Icon.view(h, Play, "size-3"), "Test rule"], + [h.Class("contents")], + [Icon.view(h, Play), "Run as a test"], ), }), testResultView(h, model), @@ -1577,21 +1767,18 @@ export const view = Submodel.defineView( ], ), model.identity._tag === "Existing" - ? h.div( - [h.Class("rule-delete-actions")], + ? h.section( + [h.DataAttribute("slot", "inspector-section")], [ + h.h3([h.DataAttribute("slot", "inspector-heading")], ["Delete"]), Button.view(h, { variant: "destructive", - size: "lg", - className: "w-full h-10", + size: "sm", onClick: Message.ClickedDelete(), isDisabled: busy, label: h.span( - [h.Class("flex items-center gap-1.5")], - [ - Icon.view(h, Trash2, "size-4"), - isDeleting ? "Deleting rule…" : "Delete rule", - ], + [h.Class("contents")], + [Icon.view(h, Trash2), isDeleting ? "Deleting rule…" : "Delete rule"], ), attributes: [h.DataAttribute("action", "delete-rule")], }), @@ -1609,55 +1796,31 @@ export const view = Submodel.defineView( toParentMessage: (message) => Message.GotDeleteDialogMessage({ message }), viewInputs: { toView: (render) => - h.dialog( - [ - ...render.dialog, - h.Class( - "fixed inset-0 m-0 h-dvh w-screen max-h-none max-w-none bg-transparent p-0 text-foreground", - ), + DialogChrome.view(h, { + dialog: render.dialog, + backdrop: render.backdrop, + panel: render.panel, + title: render.title, + description: render.description, + isVisible: render.isVisible, + titleText: "Delete rule?", + descriptionText: + 'Delete "' + (label?.name ?? "this rule") + '"? This cannot be undone.', + actions: [ + Button.view(h, { + label: "Cancel", + variant: "secondary", + attributes: [h.Id("cancel-delete-rule")], + onClick: Message.CancelledDelete(), + }), + Button.view(h, { + label: "Delete rule", + variant: "destructive", + isDisabled: busy, + onClick: Message.ConfirmedDelete(), + }), ], - render.isVisible - ? [ - h.div([...render.backdrop, h.Class("fixed inset-0 bg-black/40")], []), - h.div( - [ - ...render.panel, - h.Class( - "relative mx-auto mt-[20vh] w-[calc(100%-2rem)] max-w-md rounded-xl border bg-background p-5 shadow-xl space-y-4", - ), - ], - [ - h.h2([...render.title, h.Class("font-semibold")], ["Delete rule?"]), - h.p( - [...render.description, h.Class("text-sm text-muted-foreground")], - [ - 'Delete "' + - (label?.name ?? "this rule") + - '"? This cannot be undone.', - ], - ), - h.div( - [h.Class("flex justify-end gap-2")], - [ - Button.view(h, { - label: "Cancel", - variant: "outline", - attributes: [h.Id("cancel-delete-rule")], - onClick: Message.CancelledDelete(), - }), - Button.view(h, { - label: "Delete rule", - variant: "destructive", - isDisabled: busy, - onClick: Message.ConfirmedDelete(), - }), - ], - ), - ], - ), - ] - : [], - ), + }), }, }), ], @@ -1711,137 +1874,6 @@ const MountAiPrompt = Mount.defineStream("MountAiPrompt", { ), ), }) -const aiFields = (h: HtmlBuilder, model: Model): Html => { - const ai = model.ai! - return h.div( - [h.Class("flex flex-col gap-5")], - [ - h.div( - [h.Class("grid grid-cols-1 gap-4 md:grid-cols-2")], - [ - selectField(h, { - id: "ai-target", - label: "Applies to", - value: ai.target, - options: [ - ["pull_request", "Pull requests"], - ["issue", "Issues"], - ], - onChange: (value) => Message.SelectedTarget({ value }), - }), - selectField(h, { - id: "ai-label", - label: "GitHub label", - value: Option.getOrElse(model.maybeLabelId, () => ""), - options: [ - ["", "Choose a label…"], - ...model.labels - .filter((l) => l.availability !== "unavailable") - .map((l) => [l.labelId, l.name] as const), - ], - onChange: (labelId) => Message.SelectedLabel({ labelId }), - }), - ], - ), - selectField(h, { - id: "ai-gate", - label: "Gate policy", - value: ai.gatePolicyId ?? "", - options: [ - ["", "No gate · always evaluate"], - ...model.policies - .filter( - (p) => - p.publishedVersionId !== null && - p.target === ai.target && - p.publishedEvaluator === "Conditions", - ) - .map((p) => [p.policyId, p.name] as const), - ], - onChange: (value) => Message.SelectedGate({ value }), - }), - h.p( - [h.Class("-mt-3 text-xs text-muted-foreground")], - ["AI runs only when this policy matches. Otherwise, labels stay unchanged."], - ), - h.div( - [h.Class("flex flex-col gap-2")], - [ - h.div( - [h.Class("flex justify-between text-xs")], - [ - h.span([], ["Instructions"]), - h.span([h.Class("text-muted-foreground")], [`${ai.prompt.length} / 4,000`]), - ], - ), - h.div( - [ - h.Id("ai-prompt"), - h.DataAttribute("target", ai.target), - h.DataAttribute("catalog", JSON.stringify(model.catalog)), - h.Class("rounded-md border overflow-hidden"), - h.OnMount(MountAiPrompt({ source: ai.prompt, catalog: model.catalog })), - ], - [], - ), - h.p( - [h.Class("text-xs text-muted-foreground")], - ["Type {{ to reference a fact. Only referenced facts are used as evidence."], - ), - ], - ), - h.div( - [h.Class("rounded-lg border bg-muted/20 p-5")], - [ - h.div( - [h.Class("flex justify-between items-center")], - [ - h.div( - [], - [ - h.label( - [h.For("ai-confidence"), h.Class("text-sm font-medium")], - ["Minimum confidence"], - ), - h.p( - [h.Class("text-xs text-muted-foreground mt-1")], - ["Below this score, use the non-match action."], - ), - ], - ), - h.span( - [h.Class("text-2xl tabular-nums")], - [`${Math.round(ai.minimumConfidence * 100)}%`], - ), - ], - ), - h.input([ - h.Id("ai-confidence"), - h.Type("range"), - h.Min("0"), - h.Max("100"), - h.Step("5"), - h.Value(String(ai.minimumConfidence * 100)), - h.Class("ai-confidence w-full my-5"), - h.OnInput((value) => Message.ChangedConfidence({ value })), - ]), - h.div( - [h.Class("flex justify-end gap-2")], - [70, 80, 95].map((value) => - Button.view(h, { - variant: ai.minimumConfidence * 100 === value ? "secondary" : "outline", - size: "sm", - label: `${value}%`, - onClick: Message.ChangedConfidence({ value: String(value) }), - }), - ), - ), - ], - ), - ], - ) -} - const RuleTestJob = Schema.Struct({ testId: Schema.String, status: Schema.Literals(["queued", "running", "done", "failed"]), diff --git a/apps/web/src/components/sessions.ts b/apps/web/src/components/sessions.ts index cff2949e..d0eb0061 100644 --- a/apps/web/src/components/sessions.ts +++ b/apps/web/src/components/sessions.ts @@ -23,12 +23,16 @@ import * as Submodel from "foldkit/submodel" import * as Subscription from "foldkit/subscription" import * as Update from "foldkit/update" import * as Button from "@/components/ui/button" +import { chip, type ChipVariant } from "@/components/ui/chip" +import * as Feed from "@/components/ui/feed" +import { emptyPanel, panel } from "@/components/ui/panel" +import { sign } from "@/components/ui/sign" import * as Live from "@/components/live" import { readFailure, reasonOf } from "@/lib/api" import * as Icon from "@/lib/icons" import { cn } from "@/lib/utils" import * as Routes from "@/routes" -import { Bot, CircleAlert, ExternalLink, GitPullRequest, MessageSquare } from "lucide" +import { CircleAlert, Clock, ExternalLink, GitPullRequest, MessageSquare } from "lucide" /** * The Janitor dashboard: every teammate sees the same compact list of agent @@ -346,11 +350,13 @@ const EXECUTION_LABEL: Record = { failed: "Failed", } -const EXECUTION_CLASS: Record = { - working: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300", - idle: "bg-muted text-muted-foreground", - blocked: "bg-amber-500/15 text-amber-700 dark:text-amber-300", - failed: "bg-destructive/10 text-destructive", +/** Working is the agent running, so it takes the agent mark; blocked is a + * neutral state whose reason sits beside it; failed is genuine failure. */ +const EXECUTION_VARIANT: Record = { + working: "agent", + idle: "neutral", + blocked: "neutral", + failed: "danger", } export const formatTime = (time: DateTime.Utc): string => @@ -373,28 +379,36 @@ export const describeFreshness = (session: SessionSummary): string => ? "Not yet read from the runner" : `Confirmed ${formatTime(session.freshness.readAt)}` +/** A machine value: a timestamp, a count, an identifier. */ +const mono = (h: HtmlBuilder, text: string, className?: string): Html => + h.span([h.Class(cn("font-mono text-mono-sm", className))], [text]) + +const agentDot = (h: HtmlBuilder): Html => + h.span([h.Class("oc-agent-dot"), h.AriaHidden(true)], []) + const badge = (h: HtmlBuilder, session: SessionSummary): Html => - h.span( - [ - h.Class( - cn( - "inline-flex shrink-0 items-center rounded-md px-2 py-0.5 text-xs font-medium", - EXECUTION_CLASS[session.execution], - ), - ), + chip(h, { + variant: EXECUTION_VARIANT[session.execution], + className: "shrink-0", + children: [ + ...(session.execution === "working" ? [agentDot(h)] : []), + EXECUTION_LABEL[session.execution], ], - [EXECUTION_LABEL[session.execution]], - ) + }) -const externalLink = (h: HtmlBuilder, url: string, label: string): Html => +const externalLink = ( + h: HtmlBuilder, + url: string, + label: ReadonlyArray, +): Html => h.a( [ h.Href(url), h.Target("_blank"), h.Rel("noreferrer"), - h.Class("inline-flex items-center gap-1 text-sm underline"), + h.Class("inline-flex items-center gap-1 text-primary"), ], - [label, Icon.view(h, ExternalLink, "size-3")], + [...label, Icon.view(h, ExternalLink, "size-3 shrink-0")], ) const links = (h: HtmlBuilder, session: SessionSummary): ReadonlyArray => [ @@ -403,56 +417,60 @@ const links = (h: HtmlBuilder, session: SessionSummary): ReadonlyArray< : [ h.span( [h.Class("inline-flex items-center gap-1")], - [ - Icon.view(h, MessageSquare, "size-3"), - externalLink(h, session.homeThread.url, "Home thread"), - ], + [Icon.view(h, MessageSquare), externalLink(h, session.homeThread.url, ["Home thread"])], ), ]), ...session.pullRequests.map((pr) => h.span( [h.Class("inline-flex items-center gap-1")], - [Icon.view(h, GitPullRequest, "size-3"), externalLink(h, pr.url, `PR #${pr.number}`)], + [ + Icon.view(h, GitPullRequest), + externalLink(h, pr.url, ["PR ", mono(h, `#${pr.number}`, "text-primary")]), + ], ), ), ] +/** A warning is neutral text with an icon; there is no warning colour. */ +const warning = (h: HtmlBuilder, text: string): ReadonlyArray => [ + Icon.view(h, CircleAlert), + text, +] + const row = (h: HtmlBuilder, session: SessionSummary): Html => h.li( - [h.Class("flex flex-col gap-1 py-3")], + [h.Class("flex flex-col gap-1 border-b border-border-subtle px-3 py-2 last:border-b-0")], [ h.div( [h.Class("flex flex-wrap items-center gap-2")], [ + agentDot(h), badge(h, session), h.a( [ h.Href(Routes.session({ sessionId: session.sessionId })), - h.Class("text-sm font-medium underline-offset-4 hover:underline"), + h.Class("text-body-md font-medium"), ], [session.title], ), session.repository === null - ? h.span([h.Class("text-xs text-muted-foreground")], ["No repository yet"]) - : h.span( - [h.Class("text-xs text-muted-foreground")], - [`${session.repository.owner}/${session.repository.repo}`], - ), + ? h.span([h.Class("text-body-sm text-ink-subtle")], ["No repository yet"]) + : mono(h, `${session.repository.owner}/${session.repository.repo}`, "text-ink-subtle"), ], ), session.reason === null ? h.empty - : h.p([h.Class("text-xs text-muted-foreground")], [session.reason]), + : h.p([h.Class("text-body-sm text-ink-muted")], [session.reason]), h.div( - [h.Class("flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground")], + [h.Class("flex flex-wrap items-center gap-x-4 gap-y-1 text-body-sm text-ink-subtle")], [ - h.span([], [`Activity ${formatTime(session.activityAt)}`]), - h.span([h.Title(USAGE_NOTE)], [describeUsage(session.usage)]), + h.span([], ["Activity ", mono(h, formatTime(session.activityAt), "text-mono-xs")]), + mono(h, describeUsage(session.usage), "text-mono-xs"), session.deliveryWarning === null ? h.empty : h.span( - [h.Class("inline-flex items-center gap-1 text-amber-700 dark:text-amber-300")], - [Icon.view(h, CircleAlert, "size-3"), `Delivery: ${session.deliveryWarning}`], + [h.Class("inline-flex items-center gap-1 text-ink-muted")], + warning(h, `Delivery: ${session.deliveryWarning}`), ), ...links(h, session), ], @@ -470,21 +488,33 @@ const liveStatus = (h: HtmlBuilder, model: Model): Html => { ? "Showing last known data; the latest refresh failed." : "Reconnecting to live updates…" return h.p( - [h.Role("status"), h.Class("flex flex-wrap gap-2 text-xs text-muted-foreground")], + [h.Role("status"), h.Class("flex flex-wrap items-center gap-2 text-body-sm text-ink-muted")], [ h.span([], [text]), - h.button( - [h.Class("underline"), h.OnClick(Message.ClickedRetry())], - [status === "denied" ? "Reconnect" : "Retry"], - ), + Button.view(h, { + variant: "link", + size: "sm", + label: status === "denied" ? "Reconnect" : "Retry", + onClick: Message.ClickedRetry(), + }), ], ) } +const alert = (h: HtmlBuilder, text: string): Html => + panel(h, { + attributes: [h.Role("alert")], + className: "border-destructive text-body-sm", + children: [h.div([h.Class("font-medium text-destructive")], [text])], + }) + const fact = (h: HtmlBuilder, label: string, value: ReadonlyArray) => h.div( [h.Class("flex flex-col gap-0.5")], - [h.dt([h.Class("text-xs text-muted-foreground")], [label]), h.dd([h.Class("text-sm")], value)], + [ + h.dt([h.Class("text-caption font-medium text-ink-subtle")], [label]), + h.dd([h.Class("text-body-md")], value), + ], ) /** @@ -492,146 +522,206 @@ const fact = (h: HtmlBuilder, label: string, value: ReadonlyArray { - const parts: Array = [] - if (status.completedAt === null) parts.push("not scanned yet") - else if (status.overdue) parts.push(`scan overdue, last ${formatTime(status.completedAt)}`) - if (status.incomplete) parts.push("results still arriving") +const describeRecovery = ( + h: HtmlBuilder, + status: RecoveryStatus, +): ReadonlyArray => { + const parts: Array> = [] + if (status.completedAt === null) parts.push(["not scanned yet"]) + else if (status.overdue) + parts.push(["scan overdue, last ", mono(h, formatTime(status.completedAt))]) + if (status.incomplete) parts.push(["results still arriving"]) if (status.hydrating > 0) - parts.push( - `fetching comments for ${formatCount(status.hydrating)} contribution${status.hydrating === 1 ? "" : "s"}`, - ) - if (status.warning !== null) parts.push(`retrying past: ${status.warning}`) - return `${platformName(status.platform)}: ${parts.length === 0 ? `caught up, last scan ${formatTime(status.completedAt!)}` : parts.join("; ")}` + parts.push([ + "fetching comments for ", + mono(h, formatCount(status.hydrating)), + ` contribution${status.hydrating === 1 ? "" : "s"}`, + ]) + const body: ReadonlyArray = + parts.length === 0 + ? ["caught up, last scan ", mono(h, formatTime(status.completedAt!))] + : parts.flatMap((part, index) => (index === 0 ? part : ["; ", ...part])) + return [`${platformName(status.platform)}: `, ...body] } +const recoveryRow = (h: HtmlBuilder, status: RecoveryStatus): Html => + h.li( + [ + h.Class( + "flex items-start gap-2 border-b border-border-subtle px-3 py-2 text-body-md last:border-b-0", + ), + ], + [ + status.overdue + ? Icon.view(h, Clock, "mt-0.5 size-3.5 shrink-0 text-ink-muted") + : status.incomplete + ? Icon.view(h, CircleAlert, "mt-0.5 size-3.5 shrink-0 text-ink-muted") + : h.empty, + h.div( + [h.Class("flex min-w-0 flex-col gap-0.5")], + [ + h.p([], describeRecovery(h, status)), + status.warning === null + ? h.empty + : h.p([h.Class("text-body-sm text-ink-muted")], [`Retrying past: ${status.warning}`]), + status.gap === null + ? h.empty + : h.p([h.Class("text-body-sm text-ink-subtle")], [status.gap]), + ], + ), + ], + ) + const recoveryView = (h: HtmlBuilder, recovery: ReadonlyArray): Html => recovery.length === 0 ? h.empty : h.section( - [h.Class("rounded-lg border p-5 space-y-2")], + [h.Class("flex flex-col gap-3")], + [ + sign(h, { children: ["Recovery"] }), + panel(h, { + flush: true, + children: [ + h.ul( + [], + recovery.map((status) => recoveryRow(h, status)), + ), + ], + }), + ], + ) + +const pendingDeliveryView = (h: HtmlBuilder, detail: SessionDetail): Html => + detail.pendingDelivery.length === 0 + ? h.empty + : h.section( + [h.Class("flex flex-col gap-3")], [ - h.h2([h.Class("text-base font-semibold")], ["Recovery"]), - h.ul( - [h.Class("text-sm space-y-2")], - recovery.map((status) => - h.li( - [h.Class("space-y-0.5")], - [ - h.p( - status.overdue || status.incomplete || status.warning !== null - ? [h.Class("text-amber-700 dark:text-amber-300")] - : [], - [describeRecovery(status)], + sign(h, { children: ["Pending delivery"] }), + panel(h, { + flush: true, + children: [ + h.ul( + [], + detail.pendingDelivery.map((item) => + h.li( + [ + h.Class( + "flex flex-wrap items-center gap-2 border-b border-border-subtle px-3 py-2 text-body-md last:border-b-0", + ), + ], + [ + `${platformName(item.platform)} reply ${item.state}${item.error === null ? "" : ": "}`, + ...(item.error === null ? [] : [mono(h, item.error, "text-destructive")]), + ], ), - status.gap === null - ? h.empty - : h.p([h.Class("text-xs text-muted-foreground")], [status.gap]), - ], + ), ), - ), - ), + ], + }), ], ) const detailView = (h: HtmlBuilder, detail: SessionDetail): Html => h.div( - [h.Class("space-y-5")], + [h.Class("flex flex-col gap-4")], [ - h.a([h.Href(Routes.sessions()), h.Class("text-sm underline")], ["All sessions"]), + h.a([h.Href(Routes.sessions()), h.Class("self-start text-body-sm")], ["All sessions"]), h.div( [h.Class("flex flex-wrap items-center gap-2")], - [badge(h, detail), h.h1([h.Class("text-lg font-semibold")], [detail.title])], + [h.h1([], [detail.title]), Feed.agentBadge(h), badge(h, detail)], ), - detail.reason === null ? h.empty : h.p([h.Class("text-sm")], [detail.reason]), - detail.latestError === null + detail.reason === null ? h.empty - : h.p( - [h.Role("alert"), h.Class("text-sm text-destructive")], - [`Latest error: ${detail.latestError}`], - ), + : h.p([h.Class("text-body-md text-ink-muted")], [detail.reason]), + detail.latestError === null ? h.empty : alert(h, `Latest error: ${detail.latestError}`), detail.deliveryWarning === null ? h.empty : h.p( - [h.Role("alert"), h.Class("text-sm text-amber-700 dark:text-amber-300")], - [`Delivery: ${detail.deliveryWarning}`], + [h.Role("alert"), h.Class("flex items-center gap-1 text-body-sm text-ink-muted")], + warning(h, `Delivery: ${detail.deliveryWarning}`), ), - h.dl( - [h.Class("grid gap-4 rounded-lg border p-5 sm:grid-cols-2")], - [ - fact(h, "Repository", [ - detail.repository === null - ? "Not selected yet" - : `${detail.repository.owner}/${detail.repository.repo}`, - ]), - fact(h, "Links", links(h, detail).length === 0 ? ["None"] : links(h, detail)), - fact(h, "Latest activity", [formatTime(detail.activityAt)]), - fact(h, "Projection", [describeFreshness(detail)]), - fact(h, "Inputs", [ - `${formatCount(detail.acceptedInputs)} accepted, ${formatCount(detail.pendingInputs)} awaiting the runner${detail.lastInputAt === null ? "" : `, last ${formatTime(detail.lastInputAt)}`}`, - ]), - fact(h, "Recorded usage", [ - h.span([], [describeUsage(detail.usage)]), - h.p([h.Class("text-xs text-muted-foreground")], [USAGE_NOTE]), - ]), - ], - ), - detail.pendingDelivery.length === 0 - ? h.empty - : h.section( - [h.Class("rounded-lg border p-5 space-y-2")], + panel(h, { + children: [ + h.dl( + [h.Class("grid gap-4 sm:grid-cols-2")], [ - h.h2([h.Class("text-base font-semibold")], ["Pending delivery"]), - h.ul( - [h.Class("text-sm space-y-1")], - detail.pendingDelivery.map((item) => - h.li( - [], - [ - `${platformName(item.platform)} reply ${item.state}${item.error === null ? "" : `: ${item.error}`}`, - ], - ), + fact(h, "Repository", [ + detail.repository === null + ? "Not selected yet" + : mono(h, `${detail.repository.owner}/${detail.repository.repo}`), + ]), + fact(h, "Links", [ + h.span( + [h.Class("flex flex-wrap items-center gap-x-4 gap-y-1")], + links(h, detail).length === 0 ? ["None"] : links(h, detail), ), - ), + ]), + fact(h, "Latest activity", [mono(h, formatTime(detail.activityAt))]), + fact(h, "Projection", [describeFreshness(detail)]), + fact(h, "Inputs", [ + mono(h, formatCount(detail.acceptedInputs)), + " accepted, ", + mono(h, formatCount(detail.pendingInputs)), + " awaiting the runner", + ...(detail.lastInputAt === null + ? [] + : [", last ", mono(h, formatTime(detail.lastInputAt))]), + ]), + fact(h, "Recorded usage", [ + mono(h, describeUsage(detail.usage)), + h.p([h.Class("text-body-sm text-ink-muted")], [USAGE_NOTE]), + ]), ], ), + ], + }), + pendingDeliveryView(h, detail), recoveryView(h, detail.recovery), - h.p([h.Class("text-xs text-muted-foreground")], [describeFreshness(detail)]), + h.p([h.Class("text-body-sm text-ink-subtle")], [describeFreshness(detail)]), ], ) const listView = (h: HtmlBuilder, model: Model): Html => h.div( - [h.Class("space-y-4")], + [h.Class("flex flex-col gap-4")], [ h.div( - [h.Class("flex items-center gap-3")], + [h.Class("flex flex-col gap-1")], [ - Icon.view(h, Bot, "size-6 text-muted-foreground"), - h.h1([h.Class("text-lg font-semibold")], ["Sessions"]), - ], - ), - h.p( - [h.Class("text-sm text-muted-foreground")], - [ - "Every teammate sees the same sessions. Collaborate with an agent in its home thread; usage totals are recorded by OpenCode and are not a bill.", + h.div( + [h.Class("flex flex-wrap items-center gap-2")], + [h.h1([], ["Sessions"]), Feed.agentBadge(h)], + ), + h.p( + [h.Class("text-body-sm text-ink-muted")], + [ + "Every teammate sees the same sessions. Collaborate with an agent in its home thread; usage totals are recorded by OpenCode and are not a bill.", + ], + ), ], ), !model.loaded && model.loading - ? h.p([h.Role("status")], ["Loading sessions…"]) + ? h.p([h.Role("status"), h.Class("text-body-sm text-ink-muted")], ["Loading sessions…"]) : model.loaded && model.sessions.length === 0 - ? h.p([h.Class("text-sm text-muted-foreground")], ["No agent sessions yet."]) - : h.ul( - [h.Class("divide-y")], - model.sessions.map((session) => row(h, session)), - ), + ? emptyPanel(h, { children: ["No agent sessions yet."] }) + : panel(h, { + flush: true, + children: [ + h.ul( + [], + model.sessions.map((session) => row(h, session)), + ), + ], + }), model.cursor === null ? h.empty : Button.view(h, { label: model.loadingMore ? "Loading…" : "Load more", onClick: Message.ClickedMore(), - variant: "outline", + variant: "secondary", size: "sm", + className: "self-start", isDisabled: model.loadingMore, }), ], @@ -640,18 +730,19 @@ const listView = (h: HtmlBuilder, model: Model): Html => export const view = Submodel.defineView>( (model, _inputs, h) => h.div( - [h.Class("mx-auto w-full max-w-3xl p-6 space-y-4")], + [h.Class("flex w-full max-w-3xl flex-col gap-4 p-4 lg:p-5")], [ liveStatus(h, model), - model.error !== null && !model.stale - ? h.p([h.Role("alert"), h.Class("text-sm text-destructive")], [model.error]) - : h.empty, + model.error !== null && !model.stale ? alert(h, model.error) : h.empty, model.selected === null ? listView(h, model) : model.detail === null ? model.loading - ? h.p([h.Role("status")], ["Loading session…"]) - : h.a([h.Href(Routes.sessions()), h.Class("text-sm underline")], ["All sessions"]) + ? h.p( + [h.Role("status"), h.Class("text-body-sm text-ink-muted")], + ["Loading session…"], + ) + : h.a([h.Href(Routes.sessions()), h.Class("text-body-sm")], ["All sessions"]) : detailView(h, model.detail), ], ), diff --git a/apps/web/src/components/sync-button.ts b/apps/web/src/components/sync-button.ts index 3270ca1c..81c9b243 100644 --- a/apps/web/src/components/sync-button.ts +++ b/apps/web/src/components/sync-button.ts @@ -14,7 +14,7 @@ import { evo } from "foldkit/struct" import * as Submodel from "foldkit/submodel" import * as Update from "foldkit/update" import { RefreshCw } from "lucide" -import { buttonSizes, buttonVariants } from "@/components/ui/button" +import * as Overlay from "@/components/ui/overlay" import * as Icon from "@/lib/icons" import { cn } from "@/lib/utils" @@ -361,30 +361,23 @@ export const view = Submodel.defineView< ]), h.Class( cn( - "inline-flex shrink-0 cursor-pointer items-center justify-center rounded-md outline-none transition-all focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50", - buttonVariants.ghost, - buttonSizes["icon-sm"], + Overlay.menuButtonClass, disabled && "opacity-50 cursor-default", Option.isSome(model.summary) && model.summary.value.failedTargets > 0 && - "text-amber-500", + "text-destructive", ), ), ], - [Icon.view(h, RefreshCw, cn("size-4 shrink-0", syncing && "animate-spin"))], + [Icon.view(h, RefreshCw, cn("size-3.5 shrink-0", syncing && "animate-spin"))], ), render.isVisible ? h.div( - [ - ...render.panel, - h.Class( - "z-50 rounded-md bg-card px-3 py-2 text-xs text-foreground shadow-md ring ring-border whitespace-nowrap", - ), - ], + [...render.panel, h.Class(cn(Overlay.tooltipContentClass, "whitespace-nowrap"))], [ h.div([h.Class("font-medium")], ["Re-sync GitHub"]), h.div( - [h.Class("text-muted-foreground")], + [h.Class("text-ink-muted")], [ syncDisabled ? "Repository paused. Resume it in repository settings before syncing." diff --git a/apps/web/src/components/test-bench.ts b/apps/web/src/components/test-bench.ts index ec32ce64..c0e83176 100644 --- a/apps/web/src/components/test-bench.ts +++ b/apps/web/src/components/test-bench.ts @@ -11,20 +11,24 @@ import { evo } from "foldkit/struct" import * as Submodel from "foldkit/submodel" import type * as Update from "foldkit/update" import * as Button from "@/components/ui/button" +import { chip, type ChipVariant } from "@/components/ui/chip" +import * as Feed from "@/components/ui/feed" +import { panel, panelHeader } from "@/components/ui/panel" +import * as Select from "@/components/ui/select" import * as Icon from "@/lib/icons" -import { Play, X } from "lucide" +import { Check, CircleAlert, CircleHelp, Play, X } from "lucide" import { ConfigurationView, describeLocation, describeOutcome, describePlan, labelName, + type Outcome, TestEntity, testEndpoint, TestResponse, TestSubject, } from "@/components/labeling-wire" -import { cn } from "@/lib/utils" /** * The test bench (plan: "User interface"). Runs a subject against the most @@ -172,16 +176,29 @@ export const update = (model: Model, message: Message): UpdateReturn => // VIEW -const chipClass = "inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium" +/** Outcome as a chip: match neutral with a check, no-match neutral, unknown + * neutral with a question mark, failed danger. No warning colour exists. */ +const outcomeChip = (h: HtmlBuilder, outcome: Outcome, text?: string): Html => { + const variant: ChipVariant = outcome === "failed" ? "danger" : "neutral" + const icon = + outcome === "match" + ? Check + : outcome === "unknown" + ? CircleHelp + : outcome === "failed" + ? CircleAlert + : null + return chip(h, { + variant, + className: "shrink-0", + children: [ + ...(icon === null ? [] : [Icon.view(h, icon, "size-3 shrink-0")]), + text ?? describeOutcome(outcome), + ], + }) +} -const outcomeClass = (outcome: string) => - outcome === "match" - ? "text-emerald-600 dark:text-emerald-400" - : outcome === "failed" - ? "text-destructive" - : outcome === "no-match" - ? "text-amber-600 dark:text-amber-400" - : "text-muted-foreground" +const labelChip = (h: HtmlBuilder, name: string): Html => chip(h, { children: [name] }) const entityRow = (h: HtmlBuilder, model: Model, entity: TestEntity): Html => { const isExpanded = model.expanded.includes(entity.number) @@ -193,7 +210,7 @@ const entityRow = (h: HtmlBuilder, model: Model, entity: TestEntity): H ].filter((part) => part.length > 0) return h.li( [ - h.Class("flex flex-col gap-1 border-t py-2"), + h.Class("flex flex-col gap-1.5 border-t border-border-subtle px-3 py-2 first:border-t-0"), h.DataAttribute("number", String(entity.number)), ], [ @@ -201,22 +218,19 @@ const entityRow = (h: HtmlBuilder, model: Model, entity: TestEntity): H [h.Class("flex items-start justify-between gap-3")], [ h.div( - [h.Class("flex min-w-0 flex-col")], + [h.Class("flex min-w-0 flex-col gap-0.5")], [ h.span( - [h.Class("truncate text-sm font-medium")], - [`#${entity.number} ${entity.title}`], + [h.Class("truncate text-body-md font-medium")], + [h.span([h.Class("font-mono")], [`#${entity.number}`]), ` ${entity.title}`], ), - h.span([h.Class("text-muted-foreground text-xs")], [meta.join(" · ")]), + h.span([h.Class("text-body-sm text-ink-muted")], [meta.join(" · ")]), entity.labels.length === 0 ? h.empty : h.div( - [h.Class("mt-1 flex flex-wrap gap-1")], + [h.Class("mt-0.5 flex flex-wrap gap-1")], entity.labels.map((labelId) => - h.span( - [h.Class(chipClass)], - [labelName(model.configuration.labels, labelId)], - ), + labelChip(h, labelName(model.configuration.labels, labelId)), ), ), ], @@ -227,30 +241,25 @@ const entityRow = (h: HtmlBuilder, model: Model, entity: TestEntity): H [ h.Type("button"), h.OnClick(Message.ToggledTrace({ number: entity.number })), - h.Class( - cn( - "shrink-0 cursor-pointer text-sm font-medium", - outcomeClass(entity.evaluation.outcome), - ), - ), + h.AriaExpanded(isExpanded), + h.Class("shrink-0 cursor-pointer rounded-xs"), h.DataAttribute("outcome", entity.evaluation.outcome), ], - [describeOutcome(entity.evaluation.outcome)], + [outcomeChip(h, entity.evaluation.outcome)], ), ], ), entity.evaluation === null ? h.empty - : h.div([h.Class("text-muted-foreground text-xs")], [entity.evaluation.reason]), + : h.div([h.Class("text-body-sm text-ink-muted")], [entity.evaluation.reason]), entity.evaluation !== null && isExpanded ? h.ul( - [h.Class("text-muted-foreground mt-1 flex flex-col gap-0.5 font-mono text-xs")], + [h.Class("mt-1 flex flex-col gap-0.5 font-mono text-mono-sm text-ink-muted")], entity.evaluation.trace.map((node) => h.li( [], [ - h.span([h.Class(outcomeClass(node.outcome))], [describeOutcome(node.outcome)]), - ` ${describeLocation(node.location)}: ${node.reason}`, + `${describeOutcome(node.outcome)} · ${describeLocation(node.location)}: ${node.reason}`, ], ), ), @@ -259,19 +268,12 @@ const entityRow = (h: HtmlBuilder, model: Model, entity: TestEntity): H entity.plan === null ? h.empty : entity.plan.actions.length === 0 - ? h.div([h.Class("text-muted-foreground text-xs")], ["no changes"]) + ? h.div([h.Class("text-body-sm text-ink-muted")], ["no changes"]) : h.ul( - [h.Class("flex flex-col gap-0.5 text-sm")], + [h.Class("flex flex-col gap-0.5 font-mono text-mono-sm")], describePlan(entity.plan, model.configuration).map((line, index) => h.li( - [ - h.DataAttribute("action", entity.plan?.actions[index]?.action ?? ""), - h.Class( - outcomeClass( - entity.plan?.actions[index]?.action === "add" ? "match" : "no-match", - ), - ), - ], + [h.DataAttribute("action", entity.plan?.actions[index]?.action ?? "")], [line], ), ), @@ -285,19 +287,19 @@ const draftView = (h: HtmlBuilder, model: Model): Html => { const entity = entities.find((entry) => entry.number === model.selectedNumber) ?? entities[0] const running = model.run._tag === "Running" return h.section( - [h.Class("policy-draft-bench"), h.DataAttribute("bench", model.run._tag)], + [h.Class("flex flex-col gap-3"), h.DataAttribute("bench", model.run._tag)], [ h.div( - [h.Class("flex items-center justify-between")], + [h.Class("flex items-center justify-between gap-2")], [ h.h2( - [h.Class("text-sm font-semibold")], + [h.Class("text-h3 font-semibold")], [model.numbers.length > 0 ? "Result" : "Test bench"], ), Button.view(h, { variant: "ghost", size: "icon-xs", - label: Icon.view(h, X, "size-3"), + label: Icon.view(h, X), onClick: Message.ClickedClose(), attributes: [h.AriaLabel("Close"), h.Title("Clear test results")], }), @@ -306,79 +308,69 @@ const draftView = (h: HtmlBuilder, model: Model): Html => { model.numbers.length > 0 ? h.empty : h.p( - [h.Class("text-xs text-muted-foreground")], + [h.Class("text-body-sm text-ink-muted")], ["Preview this draft against recent open items."], ), entities.length > 0 && model.numbers.length === 0 - ? h.div( - [h.Class("flex flex-col gap-1.5")], - [ - h.label( - [h.For("policy-test-item"), h.Class("text-xs text-muted-foreground")], - ["Issue or pull request"], - ), - h.select( - [ - h.Id("policy-test-item"), - h.Class("policy-test-select"), - h.Value(String(entity?.number ?? "")), - h.OnChange((value) => Message.SelectedEntity({ number: Number(value) })), - ], - entities.map((entry) => - h.option([h.Value(String(entry.number))], [`#${entry.number} · ${entry.title}`]), - ), - ), - ], - ) + ? Select.view(h, { + id: "policy-test-item", + label: "Issue or pull request", + value: String(entity?.number ?? ""), + options: entities.map((entry) => [ + String(entry.number), + `#${entry.number} · ${entry.title}`, + ]), + onChange: (value) => Message.SelectedEntity({ number: Number(value) }), + }) : h.empty, Button.view(h, { - variant: "outline", + variant: "secondary", size: "sm", onClick: Message.ClickedRun(), isDisabled: running, label: h.span( [h.Class("flex items-center gap-1.5")], - [Icon.view(h, Play, "size-3"), running ? "Running" : "Test draft"], + [Icon.view(h, Play), running ? "Running" : "Test draft"], ), }), model.run._tag === "Failed" || model.run._tag === "Rejected" ? h.p( - [h.Role("alert"), h.Class("text-xs text-destructive")], + [h.Role("alert"), h.Class("text-body-sm text-destructive")], [model.run._tag === "Failed" ? model.run.reason : model.run.message], ) : running ? h.p( - [h.Role("status"), h.Class("text-xs text-muted-foreground")], - ["Evaluating the most recently updated open items"], + [h.Role("status"), h.Class("text-body-sm text-ink-muted")], + ["Evaluating the most recently updated open items…"], ) : entity === undefined ? h.p( - [h.Class("text-xs text-muted-foreground")], + [h.Class("text-body-sm text-ink-muted")], ["No open issues or pull requests to test against yet."], ) - : h.div( - [h.Class("policy-test-result"), h.DataAttribute("number", String(entity.number))], - [ + : panel(h, { + className: "oc-agent-edge flex flex-col gap-3 p-3", + attributes: [h.DataAttribute("number", String(entity.number))], + children: [ h.div( - [h.Class("flex items-center justify-between gap-2")], + [h.Class("flex flex-wrap items-center gap-2")], [ + Feed.agentBadge(h), + h.span( + [h.Class("font-mono text-mono-sm text-ink-subtle")], + [`#${entity.number}`], + ), h.span( [ - h.Class( - cn( - "policy-result-tag", - outcomeClass(entity.evaluation?.outcome ?? "unknown"), - ), - ), + h.Class("ml-auto"), h.DataAttribute("outcome", entity.evaluation?.outcome ?? "unknown"), ], - [describeOutcome(entity.evaluation?.outcome ?? "unknown")], + [outcomeChip(h, entity.evaluation?.outcome ?? "unknown")], ), - h.span([h.Class("text-xs text-muted-foreground")], [`#${entity.number}`]), ], ), h.p( - [h.Class("text-xs leading-relaxed text-muted-foreground")], + [h.Class("text-body-sm text-ink-muted")], [ entity.evaluation?.outcome === "match" ? `This ${entity.kind === "pull_request" ? "pull request" : "issue"} matches the draft.` @@ -388,33 +380,45 @@ const draftView = (h: HtmlBuilder, model: Model): Html => { "No evaluation is available for this item."), ], ), - h.ul( - [h.Class("policy-test-trace")], - (entity.evaluation?.trace ?? []).map((node) => - h.li( - [h.Title(describeLocation(node.location))], - [ - h.span([], [node.reason]), - h.span( + (entity.evaluation?.trace ?? []).length === 0 + ? h.empty + : h.ul( + [h.Class("flex flex-col gap-1.5")], + (entity.evaluation?.trace ?? []).map((node) => + h.li( [ - h.Class(outcomeClass(node.outcome)), - h.AriaLabel(describeOutcome(node.outcome)), + h.Class("flex items-start justify-between gap-3 text-body-sm"), + h.Title(describeLocation(node.location)), ], [ - node.outcome === "match" - ? "✓" - : node.outcome === "no-match" - ? "−" - : "?", + h.span([h.Class("min-w-0 text-ink-muted")], [node.reason]), + h.span( + [ + h.Class("inline-flex shrink-0 items-center text-ink-muted"), + h.AriaLabel(describeOutcome(node.outcome)), + ], + [ + Icon.view( + h, + node.outcome === "match" + ? Check + : node.outcome === "no-match" + ? X + : CircleHelp, + ), + ], + ), ], ), - ], + ), ), - ), - ), model.subject._tag === "Draft" && model.subject.policyId !== undefined ? h.div( - [h.Class("policy-test-bindings")], + [ + h.Class( + "flex flex-col gap-1.5 border-t border-border-subtle pt-2.5 text-body-sm empty:hidden", + ), + ], model.configuration.rules .filter( (rule) => @@ -425,19 +429,16 @@ const draftView = (h: HtmlBuilder, model: Model): Html => { h.div( [h.Class("flex items-center justify-between gap-2")], [ - h.span([h.Class("text-muted-foreground")], ["Used by rule"]), - h.span( - [h.Class(chipClass)], - [labelName(model.configuration.labels, rule.labelId)], - ), + h.span([h.Class("text-ink-muted")], ["Used by rule"]), + labelChip(h, labelName(model.configuration.labels, rule.labelId)), ], ), ), ) : h.empty, ], - ), - h.p([h.Class("text-xs text-muted-foreground")], ["Tests do not change labels."]), + }), + h.p([h.Class("text-body-sm text-ink-muted")], ["Tests do not change labels."]), ], ) } @@ -446,23 +447,23 @@ export const view = Submodel.defineView((model, h): Html => { if (model.subject._tag === "Draft") return draftView(h, model) const running = model.run._tag === "Running" return h.section( - [h.Class("flex flex-col gap-2"), h.DataAttribute("bench", model.run._tag)], + [h.Class("flex flex-col gap-3"), h.DataAttribute("bench", model.run._tag)], [ h.div( [h.Class("flex flex-wrap items-center justify-between gap-3")], [ h.div( - [h.Class("flex flex-col")], + [h.Class("flex min-w-0 flex-col")], [ - h.h2([h.Class("text-sm font-semibold tracking-tight")], ["Test bench"]), - h.span([h.Class("text-muted-foreground text-xs")], [model.title]), + h.h2([h.Class("text-h3 font-semibold")], ["Test bench"]), + h.span([h.Class("truncate text-body-sm text-ink-muted")], [model.title]), ], ), h.div( - [h.Class("flex items-center gap-2")], + [h.Class("flex items-center gap-1.5")], [ Button.view(h, { - variant: "outline", + variant: "secondary", size: "sm", onClick: Message.ClickedRun(), isDisabled: running, @@ -481,34 +482,46 @@ export const view = Submodel.defineView((model, h): Html => { (() => { switch (model.run._tag) { case "Running": - return h.div( - [h.Class("text-muted-foreground text-sm")], - ["Evaluating the most recently updated open items"], + return h.p( + [h.Role("status"), h.Class("text-body-sm text-ink-muted")], + ["Evaluating the most recently updated open items…"], ) case "Rejected": - return h.div( - [h.Class("text-destructive text-sm"), h.Role("alert")], + return h.p( + [h.Class("text-body-sm text-destructive"), h.Role("alert")], [model.run.message], ) case "Failed": - return h.div( - [h.Class("text-destructive text-sm"), h.Role("alert")], + return h.p( + [h.Class("text-body-sm text-destructive"), h.Role("alert")], [`Test failed: ${model.run.reason}`], ) case "Evaluated": { const list = model.run.entities return list.length === 0 - ? h.div( - [h.Class("text-muted-foreground text-sm")], + ? h.p( + [h.Class("text-body-sm text-ink-muted")], ["No open issues or pull requests to test against yet."], ) - : h.ul( - [h.Class("flex flex-col")], - list.map((entity) => entityRow(h, model, entity)), - ) + : panel(h, { + flush: true, + className: "oc-agent-edge", + children: [ + panelHeader(h, { + title: "Results", + meta: `${list.length} ${list.length === 1 ? "item" : "items"}`, + actions: [Feed.agentBadge(h)], + }), + h.ul( + [h.Class("flex flex-col")], + list.map((entity) => entityRow(h, model, entity)), + ), + ], + }) } } })(), + h.p([h.Class("text-body-sm text-ink-muted")], ["Tests do not change labels."]), ], ) }) diff --git a/apps/web/src/components/theme-switcher.ts b/apps/web/src/components/theme-switcher.ts index 07f0674d..5be5e757 100644 --- a/apps/web/src/components/theme-switcher.ts +++ b/apps/web/src/components/theme-switcher.ts @@ -1,4 +1,6 @@ import * as Menu from "@foldkit/ui/menu" +import * as Overlay from "@/components/ui/overlay" +import { cn } from "@/lib/utils" import * as Effect from "effect/Effect" import * as Match from "effect/Match" import * as Option from "effect/Option" @@ -258,11 +260,7 @@ const menuItem = ( item: ThemePreference, ): Html => h.div( - [ - h.Class( - "flex items-center gap-2 bg-card px-3 py-2 text-sm text-foreground transition-colors hover:bg-popover", - ), - ], + [h.Class(cn(Overlay.menuItemClass, "hover:bg-surface-muted"))], [ menuItemIcon(h, item), h.span([h.Class("flex-1")], [item]), @@ -281,13 +279,12 @@ export const view = Submodel.defineView((model, h) => anchor: { placement: "bottom-end", gap: 4, padding: 8 }, ariaLabel: "Theme", buttonContent: menuItemIcon(h, model.resolvedTheme), - buttonClassName: - "inline-flex size-7 shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-sm border-2 border-outline bg-card text-card-foreground text-sm font-medium outline-none transition-colors hover:bg-popover disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", - itemsClassName: "z-50 w-full max-w-36 cursor-pointer overflow-hidden bg-card py-1 jn-mount", + buttonClassName: Overlay.menuButtonClass, + itemsClassName: cn(Overlay.menuItemsClass, "w-36"), itemToConfig: (item) => ({ content: menuItem(h, model.preferredTheme, item), }), - backdropClassName: "fixed inset-0 z-40", + backdropClassName: Overlay.menuBackdropClass, }, }), ) diff --git a/apps/web/src/components/ui/blueprint.ts b/apps/web/src/components/ui/blueprint.ts new file mode 100644 index 00000000..be3b9656 --- /dev/null +++ b/apps/web/src/components/ui/blueprint.ts @@ -0,0 +1,188 @@ +import type { Attribute, ChildAttribute, Html, HtmlBuilder } from "foldkit/html" +import { cn } from "@/lib/utils" + +/** Blueprint canvas primitives. Graph views only; these must not appear on + * ordinary pages. Layout is a left-to-right chain of columns; connectors are + * orthogonal SVG segments drawn between columns. */ +type Attributes = ReadonlyArray | ChildAttribute> + +/** The card body: 22px grid, horizontal scroll, never reflows. */ +export const canvas = ( + h: HtmlBuilder, + config: { + readonly className?: string + readonly attributes?: Attributes + readonly children: ReadonlyArray + }, +): Html => + h.div( + [ + h.Class(cn("oc-canvas overflow-x-auto", config.className)), + h.DataAttribute("slot", "blueprint-canvas"), + ...(config.attributes ?? []), + ], + [h.div([h.Class("flex w-max min-w-full items-start gap-0 px-6 py-5")], config.children)], + ) + +/** A column: caption label above a stack of nodes. */ +export const column = ( + h: HtmlBuilder, + config: { + readonly label: string + readonly className?: string + readonly children: ReadonlyArray + }, +): Html => + h.div( + [ + h.Class(cn("flex w-64 shrink-0 flex-col gap-3", config.className)), + h.DataAttribute("slot", "blueprint-column"), + ], + [ + h.div([h.Class("text-caption font-medium text-ink-subtle")], [config.label]), + ...config.children, + ], + ) + +export type NodeConfig = { + readonly kind: string + readonly isSelected?: boolean + /** The Janitor evaluates this node itself (an AI classification). */ + readonly isAgent?: boolean + readonly className?: string + readonly attributes?: Attributes + readonly children: ReadonlyArray +} + +/** A step: caption kind label above a mono expression. */ +export const node = (h: HtmlBuilder, config: NodeConfig): Html => + h.div( + [ + h.Class( + cn("oc-node flex flex-col gap-1", config.isAgent && "oc-agent-edge", config.className), + ), + h.DataAttribute("slot", "blueprint-node"), + ...(config.isSelected === true ? [h.DataAttribute("selected", "true")] : []), + ...(config.attributes ?? []), + ], + [ + h.div( + [h.Class("flex items-center gap-1.5 text-caption font-medium text-ink-subtle")], + [ + config.kind, + ...(config.isAgent ? [h.span([h.Class("oc-agent-badge")], ["The Janitor"])] : []), + ], + ), + h.div([h.Class("font-mono text-mono-sm text-foreground")], config.children), + ], + ) + +export type WirePath = { + readonly fromY: number + readonly toY: number + readonly label?: string +} + +/** Orthogonal connectors between two columns: a horizontal run, a vertical + * drop at the midpoint, and a horizontal run, with junction dots at both + * ends. Pass `paths` for a branch; a single path is the default. */ +export const wire = ( + h: HtmlBuilder, + config: { + readonly height?: number + readonly fromY?: number + readonly toY?: number + readonly label?: string + readonly paths?: ReadonlyArray + readonly className?: string + }, +): Html => { + const paths: ReadonlyArray = config.paths ?? [ + { + fromY: config.fromY ?? 24, + toY: config.toY ?? 24, + ...(config.label === undefined ? {} : { label: config.label }), + }, + ] + const height = + config.height ?? Math.max(48, ...paths.map((path) => Math.max(path.fromY, path.toY) + 24)) + const width = 56 + const mid = width / 2 + return h.div( + [ + h.Class(cn("relative mt-7 shrink-0", config.className)), + h.DataAttribute("slot", "blueprint-wire"), + h.AriaHidden(true), + ], + [ + h.svg( + [ + h.Xmlns("http://www.w3.org/2000/svg"), + h.Class("block"), + h.Attribute("width", String(width)), + h.Attribute("height", String(height)), + h.ViewBox(`0 0 ${width} ${height}`), + ], + paths.flatMap((path) => [ + h.path([ + h.Class("oc-wire"), + h.Attribute("d", `M0 ${path.fromY} H${mid} V${path.toY} H${width}`), + h.Attribute("shape-rendering", "crispEdges"), + ]), + h.circle([ + h.Class("fill-wire"), + h.Attribute("cx", "0"), + h.Attribute("cy", String(path.fromY)), + h.Attribute("r", "2.6"), + ]), + h.circle([ + h.Class("fill-wire"), + h.Attribute("cx", String(width)), + h.Attribute("cy", String(path.toY)), + h.Attribute("r", "2.6"), + ]), + ]), + ), + ...paths.flatMap((path) => + path.label === undefined + ? [] + : [ + h.span( + [ + h.Class("oc-junction absolute left-1/2 -translate-x-1/2 -translate-y-1/2"), + h.Style({ top: `${Math.round((path.fromY + path.toY) / 2)}px` }), + ], + [path.label], + ), + ], + ), + ], + ) +} + +/** Annotation: a note The Janitor wrote about the graph. Use sparingly. */ +export const annotation = (h: HtmlBuilder, children: ReadonlyArray): Html => + h.div([h.Class("oc-annotation"), h.DataAttribute("slot", "blueprint-annotation")], children) + +/** Footer bar: controls left, plain summary middle, machine counts right. */ +export const footer = ( + h: HtmlBuilder, + config: { + readonly controls?: ReadonlyArray + readonly summary: string + readonly counts: string + }, +): Html => + h.div( + [ + h.Class( + "flex min-h-7 items-center gap-3 border-t border-border bg-surface-muted px-3 py-1 text-body-sm text-ink-muted", + ), + h.DataAttribute("slot", "blueprint-footer"), + ], + [ + h.div([h.Class("flex items-center gap-1")], config.controls ?? []), + h.div([h.Class("min-w-0 flex-1 truncate")], [config.summary]), + h.div([h.Class("font-mono text-mono-xs text-ink-subtle")], [config.counts]), + ], + ) diff --git a/apps/web/src/components/ui/button.ts b/apps/web/src/components/ui/button.ts index 920cf19a..185d633a 100644 --- a/apps/web/src/components/ui/button.ts +++ b/apps/web/src/components/ui/button.ts @@ -14,18 +14,19 @@ export const buttonVariantKeys = [ ] as const export const buttonVariants: Record = { - // Utility Room: primary is safety yellow, danger is a rust fill with the same - // hard drop edge, secondary and outline are cream plates, ghost is a dashed - // outline that does not travel, link is cobalt text. - default: "bg-primary text-primary-foreground border-outline hover:bg-yellow-safety-dark", + // Ops Console: primary is filled blue (interactive), danger is filled for + // irreversible actions only, outline and secondary are the plain surface + // button, ghost has no border, link is blue text. + default: + "bg-primary text-primary-foreground border-primary hover:bg-primary-hover hover:border-primary-hover", destructive: - "bg-rust text-cream-hi border-rust-dark shadow-[0_3px_0_var(--jn-rust-dark)] hover:bg-rust-dark", - outline: "bg-card text-card-foreground border-outline hover:bg-popover aria-expanded:bg-popover", + "bg-destructive text-destructive-foreground border-destructive hover:bg-destructive-hover hover:border-destructive-hover", + outline: + "bg-card text-foreground border-border hover:bg-surface-muted aria-expanded:bg-surface-muted", secondary: - "bg-secondary text-secondary-foreground border-outline hover:bg-popover aria-expanded:bg-popover", - ghost: - "border-dashed border-outline/70 bg-transparent shadow-none hover:bg-muted/40 aria-expanded:bg-muted/40 active:not-aria-[haspopup]:translate-y-0", - link: "border-transparent bg-transparent shadow-none text-cobalt-dark underline underline-offset-4 hover:text-cobalt dark:text-cobalt-light active:not-aria-[haspopup]:translate-y-0", + "bg-card text-foreground border-border hover:bg-surface-muted aria-expanded:bg-surface-muted", + ghost: "border-transparent bg-transparent hover:bg-surface-muted aria-expanded:bg-surface-muted", + link: "h-auto border-transparent bg-transparent px-0 text-primary underline-offset-4 hover:underline", } export type ButtonSize = (typeof buttonSizeKeys)[number] @@ -42,20 +43,20 @@ export const buttonSizeKeys = [ ] as const export const buttonSizes: Record = { - default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - xs: "h-6 gap-1 px-2 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-7 gap-1 px-2.5 text-body-sm has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", - lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - icon: "size-8 p-0", + default: "h-7 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 px-2 text-body-sm has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-6 gap-1 px-2 text-body-sm has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-8 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + icon: "size-7 p-0", "icon-xs": "size-6 p-0 [&_svg:not([class*='size-'])]:size-3", - "icon-sm": "size-7 p-0", - "icon-lg": "size-9 p-0", + "icon-sm": "size-6 p-0", + "icon-lg": "size-8 p-0", } -/** Every button is a mounted object: a 2.5px outline and a 3px hard drop edge. - * Pressing it travels 3px into the surface and loses the edge. */ -const buttonBase = - "aria-invalid:border-destructive rounded-sm border-[2.5px] border-outline bg-clip-padding text-button font-bold shadow-edge transition-[transform,box-shadow,background-color] duration-100 active:not-aria-[haspopup]:translate-y-[3px] active:not-aria-[haspopup]:shadow-none [&_svg:not([class*='size-'])]:size-4 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 disabled:pointer-events-none disabled:opacity-50 cursor-pointer" +/** Flat: a 1px border, no shadow, no press travel. The only feedback is the + * background change. */ +export const buttonBase = + "aria-invalid:border-destructive rounded-sm border text-label font-medium leading-none shadow-none transition-colors duration-120 ease-ui [&_svg:not([class*='size-'])]:size-3.5 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 disabled:pointer-events-none disabled:opacity-50 cursor-pointer" export type ButtonConfig = { readonly type?: "button" | "submit" | "reset" | undefined @@ -90,6 +91,8 @@ export const view = (h: HtmlBuilder, config: ButtonConfig): Html => ), ), h.DataAttribute("slot", "button"), + h.DataAttribute("size", config.size ?? "default"), + h.DataAttribute("variant", config.variant ?? "default"), ...(config.attributes ?? []), ], config.label === undefined ? [] : [config.label], diff --git a/apps/web/src/components/ui/chip.ts b/apps/web/src/components/ui/chip.ts index 861037dd..b77d9f92 100644 --- a/apps/web/src/components/ui/chip.ts +++ b/apps/web/src/components/ui/chip.ts @@ -1,18 +1,20 @@ import type { Html, HtmlBuilder } from "foldkit/html" import { cn } from "@/lib/utils" -/** Status chip. Mono, pill-shaped, and the only element allowed a full radius. - * `on` is safety yellow and therefore rationed: one meaning, "this is on". */ -export type ChipVariant = "neutral" | "on" | "danger" | "agent" +/** Badge. Mono, 3px radius, neutral by default. `selected` is the only pill + * shape. `agent` is yellow and means The Janitor produced the thing; `success` + * and `danger` are genuine state, never emphasis. */ +export type ChipVariant = "neutral" | "selected" | "success" | "danger" | "agent" export const chipClass = - "inline-flex items-center gap-1.5 whitespace-nowrap rounded-full border-2 px-2 py-0.5 font-mono text-mono-sm font-semibold" + "inline-flex h-4.5 max-w-full items-center gap-1 truncate whitespace-nowrap rounded-xs border border-border bg-surface-muted px-1.5 font-mono text-mono-xs font-medium leading-none text-ink-muted" export const chipVariants: Record = { - neutral: "border-outline bg-muted text-foreground", - on: "border-yellow-safety-dark bg-yellow-safety text-navy", - danger: "border-destructive bg-transparent text-destructive", - agent: "border-cobalt-dark bg-cobalt-light text-navy-deep", + neutral: "", + selected: "rounded-full border-primary-line bg-primary-wash text-primary-hover", + success: "border-success bg-success text-primary-foreground", + danger: "border-destructive bg-destructive text-destructive-foreground", + agent: "border-agent-line bg-agent-wash text-agent-ink", } export type ChipConfig = { @@ -25,7 +27,8 @@ export const chip = (h: HtmlBuilder, config: ChipConfig): Html => h.span( [ h.Class(cn(chipClass, chipVariants[config.variant ?? "neutral"], config.className)), - h.DataAttribute("slot", "chip"), + h.DataAttribute("slot", "badge"), + h.DataAttribute("variant", config.variant ?? "neutral"), ], config.children, ) diff --git a/apps/web/src/components/ui/command.ts b/apps/web/src/components/ui/command.ts index b5bee2eb..7d6df7d5 100644 --- a/apps/web/src/components/ui/command.ts +++ b/apps/web/src/components/ui/command.ts @@ -9,28 +9,28 @@ import * as Button from "./button" type Child = Html | string export const commandClass = - "bg-popover text-popover-foreground rounded-md! p-1 flex size-full flex-col overflow-hidden" + "bg-popover text-popover-foreground rounded-sm p-1 flex size-full flex-col overflow-hidden" export const commandInputClass = - "w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50" + "w-full text-body-md disabled:cursor-not-allowed disabled:opacity-50" export const commandListClass = "no-scrollbar max-h-72 scroll-py-1 outline-none overflow-x-hidden overflow-y-auto" -export const commandEmptyClass = "py-6 text-center text-sm" +export const commandEmptyClass = "px-2 py-4 text-body-sm text-ink-muted" export const commandGroupClass = "text-foreground overflow-hidden p-1" export const commandGroupHeadingClass = - "text-muted-foreground px-2 py-1.5 text-xs font-medium overflow-hidden" + "text-muted-foreground px-2 py-1.5 text-caption font-medium overflow-hidden" export const commandItemClass = - "data-selected:bg-muted data-selected:text-foreground data-selected:*:[svg]:text-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-sm! [&_svg:not([class*='size-'])]:size-4 group/command-item data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0" + "data-selected:bg-primary-wash data-selected:text-foreground data-selected:*:[svg]:text-foreground relative flex cursor-default items-center gap-2 rounded-xs px-2 py-1 text-body-md select-none [&_svg:not([class*='size-'])]:size-3.5 group/command-item data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0" -export const commandSeparatorClass = "bg-border -mx-1 h-px" +export const commandSeparatorClass = "bg-border-subtle -mx-1 h-px" export const commandShortcutClass = - "text-muted-foreground group-data-selected/command-item:text-foreground ml-auto text-xs tracking-widest" + "text-muted-foreground group-data-selected/command-item:text-foreground ml-auto font-mono text-mono-xs" type StyleConfig = { readonly className?: string @@ -67,11 +67,13 @@ export const input = (h: HtmlBuilder, config: CommandInputConfig): Html [ inputGroup(h, { className: cn( - "h-8! rounded-sm! shadow-none! *:data-[slot=input-group-addon]:pl-2!", + "h-7 rounded-sm shadow-none *:data-[slot=input-group-addon]:pl-2", config.groupClassName, ), children: [ - inputGroupAddon(h, { children: [Icon.view(h, Search, "size-4 shrink-0 opacity-50")] }), + inputGroupAddon(h, { + children: [Icon.view(h, Search, "size-3.5 shrink-0 text-muted-foreground")], + }), inputGroupInput(h, { ...config, id: config.id ?? "command-input", diff --git a/apps/web/src/components/ui/dialog.ts b/apps/web/src/components/ui/dialog.ts new file mode 100644 index 00000000..4f8dac86 --- /dev/null +++ b/apps/web/src/components/ui/dialog.ts @@ -0,0 +1,66 @@ +import type { Attribute, ChildAttribute, Html, HtmlBuilder } from "foldkit/html" +import { cn } from "@/lib/utils" + +/** Modal dialog chrome. Pair with `@foldkit/ui/dialog`'s `render` values: + * the root `` is transparent and fills the viewport, the overlay is + * the flat scrim, and the content box carries the only shadow in the system. */ +export const dialogRootClass = + "fixed inset-0 m-0 h-dvh max-h-none w-screen max-w-none bg-transparent p-0 text-foreground open:block" + +export const dialogOverlayClass = "fixed inset-0 z-50 bg-scrim" + +export const dialogContentClass = + "relative z-50 mx-auto flex max-w-md flex-col gap-3 rounded-sm border border-border bg-popover p-4 text-popover-foreground shadow-overlay" + +export const dialogTitleClass = "text-h2 font-semibold" + +export const dialogDescriptionClass = "text-body-sm text-ink-muted" + +export const dialogActionsClass = "flex justify-end gap-2 pt-1" + +type Attributes = ReadonlyArray | ChildAttribute> + +export type DialogContentConfig = { + readonly dialog: Attributes + readonly backdrop: Attributes + readonly panel: Attributes + readonly title: Attributes + readonly description: Attributes + readonly isVisible: boolean + readonly titleText: string + readonly descriptionText: Html | string + readonly actions: ReadonlyArray + readonly className?: string +} + +export const view = (h: HtmlBuilder, config: DialogContentConfig): Html => + h.dialog( + [...config.dialog, h.Class(dialogRootClass), h.DataAttribute("slot", "dialog")], + config.isVisible + ? [ + h.div( + [ + ...config.backdrop, + h.Class(dialogOverlayClass), + h.DataAttribute("slot", "dialog-overlay"), + ], + [], + ), + h.div( + [ + ...config.panel, + h.Class(cn(dialogContentClass, config.className)), + h.DataAttribute("slot", "dialog-content"), + ], + [ + h.h2([...config.title, h.Class(dialogTitleClass)], [config.titleText]), + h.p( + [...config.description, h.Class(dialogDescriptionClass)], + [config.descriptionText], + ), + h.div([h.Class(dialogActionsClass)], config.actions), + ], + ), + ] + : [], + ) diff --git a/apps/web/src/components/ui/feed.ts b/apps/web/src/components/ui/feed.ts new file mode 100644 index 00000000..2b67d4a6 --- /dev/null +++ b/apps/web/src/components/ui/feed.ts @@ -0,0 +1,77 @@ +import type { Attribute, ChildAttribute, Html, HtmlBuilder } from "foldkit/html" +import { cn } from "@/lib/utils" + +/** Activity feed entry. Agent entries take the agent marker and name + * "The Janitor" as the actor in agent-ink; human entries take a faint + * marker and a plain semibold actor. The marker is never the only carrier. */ +export type FeedActor = + | { readonly kind: "agent" } + | { readonly kind: "human"; readonly name: string } + +export const AGENT_NAME = "The Janitor" + +export type FeedItemConfig = { + readonly actor: FeedActor + readonly body: ReadonlyArray + /** Machine timestamp, rendered in mono-xs at the right edge. */ + readonly timestamp?: string + readonly className?: string + readonly attributes?: ReadonlyArray | ChildAttribute> +} + +export const actorName = (h: HtmlBuilder, actor: FeedActor): Html => + actor.kind === "agent" + ? h.span( + [h.Class("text-label font-medium text-agent-ink"), h.DataAttribute("actor", "agent")], + [AGENT_NAME], + ) + : h.span([h.Class("text-label font-semibold")], [actor.name]) + +export const marker = (h: HtmlBuilder, actor: FeedActor): Html => + h.span( + [ + h.Class(cn("mt-1.5", actor.kind === "agent" ? "oc-agent-dot" : "oc-human-dot")), + h.AriaHidden(true), + ], + [], + ) + +export const item = (h: HtmlBuilder, config: FeedItemConfig): Html => + h.div( + [ + h.Class( + cn( + "flex items-start gap-2.5 border-b border-border-subtle px-3 py-2 text-body-md last:border-b-0", + config.className, + ), + ), + h.DataAttribute("slot", "feed-item"), + h.DataAttribute("actor", config.actor.kind), + ...(config.attributes ?? []), + ], + [ + marker(h, config.actor), + h.div( + [h.Class("min-w-0 flex-1 leading-snug")], + [actorName(h, config.actor), " ", ...config.body], + ), + ...(config.timestamp === undefined + ? [] + : [ + h.span( + [ + h.Class("shrink-0 font-mono text-mono-xs text-ink-subtle"), + h.DataAttribute("slot", "feed-timestamp"), + ], + [config.timestamp], + ), + ]), + ], + ) + +/** Small "The Janitor" badge for content the agent wrote inline. */ +export const agentBadge = (h: HtmlBuilder, text = AGENT_NAME): Html => + h.span( + [h.Class("oc-agent-badge"), h.DataAttribute("slot", "agent-badge")], + [h.span([h.Class("oc-agent-dot"), h.AriaHidden(true)], []), text], + ) diff --git a/apps/web/src/components/ui/input-group.ts b/apps/web/src/components/ui/input-group.ts index 9abc820c..c38d702b 100644 --- a/apps/web/src/components/ui/input-group.ts +++ b/apps/web/src/components/ui/input-group.ts @@ -8,16 +8,16 @@ import { textareaClass } from "@/components/ui/textarea" type Child = Html | string export const inputGroupClass = - "border-outline bg-card has-[[data-slot=input-group-control]:focus-visible]:bg-popover has-[[data-slot][aria-invalid=true]]:border-destructive h-8 rounded-sm border-2 shadow-[inset_0_2px_0_rgb(18_34_92/0.1)] transition-colors in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:opacity-50 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 group/input-group relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto" + "border-border bg-surface-muted has-[[data-slot=input-group-control]:focus-visible]:border-primary has-[[data-slot=input-group-control]:focus-visible]:bg-card has-[[data-slot=input-group-control]:focus-visible]:outline-2 has-[[data-slot=input-group-control]:focus-visible]:outline-ring has-[[data-slot=input-group-control]:focus-visible]:outline-offset-1 has-[[data-slot][aria-invalid=true]]:border-destructive h-7 rounded-sm border shadow-none transition-colors duration-120 ease-ui in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:opacity-50 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 group/input-group relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto" export const inputGroupAddonClass = - "text-muted-foreground h-auto gap-2 py-1.5 text-sm font-medium group-data-[disabled]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4 flex cursor-text items-center justify-center select-none" + "text-muted-foreground h-auto gap-2 py-1 text-body-sm font-medium group-data-[disabled]/input-group:opacity-50 [&>kbd]:rounded-xs [&>svg:not([class*='size-'])]:size-3.5 flex cursor-text items-center justify-center select-none" export type InputGroupAddonAlign = "inline-start" | "inline-end" | "block-start" | "block-end" export const inputGroupAddonAlignClasses = { - "inline-start": "pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem] order-first", - "inline-end": "pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem] order-last", + "inline-start": "pl-2 has-[>button]:-ml-1 has-[>kbd]:-ml-0.5 order-first", + "inline-end": "pr-2 has-[>button]:-mr-1 has-[>kbd]:-mr-0.5 order-last", "block-start": "px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2 order-first w-full justify-start", "block-end": @@ -33,13 +33,13 @@ export const inputGroupButtonSizeKeys: ReadonlyArray = [ "icon-sm", ] -export const inputGroupButtonClass = "gap-2 text-sm flex items-center shadow-none" +export const inputGroupButtonClass = "gap-2 text-body-sm flex items-center shadow-none" export const inputGroupButtonSizeClasses = { xs: "h-6 gap-1 rounded-xs px-1.5 [&>svg:not([class*='size-'])]:size-3.5", sm: "gap-1", "icon-xs": "size-6 rounded-xs p-0 has-[>svg]:p-0", - "icon-sm": "size-8 p-0 has-[>svg]:p-0", + "icon-sm": "size-6 p-0 has-[>svg]:p-0", } as const satisfies Record export type InputGroupTextareaConfig = { @@ -56,13 +56,13 @@ export type InputGroupTextareaConfig = { } export const inputGroupTextareaClass = - "rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent flex-1 resize-none" + "rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 focus-visible:outline-none disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent flex-1 resize-none" export const inputGroupTextClass = - "text-muted-foreground gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none" + "text-muted-foreground gap-2 text-body-sm [&_svg:not([class*='size-'])]:size-3.5 flex items-center [&_svg]:pointer-events-none" export const inputGroupInputClass = - "rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent flex-1" + "rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 focus-visible:outline-none disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent flex-1" export const inputGroupTextarea = ( h: HtmlBuilder, diff --git a/apps/web/src/components/ui/input.ts b/apps/web/src/components/ui/input.ts index cc2916b2..1eca6846 100644 --- a/apps/web/src/components/ui/input.ts +++ b/apps/web/src/components/ui/input.ts @@ -2,9 +2,10 @@ import * as FoldkitInput from "@foldkit/ui/input" import type { Attribute, ChildAttribute, Html, HtmlBuilder } from "foldkit/html" import { cn } from "@/lib/utils" -/** Recessed control: cream well, 2px outline, inset lip, brightens on focus. */ +/** Input: surface-muted fill, 1px border, 28px tall, brightens to surface + * and takes a primary border on focus. */ export const inputClass = - "border-outline aria-invalid:border-destructive h-8 rounded-sm border-2 bg-card text-card-foreground shadow-[inset_0_2px_0_rgb(18_34_92/0.1)] px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:bg-popover md:text-sm w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50" + "h-7 w-full min-w-0 rounded-sm border border-border bg-surface-muted px-2 py-1 text-body-md text-foreground transition-colors duration-120 ease-ui placeholder:text-muted-foreground focus-visible:border-primary focus-visible:bg-card aria-invalid:border-destructive file:inline-flex file:h-5 file:border-0 file:bg-transparent file:text-body-sm file:font-medium file:text-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50" /** Same string as the `label` item's component classes (upstream label.tsx). */ /** Upstream string re-keyed for foldkit: the label precedes the control, so @@ -12,9 +13,10 @@ export const inputClass = * state flows from the wrapper (group/field + data-disabled, mirroring * switch.ts). */ export const inputLabelClass = - "gap-2 text-label leading-none font-semibold group-data-[disabled]:opacity-50 flex items-center select-none group-data-[disabled]/field:pointer-events-none group-data-[disabled]/field:cursor-not-allowed group-data-[disabled]/field:opacity-50" + "gap-2 text-label leading-none font-medium group-data-[disabled]:opacity-50 flex items-center select-none group-data-[disabled]/field:pointer-events-none group-data-[disabled]/field:cursor-not-allowed group-data-[disabled]/field:opacity-50" -export const inputDescriptionClass = "text-sm text-muted-foreground" +/** Help text sits under its label at foreground-muted, never in a tooltip. */ +export const inputDescriptionClass = "text-body-sm text-ink-muted" export const inputWrapperClass = "group/field flex flex-col gap-1.5 w-full" diff --git a/apps/web/src/components/ui/inspector.ts b/apps/web/src/components/ui/inspector.ts new file mode 100644 index 00000000..0515d4ac --- /dev/null +++ b/apps/web/src/components/ui/inspector.ts @@ -0,0 +1,36 @@ +import type { Html, HtmlBuilder } from "foldkit/html" +import { cn } from "@/lib/utils" + +/** Inspector: a stack of bordered sections with caption headings. Values + * are machine truth in mono; keys sit in a fixed 88px column. */ +export const section = ( + h: HtmlBuilder, + config: { + readonly heading: string + readonly className?: string + readonly children: ReadonlyArray + }, +): Html => + h.section( + [h.Class(cn(config.className)), h.DataAttribute("slot", "inspector-section")], + [h.h3([h.DataAttribute("slot", "inspector-heading")], [config.heading]), ...config.children], + ) + +export const row = (h: HtmlBuilder, key: string, value: Html | string): Html => + h.div( + [h.DataAttribute("slot", "inspector-row")], + [ + h.span([h.DataAttribute("slot", "inspector-key")], [key]), + h.span([h.DataAttribute("slot", "inspector-value")], [value]), + ], + ) + +/** Same row, but the value is prose a human wrote, so it stays in Inter. */ +export const proseRow = (h: HtmlBuilder, key: string, value: Html | string): Html => + h.div( + [h.DataAttribute("slot", "inspector-row")], + [ + h.span([h.DataAttribute("slot", "inspector-key")], [key]), + h.span([h.Class("text-body-sm text-foreground")], [value]), + ], + ) diff --git a/apps/web/src/components/ui/mark.ts b/apps/web/src/components/ui/mark.ts index 53d5176f..3f007227 100644 --- a/apps/web/src/components/ui/mark.ts +++ b/apps/web/src/components/ui/mark.ts @@ -4,7 +4,7 @@ import { cn } from "@/lib/utils" /** Small bordered squares carrying mono letters. No logos, no props. */ const markClass = - "inline-flex shrink-0 items-center justify-center rounded-sm border-2 border-outline bg-popover font-mono font-semibold text-foreground" + "inline-flex shrink-0 items-center justify-center rounded-xs border border-border bg-surface-muted font-mono font-medium text-foreground" export const platformMark = ( h: HtmlBuilder, @@ -12,7 +12,7 @@ export const platformMark = ( className?: string, ): Html => h.span( - [h.Class(cn(markClass, "size-[30px] text-mono-sm", className)), h.AriaHidden(true)], + [h.Class(cn(markClass, "size-7 text-mono-sm", className)), h.AriaHidden(true)], [platform === "github" ? "GH" : "SL"], ) @@ -27,6 +27,6 @@ export const initialsOf = (name: string): string => { export const avatar = (h: HtmlBuilder, name: string, className?: string): Html => h.span( - [h.Class(cn(markClass, "size-[34px] text-body-sm", className)), h.AriaHidden(true)], + [h.Class(cn(markClass, "size-8 text-mono-sm", className)), h.AriaHidden(true)], [initialsOf(name)], ) diff --git a/apps/web/src/components/ui/overlay.ts b/apps/web/src/components/ui/overlay.ts new file mode 100644 index 00000000..927e251e --- /dev/null +++ b/apps/web/src/components/ui/overlay.ts @@ -0,0 +1,27 @@ +/** Class strings for headless overlays (menus, popovers, tooltips, toasts). + * Overlays are the only shadowed elements in the system. */ +export const menuButtonClass = + "inline-flex size-7 shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-sm border border-border bg-card text-foreground text-label font-medium transition-colors duration-120 ease-ui hover:bg-surface-muted aria-expanded:bg-surface-muted disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5" + +export const menuItemsClass = + "z-50 min-w-36 overflow-hidden rounded-sm border border-border bg-popover p-1 text-popover-foreground shadow-overlay" + +export const menuItemClass = + "flex cursor-pointer items-center gap-2 rounded-xs px-2 py-1 text-body-sm text-foreground transition-colors duration-120 ease-ui" + +export const menuItemActiveClass = "bg-primary-wash" + +export const menuBackdropClass = "fixed inset-0 z-40" + +export const popoverContentClass = + "z-50 overflow-hidden rounded-sm border border-border bg-popover text-popover-foreground shadow-overlay" + +export const tooltipContentClass = + "z-50 max-w-72 rounded-sm border border-border bg-popover px-2 py-1 text-body-sm text-popover-foreground shadow-overlay" + +export const toastClass = + "pointer-events-auto w-80 rounded-sm border border-border bg-popover px-3 py-2.5 text-body-sm text-popover-foreground shadow-overlay" + +export const toastTitleClass = "font-medium" + +export const toastDescriptionClass = "text-ink-muted" diff --git a/apps/web/src/components/ui/panel.ts b/apps/web/src/components/ui/panel.ts index 9aebe9b3..2a89f3ab 100644 --- a/apps/web/src/components/ui/panel.ts +++ b/apps/web/src/components/ui/panel.ts @@ -1,9 +1,9 @@ import type { Attribute, ChildAttribute, Html, HtmlBuilder } from "foldkit/html" import { cn } from "@/lib/utils" -/** A mounted cream panel: the one raised treatment every object shares. - * `flush` drops the padding so row lists can run edge to edge. */ -export const panelClass = "jn-mount bg-card text-card-foreground" +/** Card: surface, 1px border, 5px radius, no shadow. `flush` drops the + * padding so row lists and tables run edge to edge. Cards do not nest. */ +export const panelClass = "rounded-sm border border-border bg-card text-card-foreground" export type PanelConfig = { readonly flush?: boolean @@ -15,14 +15,56 @@ export type PanelConfig = { export const panel = (h: HtmlBuilder, config: PanelConfig): Html => h.div( [ - h.Class(cn(panelClass, config.flush ? "overflow-hidden" : "p-[18px]", config.className)), - h.DataAttribute("slot", "panel"), + h.Class(cn(panelClass, config.flush ? "overflow-hidden" : "p-4", config.className)), + h.DataAttribute("slot", "card"), ...(config.attributes ?? []), ], config.children, ) -/** Empty state inside a working panel: dashed top edge, faint hatch, one - * sentence naming the action. Never an illustration. */ +export type PanelHeaderConfig = { + readonly title: Html | string + /** Machine metadata, set in mono. */ + readonly meta?: Html | string + readonly actions?: ReadonlyArray + readonly className?: string + readonly attributes?: ReadonlyArray | ChildAttribute> +} + +/** Optional header strip: surface-muted, an h3 title, a mono metadata string, + * and right-aligned actions. Use inside a `flush` panel. */ +export const panelHeader = (h: HtmlBuilder, config: PanelHeaderConfig): Html => + h.div( + [ + h.Class( + cn( + "flex min-h-8 items-center gap-2 border-b border-border bg-surface-muted px-3 py-1.5", + config.className, + ), + ), + h.DataAttribute("slot", "card-header"), + ...(config.attributes ?? []), + ], + [ + h.h3([h.Class("min-w-0 truncate text-h3 font-semibold")], [config.title]), + ...(config.meta === undefined + ? [] + : [ + h.span( + [ + h.Class("min-w-0 truncate font-mono text-mono-sm text-ink-subtle"), + h.DataAttribute("slot", "card-meta"), + ], + [config.meta], + ), + ]), + ...(config.actions === undefined + ? [] + : [h.div([h.Class("ml-auto flex shrink-0 items-center gap-1.5")], config.actions)]), + ], + ) + +/** Empty state inside a working panel: one sentence naming the action, plus + * at most one button. Never an illustration. */ export const emptyPanel = (h: HtmlBuilder, config: PanelConfig): Html => - panel(h, { ...config, className: cn("jn-empty", config.className) }) + panel(h, { ...config, className: cn("text-body-sm text-ink-muted", config.className) }) diff --git a/apps/web/src/components/ui/rack.ts b/apps/web/src/components/ui/rack.ts index 55895f13..75a75449 100644 --- a/apps/web/src/components/ui/rack.ts +++ b/apps/web/src/components/ui/rack.ts @@ -1,9 +1,9 @@ import type { Attribute, ChildAttribute, Html, HtmlBuilder } from "foldkit/html" import { cn } from "@/lib/utils" -/** Hook rack: a steel rail with two screw heads and key tags hanging below. - * Navigation you take down and carry. The current tag is yellow and hangs - * straight; `aria-current` carries the state, colour never does alone. */ +/** Section navigation: 26px rows in body-sm. The current item takes the + * primary wash with a 2px primary left edge; `aria-current` carries the + * state, colour never does alone. */ export type RackItem = { readonly href: string readonly label: string @@ -18,34 +18,26 @@ export type RackConfig = { readonly className?: string } -export const rackRailClass = "jn-steel relative mb-3.5 h-3.5 rounded-xs" -const screwClass = "absolute top-0.5 size-1.5 rounded-full bg-navy-deep" +export const rackItemClass = + "flex h-6.5 items-center border-l-2 border-transparent px-2 text-body-sm text-foreground no-underline transition-colors duration-120 ease-ui hover:bg-surface-muted hover:no-underline aria-[current=page]:border-primary aria-[current=page]:bg-primary-wash aria-[current=page]:font-semibold data-[variant=danger]:text-destructive" export const rack = (h: HtmlBuilder, config: RackConfig): Html => - h.div( - [h.Class(cn("flex flex-col", config.className)), h.DataAttribute("slot", "rack")], + h.nav( [ - h.div( - [h.Class(rackRailClass), h.AriaHidden(true)], + h.AriaLabel(config.label), + h.Class(cn("flex flex-row flex-wrap gap-1 md:flex-col md:gap-0.5", config.className)), + h.DataAttribute("slot", "section-nav"), + ], + config.items.map((item) => + h.a( [ - h.span([h.Class(cn(screwClass, "left-3"))], []), - h.span([h.Class(cn(screwClass, "right-3"))], []), + h.Href(item.href), + h.Class(rackItemClass), + h.AriaCurrent(item.isCurrent ? "page" : "false"), + ...(item.variant === "danger" ? [h.DataAttribute("variant", "danger")] : []), + ...(item.attributes ?? []), ], + [item.label], ), - h.nav( - [h.AriaLabel(config.label), h.Class("flex flex-row flex-wrap gap-3 px-1 md:flex-col")], - config.items.map((item) => - h.a( - [ - h.Href(item.href), - h.Class("jn-tag text-body-sm"), - h.AriaCurrent(item.isCurrent ? "page" : "false"), - ...(item.variant === "danger" ? [h.DataAttribute("variant", "danger")] : []), - ...(item.attributes ?? []), - ], - [item.label], - ), - ), - ), - ], + ), ) diff --git a/apps/web/src/components/ui/select.ts b/apps/web/src/components/ui/select.ts new file mode 100644 index 00000000..6b7a1504 --- /dev/null +++ b/apps/web/src/components/ui/select.ts @@ -0,0 +1,83 @@ +import * as FoldkitSelect from "@foldkit/ui/select" +import type { Html, HtmlBuilder } from "foldkit/html" +import { cn } from "@/lib/utils" +import { inputLabelClass, inputDescriptionClass, inputWrapperClass } from "./input" + +/** Native select styled like an input: surface-muted fill, 1px border, 28px. */ +export const selectClass = + "h-7 w-full min-w-0 appearance-none rounded-sm border border-border bg-surface-muted pr-7 pl-2 text-body-md text-foreground transition-colors duration-120 ease-ui focus-visible:border-primary focus-visible:bg-card aria-invalid:border-destructive disabled:cursor-not-allowed disabled:opacity-50" + +export type SelectConfig = { + readonly id: string + readonly label: string + readonly description?: string + readonly value: string + readonly options: ReadonlyArray + readonly isDisabled?: boolean + readonly isLabelHidden?: boolean + readonly className?: string + readonly wrapperClass?: string + readonly onChange: (value: string) => M +} + +const chevron = (h: HtmlBuilder): Html => + h.svg( + [ + h.AriaHidden(true), + h.Class( + "pointer-events-none absolute right-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground", + ), + h.Xmlns("http://www.w3.org/2000/svg"), + h.Fill("none"), + h.ViewBox("0 0 24 24"), + h.StrokeWidth("1.5"), + h.Stroke("currentColor"), + h.StrokeLinecap("round"), + h.StrokeLinejoin("round"), + ], + [h.path([h.Attribute("d", "m6 9 6 6 6-6")])], + ) + +export const view = (h: HtmlBuilder, config: SelectConfig): Html => + FoldkitSelect.view( + { + id: config.id, + value: config.value, + ...(config.isDisabled === undefined ? {} : { isDisabled: config.isDisabled }), + onChange: config.onChange, + toView: (attributes) => + h.div( + [h.Class(cn(inputWrapperClass, config.wrapperClass))], + [ + h.label( + [ + ...attributes.label, + h.DataAttribute("slot", "label"), + h.Class(cn(inputLabelClass, config.isLabelHidden && "sr-only")), + ], + [config.label], + ), + h.div( + [h.Class("relative")], + [ + h.select( + [ + ...attributes.select, + h.DataAttribute("slot", "select-trigger"), + h.Class(cn(selectClass, config.className)), + ], + config.options.map(([value, text]) => + h.option([h.Value(value), h.Selected(value === config.value)], [text]), + ), + ), + chevron(h), + ], + ), + config.description === undefined + ? h.empty + : h.span([h.Class(inputDescriptionClass)], [config.description]), + ], + ), + }, + h, + ) diff --git a/apps/web/src/components/ui/sheet.ts b/apps/web/src/components/ui/sheet.ts index 10b62dd3..0644e158 100644 --- a/apps/web/src/components/ui/sheet.ts +++ b/apps/web/src/components/ui/sheet.ts @@ -63,7 +63,7 @@ export type HeaderConfig = StyleConfig & { readonly children: ReadonlyArray } -const headerClass = "gap-0.5 p-4 flex flex-col" +const headerClass = "gap-0.5 border-b border-border px-3.5 py-3 flex flex-col" export const header = (h: HtmlBuilder, config: HeaderConfig): Html => h.div( @@ -81,7 +81,7 @@ export type TitleConfig = StyleConfig & { readonly children: ReadonlyArray } -const titleClass = "text-foreground text-base font-medium font-sans" +const titleClass = "text-foreground text-h2 font-semibold" export const title = (h: HtmlBuilder, config: TitleConfig): Html => h.div( @@ -99,7 +99,7 @@ export type DescriptionConfig = StyleConfig & { readonly children: ReadonlyArray } -const descriptionClass = "text-muted-foreground text-sm" +const descriptionClass = "text-ink-muted text-body-sm" export const description = (h: HtmlBuilder, config: DescriptionConfig): Html => h.div( @@ -117,7 +117,7 @@ export type FooterConfig = StyleConfig & { readonly children: ReadonlyArray } -const footerClass = "gap-2 p-4 mt-auto flex flex-col" +const footerClass = "gap-2 border-t border-border px-3.5 py-3 mt-auto flex flex-col" export const footer = (h: HtmlBuilder, config: FooterConfig): Html => h.div( @@ -136,7 +136,7 @@ export type CloseButtonConfig = StyleConfig & { } const closeButtonClass = - "absolute top-3 right-3 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*='size-'])]:size-4 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground size-7 rounded-sm" + "absolute top-2 right-2 inline-flex size-6 items-center justify-center rounded-sm border border-transparent text-muted-foreground transition-colors duration-120 ease-ui hover:bg-surface-muted hover:text-foreground aria-expanded:bg-surface-muted aria-disabled:pointer-events-none aria-disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-3.5" export const closeButton = (h: HtmlBuilder, config: CloseButtonConfig): Html => h.div( @@ -163,19 +163,15 @@ export type StyledViewInputs = { } const backdropClass = - "bg-navy-deep/55 data-enter:opacity-0 data-leave:opacity-0 fixed inset-0 z-50 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0" + "bg-scrim data-enter:opacity-0 data-leave:opacity-0 fixed inset-0 z-50 transition-opacity duration-120 ease-ui data-ending-style:opacity-0 data-starting-style:opacity-0" const motionClass = - "data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem]" + "data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:data-ending-style:translate-y-10 data-[side=bottom]:data-starting-style:translate-y-10 data-[side=left]:data-ending-style:-translate-x-10 data-[side=left]:data-starting-style:-translate-x-10 data-[side=right]:data-ending-style:translate-x-10 data-[side=right]:data-starting-style:translate-x-10 data-[side=top]:data-ending-style:-translate-y-10 data-[side=top]:data-starting-style:-translate-y-10 data-enter:opacity-0 data-leave:opacity-0 data-[side=bottom]:data-enter:translate-y-10 data-[side=bottom]:data-leave:translate-y-10 data-[side=left]:data-enter:-translate-x-10 data-[side=left]:data-leave:-translate-x-10 data-[side=right]:data-enter:translate-x-10 data-[side=right]:data-leave:translate-x-10 data-[side=top]:data-enter:-translate-y-10 data-[side=top]:data-leave:-translate-y-10" -const panelClass = { - top: "bg-card text-card-foreground border-outline fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-mount transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t-[3px] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r-[3px] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l-[3px] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b-[3px] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-enter:opacity-0 data-leave:opacity-0 data-[side=bottom]:data-enter:translate-y-[2.5rem] data-[side=bottom]:data-leave:translate-y-[2.5rem] data-[side=left]:data-enter:translate-x-[-2.5rem] data-[side=left]:data-leave:translate-x-[-2.5rem] data-[side=right]:data-enter:translate-x-[2.5rem] data-[side=right]:data-leave:translate-x-[2.5rem] data-[side=top]:data-enter:translate-y-[-2.5rem] data-[side=top]:data-leave:translate-y-[-2.5rem]", - bottom: - "bg-card text-card-foreground border-outline fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-mount transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t-[3px] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r-[3px] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l-[3px] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b-[3px] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-enter:opacity-0 data-leave:opacity-0 data-[side=bottom]:data-enter:translate-y-[2.5rem] data-[side=bottom]:data-leave:translate-y-[2.5rem] data-[side=left]:data-enter:translate-x-[-2.5rem] data-[side=left]:data-leave:translate-x-[-2.5rem] data-[side=right]:data-enter:translate-x-[2.5rem] data-[side=right]:data-leave:translate-x-[2.5rem] data-[side=top]:data-enter:translate-y-[-2.5rem] data-[side=top]:data-leave:translate-y-[-2.5rem]", - left: "bg-card text-card-foreground border-outline fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-mount transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t-[3px] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r-[3px] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l-[3px] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b-[3px] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-enter:opacity-0 data-leave:opacity-0 data-[side=bottom]:data-enter:translate-y-[2.5rem] data-[side=bottom]:data-leave:translate-y-[2.5rem] data-[side=left]:data-enter:translate-x-[-2.5rem] data-[side=left]:data-leave:translate-x-[-2.5rem] data-[side=right]:data-enter:translate-x-[2.5rem] data-[side=right]:data-leave:translate-x-[2.5rem] data-[side=top]:data-enter:translate-y-[-2.5rem] data-[side=top]:data-leave:translate-y-[-2.5rem]", - right: - "bg-card text-card-foreground border-outline fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-mount transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t-[3px] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r-[3px] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l-[3px] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b-[3px] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-enter:opacity-0 data-leave:opacity-0 data-[side=bottom]:data-enter:translate-y-[2.5rem] data-[side=bottom]:data-leave:translate-y-[2.5rem] data-[side=left]:data-enter:translate-x-[-2.5rem] data-[side=left]:data-leave:translate-x-[-2.5rem] data-[side=right]:data-enter:translate-x-[2.5rem] data-[side=right]:data-leave:translate-x-[2.5rem] data-[side=top]:data-enter:translate-y-[-2.5rem] data-[side=top]:data-leave:translate-y-[-2.5rem]", -} as const satisfies Record +/** Slide-over: surface, 1px border, 8px radius, the overlay shadow, and a + * 120ms user-triggered entry. */ +const panelClass = + "bg-card text-card-foreground border-border fixed z-50 flex flex-col text-body-md shadow-overlay transition duration-120 ease-ui data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:rounded-t-md data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:rounded-r-md data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:rounded-l-md data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:rounded-b-md data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm" export const styledViewInputs = ( h: HtmlBuilder, @@ -198,7 +194,7 @@ export const styledViewInputs = ( ...panel, h.DataAttribute("slot", "sheet-content"), h.DataAttribute("side", side), - h.Class(cn(panelClass[side], motionClass, viewInputs.panelClass)), + h.Class(cn(panelClass, motionClass, viewInputs.panelClass)), ], viewInputs.content(h, { closeButton, title, description }), ), diff --git a/apps/web/src/components/ui/sidebar.ts b/apps/web/src/components/ui/sidebar.ts index a1ce0597..235642be 100644 --- a/apps/web/src/components/ui/sidebar.ts +++ b/apps/web/src/components/ui/sidebar.ts @@ -27,7 +27,7 @@ import { PanelLeft } from "lucide" export const SIDEBAR_KEYBOARD_SHORTCUT = "b" -export const SIDEBAR_WIDTH = "16rem" +export const SIDEBAR_WIDTH = "var(--spacing-sidebar)" export const SIDEBAR_WIDTH_MOBILE = "18rem" @@ -219,27 +219,26 @@ export const sidebarProviderClass = export const sidebarShellClass = "group peer hidden text-sidebar-foreground md:block" export const sidebarMobilePanelClass = - "bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden [--sidebar-width:18rem] w-(--sidebar-width)" + "bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden w-72" export const sidebarContainerClass = - "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex" + "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-120 ease-ui data-[side=left]:left-0 data-[side=right]:right-0 md:flex" export const sidebarInnerContainerClass = - "bg-sidebar group-data-[variant=floating]:ring-sidebar-border group-data-[variant=floating]:rounded-md group-data-[variant=floating]:shadow-mount group-data-[variant=floating]:ring-2 flex size-full flex-col" + "bg-sidebar group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:rounded-sm flex size-full flex-col" export const sidebarContainerVariantClass = { docked: - "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r-2 group-data-[side=right]:border-l-2 border-sidebar-border", - padded: - "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]", + "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l border-sidebar-border", + padded: "p-2", } as const satisfies Record export const sidebarGapClass = - "transition-[width] duration-200 ease-linear relative w-(--sidebar-width) bg-transparent group-data-[collapsible=offcanvas]:w-0 group-data-[side=right]:rotate-180" + "transition-[width] duration-120 ease-ui relative w-(--sidebar-width) bg-transparent group-data-[collapsible=offcanvas]:w-0 group-data-[side=right]:rotate-180" export const sidebarIconWidthClass = { docked: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)", - padded: "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]", + padded: "", } as const satisfies Record export const view = defineView((model, viewInputs, h) => { @@ -348,12 +347,14 @@ export const view = defineView((model, viewInputs, h [ h.div([ h.DataAttribute("slot", "sidebar-gap"), + h.DataAttribute("padding", padding), h.Class(cn(sidebarGapClass, sidebarIconWidthClass[padding])), ]), h.div( [ h.DataAttribute("slot", "sidebar-container"), h.DataAttribute("side", side), + h.DataAttribute("padding", padding), h.Class( cn( sidebarContainerClass, @@ -454,7 +455,7 @@ export type GroupLabelConfig = StyleConfig & { } export const sidebarGroupLabelClass = - "text-muted-foreground ring-sidebar-ring h-8 rounded-sm px-2 font-mono text-mono-sm font-semibold transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 flex shrink-0 items-center outline-hidden [&>svg]:shrink-0" + "text-muted-foreground ring-sidebar-ring h-6 rounded-none px-2 text-caption font-medium transition-[margin,opacity] duration-120 ease-ui group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 flex shrink-0 items-center [&>svg]:shrink-0" export const groupLabel = (h: HtmlBuilder, config: GroupLabelConfig): Html => h.div( @@ -472,7 +473,7 @@ export type GroupActionConfig = StyleConfig & { } export const sidebarGroupActionClass = - "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 w-5 rounded-md p-0 focus-visible:ring-2 [&>svg]:size-4 flex aspect-square items-center justify-center outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 md:after:hidden [&>svg]:shrink-0" + "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 w-5 rounded-sm p-0 focus-visible:ring-2 [&>svg]:size-3.5 flex aspect-square items-center justify-center group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 md:after:hidden [&>svg]:shrink-0" export const groupAction = (h: HtmlBuilder, config: GroupActionConfig): Html => h.button( @@ -490,7 +491,7 @@ export type GroupContentConfig = StyleConfig & { readonly children: ReadonlyArray } -export const sidebarGroupContentClass = "w-full text-sm" +export const sidebarGroupContentClass = "w-full text-body-sm" export const groupContent = (h: HtmlBuilder, config: GroupContentConfig): Html => h.div( @@ -506,7 +507,7 @@ export type MenuConfig = StyleConfig & { readonly children: ReadonlyArray } -export const sidebarMenuClass = "flex w-full min-w-0 flex-col gap-1" +export const sidebarMenuClass = "flex w-full min-w-0 flex-col gap-0.5" export const menu = (h: HtmlBuilder, config: MenuConfig): Html => h.div( @@ -547,18 +548,17 @@ export type MenuButtonConfig = StyleConfig & { } export const sidebarMenuButtonClass = - "ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground gap-2 rounded-sm p-2 text-left text-sm font-medium transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! focus-visible:ring-2 data-active:font-semibold peer/menu-button group/menu-button flex w-full items-center overflow-hidden outline-hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate cursor-pointer" + "ring-sidebar-ring hover:bg-surface-muted active:bg-sidebar-accent data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground data-active:border-primary data-open:hover:bg-surface-muted gap-2 rounded-none border-l-2 border-transparent px-2 text-left text-body-sm font-normal transition-colors duration-120 ease-ui group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-7! group-data-[collapsible=icon]:p-1.5! focus-visible:ring-2 data-active:font-semibold peer/menu-button group/menu-button flex w-full items-center overflow-hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:size-3.5 [&_svg]:shrink-0 [&>span:last-child]:truncate cursor-pointer" export const sidebarMenuButtonVariantClass = { - default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", - outline: - "bg-background hover:bg-sidebar-accent hover:text-sidebar-accent-foreground shadow-[0_0_0_2px_var(--sidebar-border)]", + default: "", + outline: "border border-sidebar-border bg-card rounded-sm", } as const satisfies Record export const sidebarMenuButtonSizeClass = { - default: "h-8 text-sm", - sm: "h-7 text-xs", - lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!", + default: "h-6.5 text-body-sm", + sm: "h-6 text-caption", + lg: "h-10 text-body-sm group-data-[collapsible=icon]:p-0!", } as const satisfies Record export const menuButton = (h: HtmlBuilder, config: MenuButtonConfig): Html => { @@ -592,7 +592,7 @@ export type MenuActionConfig = StyleConfig & { } export const sidebarMenuActionClass = - "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 aspect-square w-5 rounded-sm p-0 peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 focus-visible:ring-2 [&>svg]:size-4 flex items-center justify-center outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 md:after:hidden [&>svg]:shrink-0" + "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1 right-1 aspect-square w-5 rounded-sm p-0 peer-data-[size=default]/menu-button:top-1 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-0.5 focus-visible:ring-2 [&>svg]:size-3.5 flex items-center justify-center group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 md:after:hidden [&>svg]:shrink-0" export const sidebarMenuActionShowOnHoverClass = "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0" @@ -620,7 +620,7 @@ export type MenuBadgeConfig = StyleConfig & { } export const sidebarMenuBadgeClass = - "text-sidebar-foreground peer-hover/menu-button:text-sidebar-accent-foreground peer-data-active/menu-button:text-sidebar-accent-foreground pointer-events-none absolute right-1 h-5 min-w-5 rounded-full px-1 font-mono text-mono-sm font-semibold peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 flex items-center justify-center tabular-nums select-none group-data-[collapsible=icon]:hidden" + "text-ink-subtle pointer-events-none absolute right-2 h-5 min-w-5 px-0.5 font-mono text-mono-xs font-medium peer-data-[size=default]/menu-button:top-0.75 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-0.5 flex items-center justify-end tabular-nums select-none group-data-[collapsible=icon]:hidden" export const menuBadge = (h: HtmlBuilder, config: MenuBadgeConfig): Html => h.div( @@ -636,29 +636,27 @@ export type MenuSkeletonConfig = StyleConfig & { readonly showIcon?: boolean } -export const sidebarMenuSkeletonClass = "h-8 gap-2 rounded-sm px-2 flex items-center" +export const sidebarMenuSkeletonClass = "h-6.5 gap-2 px-2 flex items-center" export const menuSkeleton = (h: HtmlBuilder, config: MenuSkeletonConfig): Html => { - const width = `${Math.floor(Math.random() * 40) + 50}%` return h.div( [ h.DataAttribute("slot", "sidebar-menu-skeleton"), h.DataAttribute("sidebar", "menu-skeleton"), h.Class(cn(sidebarMenuSkeletonClass, config.className)), - h.Style({ "--skeleton-width": width }), ], [ ...(config.showIcon === true ? [ h.div([ h.DataAttribute("sidebar", "menu-skeleton-icon"), - h.Class(cn(skeletonClass, "size-4 rounded-xs")), + h.Class(cn(skeletonClass, "size-3.5")), ]), ] : []), h.div([ h.DataAttribute("sidebar", "menu-skeleton-text"), - h.Class(cn(skeletonClass, "h-4 max-w-(--skeleton-width) flex-1")), + h.Class(cn(skeletonClass, "h-3 w-2/3")), ]), ], ) @@ -669,7 +667,7 @@ export type MenuSubConfig = StyleConfig & { } export const sidebarMenuSubClass = - "border-sidebar-border mx-3.5 translate-x-px gap-1 border-l px-2.5 py-0.5 group-data-[collapsible=icon]:hidden flex min-w-0 flex-col" + "border-border-subtle mx-3.5 translate-x-px gap-0.5 border-l px-2.5 py-0.5 group-data-[collapsible=icon]:hidden flex min-w-0 flex-col" export const menuSub = (h: HtmlBuilder, config: MenuSubConfig): Html => h.ul( @@ -705,7 +703,7 @@ export type MenuSubButtonConfig = StyleConfig & { } export const sidebarMenuSubButtonClass = - "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground h-7 gap-2 rounded-sm px-2 focus-visible:ring-2 data-[size=md]:text-sm data-[size=sm]:text-xs [&>svg]:size-4 flex min-w-0 -translate-x-px items-center overflow-hidden outline-hidden group-data-[collapsible=icon]:hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:shrink-0" + "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground h-6.5 gap-2 rounded-none px-2 focus-visible:ring-2 data-[size=md]:text-body-sm data-[size=sm]:text-caption [&>svg]:size-3.5 flex min-w-0 -translate-x-px items-center overflow-hidden group-data-[collapsible=icon]:hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:shrink-0" export const menuSubButton = (h: HtmlBuilder, config: MenuSubButtonConfig): Html => { const size = config.size ?? "md" @@ -731,12 +729,12 @@ export const input = (h: HtmlBuilder, config: InputConfig): Html => ...config.attributes, h.DataAttribute("slot", "sidebar-input"), h.DataAttribute("sidebar", "input"), - h.Class(cn(inputClass, "bg-background h-8 w-full shadow-none", config.className)), + h.Class(cn(inputClass, "h-7 w-full", config.className)), ]) export type SeparatorConfig = StyleConfig -export const sidebarSeparatorClass = "mx-2 h-px w-auto shrink-0 bg-sidebar-border" +export const sidebarSeparatorClass = "mx-2 h-px w-auto shrink-0 bg-border-subtle" export const separator = (h: HtmlBuilder, config: SeparatorConfig): Html => h.div([ @@ -750,7 +748,7 @@ export type RailConfig = StyleConfig & { } export const sidebarRailClass = - "hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2 in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize [[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar [[data-side=left][data-collapsible=offcanvas]_&]:-right-2 [[data-side=right][data-collapsible=offcanvas]_&]:-left-2" + "hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-0.5 sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2 in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize [[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar [[data-side=left][data-collapsible=offcanvas]_&]:-right-2 [[data-side=right][data-collapsible=offcanvas]_&]:-left-2" export const rail = (h: HtmlBuilder, config: RailConfig): Html => h.button([ @@ -769,7 +767,7 @@ export type TriggerConfig = StyleConfig & { } export const sidebarTriggerClass = - "inline-flex size-7 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-sm border-2 border-outline bg-card text-card-foreground text-sm font-medium cursor-pointer outline-none transition-colors hover:bg-popover disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" + "inline-flex size-7 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-sm border border-border bg-card text-foreground text-label font-medium cursor-pointer transition-colors duration-120 ease-ui hover:bg-surface-muted disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5" export const trigger = (h: HtmlBuilder, config: TriggerConfig): Html => h.button( @@ -788,7 +786,7 @@ export type InsetConfig = StyleConfig & { } export const sidebarInsetClass = - "bg-transparent md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-md md:peer-data-[variant=inset]:shadow-mount md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 relative flex w-full flex-1 flex-col" + "bg-transparent md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-sm md:peer-data-[variant=inset]:border md:peer-data-[variant=inset]:border-border md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 relative flex w-full flex-1 flex-col" export const inset = (h: HtmlBuilder, config: InsetConfig): Html => h.main( diff --git a/apps/web/src/components/ui/sign.ts b/apps/web/src/components/ui/sign.ts index 6e249c1b..22adcba1 100644 --- a/apps/web/src/components/ui/sign.ts +++ b/apps/web/src/components/ui/sign.ts @@ -1,9 +1,8 @@ import type { Attribute, ChildAttribute, Html, HtmlBuilder } from "foldkit/html" import { cn } from "@/lib/utils" -/** Enamel section sign: a cobalt plate with a mounting screw, set in the - * signage face, uppercase. One per section; it sits above its panel. */ -export const signClass = "jn-sign font-sign text-sign font-bold uppercase" +/** Section heading. Sentence case h2, one per section, above its card. */ +export const signClass = "text-h2 font-semibold" export type SignConfig = { readonly id?: string @@ -17,8 +16,8 @@ export const sign = (h: HtmlBuilder, config: SignConfig): Html => [ ...(config.id === undefined ? [] : [h.Id(config.id)]), h.Class(cn(signClass, config.className)), - h.DataAttribute("slot", "sign"), + h.DataAttribute("slot", "section-heading"), ...(config.attributes ?? []), ], - [h.span([h.Class("jn-screw"), h.AriaHidden(true)], []), ...config.children], + config.children, ) diff --git a/apps/web/src/components/ui/skeleton.ts b/apps/web/src/components/ui/skeleton.ts index 14540706..5836723c 100644 --- a/apps/web/src/components/ui/skeleton.ts +++ b/apps/web/src/components/ui/skeleton.ts @@ -8,7 +8,8 @@ export type SkeletonConfig = { readonly children: ReadonlyArray } -export const skeletonClass = "bg-muted rounded-xs animate-pulse" +/** A static surface-muted block. Never a shimmer. */ +export const skeletonClass = "rounded-xs bg-surface-muted" export const skeleton = (h: HtmlBuilder, config: SkeletonConfig): Html => h.div( diff --git a/apps/web/src/components/ui/switch.ts b/apps/web/src/components/ui/switch.ts new file mode 100644 index 00000000..35bf19da --- /dev/null +++ b/apps/web/src/components/ui/switch.ts @@ -0,0 +1,74 @@ +import * as FoldkitSwitch from "@foldkit/ui/switch" +import type { Attribute, ChildAttribute, Html, HtmlBuilder } from "foldkit/html" +import { cn } from "@/lib/utils" + +/** 26×15 switch. Off is a faint track (a non-text mark), on is primary blue. + * The label is a real `
- - -
- -
-
-

You

-
-

- Signed in through Cloudflare Access. The verified issuer and subject are your identity; - email is display only. -

-
-
-
- FO -
-
founder@example.com
-
- AdminMember · signed in through - https://team.cloudflareaccess.test -
-
-
- adminmember -
-
-
-
Subject
-
9c1d4e2a-7b60-4f3e-a2c8-51d0e7f9a3b1
-
Teammate since
-
2 Jun 2026
-
Status
-
Active
-
-
-
- - -
-
-

- Connected accounts -

-
-

- Connected accounts may direct Janitor from Slack and GitHub until you disconnect them, - independently of this browser session. -

- -
-

- Each breaker is one platform account you have proved. On means instructions from that - account are accepted. Flip a breaker off to disconnect; connecting again means proving - the account again. -

- - -
-
-
- GitHub - github.com -
-
-
- Connected as octocat - · user ID - 4242 · - linked 14 Aug 2026 -
-

- GitHub login names may change without changing account ownership. The numeric ID - is the identity. -

-
-
-
No GitHub account connected.
-

- Connect once and Janitor will act on your pull request review feedback without - another request in the home thread. -

-
-
-
- Disconnected 10 Sep 2026 - · was - octocat -
-

- A self-disconnected account still requires linking again. Previously accepted - work keeps its attribution. -

-
-
-
- - - -
-
- - -
-
-
- Slack - T0A1B2C3D -
-
-
- Connected as founder - · member ID - U04ABCD12 - · linked 2 Jun 2026 -
-

- Ordinary replies in a session's home thread are accepted as instructions from - this account. -

-
-
-
No Slack account connected.
-

- Until you connect, mentions of the bot only get an ephemeral reminder to connect - here. -

-
-
-
- Disconnected 10 Sep 2026 - · was - founder -
-

- A self-disconnected account still requires linking again. Shared sessions and - accepted inputs are unaffected. -

-
-
-
Slack account linking is not configured for this deployment.
-

- To enable it, set the Slack OAuth client and the workspace allowlist for this - deployment. -

-
-
-
- - - -
-
-
-
- - -
-
-

Team

-
-

- Removing a teammate disables their connected accounts and sign-in; their accepted work - and attribution are kept. The last active admin cannot be removed or demoted. -

-

- Admins and members have equal control when collaborating with agents. Only an active - admin can manage teammates. -

- -
-
-
- 5 teammates - · 4 active · 1 removed -
- New teammates are admitted as members on first sign-in. -
-
-
- FO -
-
- founder@example.com (you) -
-
- Admin · joined 2 Jun 2026 · Slack: founder · GitHub: - octocat -
-
The last active admin cannot be removed or demoted.
-
- admin -
- - -
-
-
- SE -
-
second@example.com
-
- Member · joined 9 Jun 2026 · Slack: second · GitHub: - second -
-
- member -
- - -
-
-
- TH -
-
third@example.com
-
- Member · joined 21 Jul 2026 · Slack: not connected · GitHub: third -
-
- member -
- - -
-
-
- a1 -
-
- a1f9c0d2-5e77-4b8a-9d13-0c2f6e4b7a90 -
-
- Member · joined 1 Sep 2026 · no email from Access · no accounts connected yet -
-
- member -
- - -
-
-
- IM -
-
impostor@example.com
-
- Member · removed 30 Aug 2026 · Slack: impostor (disabled) · joined - 3 Aug 2026 -
-
- removed -
- -
-
-
-
- -
-

- Only an active admin can manage teammates. Ask an admin if you need a role change. -

-
-
-
-
- - diff --git a/docs/settings-mockups/02-ledger.html b/docs/settings-mockups/02-ledger.html deleted file mode 100644 index 06546e6b..00000000 --- a/docs/settings-mockups/02-ledger.html +++ /dev/null @@ -1,458 +0,0 @@ - - - - - - 02 · Ledger - - - - - - - - -
-
The Janitor
-
founder@example.com
-
- - - -
- - - - -
-
-

Team

-

- Everyone admitted through team sign-in. Roles change immediately. Removing a teammate - disables their connected accounts and sign-in; their accepted work and attribution are - kept. -

-

Only an active admin can manage teammates.

- -
-
-
- Roster - · 4 active · 1 removed -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
TeammateRoleSlackGitHubSinceActions
-
- founder@example.com (you) -
-
- Last active admin · cannot be removed or demoted -
-
- - founderoctocat2 Jun 2026 - -
second@example.com
- - secondsecond9 Jun 2026 - -
third@example.com
- - not connectedthird21 Jul 2026 - -
-
a1f9c0d2-5e77-4b8a-9d13-0c2f6e4b7a90
-
No email from Access; showing the subject
-
- - not connectednot connected1 Sep 2026 - -
-
impostor@example.com
-
Removed 30 Aug 2026 by founder@example.com
-
removedimpostor (disabled)not connected3 Aug 2026
-
- -
-

Only an active admin can manage teammates. Your role is member.

-
-
- -
-

Recent changes

-

- The teammate audit trail. Actions are recorded with the acting admin, the subject and - the time. -

-
-
-
- remove -
founder@example.com removed impostor@example.com
- 30 Aug 2026, 16:02 UTC -
-
- relink -
-
- founder@example.com replaced GitHub founder with - octocat -
-
- 14 Aug 2026, 09:32 UTC -
-
- link -
-
third@example.com connected GitHub third
-
- 21 Jul 2026, 11:47 UTC -
-
- set-role -
-
founder@example.com made second@example.com a member
-
- 9 Jun 2026, 08:15 UTC -
-
- initial-admin -
founder@example.com admitted as the initial admin
- 2 Jun 2026, 14:11 UTC -
-
-
-
-
-
- - diff --git a/docs/settings-mockups/03-clipboard.html b/docs/settings-mockups/03-clipboard.html deleted file mode 100644 index 6967dc5c..00000000 --- a/docs/settings-mockups/03-clipboard.html +++ /dev/null @@ -1,352 +0,0 @@ - - - - - - 03 · Clipboard roster - - - - - - - - -
-
The Janitor
-
- founder@example.com · - adminmember · - team.cloudflareaccess.test -
-
- -
- - -
-
- -
-

GitHub

-
-
-
- GH -
-
octocat
-
github.com · user ID 4242
-
- connected -
-
-
Linked
-
14 Aug 2026, 09:32 UTC
-
Replaced
-
- founder - · 2 Jun – 14 Aug 2026 -
-
-

- Review feedback from this login on agent pull requests is handled automatically. -

-
- -
-
-
-
- GH -
No GitHub account connected.
-
-

- You will be sent to GitHub to authorize the Janitor app. Only your numeric user ID - and login are kept; the token is never stored. -

-
- -
-
-
-
- GH -
-
octocat
-
disconnected 10 Sep 2026
-
- disconnected -
-

- A self-disconnected account still requires linking again. Work accepted before - then keeps its attribution. -

-
- -
-
-
-
- - -
-

Slack

-
-
-
- SL -
-
founder
-
workspace T0A1B2C3D · member ID U04ABCD12
-
- connected -
-
-
Linked
-
2 Jun 2026, 14:20 UTC
-
Bot
-
- @Janitor - · invite it to each private channel that hosts sessions -
-
-

- Replies from this account in a home thread are accepted as instructions. -

-
- -
-
-
-
- SL -
No Slack account connected.
-
-

- Sign in with Slack in workspace T0A1B2C3D. Only your team and member - IDs and display name are kept. -

-
- -
-
-
-
- SL -
-
founder
-
disconnected 10 Sep 2026
-
- disconnected -
-

- Bot mentions from you get an ephemeral reminder to connect until you link again. -

-
- -
-
-
-
- SL -
- Slack account linking is not configured for this deployment. -
-
-

- The deployment needs a Slack OAuth client and a workspace allowlist before anyone - can connect. -

-
-
-
-
-
- - -
-

Team roster

-

- Removing a teammate disables their connected accounts and sign-in; their accepted work - and attribution are kept. The last active admin cannot be removed or demoted. -

-

- Only an active admin can manage teammates. You can see yourself here. -

- -
-
-
-
-
Duty roster
-
- 4 active · 1 removedyou -
-
- -
-
-
- FOfounder@example.com - (you) -
- Since 2 Jun 2026 · Slack founder · GitHub - octocat - The last active admin cannot be removed or demoted. -
-
- adminmember - - -
-
- -
-
-
-
- SEsecond@example.com -
- Since 9 Jun 2026 · Slack second · GitHub - second -
-
- member - - -
-
-
-
-
- THthird@example.com -
- Since 21 Jul 2026 · Slack not connected · GitHub third -
-
- member - - -
-
-
-
-
- a1a1f9c0d2-5e77-4b8a-9d13-0c2f6e4b7a90 -
- Since 1 Sep 2026 · no email from Access · no accounts connected yet -
-
- member - - -
-
- -
-
Removed
-
-
-
- IMimpostor@example.com -
- Removed 30 Aug 2026 · Slack impostor disabled · accepted work - kept -
-
- removed - -
-
-
-
-
-
-
-
-
- - diff --git a/docs/settings-mockups/04-punch-card.html b/docs/settings-mockups/04-punch-card.html deleted file mode 100644 index 3dce2bc3..00000000 --- a/docs/settings-mockups/04-punch-card.html +++ /dev/null @@ -1,363 +0,0 @@ - - - - - - 04 · Punch card - - - - - - - - -
-
The Janitor
-
founder@example.com
-
- -
- - -
- -
-
-
-
founder@example.com
-
- AdminMember · signed in through - https://team.cloudflareaccess.test · teammate since 2 Jun 2026 -
-
-
- All connected - Connect GitHub - Connect Slack - GitHub only -
-
-
-
- 2110connected accounts -
-
- 4active teammates -
-
- 1admin -
-
- 1removed -
-
- 12inputs accepted this week -
-
-
- - -
- -
-

Connected accounts

-

- Connected accounts may direct Janitor from Slack and GitHub until you disconnect them, - independently of this browser session. -

- - - - -
- - -
-

Sign-in identity

-

- Access protects sign-in and account management. Successful account links persist - independently of Access session expiry. -

-
-
Issuer
-
https://team.cloudflareaccess.test
-
Subject
-
9c1d4e2a-7b60-4f3e-a2c8-51d0e7f9a3b1
-
Email
-
founder@example.com · display only
-
Role
-
- AdminMember -
-
-
- - -
-

Team

-

- Removing a teammate disables their connected accounts and sign-in; their accepted work - and attribution are kept. The last active admin cannot be removed or demoted. -

-
-
- FO -
-
- founder@example.com (you) -
-
- Slack founder · GitHub octocat · since 2 Jun 2026 · - last active admin -
-
- admin -
- -
-
-
- SE -
-
second@example.com
-
- Slack second · GitHub second · since 9 Jun 2026 -
-
- member -
- -
-
-
- TH -
-
third@example.com
-
- Slack not connected · GitHub third · since 21 Jul 2026 -
-
- member -
- -
-
-
- a1 -
-
a1f9c0d2-5e77-4b8a-9d13-0c2f6e4b7a90
-
- No email from Access · no accounts connected yet · since 1 Sep 2026 -
-
- member -
- -
-
-
- IM -
-
impostor@example.com
-
- Removed 30 Aug 2026 · Slack impostor disabled · since 3 Aug 2026 -
-
- removed -
-
-
-
-
-

Only an active admin can manage teammates.

-
-
-
-
- - diff --git a/docs/settings-mockups/05-card-grid.html b/docs/settings-mockups/05-card-grid.html deleted file mode 100644 index f5221c0f..00000000 --- a/docs/settings-mockups/05-card-grid.html +++ /dev/null @@ -1,491 +0,0 @@ - - - - - - 05 · Card grid - - - - - - - - -
-
The Janitor
-
- founder@example.com · - adminmember -
-
- -
- - -
- -
-

- Connected accounts -

-

- Every account you have ever proved, newest first. Rows are never deleted: accepted work - keeps pointing at the link that authorized it. -

- - -
- - -
-

Team

-

- The admin breaker on each card changes the role immediately. Removing a teammate - disables their connected accounts and sign-in; their accepted work and attribution are - kept. -

-

Only an active admin can manage teammates.

- -
-
-
- FO -
-
founder@example.com
-
you · since 2 Jun 2026
-
-
- -
-
-
Admin
-
The last active admin cannot be demoted.
-
- -
-
- The last active admin cannot be removed. -
-
- -
-
- SE -
-
second@example.com
-
since 9 Jun 2026
-
-
- -
-
-
Admin
-
- Can change roles and remove or restore teammates. -
-
- -
-
- Member -
-
- -
-
- TH -
-
third@example.com
-
since 21 Jul 2026
-
-
- -
-
-
Admin
-
- Can change roles and remove or restore teammates. -
-
- -
-
- Member -
-
- -
-
- a1 -
-
a1f9c0d2…7a90
-
no email from Access · since 1 Sep 2026
-
-
- -
-
-
Admin
-
- Can change roles and remove or restore teammates. -
-
- -
-
- Member -
-
- -
-
- IM -
-
impostor@example.com
-
removed 30 Aug 2026 · since 3 Aug 2026
-
- removed -
- -
-
-
Admin
-
- Restore the teammate before changing their role. -
-
- -
-
- Accepted work and attribution are kept. -
-
-
- -
-

Only an active admin can manage teammates.

-
-
-
-
- - diff --git a/docs/settings-mockups/07-sidebar-sections.html b/docs/settings-mockups/07-sidebar-sections.html deleted file mode 100644 index a0a58a07..00000000 --- a/docs/settings-mockups/07-sidebar-sections.html +++ /dev/null @@ -1,520 +0,0 @@ - - - - - - 07 · Sidebar sections - - - - - - - - -
- - - -
-
- Account - -
- -
- - - -
- -
-

You

-

How Janitor knows you. Sign-in is handled by Cloudflare Access.

-
-
-
Email
-
founder@example.com
-
Role
-
- adminmember -
-
Teammate since
-
2 Jun 2026
-
-
-
- - -
-

- Connected accounts -

-

Accounts that can give Janitor instructions on your behalf.

-
-
- GH -
-
GitHub
-
- Connected as octocat -
-
Not connected
-
- Disconnected · was octocat -
-
-
- - -
-
-
- SL -
-
Slack
-
- Connected as founder -
-
Not connected
-
- Disconnected · was founder -
-
- Not available in this deployment -
-
-
- - -
-
-
-

- Disconnecting stops new instructions from that account. Work it already contributed - is kept. -

-
- - -
-

Team

-

- Everyone who has signed in. New teammates start as members. -

-

Only admins can manage the team.

- -
-
- FO -
-
founder@example.com (you)
-
-
admin
-
-
- SE -
second@example.com
-
- member -
-
-
- TH -
third@example.com
-
- member -
-
-
- a1 -
-
a1f9c0d2…7a90
-
No email on record
-
-
- member -
-
-
-
- IM -
-
impostor@example.com
-
Removed 30 Aug 2026
-
-
- removed -
-
-
-
- 4 active - -
-
- -
-

Ask an admin if you need a role change or to remove someone.

-
-
-
-
-
-
- - - - diff --git a/docs/settings-mockups/index.html b/docs/settings-mockups/index.html deleted file mode 100644 index c44fa8b1..00000000 --- a/docs/settings-mockups/index.html +++ /dev/null @@ -1,201 +0,0 @@ - - - - - - Settings mockups · The Janitor - - - - - - - -
-
The Janitor · Settings mockups
-
docs/settings-mockups
-
-
-
-

Settings screen, six layouts

-

- Each page covers the same states: GitHub connected, never linked or disconnected; Slack - connected, never linked, disconnected or not configured; and team management as an admin - or as a member. Use the control bar at the top of each page to switch states and between - day and night shift, or the deep links below. -

-

- Data shown is what the account view returns today: issuer, subject, email, role, status, - timestamps, and per-platform links with workspace ID, account ID, display name, status, - linked and ended times. No avatars, no token expiry, no invitations exist in the model, so - none are shown. -

-
- -
-
- 07 · Sidebar sections -

- Grounded in the real app shell. A settings rack on the left switches between Account, - Connected accounts and Team; each section is trimmed to fit one screen. -

- -
-
- 01 · Breaker board -

- Canonical DESIGN.md shell. Connected accounts live on the navy breaker panel as two - switches. Team is a chunky data-row list. -

- -
-
- 02 · Ledger -

- Hook tags along the top. Sticky summary column for you and your accounts; the roster is - a dense table with a role select, plus the audit trail. -

- -
-
- 03 · Clipboard roster -

- One enamel sign per platform, side by side. The team is a duty roster held on the - clipboard, the loud object. -

- -
-
- 04 · Punch card -

- Punch-card hero with tallies and a stamp that names the next action. Everything else is - one tall panel with hairline sections. -

- -
-
- 05 · Card grid -

- Full-width link ledger with replaced and disconnected history. Teammates are cards with - an Admin breaker each. -

- -
-
-
- - diff --git a/docs/settings-mockups/mockup.js b/docs/settings-mockups/mockup.js deleted file mode 100644 index dea79291..00000000 --- a/docs/settings-mockups/mockup.js +++ /dev/null @@ -1,81 +0,0 @@ -// Mockup harness. Reads ?github=&slack=&viewer= (or the hash), shows only the -// elements whose data-when matches, and renders the control bar. Not design. -;(function () { - var DIMS = { - github: ["connected", "never", "disconnected"], - slack: ["connected", "never", "disconnected", "unavailable"], - viewer: ["admin", "member"], - theme: ["day", "night"], - } - var LABELS = { github: "GitHub", slack: "Slack", viewer: "Viewer", theme: "Theme" } - var state = {} - var params = new URLSearchParams(location.search) - Object.keys(DIMS).forEach(function (k) { - var v = params.get(k) - state[k] = DIMS[k].indexOf(v) >= 0 ? v : DIMS[k][0] - }) - - function matches(spec) { - return spec.split(";").every(function (clause) { - var parts = clause.split("=") - if (parts.length !== 2) return true - var key = parts[0].trim() - var allowed = parts[1].split(",").map(function (s) { - return s.trim() - }) - return allowed.indexOf(state[key]) >= 0 - }) - } - function apply() { - document.querySelectorAll("[data-when]").forEach(function (el) { - el.hidden = !matches(el.getAttribute("data-when")) - }) - document.querySelectorAll("[data-checked-when]").forEach(function (el) { - el.checked = matches(el.getAttribute("data-checked-when")) - }) - document.querySelectorAll("[data-disabled-when]").forEach(function (el) { - el.disabled = matches(el.getAttribute("data-disabled-when")) - }) - document.documentElement.setAttribute("data-viewer", state.viewer) - document.documentElement.classList.toggle("dark", state.theme === "night") - } - function navigate() { - var q = new URLSearchParams(state).toString() - history.replaceState(null, "", location.pathname + "?" + q + location.hash) - apply() - } - function bar() { - var b = document.createElement("div") - b.className = "mock-bar" - var title = document.createElement("strong") - title.textContent = document.title + " — mockup controls (not part of the design)" - b.appendChild(title) - Object.keys(DIMS).forEach(function (k) { - var l = document.createElement("label") - l.textContent = LABELS[k] + " " - var s = document.createElement("select") - DIMS[k].forEach(function (v) { - var o = document.createElement("option") - o.value = v - o.textContent = v - o.selected = state[k] === v - s.appendChild(o) - }) - s.addEventListener("change", function () { - state[k] = s.value - navigate() - }) - l.appendChild(s) - b.appendChild(l) - }) - var back = document.createElement("a") - back.href = "index.html" - back.textContent = "All mockups" - b.appendChild(back) - document.body.insertBefore(b, document.body.firstChild) - } - document.addEventListener("DOMContentLoaded", function () { - bar() - apply() - }) -})() diff --git a/docs/settings-mockups/utility-room.css b/docs/settings-mockups/utility-room.css deleted file mode 100644 index 746f06e6..00000000 --- a/docs/settings-mockups/utility-room.css +++ /dev/null @@ -1,1325 +0,0 @@ -/* ============================================================================ - The Janitor — "Utility Room" — mockup stylesheet. - A build-free port of the supplied Tailwind v4 theme (styles.css) and - DESIGN.md. Token values, depth primitives, switch, tag and density rules are - copied verbatim; @utility / @layer blocks become plain classes. - ========================================================================= */ - -:root { - --jn-cobalt: #1e5fd0; - --jn-cobalt-dark: #174ca8; - --jn-cobalt-light: #4a8af0; - - --jn-navy: #12225c; - --jn-navy-deep: #0b1436; - --jn-panel: #0e1b4a; - --jn-inset: #0a1233; - - --jn-yellow: #ffce1b; - --jn-yellow-dark: #e0a800; - - --jn-cream: #f6efdd; - --jn-cream-hi: #fffbf0; - - --jn-wall: #ddd8c9; - --jn-mortar: #c7c1b0; - --jn-board: #b9ae93; - - --jn-rust: #b23a2b; - --jn-rust-dark: #8e2c20; - - --jn-metal: #8e97ae; - --jn-metal-dark: #4c5570; - - --jn-muted-ink: #4a5478; - --jn-muted-panel-ink: #9daad0; - - --jn-edge: 0 3px 0 var(--jn-navy-deep); - --jn-mount: 0 3px 0 var(--jn-navy-deep), 0 6px 14px rgb(11 20 54 / 0.28); - --jn-recess: inset 0 3px 6px rgb(11 20 54 / 0.55); - --jn-ease: cubic-bezier(0.3, 1.6, 0.6, 1); - - --jn-wall-image: url("data:image/svg+xml;utf8,"); - --jn-noise-image: url("data:image/svg+xml;utf8,"); - - --radius-xs: 3px; - --radius-sm: 4px; - --radius-md: 6px; - --radius-lg: 14px; - - --font-sans: "Archivo", ui-sans-serif, system-ui, sans-serif; - --font-sign: "Big Shoulders Display", "Archivo Narrow", sans-serif; - --font-mono: "Spline Sans Mono", ui-monospace, "SFMono-Regular", monospace; - - --text-body-sm: 0.8125rem; - --text-body-md: 0.9375rem; - --text-label: 0.90625rem; - --text-button: 0.875rem; - --text-h3: 1rem; - --text-h2: 1.25rem; - --text-sign: 1.5625rem; - --text-numeral: 1.6875rem; - --text-stamp: 2.125rem; - --text-display: 2.75rem; - --text-mono-sm: 0.6875rem; - --text-mono-md: 0.90625rem; - - --sp-xs: 4px; - --sp-sm: 8px; - --sp-md: 12px; - --sp-lg: 18px; - --sp-xl: 26px; - --sp-2xl: 40px; - --sp-3xl: 64px; - - --ring: var(--jn-yellow); -} - -/* --- Base ------------------------------------------------------------------ */ -*, -*::before, -*::after { - box-sizing: border-box; -} -html { - -webkit-text-size-adjust: 100%; -} -body { - margin: 0; - min-height: 100vh; - background: var(--jn-wall); - background-image: var(--jn-wall-image); - color: var(--jn-navy); - font-family: var(--font-sans); - font-size: var(--text-body-md); - line-height: 1.5; - -webkit-font-smoothing: antialiased; -} -body::before { - content: ""; - position: fixed; - inset: 0; - z-index: 0; - pointer-events: none; - opacity: 0.05; - background-image: var(--jn-noise-image); -} -:focus-visible { - outline: 3px solid var(--ring); - outline-offset: 2px; -} -h1, -h2, -h3 { - margin: 0; - font-weight: 600; - text-wrap: balance; -} -p { - margin: 0; - max-width: 62ch; -} -code, -kbd, -pre, -samp { - font-family: var(--font-mono); -} -a { - color: var(--jn-cobalt-dark); -} -button { - font: inherit; - color: inherit; - cursor: pointer; -} -select, -input { - font: inherit; - color: inherit; -} -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - transition-duration: 0.01ms !important; - animation-duration: 0.01ms !important; - } -} - -/* --- Typography helpers ----------------------------------------------------- */ -.t-display { - font-family: var(--font-sign); - font-size: var(--text-display); - font-weight: 800; - line-height: 1; - letter-spacing: 0.01em; -} -.t-sign { - font-family: var(--font-sign); - font-size: var(--text-sign); - font-weight: 700; - line-height: 1.1; - letter-spacing: 0.09em; - text-transform: uppercase; -} -.t-stamp { - font-family: var(--font-sign); - font-size: var(--text-stamp); - font-weight: 800; - line-height: 1.15; - letter-spacing: 0.09em; - text-transform: uppercase; -} -.t-h2 { - font-family: var(--font-sans); - font-size: var(--text-h2); - font-weight: 700; - line-height: 1.3; - letter-spacing: -0.005em; -} -.t-h3 { - font-family: var(--font-sans); - font-size: var(--text-h3); - font-weight: 600; - line-height: 1.35; -} -.t-label { - font-family: var(--font-sans); - font-size: var(--text-label); - font-weight: 600; - line-height: 1.4; -} -.t-body-sm { - font-family: var(--font-sans); - font-size: var(--text-body-sm); - font-weight: 400; - line-height: 1.45; -} -.t-body-md { - font-family: var(--font-sans); - font-size: var(--text-body-md); - font-weight: 400; - line-height: 1.5; -} -.t-numeral { - font-family: var(--font-mono); - font-size: var(--text-numeral); - font-weight: 600; - line-height: 1.1; -} -.t-mono-md { - font-family: var(--font-mono); - font-size: var(--text-mono-md); - font-weight: 600; - line-height: 1.4; -} -.t-mono-sm { - font-family: var(--font-mono); - font-size: var(--text-mono-sm); - font-weight: 600; - line-height: 1.3; - letter-spacing: 0.02em; -} -.muted { - color: var(--jn-muted-ink); -} -.rust { - color: var(--jn-rust); -} - -/* --- Primitives (from @utility) -------------------------------------------- */ -.jn-mount { - border: 3px solid var(--jn-navy); - border-radius: var(--radius-md); - box-shadow: var(--jn-mount); -} -.jn-recess { - box-shadow: var(--jn-recess); -} -.jn-sign { - display: inline-flex; - align-items: center; - gap: 12px; - padding: 8px 20px 9px; - color: var(--jn-cream-hi); - background: linear-gradient( - 180deg, - var(--jn-cobalt-light) 0%, - var(--jn-cobalt) 22%, - var(--jn-cobalt-dark) 100% - ); - border: 3px solid var(--jn-navy-deep); - border-radius: var(--radius-md); - box-shadow: - inset 0 0 0 2px rgb(246 239 221 / 0.9), - var(--jn-mount); -} -.jn-screw { - flex: none; - width: 7px; - height: 7px; - border-radius: 9999px; - background: rgb(246 239 221 / 0.75); -} -.jn-press { - box-shadow: var(--jn-edge); - transition: - transform 80ms, - box-shadow 80ms; -} -.jn-press:active { - transform: translateY(3px); - box-shadow: 0 0 0 currentColor; -} -.jn-hazard { - height: 16px; - border-bottom: 3px solid var(--jn-navy); - background: repeating-linear-gradient(45deg, var(--jn-yellow) 0 14px, var(--jn-navy) 14px 28px); -} -.jn-steel { - background: linear-gradient(180deg, #d2d8e6, var(--jn-metal) 55%, var(--jn-metal-dark)); - border: 2px solid var(--jn-navy-deep); -} -.jn-perforated { - background: - radial-gradient(circle at 10px 0, transparent 5px, var(--jn-cream-hi) 5.5px) 0 0 / 20px 12px - repeat-x, - var(--jn-cream-hi); -} -.jn-empty { - border-top: 2px dashed rgb(18 34 92 / 0.3); - background: repeating-linear-gradient(135deg, transparent 0 9px, rgb(18 34 92 / 0.05) 9px 18px); -} -.jn-agent { - border-left: 3px solid var(--jn-cobalt-light); -} - -/* --- Density switch (verbatim) ---------------------------------------------- */ -.jn-dense { - --jn-row-py: 8px; -} -.jn-dense [data-slot="row"] { - padding-block: var(--jn-row-py); - border-bottom: 1px solid var(--jn-mortar); - box-shadow: none; -} -.jn-dense [data-slot="row"]:last-child { - border-bottom: 0; -} -.jn-dense [data-slot="row"] :is(code, .font-mono, .t-mono-md) { - font-size: var(--text-body-sm); -} - -/* --- Breaker switch (verbatim) --------------------------------------------- */ -.jn-switch { - position: relative; - flex: none; - width: 70px; - height: 36px; - cursor: pointer; -} -.jn-switch input { - position: absolute; - inset: 0; - z-index: 2; - margin: 0; - opacity: 0; - cursor: inherit; -} -.jn-switch-track { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: flex-end; - padding-inline: 9px; - border: 2px solid var(--jn-navy-deep); - border-radius: var(--radius-sm); - background: var(--jn-inset); - box-shadow: var(--jn-recess); - color: #606c93; - font-family: var(--font-mono); - font-size: var(--text-mono-sm); - font-weight: 600; -} -.jn-switch-knob { - position: absolute; - top: 3px; - left: 3px; - width: 32px; - height: 26px; - border: 2px solid var(--jn-navy-deep); - border-radius: var(--radius-xs); - background: linear-gradient(180deg, var(--jn-cream-hi), #dcd3bc); - box-shadow: 0 2px 0 rgb(0 0 0 / 0.4); - transition: transform 130ms var(--jn-ease); -} -.jn-switch-knob::after { - content: ""; - position: absolute; - inset: 5px 8px; - background: repeating-linear-gradient(90deg, rgb(18 34 92 / 0.32) 0 1.5px, transparent 1.5px 4px); -} -.jn-switch input:checked ~ .jn-switch-track { - justify-content: flex-start; - background: var(--jn-yellow); - color: var(--jn-navy); -} -.jn-switch input:checked ~ .jn-switch-knob { - transform: translateX(31px); -} -.jn-switch input:focus-visible ~ .jn-switch-track { - outline: 3px solid var(--ring); - outline-offset: 3px; -} -.jn-switch:has(input:disabled) { - opacity: 0.5; - cursor: not-allowed; -} -@media (width < 620px) { - .jn-switch { - width: 56px; - height: 30px; - } - .jn-switch-knob { - width: 26px; - height: 22px; - } - .jn-switch input:checked ~ .jn-switch-knob { - transform: translateX(24px); - } -} - -/* --- Hook-rack tag (verbatim) ---------------------------------------------- */ -.jn-tag { - display: flex; - align-items: center; - gap: 9px; - padding: 9px 12px 10px; - color: var(--jn-navy); - background: var(--jn-cream); - border: 2px solid var(--jn-navy); - border-radius: 4px 4px 14px 14px; - box-shadow: 0 3px 0 rgb(11 20 54 / 0.22); - font-weight: 600; - font-size: var(--text-label); - text-decoration: none; - transform: rotate(-0.9deg); - transform-origin: 22px -6px; - transition: - transform 160ms ease, - background 160ms ease; -} -.jn-tag::before { - content: ""; - flex: none; - width: 9px; - height: 9px; - border: 2px solid currentColor; - border-radius: 9999px; - background: var(--jn-wall); -} -.jn-tag:hover { - transform: rotate(0deg) translateY(-1px); -} -.jn-tag[aria-current] { - background: var(--jn-yellow); - box-shadow: 0 3px 0 var(--jn-yellow-dark); - transform: rotate(0deg); -} -.jn-tag[data-variant="danger"] { - color: var(--jn-rust); - border-color: var(--jn-rust); -} - -/* --- Components (from @layer components) ------------------------------------ */ -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - padding: 9px 17px 10px; - border: 2.5px solid var(--jn-navy); - border-radius: var(--radius-sm); - background: var(--jn-cream); - color: var(--jn-navy); - box-shadow: var(--jn-edge); - font-family: var(--font-sans); - font-size: var(--text-button); - font-weight: 700; - line-height: 1.2; - text-decoration: none; - white-space: nowrap; - transition: - transform 80ms, - box-shadow 80ms, - background-color 120ms; -} -.btn:hover { - background: var(--jn-cream-hi); -} -.btn:active:not(:disabled) { - transform: translateY(3px); - box-shadow: none; -} -.btn[data-variant="primary"] { - background: var(--jn-yellow); -} -.btn[data-variant="primary"]:hover { - background: var(--jn-yellow-dark); -} -.btn[data-variant="destructive"] { - background: var(--jn-rust); - color: var(--jn-cream-hi); - border-color: var(--jn-rust-dark); - box-shadow: 0 3px 0 var(--jn-rust-dark); -} -.btn[data-variant="destructive"]:hover { - background: var(--jn-rust-dark); - color: var(--jn-cream-hi); -} -.btn[data-variant="ghost"] { - border-style: dashed; - box-shadow: none; - background: transparent; -} -.btn[data-variant="ghost"]:active { - transform: none; -} -.btn[data-size="sm"] { - padding: 6px 12px 7px; - font-size: var(--text-body-sm); -} -.btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} -.btn:disabled:active { - transform: none; - box-shadow: var(--jn-edge); -} -.btn-link { - background: none; - border: 0; - padding: 0; - color: var(--jn-cobalt-dark); - font-weight: 700; - font-size: var(--text-button); - text-decoration: underline; - text-underline-offset: 3px; -} - -.chip { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 3px 9px; - border: 2px solid var(--jn-navy); - border-radius: 9999px; - background: var(--jn-mortar); - color: var(--jn-navy); - font-family: var(--font-mono); - font-size: var(--text-mono-sm); - font-weight: 600; - letter-spacing: 0.02em; - white-space: nowrap; -} -.chip[data-variant="on"] { - background: var(--jn-yellow); - border-color: var(--jn-yellow-dark); -} -.chip[data-variant="danger"] { - background: var(--jn-cream); - color: var(--jn-rust); - border-color: var(--jn-rust); -} -.chip[data-variant="agent"] { - background: var(--jn-cobalt-light); - color: var(--jn-navy-deep); - border-color: var(--jn-cobalt-dark); -} - -.input, -.select { - display: inline-block; - padding: 8px 11px; - border: 2px solid var(--jn-navy); - border-radius: var(--radius-sm); - background: var(--jn-cream); - color: var(--jn-navy); - box-shadow: inset 0 2px 0 rgb(18 34 92 / 0.1); - font-family: var(--font-sans); - font-size: var(--text-body-md); -} -.select { - padding-right: 30px; - appearance: none; - background-image: url("data:image/svg+xml;utf8,"); - background-repeat: no-repeat; - background-position: right 10px center; -} -.input:focus, -.select:focus { - background: var(--jn-cream-hi); -} - -.checkbox { - width: 22px; - height: 22px; - border: 2.5px solid var(--jn-navy); - border-radius: var(--radius-xs); - background: var(--jn-cream); - box-shadow: inset 0 2px 0 rgb(18 34 92 / 0.12); -} - -.separator { - height: 1px; - background: rgb(18 34 92 / 0.14); -} - -/* --- Shell / layout ---------------------------------------------------------- */ -.rail { - position: relative; - z-index: 1; - display: flex; - align-items: center; - justify-content: space-between; - gap: 22px; - height: 62px; - padding: 0 22px; - background: var(--jn-navy); - color: var(--jn-cream); - border-bottom: 3px solid var(--jn-navy-deep); - box-shadow: var(--jn-mount); -} -.rail .brand { - display: flex; - align-items: center; - gap: 12px; - font-weight: 700; -} -.rail .brand .mark { - width: 22px; - height: 22px; - border-radius: 9999px; - background: var(--jn-cobalt); - border: 2px solid var(--jn-cream); - box-shadow: inset 0 0 0 2px var(--jn-navy); -} -.rail .who { - display: flex; - align-items: center; - gap: 10px; - font-size: var(--text-body-sm); - color: var(--jn-muted-panel-ink); -} -.rail .who b { - color: var(--jn-cream); - font-weight: 600; -} -.rail .who .dot { - width: 10px; - height: 10px; - border-radius: 9999px; - background: var(--jn-yellow); - border: 2px solid var(--jn-navy-deep); -} - -.shell { - position: relative; - z-index: 1; - display: grid; - grid-template-columns: 212px minmax(0, 1fr); - gap: 34px; - max-width: calc(1180px + 212px + 34px); - padding: 34px; - margin: 0 auto; -} -.rack { - position: sticky; - top: 34px; - align-self: start; -} -.rack-rail { - height: 14px; - border-radius: var(--radius-xs); - position: relative; - margin-bottom: 18px; -} -.rack-rail::before, -.rack-rail::after { - content: ""; - position: absolute; - top: 2px; - width: 6px; - height: 6px; - border-radius: 9999px; - background: var(--jn-navy-deep); -} -.rack-rail::before { - left: 12px; -} -.rack-rail::after { - right: 12px; -} -.rack nav { - display: flex; - flex-direction: column; - gap: 14px; - padding: 0 6px; -} -.content { - min-width: 0; - display: flex; - flex-direction: column; - gap: var(--sp-2xl); -} -@media (width < 880px) { - .shell { - grid-template-columns: 1fr; - gap: 26px; - padding: 22px; - } - .rack { - position: static; - } - .rack nav { - flex-direction: row; - flex-wrap: wrap; - } -} - -.section { - display: flex; - flex-direction: column; - gap: 14px; -} -.section > .lede { - color: var(--jn-muted-ink); - padding-left: 2px; -} -.sign-row { - display: flex; - align-items: center; - gap: 14px; -} - -.panel { - background: var(--jn-cream); - color: var(--jn-navy); - padding: 18px; - border: 3px solid var(--jn-navy); - border-radius: var(--radius-md); - box-shadow: var(--jn-mount); -} -.panel.flush { - padding: 0; - overflow: hidden; -} -.panel.flush > .panel-head { - padding: 14px 18px; - border-bottom: 2px solid var(--jn-navy); - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} -@media (width < 620px) { - .panel, - .jn-mount, - .jn-sign, - .rail { - box-shadow: none; - } -} - -/* Data rows: chunky by default, quiet under .jn-dense */ -.rows { - display: flex; - flex-direction: column; -} -[data-slot="row"] { - display: flex; - align-items: center; - gap: 18px; - padding: 14px 18px; - border-bottom: 2px solid var(--jn-navy); -} -[data-slot="row"]:last-child { - border-bottom: 0; -} -[data-slot="row"] .main { - flex: 1 1 auto; - min-width: 0; - display: flex; - flex-direction: column; - gap: 3px; -} -[data-slot="row"] .meta { - color: var(--jn-muted-ink); - font-size: var(--text-body-sm); - line-height: 1.45; -} -[data-slot="row"] .actions { - display: flex; - align-items: center; - gap: 10px; - flex: none; - flex-wrap: wrap; - justify-content: flex-end; -} -.row-empty { - background: var(--jn-mortar); - padding: 18px; -} -.row-empty.jn-empty { - background-color: var(--jn-cream); -} - -/* Breaker panel */ -.breaker-panel { - background: var(--jn-panel); - color: var(--jn-cream); - padding: 22px; - border: 3px solid var(--jn-navy); - border-radius: var(--radius-md); - box-shadow: var(--jn-mount); -} -.breaker-panel .caption { - color: var(--jn-muted-panel-ink); - font-size: var(--text-body-sm); - line-height: 1.45; -} -.breaker { - display: flex; - align-items: center; - gap: 22px; - padding: 18px 0; - border-top: 1px solid rgb(157 170 208 / 0.28); -} -.breaker:first-of-type { - border-top: 0; - padding-top: 4px; -} -.breaker .main { - flex: 1 1 auto; - min-width: 0; - display: flex; - flex-direction: column; - gap: 4px; -} -.breaker .name { - font-size: var(--text-h3); - font-weight: 600; -} -.breaker-tape { - display: inline-flex; - align-items: center; - padding: 3px 7px; - border-radius: var(--radius-xs); - background: rgb(10 18 51 / 0.8); - color: var(--jn-yellow); - font-family: var(--font-mono); - font-size: var(--text-mono-sm); - font-weight: 600; - letter-spacing: 0.02em; - white-space: nowrap; -} -.breaker-panel .t-mono-md, -.breaker-panel code { - color: var(--jn-cream-hi); -} -.breaker-panel .btn { - border-color: var(--jn-navy-deep); -} - -/* Clipboard */ -.clipboard { - position: relative; - background: var(--jn-board); - padding: 18px; - padding-top: 30px; - border: 3px solid var(--jn-navy); - border-radius: var(--radius-md); - box-shadow: var(--jn-mount); - margin-top: 14px; -} -.clipboard-clip { - position: absolute; - top: -14px; - left: 50%; - width: 110px; - height: 24px; - margin-left: -55px; - border-radius: var(--radius-sm); -} -.clipboard-clip::after { - content: ""; - position: absolute; - left: 50%; - top: -7px; - width: 26px; - height: 12px; - margin-left: -13px; - border: 2px solid var(--jn-navy-deep); - border-bottom: 0; - border-radius: 6px 6px 0 0; - background: var(--jn-metal); -} -.clipboard-sheet { - background: var(--jn-cream-hi); - color: var(--jn-navy); - padding: 20px; - border-radius: var(--radius-xs); - border: 1px solid rgb(18 34 92 / 0.18); -} -.field { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 12px 26px; - padding: 14px 0; - border-bottom: 1px solid rgb(18 34 92 / 0.14); -} -.field:last-child { - border-bottom: 0; -} -.field .label { - flex: 1 1 190px; - min-width: 190px; - display: flex; - flex-direction: column; - gap: 3px; -} -.field .label .help { - color: var(--jn-muted-ink); - font-size: var(--text-body-sm); -} -.field .control { - flex: 0 0 auto; - display: flex; - align-items: center; - gap: 10px; -} - -/* Punch card */ -.punch-card { - position: relative; - padding: 26px; - padding-top: 34px; - border: 3px solid var(--jn-navy); - border-radius: var(--radius-md); - box-shadow: var(--jn-mount); - color: var(--jn-navy); -} -.punch-card::after { - content: ""; - position: absolute; - inset: 9px; - border: 1px dashed rgb(18 34 92 / 0.35); - border-radius: var(--radius-xs); - pointer-events: none; -} -.stamp { - display: inline-block; - padding: 4px 14px; - border: 3px solid var(--jn-rust); - border-radius: var(--radius-sm); - color: var(--jn-rust); - background: var(--jn-cream-hi); - transform: rotate(-3.5deg); -} - -/* Hazard region */ -.hazard-zone { - border: 3px solid var(--jn-navy); - border-radius: var(--radius-md); - box-shadow: var(--jn-mount); - overflow: hidden; - background: var(--jn-cream); -} -.hazard-zone .jn-hazard { - border-radius: 0; -} -.hazard-zone .body { - padding: 18px; -} - -/* Misc helpers */ -.stack { - display: flex; - flex-direction: column; - gap: var(--sp-md); -} -.cluster { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--sp-sm) var(--sp-md); -} -.grid-2 { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 22px; -} -.kv { - display: grid; - grid-template-columns: max-content 1fr; - gap: 6px 18px; - align-items: baseline; -} -.kv dt { - color: var(--jn-muted-ink); - font-size: var(--text-body-sm); -} -.kv dd { - margin: 0; -} -.avatar { - width: 34px; - height: 34px; - border-radius: var(--radius-sm); - border: 2px solid var(--jn-navy); - background: var(--jn-cream-hi); - display: inline-flex; - align-items: center; - justify-content: center; - font-family: var(--font-mono); - font-weight: 600; - font-size: var(--text-body-sm); - flex: none; -} -.platform-mark { - width: 30px; - height: 30px; - border-radius: var(--radius-sm); - border: 2px solid var(--jn-navy); - display: inline-flex; - align-items: center; - justify-content: center; - background: var(--jn-cream-hi); - flex: none; -} -.platform-mark svg { - width: 18px; - height: 18px; - fill: var(--jn-navy); -} -.status-line { - display: flex; - align-items: center; - gap: 10px; -} -.sr-only { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0 0 0 0); - white-space: nowrap; -} -[role="alert"] { - border: 2px solid var(--jn-rust); - border-radius: var(--radius-sm); - background: var(--jn-cream-hi); - color: var(--jn-rust); - padding: 10px 14px; - font-size: var(--text-body-sm); -} -[role="status"].note { - border: 2px dashed rgb(18 34 92 / 0.3); - border-radius: var(--radius-sm); - background: var(--jn-cream-hi); - padding: 10px 14px; - font-size: var(--text-body-sm); - color: var(--jn-muted-ink); -} - -/* --- Mockup harness (not part of the design) -------------------------------- */ -.mock-bar { - position: sticky; - top: 0; - z-index: 50; - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px 18px; - padding: 8px 14px; - background: #1f1f1f; - color: #ddd; - font: - 12px/1.4 ui-sans-serif, - system-ui, - sans-serif; - border-bottom: 1px solid #000; -} -.mock-bar strong { - color: #fff; - font-weight: 600; -} -.mock-bar a { - color: #9cf; -} -.mock-bar label { - display: inline-flex; - align-items: center; - gap: 6px; -} -.mock-bar select { - font: inherit; - background: #333; - color: #fff; - border: 1px solid #555; - border-radius: 3px; - padding: 2px 6px; -} -[data-when] { -} -[data-when][hidden] { - display: none !important; -} - -/* --- Night shift ------------------------------------------------------------ - Replaces the navy-on-navy .dark block from styles.css. The room goes dark, - not blue: warm charcoal walls, cream ink, and the same cobalt, yellow and - rust plates mounted on top. Outlines lift to a warm grey so objects still - read as drawn. */ -.dark { - --nt-wall: #1c1b18; - --nt-mortar: #262420; - --nt-card: #2a2823; - --nt-card-hi: #34312b; - --nt-inset: #14130f; - --nt-line: #5a554b; - --nt-line-strong: #7b7466; - --nt-ink: #f6efdd; - --nt-muted: #a8a08e; - --nt-rust: #e0705c; - --jn-mount: 0 3px 0 #000, 0 6px 14px rgb(0 0 0 / 0.5); - --jn-edge: 0 3px 0 #000; - --jn-wall-image: url("data:image/svg+xml;utf8,"); -} -.dark body { - background-color: var(--nt-wall); - background-image: var(--jn-wall-image); - color: var(--nt-ink); -} -.dark body::before { - opacity: 0.04; -} -.dark a { - color: var(--jn-cobalt-light); -} -.dark .muted, -.dark .kv dt, -.dark [data-slot="row"] .meta, -.dark .field .label .help, -.dark .section > .lede, -.dark .pane > .lede, -.dark .acct .main .sub, -.dark .team-row .sub, -.dark .team-foot, -.dark .you-grid dt, -.dark .sb-label, -.dark .tally .fig .t-body-sm { - color: var(--nt-muted); -} -.dark .rust { - color: var(--nt-rust); -} - -.dark .panel, -.dark .punch-card, -.dark .hazard-zone, -.dark .clipboard-sheet, -.dark .row-empty.jn-empty { - background: var(--nt-card); - color: var(--nt-ink); - border-color: var(--nt-line-strong); -} -.dark .punch-card { - background-image: none; -} -.dark .punch-card::after { - border-color: rgb(246 239 221 / 0.25); -} -.dark .panel.flush > .panel-head, -.dark [data-slot="row"], -.dark .acct, -.dark .team-foot, -.dark .link-row { - border-color: var(--nt-line); -} -.dark .panel.flush > .panel-head, -.dark .team-foot, -.dark .history { - background: var(--nt-card-hi); -} -.dark .jn-dense [data-slot="row"], -.dark .team-row, -.dark .field { - border-color: var(--nt-mortar); -} -.dark .row-empty, -.dark [data-slot="row"][data-removed], -.dark .team-row[style], -.dark .removed-block, -.dark .card[data-removed] { - background: var(--nt-inset) !important; -} -.dark .separator { - background: var(--nt-line); -} - -.dark .btn { - background: var(--nt-card-hi); - color: var(--nt-ink); - border-color: var(--nt-line-strong); -} -.dark .btn:hover { - background: #3d392f; -} -.dark .btn[data-variant="primary"] { - background: var(--jn-yellow); - color: var(--jn-navy); - border-color: var(--jn-yellow-dark); -} -.dark .btn[data-variant="primary"]:hover { - background: var(--jn-yellow-dark); -} -.dark .btn[data-variant="destructive"] { - background: var(--jn-rust); - color: var(--jn-cream-hi); - border-color: var(--jn-rust-dark); - box-shadow: 0 3px 0 var(--jn-rust-dark); -} -.dark .btn[data-variant="destructive"]:hover { - background: var(--jn-rust-dark); -} -.dark .btn-link { - color: var(--jn-cobalt-light); -} -.dark .chip { - background: var(--nt-inset); - color: var(--nt-ink); - border-color: var(--nt-line-strong); -} -.dark .chip[data-variant="on"] { - background: var(--jn-yellow); - color: var(--jn-navy); - border-color: var(--jn-yellow-dark); -} -.dark .chip[data-variant="danger"] { - background: transparent; - color: var(--nt-rust); - border-color: var(--nt-rust); -} -.dark .avatar, -.dark .platform-mark { - background: var(--nt-inset); - color: var(--nt-ink); - border-color: var(--nt-line-strong); -} -.dark .platform-mark svg { - fill: var(--nt-ink); -} -.dark .select, -.dark .input { - background: var(--nt-inset); - color: var(--nt-ink); - border-color: var(--nt-line-strong); - box-shadow: var(--jn-recess); -} -.dark .stamp { - background: var(--nt-inset); - color: var(--nt-rust); - border-color: var(--nt-rust); -} -.dark [role="alert"] { - background: var(--nt-inset); - color: var(--nt-rust); - border-color: var(--nt-rust); -} -.dark [role="status"].note { - background: var(--nt-inset); - color: var(--nt-muted); - border-color: var(--nt-line); -} -.dark .jn-empty { - border-top-color: rgb(246 239 221 / 0.3); - background-image: repeating-linear-gradient( - 135deg, - transparent 0 9px, - rgb(246 239 221 / 0.05) 9px 18px - ); -} -.dark code, -.dark .t-mono-md { - color: var(--nt-ink); -} - -/* Navigation: tags, rail, app sidebar */ -.dark .jn-tag { - background: var(--nt-card); - color: var(--nt-ink); - border-color: var(--nt-line-strong); - box-shadow: 0 3px 0 #000; -} -.dark .jn-tag::before { - background: var(--nt-wall); -} -.dark .jn-tag[aria-current] { - background: var(--jn-yellow); - color: var(--jn-navy); - border-color: var(--jn-yellow-dark); - box-shadow: 0 3px 0 var(--jn-yellow-dark); -} -.dark .rail { - background: var(--nt-inset); - border-bottom-color: #000; -} -.dark .sidebar { - background: var(--nt-wall); - border-right-color: var(--nt-line); -} -.dark .sb-item { - color: var(--nt-ink); -} -.dark .sb-item:hover, -.dark .sb-item[aria-current] { - background: var(--nt-card-hi); -} -.dark .sb-item .ico { - border-color: var(--nt-line-strong); -} -.dark .sb-brand .mark { - border-color: var(--nt-ink); - box-shadow: inset 0 0 0 2px var(--nt-wall); -} -.dark .sb-switcher { - background: var(--nt-card); - color: var(--nt-ink); - border-color: var(--nt-line-strong); -} -.dark .inset-head { - background: var(--nt-card); - color: var(--nt-ink); - border-bottom-color: var(--nt-line); -} -.dark .inset-head .tools span { - background: var(--nt-card-hi); - border-color: var(--nt-line-strong); -} - -/* Objects that keep their day colours: enamel signs, breaker panel, clipboard - board, hazard stripes, switches. They are painted plates, not surfaces. */ -.dark .jn-sign { - border-color: #000; -} -.dark .breaker-panel { - border-color: var(--nt-line-strong); -} -.dark .clipboard { - border-color: var(--nt-line-strong); -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3affc669..285cf79e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -244,13 +244,10 @@ catalogs: '@foldkit/vite-plugin': specifier: ^0.20.1 version: 0.20.1 - '@fontsource-variable/archivo': + '@fontsource-variable/inter': specifier: ^5.3.0 version: 5.3.0 - '@fontsource-variable/big-shoulders-display': - specifier: ^5.3.0 - version: 5.3.0 - '@fontsource-variable/spline-sans-mono': + '@fontsource-variable/jetbrains-mono': specifier: ^5.3.0 version: 5.3.0 '@lezer/common': @@ -289,9 +286,6 @@ catalogs: tailwindcss: specifier: ^4.3.3 version: 4.3.3 - tw-animate-css: - specifier: ^1.4.0 - version: 1.4.0 typescript: specifier: ^7.0.2 version: 7.0.2 @@ -429,13 +423,10 @@ importers: '@foldkit/ui': specifier: 'catalog:' version: 0.157.0(effect@https://pkg.pr.new/Effect-TS/effect/effect@ec83cbdb3c0b0f89df627a7895939a359db9f4ab)(foldkit@0.157.0(effect@https://pkg.pr.new/Effect-TS/effect/effect@ec83cbdb3c0b0f89df627a7895939a359db9f4ab)) - '@fontsource-variable/archivo': + '@fontsource-variable/inter': specifier: 'catalog:' version: 5.3.0 - '@fontsource-variable/big-shoulders-display': - specifier: 'catalog:' - version: 5.3.0 - '@fontsource-variable/spline-sans-mono': + '@fontsource-variable/jetbrains-mono': specifier: 'catalog:' version: 5.3.0 '@janitor/domain': @@ -481,9 +472,6 @@ importers: tailwindcss: specifier: 'catalog:' version: 4.3.3 - tw-animate-css: - specifier: 'catalog:' - version: 1.4.0 vite: specifier: npm:@voidzero-dev/vite-plus-core@0.3.0 version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.20.6)(typescript@7.0.2)(yaml@2.9.0)' @@ -1236,14 +1224,11 @@ packages: foldkit: '>=0.153.0' vite: ^7.0.0 || ^8.0.0 - '@fontsource-variable/archivo@5.3.0': - resolution: {integrity: sha512-HogK8FJelrD1o7TlZlkIVtHgc20bO5PZRWE7mUeUTdMN055alznQV6/00J00IBeu8FQAH4s3zW9UJNvKExXf+g==} + '@fontsource-variable/inter@5.3.0': + resolution: {integrity: sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==} - '@fontsource-variable/big-shoulders-display@5.3.0': - resolution: {integrity: sha512-7k1REXMZFEa7gSWOURbmCJW5Gv9D0EbEFQgosZidM8Ob/qpPlK96vqyIzCTnNZ/C0XFZWxeKU53D3QdeHctYCA==} - - '@fontsource-variable/spline-sans-mono@5.3.0': - resolution: {integrity: sha512-dH/qBj/MCE4ObiuCwlA9X1o3AftMO9dwfFgoRVYjy6VCPYkpWAlEtK/RjeyICgsR8rW763IAQARskVgShBY5JA==} + '@fontsource-variable/jetbrains-mono@5.3.0': + resolution: {integrity: sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==} '@grpc/grpc-js@1.14.4': resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==} @@ -4048,9 +4033,6 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - tw-animate-css@1.4.0: - resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} - tweetnacl@0.14.5: resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} @@ -4945,11 +4927,9 @@ snapshots: - bufferutil - utf-8-validate - '@fontsource-variable/archivo@5.3.0': {} + '@fontsource-variable/inter@5.3.0': {} - '@fontsource-variable/big-shoulders-display@5.3.0': {} - - '@fontsource-variable/spline-sans-mono@5.3.0': {} + '@fontsource-variable/jetbrains-mono@5.3.0': {} '@grpc/grpc-js@1.14.4': dependencies: @@ -7442,8 +7422,6 @@ snapshots: fsevents: 2.3.3 optional: true - tw-animate-css@1.4.0: {} - tweetnacl@0.14.5: {} type-fest@5.8.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 315249e5..1d3c63c2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -33,9 +33,8 @@ catalog: "@foldkit/oxlint-plugin": ^0.12.0 "@foldkit/ui": ^0.157.0 "@foldkit/vite-plugin": ^0.20.1 - "@fontsource-variable/archivo": ^5.3.0 - "@fontsource-variable/big-shoulders-display": ^5.3.0 - "@fontsource-variable/spline-sans-mono": ^5.3.0 + "@fontsource-variable/inter": ^5.3.0 + "@fontsource-variable/jetbrains-mono": ^5.3.0 "@tailwindcss/vite": ^4.3.3 "@testcontainers/postgresql": 12.1.0 "@types/node": ^24.13.3 @@ -47,7 +46,6 @@ catalog: lucide: ^1.33.0 tailwind-merge: ^3.6.0 tailwindcss: ^4.3.3 - tw-animate-css: ^1.4.0 typescript: ^7.0.2 vite: npm:@voidzero-dev/vite-plus-core@0.3.0 vite-plus: ^0.3.0