mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
refactor: split sidebar callout from generic Alert primitive
Rename CalloutCard to SidebarCallout (it was always sidebar-specific) and add a shadcn-style Alert primitive with info/success/warning/error variants for inline use in screens. Replace the misused SidebarCallout in project settings with Alert and surface the underlying RPC error message instead of generic copy. Tune destructive color per theme to a calm tinted red.
This commit is contained in:
@@ -33,8 +33,8 @@ Hierarchy is conveyed through weight and color, not size. Most labels, titles, a
|
|||||||
Weight has three tiers, applied by role:
|
Weight has three tiers, applied by role:
|
||||||
|
|
||||||
- **Screen titles** — the title at the top of a screen — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx:31-34`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. Top-of-screen titles are lighter on desktop, not heavier. The workspace screen header follows the same rule (`packages/app/src/screens/workspace/workspace-screen.tsx:3052-3057`).
|
- **Screen titles** — the title at the top of a screen — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx:31-34`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. Top-of-screen titles are lighter on desktop, not heavier. The workspace screen header follows the same rule (`packages/app/src/screens/workspace/workspace-screen.tsx:3052-3057`).
|
||||||
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/components/add-host-modal.tsx:19-23`, `packages/app/src/components/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the callout actions (`packages/app/src/components/callout-card.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
|
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/components/add-host-modal.tsx:19-23`, `packages/app/src/components/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
|
||||||
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<Button>` text (`packages/app/src/components/ui/button.tsx:80-84`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<CalloutCard>` titles (`packages/app/src/components/callout-card.tsx:175-180`).
|
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<Button>` text (`packages/app/src/components/ui/button.tsx:80-84`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`).
|
||||||
|
|
||||||
The rule, condensed: text that _names_ a surface or a group is `medium`. Text that lives _inside_ a surface or a group is `normal`. Top-of-screen titles are `<ScreenTitle>`, which is lighter still.
|
The rule, condensed: text that _names_ a surface or a group is `medium`. Text that lives _inside_ a surface or a group is `normal`. Top-of-screen titles are `<ScreenTitle>`, which is lighter still.
|
||||||
|
|
||||||
@@ -163,9 +163,11 @@ Empty states are short noun phrases. Centered, muted, one or two lines. Sessions
|
|||||||
|
|
||||||
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
|
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
|
||||||
|
|
||||||
Actionable errors are `<CalloutCard variant="error" actions={[...]} />` when the user can act on them from the page. The daemon-version-mismatch callout is the canonical use (`packages/app/src/components/daemon-version-mismatch-callout-source.tsx`).
|
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline" size="sm">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
|
||||||
|
|
||||||
Imperative errors are `Alert.alert("Error", "Unable to ...")` for failures that interrupt the flow and have no place on the page.
|
Sidebar callouts — cross-cutting alerts that apply across the whole app, like daemon version mismatch and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Canonical sources: `packages/app/src/components/daemon-version-mismatch-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
|
||||||
|
|
||||||
|
Imperative errors are `Alert.alert("Error", "Unable to ...")` (the React Native `Alert` API, not this component) for failures that interrupt the flow and have no place on the page.
|
||||||
|
|
||||||
Disabled state is `opacity: theme.opacity[50]` on the outer pressable. Color changes for disabled state are wrong; a disabled button is the same button, dimmer.
|
Disabled state is `opacity: theme.opacity[50]` on the outer pressable. Color changes for disabled state are wrong; a disabled button is the same button, dimmer.
|
||||||
|
|
||||||
@@ -203,7 +205,7 @@ The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `
|
|||||||
|
|
||||||
## 13. Forbidden
|
## 13. Forbidden
|
||||||
|
|
||||||
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<CalloutCard>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
|
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
|
||||||
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
|
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
|
||||||
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
|
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
|
||||||
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
|
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
|
||||||
@@ -223,22 +225,23 @@ The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `
|
|||||||
|
|
||||||
## 14. Canonical surfaces by pattern
|
## 14. Canonical surfaces by pattern
|
||||||
|
|
||||||
| Pattern | Reference |
|
| Pattern | Reference |
|
||||||
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| List+detail (compact stack, desktop sidebar+pane) | `packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx` |
|
| List+detail (compact stack, desktop sidebar+pane) | `packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx` |
|
||||||
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/screens/settings/providers-section.tsx` |
|
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/screens/settings/providers-section.tsx` |
|
||||||
| Section grouping inside a card list | `packages/app/src/screens/settings/settings-section.tsx` |
|
| Section grouping inside a card list | `packages/app/src/screens/settings/settings-section.tsx` |
|
||||||
| Form modal (label + input fields, primary + cancel) | `packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
|
| Form modal (label + input fields, primary + cancel) | `packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
|
||||||
| Destructive confirmation | `confirmDialog` invoked from `packages/app/src/screens/settings/host-page.tsx:541-547` |
|
| Destructive confirmation | `confirmDialog` invoked from `packages/app/src/screens/settings/host-page.tsx:541-547` |
|
||||||
| Centered hero / first-run | `packages/app/src/components/welcome-screen.tsx` |
|
| Centered hero / first-run | `packages/app/src/components/welcome-screen.tsx` |
|
||||||
| Sidebar list (workspaces, hosts) | `packages/app/src/components/sidebar-workspace-list.tsx`, `packages/app/src/components/left-sidebar.tsx` |
|
| Sidebar list (workspaces, hosts) | `packages/app/src/components/sidebar-workspace-list.tsx`, `packages/app/src/components/left-sidebar.tsx` |
|
||||||
| Live list of items with sections (agents) | `packages/app/src/components/agent-list.tsx` |
|
| Live list of items with sections (agents) | `packages/app/src/components/agent-list.tsx` |
|
||||||
| Historical list (sessions) | `packages/app/src/screens/sessions-screen.tsx` |
|
| Historical list (sessions) | `packages/app/src/screens/sessions-screen.tsx` |
|
||||||
| Workspace pane (multi-tab, split) | `packages/app/src/screens/workspace/workspace-screen.tsx` |
|
| Workspace pane (multi-tab, split) | `packages/app/src/screens/workspace/workspace-screen.tsx` |
|
||||||
| Composer / message input | `packages/app/src/components/composer.tsx`, `packages/app/src/components/message-input.tsx` |
|
| Composer / message input | `packages/app/src/components/composer.tsx`, `packages/app/src/components/message-input.tsx` |
|
||||||
| Pane chrome with single bottom border | `packages/app/src/components/git-diff-pane.tsx`, `packages/app/src/components/file-explorer-pane.tsx`, `packages/app/src/components/terminal-pane.tsx` |
|
| Pane chrome with single bottom border | `packages/app/src/components/git-diff-pane.tsx`, `packages/app/src/components/file-explorer-pane.tsx`, `packages/app/src/components/terminal-pane.tsx` |
|
||||||
| Page-level callout with action | `packages/app/src/components/callout-card.tsx`, `packages/app/src/components/daemon-version-mismatch-callout-source.tsx` |
|
| Page-level alert (info / success / warning / error) | `packages/app/src/components/ui/alert.tsx`, `packages/app/src/screens/project-settings-screen.tsx` |
|
||||||
| Searchable picker | `packages/app/src/components/ui/combobox.tsx`, `packages/app/src/components/branch-switcher.tsx` |
|
| Sidebar callout (cross-cutting alert) | `packages/app/src/components/sidebar-callout.tsx`, `packages/app/src/contexts/sidebar-callout-context.tsx`, `packages/app/src/components/daemon-version-mismatch-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx` |
|
||||||
| Trigger-anchored menu | `packages/app/src/components/ui/dropdown-menu.tsx` (used in `sidebar-workspace-list.tsx`, theme picker) |
|
| Searchable picker | `packages/app/src/components/ui/combobox.tsx`, `packages/app/src/components/branch-switcher.tsx` |
|
||||||
| Right-click / long-press menu | `packages/app/src/components/ui/context-menu.tsx` (used in `sidebar-workspace-list.tsx`) |
|
| Trigger-anchored menu | `packages/app/src/components/ui/dropdown-menu.tsx` (used in `sidebar-workspace-list.tsx`, theme picker) |
|
||||||
| Headers (back, screen, menu) | `packages/app/src/components/headers/back-header.tsx`, `screen-header.tsx`, `menu-header.tsx` |
|
| Right-click / long-press menu | `packages/app/src/components/ui/context-menu.tsx` (used in `sidebar-workspace-list.tsx`) |
|
||||||
|
| Headers (back, screen, menu) | `packages/app/src/components/headers/back-header.tsx`, `screen-header.tsx`, `menu-header.tsx` |
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { router, usePathname } from "expo-router";
|
import { router, usePathname } from "expo-router";
|
||||||
import { MessagesSquare, Plus, Settings } from "lucide-react-native";
|
import { FolderPlus, MessagesSquare, Settings } from "lucide-react-native";
|
||||||
import {
|
import {
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
memo,
|
memo,
|
||||||
@@ -365,7 +365,7 @@ function FooterIconButton({
|
|||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
testID: string;
|
testID: string;
|
||||||
accessibilityLabel: string;
|
accessibilityLabel: string;
|
||||||
icon: typeof Plus;
|
icon: typeof FolderPlus;
|
||||||
theme: SidebarTheme;
|
theme: SidebarTheme;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -448,7 +448,7 @@ function SidebarFooter({
|
|||||||
onPress={handleOpenProject}
|
onPress={handleOpenProject}
|
||||||
testID="sidebar-add-project"
|
testID="sidebar-add-project"
|
||||||
accessibilityLabel="Add project"
|
accessibilityLabel="Add project"
|
||||||
icon={Plus}
|
icon={FolderPlus}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
/>
|
/>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|||||||
@@ -55,15 +55,15 @@ vi.mock("lucide-react-native", () => {
|
|||||||
vi.stubGlobal("React", React);
|
vi.stubGlobal("React", React);
|
||||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||||
|
|
||||||
import { CalloutCard } from "./callout-card";
|
import { SidebarCallout } from "./sidebar-callout";
|
||||||
|
|
||||||
type CalloutCardActions = React.ComponentProps<typeof CalloutCard>["actions"];
|
type SidebarCalloutActions = React.ComponentProps<typeof SidebarCallout>["actions"];
|
||||||
|
|
||||||
function buildSingleAction(onPress: () => void): CalloutCardActions {
|
function buildSingleAction(onPress: () => void): SidebarCalloutActions {
|
||||||
return [{ label: "Undo", onPress }];
|
return [{ label: "Undo", onPress }];
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTwoActions(onWhatsNew: () => void, onInstall: () => void): CalloutCardActions {
|
function buildTwoActions(onWhatsNew: () => void, onInstall: () => void): SidebarCalloutActions {
|
||||||
return [
|
return [
|
||||||
{ label: "What's new", onPress: onWhatsNew },
|
{ label: "What's new", onPress: onWhatsNew },
|
||||||
{ label: "Install & restart", onPress: onInstall, variant: "primary" },
|
{ label: "Install & restart", onPress: onInstall, variant: "primary" },
|
||||||
@@ -72,7 +72,7 @@ function buildTwoActions(onWhatsNew: () => void, onInstall: () => void): Callout
|
|||||||
|
|
||||||
const calloutTitleIcon = <span data-testid="callout-title-icon" />;
|
const calloutTitleIcon = <span data-testid="callout-title-icon" />;
|
||||||
|
|
||||||
describe("CalloutCard", () => {
|
describe("SidebarCallout", () => {
|
||||||
let root: Root | null = null;
|
let root: Root | null = null;
|
||||||
let container: HTMLElement | null = null;
|
let container: HTMLElement | null = null;
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ describe("CalloutCard", () => {
|
|||||||
it("renders title and description", () => {
|
it("renders title and description", () => {
|
||||||
act(() => {
|
act(() => {
|
||||||
root?.render(
|
root?.render(
|
||||||
<CalloutCard title="Update available" description="v1.2.3 is ready to install." />,
|
<SidebarCallout title="Update available" description="v1.2.3 is ready to install." />,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ describe("CalloutCard", () => {
|
|||||||
|
|
||||||
it("renders an icon next to the title", () => {
|
it("renders an icon next to the title", () => {
|
||||||
act(() => {
|
act(() => {
|
||||||
root?.render(<CalloutCard title="Update available" icon={calloutTitleIcon} />);
|
root?.render(<SidebarCallout title="Update available" icon={calloutTitleIcon} />);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(container?.querySelector('[data-testid="callout-title-icon"]')).not.toBeNull();
|
expect(container?.querySelector('[data-testid="callout-title-icon"]')).not.toBeNull();
|
||||||
@@ -116,7 +116,7 @@ describe("CalloutCard", () => {
|
|||||||
const onPress = vi.fn();
|
const onPress = vi.fn();
|
||||||
const actions = buildSingleAction(onPress);
|
const actions = buildSingleAction(onPress);
|
||||||
act(() => {
|
act(() => {
|
||||||
root?.render(<CalloutCard description="Saved." actions={actions} testID="callout" />);
|
root?.render(<SidebarCallout description="Saved." actions={actions} testID="callout" />);
|
||||||
});
|
});
|
||||||
|
|
||||||
const button = container?.querySelector(
|
const button = container?.querySelector(
|
||||||
@@ -130,7 +130,7 @@ describe("CalloutCard", () => {
|
|||||||
const actions = buildTwoActions(vi.fn(), vi.fn());
|
const actions = buildTwoActions(vi.fn(), vi.fn());
|
||||||
act(() => {
|
act(() => {
|
||||||
root?.render(
|
root?.render(
|
||||||
<CalloutCard
|
<SidebarCallout
|
||||||
title="Update available"
|
title="Update available"
|
||||||
description="v1 ready."
|
description="v1 ready."
|
||||||
actions={actions}
|
actions={actions}
|
||||||
@@ -149,7 +149,7 @@ describe("CalloutCard", () => {
|
|||||||
|
|
||||||
it("renders no action row when no actions are provided", () => {
|
it("renders no action row when no actions are provided", () => {
|
||||||
act(() => {
|
act(() => {
|
||||||
root?.render(<CalloutCard description="Copied" testID="callout" />);
|
root?.render(<SidebarCallout description="Copied" testID="callout" />);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(container?.querySelector('[data-testid="callout-actions"]')).toBeNull();
|
expect(container?.querySelector('[data-testid="callout-actions"]')).toBeNull();
|
||||||
@@ -158,7 +158,7 @@ describe("CalloutCard", () => {
|
|||||||
it("renders the dismiss X in the top-left when onDismiss is provided", () => {
|
it("renders the dismiss X in the top-left when onDismiss is provided", () => {
|
||||||
const onDismiss = vi.fn();
|
const onDismiss = vi.fn();
|
||||||
act(() => {
|
act(() => {
|
||||||
root?.render(<CalloutCard description="Saved" onDismiss={onDismiss} testID="callout" />);
|
root?.render(<SidebarCallout description="Saved" onDismiss={onDismiss} testID="callout" />);
|
||||||
});
|
});
|
||||||
|
|
||||||
const dismissButton = container?.querySelector(
|
const dismissButton = container?.querySelector(
|
||||||
@@ -169,7 +169,7 @@ describe("CalloutCard", () => {
|
|||||||
|
|
||||||
it("omits the dismiss button when onDismiss is not provided", () => {
|
it("omits the dismiss button when onDismiss is not provided", () => {
|
||||||
act(() => {
|
act(() => {
|
||||||
root?.render(<CalloutCard description="Saved" testID="callout" />);
|
root?.render(<SidebarCallout description="Saved" testID="callout" />);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(container?.querySelector('[data-testid="callout-dismiss"]')).toBeNull();
|
expect(container?.querySelector('[data-testid="callout-dismiss"]')).toBeNull();
|
||||||
@@ -3,33 +3,33 @@ import { useCallback, useMemo, type ReactNode } from "react";
|
|||||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
|
||||||
export type CalloutActionVariant = "primary" | "secondary";
|
export type SidebarCalloutActionVariant = "primary" | "secondary";
|
||||||
|
|
||||||
export interface CalloutAction {
|
export interface SidebarCalloutAction {
|
||||||
label: string;
|
label: string;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
variant?: CalloutActionVariant;
|
variant?: SidebarCalloutActionVariant;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CalloutVariant = "default" | "success" | "error";
|
export type SidebarCalloutVariant = "default" | "success" | "error";
|
||||||
|
|
||||||
export interface CalloutCardProps {
|
export interface SidebarCalloutProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: ReactNode;
|
description?: ReactNode;
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
variant?: CalloutVariant;
|
variant?: SidebarCalloutVariant;
|
||||||
actions?: readonly CalloutAction[];
|
actions?: readonly SidebarCalloutAction[];
|
||||||
onDismiss?: () => void;
|
onDismiss?: () => void;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CalloutDescriptionText({ children }: { children: ReactNode }) {
|
export function SidebarCalloutDescriptionText({ children }: { children: ReactNode }) {
|
||||||
return <Text style={styles.description}>{children}</Text>;
|
return <Text style={styles.description}>{children}</Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CalloutCard({
|
export function SidebarCallout({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
icon,
|
icon,
|
||||||
@@ -37,7 +37,7 @@ export function CalloutCard({
|
|||||||
actions,
|
actions,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
testID,
|
testID,
|
||||||
}: CalloutCardProps) {
|
}: SidebarCalloutProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const visibleActions = (actions ?? []).slice(0, 2);
|
const visibleActions = (actions ?? []).slice(0, 2);
|
||||||
const hasHeader = title != null || icon != null;
|
const hasHeader = title != null || icon != null;
|
||||||
@@ -84,7 +84,7 @@ export function CalloutCard({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{hasDescription && typeof description === "string" ? (
|
{hasDescription && typeof description === "string" ? (
|
||||||
<CalloutDescriptionText>{description}</CalloutDescriptionText>
|
<SidebarCalloutDescriptionText>{description}</SidebarCalloutDescriptionText>
|
||||||
) : null}
|
) : null}
|
||||||
{hasDescription && typeof description !== "string" ? (
|
{hasDescription && typeof description !== "string" ? (
|
||||||
<View style={styles.descriptionSlot}>{description}</View>
|
<View style={styles.descriptionSlot}>{description}</View>
|
||||||
@@ -93,7 +93,7 @@ export function CalloutCard({
|
|||||||
{visibleActions.length > 0 ? (
|
{visibleActions.length > 0 ? (
|
||||||
<View style={styles.actionRow} testID={testID ? `${testID}-actions` : undefined}>
|
<View style={styles.actionRow} testID={testID ? `${testID}-actions` : undefined}>
|
||||||
{visibleActions.map((action, index) => (
|
{visibleActions.map((action, index) => (
|
||||||
<CalloutActionButton
|
<SidebarCalloutActionButton
|
||||||
key={action.label}
|
key={action.label}
|
||||||
action={action}
|
action={action}
|
||||||
testID={action.testID ?? (testID ? `${testID}-action-${index}` : undefined)}
|
testID={action.testID ?? (testID ? `${testID}-action-${index}` : undefined)}
|
||||||
@@ -106,7 +106,13 @@ export function CalloutCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CalloutActionButton({ action, testID }: { action: CalloutAction; testID?: string }) {
|
function SidebarCalloutActionButton({
|
||||||
|
action,
|
||||||
|
testID,
|
||||||
|
}: {
|
||||||
|
action: SidebarCalloutAction;
|
||||||
|
testID?: string;
|
||||||
|
}) {
|
||||||
const isPrimary = action.variant === "primary";
|
const isPrimary = action.variant === "primary";
|
||||||
const labelStyle = useMemo(
|
const labelStyle = useMemo(
|
||||||
() => [styles.actionLabel, isPrimary ? styles.actionLabelPrimary : styles.actionLabelSecondary],
|
() => [styles.actionLabel, isPrimary ? styles.actionLabelPrimary : styles.actionLabelSecondary],
|
||||||
121
packages/app/src/components/ui/alert.tsx
Normal file
121
packages/app/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { AlertTriangle, CheckCircle2, Info, XCircle, type LucideIcon } from "lucide-react-native";
|
||||||
|
import { type ReactNode, useMemo } from "react";
|
||||||
|
import { Text, View } from "react-native";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
|
||||||
|
export type AlertVariant = "default" | "info" | "success" | "warning" | "error";
|
||||||
|
|
||||||
|
export interface AlertProps {
|
||||||
|
title?: string;
|
||||||
|
description?: ReactNode;
|
||||||
|
variant?: AlertVariant;
|
||||||
|
icon?: ReactNode;
|
||||||
|
children?: ReactNode;
|
||||||
|
testID?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VARIANT_ICON: Record<Exclude<AlertVariant, "default">, LucideIcon> = {
|
||||||
|
info: Info,
|
||||||
|
success: CheckCircle2,
|
||||||
|
warning: AlertTriangle,
|
||||||
|
error: XCircle,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Alert({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
variant = "default",
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
testID,
|
||||||
|
}: AlertProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const accentColor = resolveAccentColor(variant, theme);
|
||||||
|
|
||||||
|
const containerStyle = useMemo(
|
||||||
|
() => [styles.container, accentColor ? { borderColor: accentColor } : null],
|
||||||
|
[accentColor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const titleStyle = useMemo(
|
||||||
|
() => [styles.title, accentColor ? { color: accentColor } : null],
|
||||||
|
[accentColor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolvedIcon = useMemo(() => {
|
||||||
|
if (icon !== undefined) return icon;
|
||||||
|
if (variant === "default") return null;
|
||||||
|
const Icon = VARIANT_ICON[variant];
|
||||||
|
return <Icon size={theme.iconSize.sm} color={accentColor ?? theme.colors.foreground} />;
|
||||||
|
}, [icon, variant, theme, accentColor]);
|
||||||
|
|
||||||
|
const hasDescription = description != null && description !== "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={containerStyle} testID={testID} accessibilityRole="alert">
|
||||||
|
{resolvedIcon ? <View style={styles.iconSlot}>{resolvedIcon}</View> : null}
|
||||||
|
<View style={styles.body}>
|
||||||
|
{title ? <Text style={titleStyle}>{title}</Text> : null}
|
||||||
|
{hasDescription && typeof description === "string" ? (
|
||||||
|
<Text style={styles.description}>{description}</Text>
|
||||||
|
) : null}
|
||||||
|
{hasDescription && typeof description !== "string" ? (
|
||||||
|
<View style={styles.descriptionSlot}>{description}</View>
|
||||||
|
) : null}
|
||||||
|
{children ? <View style={styles.actions}>{children}</View> : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAccentColor(
|
||||||
|
variant: AlertVariant,
|
||||||
|
theme: ReturnType<typeof useUnistyles>["theme"],
|
||||||
|
): string | null {
|
||||||
|
if (variant === "info") return theme.colors.palette.blue[300];
|
||||||
|
if (variant === "success") return theme.colors.palette.green[400];
|
||||||
|
if (variant === "warning") return theme.colors.palette.amber[500];
|
||||||
|
if (variant === "error") return theme.colors.destructive;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
container: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
borderRadius: theme.borderRadius.xl,
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
},
|
||||||
|
iconSlot: {
|
||||||
|
paddingTop: 2,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.medium,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
},
|
||||||
|
descriptionSlot: {
|
||||||
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
marginTop: theme.spacing[2],
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -9,22 +9,20 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
type CalloutAction,
|
SidebarCallout,
|
||||||
CalloutCard,
|
type SidebarCalloutAction,
|
||||||
type CalloutCardProps,
|
type SidebarCalloutProps,
|
||||||
type CalloutVariant,
|
type SidebarCalloutVariant,
|
||||||
} from "@/components/callout-card";
|
} from "@/components/sidebar-callout";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
|
|
||||||
export type SidebarCalloutAction = CalloutAction;
|
|
||||||
|
|
||||||
export interface SidebarCalloutOptions {
|
export interface SidebarCalloutOptions {
|
||||||
id: string;
|
id: string;
|
||||||
dismissalKey?: string;
|
dismissalKey?: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: ReactNode;
|
description?: ReactNode;
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
variant?: CalloutVariant;
|
variant?: SidebarCalloutVariant;
|
||||||
actions?: readonly SidebarCalloutAction[];
|
actions?: readonly SidebarCalloutAction[];
|
||||||
dismissible?: boolean;
|
dismissible?: boolean;
|
||||||
priority?: number;
|
priority?: number;
|
||||||
@@ -218,7 +216,7 @@ export function SidebarCalloutViewport() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cardProps: CalloutCardProps = {
|
const cardProps: SidebarCalloutProps = {
|
||||||
title: activeCallout.title,
|
title: activeCallout.title,
|
||||||
description: activeCallout.description,
|
description: activeCallout.description,
|
||||||
icon: activeCallout.icon,
|
icon: activeCallout.icon,
|
||||||
@@ -229,5 +227,5 @@ export function SidebarCalloutViewport() {
|
|||||||
testID: activeCallout.testID,
|
testID: activeCallout.testID,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <CalloutCard {...cardProps} />;
|
return <SidebarCallout {...cardProps} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { Gift } from "lucide-react-native";
|
import { Gift } from "lucide-react-native";
|
||||||
import { type ReactNode, useEffect, useRef } from "react";
|
import { type ReactNode, useEffect, useRef } from "react";
|
||||||
import { useUnistyles } from "react-native-unistyles";
|
import { useUnistyles } from "react-native-unistyles";
|
||||||
import { CalloutDescriptionText } from "@/components/callout-card";
|
import {
|
||||||
import { type SidebarCalloutAction, useSidebarCallouts } from "@/contexts/sidebar-callout-context";
|
type SidebarCalloutAction,
|
||||||
|
SidebarCalloutDescriptionText,
|
||||||
|
} from "@/components/sidebar-callout";
|
||||||
|
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
|
||||||
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
|
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
@@ -167,14 +170,14 @@ export function UpdateCalloutSource() {
|
|||||||
function UpdateAvailableDescription({ versionLabel }: { versionLabel?: string }) {
|
function UpdateAvailableDescription({ versionLabel }: { versionLabel?: string }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CalloutDescriptionText>
|
<SidebarCalloutDescriptionText>
|
||||||
{versionLabel
|
{versionLabel
|
||||||
? `${versionLabel} is ready to install.`
|
? `${versionLabel} is ready to install.`
|
||||||
: "A new version is ready to install."}
|
: "A new version is ready to install."}
|
||||||
</CalloutDescriptionText>
|
</SidebarCalloutDescriptionText>
|
||||||
<CalloutDescriptionText>
|
<SidebarCalloutDescriptionText>
|
||||||
Upgrading the app will stop running agents and close terminal sessions.
|
Upgrading the app will stop running agents and close terminal sessions.
|
||||||
</CalloutDescriptionText>
|
</SidebarCalloutDescriptionText>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -355,16 +355,16 @@ vi.mock("@/components/adaptive-modal-sheet", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/components/callout-card", () => ({
|
vi.mock("@/components/ui/alert", () => ({
|
||||||
CalloutCard: ({
|
Alert: ({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
actions,
|
children,
|
||||||
testID,
|
testID,
|
||||||
}: {
|
}: {
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: React.ReactNode;
|
description?: React.ReactNode;
|
||||||
actions?: readonly { label: string; onPress: () => void }[];
|
children?: React.ReactNode;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
}) =>
|
}) =>
|
||||||
React.createElement(
|
React.createElement(
|
||||||
@@ -372,18 +372,7 @@ vi.mock("@/components/callout-card", () => ({
|
|||||||
{ "data-testid": testID, role: "alert" },
|
{ "data-testid": testID, role: "alert" },
|
||||||
React.createElement("span", { "data-testid": `${testID}-title` }, title),
|
React.createElement("span", { "data-testid": `${testID}-title` }, title),
|
||||||
React.createElement("span", { "data-testid": `${testID}-description` }, description),
|
React.createElement("span", { "data-testid": `${testID}-description` }, description),
|
||||||
actions?.map((action, index) =>
|
children,
|
||||||
React.createElement(
|
|
||||||
"button",
|
|
||||||
{
|
|
||||||
key: index,
|
|
||||||
type: "button",
|
|
||||||
"data-testid": `${testID}-action-${index}`,
|
|
||||||
onClick: action.onPress,
|
|
||||||
},
|
|
||||||
action.label,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Alert } from "@/components/ui/alert";
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { CalloutCard, type CalloutAction } from "@/components/callout-card";
|
|
||||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||||
import { settingsStyles } from "@/styles/settings";
|
import { settingsStyles } from "@/styles/settings";
|
||||||
@@ -232,6 +232,7 @@ function renderContent({
|
|||||||
return (
|
return (
|
||||||
<ReadFailureCallout
|
<ReadFailureCallout
|
||||||
kind="transport"
|
kind="transport"
|
||||||
|
error={readQuery.error}
|
||||||
onReload={onReload}
|
onReload={onReload}
|
||||||
hasMultipleHosts={hasMultipleHosts}
|
hasMultipleHosts={hasMultipleHosts}
|
||||||
/>
|
/>
|
||||||
@@ -242,6 +243,7 @@ function renderContent({
|
|||||||
return (
|
return (
|
||||||
<ReadFailureCallout
|
<ReadFailureCallout
|
||||||
kind={readError.code}
|
kind={readError.code}
|
||||||
|
error={null}
|
||||||
onReload={onReload}
|
onReload={onReload}
|
||||||
hasMultipleHosts={hasMultipleHosts}
|
hasMultipleHosts={hasMultipleHosts}
|
||||||
/>
|
/>
|
||||||
@@ -277,79 +279,66 @@ function revisionToKey(revision: PaseoConfigRevision | null): string {
|
|||||||
|
|
||||||
interface ReadFailureCalloutProps {
|
interface ReadFailureCalloutProps {
|
||||||
kind: "transport" | ProjectConfigRpcError["code"];
|
kind: "transport" | ProjectConfigRpcError["code"];
|
||||||
|
error: unknown;
|
||||||
onReload: () => void;
|
onReload: () => void;
|
||||||
hasMultipleHosts: boolean;
|
hasMultipleHosts: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReadFailureCallout({ kind, onReload, hasMultipleHosts }: ReadFailureCalloutProps) {
|
function ReadFailureCallout({ kind, error, onReload, hasMultipleHosts }: ReadFailureCalloutProps) {
|
||||||
const reloadAction = useMemo<readonly CalloutAction[]>(
|
const { testID, title, description } = resolveReadFailureCopy({ kind, error, hasMultipleHosts });
|
||||||
() => [{ label: "Reload", onPress: onReload, variant: "primary" }],
|
|
||||||
[onReload],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (kind === "invalid_project_config") {
|
|
||||||
return (
|
|
||||||
<View style={styles.errorBlock}>
|
|
||||||
<CalloutCard
|
|
||||||
testID="invalid-callout"
|
|
||||||
variant="error"
|
|
||||||
title="paseo.json couldn't be parsed"
|
|
||||||
description="Fix the file on disk, then reload."
|
|
||||||
actions={reloadAction}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (kind === "project_not_found") {
|
|
||||||
return (
|
|
||||||
<View style={styles.errorBlock}>
|
|
||||||
<CalloutCard
|
|
||||||
testID="project-not-found-callout"
|
|
||||||
variant="error"
|
|
||||||
title="This host doesn't have this project"
|
|
||||||
description={
|
|
||||||
hasMultipleHosts
|
|
||||||
? "Switch to another host above, or reload."
|
|
||||||
: "The selected host has no record of this project."
|
|
||||||
}
|
|
||||||
actions={reloadAction}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (kind === "transport") {
|
|
||||||
return (
|
|
||||||
<View style={styles.errorBlock}>
|
|
||||||
<CalloutCard
|
|
||||||
testID="read-transport-callout"
|
|
||||||
variant="error"
|
|
||||||
title="Couldn't reach this host"
|
|
||||||
description={
|
|
||||||
hasMultipleHosts
|
|
||||||
? "The host didn't respond. Switch to another host above, or reload."
|
|
||||||
: "The host didn't respond. Try again."
|
|
||||||
}
|
|
||||||
actions={reloadAction}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.errorBlock}>
|
<View style={styles.errorBlock}>
|
||||||
<CalloutCard
|
<Alert testID={testID} variant="error" title={title} description={description}>
|
||||||
testID="read-failed-callout"
|
<Button testID={`${testID}-action-0`} onPress={onReload} variant="outline" size="sm">
|
||||||
variant="error"
|
Reload
|
||||||
title="Couldn't load paseo.json"
|
</Button>
|
||||||
description="Reload to try again."
|
</Alert>
|
||||||
actions={reloadAction}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveReadFailureCopy(input: {
|
||||||
|
kind: ReadFailureCalloutProps["kind"];
|
||||||
|
error: unknown;
|
||||||
|
hasMultipleHosts: boolean;
|
||||||
|
}): { testID: string; title: string; description: string } {
|
||||||
|
if (input.kind === "invalid_project_config") {
|
||||||
|
return {
|
||||||
|
testID: "invalid-callout",
|
||||||
|
title: "paseo.json couldn't be parsed",
|
||||||
|
description: "Fix the file on disk, then reload.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (input.kind === "project_not_found") {
|
||||||
|
return {
|
||||||
|
testID: "project-not-found-callout",
|
||||||
|
title: "This host doesn't have this project",
|
||||||
|
description: input.hasMultipleHosts
|
||||||
|
? "Switch to another host above, or reload."
|
||||||
|
: "The selected host has no record of this project.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (input.kind === "transport") {
|
||||||
|
const detail = errorToDetail(input.error);
|
||||||
|
return {
|
||||||
|
testID: "read-transport-callout",
|
||||||
|
title: "Couldn't load paseo.json",
|
||||||
|
description: detail ?? "The host didn't respond.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
testID: "read-failed-callout",
|
||||||
|
title: "Couldn't load paseo.json",
|
||||||
|
description: "Reload to try again.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorToDetail(error: unknown): string | null {
|
||||||
|
if (error instanceof Error && error.message.length > 0) return error.message;
|
||||||
|
if (typeof error === "string" && error.length > 0) return error;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
interface ProjectConfigFormProps {
|
interface ProjectConfigFormProps {
|
||||||
baseConfig: PaseoConfigRaw;
|
baseConfig: PaseoConfigRaw;
|
||||||
revision: PaseoConfigRevision | null;
|
revision: PaseoConfigRevision | null;
|
||||||
@@ -508,18 +497,6 @@ function ProjectConfigForm({
|
|||||||
[draft.scripts],
|
[draft.scripts],
|
||||||
);
|
);
|
||||||
|
|
||||||
const staleActions = useMemo<readonly CalloutAction[]>(
|
|
||||||
() => [{ label: "Reload", onPress: handleReload, variant: "primary" }],
|
|
||||||
[handleReload],
|
|
||||||
);
|
|
||||||
const writeFailedActions = useMemo<readonly CalloutAction[]>(
|
|
||||||
() => [
|
|
||||||
{ label: "Try again", onPress: handleSave, variant: "primary" },
|
|
||||||
{ label: "Reload", onPress: handleReload, variant: "secondary" },
|
|
||||||
],
|
|
||||||
[handleSave, handleReload],
|
|
||||||
);
|
|
||||||
|
|
||||||
const scriptsTrailing = useMemo(
|
const scriptsTrailing = useMemo(
|
||||||
() => (
|
() => (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -594,25 +571,49 @@ function ProjectConfigForm({
|
|||||||
|
|
||||||
{isStale ? (
|
{isStale ? (
|
||||||
<View style={styles.calloutWrap}>
|
<View style={styles.calloutWrap}>
|
||||||
<CalloutCard
|
<Alert
|
||||||
testID="stale-callout"
|
testID="stale-callout"
|
||||||
variant="error"
|
variant="error"
|
||||||
title="Config changed on disk"
|
title="Config changed on disk"
|
||||||
description="Reload to fetch the latest paseo.json before saving."
|
description="Reload to fetch the latest paseo.json before saving."
|
||||||
actions={staleActions}
|
>
|
||||||
/>
|
<Button
|
||||||
|
testID="stale-callout-action-0"
|
||||||
|
onPress={handleReload}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Reload
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isWriteFailed ? (
|
{isWriteFailed ? (
|
||||||
<View style={styles.calloutWrap}>
|
<View style={styles.calloutWrap}>
|
||||||
<CalloutCard
|
<Alert
|
||||||
testID="write-failed-callout"
|
testID="write-failed-callout"
|
||||||
variant="error"
|
variant="error"
|
||||||
title="Couldn't save paseo.json"
|
title="Couldn't save paseo.json"
|
||||||
description="Try again, or reload the latest version from disk."
|
description="Try again, or reload the latest version from disk."
|
||||||
actions={writeFailedActions}
|
>
|
||||||
/>
|
<Button
|
||||||
|
testID="write-failed-callout-action-0"
|
||||||
|
onPress={handleSave}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
testID="write-failed-callout-action-1"
|
||||||
|
onPress={handleReload}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Reload
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -1033,8 +1034,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
padding: theme.spacing[6],
|
padding: theme.spacing[6],
|
||||||
},
|
},
|
||||||
errorBlock: {
|
errorBlock: {
|
||||||
padding: theme.spacing[4],
|
marginTop: theme.spacing[2],
|
||||||
gap: theme.spacing[3],
|
|
||||||
},
|
},
|
||||||
lifecycleInput: {
|
lifecycleInput: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ const lightSemanticColors = {
|
|||||||
accentForeground: "#ffffff",
|
accentForeground: "#ffffff",
|
||||||
|
|
||||||
// Semantic
|
// Semantic
|
||||||
destructive: "#dc2626",
|
destructive: "#b04138", // dark warm red on white — calm but unambiguously red
|
||||||
destructiveForeground: "#ffffff",
|
destructiveForeground: "#ffffff",
|
||||||
success: "#20744A",
|
success: "#20744A",
|
||||||
successForeground: "#ffffff",
|
successForeground: "#ffffff",
|
||||||
@@ -233,6 +233,7 @@ interface DarkThemeConfig {
|
|||||||
borderAccent: string;
|
borderAccent: string;
|
||||||
accent: string;
|
accent: string;
|
||||||
accentBright: string;
|
accentBright: string;
|
||||||
|
destructive: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const darkTerminalAnsi = {
|
const darkTerminalAnsi = {
|
||||||
@@ -276,7 +277,7 @@ function buildDarkSemanticColors(tint: DarkThemeConfig) {
|
|||||||
accentBright: tint.accentBright,
|
accentBright: tint.accentBright,
|
||||||
accentForeground: "#ffffff",
|
accentForeground: "#ffffff",
|
||||||
|
|
||||||
destructive: "#ef4444",
|
destructive: tint.destructive,
|
||||||
destructiveForeground: "#ffffff",
|
destructiveForeground: "#ffffff",
|
||||||
success: tint.accent,
|
success: tint.accent,
|
||||||
successForeground: "#ffffff",
|
successForeground: "#ffffff",
|
||||||
@@ -332,6 +333,7 @@ const paseoDarkColors = buildDarkSemanticColors({
|
|||||||
borderAccent: "#2F3534",
|
borderAccent: "#2F3534",
|
||||||
accent: "#20744A",
|
accent: "#20744A",
|
||||||
accentBright: "#7ccba0",
|
accentBright: "#7ccba0",
|
||||||
|
destructive: "#c64f43", // warm red, hue ~7 — reads as red (not pink) against the green tint
|
||||||
});
|
});
|
||||||
|
|
||||||
// Zinc — neutral gray, no tint
|
// Zinc — neutral gray, no tint
|
||||||
@@ -350,6 +352,7 @@ const zincDarkColors = buildDarkSemanticColors({
|
|||||||
borderAccent: "#303036",
|
borderAccent: "#303036",
|
||||||
accent: "#20744A",
|
accent: "#20744A",
|
||||||
accentBright: "#7ccba0",
|
accentBright: "#7ccba0",
|
||||||
|
destructive: "#c44a4a", // neutral red, hue 0 — clearly red without screaming
|
||||||
});
|
});
|
||||||
|
|
||||||
// Midnight — subtle blue tint
|
// Midnight — subtle blue tint
|
||||||
@@ -368,6 +371,7 @@ const midnightDarkColors = buildDarkSemanticColors({
|
|||||||
borderAccent: "#2e3040",
|
borderAccent: "#2e3040",
|
||||||
accent: "#3b6fcf",
|
accent: "#3b6fcf",
|
||||||
accentBright: "#7eaaeb",
|
accentBright: "#7eaaeb",
|
||||||
|
destructive: "#c44a52", // red with a hint of cool lean against the blue tint
|
||||||
});
|
});
|
||||||
|
|
||||||
// Claude — warm neutral with subtle orange undertone
|
// Claude — warm neutral with subtle orange undertone
|
||||||
@@ -386,6 +390,7 @@ const claudeDarkColors = buildDarkSemanticColors({
|
|||||||
borderAccent: "#36332f",
|
borderAccent: "#36332f",
|
||||||
accent: "#d97757",
|
accent: "#d97757",
|
||||||
accentBright: "#e89a7f",
|
accentBright: "#e89a7f",
|
||||||
|
destructive: "#cf513e", // warm orange-red, hue ~10 — sits with the Claude orange accent
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ghostty — blue-tinted dark based on Ghostty default background
|
// Ghostty — blue-tinted dark based on Ghostty default background
|
||||||
@@ -404,6 +409,7 @@ const ghosttyDarkColors = buildDarkSemanticColors({
|
|||||||
borderAccent: "#3f4454",
|
borderAccent: "#3f4454",
|
||||||
accent: "#89b4fa",
|
accent: "#89b4fa",
|
||||||
accentBright: "#b4d0fc",
|
accentBright: "#b4d0fc",
|
||||||
|
destructive: "#c44a55", // red with slight cool lean against the slate-blue surfaces
|
||||||
});
|
});
|
||||||
|
|
||||||
const commonTheme = {
|
const commonTheme = {
|
||||||
|
|||||||
Reference in New Issue
Block a user