From eb97fa7c7fb114eadf8d9a07457be75513bb4682 Mon Sep 17 00:00:00 2001
From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com>
Date: Sat, 3 Oct 2026 14:46:48 -0300
Subject: [PATCH 1/2] feat(app): chat card primitives
ui/tone.ts holds one class table per tone; ui/ChatCard.tsx has the
round icon, the compact request card, the card in sections, the note
fields and the settled line. The approval, site, help, plan,
suggestion, routine request and question cards are built from them.
---
app/src/features/browser/HelpCard.tsx | 51 ++---
app/src/features/browser/SiteCard.tsx | 52 ++---
app/src/features/chat/ApprovalCard.tsx | 64 +++---
app/src/features/chat/PlanCard.tsx | 91 ++++-----
app/src/features/chat/SuggestionCard.tsx | 119 +++++------
app/src/features/questions/QuestionCard.tsx | 146 ++++++-------
.../features/routines/RoutineRequestCard.tsx | 117 +++++------
app/src/ui/Badge.tsx | 41 ++++
app/src/ui/ChatCard.tsx | 193 ++++++++++++++++++
app/src/ui/tone.ts | 63 ++++++
10 files changed, 551 insertions(+), 386 deletions(-)
create mode 100644 app/src/ui/Badge.tsx
create mode 100644 app/src/ui/ChatCard.tsx
create mode 100644 app/src/ui/tone.ts
diff --git a/app/src/features/browser/HelpCard.tsx b/app/src/features/browser/HelpCard.tsx
index fd2937b4..07d038c7 100644
--- a/app/src/features/browser/HelpCard.tsx
+++ b/app/src/features/browser/HelpCard.tsx
@@ -3,10 +3,11 @@
// it" answer the request; once answered it shrinks to one line.
import { Ban, Check, Hand, TimerOff } from "lucide-react";
-import { useContext, useId } from "react";
+import { useContext } from "react";
import { useT } from "../../i18n";
import type { ApprovalItem, Bot } from "../../lib/protocol.gen";
import { Button } from "../../ui/Button";
+import { NoteInput, RequestCard, SettledLine } from "../../ui/ChatCard";
import { useAnswer } from "../chat/useAnswer";
import { ShowBrowser } from "./showBrowser";
@@ -28,30 +29,15 @@ function read(approval: ApprovalItem): { task: string; site: string | null } {
function Answered({ approval, task }: { approval: ApprovalItem; task: string }) {
const words = useT().browser.help;
- const line = "flex min-w-0 items-center gap-2 rounded-lg px-1.5 py-1 text-sm";
switch (approval.status) {
case "allowed":
- return (
-
-
- {words.doneLine(task)}
-
- );
+ return ;
case "denied":
return (
-
-
- {words.wontLine(task)}
- {approval.note && “{approval.note}” }
-
+
);
default:
- return (
-
-
- {words.expired(task)}
-
- );
+ return ;
}
}
@@ -63,23 +49,18 @@ export function HelpCard({ approval, bot }: { approval: ApprovalItem; bot: Bot }
allow: t.chat.approval.allowFailed,
deny: t.chat.approval.denyFailed,
});
- const noteId = useId();
const { task, site } = read(approval);
if (approval.status !== "pending") {
return ;
}
return (
-
-
-
-
-
- {words.needs(bot.name)}
-
{task}
@@ -89,15 +70,11 @@ export function HelpCard({ approval, bot }: { approval: ApprovalItem; bot: Bot }
)}
{words.why}
-
- {t.chat.approval.noteLabel(bot.name)}
-
- setNote(event.target.value)}
+ onChange={setNote}
placeholder={t.chat.approval.notePlaceholder}
- className="mt-3 h-9 w-full rounded-xl border border-line-strong bg-canvas px-3 text-sm outline-none placeholder:text-muted focus:border-muted"
/>
{showBrowser && (
@@ -117,6 +94,6 @@ export function HelpCard({ approval, bot }: { approval: ApprovalItem; bot: Bot }
{words.wontDo}
-
+
);
}
diff --git a/app/src/features/browser/SiteCard.tsx b/app/src/features/browser/SiteCard.tsx
index d2a074e8..dd962230 100644
--- a/app/src/features/browser/SiteCard.tsx
+++ b/app/src/features/browser/SiteCard.tsx
@@ -2,10 +2,10 @@
// address, and Allow or Deny. Once answered it shrinks to one line.
import { Ban, Check, Globe, TimerOff } from "lucide-react";
-import { useId } from "react";
import { useT } from "../../i18n";
import type { ApprovalItem, Bot } from "../../lib/protocol.gen";
import { Button } from "../../ui/Button";
+import { NoteInput, RequestCard, SettledLine } from "../../ui/ChatCard";
import { useAnswer } from "../chat/useAnswer";
/** The request the daemon opens from the browser tools. */
@@ -23,30 +23,15 @@ function addressOf(input: string): string | null {
function Answered({ approval, site }: { approval: ApprovalItem; site: string }) {
const words = useT().browser.site;
- const line = "flex min-w-0 items-center gap-2 rounded-lg px-1.5 py-1 text-sm";
switch (approval.status) {
case "allowed":
- return (
-
-
- {words.allowed(site)}
-
- );
+ return ;
case "denied":
return (
-
-
- {words.denied(site)}
- {approval.note && “{approval.note}” }
-
+
);
default:
- return (
-
-
- {words.expired(site)}
-
- );
+ return ;
}
}
@@ -57,7 +42,6 @@ export function SiteCard({ approval, bot }: { approval: ApprovalItem; bot: Bot }
allow: t.chat.approval.allowFailed,
deny: t.chat.approval.denyFailed,
});
- const noteId = useId();
const site = approval.summary;
if (approval.status !== "pending") {
return ;
@@ -65,33 +49,27 @@ export function SiteCard({ approval, bot }: { approval: ApprovalItem; bot: Bot }
const url = addressOf(approval.input);
return (
-
-
-
-
-
+
{words.wants(bot.name)} {site}
-
+ }
+ >
{url && (
{url}
)}
{words.why}
-
- {t.chat.approval.noteLabel(bot.name)}
-
- setNote(event.target.value)}
+ onChange={setNote}
placeholder={t.chat.approval.notePlaceholder}
- className="mt-3 h-9 w-full rounded-xl border border-line-strong bg-canvas px-3 text-sm outline-none placeholder:text-muted focus:border-muted"
/>
answer(true)}>
@@ -101,6 +79,6 @@ export function SiteCard({ approval, bot }: { approval: ApprovalItem; bot: Bot }
{t.chat.approval.deny}
-
+
);
}
diff --git a/app/src/features/chat/ApprovalCard.tsx b/app/src/features/chat/ApprovalCard.tsx
index 5af4c107..1536d882 100644
--- a/app/src/features/chat/ApprovalCard.tsx
+++ b/app/src/features/chat/ApprovalCard.tsx
@@ -2,10 +2,10 @@
// and Deny. Once answered it shrinks to one line.
import { Ban, Check, CheckCheck, Hand, TimerOff } from "lucide-react";
-import { useId } from "react";
import { useT } from "../../i18n";
import type { AllowScope, ApprovalItem, Bot } from "../../lib/protocol.gen";
import { Button } from "../../ui/Button";
+import { NoteInput, RequestCard, SettledLine } from "../../ui/ChatCard";
import { Details } from "../../ui/Details";
import { HELP_TOOL, HelpCard } from "../browser/HelpCard";
import { SITE_TOOL, SiteCard } from "../browser/SiteCard";
@@ -29,35 +29,28 @@ function About({ approval }: { approval: ApprovalItem }) {
function Answered({ approval }: { approval: ApprovalItem }) {
const t = useT();
const label = toolAction(approval.toolName, t.tools);
- const line = "flex min-w-0 items-center gap-2 rounded-lg px-1.5 py-1 text-sm";
switch (approval.status) {
case "allowed":
return (
-
-
- {t.chat.approval.allowed(label)}
-
-
+ }
+ />
);
case "denied":
return (
-
-
- {t.chat.approval.denied(label)}
- {approval.note ? (
- {`“${approval.note}”`}
- ) : (
-
- )}
-
+ }
+ />
);
default:
- return (
-
-
- {t.chat.approval.expired(label)}
-
- );
+ return ;
}
}
@@ -134,23 +127,18 @@ function ToolApproval({ approval, bot }: { approval: ApprovalItem; bot: Bot }) {
allow: t.chat.approval.allowFailed,
deny: t.chat.approval.denyFailed,
});
- const noteId = useId();
if (approval.status !== "pending") {
return ;
}
const label = toolAction(approval.toolName, t.tools);
return (
-
+
);
}
diff --git a/app/src/features/chat/PlanCard.tsx b/app/src/features/chat/PlanCard.tsx
index 44157ecd..e37252ab 100644
--- a/app/src/features/chat/PlanCard.tsx
+++ b/app/src/features/chat/PlanCard.tsx
@@ -2,11 +2,11 @@
// it as the ExitPlanMode tool, with the plan. Approving lets the bot work;
// sending it back keeps it planning, with what should change.
-import { Check, ChevronRight, ListTodo, PencilLine, TimerOff } from "lucide-react";
-import { useId } from "react";
+import { Check, ListTodo, PencilLine, TimerOff } from "lucide-react";
import { useT } from "../../i18n";
import type { ApprovalItem, Bot } from "../../lib/protocol.gen";
import { Button } from "../../ui/Button";
+import { NoteArea, SectionCard, SettledLine } from "../../ui/ChatCard";
import { Markdown } from "./Markdown";
import { useAnswer } from "./useAnswer";
@@ -34,77 +34,58 @@ export function PlanCard({ approval, bot }: { approval: ApprovalItem; bot: Bot }
allow: p.approveFailed,
deny: p.keepFailed,
});
- const noteId = useId();
const plan = planText(approval.input);
if (approval.status !== "pending") {
return ;
}
return (
-
+
+
+ answer(true)}>
+ {p.approve}
+
+ answer(false)}>
+ {p.keepPlanning}
+
+
+ >
+ }
>
-
-
-
-
- {p.ready(bot.name)}
-
-
-
- {p.noteLabel(bot.name)}
-
-
-
+
);
}
/** One line once answered; the plan stays a click away. */
function Answered({ approval, plan }: { approval: ApprovalItem; plan: string }) {
const p = useT().chat.plan;
- const [Icon, text, tone] =
+ const [icon, text, tone] =
approval.status === "allowed"
- ? [Check, p.approved, "text-ok"]
+ ? ([Check, p.approved, "ok"] as const)
: approval.status === "denied"
- ? [PencilLine, p.sentBack, "text-work"]
- : [TimerOff, p.expired, "text-quiet"];
+ ? ([PencilLine, p.sentBack, "work"] as const)
+ : ([TimerOff, p.expired, "quiet"] as const);
return (
-
-
-
- {text}
- {approval.note && (
- {`“${approval.note}”`}
- )}
-
-
-
-
-
-
+ }
+ />
);
}
diff --git a/app/src/features/chat/SuggestionCard.tsx b/app/src/features/chat/SuggestionCard.tsx
index 1d87109b..1cb5b558 100644
--- a/app/src/features/chat/SuggestionCard.tsx
+++ b/app/src/features/chat/SuggestionCard.tsx
@@ -2,11 +2,12 @@
// the name, role, model and instructions before creating it, or say no
// with a note for the chief. Once answered it shrinks to one line.
-import { Ban, Check, ChevronRight, TimerOff, UserPlus } from "lucide-react";
-import { useId, useState } from "react";
+import { Ban, Check, TimerOff, UserPlus } from "lucide-react";
+import { useState } from "react";
import { useT } from "../../i18n";
import type { ApprovalItem, Bot, BotModel } from "../../lib/protocol.gen";
import { Button } from "../../ui/Button";
+import { NoteArea, SectionCard, SettledLine } from "../../ui/ChatCard";
import { SelectField, TextArea, TextField } from "../../ui/Field";
import { useAnswer } from "./useAnswer";
@@ -47,7 +48,6 @@ export function SuggestionCard({ approval, bot }: { approval: ApprovalItem; bot:
allow: s.createFailed,
deny: s.declineFailed,
});
- const noteId = useId();
if (approval.status !== "pending") {
return ;
}
@@ -56,16 +56,34 @@ export function SuggestionCard({ approval, bot }: { approval: ApprovalItem; bot:
setEdited((current) => ({ ...current, [field]: value }));
return (
-
+
+
+ answer(true, changed ? JSON.stringify(edited) : undefined)}
+ >
+ {s.create}
+
+ answer(false)}>
+ {s.decline}
+
+
+ >
+ }
>
-
-
-
-
- {s.title(bot.name)}
-
{asked.reason && (
@@ -99,33 +117,7 @@ export function SuggestionCard({ approval, bot }: { approval: ApprovalItem; bot:
/>
{s.startsNow(bot.name)}
-
-
- {s.noteLabel(bot.name)}
-
-
-
+
);
}
@@ -135,37 +127,28 @@ function Answered({ approval }: { approval: ApprovalItem }) {
const s = t.chat.suggestion;
const suggestion = suggestionOf(approval.input);
const name = suggestion.name || s.name;
- const [Icon, text, tone] =
+ const [icon, text, tone] =
approval.status === "allowed"
- ? [Check, s.created(name), "text-ok"]
+ ? ([Check, s.created(name), "ok"] as const)
: approval.status === "denied"
- ? [Ban, s.declined(name), "text-danger"]
- : [TimerOff, s.expired(name), "text-quiet"];
+ ? ([Ban, s.declined(name), "danger"] as const)
+ : ([TimerOff, s.expired(name), "quiet"] as const);
return (
-
-
-
- {text}
- {approval.note && (
- {`“${approval.note}”`}
- )}
-
-
-
- {s.role}
- {suggestion.role}
- {s.model}
- {t.chat.model.names[suggestion.model ?? "default"]}
- {s.instructions}
- {suggestion.instructions}
-
-
+
+ {s.role}
+ {suggestion.role}
+ {s.model}
+ {t.chat.model.names[suggestion.model ?? "default"]}
+ {s.instructions}
+ {suggestion.instructions}
+
+ }
+ />
);
}
diff --git a/app/src/features/questions/QuestionCard.tsx b/app/src/features/questions/QuestionCard.tsx
index c065d6c2..a2891899 100644
--- a/app/src/features/questions/QuestionCard.tsx
+++ b/app/src/features/questions/QuestionCard.tsx
@@ -3,12 +3,13 @@
// bot's chat and in the question box. Once closed it shrinks to one line.
import { Check, MessageCircleQuestion, Reply, X } from "lucide-react";
-import { useId, useState } from "react";
+import { useState } from "react";
import { useT } from "../../i18n";
import { when } from "../../lib/format";
import type { Question } from "../../lib/protocol.gen";
import { useApi } from "../../store/context";
import { Button } from "../../ui/Button";
+import { NoteArea, SectionCard, SettledLine } from "../../ui/ChatCard";
import { attempt } from "../../ui/toast";
import { Markdown } from "../chat/Markdown";
@@ -25,7 +26,6 @@ function OpenQuestion({ question, bot }: { question: Question; bot: string }) {
const api = useApi();
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
- const fieldId = useId();
const answer = async (value: string) => {
setBusy(true);
@@ -37,6 +37,11 @@ function OpenQuestion({ question, bot }: { question: Question; bot: string }) {
setText("");
}
};
+ const answerText = () => {
+ if (text.trim()) {
+ void answer(text);
+ }
+ };
const dismiss = async () => {
setBusy(true);
await attempt(q.dismissFailed, () =>
@@ -46,16 +51,42 @@ function OpenQuestion({ question, bot }: { question: Question; bot: string }) {
};
return (
- {
+ event.preventDefault();
+ answerText();
+ }}
+ >
+
+
+
+ {q.send}
+
+
+ {q.dismiss}
+
+
+
+ }
>
-
-
-
-
- {q.asks(bot)}
-
{question.options.length > 0 && (
@@ -68,89 +99,40 @@ function OpenQuestion({ question, bot }: { question: Question; bot: string }) {
)}
-
-
+
);
}
/** One line: what the owner answered, or that they dismissed it. */
function Closed({ question }: { question: Question }) {
- const t = useT();
- const q = t.questions.card;
+ const q = useT().questions.card;
const answered = question.status === "answered";
return (
-
-
- {answered ? (
-
- ) : (
-
- )}
- {answered ? q.answered : q.dismissed}
- {answered && question.answer && (
- {`“${question.answer}”`}
- )}
- {question.answeredAt !== null && (
+
{when(question.answeredAt)}
- )}
-
-
-
- {answered && question.answer && (
-
- {question.answer}
-
- )}
-
-
+ )
+ }
+ details={
+ <>
+
+ {answered && question.answer && (
+
+ {question.answer}
+
+ )}
+ >
+ }
+ />
);
}
diff --git a/app/src/features/routines/RoutineRequestCard.tsx b/app/src/features/routines/RoutineRequestCard.tsx
index 5221c35f..2e234e6f 100644
--- a/app/src/features/routines/RoutineRequestCard.tsx
+++ b/app/src/features/routines/RoutineRequestCard.tsx
@@ -4,14 +4,15 @@
// explains with a reason is worded here. Once answered it shrinks to one
// line.
-import { AlarmClock, Ban, Check, ChevronRight, TimerOff } from "lucide-react";
-import { useId, useState } from "react";
+import { AlarmClock, Ban, Check, TimerOff } from "lucide-react";
+import { useState } from "react";
import { useT } from "../../i18n";
import { errorText } from "../../lib/api";
import { type ApprovalItem, type Bot, FIELD_LIMITS } from "../../lib/protocol.gen";
import { RpcError } from "../../lib/rpc";
import { useApi, useApp } from "../../store/context";
import { Button } from "../../ui/Button";
+import { NoteArea, SectionCard, SettledLine } from "../../ui/ChatCard";
import { TextArea, TextField } from "../../ui/Field";
import { describeSchedule, systemZone } from "./describe";
import { formOf, type RoutineFields, type RoutineForm, scheduleOf } from "./form";
@@ -78,7 +79,6 @@ export function RoutineRequestCard({ approval, bot }: { approval: ApprovalItem;
const [note, setNote] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
- const noteId = useId();
if (approval.status !== "pending") {
return ;
}
@@ -108,16 +108,34 @@ export function RoutineRequestCard({ approval, bot }: { approval: ApprovalItem;
const title = asked.bot ? r.titleFor(bot.name, runner.name) : r.title(bot.name);
return (
-
+
+
+ answer(true)}
+ >
+ {r.create}
+
+ answer(false)}>
+ {r.decline}
+
+
+ >
+ }
>
-
-
-
-
- {title}
-
)}
-
-
- {r.noteLabel(bot.name)}
-
-
setNote(event.target.value)}
- placeholder={r.notePlaceholder(bot.name)}
- className="block w-full resize-none rounded-xl border border-line-strong bg-panel px-3 py-2 text-sm outline-none placeholder:text-muted focus:border-muted"
- />
-
- answer(true)}
- >
- {r.create}
-
- answer(false)}>
- {r.decline}
-
-
-
-
+
);
}
@@ -177,36 +169,27 @@ function Answered({ approval }: { approval: ApprovalItem }) {
const r = t.chat.routineRequest;
const { routine } = askedOf(approval.input);
const name = routine.name || t.routines.dialog.name;
- const [Icon, text, tone] =
+ const [icon, text, tone] =
approval.status === "allowed"
- ? [Check, r.created(name), "text-ok"]
+ ? ([Check, r.created(name), "ok"] as const)
: approval.status === "denied"
- ? [Ban, r.declined(name), "text-danger"]
- : [TimerOff, r.expired(name), "text-quiet"];
+ ? ([Ban, r.declined(name), "danger"] as const)
+ : ([TimerOff, r.expired(name), "quiet"] as const);
return (
-
-
-
- {text}
- {approval.note && (
- {`“${approval.note}”`}
- )}
-
-
-
- {t.routines.dialog.when}
- {describeSchedule(routine.schedule, t.routines.when)}
- {t.routines.dialog.name}
- {routine.name}
- {routine.prompt}
-
-
+
+ {t.routines.dialog.when}
+ {describeSchedule(routine.schedule, t.routines.when)}
+ {t.routines.dialog.name}
+ {routine.name}
+ {routine.prompt}
+
+ }
+ />
);
}
diff --git a/app/src/ui/Badge.tsx b/app/src/ui/Badge.tsx
new file mode 100644
index 00000000..6b9f24ce
--- /dev/null
+++ b/app/src/ui/Badge.tsx
@@ -0,0 +1,41 @@
+// Small labels (spec 15.3): a pill with a word and maybe an icon, and the
+// round count beside a crew, the question box and the crews' cards.
+
+import type { LucideIcon } from "lucide-react";
+import type { ReactNode } from "react";
+import { TONES, type Tone } from "./tone";
+
+/** A word in a soft pill of its tone, like "Chief" or "Closed". */
+export function Badge({
+ tone,
+ icon: Icon,
+ title,
+ children,
+}: {
+ tone: Tone;
+ icon?: LucideIcon;
+ title?: string;
+ children: ReactNode;
+}) {
+ return (
+
+ {Icon && }
+ {children}
+
+ );
+}
+
+/** A number in a solid circle; `label` says it in words for screen readers. */
+export function CountBadge({ tone, count, label }: { tone: Tone; count: number; label: string }) {
+ return (
+
+ {count}
+ {label}
+
+ );
+}
diff --git a/app/src/ui/ChatCard.tsx b/app/src/ui/ChatCard.tsx
new file mode 100644
index 00000000..8ca2a130
--- /dev/null
+++ b/app/src/ui/ChatCard.tsx
@@ -0,0 +1,193 @@
+// The cards a bot puts in front of the owner in its chat (spec 15.1):
+// permission requests, plans, bot and routine suggestions, questions.
+// Two shapes share one look: a compact request with a tinted border, and
+// a card in sections (head, body, a footer with the owner's note). Once
+// answered, a card shrinks to one settled line.
+
+import { ChevronRight, type LucideIcon } from "lucide-react";
+import { type ReactNode, useId } from "react";
+import { TONES, type Tone } from "./tone";
+
+/** The round icon at the head of a card. */
+export function IconBadge({ icon: Icon, tone }: { icon: LucideIcon; tone: Tone }) {
+ return (
+
+
+
+ );
+}
+
+interface CardHead {
+ /** What a screen reader calls the card. */
+ label: string;
+ icon: LucideIcon;
+ tone: Tone;
+ /** The line beside the icon; `label` when absent. */
+ title?: ReactNode;
+}
+
+/** A short request, its border in the card's tone: a command, a site. */
+export function RequestCard({
+ label,
+ icon,
+ tone,
+ title,
+ children,
+}: CardHead & { children: ReactNode }) {
+ return (
+
+
+
+ {title ?? label}
+
+ {children}
+
+ );
+}
+
+/**
+ * A longer card: the head, the body as the caller lays it out, and a
+ * footer, usually the owner's note and the buttons.
+ */
+export function SectionCard({
+ label,
+ icon,
+ tone,
+ title,
+ children,
+ footer,
+}: CardHead & { children: ReactNode; footer?: ReactNode }) {
+ return (
+
+
+
+ {title ?? label}
+
+ {children}
+ {footer && {footer}
}
+
+ );
+}
+
+interface NoteProps {
+ /** Said to screen readers; the field shows `placeholder`. */
+ label: string;
+ value: string;
+ placeholder: string;
+ onChange(value: string): void;
+}
+
+const NOTE =
+ "rounded-xl border border-line-strong px-3 text-sm outline-none placeholder:text-muted focus:border-muted";
+
+/** One line for the owner's note, in a request card. */
+export function NoteInput({ label, value, placeholder, onChange }: NoteProps) {
+ const id = useId();
+ return (
+ <>
+
+ {label}
+
+ onChange(event.target.value)}
+ placeholder={placeholder}
+ className={`mt-3 h-9 w-full bg-canvas ${NOTE}`}
+ />
+ >
+ );
+}
+
+/** Two lines for the owner's note, in a card's footer. */
+export function NoteArea({
+ label,
+ value,
+ placeholder,
+ onChange,
+ onEnter,
+}: NoteProps & {
+ /** Enter without Shift sends; Shift+Enter starts a new line. */
+ onEnter?: () => void;
+}) {
+ const id = useId();
+ return (
+ <>
+
+ {label}
+
+ onChange(event.target.value)}
+ onKeyDown={(event) => {
+ if (onEnter && event.key === "Enter" && !event.shiftKey) {
+ event.preventDefault();
+ onEnter();
+ }
+ }}
+ placeholder={placeholder}
+ className={`block w-full resize-none bg-panel py-2 ${NOTE}`}
+ />
+ >
+ );
+}
+
+/**
+ * How an answered card reads: an icon in its tone, what happened, and the
+ * owner's note. With `details`, the line opens to show what was asked.
+ */
+export function SettledLine({
+ icon: Icon,
+ tone,
+ text,
+ note,
+ aside,
+ details,
+}: {
+ icon: LucideIcon;
+ tone: Tone;
+ text: string;
+ note?: string | null;
+ /** After the note: what it was about, or when it happened. */
+ aside?: ReactNode;
+ details?: ReactNode;
+}) {
+ const line = (
+ <>
+
+ {text}
+ {note && {`“${note}”`} }
+ {aside}
+ >
+ );
+ const row = "flex min-w-0 items-center gap-2 rounded-lg px-1.5 py-1 text-sm";
+ if (!details) {
+ return {line}
;
+ }
+ return (
+
+
+ {line}
+
+
+
+ {details}
+
+
+ );
+}
diff --git a/app/src/ui/tone.ts b/app/src/ui/tone.ts
new file mode 100644
index 00000000..d2b6c084
--- /dev/null
+++ b/app/src/ui/tone.ts
@@ -0,0 +1,63 @@
+// The tones the app colors things by (spec 15.3), each with the classes
+// for its uses, written out whole so Tailwind finds them. One table, so a
+// badge, a card's icon and a notice of the same tone always match.
+
+export type Tone = "accent" | "work" | "ok" | "warn" | "danger" | "quiet";
+
+interface ToneClasses {
+ /** An icon or a mark in the tone. */
+ text: string;
+ /** A soft fill with the tone on it: badges, a card's round icon. */
+ soft: string;
+ /** A border in the tone, for a card that asks for something. */
+ border: string;
+ /** A notice's frame: border and a faint fill. */
+ frame: string;
+ /** A solid fill, for counts. */
+ solid: string;
+}
+
+export const TONES: Record = {
+ accent: {
+ text: "text-accent",
+ soft: "bg-accent/12 text-accent",
+ border: "border-accent/40",
+ frame: "border-accent/40 bg-accent/6",
+ solid: "bg-accent text-canvas",
+ },
+ work: {
+ text: "text-work",
+ soft: "bg-work/12 text-work",
+ border: "border-work/40",
+ frame: "border-work/40 bg-work/6",
+ solid: "bg-work text-canvas",
+ },
+ ok: {
+ text: "text-ok",
+ soft: "bg-ok/12 text-ok",
+ border: "border-ok/40",
+ frame: "border-ok/40 bg-ok/6",
+ solid: "bg-ok text-canvas",
+ },
+ warn: {
+ text: "text-warn",
+ soft: "bg-warn/12 text-warn",
+ border: "border-warn/40",
+ frame: "border-warn/40 bg-warn/6",
+ solid: "bg-warn text-canvas",
+ },
+ danger: {
+ text: "text-danger",
+ soft: "bg-danger/12 text-danger",
+ border: "border-danger/40",
+ frame: "border-danger/40 bg-danger/6",
+ solid: "bg-danger text-canvas",
+ },
+ quiet: {
+ text: "text-quiet",
+ soft: "bg-sunken text-muted",
+ border: "border-line",
+ frame: "border-line bg-panel",
+ solid: "bg-quiet text-canvas",
+ },
+};
From 83cb0ee7d8736f861fe944f7766017e5f61f4a96 Mon Sep 17 00:00:00 2001
From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com>
Date: Sat, 3 Oct 2026 14:50:14 -0300
Subject: [PATCH 2/2] feat(app): badge, tone and empty state primitives
Badge and CountBadge replace the hand-written pills and counts; the
Callout, the chat notices and the bot state read the one tone table, so
notice borders agree at /40; EmptyState draws the empty browser, files,
screens and question box. Spec 15.3 lists the shared pieces.
---
app/src/features/account/ArchivedSettings.tsx | 7 +---
app/src/features/bots/BotHeader.tsx | 9 ++--
app/src/features/bots/BotStateBadge.tsx | 13 ++----
app/src/features/bots/ChiefBadge.tsx | 9 ++--
app/src/features/browser/BrowserPanel.tsx | 27 ++++--------
app/src/features/chat/NoticeRow.tsx | 7 ++--
app/src/features/crews/CrewsOverview.tsx | 6 +--
app/src/features/crews/Sidebar.tsx | 6 +--
app/src/features/crews/SidebarPages.tsx | 8 +---
app/src/features/files/FilesPanel.tsx | 17 ++------
app/src/features/questions/QuestionBox.tsx | 23 +++++-----
app/src/features/screens/ScreensPanel.tsx | 20 ++++-----
app/src/ui/Callout.tsx | 7 ++--
app/src/ui/EmptyState.tsx | 42 +++++++++++++++++++
app/src/ui/tone.ts | 5 ++-
docs/spec.md | 2 +
16 files changed, 101 insertions(+), 107 deletions(-)
create mode 100644 app/src/ui/EmptyState.tsx
diff --git a/app/src/features/account/ArchivedSettings.tsx b/app/src/features/account/ArchivedSettings.tsx
index 7666c76d..cb83d019 100644
--- a/app/src/features/account/ArchivedSettings.tsx
+++ b/app/src/features/account/ArchivedSettings.tsx
@@ -11,6 +11,7 @@ import type { Archive, Bot, Crew } from "../../lib/protocol.gen";
import { useApi, useApp } from "../../store/context";
import { Button } from "../../ui/Button";
import { Callout } from "../../ui/Callout";
+import { IconBadge } from "../../ui/ChatCard";
import { BotAvatar } from "../bots/BotAvatar";
import { DeleteBot } from "../bots/DeleteBot";
import { DeleteCrew } from "../crews/DeleteCrew";
@@ -73,11 +74,7 @@ export function ArchivedSettings() {
{archive.crews.map((crew) => (
-
-
- }
+ icon={ }
name={crew.name}
line={s.archivedCrew(botsOf(crew).length, fromNow(crew.archivedAt ?? 0))}
onDelete={() => setAsking({ crew })}
diff --git a/app/src/features/bots/BotHeader.tsx b/app/src/features/bots/BotHeader.tsx
index f48f272b..e54427dc 100644
--- a/app/src/features/bots/BotHeader.tsx
+++ b/app/src/features/bots/BotHeader.tsx
@@ -12,6 +12,7 @@ import {
import { useT } from "../../i18n";
import type { Bot, Crew } from "../../lib/protocol.gen";
import { useApi, useApp } from "../../store/context";
+import { Badge } from "../../ui/Badge";
import { Button } from "../../ui/Button";
import { Menu } from "../../ui/Menu";
import { attempt } from "../../ui/toast";
@@ -91,13 +92,9 @@ export function BotHeader({
{chief && }
{bot.permissionMode === "bypass_permissions" && (
-
-
+
{t.chat.mode.badge}
-
+
)}
{bot.role || words.noRole}
diff --git a/app/src/features/bots/BotStateBadge.tsx b/app/src/features/bots/BotStateBadge.tsx
index 6d67eeb4..e2b67ab7 100644
--- a/app/src/features/bots/BotStateBadge.tsx
+++ b/app/src/features/bots/BotStateBadge.tsx
@@ -10,9 +10,10 @@ import {
} from "lucide-react";
import { type Messages, t, useT } from "../../i18n";
import type { Bot, BotState } from "../../lib/protocol.gen";
+import { TONES, type Tone as ToneName } from "../../ui/tone";
import { ReadyIcon, type StateIcon, WorkingIcon } from "./StateIcons";
-type Tone = "ok" | "work" | "warn" | "danger" | "quiet";
+type Tone = Exclude;
export interface StateView {
label: string;
@@ -57,14 +58,6 @@ export function stateView(
return { ...looks[bot.state], ...words[bot.state] };
}
-const toneText: Record = {
- ok: "text-ok",
- work: "text-work",
- warn: "text-warn",
- danger: "text-danger",
- quiet: "text-quiet",
-};
-
export function BotStateBadge({
bot,
crewPaused,
@@ -80,7 +73,7 @@ export function BotStateBadge({
return (
crew.leadBotId === bot.id;
@@ -18,12 +19,8 @@ export function ChiefBadge({ crew, compact = false }: { crew: Crew; compact?: bo
);
}
return (
-
-
+
{c.badge}
-
+
);
}
diff --git a/app/src/features/browser/BrowserPanel.tsx b/app/src/features/browser/BrowserPanel.tsx
index 0a05dfd7..0d21a5c8 100644
--- a/app/src/features/browser/BrowserPanel.tsx
+++ b/app/src/features/browser/BrowserPanel.tsx
@@ -9,8 +9,10 @@ import { useT } from "../../i18n";
import type { Bot } from "../../lib/protocol.gen";
import { SYSTEM } from "../../lib/system";
import { useApp } from "../../store/context";
+import { Badge } from "../../ui/Badge";
import { Button } from "../../ui/Button";
import { Callout } from "../../ui/Callout";
+import { EmptyState } from "../../ui/EmptyState";
import { SidePanel } from "../../ui/SidePanel";
import { BotAvatar } from "../bots/BotAvatar";
import { AddressBar } from "./AddressBar";
@@ -81,19 +83,15 @@ export function BrowserPanel({
{t.heading}
{resting ? (
-
-
+
{t.resting}
-
+
) : (
live && (
-
+
{t.live}
-
+
)
)}
@@ -211,17 +209,6 @@ function Overlay({ busy, title, body }: { busy: boolean; title: string; body: st
function Empty({ bot }: { bot: Bot }) {
const t = useT().browser;
return (
-
-
-
-
-
-
{t.emptyTitle(bot.name)}
-
{t.emptyBody}
-
+
);
}
diff --git a/app/src/features/chat/NoticeRow.tsx b/app/src/features/chat/NoticeRow.tsx
index 7f8ecddf..ffb39fee 100644
--- a/app/src/features/chat/NoticeRow.tsx
+++ b/app/src/features/chat/NoticeRow.tsx
@@ -6,11 +6,12 @@ import { memo } from "react";
import { type Messages, useT } from "../../i18n";
import type { NoticeItem } from "../../lib/protocol.gen";
import { useArrival } from "../../ui/motion";
+import { TONES } from "../../ui/tone";
const LOOK = {
- info: { icon: Info, tone: "text-work", frame: "border-line bg-panel" },
- warning: { icon: TriangleAlert, tone: "text-warn", frame: "border-warn/40 bg-warn/6" },
- error: { icon: CircleAlert, tone: "text-danger", frame: "border-danger/40 bg-danger/6" },
+ info: { icon: Info, tone: TONES.work.text, frame: TONES.quiet.frame },
+ warning: { icon: TriangleAlert, tone: TONES.warn.text, frame: TONES.warn.frame },
+ error: { icon: CircleAlert, tone: TONES.danger.text, frame: TONES.danger.frame },
} as const;
/** `at` is when the notice came, so a new one animates in. */
diff --git a/app/src/features/crews/CrewsOverview.tsx b/app/src/features/crews/CrewsOverview.tsx
index 8662b2e9..fe66f7fc 100644
--- a/app/src/features/crews/CrewsOverview.tsx
+++ b/app/src/features/crews/CrewsOverview.tsx
@@ -6,6 +6,7 @@ import type { Bot, Crew } from "../../lib/protocol.gen";
import { botsOf, crewList } from "../../store/app";
import { useApp } from "../../store/context";
import { unreadIn } from "../../store/seen";
+import { CountBadge } from "../../ui/Badge";
import { Button } from "../../ui/Button";
import { BotAvatar, moodOf } from "../bots/BotAvatar";
import { CrewDialog } from "./CrewDialog";
@@ -72,10 +73,7 @@ function CrewCard({ crew }: { crew: Crew }) {
{t.crews.view.bots(bots.length)}
{unread > 0 && (
-
- {unread}
- {t.crews.sidebar.unreadCount(unread)}
-
+
)}
{bots.length > 0 && (
diff --git a/app/src/features/crews/Sidebar.tsx b/app/src/features/crews/Sidebar.tsx
index 3d8dac16..a4f447f2 100644
--- a/app/src/features/crews/Sidebar.tsx
+++ b/app/src/features/crews/Sidebar.tsx
@@ -8,6 +8,7 @@ import type { Activity, Bot, Crew } from "../../lib/protocol.gen";
import { activityOf, botsOf, crewList } from "../../store/app";
import { useApp } from "../../store/context";
import { isUnread, unreadIn } from "../../store/seen";
+import { CountBadge } from "../../ui/Badge";
import { Button } from "../../ui/Button";
import { ContextMenu, menuPoint, type Point } from "../../ui/ContextMenu";
import { APP_OPENED } from "../../ui/motion";
@@ -120,10 +121,7 @@ function CrewEntry({ crew }: { crew: Crew }) {
)}
{unread > 0 && (
-
- {unread}
- {t.crews.sidebar.unreadCount(unread)}
-
+
)}
{crew.paused && (
diff --git a/app/src/features/crews/SidebarPages.tsx b/app/src/features/crews/SidebarPages.tsx
index 2eb4945d..b7299793 100644
--- a/app/src/features/crews/SidebarPages.tsx
+++ b/app/src/features/crews/SidebarPages.tsx
@@ -5,6 +5,7 @@ import { MessageCircleQuestion, Search } from "lucide-react";
import { useT } from "../../i18n";
import { useApp } from "../../store/context";
import { openQuestions } from "../../store/questions";
+import { CountBadge } from "../../ui/Badge";
/** A row of the sidebar, as the conversations are. */
export const row =
@@ -54,12 +55,7 @@ export function QuestionsEntry() {
className={count > 0 ? "text-warn" : "text-muted"}
/>
{words.label}
- {count > 0 && (
-
- {count}
- {words.open(count)}
-
- )}
+ {count > 0 && }
);
diff --git a/app/src/features/files/FilesPanel.tsx b/app/src/features/files/FilesPanel.tsx
index e4aee84c..5dd43580 100644
--- a/app/src/features/files/FilesPanel.tsx
+++ b/app/src/features/files/FilesPanel.tsx
@@ -6,10 +6,11 @@ import { ArrowLeft, RefreshCw, X } from "lucide-react";
import { useT } from "../../i18n";
import { fileSize, fromNow } from "../../lib/format";
import type { Bot, BotFile } from "../../lib/protocol.gen";
+import { Badge } from "../../ui/Badge";
import { Button } from "../../ui/Button";
import { Callout } from "../../ui/Callout";
+import { EmptyState } from "../../ui/EmptyState";
import { SidePanel } from "../../ui/SidePanel";
-import { BotAvatar } from "../bots/BotAvatar";
import { FilePreview } from "./FilePreview";
import { fileKind, kindIcon } from "./kinds";
import type { BotFiles } from "./useBotFiles";
@@ -102,13 +103,7 @@ function List({
);
}
if (data.files.length === 0 && !data.loading) {
- return (
-
-
-
{t.emptyTitle(bot.name)}
-
{t.emptyBody}
-
- );
+ return ;
}
return (
@@ -145,11 +140,7 @@ function FileRow({
{file.name}
- {fresh && (
-
- {t.newTag}
-
- )}
+ {fresh && {t.newTag} }
{file.folder ? `${file.folder} · ` : ""}
diff --git a/app/src/features/questions/QuestionBox.tsx b/app/src/features/questions/QuestionBox.tsx
index 5633e38c..308706cc 100644
--- a/app/src/features/questions/QuestionBox.tsx
+++ b/app/src/features/questions/QuestionBox.tsx
@@ -8,6 +8,7 @@ import { AVATAR_PALETTE, type Question } from "../../lib/protocol.gen";
import { useApp } from "../../store/context";
import { openQuestions } from "../../store/questions";
import { Button } from "../../ui/Button";
+import { EmptyState } from "../../ui/EmptyState";
import { BotAvatar } from "../bots/BotAvatar";
import { QuestionCard } from "./QuestionCard";
@@ -74,20 +75,16 @@ function Entry({ question }: { question: Question }) {
}
function Empty() {
- const t = useT();
- const words = t.questions.box;
+ const words = useT().questions.box;
return (
-
-
-
-
-
-
{words.emptyTitle}
-
{words.emptyBody}
+
+
);
}
diff --git a/app/src/features/screens/ScreensPanel.tsx b/app/src/features/screens/ScreensPanel.tsx
index 97869223..bfc53c94 100644
--- a/app/src/features/screens/ScreensPanel.tsx
+++ b/app/src/features/screens/ScreensPanel.tsx
@@ -9,6 +9,7 @@ import { fileSize } from "../../lib/format";
import type { Bot, Screen, ScreenDevice } from "../../lib/protocol.gen";
import { Button } from "../../ui/Button";
import { Callout } from "../../ui/Callout";
+import { EmptyState } from "../../ui/EmptyState";
import { SidePanel } from "../../ui/SidePanel";
import { BotAvatar } from "../bots/BotAvatar";
import { DEVICES, LiveFrame } from "./LiveFrame";
@@ -142,18 +143,13 @@ function Board({
}
if (data.screens.length === 0 && !data.loading) {
return (
-
-
-
-
-
-
{t.emptyTitle(bot.name)}
-
{t.emptyBody}
-
+
);
}
const shown = all ? data.screens : data.screens.slice(0, BOARD_MAX);
diff --git a/app/src/ui/Callout.tsx b/app/src/ui/Callout.tsx
index 49891ec1..18291d2d 100644
--- a/app/src/ui/Callout.tsx
+++ b/app/src/ui/Callout.tsx
@@ -1,12 +1,13 @@
import { CircleAlert, Info, TriangleAlert } from "lucide-react";
import type { ReactNode } from "react";
+import { TONES } from "./tone";
type Tone = "info" | "warn" | "danger";
const tones: Record
= {
- info: { icon: Info, frame: "border-line bg-panel", mark: "text-work" },
- warn: { icon: TriangleAlert, frame: "border-warn/45 bg-warn/6", mark: "text-warn" },
- danger: { icon: CircleAlert, frame: "border-danger/45 bg-danger/6", mark: "text-danger" },
+ info: { icon: Info, frame: TONES.quiet.frame, mark: TONES.work.text },
+ warn: { icon: TriangleAlert, frame: TONES.warn.frame, mark: TONES.warn.text },
+ danger: { icon: CircleAlert, frame: TONES.danger.frame, mark: TONES.danger.text },
};
export function Callout({
diff --git a/app/src/ui/EmptyState.tsx b/app/src/ui/EmptyState.tsx
new file mode 100644
index 00000000..1aba09fc
--- /dev/null
+++ b/app/src/ui/EmptyState.tsx
@@ -0,0 +1,42 @@
+// What a panel or page shows with nothing in it yet (spec 15.3): the bot's
+// mascot, resting, with what the place is for, and an icon of it.
+
+import type { LucideIcon } from "lucide-react";
+import { BotAvatar } from "../features/bots/BotAvatar";
+
+export function EmptyState({
+ color,
+ icon: Icon,
+ title,
+ body,
+ framed = false,
+}: {
+ /** The mascot's color: the bot's, or the app's first for a page of all bots. */
+ color: string;
+ icon?: LucideIcon;
+ title: string;
+ body: string;
+ /** On a dotted board or a busy page, the text sits on a card. */
+ framed?: boolean;
+}) {
+ return (
+
+
+
+ {Icon && (
+
+ )}
+
+
{title}
+
{body}
+
+ );
+}
diff --git a/app/src/ui/tone.ts b/app/src/ui/tone.ts
index d2b6c084..701dff07 100644
--- a/app/src/ui/tone.ts
+++ b/app/src/ui/tone.ts
@@ -7,7 +7,8 @@ export type Tone = "accent" | "work" | "ok" | "warn" | "danger" | "quiet";
interface ToneClasses {
/** An icon or a mark in the tone. */
text: string;
- /** A soft fill with the tone on it: badges, a card's round icon. */
+ /** A soft fill with the tone on it: badges, a card's round icon. Words
+ * in the accent use `accent-text` (spec 15.3). */
soft: string;
/** A border in the tone, for a card that asks for something. */
border: string;
@@ -20,7 +21,7 @@ interface ToneClasses {
export const TONES: Record = {
accent: {
text: "text-accent",
- soft: "bg-accent/12 text-accent",
+ soft: "bg-accent/12 text-accent-text",
border: "border-accent/40",
frame: "border-accent/40 bg-accent/6",
solid: "bg-accent text-canvas",
diff --git a/docs/spec.md b/docs/spec.md
index 2a8021c0..16a7ff44 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -865,6 +865,8 @@ Ferramenta de trabalho densa e calma: tipografia forte, grid firme, estados dos
**Cores (tokens).** Toda cor vem de um token em `index.css` (`:root` para o tema claro, `[data-theme="dark"]` para o escuro), exposto ao Tailwind como `--color-*`: fundos `canvas`, `panel` e `sunken`; bordas `line` e `line-strong`; texto `ink`, `ink-soft` e `muted`; estados `ok`, `work`, `warn`, `danger` e `quiet`; `accent`, o laranja do Botloft, como preenchimento (botões, selos, bordas, ícones) e `accent-text` para palavras nessa cor; `read` (os tiques de lida), `scrim` (o fundo atrás de um diálogo) e `close` (o vermelho do botão fechar do Windows). Toda cor de texto passa de 4,5:1 (WCAG AA) sobre os três fundos nos dois temas, e um teste (`ui/tokens.test.ts`) confere isso a cada mudança; no tema claro o laranja de preenchimento não passa como texto, por isso o `accent-text` existe. Ícones podem usar `accent` (pedem 3:1). Ficam fora dos tokens só desenhos: o mascote, o cursor do bot, as cores do seletor de cor e o fundo branco da tela de uma página (22). Os cantos usam só a escala acima (8, 12 e 16 px, `rounded-lg`, `rounded-xl` e `rounded-2xl`) e o círculo (`rounded-full`).
+**Peças comuns** (`app/src/ui/`). Os tons (`accent`, `work`, `ok`, `warn`, `danger`, `quiet`) têm uma tabela só, `tone.ts`, com as classes de cada uso: texto, fundo suave (selos, o ícone redondo dos cartões), borda, moldura de aviso e preenchimento sólido (contadores). Em cima dela: `Badge` (selo com palavra, como "Chefe" ou "Ao vivo") e `CountBadge` (o número redondo de não lidas e de perguntas); `ChatCard` com o ícone do cabeçalho (`IconBadge`), o cartão de pedido com a borda no tom (`RequestCard`: comando, site, ajuda no navegador), o cartão em seções (`SectionCard`: plano, sugestão de bot, pedido de rotina, pergunta), os campos da nota do dono (`NoteInput`, `NoteArea`) e a linha de um cartão já respondido (`SettledLine`); `Callout` e os avisos do chat usam a moldura do tom; e `EmptyState`, o mascote com título e texto de um painel ou página vazios. Um cartão ou selo novo começa dessas peças.
+
Tamanho: a janela inteira é desenhada numa escala (zoom do webview, `setZoom`), então texto, espaçamento, ícones e avatares crescem juntos. Níveis 100%, 110%, 125% e 150%; o padrão é **125%**, porque o desenho a 100% ficava miúdo num monitor sem ampliação do Windows. Quem já usa a ampliação alta volta para 100% em Configurações (área da conta) ou com Ctrl+-; Ctrl+= aumenta e Ctrl+0 volta ao padrão. A escolha fica no `localStorage` (`botloft.zoom`) e é aplicada antes da primeira pintura, para a janela não piscar pequena. Acima de 150% a janela mínima (900 px) não cabe o layout.
Movimento: o app se mexe para parecer vivo, sem atrapalhar o trabalho. Animações curtas (150 a 400 ms, saída suave) em CSS, sem biblioteca (`motion.css`), e todas param quando o Windows desliga os efeitos de animação (`prefers-reduced-motion`) ou quando o dono liga "Menos animações" nas Configurações (`motion-less.css` repete as mesmas regras para `:root[data-motion="less"]`, e `reducedMotion()` olha os dois).