fix(FN-2444): polish node dashboard UX and discovery core handling

- Update node management UI components to use shared form/button classes, semantic color tokens, and accessible interactions
- Refine mesh topology rendering and related dashboard tests for improved readability and behavior coverage
- Adjust discovery routes to reuse injected centralCore instances without unnecessary init/close cycles
- Add/extend tests for shared CentralCore discovery flows and model settings scope save behavior
This commit is contained in:
Fusion
2026-04-24 09:38:16 -07:00
committed by gsxdsm
parent 4f633be65e
commit 8a723c86b5
19 changed files with 507 additions and 385 deletions

View File

@@ -137,6 +137,7 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
<label className="add-node-modal__field">
<span>Name</span>
<input
className="input"
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
@@ -145,7 +146,7 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
aria-invalid={Boolean(errors.name)}
autoFocus
/>
{errors.name && <span className="add-node-modal__error">{errors.name}</span>}
{errors.name && <span className="form-error add-node-modal__error">{errors.name}</span>}
</label>
<div className="add-node-modal__type-toggle">
@@ -175,6 +176,7 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
<label className="add-node-modal__field">
<span>URL</span>
<input
className="input"
type="text"
value={url}
onChange={(event) => setUrl(event.target.value)}
@@ -182,12 +184,13 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
disabled={isSubmitting}
aria-invalid={Boolean(errors.url)}
/>
{errors.url && <span className="add-node-modal__error">{errors.url}</span>}
{errors.url && <span className="form-error add-node-modal__error">{errors.url}</span>}
</label>
<label className="add-node-modal__field">
<span>API Key</span>
<input
className="input"
type="password"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
@@ -200,6 +203,7 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
<label className="add-node-modal__field">
<span>Max Concurrent</span>
<input
className="input"
type="number"
min={MAX_CONCURRENT_MIN}
max={MAX_CONCURRENT_MAX}
@@ -209,7 +213,7 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
aria-invalid={Boolean(errors.maxConcurrent)}
/>
<span className="add-node-modal__hint">Max simultaneous task agents (1–10)</span>
{errors.maxConcurrent && <span className="add-node-modal__error">{errors.maxConcurrent}</span>}
{errors.maxConcurrent && <span className="form-error add-node-modal__error">{errors.maxConcurrent}</span>}
</label>
</div>

View File

@@ -179,9 +179,11 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
</div>
<div className="modal-body connect-node-form">
<label className="connect-node-field">
<span>Node Name</span>
<div className="form-group connect-node-field">
<label htmlFor="connect-node-name">Node Name</label>
<input
id="connect-node-name"
className="input connect-node-field__input"
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
@@ -190,11 +192,13 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
aria-invalid={Boolean(errors.name)}
/>
{errors.name && <span className="form-error">{errors.name}</span>}
</label>
</div>
<label className="connect-node-field">
<span>Host / IP Address</span>
<div className="form-group connect-node-field">
<label htmlFor="connect-node-host">Host / IP Address</label>
<input
id="connect-node-host"
className="input connect-node-field__input"
type="text"
value={host}
onChange={(event) => setHost(event.target.value)}
@@ -203,11 +207,13 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
aria-invalid={Boolean(errors.host)}
/>
{errors.host && <span className="form-error">{errors.host}</span>}
</label>
</div>
<label className="connect-node-field">
<span>Port</span>
<div className="form-group connect-node-field">
<label htmlFor="connect-node-port">Port</label>
<input
id="connect-node-port"
className="input connect-node-field__input"
type="number"
value={port}
onChange={(event) => setPort(event.target.value)}
@@ -217,7 +223,7 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
aria-invalid={Boolean(errors.port)}
/>
{errors.port && <span className="form-error">{errors.port}</span>}
</label>
</div>
{constructedUrl && (
<div className="connect-node-url-preview">
@@ -226,20 +232,24 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
</div>
)}
<label className="connect-node-field">
<span>Auth Key</span>
<div className="form-group connect-node-field">
<label htmlFor="connect-node-auth-key">Auth Key</label>
<input
id="connect-node-auth-key"
className="input connect-node-field__input"
type="password"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
placeholder="Optional"
disabled={isSubmitting}
/>
</label>
</div>
<label className="connect-node-field">
<span>Max Concurrent</span>
<div className="form-group connect-node-field">
<label htmlFor="connect-node-max-concurrent">Max Concurrent</label>
<input
id="connect-node-max-concurrent"
className="input connect-node-field__input"
type="number"
value={maxConcurrent}
onChange={(event) => setMaxConcurrent(Number(event.target.value))}
@@ -249,7 +259,7 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
aria-invalid={Boolean(errors.maxConcurrent)}
/>
{errors.maxConcurrent && <span className="form-error">{errors.maxConcurrent}</span>}
</label>
</div>
</div>
<div className="modal-actions connect-node-actions">

View File

@@ -57,23 +57,6 @@ function MeshTopologyInner({ nodes, className }: MeshTopologyProps): ReactElemen
});
}, [remoteNodes, viewBoxSize, centerX, centerY]);
// Build peer awareness lines (lines between remote nodes that share knowledge)
const peerLines = useMemo(() => {
// Simple heuristic: show lines between remote nodes that share knowledge
const lines: Array<{ x1: number; y1: number; x2: number; y2: number }> = [];
// Only draw peer lines if we have more than 2 remote nodes
if (remotePositions.length > 2) {
remotePositions.forEach((rp, index) => {
// Draw a subtle line to one other node for visual interest
if (index % 2 === 0 && index + 1 < remotePositions.length) {
const other = remotePositions[index + 1];
lines.push({ x1: rp.x, y1: rp.y, x2: other.x, y2: other.y });
}
});
}
return lines;
}, [remotePositions]);
if (nodes.length === 0) {
return (
@@ -93,18 +76,6 @@ function MeshTopologyInner({ nodes, className }: MeshTopologyProps): ReactElemen
preserveAspectRatio="xMidYMid meet"
aria-label="Node mesh topology visualization"
>
{/* Peer awareness lines between remote nodes */}
{peerLines.map((line, index) => (
<line
key={`peer-${index}`}
className="mesh-topology__peer-line"
x1={line.x1}
y1={line.y1}
x2={line.x2}
y2={line.y2}
/>
))}
{/* Lines from local node to remote nodes */}
{remotePositions.map((rp) => (
<line
@@ -133,13 +104,16 @@ function MeshTopologyInner({ nodes, className }: MeshTopologyProps): ReactElemen
>
{localNode.name.length > 12 ? `${localNode.name.slice(0, 10)}…` : localNode.name}
</text>
<text
className="mesh-topology__node-type"
y={-NODE_RADIUS - 4}
textAnchor="middle"
>
{localNode.type === "local" ? "🏠" : "🌐"}
</text>
<g className="mesh-topology__node-type" transform={`translate(0 ${-NODE_RADIUS - 10})`}>
<circle className="mesh-topology__node-type-badge" r="8" />
<text
className="mesh-topology__node-type-text"
textAnchor="middle"
dominantBaseline="middle"
>
{localNode.type === "local" ? "L" : "R"}
</text>
</g>
</g>
)}
@@ -159,13 +133,16 @@ function MeshTopologyInner({ nodes, className }: MeshTopologyProps): ReactElemen
>
{rp.node.name.length > 12 ? `${rp.node.name.slice(0, 10)}…` : rp.node.name}
</text>
<text
className="mesh-topology__node-type"
y={-NODE_RADIUS - 4}
textAnchor="middle"
>
🌐
</text>
<g className="mesh-topology__node-type" transform={`translate(0 ${-NODE_RADIUS - 10})`}>
<circle className="mesh-topology__node-type-badge" r="8" />
<text
className="mesh-topology__node-type-text"
textAnchor="middle"
dominantBaseline="middle"
>
{rp.node.type === "local" ? "L" : "R"}
</text>
</g>
</g>
))}
</svg>
@@ -189,6 +166,7 @@ function MeshTopologyInner({ nodes, className }: MeshTopologyProps): ReactElemen
<span>Error</span>
</div>
</div>
<p className="mesh-topology__notice">Peer-to-peer discovery data unavailable.</p>
</div>
);
}

View File

@@ -20,15 +20,15 @@ export interface NodeCardProps {
}
const STATUS_CONFIG: Record<NodeInfo["status"], { label: string; color: string; className: string }> = {
online: { label: "Online", color: "var(--success)", className: "node-card__status--online" },
online: { label: "Online", color: "var(--color-success)", className: "node-card__status--online" },
offline: { label: "Offline", color: "var(--color-error)", className: "node-card__status--offline" },
connecting: { label: "Connecting", color: "var(--warning)", className: "node-card__status--connecting" },
connecting: { label: "Connecting", color: "var(--color-warning)", className: "node-card__status--connecting" },
error: { label: "Error", color: "var(--color-error)", className: "node-card__status--error" },
};
const AUTH_SYNC_COLORS: Record<string, string> = {
match: "var(--success)",
differs: "var(--warning)",
match: "var(--color-success)",
differs: "var(--color-warning)",
"not-synced": "var(--text-muted)",
};
@@ -233,7 +233,7 @@ function NodeCardInner({
<footer className="node-card__actions">
<button
className="node-card__action"
className="btn btn-sm node-card__action"
type="button"
onClick={handleHealthCheck}
disabled={isLoading}
@@ -245,7 +245,7 @@ function NodeCardInner({
</button>
<button
className="node-card__action"
className="btn btn-sm node-card__action"
type="button"
onClick={handleEdit}
disabled={isLoading}
@@ -257,7 +257,7 @@ function NodeCardInner({
</button>
<button
className={`node-card__action node-card__action--remove ${removeArmed ? "is-armed" : ""}`}
className={`btn btn-sm node-card__action node-card__action--remove ${removeArmed ? "btn-danger is-armed" : ""}`}
type="button"
onClick={handleRemove}
disabled={isLoading}

View File

@@ -263,7 +263,7 @@ export function NodeDetailModal({
<label className="node-detail-modal__field">
<span>Name</span>
{editMode ? (
<input value={name} onChange={(event) => setName(event.target.value)} disabled={isSaving} />
<input className="input" value={name} onChange={(event) => setName(event.target.value)} disabled={isSaving} />
) : (
<strong>{node.name}</strong>
)}
@@ -283,6 +283,7 @@ export function NodeDetailModal({
<span>Max Concurrent</span>
{editMode ? (
<input
className="input"
type="number"
min={1}
max={10}
@@ -300,7 +301,7 @@ export function NodeDetailModal({
<label className="node-detail-modal__field node-detail-modal__field--full">
<span>URL</span>
{editMode ? (
<input value={url} onChange={(event) => setUrl(event.target.value)} disabled={isSaving} />
<input className="input" value={url} onChange={(event) => setUrl(event.target.value)} disabled={isSaving} />
) : (
<strong>{node.url ?? "—"}</strong>
)}
@@ -310,6 +311,7 @@ export function NodeDetailModal({
<span>API Key</span>
{editMode ? (
<input
className="input"
type="password"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}

View File

@@ -114,31 +114,21 @@ export function SettingsSyncLog({
return (
<div className="settings-sync-log">
<div
<button
className="settings-sync-log__header"
type="button"
onClick={handleToggle}
role="button"
tabIndex={0}
aria-expanded={isExpanded}
data-testid="settings-sync-log-header"
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleToggle();
}
}}
>
<ChevronDown
size={16}
style={{
transform: isExpanded ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 0.15s ease",
}}
className={`settings-sync-log__chevron ${isExpanded ? "settings-sync-log__chevron--expanded" : ""}`}
/>
<span>
{entries.length} {entries.length === 1 ? "entry" : "entries"}
</span>
</div>
</button>
{isExpanded && (
<>

View File

@@ -64,6 +64,20 @@ describe("MeshTopology", () => {
expect(links).toHaveLength(1); // One line from local to remote
});
it("does not render fabricated peer links between remote nodes", () => {
const nodes = [
makeNode({ id: "local", name: "Local", type: "local" }),
makeNode({ id: "remote-1", name: "Remote 1", type: "remote" }),
makeNode({ id: "remote-2", name: "Remote 2", type: "remote" }),
makeNode({ id: "remote-3", name: "Remote 3", type: "remote" }),
];
render(<MeshTopology nodes={nodes} />);
expect(document.querySelectorAll(".mesh-topology__peer-line")).toHaveLength(0);
expect(screen.getByText("Peer-to-peer discovery data unavailable.")).toBeInTheDocument();
});
it("renders legend with status colors", () => {
render(<MeshTopology nodes={[makeNode()]} />);
@@ -123,6 +137,19 @@ describe("MeshTopology", () => {
expect(circles[0]).toHaveAttribute("fill", expect.stringContaining("var(--color-error"));
});
it("uses consistent node type badges instead of emoji glyphs", () => {
const nodes = [
makeNode({ id: "local", name: "Local", type: "local" }),
makeNode({ id: "remote", name: "Remote", type: "remote" }),
];
render(<MeshTopology nodes={nodes} />);
const typeBadges = document.querySelectorAll(".mesh-topology__node-type-badge");
expect(typeBadges).toHaveLength(2);
expect(screen.queryByText("🏠")).not.toBeInTheDocument();
expect(screen.queryByText("🌐")).not.toBeInTheDocument();
});
it("renders correct status color for connecting nodes", () => {
const nodes = [makeNode({ id: "connecting", name: "Connecting Node", status: "connecting" })];
render(<MeshTopology nodes={nodes} />);

View File

@@ -1063,11 +1063,11 @@ describe("SettingsModal", () => {
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Click on "Models" (global models section)
fireEvent.click(screen.getByText("Models"));
fireEvent.click((await screen.findAllByText("Models"))[0]);
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
// Select a model
const trigger = screen.getByLabelText("Default Model");
const trigger = await screen.findByLabelText("Default Model");
await user.click(trigger);
await user.click(screen.getByText("Claude Sonnet 4.5"));
@@ -1347,11 +1347,11 @@ describe("SettingsModal", () => {
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Models section has default model dropdown
fireEvent.click(screen.getByText("Models"));
fireEvent.click((await screen.findAllByText("Models"))[0]);
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
// Open dropdown and select "Use default"
const trigger = screen.getByLabelText("Default Model");
const trigger = await screen.findByLabelText("Default Model");
await user.click(trigger);
// Find and click the "Use default" option in the dropdown
@@ -2341,7 +2341,7 @@ describe("SettingsModal", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Notifications"));
fireEvent.click((await screen.findAllByText("Notifications"))[0]);
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
expect(checkbox).toBeTruthy();
expect(checkbox.getAttribute("type")).toBe("checkbox");
@@ -2606,7 +2606,7 @@ describe("SettingsModal", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Notifications"));
fireEvent.click((await screen.findAllByText("Notifications"))[0]);
expect((screen.getByLabelText("Task completed (in-review)") as HTMLInputElement).checked).toBe(true);
expect((screen.getByLabelText("Task merged") as HTMLInputElement).checked).toBe(true);
expect((screen.getByLabelText("Task failed") as HTMLInputElement).checked).toBe(true);
@@ -2656,7 +2656,7 @@ describe("SettingsModal", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Notifications"));
fireEvent.click((await screen.findAllByText("Notifications"))[0]);
// Uncheck all six
fireEvent.click(screen.getByLabelText("Task completed (in-review)"));
@@ -2726,7 +2726,7 @@ describe("SettingsModal", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Node Sync"));
fireEvent.click((await screen.findAllByText("Node Sync"))[0]);
expect(screen.getByLabelText("Enable automatic settings sync")).toBeTruthy();
expect(screen.getByLabelText("Sync model auth credentials")).toBeTruthy();
expect(screen.getByLabelText("Sync interval")).toBeTruthy();
@@ -3582,7 +3582,7 @@ describe("Prompts section", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getAllByText("Prompts")[0]);
fireEvent.click((await screen.findAllByText("Prompts"))[0]);
// Templates tab should be active by default
expect(screen.getByTestId("tab-templates")).toHaveClass(/active/);
@@ -3612,7 +3612,7 @@ describe("Prompts section", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getAllByText("Prompts")[0]);
fireEvent.click((await screen.findAllByText("Prompts"))[0]);
// Click Overrides tab
fireEvent.click(screen.getByTestId("tab-overrides"));
@@ -3626,7 +3626,7 @@ describe("Prompts section", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getAllByText("Prompts")[0]);
fireEvent.click((await screen.findAllByText("Prompts"))[0]);
// Click Overrides tab
fireEvent.click(screen.getByTestId("tab-overrides"));
@@ -3897,8 +3897,8 @@ describe("Prompts section", () => {
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
// Verify default and fallback model dropdowns
expect(screen.getByLabelText("Default Model")).toBeTruthy();
expect(screen.getByLabelText("Fallback Model")).toBeTruthy();
expect(await screen.findByLabelText("Default Model")).toBeTruthy();
expect(await screen.findByLabelText("Fallback Model")).toBeTruthy();
});
it("renders global baseline lane dropdowns in Models section", async () => {
@@ -3922,7 +3922,7 @@ describe("Prompts section", () => {
fireEvent.click(screen.getAllByText("Models")[0]);
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
const executionTrigger = screen.getByLabelText("Execution Model");
const executionTrigger = await screen.findByLabelText("Execution Model");
await user.click(executionTrigger);
await user.click(screen.getByText("GPT-4o"));
@@ -3948,7 +3948,7 @@ describe("Prompts section", () => {
fireEvent.click(screen.getAllByText("Models")[0]);
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
const executionTrigger = screen.getByLabelText("Execution Model");
const executionTrigger = await screen.findByLabelText("Execution Model");
await user.click(executionTrigger);
const useDefaultOption = await screen.findByRole("option", { name: /use default/i });
await user.click(useDefaultOption);
@@ -3965,7 +3965,7 @@ describe("Prompts section", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getAllByText("Models")[0]);
fireEvent.click((await screen.findAllByText("Models"))[0]);
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
expect(screen.getByLabelText("Execution Model").textContent).toContain("Use default");
@@ -3978,7 +3978,7 @@ describe("Prompts section", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getAllByText("Models")[0]);
fireEvent.click((await screen.findAllByText("Models"))[0]);
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
// Thinking Effort is a native select
@@ -4136,6 +4136,54 @@ describe("Prompts section", () => {
expect(globalPayload.planningModelId).toBeUndefined();
}
}, FN1712_SCOPE_TEST_TIMEOUT_MS);
it("mixed global and project changes call both endpoints with correct subsets", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Change global model
fireEvent.click(screen.getAllByText("Models")[0]);
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
let user = userEvent.setup();
let dropdownBtn = screen.getByRole("button", { name: /default model/i });
await user.click(dropdownBtn);
let option = await screen.findByRole("option", { name: /gpt-4o/i });
await user.click(option);
// Change project model
fireEvent.click(screen.getAllByText("Project Models")[0]);
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
dropdownBtn = screen.getByRole("button", { name: /planning model/i });
await user.click(dropdownBtn);
option = await screen.findByRole("option", { name: /claude/i });
await user.click(option);
// Save
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(updateSettings).toHaveBeenCalled();
expect(updateGlobalSettings).toHaveBeenCalled();
});
const globalCalls = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls;
const projectCalls = (updateSettings as ReturnType<typeof vi.fn>).mock.calls;
const globalPayload = globalCalls[globalCalls.length - 1][0];
const projectPayload = projectCalls[projectCalls.length - 1][0];
// Verify global payload contains only global keys
expect(globalPayload).toHaveProperty("defaultProvider");
expect(globalPayload).toHaveProperty("defaultModelId");
expect(globalPayload.planningProvider).toBeUndefined();
expect(globalPayload.planningModelId).toBeUndefined();
// Verify project payload contains only project keys
expect(projectPayload).toHaveProperty("planningProvider");
expect(projectPayload).toHaveProperty("planningModelId");
expect(projectPayload.defaultProvider).toBeUndefined();
expect(projectPayload.defaultModelId).toBeUndefined();
}, FN1712_SCOPE_TEST_TIMEOUT_MS);
});
describe("Reset/clear null-as-delete semantics (FN-1712)", () => {

View File

@@ -3189,7 +3189,9 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", ()
});
// resize should have been called with xterm dimensions
expect(mockResize).toHaveBeenCalledWith(80, 24);
await waitFor(() => {
expect(mockResize).toHaveBeenCalledWith(80, 24);
});
});
it("clears --vv-height when keyboard closes", async () => {

View File

@@ -44,10 +44,10 @@ describe("nodes-view mobile CSS", () => {
expect(block).toContain("flex-wrap: wrap");
});
it("defines .nodes-view-title h2 with 16px font on mobile", () => {
it("defines .nodes-view-title h2 with tokenized font size on mobile", () => {
expect(mobileMediaBlock).toContain(".nodes-view-title h2");
const block = extractRuleBlock(mobileMediaBlock, ".nodes-view-title h2");
expect(block).toContain("font-size: 16px");
expect(block).toContain("font-size: calc(var(--space-md) + var(--space-xs));");
});
it("defines .nodes-view-title h2 svg with flex-shrink: 0 on mobile", () => {
@@ -56,17 +56,17 @@ describe("nodes-view mobile CSS", () => {
expect(block).toContain("flex-shrink: 0");
});
it("defines .nodes-view-count with smaller font on mobile", () => {
it("defines .nodes-view-count with tokenized smaller font on mobile", () => {
expect(mobileMediaBlock).toContain(".nodes-view-count");
const block = extractRuleBlock(mobileMediaBlock, ".nodes-view-count");
expect(block).toContain("font-size: 12px");
expect(block).toContain("font-size: calc(var(--space-sm) + var(--space-xs));");
});
it("defines .nodes-view-close with 36px touch target on mobile", () => {
it("defines .nodes-view-close with tokenized touch target on mobile", () => {
expect(mobileMediaBlock).toContain(".nodes-view-close");
const block = extractRuleBlock(mobileMediaBlock, ".nodes-view-close");
expect(block).toContain("min-height: 36px");
expect(block).toContain("min-width: 36px");
expect(block).toContain("min-height: calc(var(--space-xl) + var(--space-md));");
expect(block).toContain("min-width: calc(var(--space-xl) + var(--space-md));");
});
it("defines .nodes-view-actions with full width on mobile", () => {
@@ -77,10 +77,10 @@ describe("nodes-view mobile CSS", () => {
expect(block).toContain("justify-content: flex-end");
});
it("defines .nodes-view-actions .btn with min-height on mobile", () => {
it("defines .nodes-view-actions .btn with tokenized min-height on mobile", () => {
expect(mobileMediaBlock).toContain(".nodes-view-actions .btn");
const block = extractRuleBlock(mobileMediaBlock, ".nodes-view-actions .btn");
expect(block).toContain("min-height: 36px");
expect(block).toContain("min-height: calc(var(--space-xl) + var(--space-md));");
});
it("defines .nodes-view-stats with 2-column grid on mobile", () => {
@@ -96,16 +96,16 @@ describe("nodes-view mobile CSS", () => {
expect(block).toContain("padding: var(--space-xs) var(--space-sm)");
});
it("defines .nodes-view-stat span with smaller font on mobile", () => {
it("defines .nodes-view-stat span with tokenized smaller font on mobile", () => {
expect(mobileMediaBlock).toContain(".nodes-view-stat span");
const block = extractRuleBlock(mobileMediaBlock, ".nodes-view-stat span");
expect(block).toContain("font-size: 11px");
expect(block).toContain("font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);");
});
it("defines .nodes-view-stat strong with smaller font on mobile", () => {
it("defines .nodes-view-stat strong with tokenized smaller font on mobile", () => {
expect(mobileMediaBlock).toContain(".nodes-view-stat strong");
const block = extractRuleBlock(mobileMediaBlock, ".nodes-view-stat strong");
expect(block).toContain("font-size: 14px");
expect(block).toContain("font-size: calc(var(--space-md) + var(--space-xs) / 2);");
});
it("defines .nodes-view-empty with compact padding on mobile", () => {
@@ -128,9 +128,9 @@ describe("nodes-view mobile CSS", () => {
expect(block).toContain("padding: var(--space-sm) 0");
});
it("defines .nodes-view-section-title with smaller font on mobile", () => {
it("defines .nodes-view-section-title with tokenized smaller font on mobile", () => {
expect(mobileMediaBlock).toContain(".nodes-view-section-title");
const block = extractRuleBlock(mobileMediaBlock, ".nodes-view-section-title");
expect(block).toContain("font-size: 13px");
expect(block).toContain("font-size: calc(var(--space-md) + var(--space-xs) / 4);");
});
});
});

View File

@@ -770,7 +770,9 @@ describe("onboarding flow integration", () => {
expect(mockMarkOnboardingCompleted).toHaveBeenCalled();
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ modelOnboardingComplete: true });
expect(screen.getByText("Your first task is ready!")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText("Your first task is ready!")).toBeInTheDocument();
});
});
it("task creation flow: View Task button navigates and completes onboarding", async () => {