feat(web/ui6): migrate dashboard, workspaces, search, chat pages

Phase UI6 — the three highest-traffic operator surfaces plus chat
move off legacy globals onto @bytelyst/ui Card + Badge + Input +
Select + Textarea + Button primitives via the local adapter.

dashboard/page.tsx:
- Welcome card, Saved views card, Quick links card, Operator workflows
  card, Recent note activity card — all section.surface-card → Card.
- All saved-view/quick-link/workflow/note rows: surface-muted with
  inline styles → grid+rounded+bg utility classes with hover state.
- All inline 'badge' spans (scope, status, tags) → Badge with
  semantic variants (workflow status maps to warning/success).

workspaces/page.tsx:
- Saved-views aside, filter section, workspace article rows, error
  banner — all surface-card → Card.
- Filter input → Input. Visibility/owner/tag chips → Badge.
- Workspace-note rows → utility-class hover panels.

search/page.tsx:
- POST /notes/search action chip → Badge.
- Saved searches aside + results pane — both surface-card → Card.
- '+ Save current' button + per-view Remove button — raw <button>
  → Button (size sm, ghost variant for Remove).
- Search input + filter chips + result rows — Input + Badge +
  utility-class panels.

chat/page.tsx:
- Workspace <select> → Select with options=[{value,label}].
- Question <textarea> → Textarea.

Ratchet impact for this commit:
  raw interactive controls       25 → 19  (-6)
  legacy global surface classes  67 → 38  (-29)

Cumulative since session start (38/92 baseline):
  raw   38 → 19  (-19)
  legacy 92 → 38  (-54)

Verified: pnpm typecheck, test (96/96), audit:ui:ratchet at new
baseline.
This commit is contained in:
saravanakumardb1 2026-05-23 01:38:35 -07:00
parent 2408f43426
commit 8d484c30d1
4 changed files with 100 additions and 104 deletions

View File

@ -3,7 +3,7 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { Badge, Button, Card } from "@/components/ui/Primitives";
import { Badge, Button, Card, Select, Textarea } from "@/components/ui/Primitives";
import { chatOverWorkspace, listWorkspaceSummaries } from "@/lib/notes-client";
import type { WorkspaceSummary } from "@/lib/types";
import { toast } from "@/lib/toast";
@ -53,19 +53,16 @@ export default function ChatPage() {
Create a workspace first (Dashboard seed or Workspaces), then return here.
</p>
) : (
<select className="input-shell" value={workspaceId} onChange={(e) => setWorkspaceId(e.target.value)}>
{workspaces.map((w) => (
<option key={w.id} value={w.id}>
{w.name}
</option>
))}
</select>
<Select
value={workspaceId}
onChange={(e) => setWorkspaceId(e.target.value)}
options={workspaces.map((w) => ({ value: w.id, label: w.name }))}
/>
)}
</label>
<label style={{ display: "grid", gap: 8 }}>
<span style={{ fontWeight: 600 }}>Question</span>
<textarea
className="input-shell"
<Textarea
rows={4}
value={message}
onChange={(e) => setMessage(e.target.value)}

View File

@ -6,7 +6,7 @@ import { Suspense, useCallback, useEffect, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { CreateNoteModal } from "@/components/CreateNoteModal";
import { StateNotice } from "@/components/StateNotice";
import { Button, Card } from "@/components/ui/Primitives";
import { Badge, Button, Card } from "@/components/ui/Primitives";
import { listNoteSummaries, listWorkspaceSummaries, seedOnboardingWorkspace } from "@/lib/notes-client";
import { listApprovalQueue } from "@/lib/review-client";
import { listSavedViews, type SavedView } from "@/lib/saved-views-client";
@ -174,7 +174,7 @@ function DashboardContent() {
</section>
{workspaces.length === 0 ? (
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)" }}>
<Card padding="md" className="grid gap-3">
<strong>Welcome create a sample workspace</strong>
<p style={{ margin: 0, color: "var(--nl-text-secondary)" }}>
Seeds a &quot;Getting started&quot; workspace with sample notes and one agent item in the review queue (backend flag{" "}
@ -201,18 +201,18 @@ function DashboardContent() {
>
Seed sample workspace
</Button>
</section>
</Card>
) : null}
<section style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.1fr) minmax(320px, 0.9fr)", gap: "var(--nl-space-4)" }}>
<section className="surface-card" style={{ padding: "var(--nl-space-6)", display: "grid", gap: "var(--nl-space-4)" }}>
<Card padding="lg" className="grid gap-4">
<div style={{ fontSize: "var(--nl-fs-xl)", fontWeight: 700 }}>Saved views</div>
<p style={{ margin: 0, color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
Views stored in your account (Search page can add more). Below that, quick links stay on the dashboard for one-tap access.
</p>
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
{sortedSaved.length === 0 ? (
<div className="surface-muted" style={{ padding: "var(--nl-space-4)", color: "var(--nl-text-secondary)" }}>
<div className="rounded-[var(--nl-radius-sm)] bg-[color:var(--nl-surface-muted)] p-4 text-[color:var(--nl-text-secondary)]">
No saved views yet. Open <Link href="/search">Search</Link> and save a query to pin it here.
</div>
) : (
@ -220,12 +220,11 @@ function DashboardContent() {
<Link
key={view.id}
href={hrefForSavedView(view)}
className="surface-muted"
style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}
className="grid gap-2 rounded-[var(--nl-radius-sm)] bg-[color:var(--nl-surface-muted)] p-4 transition-colors hover:bg-[color:var(--nl-surface-elevated)]"
>
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", flexWrap: "wrap" }}>
<div className="flex flex-wrap items-center justify-between gap-3">
<strong>{view.name}</strong>
<span className="badge">{view.scope}</span>
<Badge variant="neutral">{view.scope}</Badge>
</div>
{view.description ? (
<div style={{ color: "var(--nl-text-secondary)" }}>{view.description}</div>
@ -245,12 +244,11 @@ function DashboardContent() {
<Link
key={view.id}
href={view.href}
className="surface-muted"
style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}
className="grid gap-2 rounded-[var(--nl-radius-sm)] bg-[color:var(--nl-surface-muted)] p-4 transition-colors hover:bg-[color:var(--nl-surface-elevated)]"
>
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", flexWrap: "wrap" }}>
<div className="flex flex-wrap items-center justify-between gap-3">
<strong>{view.name}</strong>
<span className="badge">{view.scope}</span>
<Badge variant="neutral">{view.scope}</Badge>
</div>
<div style={{ color: "var(--nl-text-secondary)" }}>{view.description}</div>
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", flexWrap: "wrap" }}>
@ -260,21 +258,20 @@ function DashboardContent() {
</Link>
))}
</div>
</section>
</Card>
<section className="surface-card" style={{ padding: "var(--nl-space-6)", display: "grid", gap: "var(--nl-space-4)" }}>
<Card padding="lg" className="grid gap-4">
<div style={{ fontSize: "var(--nl-fs-xl)", fontWeight: 700 }}>Operator workflows</div>
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
{operatorWorkflows.map((workflow) => (
<Link
key={workflow.id}
href={getWorkflowHref(workflow)}
className="surface-muted"
style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}
className="grid gap-2 rounded-[var(--nl-radius-sm)] bg-[color:var(--nl-surface-muted)] p-4 transition-colors hover:bg-[color:var(--nl-surface-elevated)]"
>
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", flexWrap: "wrap" }}>
<div className="flex flex-wrap items-center justify-between gap-3">
<strong>{workflow.name}</strong>
<span className="badge">{workflow.status}</span>
<Badge variant={workflow.status === "at_risk" ? "warning" : "success"}>{workflow.status}</Badge>
</div>
<div style={{ color: "var(--nl-text-secondary)" }}>Owner: {workflow.owner}</div>
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", flexWrap: "wrap" }}>
@ -284,10 +281,10 @@ function DashboardContent() {
</Link>
))}
</div>
</section>
</Card>
</section>
<section className="surface-card" style={{ padding: "var(--nl-space-6)", display: "grid", gap: "var(--nl-space-4)" }}>
<Card padding="lg" className="grid gap-4">
<div style={{ fontSize: "var(--nl-fs-xl)", fontWeight: 700 }}>Recent note activity</div>
{errorState ? <StateNotice state={errorState} onAction={() => void loadDashboard()} compact /> : null}
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
@ -295,27 +292,27 @@ function DashboardContent() {
<StateNotice state={getEmptyState("backend", "recent notes")} compact />
) : null}
{recentNotes.map((note) => (
<div key={note.id} className="surface-muted" style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", flexWrap: "wrap" }}>
<div key={note.id} className="grid gap-2 rounded-[var(--nl-radius-sm)] bg-[color:var(--nl-surface-muted)] p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<Link href={`/notes/${note.id}`}>
<strong>{note.title}</strong>
</Link>
<span style={{ color: "var(--nl-text-secondary)" }}>{note.updatedBy}</span>
<span className="text-[color:var(--nl-text-secondary)]">{note.updatedBy}</span>
</div>
<Link href={`/notes/${note.id}`} style={{ color: "var(--nl-text-secondary)" }}>
<Link href={`/notes/${note.id}`} className="text-[color:var(--nl-text-secondary)]">
{note.excerpt}
</Link>
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
<div className="flex flex-wrap gap-2">
{note.tags.map((tag) => (
<Link key={tag} href={`/search?q=${encodeURIComponent(tag)}`} className="badge">
#{tag}
<Link key={tag} href={`/search?q=${encodeURIComponent(tag)}`}>
<Badge variant="neutral">#{tag}</Badge>
</Link>
))}
</div>
</div>
))}
</div>
</section>
</Card>
{showCreateNote && (
<CreateNoteModal
workspaces={workspaces}

View File

@ -4,6 +4,7 @@ import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { Badge, Button, Card, Input } from "@/components/ui/Primitives";
import { searchNotesRanked, type SearchRankedHit } from "@/lib/notes-client";
import { listSavedViews, createSavedView, deleteSavedView, type SavedView } from "@/lib/saved-views-client";
import { useDebounce } from "@/lib/use-debounce";
@ -90,50 +91,44 @@ function SearchPageInner() {
<AppShell
title="Search"
description="Lexical and hybrid ranked search with match hints (title, body, tag). Toggle modes to compare behavior."
actions={<div className="badge">POST /notes/search</div>}
actions={<Badge variant="info">POST /notes/search</Badge>}
>
<section style={{ display: "grid", gridTemplateColumns: "minmax(260px, 320px) minmax(0, 1fr)", gap: "var(--nl-space-4)" }}>
<aside className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-4)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div style={{ fontWeight: 700 }}>Saved searches</div>
<Card padding="md" className="grid gap-4">
<div className="flex items-center justify-between">
<div className="font-bold">Saved searches</div>
{query.trim() ? (
<button
<Button
type="button"
className="badge"
size="sm"
variant="secondary"
onClick={() => void handleSaveCurrentSearch()}
style={{ cursor: "pointer" }}
>
+ Save current
</button>
</Button>
) : null}
</div>
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
{savedViews.map((view) => (
<div
key={view.id}
className="surface-muted"
style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}
className="grid gap-2 rounded-[var(--nl-radius-sm)] bg-[color:var(--nl-surface-muted)] p-4"
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "start" }}>
<Link href={`/search?q=${encodeURIComponent(view.query)}`} style={{ display: "grid", gap: "var(--nl-space-2)" }}>
<div className="flex items-start justify-between">
<Link href={`/search?q=${encodeURIComponent(view.query)}`} className="grid gap-2">
<strong>{view.name}</strong>
<span style={{ color: "var(--nl-text-secondary)" }}>{view.query}</span>
<span className="text-[color:var(--nl-text-secondary)]">{view.query}</span>
</Link>
<button
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
if (window.confirm("Remove this saved view?")) void handleDeleteSavedView(view.id);
}}
style={{
color: "var(--nl-text-secondary)",
background: "none",
border: "none",
cursor: "pointer",
fontSize: "0.75rem",
}}
>
Remove
</button>
</Button>
</div>
</div>
))}
@ -153,40 +148,45 @@ function SearchPageInner() {
Lexical only
</label>
</div>
</aside>
</Card>
<section className="surface-card" style={{ padding: "var(--nl-space-6)", display: "grid", gap: "var(--nl-space-4)" }}>
<input
<Card padding="lg" className="grid gap-4">
<Input
aria-label="Search notes"
className="input-shell"
placeholder="Search notes, tags, and body text"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
<span className="badge">mode:{mode}</span>
<span className="badge">{hits.length} hits</span>
<div className="flex flex-wrap gap-2">
<Badge variant="neutral">mode:{mode}</Badge>
<Badge variant="neutral">{hits.length} hits</Badge>
</div>
{error ? <div style={{ color: "var(--nl-text-secondary)" }}>{error}</div> : null}
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
{hits.map((hit) => (
<div key={hit.noteId} className="surface-muted" style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", flexWrap: "wrap" }}>
<Link href={`/notes/${hit.noteId}`} style={{ fontWeight: 700 }}>
<div
key={hit.noteId}
className="grid gap-2 rounded-[var(--nl-radius-sm)] bg-[color:var(--nl-surface-muted)] p-4"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<Link href={`/notes/${hit.noteId}`} className="font-bold">
{hit.title}
</Link>
<span className="badge">
<Badge variant="neutral">
{hit.matchKind} · score {hit.score}
</span>
</Badge>
</div>
<div style={{ color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>{hit.snippet}</div>
<Link href={`/search?q=${encodeURIComponent(hit.workspaceId)}`} style={{ color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
<div className="text-[length:var(--nl-fs-sm)] text-[color:var(--nl-text-secondary)]">{hit.snippet}</div>
<Link
href={`/search?q=${encodeURIComponent(hit.workspaceId)}`}
className="text-[length:var(--nl-fs-sm)] text-[color:var(--nl-text-secondary)]"
>
workspace: {hit.workspaceId}
</Link>
</div>
))}
</div>
</section>
</Card>
</section>
</AppShell>
);

View File

@ -5,7 +5,7 @@ import { useSearchParams } from "next/navigation";
import { Suspense, useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { CreateWorkspaceModal } from "@/components/CreateWorkspaceModal";
import { Button } from "@/components/ui/Primitives";
import { Badge, Button, Card, Input } from "@/components/ui/Primitives";
import { downloadNotesExport, exportNotes, listNoteSummaries, listWorkspaceSummaries, deleteWorkspace } from "@/lib/notes-client";
import { buildWorkspaceContextPackMarkdown } from "@/lib/context-pack";
import { toast } from "@/lib/toast";
@ -169,60 +169,58 @@ function WorkspacesPageInner() {
}
>
<section className="workspace-layout-grid" style={{ display: "grid", gridTemplateColumns: "minmax(260px, 320px) minmax(0, 1fr)", gap: "var(--nl-space-4)" }}>
<aside className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-4)" }}>
<div style={{ fontWeight: 700 }}>Saved views</div>
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
<Card padding="md" className="grid gap-4">
<div className="font-bold">Saved views</div>
<div className="grid gap-3">
{savedViews.map((view) => (
<Link
key={view.id}
href={view.id === "workspace-shared" ? "/workspaces?q=shared" : "/workspaces"}
className="surface-muted"
style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}
className="grid gap-2 rounded-[var(--nl-radius-sm)] bg-[color:var(--nl-surface-muted)] p-4 transition-colors hover:bg-[color:var(--nl-surface-elevated)]"
>
<strong>{view.name}</strong>
<span style={{ color: "var(--nl-text-secondary)" }}>{view.description}</span>
<span className="badge">{view.resultCount} results</span>
<span className="text-[color:var(--nl-text-secondary)]">{view.description}</span>
<Badge variant="neutral" className="justify-self-start">{view.resultCount} results</Badge>
</Link>
))}
</div>
</aside>
</Card>
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-4)" }}>
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
<input
<Card padding="md" className="grid gap-4">
<div className="grid gap-3">
<Input
aria-label="Filter workspaces"
className="input-shell"
placeholder="Filter workspaces by owner, tag, or visibility"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
<span className="badge">owner:any</span>
<span className="badge">visibility:any</span>
<span className="badge">sort:updated</span>
<div className="flex flex-wrap gap-2">
<Badge variant="neutral">owner:any</Badge>
<Badge variant="neutral">visibility:any</Badge>
<Badge variant="neutral">sort:updated</Badge>
</div>
</div>
</section>
</Card>
</section>
{error ? (
<section className="surface-card" style={{ padding: "var(--nl-space-6)", color: "var(--nl-text-secondary)" }}>
<Card padding="lg" className="text-[color:var(--nl-text-secondary)]">
{error}
</section>
</Card>
) : null}
<section style={{ display: "grid", gap: "var(--nl-space-4)" }}>
{filteredWorkspaces.map((workspace) => {
const workspaceNotes = notesByWorkspace.get(workspace.id) ?? [];
return (
<article key={workspace.id} className="surface-card" style={{ padding: "var(--nl-space-6)", display: "grid", gap: "var(--nl-space-4)" }}>
<Card key={workspace.id} padding="lg" className="grid gap-4">
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--nl-space-4)", flexWrap: "wrap" }}>
<div>
<div style={{ fontSize: "var(--nl-fs-xl)", fontWeight: 700 }}>{workspace.name}</div>
<div style={{ color: "var(--nl-text-secondary)", marginTop: 6 }}>{workspace.description}</div>
</div>
<div style={{ display: "grid", gap: 8, justifyItems: "end" }}>
<Link href={`/workspaces?q=${encodeURIComponent(workspace.visibility)}`} className="badge">
{workspace.visibility}
<Link href={`/workspaces?q=${encodeURIComponent(workspace.visibility)}`}>
<Badge variant="neutral">{workspace.visibility}</Badge>
</Link>
<Link href={`/workspaces?q=${encodeURIComponent(workspace.owner)}`} style={{ color: "var(--nl-text-secondary)" }}>
Owner: {workspace.owner}
@ -263,20 +261,24 @@ function WorkspacesPageInner() {
</div>
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
{workspace.tags.map((tag) => (
<Link key={tag} href={`/workspaces?q=${encodeURIComponent(tag)}`} className="badge">
#{tag}
<Link key={tag} href={`/workspaces?q=${encodeURIComponent(tag)}`}>
<Badge variant="neutral">#{tag}</Badge>
</Link>
))}
</div>
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
{workspaceNotes.map((note) => (
<Link key={note.id} href={`/notes/${note.id}`} className="surface-muted" style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}>
<Link
key={note.id}
href={`/notes/${note.id}`}
className="grid gap-2 rounded-[var(--nl-radius-sm)] bg-[color:var(--nl-surface-muted)] p-4 transition-colors hover:bg-[color:var(--nl-surface-elevated)]"
>
<strong>{note.title}</strong>
<span style={{ color: "var(--nl-text-secondary)" }}>{note.excerpt}</span>
<span className="text-[color:var(--nl-text-secondary)]">{note.excerpt}</span>
</Link>
))}
</div>
</article>
</Card>
);
})}
</section>