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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user