S3.3: pixel components: name-plate dialog titles, sunken fields, raised tabs, notched badges, XP bars, inventory slots, guide hint bubbles, game-over errors with Continue

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-09 12:23:17 -04:00
1 parent a6036308b7
commit 08ff96eec8
19 files changed
+149 -86

No files matched your search

+7 -7
View File
@@ -1,4 +1,4 @@
import { Button } from "../shared/ui";
import { GameOver } from "../shared/GameOver";
import { t } from "../i18n/i18n";
import type { SyncFailure } from "./useSnapshot";
@@ -7,12 +7,12 @@ export function StartupScreen({ failure, onRetry }: { failure: SyncFailure | nul
return (
<div className="app-shell" style={{ display: "grid", placeItems: "center", padding: 24 }}>
{failure ? (
<div role="alert" style={{ maxWidth: 480, display: "grid", gap: 12, textAlign: "center" }}>
<strong>{t("AgentCompany could not load its data.")}</strong>
<div className="mono" style={{ fontSize: 12, color: "var(--text-2)", wordBreak: "break-word" }}>{failure.message}</div>
<div style={{ fontSize: 13, color: "var(--text-3)" }}>{t("Retrying every few seconds (attempt {count}).", { count: failure.attempts })}</div>
<div><Button onClick={onRetry}>{t("Retry now")}</Button></div>
</div>
<GameOver
title={t("AgentCompany could not load its data.")}
error={failure.message}
note={t("Retrying every few seconds (attempt {count}); Continue tries now.", { count: failure.attempts })}
onContinue={onRetry}
/>
) : (
<div role="status" style={{ color: "var(--text-3)" }}>{t("Starting AgentCompany…")}</div>
)}
+7 -5
View File
@@ -601,6 +601,13 @@ export const zh: Record<string, string> = {
"Version (semver)": "版本(semver)",
"Visibility": "可见性",
"Needs you": "需要你处理",
"Goal progress": "目标进度",
"Budget allocated": "已分配预算",
"Budget compared with the largest agent budget": "与最高智能体预算相比",
"GAME OVER": "游戏结束",
"The rest of the app still works; Continue tries this page again.": "应用其他部分仍可使用;点“继续”会重新打开此页。",
"Continue reloads the window; your data is kept.": "点“继续”会重新加载窗口;数据会保留。",
"Retrying every few seconds (attempt {count}); Continue tries now.": "每隔几秒自动重试(第 {count} 次);点“继续”立即重试。",
"Follow the clock": "跟随时间(夜间变暗)",
"Next in line": "下一项工作",
"Stopped: {reason}": "已停下:{reason}",
@@ -742,12 +749,7 @@ export const zh: Record<string, string> = {
"Stay": "留下",
"This page could not be shown.": "此页面无法显示。",
"AgentCompany hit an error it could not recover from here.": "AgentCompany 遇到了无法在此恢复的错误。",
"The rest of the app still works; go to another page or try this one again.": "应用的其他部分仍可使用;可以前往其他页面,或重试此页面。",
"Reloading the window starts it again; your data is kept.": "重新加载窗口即可重新开始;你的数据会保留。",
"Try again": "重试",
"Reload": "重新加载",
"AgentCompany could not load its data.": "AgentCompany 无法加载数据。",
"Retrying every few seconds (attempt {count}).": "每隔几秒重试一次(第 {count} 次)。",
"Retry now": "立即重试",
"Starting AgentCompany…": "正在启动 AgentCompany…",
"Direct: only its members take part, and every message wakes the other members. As the board, you can read it even when you are not a member.": "私聊:只有成员参与,每条消息都会唤醒其他成员。作为董事会,即使你不是成员也能阅读。",
+2 -1
View File
@@ -23,7 +23,8 @@
from { opacity: 0; transform: translateY(8px) scale(0.98); }
to { opacity: 1; transform: none; }
}
.modal-title { margin: 0 0 var(--sp-4); font-size: var(--fs-title); font-weight: 600; letter-spacing: 0; }
/* Dialog name plate, as a speaker's name above an RPG text box. */
.modal-title { display: inline-block; margin: 0 0 var(--sp-4); padding: 2px 10px; font-size: var(--fs-title); font-weight: 400; background: var(--btn-primary-bg); color: var(--btn-primary-fg); box-shadow: 2px 2px 0 0 var(--border-strong); }
.modal-actions { display: flex; justify-content: flex-end; gap: var(--sp-2); margin-top: var(--sp-5); }
.onboarding-window {
+3 -6
View File
@@ -1,3 +1,4 @@
import { XpBar } from "../shared/XpBar";
import { runTriggerLabel } from "../i18n/labels";
import { goalProgress, type GoalPace } from "./home/goal-progress";
import { nextUp } from "./home/next-up";
@@ -122,9 +123,7 @@ function GoalCard({
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{progress?.pace ? statusLabel(progress.pace) : t("no target date")}</span>
{goal && <TargetDate goal={goal} actions={actions} />}
</div>
<div style={{ marginTop: 12, height: 4, background: "var(--panel-3)", borderRadius: 999, overflow: "hidden" }}>
<div style={{ width: `${percent}%`, height: "100%", background: "var(--text-1)", transition: "width var(--motion-slow) var(--ease-out)" }} />
</div>
<div style={{ marginTop: 12 }}><XpBar percent={percent} label={t("Goal progress")} /></div>
{goal?.status === "active" && goal.stalledReason && (
<div className="row" style={{ marginTop: 12, gap: 8, alignItems: "center" }}>
<span className="form-error" role="status" style={{ flex: 1, marginTop: 0 }}>{goal.stalledReason}</span>
@@ -156,9 +155,7 @@ function SpendCard({
<span className="num" style={{ fontSize: 18, fontWeight: 600 }}>{money(totalBudgetCents)}</span>
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{capCents > 0 ? `/ ${money(capCents)}` : t("allocated")}</span>
</div>
<div style={{ marginTop: 12, height: 4, background: "var(--panel-3)", borderRadius: 999, overflow: "hidden" }}>
<div style={{ width: `${fillPct}%`, height: "100%", background: "var(--text-1)" }} />
</div>
<div style={{ marginTop: 12 }}><XpBar percent={fillPct} label={t("Budget allocated")} /></div>
<div style={{ marginTop: 12, display: "flex", flexDirection: "column", gap: 8 }}>
{connected.slice(0, 3).map((provider) => {
const allocatedCents = agents.filter((agent) => agent.modelProviderId === provider.id).reduce((sum, agent) => sum + agent.budgetCents, 0);
+2 -1
View File
@@ -1,3 +1,4 @@
import { HintBubble } from "../shared/HintBubble";
import { oneOf, usePersistentState } from "../shared/view-state";
import { useMemo } from "react";
import { DocumentsPanel } from "./library/DocumentsPanel";
@@ -49,7 +50,7 @@ export function LibraryPage({ snapshot, actions, focus }: PageProps) {
<div className="content">
<div className="page">
<h1 className="page-h1">{tab === "documents" ? t("Documents") : t("Knowledge")}</h1>
<p className="page-sub">{t("Agents read and write these while working on tasks. Nothing here starts work; a task does.")}</p>
<HintBubble>{t("Agents read and write these while working on tasks. Nothing here starts work; a task does.")}</HintBubble>
{tab === "documents"
? <DocumentsPanel focusId={focus?.kind === "document" ? focus.id : null} orgId={orgId} employeeId={employee.id} documents={documents} tasks={tasks} actions={actions} />
: <KnowledgePanel focusId={focus?.kind === "knowledge" ? focus.id : null} orgId={orgId} employeeId={employee.id} entries={entries} scopes={scopes} actions={actions} />}
+3 -8
View File
@@ -1,3 +1,4 @@
import { XpBar } from "../shared/XpBar";
import { useRevealObject } from "../shared/reveal";
import { oneOf, usePersistentState } from "../shared/view-state";
import { agentModel } from "../../../shared/agent-model";
@@ -322,14 +323,8 @@ function RosterRow({ row, last, onEdit }: { row: RosterEntry; last: boolean; onE
<span style={{ color: "var(--text-4)" }}>—</span>
) : (
<div className="row" style={{ gap: 8 }}>
<div style={{ flex: 1, height: 3, background: "var(--panel-3)", borderRadius: 2, overflow: "hidden", maxWidth: 100 }}>
<div
style={{
width: `${row.budgetCapCents && row.budgetCapCents > 0 ? Math.min(100, Math.round((row.budgetCents / row.budgetCapCents) * 100)) : 0}%`,
height: "100%",
background: "var(--text-2)",
}}
/>
<div style={{ flex: 1, maxWidth: 100 }}>
<XpBar percent={row.budgetCapCents && row.budgetCapCents > 0 ? (row.budgetCents / row.budgetCapCents) * 100 : 0} label={t("Budget compared with the largest agent budget")} />
</div>
<span className="num mono" style={{ fontSize: 12, color: "var(--text-3)" }}>{money(row.budgetCents)}</span>
</div>
+2 -1
View File
@@ -1,3 +1,4 @@
import { HintBubble } from "../shared/HintBubble";
import { oneOf, usePersistentState } from "../shared/view-state";
import { useMemo, useState } from "react";
import { Avatar, Button, Icon, IconButton, Pill, Segmented } from "../shared/ui";
@@ -113,7 +114,7 @@ export function WorkPage({ snapshot, actions, navigate, focus }: PageProps) {
<Button tone="primary" onClick={() => setNewTaskOpen(true)}><Icon name="plus" /> {t("New task")}</Button>
</TopbarActions>
<h1 className="page-h1">{t("Work")}</h1>
<p className="page-sub">{t("Only a task runs: an agent picks it up and does the work. Goals and projects group tasks; documents and knowledge are what agents read and write along the way.")}</p>
<HintBubble>{t("Only a task runs: an agent picks it up and does the work. Goals and projects group tasks; documents and knowledge are what agents read and write along the way.")}</HintBubble>
{scope && (
<div className="row" style={{ gap: 8, marginBottom: 8 }}>
<Pill>
+3 -13
View File
@@ -26,7 +26,7 @@ export function FilesGrid({
onTogglePin: (file: FileObjectRecord) => void;
}) {
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: 12 }}>
<div className="inventory">
{files.map((file) => (
<PinnedCard
key={file.id}
@@ -58,22 +58,12 @@ export function PinnedCard({
return (
<div
data-object-id={file.id}
className={canOpen ? "clickable-card" : ""}
className={`inventory-slot ${canOpen ? "clickable-card" : ""}`.trim()}
role={canOpen ? "button" : undefined}
tabIndex={canOpen ? 0 : undefined}
onClick={canOpen ? onOpen : undefined}
onKeyDown={canOpen ? (event) => { if (event.key === "Enter") onOpen(); } : undefined}
style={{
border: "1px solid var(--border)",
borderRadius: 10,
background: "var(--panel)",
padding: 14,
display: "flex",
flexDirection: "column",
gap: 10,
minWidth: 0,
cursor: canOpen ? "pointer" : "default",
}}
style={{ cursor: canOpen ? "pointer" : "default" }}
>
<div className="row" style={{ gap: 10 }}>
<FileIcon kind={file.kind} big />
@@ -1,3 +1,4 @@
import { XpBar } from "../../shared/XpBar";
import { Avatar, Pill } from "../../shared/ui";
import type { AgentRecord, HiringPolicyRecord, ProviderRecord } from "../../../../shared/domain";
import { money } from "../types";
@@ -52,9 +53,7 @@ export function BillingPanel({
</div>
<div className="card" style={{ padding: 14 }}>
<div className="card-label" style={{ marginBottom: 8 }}>{t("Spent this month")}</div>
<div style={{ height: 6, background: "var(--panel-3)", borderRadius: 3, overflow: "hidden", marginBottom: 8 }}>
<div style={{ width: `${totalBudget > 0 ? Math.min(100, Math.round((spentCents / totalBudget) * 100)) : 0}%`, height: "100%", background: "var(--accent)" }} />
</div>
<div style={{ marginBottom: 8 }}><XpBar percent={totalBudget > 0 ? (spentCents / totalBudget) * 100 : 0} label={t("Spent this month")} /></div>
<div style={{ fontSize: 12, color: "var(--text-3)" }}>
{t("${spent} of ${allocated} allocated to agents", { spent: (spentCents / 100).toFixed(2), allocated: (totalBudget / 100).toFixed(2) })}
{unpricedCalls > 0 && <> · {t("{count} calls not priced", { count: unpricedCalls })}</>}
+8 -12
View File
@@ -1,5 +1,5 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
import { Button } from "./ui";
import { GameOver } from "./GameOver";
import { t } from "../i18n/i18n";
/** Contains a render error to the part of the app that threw, says what failed, and offers a way back; the error is logged with its component stack. */
@@ -19,17 +19,13 @@ export class ErrorBoundary extends Component<{ scope: "page" | "app"; children:
if (!error) return this.props.children;
const page = this.props.scope === "page";
return (
<div role="alert" style={{ padding: 24, display: "grid", gap: 10, maxWidth: 560 }}>
<strong>{page ? t("This page could not be shown.") : t("AgentCompany hit an error it could not recover from here.")}</strong>
<div className="mono" style={{ fontSize: 12, color: "var(--text-2)", wordBreak: "break-word" }}>{error.message}</div>
<div style={{ fontSize: 13, color: "var(--text-3)" }}>
{page ? t("The rest of the app still works; go to another page or try this one again.") : t("Reloading the window starts it again; your data is kept.")}
</div>
<div>
{page
? <Button onClick={() => this.setState({ error: null })}>{t("Try again")}</Button>
: <Button onClick={() => window.location.reload()}>{t("Reload")}</Button>}
</div>
<div style={{ padding: 24 }}>
<GameOver
title={page ? t("This page could not be shown.") : t("AgentCompany hit an error it could not recover from here.")}
error={error.message}
note={page ? t("The rest of the app still works; Continue tries this page again.") : t("Continue reloads the window; your data is kept.")}
onContinue={page ? () => this.setState({ error: null }) : () => window.location.reload()}
/>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import type { ReactNode } from "react";
import { Button } from "./ui";
import { t } from "../i18n/i18n";
/** A failure as a game-over screen: what failed, the error itself, what happens next, and Continue to try again. */
export function GameOver({ title, error, note, onContinue }: { title: string; error: string; note: ReactNode; onContinue: () => void }) {
return (
<div role="alert" className="game-over modal">
<div className="game-over-banner" aria-hidden="true">{t("GAME OVER")}</div>
<strong>{title}</strong>
<div className="mono game-over-error">{error}</div>
<div className="game-over-note">{note}</div>
<div><Button tone="primary" onClick={onContinue}>{t("Continue")}</Button></div>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { ReactNode } from "react";
import { Icon } from "./Icon";
/** A hint spoken by the office guide: a pixel portrait and a speech bubble, so help reads like a character talking. */
export function HintBubble({ children }: { children: ReactNode }) {
return (
<div className="hint-bubble" role="note">
<span className="hint-speaker" aria-hidden="true"><Icon name="bot" /></span>
<p className="hint-text">{children}</p>
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
/** An XP-style bar: a framed track filled in pixel blocks, announced as a progress value. */
export function XpBar({ percent, label }: { percent: number; label: string }) {
const filled = Math.max(0, Math.min(100, Math.round(percent)));
return (
<div className="xp-bar" role="progressbar" aria-label={label} aria-valuemin={0} aria-valuemax={100} aria-valuenow={filled}>
<div className="xp-fill" style={{ width: `${filled}%` }} />
</div>
);
}
+2
View File
@@ -21,6 +21,7 @@
--amber: #a85a1d;
--amber-soft: #fbe6c4;
--red: #b13e53;
--red-soft: #f6d5da;
--r-sm: 0;
--r-md: 0;
@@ -88,6 +89,7 @@
--amber: #ffcd75;
--amber-soft: #3d3220;
--red: #ef7d57;
--red-soft: #3d1f26;
--shadow-sm: 2px 2px 0 0 #10111c;
--shadow-md: 4px 4px 0 0 rgba(0, 0, 0, 0.5);
+25 -28
View File
@@ -65,41 +65,37 @@
.icon-button:disabled { opacity: 0.4; cursor: default; }
.icon-button:disabled:hover { background: transparent; color: var(--text-2); }
/* Tabs: a strip of pixel tabs on a 2px rule; the chosen tab is raised and joins the panel below. */
.segmented {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
padding: var(--sp-1);
border: 1px solid var(--border);
border-radius: var(--r-sm);
background: var(--panel);
align-items: flex-end;
gap: 2px;
border-bottom: 2px solid var(--border-strong);
}
.segmented .segment {
height: 24px;
padding: 0 var(--sp-2);
border-radius: var(--r-sm);
border: 0;
background: transparent;
padding: 2px var(--sp-2);
margin-bottom: -2px;
border: 2px solid var(--border-strong);
border-radius: 0;
background: var(--panel-3);
color: var(--text-2);
font-size: var(--fs-caption);
font-weight: 500;
transition: background var(--motion-fast) var(--ease-out), color var(--motion-fast) var(--ease-out);
}
.segmented .segment:hover:not(.active) { color: var(--text-1); }
.segmented .segment.active { background: var(--panel-3); color: var(--text-1); font-weight: 540; }
.segmented .segment:hover:not(.active) { color: var(--text-1); background: var(--hover); }
.segmented .segment.active { background: var(--panel); color: var(--text-1); border-bottom-color: var(--panel); padding-bottom: 6px; }
.pill {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
height: 20px;
padding: 0 var(--sp-2);
border-radius: var(--r-full);
background: var(--panel-2);
padding: 2px 8px;
border-radius: 0;
background: var(--panel-3);
color: var(--text-2);
border: 1px solid var(--border);
border: 0;
/* Pixel badge: a filled block with its corner pixels notched out. */
clip-path: polygon(2px 0, calc(100% - 2px) 0, 100% 2px, 100% calc(100% - 2px), calc(100% - 2px) 100%, 2px 100%, 0 calc(100% - 2px), 0 2px);
font-size: var(--fs-caption);
font-weight: 500;
white-space: nowrap;
max-width: 100%;
min-width: 0;
@@ -107,13 +103,13 @@
}
.pill .pill-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pill .dot { width: 6px; height: 6px; border-radius: 0; background: var(--text-3); flex-shrink: 0; transition: background var(--motion-base) var(--ease-out); }
.pill.amber { background: var(--amber-soft); color: var(--amber); border-color: transparent; }
.pill.amber { background: var(--amber-soft); color: var(--amber); }
.pill.amber .dot { background: var(--amber); }
.pill.green { background: var(--green-soft); color: var(--green); border-color: transparent; }
.pill.green { background: var(--green-soft); color: var(--green); }
.pill.green .dot { background: var(--green); }
.pill.violet { background: var(--accent-soft); color: var(--accent); border-color: transparent; }
.pill.violet { background: var(--accent-soft); color: var(--accent); }
.pill.violet .dot { background: var(--accent); }
.pill.red { background: rgba(210, 59, 59, 0.1); color: var(--red); border-color: transparent; }
.pill.red { background: var(--red-soft); color: var(--red); }
.pill.red .dot { background: var(--red); }
.avatar {
@@ -141,8 +137,9 @@
.input {
width: 100%;
border: 1px solid var(--border-strong);
border-radius: var(--r-sm);
border: 2px solid var(--border-strong);
border-radius: 0;
box-shadow: inset 2px 2px 0 0 var(--border-soft);
background: var(--panel);
color: var(--text-1);
padding: var(--sp-2) var(--sp-3);
@@ -150,7 +147,7 @@
font-size: var(--fs-body);
transition: border-color var(--motion-fast) var(--ease-out), box-shadow var(--motion-fast) var(--ease-out);
}
.input:focus { border-color: var(--text-1); box-shadow: 0 0 0 3px rgba(24, 24, 24, 0.06); }
.input:focus { border-color: var(--accent); outline: 2px dashed var(--accent); outline-offset: 2px; }
input[type="checkbox"] { accent-color: var(--text-1); }
textarea.input { min-height: 84px; resize: vertical; line-height: 1.5; }
.field {
+26
View File
@@ -59,3 +59,29 @@
/* Icons share the font's grid: 12 icon pixels at the same whole-screen-pixel scale. */
.pixel-icon { width: calc(12px * var(--px)); height: calc(12px * var(--px)); flex-shrink: 0; }
/* XP bar: a framed track, filled in 6px blocks with a 2px gap, a lighter top row for shine. */
.xp-bar { height: calc(10px * var(--px)); padding: 1px; border: 2px solid var(--border-strong); background: var(--panel-3); }
.xp-fill {
height: 100%;
background: repeating-linear-gradient(90deg, var(--accent) 0 6px, transparent 6px 8px);
box-shadow: inset 0 2px 0 0 rgba(255, 255, 255, 0.35);
transition: width var(--motion-slow) var(--ease-out);
}
/* Hint bubble: the guide's portrait and a speech box with a stepped tail. */
.hint-bubble { display: flex; align-items: flex-start; gap: 10px; margin: 0 0 var(--sp-4); }
.hint-speaker { display: grid; place-items: center; width: calc(24px * var(--px)); height: calc(24px * var(--px)); background: var(--accent); color: var(--btn-primary-fg); box-shadow: 2px 2px 0 0 var(--border-strong); flex-shrink: 0; }
.hint-text { position: relative; margin: 0; padding: 6px 10px; background: var(--panel); border: 2px solid var(--border-strong); color: var(--text-2); }
.hint-text::before { content: ""; position: absolute; left: -8px; top: 6px; width: 6px; height: 8px; background: var(--border-strong); clip-path: polygon(100% 0, 100% 100%, 67% 100%, 67% 75%, 34% 75%, 34% 50%, 0 50%, 0 25%, 34% 25%, 34% 0); }
/* Game over: a framed panel with a blinking banner and Continue. */
.game-over { display: grid; gap: 12px; max-width: 520px; text-align: center; justify-items: center; }
.game-over-banner { font-size: var(--fs-display); color: var(--red); letter-spacing: 0.1em; animation: px-blink 1.2s steps(1, end) infinite; }
.game-over-error { font-size: var(--fs-caption); color: var(--text-2); word-break: break-word; }
.game-over-note { color: var(--text-3); }
/* Inventory: files sit in square-cornered bevelled slots. */
.inventory { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 8px; padding: 8px; background: var(--panel-3); box-shadow: inset 2px 2px 0 0 var(--border-soft); }
.inventory-slot { display: flex; flex-direction: column; gap: 10px; min-width: 0; padding: 12px; background: var(--panel); border: 2px solid var(--border-strong); box-shadow: inset -2px -2px 0 0 var(--border-soft), inset 2px 2px 0 0 #ffffff40; }
.inventory-slot:hover { background: var(--hover); }