Add Project Home, Auth Bridge, shared auth token, and Tier-1 polish

Project Home (DESIGN-REVIEW §B2): the project is promoted from a 280px
sidebar card to a first-class main-area view. ProjectCard.tsx (1,257
lines) is replaced by a select-only ProjectRow plus tabs for Overview,
Sessions, Automation, Config and Files. The PortMappings, FileManager
and ContainerProgress modals are absorbed rather than reimplemented.
Config gains a Saved/Saving/Failed indicator — save-on-blur failures
previously reached only console.error.

Tier-1 polish (DESIGN-REVIEW §A): new elevation, muted-accent, disabled
and focus-ring tokens; a global :focus-visible ring with every
focus:outline-none removed; filled buttons moved to --accent-emphasis
and white-on-success toggles retired, fixing three WCAG AA failures
(2.1:1, 2.5:1, 2.4:1); a shared Modal primitive with role="dialog",
focus trap and restore, adopted by all remaining modals; status
indicators that carry a glyph and word rather than colour alone.

Ctrl+Shift+W closes a tab, deliberately not Ctrl+W — that is readline's
kill-word, used constantly in the terminal this app is built around.

Auth Bridge: a general loopback-callback bridge so browser logins run
inside a container (aws sso login, Concourse fly login, claude login)
can complete against the host browser. Listeners are discovered from
/proc/net/tcp{,6} — ss/netstat/lsof are absent from the image — bound on
host 127.0.0.1 only, and tunnelled in over the Docker API via socat,
which keeps working on Docker Desktop where container IPs are not
routable. Falls back to [::1] because Node resolves localhost to IPv6
first, so claude login often binds ::1 alone. Opt-in per project.

This extracts create_attached_exec() and moves the existing terminal
session path onto it, so there is one attached-exec implementation
rather than two.

Shared auth token: `claude setup-token` is run in a container, the token
is stored in the OS keychain and injected as CLAUDE_CODE_OAUTH_TOKEN
into Anthropic-backend projects. Contrary to the initial design note,
setup-token uses an Anthropic-hosted redirect and blocks on a stdin
paste prompt rather than a loopback callback, so a stdin command is
required for the flow to complete.

The token is never logged, never returned to the frontend, and is
redacted from the streamed output with a stateful matcher that withholds
any tail that could still grow into a secret. Change detection uses a
random rotation id rather than a hash, since a hash in a docker-inspect
readable label would be an offline verification oracle.

Frontend 33 -> 51 tests; Rust 34 tests. Both builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 11:35:42 -07:00
co-authored by Claude Opus 5
parent f68d10d5c2
commit 01a2f6aec8
83 changed files with 8042 additions and 3064 deletions
+50
View File
@@ -0,0 +1,50 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
export type ButtonSize = "sm" | "md";
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
children: ReactNode;
}
/**
* Real buttons with visible bounds and a ≥24px hit target.
* Filled variants use the *-emphasis tokens so white text clears WCAG AA;
* `--accent` stays reserved for foreground/link use.
*/
const VARIANTS: Record<ButtonVariant, string> = {
primary:
"bg-[var(--accent-emphasis)] text-white border border-transparent hover:bg-[var(--accent-emphasis-hover)] disabled:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)]",
secondary:
"bg-[var(--bg-tertiary)] text-[var(--text-primary)] border border-[var(--border-color)] hover:bg-[var(--border-color)] disabled:text-[var(--text-disabled)] disabled:hover:bg-[var(--bg-tertiary)]",
danger:
"bg-transparent text-[var(--error)] border border-[var(--error)]/40 hover:bg-[var(--error-muted)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] disabled:hover:bg-transparent",
ghost:
"bg-transparent text-[var(--text-secondary)] border border-transparent hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:hover:bg-transparent",
};
const SIZES: Record<ButtonSize, string> = {
sm: "h-6 px-2 text-xs gap-1",
md: "h-8 px-3 text-[13px] gap-1.5",
};
export default function Button({
variant = "secondary",
size = "sm",
className = "",
type = "button",
children,
...rest
}: Props) {
return (
<button
type={type}
{...rest}
className={`inline-flex items-center justify-center whitespace-nowrap rounded-[var(--radius-control)] font-medium transition-colors disabled:cursor-not-allowed ${SIZES[size]} ${VARIANTS[variant]} ${className}`}
>
{children}
</button>
);
}
+89
View File
@@ -0,0 +1,89 @@
import { useId, type ReactNode } from "react";
/**
* Shared control styling. Full-width forms mean the helper text that used to
* hide inside 27 hover-only tooltips can just be visible.
*/
export const inputClass =
"w-full px-2.5 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] text-[var(--text-primary)] focus:border-[var(--accent)] disabled:text-[var(--text-disabled)] disabled:bg-[var(--bg-secondary)] transition-colors";
export const monoInputClass = `${inputClass} font-mono`;
export const selectClass =
"px-2.5 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] text-[var(--text-primary)] focus:border-[var(--accent)] disabled:text-[var(--text-disabled)] disabled:bg-[var(--bg-secondary)] transition-colors";
interface FieldProps {
label: string;
/** Visible helper text — the replacement for hover-only tooltips. */
hint?: ReactNode;
children: (id: string) => ReactNode;
className?: string;
}
export default function Field({ label, hint, children, className = "" }: FieldProps) {
const id = useId();
return (
<div className={className}>
<label
htmlFor={id}
className="block text-[13px] font-medium text-[var(--text-primary)]"
>
{label}
</label>
{hint && (
<p className="mt-0.5 mb-1 text-xs text-[var(--text-secondary)] leading-snug">
{hint}
</p>
)}
<div className={hint ? "" : "mt-1"}>{children(id)}</div>
</div>
);
}
/** Label + helper text on the left, a control (usually a Toggle) on the right. */
export function SwitchRow({
label,
hint,
control,
}: {
label: string;
hint?: ReactNode;
control: ReactNode;
}) {
return (
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-[13px] font-medium text-[var(--text-primary)]">{label}</div>
{hint && (
<p className="mt-0.5 text-xs text-[var(--text-secondary)] leading-snug">{hint}</p>
)}
</div>
<div className="flex-shrink-0 pt-0.5">{control}</div>
</div>
);
}
/** Grouping card used by the Config tab (Workspace / Model / Access / Runtime). */
export function ConfigGroup({
title,
description,
children,
}: {
title: string;
description?: string;
children: ReactNode;
}) {
return (
<section className="border border-[var(--border-color)] rounded-[var(--radius-panel)] bg-[var(--bg-secondary)]">
<header className="px-4 py-2.5 border-b border-[var(--border-color)]">
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)]">
{title}
</h3>
{description && (
<p className="mt-0.5 text-xs text-[var(--text-secondary)]">{description}</p>
)}
</header>
<div className="px-4 py-4 space-y-4">{children}</div>
</section>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import Modal from "./Modal";
/**
* Modal focuses asynchronously via rAF so the panel is laid out first; jsdom
* needs that flushed manually.
*/
async function flushFocus() {
await act(async () => {
vi.advanceTimersByTime(20);
});
}
describe("Modal", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["requestAnimationFrame", "setTimeout"] });
});
afterEach(() => {
vi.useRealTimers();
});
it("exposes dialog semantics and an accessible name", async () => {
render(
<Modal title="Remove Project" onClose={vi.fn()}>
<p>body</p>
</Modal>,
);
const dialog = screen.getByRole("dialog", { name: "Remove Project" });
expect(dialog).toHaveAttribute("aria-modal", "true");
});
it("moves focus into the dialog on open", async () => {
render(
<Modal title="Dialog" onClose={vi.fn()}>
<button>First</button>
<button>Second</button>
</Modal>,
);
await flushFocus();
const dialog = screen.getByRole("dialog");
expect(dialog.contains(document.activeElement)).toBe(true);
});
it("traps Tab inside the dialog, wrapping at both ends", async () => {
render(
<Modal title="Dialog" onClose={vi.fn()} hideCloseButton>
<button>First</button>
<button>Last</button>
</Modal>,
);
await flushFocus();
const first = screen.getByRole("button", { name: "First" });
const last = screen.getByRole("button", { name: "Last" });
last.focus();
fireEvent.keyDown(document, { key: "Tab" });
expect(document.activeElement).toBe(first);
first.focus();
fireEvent.keyDown(document, { key: "Tab", shiftKey: true });
expect(document.activeElement).toBe(last);
});
it("restores focus to the trigger on unmount", async () => {
const trigger = document.createElement("button");
document.body.appendChild(trigger);
trigger.focus();
const { unmount } = render(
<Modal title="Dialog" onClose={vi.fn()}>
<button>Inside</button>
</Modal>,
);
await flushFocus();
expect(document.activeElement).not.toBe(trigger);
unmount();
expect(document.activeElement).toBe(trigger);
trigger.remove();
});
it("closes on Escape and on an overlay click", async () => {
const onClose = vi.fn();
const { container } = render(
<Modal title="Dialog" onClose={onClose}>
<p>body</p>
</Modal>,
);
await flushFocus();
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
// The overlay is the portal root's only child.
const overlay = document.querySelector(".fixed.inset-0");
expect(overlay).not.toBeNull();
fireEvent.click(overlay!);
expect(onClose).toHaveBeenCalledTimes(2);
expect(container).toBeTruthy();
});
it("ignores Escape and overlay clicks when not dismissible", async () => {
const onClose = vi.fn();
render(
<Modal title="Installing" onClose={onClose} dismissible={false}>
<p>body</p>
</Modal>,
);
await flushFocus();
fireEvent.keyDown(document, { key: "Escape" });
fireEvent.click(document.querySelector(".fixed.inset-0")!);
expect(onClose).not.toHaveBeenCalled();
});
});
+183
View File
@@ -0,0 +1,183 @@
import { useCallback, useEffect, useId, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
const FOCUSABLE_SELECTOR = [
"a[href]",
"area[href]",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"button:not([disabled])",
"iframe",
"object",
"embed",
'[tabindex]:not([tabindex="-1"])',
'[contenteditable="true"]',
].join(",");
function focusableWithin(root: HTMLElement): HTMLElement[] {
// Deliberately no `offsetParent` check: everything a dialog renders is
// visible, and `offsetParent` is unreliable inside fixed-position overlays.
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
(el) => !el.closest("[hidden]") && el.getAttribute("aria-hidden") !== "true",
);
}
export interface ModalProps {
/** Accessible name for the dialog. Rendered as the header unless `hideTitle`. */
title: string;
onClose: () => void;
children: ReactNode;
/** Optional sticky footer row (buttons live here). */
footer?: ReactNode;
/** Optional sub-header description, wired to `aria-describedby`. */
description?: ReactNode;
/** Tailwind width class for the dialog panel. */
widthClassName?: string;
/** When false, Escape / overlay click / the ✕ button do not close. */
dismissible?: boolean;
/** Hide the ✕ in the header (the footer usually carries a Close button). */
hideCloseButton?: boolean;
/** Focused on mount; falls back to the first focusable child. */
initialFocusRef?: React.RefObject<HTMLElement | null>;
/** Applied to the scrollable body wrapper. */
bodyClassName?: string;
}
/**
* The one modal primitive. Every dialog in the app renders through this so
* `role="dialog"`, `aria-modal`, a focus trap, focus restore, Escape and
* click-outside are implemented once instead of twelve times.
*/
export default function Modal({
title,
onClose,
children,
footer,
description,
widthClassName = "w-[32rem]",
dismissible = true,
hideCloseButton = false,
initialFocusRef,
bodyClassName = "",
}: ModalProps) {
const overlayRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const restoreFocusRef = useRef<HTMLElement | null>(null);
const titleId = useId();
const descId = useId();
// Remember what had focus, move focus inside, restore on unmount.
useEffect(() => {
restoreFocusRef.current = document.activeElement as HTMLElement | null;
const panel = panelRef.current;
if (panel) {
const target =
initialFocusRef?.current ?? focusableWithin(panel)[0] ?? panel;
// Defer so the panel is laid out (offsetParent) before we query it.
requestAnimationFrame(() => target.focus?.());
}
return () => {
restoreFocusRef.current?.focus?.();
};
// Mount/unmount only — re-running would steal focus mid-interaction.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Escape closes; Tab is trapped inside the panel.
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && dismissible) {
e.stopPropagation();
onClose();
return;
}
if (e.key !== "Tab") return;
const panel = panelRef.current;
if (!panel) return;
const items = focusableWithin(panel);
if (items.length === 0) {
e.preventDefault();
panel.focus();
return;
}
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement as HTMLElement | null;
if (!active || !panel.contains(active)) {
e.preventDefault();
first.focus();
return;
}
if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", onKeyDown, true);
return () => document.removeEventListener("keydown", onKeyDown, true);
}, [dismissible, onClose]);
const handleOverlayClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (dismissible && e.target === overlayRef.current) onClose();
},
[dismissible, onClose],
);
return createPortal(
<div
ref={overlayRef}
onClick={handleOverlayClick}
className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={description ? descId : undefined}
tabIndex={-1}
className={`flex flex-col max-h-[85vh] ${widthClassName} max-w-full bg-[var(--bg-overlay)] border border-[var(--border-color)] rounded-[var(--radius-panel)]`}
style={{ boxShadow: "var(--shadow-overlay)" }}
>
<div className="flex items-start justify-between gap-4 px-5 py-3 border-b border-[var(--border-color)] flex-shrink-0">
<div className="min-w-0">
<h2 id={titleId} className="text-sm font-semibold text-[var(--text-primary)]">
{title}
</h2>
{description && (
<p id={descId} className="mt-0.5 text-xs text-[var(--text-secondary)]">
{description}
</p>
)}
</div>
{!hideCloseButton && dismissible && (
<button
type="button"
onClick={onClose}
aria-label="Close dialog"
className="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] transition-colors"
>
<span aria-hidden="true"></span>
</button>
)}
</div>
<div className={`flex-1 min-h-0 overflow-y-auto px-5 py-4 ${bodyClassName}`}>
{children}
</div>
{footer && (
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-[var(--border-color)] flex-shrink-0">
{footer}
</div>
)}
</div>
</div>,
document.body,
);
}
+82
View File
@@ -0,0 +1,82 @@
import { useEffect, useRef, useState } from "react";
export interface OverflowItem {
label: string;
onSelect: () => void;
danger?: boolean;
disabled?: boolean;
}
interface Props {
items: OverflowItem[];
label?: string;
align?: "left" | "right";
}
/** The `⋯` menu that keeps destructive actions out of the main button row. */
export default function OverflowMenu({
items,
label = "More actions",
align = "right",
}: Props) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDocClick = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDocClick);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDocClick);
document.removeEventListener("keydown", onKey);
};
}, [open]);
return (
<div ref={rootRef} className="relative inline-block">
<button
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-label={label}
onClick={() => setOpen((o) => !o)}
className="inline-flex items-center justify-center h-6 w-7 rounded-[var(--radius-control)] border border-[var(--border-color)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--border-color)] transition-colors"
>
<span aria-hidden="true" className="leading-none"></span>
</button>
{open && (
<div
role="menu"
className={`absolute z-40 mt-1 min-w-[11rem] py-1 bg-[var(--bg-overlay)] border border-[var(--border-color)] rounded-[var(--radius-panel)] ${
align === "right" ? "right-0" : "left-0"
}`}
style={{ boxShadow: "var(--shadow-overlay)" }}
>
{items.map((item) => (
<button
key={item.label}
type="button"
role="menuitem"
disabled={item.disabled}
onClick={() => {
setOpen(false);
item.onSelect();
}}
className={`w-full text-left px-3 py-1.5 text-xs transition-colors disabled:text-[var(--text-disabled)] disabled:hover:bg-transparent hover:bg-[var(--bg-tertiary)] ${
item.danger ? "text-[var(--error)]" : "text-[var(--text-primary)]"
}`}
>
{item.label}
</button>
))}
</div>
)}
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import type { SaveState } from "../../hooks/useSaveState";
/**
* Visible outcome for save-on-blur. Config writes used to fail silently into
* `console.error`, which is silent data loss.
*/
export default function SaveIndicator({ state }: { state: SaveState }) {
if (state.status === "idle") return null;
const map = {
saving: { text: "Saving…", color: "var(--text-secondary)" },
saved: { text: "Saved ✓", color: "var(--success)" },
failed: { text: "Save failed ✕", color: "var(--error)" },
} as const;
const tone = map[state.status];
return (
<span
role="status"
aria-live="polite"
className="text-xs font-medium"
style={{ color: tone.color }}
title={state.error ?? undefined}
>
{tone.text}
</span>
);
}
@@ -0,0 +1,92 @@
import { useRef } from "react";
export interface Segment<T extends string> {
value: T;
label: string;
/** Visible helper text under the control when this segment is selected. */
hint?: string;
/** Paint this segment with the caution treatment when selected. */
caution?: boolean;
}
interface Props<T extends string> {
/** Accessible group name. */
label: string;
segments: Segment<T>[];
value: T;
onChange: (value: T) => void;
disabled?: boolean;
className?: string;
}
/**
* Roving-tabindex radio group rendered as a segmented control.
* Arrow keys move between segments; only the selected one is tabbable.
*/
export default function SegmentedControl<T extends string>({
label,
segments,
value,
onChange,
disabled = false,
className = "",
}: Props<T>) {
const groupRef = useRef<HTMLDivElement>(null);
const move = (delta: number) => {
const index = segments.findIndex((s) => s.value === value);
const next = segments[(index + delta + segments.length) % segments.length];
if (!next) return;
onChange(next.value);
requestAnimationFrame(() => {
groupRef.current
?.querySelector<HTMLElement>(`[data-segment="${next.value}"]`)
?.focus();
});
};
return (
<div
ref={groupRef}
role="radiogroup"
aria-label={label}
className={`inline-flex p-0.5 gap-0.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] ${className}`}
onKeyDown={(e) => {
if (disabled) return;
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
move(1);
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
move(-1);
}
}}
>
{segments.map((segment) => {
const selected = segment.value === value;
let cls =
"text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]";
if (selected) {
cls = segment.caution
? "bg-[var(--warning-emphasis)] text-white"
: "bg-[var(--accent-emphasis)] text-white";
}
return (
<button
key={segment.value}
type="button"
role="radio"
data-segment={segment.value}
aria-checked={selected}
tabIndex={selected ? 0 : -1}
disabled={disabled}
onClick={() => onChange(segment.value)}
className={`h-6 px-2.5 text-xs font-medium rounded-[4px] transition-colors disabled:text-[var(--text-disabled)] disabled:hover:bg-transparent ${cls}`}
>
{segment.label}
</button>
);
})}
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
import type { ProjectStatus } from "../../lib/types";
/**
* Status is never encoded by hue alone: every tone carries a distinct glyph
* shape, and (unless `iconOnly`) a word.
*/
export type StatusTone =
| "running"
| "stopped"
| "busy"
| "error"
| "unknown"
| "ok"
| "off";
interface ToneStyle {
glyph: string;
color: string;
pulse?: boolean;
}
const TONES: Record<StatusTone, ToneStyle> = {
running: { glyph: "●", color: "var(--success)" },
ok: { glyph: "●", color: "var(--success)" },
stopped: { glyph: "○", color: "var(--text-secondary)" },
off: { glyph: "○", color: "var(--text-secondary)" },
busy: { glyph: "◐", color: "var(--warning)", pulse: true },
error: { glyph: "▲", color: "var(--error)" },
// Still being checked — distinct from "unavailable", and it pulses.
unknown: { glyph: "◌", color: "var(--text-disabled)", pulse: true },
};
export const PROJECT_STATUS_TONE: Record<ProjectStatus, StatusTone> = {
running: "running",
stopped: "stopped",
starting: "busy",
stopping: "busy",
error: "error",
};
export const PROJECT_STATUS_LABEL: Record<ProjectStatus, string> = {
running: "Running",
stopped: "Stopped",
starting: "Starting",
stopping: "Stopping",
error: "Error",
};
interface Props {
tone: StatusTone;
label: string;
/** Render the glyph only; `label` still ships as accessible text. */
iconOnly?: boolean;
className?: string;
title?: string;
}
export default function StatusIndicator({
tone,
label,
iconOnly = false,
className = "",
title,
}: Props) {
const style = TONES[tone];
return (
<span
className={`inline-flex items-center gap-1 whitespace-nowrap ${className}`}
title={title ?? label}
>
<span
aria-hidden="true"
className={`leading-none text-[10px] ${style.pulse ? "animate-status-pulse" : ""}`}
style={{ color: style.color }}
>
{style.glyph}
</span>
{iconOnly ? (
<span className="sr-only">{label}</span>
) : (
<span style={{ color: style.color }}>{label}</span>
)}
</span>
);
}
/** Status pill for a project, derived from `ProjectStatus`. */
export function ProjectStatusIndicator({
status,
iconOnly,
className,
}: {
status: ProjectStatus;
iconOnly?: boolean;
className?: string;
}) {
return (
<StatusIndicator
tone={PROJECT_STATUS_TONE[status]}
label={PROJECT_STATUS_LABEL[status]}
iconOnly={iconOnly}
className={className}
/>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { useEffect, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { useAppState, type Toast } from "../../store/appState";
const TONE: Record<Toast["kind"], { border: string; bg: string; fg: string; glyph: string }> = {
error: {
border: "var(--error)",
bg: "var(--error-muted)",
fg: "var(--error)",
glyph: "▲",
},
success: {
border: "var(--success)",
bg: "var(--success-muted)",
fg: "var(--success)",
glyph: "✓",
},
info: {
border: "var(--border-color)",
bg: "var(--accent-muted)",
fg: "var(--accent)",
glyph: "●",
},
};
function ToastCard({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) {
const [expanded, setExpanded] = useState(false);
const tone = TONE[toast.kind];
// Errors stay until dismissed; transient confirmations time out.
useEffect(() => {
if (toast.kind === "error") return;
const timer = setTimeout(onDismiss, 6000);
return () => clearTimeout(timer);
}, [toast.kind, onDismiss]);
return (
<div
className="animate-toast-in flex items-start gap-2 w-[24rem] max-w-[calc(100vw-2rem)] px-3 py-2 rounded-[var(--radius-panel)] border text-xs"
style={{
borderColor: tone.border,
background: `color-mix(in srgb, var(--bg-overlay) 88%, ${tone.bg})`,
boxShadow: "var(--shadow-overlay)",
}}
>
<span aria-hidden="true" className="mt-[1px] leading-none" style={{ color: tone.fg }}>
{tone.glyph}
</span>
<div className="flex-1 min-w-0">
<div className="text-[var(--text-primary)] break-words">{toast.message}</div>
{toast.detail && (
<>
<button
type="button"
onClick={() => setExpanded((e) => !e)}
aria-expanded={expanded}
className="mt-1 text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
>
{expanded ? "Hide details" : "Details"}
</button>
{expanded && (
<pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] text-[var(--text-secondary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] p-2">
{toast.detail}
</pre>
)}
</>
)}
</div>
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss notification"
className="flex-shrink-0 w-5 h-5 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] transition-colors"
>
<span aria-hidden="true"></span>
</button>
</div>
);
}
/** Bottom-right stack. Errors get a home here instead of a 12px card line. */
export default function ToastHost() {
const { toasts, dismissToast } = useAppState(
useShallow((s) => ({ toasts: s.toasts, dismissToast: s.dismissToast })),
);
if (toasts.length === 0) return null;
return (
<div
className="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 items-end"
role="region"
aria-label="Notifications"
aria-live="polite"
>
{toasts.map((toast) => (
<ToastCard
key={toast.id}
toast={toast}
onDismiss={() => dismissToast(toast.id)}
/>
))}
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
interface Props {
checked: boolean;
onChange: (value: boolean) => void;
disabled?: boolean;
/** Accessible name — required, since the visual label lives outside. */
label: string;
/** Paint the "on" state as caution rather than success. */
tone?: "success" | "caution";
}
/**
* ON/OFF switch. The old version put white text on `--success` (~2.1:1, the
* worst contrast in the app); the on-state now uses a tinted background with
* the token colour as *foreground*.
*/
export default function Toggle({
checked,
onChange,
disabled = false,
label,
tone = "success",
}: Props) {
const onStyle =
tone === "caution"
? "bg-[var(--warning-muted)] border-[var(--warning)] text-[var(--warning)]"
: "bg-[var(--success-muted)] border-[var(--success)] text-[var(--success)]";
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`inline-flex items-center justify-center h-6 min-w-[3rem] px-2 text-xs font-semibold rounded-[var(--radius-control)] border transition-colors disabled:cursor-not-allowed disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] disabled:bg-[var(--bg-primary)] ${
checked
? onStyle
: "bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)]"
}`}
>
{checked ? "ON" : "OFF"}
</button>
);
}
+4 -1
View File
@@ -51,8 +51,11 @@ export default function Tooltip({ text, children }: TooltipProps) {
>
{children ?? (
<span
className="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-[var(--text-secondary)] text-[var(--text-secondary)] text-[9px] leading-none cursor-help select-none hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors"
className="inline-flex items-center justify-center w-4 h-4 rounded-full border border-[var(--text-secondary)] text-[var(--text-secondary)] text-[10px] leading-none cursor-help select-none hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors"
aria-label="Help"
tabIndex={0}
onFocus={() => setVisible(true)}
onBlur={() => setVisible(false)}
>
?
</span>