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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/agent-memory/review-review-issue-fixer/MEMORY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- [Dashboard fix patterns](project_dashboard_fix_patterns.md) — @honmoon/policy value imports resolve; eslint type-specifier rule; oklch contrast compositing gotchas
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
name: dashboard-fix-patterns
description: Honmoon dashboard fix constraints — @honmoon/policy value imports resolve, eslint import-style rule, oklch contrast math
metadata:
type: project
---

Fixing review findings in `apps/dashboard`:

- **Value imports from `@honmoon/policy` build fine.** Its package.json `exports` points at raw
`./src/index.ts`, so Vite transpiles it directly — no dist build step needed. A finding that says
"skip if the value import cannot resolve" can be answered with a `build` run, not guessed.
**Why:** several dashboard files use `import type` only, which is erased and proves nothing at
typecheck time. **How to apply:** only `bun run --filter '@honmoon/dashboard' build` is evidence.
- **eslint enforces `import/consistent-type-specifier-style`.** Inline `type` specifiers mixed into
a value import (`import { X, type Y }`) error out; it must be split into a separate
`import type { Y }` line. **Why:** repo lint config. **How to apply:** when a finding literally
prescribes the inline form, apply it then run `bunx eslint --fix` on that file.
- **Contrast findings cite oklch tokens in `src/index.css`.** Composite translucent surfaces in
gamma-encoded sRGB (not linear) or the ratio comes out optimistic. Real backdrops nest
`--surface-soft` over `--surface-glass` over `--bg`, which is worse than the two-layer case a
finding usually quotes — check the three-layer stack too.
10 changes: 10 additions & 0 deletions .please/docs/review/rejected-findings.jsonl
Original file line number Diff line number Diff line change
@@ -1 +1,11 @@
{"key":"c289642ddef91a0d","file":"crates/honmoon-proxy/tests/redaction.rs","title":"The cache-stability wire test does not model a growing conversation prefix","reason":"Evidence misattribution: the growing-prefix scenario is covered by public_path_extending_the_history_preserves_the_redacted_prefix in crates/honmoon-core/tests/secret_tokenizer_public_api.rs:192-230 (plus two adjacent position-independence tests); the wire path delegates all tokenization to honmoon-core, so a prefix-instability regression fails at the core layer","rejected_at":"2026-07-20T12:42:25Z","source":"review-code"}
{"key":"c7b3265912ddbd2e","file":"apps/dashboard/src/App.tsx","title":"Gateway reachability status is completely hidden below md breakpoint","reason":"By design: every route renders its own visible ErrorNote (role=alert, no responsive hiding) when its poll fails, so narrow-viewport users still get an explicit unreachability signal; hiding the header capsule below md is a deliberate responsive trade-off","rejected_at":"2026-09-03T12:33:15Z","source":"code-review"}
{"key":"680f01c120f9d897","file":"apps/dashboard/src/components/Overview.tsx","title":"Overview's featured approval assumes array order that Approvals page does not","reason":"Factually wrong: honmoon-proxy approval.rs pending() sorts by monotonic id (oldest first, tested by pending_is_sorted_by_id) and mgmt list_approvals returns it unchanged; demo shim also pushes in registration order, so approvals[0] is always the oldest","rejected_at":"2026-09-03T12:33:15Z","source":"code-review"}
{"key":"c855f8b101ee73d7","file":"apps/dashboard/src/index.css","title":"Policy editor textarea permanently disables the focus-visible outline","reason":"Factually wrong: :focus-visible (index.css:129) is unlayered while .policy-editor textarea { outline:none } is inside @layer components; unlayered declarations beat layered ones regardless of specificity. Verified empirically: keyboard focus renders the 2px accent outline","rejected_at":"2026-09-03T12:33:15Z","source":"code-review"}
{"key":"88a1b1c4d3e6a220","file":"apps/dashboard/src/index.css","title":"Policy editor gutter width doesn't match the reserved code padding","reason":"Factually wrong: Tailwind v4 Preflight sets box-sizing:border-box, so .line-no width:48px includes padding-right:14px; text right edge sits at 34px and the 58px editor padding leaves a 24px gap","rejected_at":"2026-09-03T12:33:16Z","source":"code-review"}
{"key":"5cd7bd3baeb114cf","file":"apps/dashboard/src/index.css","title":"Prism gutter line numbers use absolute positioning with no positioned per-line ancestor","reason":"Empirically refuted: built demo and measured .line-no spans; each has a distinct monotonically increasing top (~21.25px apart) aligned to its line. Abspos with top/bottom auto resolves to the inline static position per CSS 2.1 10.6.4","rejected_at":"2026-09-03T12:33:16Z","source":"code-review"}
{"key":"09d20f43afff0463","file":"apps/dashboard/src/components/Overview.tsx","title":"Overview 'Latest decisions' rule column vanishes on mobile with no fallback","reason":"out of scope: style/taste — hiding a secondary column below md is a responsive design choice, not a WCAG failure or logic error; the full Audit route keeps every column","rejected_at":"2026-09-03T12:33:16Z","source":"code-review"}
{"key":"988459f6c5a04fd0","file":"apps/dashboard/src/components/Overview.tsx","title":"Overview's ApprovalStrip and Approvals' ApprovalCard track busy state in separate hook instances, allowing a duplicate approve/reject across a page switch","reason":"By design / no observable effect: approval.rs resolve() uses .remove(&id)? so a second approve/reject returns 404 'no such pending approval' — no double-application; per-list busyIds makes no cross-surface claim, and the trigger requires a second aimed click inside one same-origin POST round trip across a route change","rejected_at":"2026-09-03T12:39:57Z","source":"code-review"}
{"key":"71a632542061c550","file":"apps/dashboard/src/components/ApprovalActions.tsx","title":"ApprovalActions summary doc-comment example omits the colon the code actually renders","reason":"Pure style: the JSDoc e.g. illustrates the summary content, not the literal aria-label string; the templates sit a few lines below in the same file. No observable effect","rejected_at":"2026-09-03T12:52:50Z","source":"code-review"}
{"key":"b4811fcbf7b5071c","file":"apps/dashboard/src/components/PolicyView.tsx","title":"Prism-highlighted YAML gutter numbering can nest across multi-line tokens","reason":"Empirically refuted: prism-yaml only spans lines for block scalars, and Prism output closes every span so line-no spans are validly nested children; .token.string sets only color, so positioning is unaffected. Verified live in the built demo with a block scalar: identical left, even top spacing, normal font-style","rejected_at":"2026-09-03T12:52:50Z","source":"code-review"}
{"key":"4892e37e99f3a60c","file":"apps/dashboard/src/components/DecisionBadge.tsx","title":"DecisionBadge docstring overclaims that glyph distinguishes approved/rejected from allowed/denied","reason":"Pure style: 'glyph and label carry the meaning; color supports' mirrors the design-wide a11y comment on .verdict in index.css and does not claim the glyph alone disambiguates each pair; the label text keeps them distinct. No observable effect","rejected_at":"2026-09-03T13:07:35Z","source":"code-review"}
16 changes: 11 additions & 5 deletions apps/dashboard/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ rust-embed handler needs a rewrite rule). Unknown hashes fall back to Overview:
| Path | What |
|------|------|
| `src/main.tsx` / `src/App.tsx` | Entry point; hash router + shell over the four views. |
| `src/components/` | `Overview`, `AuditLog`, `PolicyView`, `Approvals`, `DecisionBadge`. |
| `src/api.ts` / `src/hooks.ts` / `src/format.ts` | Typed management-API client, `usePolling`, formatters. |
| `src/components/` | `Overview`, `AuditLog`, `PolicyView`, `Approvals`, plus shared pieces: `DecisionBadge` (glyph + mono verdict pill), `ApprovalActions` (Deny / Approve pair), `ui` (`Panel` double bezel, `PageHead`, `SectionHead`, `ErrorNote`, `PanelState`). |
| `src/api.ts` / `src/hooks.ts` / `src/format.ts` | Typed management-API client, `usePolling` + `useApprovalActions` (per-id busy set), formatters. |
| `src/index.css` | G2 "Barrier Membrane" tokens (oklch, dark primary / light secondary), Tailwind `@theme` bridge, and the few component classes (`bezel`/`glass`, `verdict-*`, `action-*`, Prism YAML token colors). |
| `demo/demo-mode.js` | Demo shim: patches `window.fetch` with in-memory fixtures, runs a scripted timeline, mounts the "demo" badge. Plain browser JS, no bundler. |
| `demo/build.ts` | Copies `dist/` → `dist-demo/` and injects the shim's `<script>` tag. Run by `build:demo`. |
| `vite.config.ts` | Vite + Tailwind; in dev, proxies `/api` → `127.0.0.1:8444` (a running `honmoon gateway`). |
Expand All @@ -49,12 +50,17 @@ rust-embed handler needs a rewrite rule). Unknown hashes fall back to Overview:

- React 19 + Vite + Tailwind 4. ESM, `strict: true`. Lint via `@pleaseai/eslint-config` with
`eslint-plugin-react-hooks` / `react-refresh`.
- Light/dark aware (`color-scheme` / Tailwind `dark:` variants). Keep the shell minimal — data
density over decoration. Mirror clawpatrol's dashboard structure where it enables component reuse.
- Light/dark aware: `color-scheme: light dark` plus the oklch tokens in `index.css`, switched by
`@media (prefers-color-scheme: light)` — no Tailwind `dark:` variants. Keep the shell minimal —
data density over decoration. Mirror clawpatrol's dashboard structure where it enables component reuse.

## Testing

No tests yet. Add component/interaction tests alongside any real surface you build.
`bun run test` (from this directory, or `bun run --filter '@honmoon/dashboard' test` from the
root) runs `bun test` over `src/**/*.test.tsx`. Tests register happy-dom themselves via
`@happy-dom/global-registrator` and drive React with `act` — no testing-library. Add
component/interaction tests alongside any real surface you build; `hooks.test.tsx` is the
pattern to copy. CI does not run this suite yet (`.github/workflows/ci.yml`).

## Boundaries

Expand Down
3 changes: 3 additions & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"build": "tsc -b && vite build",
"build:demo": "bun run build && bun demo/build.ts",
"typecheck": "tsc -b --noEmit",
"test": "bun test",
"preview": "vite preview"
},
"dependencies": {
Expand All @@ -19,7 +20,9 @@
"react-simple-code-editor": "^0.14.1"
},
"devDependencies": {
"@happy-dom/global-registrator": "^20.14.0",
"@tailwindcss/vite": "^4.1.3",
"@types/bun": "^1.4.0",
"@types/node": "^24.12.3",
"@types/prismjs": "^1.26.6",
"@types/react": "^19.2.14",
Expand Down
121 changes: 89 additions & 32 deletions apps/dashboard/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useSyncExternalStore } from 'react'
import { getApprovals } from './api'
import { getApprovals, getAudit } from './api'
import { Approvals } from './components/Approvals'
import { AuditLog } from './components/AuditLog'
import { Overview } from './components/Overview'
Expand All @@ -8,13 +8,16 @@

const NAV = [
{ slug: '/', label: 'Overview' },
{ slug: '/audit', label: 'Audit Log' },
{ slug: '/audit', label: 'Audit' },
{ slug: '/policies', label: 'Policies' },
{ slug: '/approvals', label: 'Approvals' },
] as const

type Slug = (typeof NAV)[number]['slug']

/** Smallest audit read that still proves the endpoint answers. */
const probeAudit = () => getAudit(1)

/**
* Hash routing, not the History API: the dashboard is served both by Cloudflare
* Pages (the demo) and by `honmoon-mgmt`'s rust-embed handler, and a hash needs
Expand All @@ -34,55 +37,109 @@
function App() {
// `currentSlug` returns a primitive, so the snapshot is stable by value.
const slug = useSyncExternalStore(subscribe, currentSlug)
// A live pending count drives the sidebar badge across every view.
const { data: approvals } = usePolling(getApprovals, 1500)
// A live pending count drives the nav badge across every view. Reachability
// in the capsule needs both live endpoints to answer: the audit route can
// fail on its own, and a badge that only watched approvals would still read
// "live" while the Audit page shows an error.
const { data: approvals, error: approvalsError } = usePolling(getApprovals, 1500)
const { data: audit, error: auditError } = usePolling(probeAudit, 2000)
const pending = approvals?.length ?? 0
const settled = (approvals !== null || approvalsError !== null)
&& (audit !== null || auditError !== null)
const reachable = approvalsError === null && auditError === null

return (
<div className="min-h-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
<header className="border-b border-zinc-200 px-6 py-4 dark:border-zinc-800">
<h1 className="text-lg font-semibold">
Honmoon
{' '}
<span className="text-zinc-400">dashboard</span>
</h1>
</header>
<div className="min-h-screen bg-bg text-fg">
{/*
Below `md` the nav capsule cannot fit beside the wordmark, so the
header grows and the capsule wraps onto its own full-width row; it
also scrolls horizontally as a last resort so no route is ever pushed
off-screen.
*/}
<header className="relative z-10 h-[72px] bg-[color-mix(in_oklch,var(--bg)_88%,transparent)] px-10 shadow-[inset_0_-1px_0_var(--hair)] max-md:h-auto max-md:px-6 max-md:py-2">
<div className="mx-auto flex h-full max-w-[1440px] items-center max-md:flex-wrap max-md:gap-y-1">
<a
href="#/"
className="flex min-h-11 items-center gap-2.5 rounded-md font-mono text-base font-semibold tracking-[-0.02em] text-fg no-underline"
>
<span
aria-hidden="true"
className="relative size-[23px] rounded-[7px] bg-accent shadow-[0_0_18px_var(--accent-glow),inset_0_1px_1px_oklch(100%_0_0/0.65)] after:absolute after:inset-[7px] after:rounded-sm after:bg-bg after:opacity-75 after:content-['']"
/>

Check warning on line 68 in apps/dashboard/src/App.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Ambiguous spacing after previous element span

See more on https://sonarcloud.io/project/issues?id=pleaseai_honmoon&issues=AaBmLSJsiYtvm270MFSA&open=AaBmLSJsiYtvm270MFSA&pullRequest=75
honmoon
</a>

<div className="flex">
<nav className="w-48 shrink-0 border-r border-zinc-200 p-4 dark:border-zinc-800">
<ul className="space-y-1 text-sm">
{NAV.map(item => (
<li key={item.slug}>
<div className="ml-auto flex items-center rounded-full bg-[var(--surface-glass)] p-1 shadow-[inset_0_1px_0_var(--hair),inset_0_0_0_1px_var(--hair),var(--shadow)] max-md:ml-0 max-md:w-full max-md:overflow-x-auto">
<nav aria-label="Dashboard pages" className="flex gap-0.5 max-md:flex-1 max-md:justify-between">
{NAV.map(item => (
<a
key={item.slug}
href={`#${item.slug}`}
aria-current={slug === item.slug ? 'page' : undefined}
className={`flex w-full items-center justify-between rounded px-3 py-2 text-left ${
className={`flex min-h-11 items-center rounded-full px-[18px] text-xs font-medium tracking-[0.02em] no-underline transition-[background-color,color,transform] duration-500 ease-[var(--ease)] hover:-translate-y-px hover:text-fg motion-reduce:transition-none max-md:px-3 ${
slug === item.slug
? 'bg-zinc-200 font-medium dark:bg-zinc-800'
: 'hover:bg-zinc-100 dark:hover:bg-zinc-900'
? 'bg-accent-soft text-fg shadow-[inset_0_0_0_1px_var(--accent-line)]'
: 'text-muted'
}`}
>
{item.label}
{item.slug === '/approvals' && pending > 0 && (
<span className="ml-2 rounded-full bg-amber-500 px-1.5 text-xs font-semibold text-white">
<span className="count-badge">
{pending}
<span className="sr-only"> pending</span>
</span>
)}
</a>
</li>
))}
</ul>
</nav>
))}
</nav>

<GatewayState reachable={reachable} settled={settled} />

<main className="flex-1 p-6">
{slug === '/' && <Overview />}
{slug === '/audit' && <AuditLog />}
{slug === '/policies' && <PolicyView />}
{slug === '/approvals' && <Approvals />}
</main>
</div>
<span
role="img"
aria-label="Account"
className="mr-0.5 grid size-9 shrink-0 place-items-center rounded-full bg-[var(--surface-soft)] text-muted shadow-[inset_0_0_0_1px_var(--hair)] max-md:hidden"
>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="8" cy="5.5" r="3" stroke="currentColor" strokeWidth="1.4" />
<path d="M2.5 14c.6-3 3-4.5 5.5-4.5s4.9 1.5 5.5 4.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</svg>
</span>
</div>
</div>
</header>

<main>
{slug === '/' && <Overview />}
{slug === '/audit' && <AuditLog />}
{slug === '/policies' && <PolicyView />}
{slug === '/approvals' && <Approvals />}
</main>
Comment thread
dannyna marked this conversation as resolved.
</div>
)
}

/**
* Live management-API reachability, derived from the approvals and audit
* polls. It says whether the API answers — nothing about what the gateway
* enforces.
*/
function GatewayState({ reachable, settled }: { reachable: boolean, settled: boolean }) {

Check warning on line 127 in apps/dashboard/src/App.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=pleaseai_honmoon&issues=AaBmLSJsiYtvm270MFSB&open=AaBmLSJsiYtvm270MFSB&pullRequest=75
const label = !settled ? 'Connecting' : reachable ? 'Gateway live' : 'Gateway unreachable'

Check warning on line 128 in apps/dashboard/src/App.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=pleaseai_honmoon&issues=AaBmLSJsiYtvm270MFSC&open=AaBmLSJsiYtvm270MFSC&pullRequest=75
const dot = !settled
? 'bg-muted'
: reachable
? 'bg-accent shadow-[0_0_11px_var(--accent-glow)]'
: 'bg-deny'

Check warning on line 133 in apps/dashboard/src/App.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=pleaseai_honmoon&issues=AaBmLSJsiYtvm270MFSD&open=AaBmLSJsiYtvm270MFSD&pullRequest=75
return (
<span
role="status"
className="ml-1.5 flex min-h-11 items-center gap-2 px-3 font-mono text-[9px] font-medium tracking-[0.1em] text-muted uppercase max-md:hidden"
>

Check warning on line 138 in apps/dashboard/src/App.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use <output> instead of the "status" role to ensure accessibility across all devices.

See more on https://sonarcloud.io/project/issues?id=pleaseai_honmoon&issues=AaBmLSJsiYtvm270MFSE&open=AaBmLSJsiYtvm270MFSE&pullRequest=75
<i aria-hidden="true" className={`size-[7px] rounded-full ${dot}`} />
{label}
</span>
)
}

export default App
57 changes: 57 additions & 0 deletions apps/dashboard/src/components/ApprovalActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Deny / Approve pair for one held request. Approve is the only solid cyan
* action in the group; Deny is a red-tinted outline. Both lock while that
* approval's own busy flag is set, so sibling rows stay independent.
*/
export function ApprovalActions({
summary,
busy,
onApprove,
onReject,
className = '',
}: {
/** Names the request for assistive technology, e.g. "Approve: DROP TABLE…". */
summary: string
busy: boolean
onApprove: () => void
onReject: () => void
/** Placement classes for the host layout (e.g. grid spans); never styling. */
className?: string
}) {

Check warning on line 20 in apps/dashboard/src/components/ApprovalActions.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=pleaseai_honmoon&issues=AaBmLSIaiYtvm270MFR0&open=AaBmLSIaiYtvm270MFR0&pullRequest=75
return (
<div className={`flex shrink-0 gap-2 ${className}`} aria-busy={busy || undefined}>
<button
type="button"
className="action action-deny"
disabled={busy}
onClick={onReject}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add interaction coverage for the new dashboard surfaces

The new dashboard behavior is only covered through direct hook tests in hooks.test.tsx; none of the added routes or components is rendered in a test. In particular, this user-facing Deny control has no interaction coverage proving it dispatches the rejection, reflects busy/error state, or remains correctly wired in its card and overview hosts. Add happy-dom component tests for the newly built surfaces rather than relying solely on hook tests.

AGENTS.md reference: apps/dashboard/AGENTS.md:L61-L63

Useful? React with 👍 / 👎.

aria-label={busy ? `Resolving… ${summary}` : `Deny: ${summary}`}
>
{busy
? 'Resolving…'
: (
<>
<span aria-hidden="true">✕</span>

Check warning on line 34 in apps/dashboard/src/components/ApprovalActions.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Ambiguous spacing after previous element span

See more on https://sonarcloud.io/project/issues?id=pleaseai_honmoon&issues=AaBmLSIaiYtvm270MFR1&open=AaBmLSIaiYtvm270MFR1&pullRequest=75
Deny
</>
)}
</button>
<button
type="button"
className="action action-approve"
disabled={busy}
onClick={onApprove}
aria-label={busy ? `Resolving… ${summary}` : `Approve: ${summary}`}
>
{busy
? 'Resolving…'
: (
<>
<span aria-hidden="true">✓</span>

Check warning on line 50 in apps/dashboard/src/components/ApprovalActions.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Ambiguous spacing after previous element span

See more on https://sonarcloud.io/project/issues?id=pleaseai_honmoon&issues=AaBmLSIaiYtvm270MFR2&open=AaBmLSIaiYtvm270MFR2&pullRequest=75
Approve
</>
)}
</button>
</div>
)
}
Loading