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/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/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/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/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/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/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/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/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/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/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
new file mode 100644
index 00000000..701dff07
--- /dev/null
+++ b/app/src/ui/tone.ts
@@ -0,0 +1,64 @@
+// 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. 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;
+ /** 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-text",
+ 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",
+ },
+};
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).