diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 7180f16d5..e8ff19dc7 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -8,6 +8,7 @@ @import "./chat/styles.css"; @import "./bench/styles.css"; @import "./settings/styles.css"; +@import "./button-roles.css"; /* react-ui's `.corbits-toast` ships a hardcoded horizontal-center transform (`left: 50%; transform: translateX(-50%) !important`) regardless of the diff --git a/apps/web/src/button-roles.css b/apps/web/src/button-roles.css new file mode 100644 index 000000000..8093c1af3 --- /dev/null +++ b/apps/web/src/button-roles.css @@ -0,0 +1,22 @@ +/* Button roles from DESIGN.md "Color roles". Primary is charcoal via the + `--primary` token; its icon carries the orange. Orange never fills. */ +[data-slot="button"].bg-primary svg { + color: var(--action); +} + +/* Danger-ghost: text-only red for everyday refusals (Deny, Remove). */ +[data-slot="button"].btn-danger-ghost { + color: var(--danger); +} +[data-slot="button"].btn-danger-ghost:hover { + background: var(--danger-wash); + color: var(--danger); +} +[data-slot="button"].bg-destructive:hover { + background: var(--danger-hover); + opacity: 1; +} +[data-slot="button"].bg-destructive:focus-visible, +[data-slot="button"].btn-danger-ghost:focus-visible { + outline-color: var(--danger); +} diff --git a/apps/web/src/chat/approval-row.tsx b/apps/web/src/chat/approval-row.tsx index 19d4fd51a..e60177c67 100644 --- a/apps/web/src/chat/approval-row.tsx +++ b/apps/web/src/chat/approval-row.tsx @@ -59,6 +59,7 @@ export function ApprovalRow({ - - diff --git a/apps/web/src/components/confirm-button.tsx b/apps/web/src/components/confirm-button.tsx new file mode 100644 index 000000000..6dae6086b --- /dev/null +++ b/apps/web/src/components/confirm-button.tsx @@ -0,0 +1,60 @@ +import { useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { Button, type ButtonProps } from "@corbits/react-ui"; + +export type ConfirmButtonProps = Omit & { + /** Fires on the second, confirming click only. */ + readonly onConfirm: () => void; + readonly children: ReactNode; + /** Armed label, e.g. "Delete X permanently". */ + readonly confirmLabel: ReactNode; +}; + +/** + * Two-step destructive action: the first click arms it (solid red plus a + * Cancel), the second commits. Escape, Cancel, or focus leaving disarms. + */ +export function ConfirmButton({ onConfirm, children, confirmLabel, ...props }: ConfirmButtonProps) { + const [armed, setArmed] = useState(false); + const rootRef = useRef(null); + + return ( + { + if (e.key === "Escape") setArmed(false); + }} + onBlur={(e) => { + if (!rootRef.current?.contains(e.relatedTarget as Node | null)) setArmed(false); + }} + > + {armed ? ( + + ) : null} + + + ); +} diff --git a/apps/web/src/pages/tools-page.tsx b/apps/web/src/pages/tools-page.tsx index 1e477315d..ede402588 100644 --- a/apps/web/src/pages/tools-page.tsx +++ b/apps/web/src/pages/tools-page.tsx @@ -8,7 +8,6 @@ import { Card, CardDescription, CardTitle, - ConfirmButton, Input, PageShell, RichEmptyState, @@ -36,6 +35,7 @@ import { import { useDeployedToolPackages } from "../tools/deployed-tool-packages"; import { useBench } from "../bench-context"; import { StageTopBar } from "../shell/stage-top-bar"; +import { ConfirmButton } from "../components/confirm-button"; const AUTH_LABEL: Record = { none: "No sign-in", @@ -84,7 +84,6 @@ function McpServersTable({ server.handle === entry.handle) } onAdd={() => { - addServer({ url: entry.url, name: entry.name, handle: entry.handle }); + addServer({ + url: entry.url, + name: entry.name, + handle: entry.handle, + }); }} /> ))} diff --git a/apps/web/src/pages/workbench-schedules-panel.tsx b/apps/web/src/pages/workbench-schedules-panel.tsx index c0062bdaf..6644d2fe9 100644 --- a/apps/web/src/pages/workbench-schedules-panel.tsx +++ b/apps/web/src/pages/workbench-schedules-panel.tsx @@ -18,6 +18,7 @@ import { type CronSchedule, } from "../routines-api"; import { tenantKeys } from "../query-client"; +import { ConfirmButton } from "../components/confirm-button"; type FormState = { readonly definitionName: string; @@ -64,7 +65,9 @@ export function WorkbenchSchedulesPanel({ }); const invalidate = () => - queryClient.invalidateQueries({ queryKey: tenantKeys.schedules(workbenchTenantId) }); + queryClient.invalidateQueries({ + queryKey: tenantKeys.schedules(workbenchTenantId), + }); const create = useMutation({ mutationFn: (input: FormState) => @@ -164,14 +167,14 @@ export function WorkbenchSchedulesPanel({ Schedule again )} - + ))} diff --git a/apps/web/src/settings/credentials-section.tsx b/apps/web/src/settings/credentials-section.tsx index cbff72728..266df250c 100644 --- a/apps/web/src/settings/credentials-section.tsx +++ b/apps/web/src/settings/credentials-section.tsx @@ -5,7 +5,6 @@ import { Button, - ConfirmButton, Dialog, DialogBody, DialogContent, @@ -56,6 +55,7 @@ import { type Provider, } from "./credentials-api"; import { SETTINGS_STRINGS } from "./strings"; +import { ConfirmButton } from "../components/confirm-button"; type CatalogProvider = typeof ModelProviderResponse.infer; type CatalogOffering = typeof ModelOfferingResponse.infer; @@ -125,7 +125,13 @@ export function CredentialsSection({ tenantId }: { readonly tenantId: string | n listOwnOfferings(tenantId), listOwnModels(tenantId), ]); - return { credentials, providers, catalogProviders, catalogOfferings, catalogModels }; + return { + credentials, + providers, + catalogProviders, + catalogOfferings, + catalogModels, + }; }, enabled: tenantId !== null, }); @@ -137,11 +143,15 @@ export function CredentialsSection({ tenantId }: { readonly tenantId: string | n function reload() { if (tenantId === null) return; - void queryClient.invalidateQueries({ queryKey: tenantKeys.credentials(tenantId) }); + void queryClient.invalidateQueries({ + queryKey: tenantKeys.credentials(tenantId), + }); // The catalog rows this dialog can now rewrite are read by the // Inference settings section and by chat's model resolution — both // key off the resolved catalog, so a credential edit must bust it too. - void queryClient.invalidateQueries({ queryKey: ["tenant", tenantId, "settings-models"] }); + void queryClient.invalidateQueries({ + queryKey: ["tenant", tenantId, "settings-models"], + }); } const create = useMutation({ @@ -157,7 +167,12 @@ export function CredentialsSection({ tenantId }: { readonly tenantId: string | n if (tenantId === null) throw new Error("no workbench selected"); const existing = providers.find((provider) => provider.name === name); const provider = existing !== undefined ? existing : await createProvider(tenantId, name); - return createCredential(tenantId, { providerId: provider.id, name, type, secret }); + return createCredential(tenantId, { + providerId: provider.id, + name, + type, + secret, + }); }, onSuccess: () => { setCreateOpen(false); @@ -279,7 +294,10 @@ export function CredentialsSection({ tenantId }: { readonly tenantId: string | n const editingLinkage: EditCredentialLinkage | null = editingProvider === null ? null - : { baseURL: editingProvider.baseURL, model: editingModel?.canonicalName ?? "" }; + : { + baseURL: editingProvider.baseURL, + model: editingModel?.canonicalName ?? "", + }; if (tenantId === null) { return ( @@ -396,7 +414,6 @@ function CredentialsTable({ {SETTINGS_STRINGS.credentialsEditAction} onDelete(credential)} diff --git a/apps/web/src/settings/grants-section.tsx b/apps/web/src/settings/grants-section.tsx index eca875564..89c2ddb52 100644 --- a/apps/web/src/settings/grants-section.tsx +++ b/apps/web/src/settings/grants-section.tsx @@ -4,7 +4,6 @@ import { Badge, Button, - ConfirmButton, Dialog, DialogBody, DialogContent, @@ -51,6 +50,7 @@ import { type Principal, type Role, } from "./tenancy-api"; +import { ConfirmButton } from "../components/confirm-button"; const EFFECT_TONE: Record = { allow: "success", @@ -110,7 +110,9 @@ export function GrantsSection({ tenantId }: { readonly tenantId: string | null } function reload() { if (tenantId === null) return; - void queryClient.invalidateQueries({ queryKey: tenantKeys.grants(tenantId) }); + void queryClient.invalidateQueries({ + queryKey: tenantKeys.grants(tenantId), + }); } if (tenantId === null) { @@ -312,7 +314,6 @@ export function GrantsTable({ {grant.expiresAt ?? SETTINGS_STRINGS.grantsNoExpiry} onRevoke(grant)} diff --git a/apps/web/src/settings/people-section.tsx b/apps/web/src/settings/people-section.tsx index 28c77e75f..37f323db4 100644 --- a/apps/web/src/settings/people-section.tsx +++ b/apps/web/src/settings/people-section.tsx @@ -4,7 +4,6 @@ import { Badge, Button, - ConfirmButton, Dialog, DialogBody, DialogContent, @@ -42,6 +41,7 @@ import { type Principal, type Role, } from "./tenancy-api"; +import { ConfirmButton } from "../components/confirm-button"; const STATUS_TONE: Record = { active: "success", @@ -86,7 +86,9 @@ export function PeopleSection({ tenantId }: { readonly tenantId: string | null } function reload() { if (tenantId === null) return; - void queryClient.invalidateQueries({ queryKey: tenantKeys.principals(tenantId) }); + void queryClient.invalidateQueries({ + queryKey: tenantKeys.principals(tenantId), + }); } const inviteMutation = useMutation({ @@ -99,7 +101,10 @@ export function PeopleSection({ tenantId }: { readonly tenantId: string | null } reload(); }, onError: (cause: unknown) => { - reportError(cause, { operation: "settings.people.invite", tenantId: tenantId ?? "none" }); + reportError(cause, { + operation: "settings.people.invite", + tenantId: tenantId ?? "none", + }); }, }); @@ -204,7 +209,10 @@ export function PeopleSection({ tenantId }: { readonly tenantId: string | null } submitting={inviteMutation.isPending} error={inviteMutation.isError ? SETTINGS_STRINGS.peopleInviteError : null} onInvite={(email, roleId) => - inviteMutation.mutate({ email, ...(roleId !== undefined ? { roleId } : {}) }) + inviteMutation.mutate({ + email, + ...(roleId !== undefined ? { roleId } : {}), + }) } /> @@ -416,7 +424,6 @@ export function PeopleTable({ )} onRemove(person)} diff --git a/apps/web/src/settings/roles-section.tsx b/apps/web/src/settings/roles-section.tsx index eefc8019d..02e8e6f57 100644 --- a/apps/web/src/settings/roles-section.tsx +++ b/apps/web/src/settings/roles-section.tsx @@ -1,7 +1,6 @@ import { Badge, Button, - ConfirmButton, Dialog, DialogBody, DialogContent, @@ -37,6 +36,7 @@ import { type Principal, type Role, } from "./tenancy-api"; +import { ConfirmButton } from "../components/confirm-button"; type RolesData = { readonly roles: readonly Role[]; @@ -69,7 +69,9 @@ export function RolesSection({ tenantId }: { readonly tenantId: string | null }) function reload() { if (tenantId === null) return; - void queryClient.invalidateQueries({ queryKey: tenantKeys.roles(tenantId) }); + void queryClient.invalidateQueries({ + queryKey: tenantKeys.roles(tenantId), + }); } if (tenantId === null) { @@ -247,7 +249,6 @@ export function RolesTable({ {SETTINGS_STRINGS.rolesRenameAction} onDelete(role)}