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:
1 parent
a6036308b7
commit
08ff96eec8
19 files changed
+149
-86
No files matched your search
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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.": "私聊:只有成员参与,每条消息都会唤醒其他成员。作为董事会,即使你不是成员也能阅读。",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })}</>}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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); }
|
||||
@@ -60,7 +60,21 @@ test("text uses the bundled pixel font, and the palette follows the day part, se
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toHaveCount(0);
|
||||
|
||||
// Progress is an XP bar announced as a value; hints are spoken by the guide in a bubble.
|
||||
await page.getByRole("button", { name: "Home" }).click();
|
||||
await expect(page.getByRole("progressbar", { name: "Goal progress" })).toHaveAttribute("aria-valuenow", "0");
|
||||
// On the roster the only agent holds the largest budget, and the bar says it compares budgets, not spend.
|
||||
await page.getByRole("button", { name: "Team", exact: true }).click();
|
||||
await expect(page.getByRole("progressbar", { name: "Budget compared with the largest agent budget" })).toHaveAttribute("aria-valuenow", "100");
|
||||
await page.getByRole("button", { name: "Work", exact: true }).click();
|
||||
const hint = page.getByRole("note").filter({ hasText: "Only a task runs" });
|
||||
await expect(hint.locator(".hint-speaker svg.pixel-icon")).toHaveCount(1);
|
||||
// Tabs are raised pixel tabs; fields are sunken pixel boxes.
|
||||
const views = page.getByRole("radiogroup", { name: "View" });
|
||||
expect(await views.locator("[aria-checked=true]").evaluate((tab) => getComputedStyle(tab).borderBottomColor === getComputedStyle(tab).backgroundColor)).toBe(true);
|
||||
|
||||
await page.getByRole("button", { name: "Settings" }).click();
|
||||
expect(await page.getByRole("combobox", { name: "Theme" }).evaluate((field) => [getComputedStyle(field).borderTopWidth, getComputedStyle(field).borderRadius])).toEqual(["2px", "0px"]);
|
||||
await page.getByRole("combobox", { name: "Theme" }).selectOption("dark");
|
||||
await expect(root).toHaveAttribute("data-theme", "dark");
|
||||
await page.getByRole("combobox", { name: "Theme" }).selectOption({ label: "Follow the clock" });
|
||||
|
||||
@@ -33,6 +33,10 @@ test("a record that breaks one page leaves the rest of the app working", async (
|
||||
await page.getByRole("button", { name: "Approvals" }).click();
|
||||
const failure = page.getByRole("alert").filter({ hasText: "This page could not be shown." });
|
||||
await expect(failure).toContainText("Approval risk tone is missing.");
|
||||
// A failure reads as a game over, with Continue to try again; the same fault fails the same way.
|
||||
await expect(failure).toContainText("GAME OVER");
|
||||
await failure.getByRole("button", { name: "Continue" }).click();
|
||||
await expect(failure).toContainText("Approval risk tone is missing.");
|
||||
await page.getByRole("button", { name: "Home" }).click();
|
||||
await expect(failure).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Approvals" })).toBeVisible();
|
||||
|
||||
@@ -35,7 +35,8 @@ test("a first load that fails says why, keeps retrying, and recovers", async ()
|
||||
await expect(failure).toContainText(/attempt [2-9]/);
|
||||
|
||||
setDraft(null);
|
||||
await page.getByRole("button", { name: "Retry now" }).click();
|
||||
await expect(failure).toContainText("GAME OVER");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user