evOo-local/apps/web/src/pages/ScratchpadSection.tsx
cynarAI dc1d28c670
fix(web): make the shell follow the light theme (#107)
The composer, the shell frame and sidebar, the right side panel and the
create-bot modal were hard-coded dark while the text on them follows the
theme, so in the light theme the typed draft, the sidebar rows and the
side panel forms rendered dark on dark.

- Composer: the island uses the card and foreground tokens; attach, stop
  and chips use border and foreground tokens, and send is the default
  primary button. The focus and shadow utilities on the bar are removed:
  .apple-island always overrode them, so they never rendered.
- Shell frame and page background use the background token. The frame's
  radial gradient never rendered under Tailwind v4 and is dropped.
- Sidebar: the panel uses the sidebar tokens on desktop too. The main
  conversation, roster, project and Heute rows share one token-based
  row style; muted text measures 4.4:1 on the light sidebar, so row
  times use a dimmed foreground instead.
- Side panel and create-bot modal use background, popover and overlay
  tokens. Settings helper texts and inactive tabs use the full muted
  color, which stays above 4.5:1 in light.
- Stage vignette, stage pill, ensemble and hero orb, and the transcript
  paths behind the call overlay use tokens.
- E2E: chat-contrast.spec.ts gains a shell test that measures the
  composer draft, sidebar rows, the new bot form and the bot settings
  form in both themes and captures screenshots.


Claude-Session: https://claude.ai/code/session_01SSVQGR4WU92LaddTP19GcQ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-03 15:27:51 +02:00

203 lines
6.2 KiB
TypeScript

import { Trans, useLingui } from "@lingui/react/macro";
import type { ScratchpadItem } from "@rakazo/contracts";
import { Button, Checkbox, Input } from "@rakazo/ui-web";
import { X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { rpc } from "../lib/rpc";
export function ScratchpadSection({ botId }: { botId: string }) {
const { t } = useLingui();
const [items, setItems] = useState<ScratchpadItem[]>([]);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const listGeneration = useRef(0);
async function refresh() {
const generation = ++listGeneration.current;
const list = await rpc.scratchpad.list({ botId });
if (generation !== listGeneration.current) return;
setItems(list);
}
useEffect(() => {
const generation = ++listGeneration.current;
void rpc.scratchpad
.list({ botId })
.then((list) => {
if (generation !== listGeneration.current) return;
setItems(list);
})
.catch(() => {
if (generation !== listGeneration.current) return;
setItems([]);
});
return () => {
listGeneration.current += 1;
};
}, [botId]);
async function addItem() {
const title = draft.trim();
if (!title || busy) return;
setBusy(true);
setError(null);
try {
const created = await rpc.scratchpad.create({ botId, title });
setDraft("");
setItems((current) => [created, ...current.filter((item) => item.id !== created.id)]);
try {
await refresh();
} catch {
setError(t`Saved, but list refresh failed`);
}
} catch {
setError(t`Could not add`);
} finally {
setBusy(false);
}
}
async function setStatus(item: ScratchpadItem, status: ScratchpadItem["status"]) {
if (busy) return;
setBusy(true);
setError(null);
try {
const updated = await rpc.scratchpad.update({ itemId: item.id, status });
setItems((current) => {
const next = current.map((entry) => (entry.id === updated.id ? updated : entry));
return status === "done" ? next.filter((entry) => entry.status !== "done") : next;
});
try {
await refresh();
} catch {
setError(t`Saved, but list refresh failed`);
}
} catch {
setError(t`Could not update`);
} finally {
setBusy(false);
}
}
async function removeItem(item: ScratchpadItem) {
if (busy) return;
setBusy(true);
setError(null);
try {
await rpc.scratchpad.remove({ itemId: item.id });
setItems((current) => current.filter((entry) => entry.id !== item.id));
try {
await refresh();
} catch {
setError(t`Removed, but list refresh failed`);
}
} catch {
setError(t`Could not remove`);
} finally {
setBusy(false);
}
}
return (
<div className="mt-6" data-testid="bot-scratchpad">
<div className="mb-3 text-ui-body text-muted-foreground">
<Trans>Open work</Trans>
</div>
{items.length === 0 ? (
<div className="py-1 text-ui-body text-muted-foreground">
<Trans>None yet</Trans>
</div>
) : (
items.map((item) => (
<div
key={item.id}
className="flex w-full items-start gap-2 rounded-xl px-2.5 py-2.5 hover:bg-accent"
>
<Checkbox
aria-label={item.status === "done" ? t`Reopen` : t`Complete`}
checked={item.status === "done"}
disabled={busy}
onCheckedChange={(checked) => void setStatus(item, checked ? "done" : "open")}
className="mt-0.5"
/>
<div className="min-w-0 flex-1">
<div
className={`text-start text-[14.5px] ${item.status === "done" ? "text-muted-foreground/80 line-through" : "text-foreground"}`}
dir="auto"
>
{item.title}
</div>
{item.notes ? (
<div className="mt-0.5 text-ui-body-sm text-muted-foreground/80" dir="auto">
{item.notes}
</div>
) : null}
</div>
<span className="shrink-0 text-ui-body-sm text-muted-foreground/80">{item.status}</span>
{item.status === "open" ? (
<Button
variant="ghost"
size="xs"
aria-label={t`Park`}
disabled={busy}
onClick={() => void setStatus(item, "parked")}
className="shrink-0 text-muted-foreground/70"
>
<Trans>Park</Trans>
</Button>
) : item.status === "parked" ? (
<Button
variant="ghost"
size="xs"
aria-label={t`Reopen`}
disabled={busy}
onClick={() => void setStatus(item, "open")}
className="shrink-0 text-muted-foreground/70"
>
<Trans>Open</Trans>
</Button>
) : null}
<Button
variant="ghost"
size="icon-xs"
aria-label={t`Remove`}
disabled={busy}
onClick={() => void removeItem(item)}
className="shrink-0 text-muted-foreground/70"
>
<X />
</Button>
</div>
))
)}
<form
className="mt-2 flex items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
void addItem();
}}
>
<Input
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder={t`Add item`}
aria-label={t`New open-work item`}
maxLength={200}
className="min-w-0 flex-1"
/>
<Button
variant="secondary"
className="rounded-full"
disabled={busy || !draft.trim()}
onClick={() => void addItem()}
>
<Trans>Add</Trans>
</Button>
</form>
{error ? <div className="mt-2 text-ui-body-sm text-destructive">{error}</div> : null}
</div>
);
}