diff --git a/.changeset/fn-9202-banner-style-unification.md b/.changeset/fn-9202-banner-style-unification.md new file mode 100644 index 0000000000..dac6225203 --- /dev/null +++ b/.changeset/fn-9202-banner-style-unification.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Unify dashboard notices with consistent banner styling. +category: feature +dev: Adds the shared Banner component and removes left accent borders. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index d377415503..2b69fdb972 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -2147,6 +2147,10 @@ The dashboard's CSS is split into a global stylesheet (`packages/dashboard/app/s **Rule:** New CSS for a component goes in `app/components/ComponentName.css`, NOT `styles.css`. Only design tokens, primitives (`.btn`, `.card`, `.modal`, `.form-input`), and cross-component `@media` overrides belong in the global file. +### Banners + +Use the shared `Banner` component for dashboard notices. Its `tone` selects semantic info, warning, error, success, or neutral tinting; `layout` selects inline cards or sticky chrome; and `density` selects compact or regular spacing. Banners use a tinted surface and `var(--btn-border-width)` hairline border, never a left accent bar. Declaration values use design tokens: raw px is allowed only in `@media` conditions, zero values, and `var()` fallbacks. + ### Dialog anatomy: spacing and stacking Two rules that a new dialog gets wrong the same way every time. Both were paid for by the Set Up AI diff --git a/packages/dashboard/app/__tests__/banner-style-consistency.test.ts b/packages/dashboard/app/__tests__/banner-style-consistency.test.ts new file mode 100644 index 0000000000..f24f093fdc --- /dev/null +++ b/packages/dashboard/app/__tests__/banner-style-consistency.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { loadComponentCss } from "../test/cssFixture"; + +const bannerCss = [ + "Banner.css", "TestModeBanner.css", "MigrationInProgressBanner.css", "SqliteMigrationBanner.css", + "EngineUnavailableBanner.css", "EngineStatusBanner.css", "OAuthReloginBanner.css", "SessionNotificationBanner.css", + "CliBinaryInstallBanner.css", "UpdateAvailableBanner.css", "MergeAdvanceNotice.css", "TaskIdIntegrityBanner.css", + "DbCorruptionBanner.css", "SetupWarningBanner.css", "ApprovalNotificationBanner.css", "CapacityRiskBanner.css", +]; + +function stripVarCalls(line: string): string { return line.replace(/var\([^)]*\)/g, ""); } + +describe("dashboard banner style consistency", () => { + it("keeps accent borders and raw CSS lengths out of banner styles", () => { + for (const file of bannerCss) { + const css = loadComponentCss(file); + expect(css, file).not.toMatch(/border-(?:left|inline-start)\s*:/); + expect(css, file).not.toMatch(/#[0-9a-f]{3,8}\b|rgba\(/i); + + // px is permitted only in media conditions, zero values, and var() fallbacks. + const offending = css.split(/\r?\n/).filter((line) => !line.includes("@media") && /\d+(?:\.\d+)?px/.test(stripVarCalls(line))); + expect(offending, `${file} has raw px declaration values`).toEqual([]); + } + }); +}); diff --git a/packages/dashboard/app/components/ApprovalNotificationBanner.css b/packages/dashboard/app/components/ApprovalNotificationBanner.css index af04b287ad..e8c7dd75f6 100644 --- a/packages/dashboard/app/components/ApprovalNotificationBanner.css +++ b/packages/dashboard/app/components/ApprovalNotificationBanner.css @@ -1,9 +1,3 @@ -.approval-notification-banner { - padding: var(--space-sm) var(--space-lg); - border-bottom: var(--btn-border-width) solid var(--border); - background: color-mix(in srgb, var(--color-warning) 12%, transparent); -} - .approval-notification-banner__content { display: flex; align-items: center; @@ -38,10 +32,6 @@ } @media (max-width: 768px) { - .approval-notification-banner { - padding: var(--space-sm) var(--space-md); - } - .approval-notification-banner__content { flex-direction: column; align-items: flex-start; diff --git a/packages/dashboard/app/components/ApprovalNotificationBanner.tsx b/packages/dashboard/app/components/ApprovalNotificationBanner.tsx index ca5c646499..d06cc44202 100644 --- a/packages/dashboard/app/components/ApprovalNotificationBanner.tsx +++ b/packages/dashboard/app/components/ApprovalNotificationBanner.tsx @@ -1,5 +1,6 @@ import { AlertTriangle, Inbox, X } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { Banner } from "./Banner"; import "./ApprovalNotificationBanner.css"; interface ApprovalNotificationBannerProps { @@ -17,7 +18,7 @@ export function ApprovalNotificationBanner({ const noun = pendingCount === 1 ? t("approval.requestSingular", "request") : t("approval.requestPlural", "requests"); return ( -
+
-
+ ); } diff --git a/packages/dashboard/app/components/Banner.css b/packages/dashboard/app/components/Banner.css new file mode 100644 index 0000000000..d5abfb30c7 --- /dev/null +++ b/packages/dashboard/app/components/Banner.css @@ -0,0 +1,81 @@ +/* +FNXC:DashboardBanners 2026-08-23-22:36: +Dashboard banners share one shell so tone is expressed through a tint and a hairline full border using var(--btn-border-width), preserving thick-border themes. The operator explicitly removed left highlight borders, so this primitive must never add an accent bar. +*/ +.banner { + display: flex; + align-items: flex-start; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + border: var(--btn-border-width) solid color-mix(in srgb, var(--banner-tone) 30%, var(--border)); + background: color-mix(in srgb, var(--banner-tone) 10%, var(--surface)); + color: var(--text); +} + +.banner--info { --banner-tone: var(--color-info); } +.banner--warning { --banner-tone: var(--color-warning); } +.banner--error { --banner-tone: var(--color-error); } +.banner--success { --banner-tone: var(--color-success); } +.banner--neutral { --banner-tone: var(--text-muted); } + +.banner--inline { + margin-bottom: var(--space-md); + border-radius: var(--radius-md); +} + +.banner--chrome { + position: sticky; + top: 0; + z-index: var(--z-sticky); + border-inline: 0; + border-top: 0; + border-radius: 0; +} + +.banner--compact { + padding: var(--space-xs) var(--space-sm); +} + +.banner__icon { + display: flex; + flex: 0 0 auto; + color: var(--banner-tone); +} + +.banner__copy { + min-width: 0; + flex: 1; +} + +.banner__title { + color: var(--text); + font-weight: 600; +} + +.banner__body { color: var(--text-muted); } + +.banner__actions { + display: flex; + align-items: center; + gap: var(--space-sm); + flex: 0 0 auto; +} + +.banner__dismiss { + flex: 0 0 auto; + color: var(--text-muted); +} + +.banner__dismiss:hover { color: var(--text); } +.banner__dismiss:focus-visible { box-shadow: var(--focus-ring-strong); } + +@media (max-width: 768px) { + .banner { + flex-direction: column; + align-items: stretch; + } + + .banner__actions { + width: 100%; + } +} diff --git a/packages/dashboard/app/components/Banner.tsx b/packages/dashboard/app/components/Banner.tsx new file mode 100644 index 0000000000..db96c5b673 --- /dev/null +++ b/packages/dashboard/app/components/Banner.tsx @@ -0,0 +1,78 @@ +import { forwardRef, type ReactNode } from "react"; +import { X } from "lucide-react"; +import "./Banner.css"; + +/* +FNXC:DashboardBanners 2026-08-23-22:36: +Dashboard banners share one shell so tone is expressed through a tint and a hairline full border using var(--btn-border-width), preserving thick-border themes. The operator explicitly removed left highlight borders, so this primitive must never add an accent bar. +*/ +export type BannerTone = "info" | "warning" | "error" | "success" | "neutral"; +export type BannerLayout = "inline" | "chrome"; +export type BannerDensity = "compact" | "regular"; + +interface BannerProps { + tone: BannerTone; + layout?: BannerLayout; + density?: BannerDensity; + as?: "div" | "section"; + icon?: ReactNode; + title?: ReactNode; + children?: ReactNode; + actions?: ReactNode; + onDismiss?: () => void; + dismissLabel?: string; + className?: string; + role?: string; + "aria-live"?: "off" | "assertive" | "polite"; + "aria-label"?: string; + "data-testid"?: string; +} + +export const Banner = forwardRef(function Banner({ + tone, + layout = "inline", + density = "regular", + as: Component = "div", + icon, + title, + children, + actions, + onDismiss, + dismissLabel = "Dismiss", + className, + role, + "aria-live": ariaLive, + "aria-label": ariaLabel, + "data-testid": dataTestId, +}, ref) { + const classes = [ + "banner", + `banner--${tone}`, + `banner--${layout}`, + `banner--${density}`, + className, + ].filter(Boolean).join(" "); + + return ( + + {icon ? {icon} : null} +
+ {title ?
{title}
: null} + {children ?
{children}
: null} +
+ {actions ?
{actions}
: null} + {onDismiss ? ( + + ) : null} +
+ ); +}); diff --git a/packages/dashboard/app/components/CapacityRiskBanner.css b/packages/dashboard/app/components/CapacityRiskBanner.css index 961050121a..1386666c5f 100644 --- a/packages/dashboard/app/components/CapacityRiskBanner.css +++ b/packages/dashboard/app/components/CapacityRiskBanner.css @@ -1,49 +1 @@ -.capacity-risk-banner { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: var(--space-sm); - margin-bottom: var(--space-md); - padding: var(--space-sm) var(--space-md); - border-radius: var(--radius-md); - border-inline-start: var(--space-xs) solid var(--color-warning); - background: color-mix(in srgb, var(--color-warning) 8%, transparent); -} - -.capacity-risk-banner__content { - color: var(--text); - line-height: 1.4; -} - -.capacity-risk-banner__dismiss { - border: 0; - background: none; - color: var(--text-muted); - padding: var(--space-xs); - border-radius: var(--radius-sm); - line-height: 0; - flex-shrink: 0; - cursor: pointer; - transition: color var(--transition-fast), background var(--transition-fast); -} - -.capacity-risk-banner__dismiss:hover { - color: var(--text); - background: color-mix(in srgb, var(--color-warning) 12%, transparent); -} - -.capacity-risk-banner__dismiss:focus-visible { - outline: none; - box-shadow: var(--focus-ring-strong); -} - -@media (max-width: 768px) { - .capacity-risk-banner { - flex-direction: column; - align-items: stretch; - } - - .capacity-risk-banner__dismiss { - align-self: flex-end; - } -} +.capacity-risk-banner__content { line-height: 1.4; } diff --git a/packages/dashboard/app/components/CapacityRiskBanner.tsx b/packages/dashboard/app/components/CapacityRiskBanner.tsx index 08848126a6..4841404dd2 100644 --- a/packages/dashboard/app/components/CapacityRiskBanner.tsx +++ b/packages/dashboard/app/components/CapacityRiskBanner.tsx @@ -1,34 +1,27 @@ import type { CapacityRiskSignal } from "@fusion/core"; import { useTranslation } from "react-i18next"; -import { X } from "lucide-react"; +import { Banner } from "./Banner"; import "./CapacityRiskBanner.css"; -interface CapacityRiskBannerProps { - signal: CapacityRiskSignal | null; - onDismiss?: () => void; -} +interface CapacityRiskBannerProps { signal: CapacityRiskSignal | null; onDismiss?: () => void; } export function CapacityRiskBanner({ signal, onDismiss }: CapacityRiskBannerProps) { const { t } = useTranslation("app"); - if (!signal || !signal.atRisk) { - return null; - } + if (!signal || !signal.atRisk) return null; return ( -
-
- {t("capacity.risk", "Capacity risk:")} {t("capacity.status", "Todo {{todoCount}} (threshold {{threshold}}) · In Progress {{inProgress}} · In Review {{inReview}} · Idle agents {{idleAgents}}", { todoCount: signal.todoCount, threshold: signal.threshold, inProgress: signal.inProgressCount, inReview: signal.inReviewCount, idleAgents: signal.idleNonEphemeralAgentCount })} -
- {onDismiss ? ( - - ) : null} -
+ + + {t("capacity.risk", "Capacity risk:")}{" "} + {t("capacity.status", "Todo {{todoCount}} (threshold {{threshold}}) · In Progress {{inProgress}} · In Review {{inReview}} · Idle agents {{idleAgents}}", { todoCount: signal.todoCount, threshold: signal.threshold, inProgress: signal.inProgressCount, inReview: signal.inReviewCount, idleAgents: signal.idleNonEphemeralAgentCount })} + + ); } diff --git a/packages/dashboard/app/components/CliBinaryInstallBanner.css b/packages/dashboard/app/components/CliBinaryInstallBanner.css index 2aa9253cfc..e3f19c3075 100644 --- a/packages/dashboard/app/components/CliBinaryInstallBanner.css +++ b/packages/dashboard/app/components/CliBinaryInstallBanner.css @@ -1,113 +1,13 @@ -.cli-binary-banner { - display: flex; - align-items: flex-start; - gap: 12px; - margin: 12px 16px 0; - padding: 12px 16px; - background: linear-gradient( - 90deg, - color-mix(in srgb, var(--todo) 12%, transparent), - color-mix(in srgb, var(--todo) 4%, transparent) - ); - border: 1px solid color-mix(in srgb, var(--todo) 35%, transparent); - border-radius: 8px; -} - -.cli-binary-banner__body { - flex: 1; - display: flex; - flex-direction: column; - gap: 6px; - min-width: 0; -} - -.cli-binary-banner__title { - font-weight: 600; - font-size: 14px; - color: var(--text); -} - -.cli-binary-banner__text { - font-size: 13px; - line-height: 1.5; - color: var(--text-muted); -} - -.cli-binary-banner__text code { - background: color-mix(in srgb, var(--text) 6%, transparent); - padding: 1px 6px; - border-radius: 4px; - font-size: 0.92em; -} - -.cli-binary-banner__actions { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-top: 4px; -} - -.cli-binary-banner__primary, -.cli-binary-banner__secondary { - cursor: pointer; - border-radius: 6px; - padding: 6px 12px; - font-size: 13px; - border: 1px solid transparent; - transition: background 0.15s ease; -} - -.cli-binary-banner__primary { - background: var(--accent, #3b82f6); - color: var(--accent-text); -} - -.cli-binary-banner__primary:hover:not(:disabled) { - background: color-mix(in srgb, var(--accent) 88%, var(--text) 12%); -} - -.cli-binary-banner__primary:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -.cli-binary-banner__secondary { - background: color-mix(in srgb, var(--text) 4%, transparent); - color: var(--text); - border-color: color-mix(in srgb, var(--text) 12%, transparent); -} - -.cli-binary-banner__secondary:hover { - background: color-mix(in srgb, var(--text) 8%, transparent); -} - -.cli-binary-banner__error { - margin-top: 4px; - padding: 6px 10px; - background: color-mix(in srgb, var(--color-error) 10%, transparent); - border-left: 3px solid color-mix(in srgb, var(--color-error) 55%, transparent); - border-radius: 0 var(--radius-sm) var(--radius-sm) 0; - color: var(--color-error); - font-size: 12.5px; - line-height: 1.5; -} - -.cli-binary-banner__dismiss { - display: inline-flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - padding: 0; - background: transparent; - color: var(--text-muted); - border: none; - border-radius: 4px; - cursor: pointer; - flex-shrink: 0; -} - -.cli-binary-banner__dismiss:hover { - background: color-mix(in srgb, var(--text) 6%, transparent); - color: var(--text); -} +.cli-binary-banner { margin: var(--space-md) var(--space-lg) 0; } +.cli-binary-banner__body { display: flex; flex: 1; flex-direction: column; gap: var(--space-sm); min-width: 0; } +.cli-binary-banner__title { font-weight: 600; font-size: var(--font-size-sm); color: var(--text); } +.cli-binary-banner__text { font-size: var(--font-size-xs); line-height: 1.5; color: var(--text-muted); } +.cli-binary-banner__text code { background: color-mix(in srgb, var(--text) 6%, transparent); padding: 0 var(--space-sm); border-radius: var(--radius-sm); font-size: 0.92em; } +.cli-binary-banner__actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); margin-top: var(--space-xs); } +.cli-binary-banner__primary, .cli-binary-banner__secondary { cursor: pointer; border-radius: var(--radius-md); padding: var(--space-sm) var(--space-md); font-size: var(--font-size-xs); border: var(--btn-border-width) solid transparent; transition: background var(--transition-fast); } +.cli-binary-banner__primary { background: var(--accent); color: var(--accent-text); } +.cli-binary-banner__primary:hover:not(:disabled) { background: color-mix(in srgb, var(--accent) 88%, var(--text) 12%); } +.cli-binary-banner__primary:disabled { opacity: 0.6; cursor: not-allowed; } +.cli-binary-banner__secondary { background: color-mix(in srgb, var(--text) 4%, transparent); color: var(--text); border-color: color-mix(in srgb, var(--text) 12%, transparent); } +.cli-binary-banner__secondary:hover { background: color-mix(in srgb, var(--text) 8%, transparent); } +.cli-binary-banner__error { margin-top: var(--space-xs); padding: var(--space-sm); background: color-mix(in srgb, var(--color-error) 10%, transparent); border: var(--btn-border-width) solid color-mix(in srgb, var(--color-error) 55%, var(--border)); border-radius: var(--radius-sm); color: var(--color-error); font-size: var(--font-size-xs); line-height: 1.5; } diff --git a/packages/dashboard/app/components/CliBinaryInstallBanner.tsx b/packages/dashboard/app/components/CliBinaryInstallBanner.tsx index 33d98be33c..0763410955 100644 --- a/packages/dashboard/app/components/CliBinaryInstallBanner.tsx +++ b/packages/dashboard/app/components/CliBinaryInstallBanner.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { X } from "lucide-react"; +import { Banner } from "./Banner"; import { fetchFnBinaryStatus, installFnBinary, @@ -175,7 +175,7 @@ export function CliBinaryInstallBanner({ onOpenSettings }: Props) { const busyLabel = isMismatch ? t("cli.updating", "Updating…") : t("cli.installing", "Installing…"); return ( -
+
{title}
{body}
@@ -200,14 +200,6 @@ export function CliBinaryInstallBanner({ onOpenSettings }: Props) {
{installError}
)}
- -
+ ); } diff --git a/packages/dashboard/app/components/DbCorruptionBanner.css b/packages/dashboard/app/components/DbCorruptionBanner.css index bbdf321de8..4857407752 100644 --- a/packages/dashboard/app/components/DbCorruptionBanner.css +++ b/packages/dashboard/app/components/DbCorruptionBanner.css @@ -1,16 +1,6 @@ /* === DbCorruptionBanner === */ -.db-corruption-banner { - display: flex; - flex-direction: column; - gap: var(--space-md); - padding: var(--space-lg); - margin-bottom: var(--space-md); - border-radius: var(--radius-md); - border: var(--btn-border-width) solid color-mix(in srgb, var(--color-error) 24%, transparent); - border-inline-start: var(--space-xs) solid var(--color-error); - background: color-mix(in srgb, var(--color-error) 10%, transparent); - box-shadow: var(--shadow-md); -} +.db-corruption-banner { box-shadow: var(--shadow-md); } +.db-corruption-banner > .banner__copy { display: flex; flex-direction: column; gap: var(--space-md); } .db-corruption-banner__header { display: flex; @@ -85,10 +75,6 @@ } @media (max-width: 768px) { - .db-corruption-banner { - padding: var(--space-md); - } - .db-corruption-banner__header { flex-direction: column; align-items: stretch; diff --git a/packages/dashboard/app/components/DbCorruptionBanner.tsx b/packages/dashboard/app/components/DbCorruptionBanner.tsx index 6ec62d38d1..5fcd822a4f 100644 --- a/packages/dashboard/app/components/DbCorruptionBanner.tsx +++ b/packages/dashboard/app/components/DbCorruptionBanner.tsx @@ -1,5 +1,6 @@ import { AlertTriangle, RefreshCw } from "lucide-react"; import { Trans, useTranslation } from "react-i18next"; +import { Banner } from "./Banner"; import "./DbCorruptionBanner.css"; @@ -33,7 +34,7 @@ export function DbCorruptionBanner({ const checkedAtLabel = lastCheckedAt ? new Date(lastCheckedAt).toLocaleString() : null; return ( -
+
+ ); } diff --git a/packages/dashboard/app/components/EngineStatusBanner.css b/packages/dashboard/app/components/EngineStatusBanner.css index 6d5a435fdd..9c705b1da7 100644 --- a/packages/dashboard/app/components/EngineStatusBanner.css +++ b/packages/dashboard/app/components/EngineStatusBanner.css @@ -1,87 +1,7 @@ -.engine-status-banner { - display: flex; - align-items: flex-start; - gap: var(--space-md); - margin: var(--space-md) var(--space-lg) 0; - padding: var(--space-md) var(--space-lg); - background: linear-gradient( - 90deg, - color-mix(in srgb, var(--color-warning) 12%, transparent), - color-mix(in srgb, var(--surface) 92%, transparent) - ); - border: var(--btn-border-width) solid color-mix(in srgb, var(--color-warning) 35%, transparent); - border-radius: var(--radius-md); -} - -.engine-status-banner__indicator { - display: inline-flex; - align-items: center; - padding-top: calc(var(--space-xs) / 2); - flex-shrink: 0; -} - -.engine-status-banner__content { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: var(--space-xs); -} - -.engine-status-banner__title { - font-weight: 600; - color: var(--text); -} - -.engine-status-banner__body, -.engine-status-banner__error { - margin: 0; - color: var(--text-muted); - line-height: 1.5; -} - -.engine-status-banner__body code { - background: color-mix(in srgb, var(--text) 6%, transparent); - padding: 0 var(--space-xs); - border-radius: var(--radius-sm); - color: var(--text); -} - -.engine-status-banner__error { - color: var(--color-error); -} - -.engine-status-banner__actions { - display: flex; - align-items: center; - justify-content: flex-end; - flex-shrink: 0; -} - -.engine-status-banner__start { - display: inline-flex; - align-items: center; - gap: var(--space-xs); - white-space: nowrap; -} - -@media (max-width: 768px) { - .engine-status-banner { - flex-direction: column; - gap: var(--space-sm); - margin-inline: var(--space-md); - } - - .engine-status-banner__actions, - .engine-status-banner__start { - width: 100%; - } - - .engine-status-banner__actions { - justify-content: stretch; - } - - .engine-status-banner__start { - justify-content: center; - } -} +.engine-status-banner { margin: var(--space-md) var(--space-lg) 0; } +.engine-status-banner__indicator { display: inline-flex; align-items: center; padding-top: calc(var(--space-xs) / 2); } +.engine-status-banner__body, .engine-status-banner__error { margin: 0; line-height: 1.5; } +.engine-status-banner__body code { background: color-mix(in srgb, var(--text) 6%, transparent); padding: 0 var(--space-xs); border-radius: var(--radius-sm); color: var(--text); } +.engine-status-banner__error { color: var(--color-error); } +.engine-status-banner__start { display: inline-flex; align-items: center; gap: var(--space-xs); white-space: nowrap; } +@media (max-width: 768px) { .engine-status-banner { margin-inline: var(--space-md); } .engine-status-banner__start { width: 100%; justify-content: center; } } diff --git a/packages/dashboard/app/components/EngineStatusBanner.tsx b/packages/dashboard/app/components/EngineStatusBanner.tsx index 77e3d1a0a2..2ad20872fb 100644 --- a/packages/dashboard/app/components/EngineStatusBanner.tsx +++ b/packages/dashboard/app/components/EngineStatusBanner.tsx @@ -1,6 +1,7 @@ import { useTranslation } from "react-i18next"; import { Loader2 } from "lucide-react"; import { useEngineStatus } from "../hooks/useEngineStatus"; +import { Banner } from "./Banner"; import "./EngineStatusBanner.css"; interface EngineStatusBannerProps { @@ -24,39 +25,28 @@ export function EngineStatusBanner({ projectId }: EngineStatusBannerProps) { : t("engineBanner.dashboardOnly", "This dashboard cannot start engines from the current process. Run `fn serve` for this project to enable task execution and live automation."); return ( -
- -
-
{t("engineBanner.title", "Project engine is not connected")}
-

+

} + title={
{t("engineBanner.title", "Project engine is not connected")}
} + role="status" + aria-live="polite" + data-testid="engine-status-banner" + actions={canStart ? : null} + > +

{isDashboardOnly ? ( <> {t("engineBanner.dashboardOnlyPrefix", "This dashboard cannot start engines from the current process. Run")} fn serve {t("engineBanner.dashboardOnlySuffix", "for this project to enable task execution and live automation.")} ) : body} -

- {error && ( -

- {t("engineBanner.error", "Start failed: {{message}}", { message: error })} -

- )} - -
- {canStart ? ( - - ) : null} -
-
+

+ {error &&

{t("engineBanner.error", "Start failed: {{message}}", { message: error })}

} + ); } diff --git a/packages/dashboard/app/components/EngineUnavailableBanner.css b/packages/dashboard/app/components/EngineUnavailableBanner.css index 4e9d7d36ce..4c948d2ad6 100644 --- a/packages/dashboard/app/components/EngineUnavailableBanner.css +++ b/packages/dashboard/app/components/EngineUnavailableBanner.css @@ -1,54 +1,18 @@ -.engine-unavailable-banner { - display: flex; - align-items: flex-start; - gap: var(--space-sm); - margin-bottom: var(--space-md); - padding: var(--space-sm) var(--space-md); - border-radius: var(--radius-md); - border-inline-start: var(--space-xs) solid var(--color-warning); - background: color-mix(in srgb, var(--color-warning) 10%, transparent); - color: var(--text); -} - .engine-unavailable-banner__icon { width: 1.1rem; height: 1.1rem; margin-top: 0.15rem; - color: var(--color-warning); - flex: 0 0 auto; -} - -.engine-unavailable-banner__copy { - display: flex; - flex-direction: column; - gap: var(--space-xs); - min-width: 0; } .engine-unavailable-banner__title { margin: 0; font-size: var(--font-size-base); line-height: var(--line-height-tight); - color: var(--text); } -.engine-unavailable-banner__body { - margin: 0; - color: var(--text-muted); -} - -.engine-unavailable-banner code { - color: var(--text); - white-space: nowrap; -} +.engine-unavailable-banner__body { margin: 0; } +.engine-unavailable-banner code { color: var(--text); white-space: nowrap; } @media (max-width: 768px) { - .engine-unavailable-banner { - padding: var(--space-sm); - } - - .engine-unavailable-banner code { - white-space: normal; - overflow-wrap: anywhere; - } + .engine-unavailable-banner code { white-space: normal; overflow-wrap: anywhere; } } diff --git a/packages/dashboard/app/components/EngineUnavailableBanner.tsx b/packages/dashboard/app/components/EngineUnavailableBanner.tsx index 7f69b71f9d..b503f12f3e 100644 --- a/packages/dashboard/app/components/EngineUnavailableBanner.tsx +++ b/packages/dashboard/app/components/EngineUnavailableBanner.tsx @@ -1,5 +1,6 @@ import { AlertTriangle } from "lucide-react"; import { Trans, useTranslation } from "react-i18next"; +import { Banner } from "./Banner"; import "./EngineUnavailableBanner.css"; @@ -18,11 +19,16 @@ export function EngineUnavailableBanner({ isVisible }: EngineUnavailableBannerPr * When the dashboard is served without an in-process AI engine, users need an explicit operational banner with the exact restart command because task execution, review, and merge automation cannot run from a UI-only process. */ return ( -
-
+

+ ); } diff --git a/packages/dashboard/app/components/MergeAdvanceNotice.css b/packages/dashboard/app/components/MergeAdvanceNotice.css index 1709f35424..53ad619b09 100644 --- a/packages/dashboard/app/components/MergeAdvanceNotice.css +++ b/packages/dashboard/app/components/MergeAdvanceNotice.css @@ -1,114 +1,15 @@ -.merge-advance-notice { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: var(--space-sm); - margin-bottom: var(--space-md); - padding: var(--space-sm) var(--space-md); - border-radius: var(--radius-md); - border-inline-start: var(--space-xs) solid var(--color-warning); - background: color-mix(in srgb, var(--color-warning) 12%, transparent); - box-shadow: var(--shadow-sm); -} - -.merge-advance-notice__content { - color: var(--text); - line-height: 1.4; -} - -.merge-advance-notice__actions { - display: flex; - align-items: center; - gap: var(--space-xs); - flex-shrink: 0; -} - -.merge-advance-notice__dismiss { - border: 0; - background: none; - color: var(--text-muted); - padding: var(--space-xs); - border-radius: var(--radius-sm); - line-height: 0; - cursor: pointer; - transition: color var(--transition-fast), background var(--transition-fast); -} - -.merge-advance-notice__dismiss:hover { - color: var(--text); - background: color-mix(in srgb, var(--color-warning) 18%, transparent); -} - -.merge-advance-notice__dismiss:focus-visible { - outline: none; - box-shadow: var(--focus-ring-strong); -} - -.merge-advance-notice__error { - color: var(--color-error); -} - -.merge-advance-notice__hint { - color: var(--text-muted); -} - -.merge-advance-notice__push { - display: grid; - gap: var(--space-xs); - margin-top: var(--space-sm); - padding-top: var(--space-sm); - border-top: 1px solid color-mix(in srgb, var(--color-warning) 25%, transparent); -} - -.merge-advance-notice__push-heading { - margin: 0; - color: var(--text); -} - -.merge-advance-notice__push-actions { - display: flex; - align-items: center; - gap: var(--space-xs); -} - -.merge-advance-notice__push-advanced summary { - cursor: pointer; - color: var(--text-muted); -} - -.merge-advance-notice__push-advanced label { - display: inline-flex; - align-items: center; - gap: var(--space-xs); - margin-top: var(--space-xs); -} - -.merge-advance-notice__push-error { - color: var(--color-error); -} - -.merge-advance-notice__push-error pre { - margin: var(--space-xs) 0; - padding: var(--space-xs); - border-radius: var(--radius-sm); - background: color-mix(in srgb, var(--color-error) 12%, transparent); - color: var(--color-error); - font-family: var(--font-mono); - white-space: pre-wrap; -} - -@media (max-width: 768px) { - .merge-advance-notice { - flex-direction: column; - align-items: stretch; - } - - .merge-advance-notice__actions { - justify-content: flex-start; - } - - .merge-advance-notice__push-actions { - flex-direction: column; - align-items: flex-start; - } -} +.merge-advance-notice { box-shadow: var(--shadow-sm); } +.merge-advance-notice__content { line-height: 1.4; } +.merge-advance-notice__actions { flex-shrink: 0; } +.merge-advance-notice__dismiss { border: 0; background: none; padding: var(--space-xs); border-radius: var(--radius-sm); line-height: 0; cursor: pointer; transition: color var(--transition-fast), background var(--transition-fast); } +.merge-advance-notice__dismiss:hover { background: color-mix(in srgb, var(--color-warning) 18%, transparent); } +.merge-advance-notice__dismiss:focus-visible { outline: none; box-shadow: var(--focus-ring-strong); } +.merge-advance-notice__error { color: var(--color-error); } +.merge-advance-notice__hint { color: var(--text-muted); } +.merge-advance-notice__push { display: grid; gap: var(--space-xs); margin-top: var(--space-sm); padding-top: var(--space-sm); border-top: var(--btn-border-width) solid color-mix(in srgb, var(--color-warning) 25%, transparent); } +.merge-advance-notice__push-heading { margin: 0; font-weight: 600; } +.merge-advance-notice__push-actions { display: flex; align-items: center; gap: var(--space-sm); flex-wrap: wrap; } +.merge-advance-notice__push-error { color: var(--color-error); } +.merge-advance-notice__push-error pre { margin: var(--space-xs) 0; white-space: pre-wrap; } +.merge-advance-notice__push-advanced { color: var(--text-muted); } +@media (max-width: 768px) { .merge-advance-notice__actions { width: 100%; justify-content: flex-end; } } diff --git a/packages/dashboard/app/components/MergeAdvanceNotice.tsx b/packages/dashboard/app/components/MergeAdvanceNotice.tsx index 0e26b89f17..023c937835 100644 --- a/packages/dashboard/app/components/MergeAdvanceNotice.tsx +++ b/packages/dashboard/app/components/MergeAdvanceNotice.tsx @@ -1,6 +1,7 @@ import { X } from "lucide-react"; import { useRef } from "react"; import { useTranslation } from "react-i18next"; +import { Banner } from "./Banner"; import StashConflictModal from "./StashConflictModal"; import { useMergeAdvanceNotice } from "../hooks/useMergeAdvanceNotice"; import "./MergeAdvanceNotice.css"; @@ -118,7 +119,23 @@ export default function MergeAdvanceNotice({ projectId, apiBase = "/api" }: Merg return ( <> -
+ + {conflictState ? null : ( + + )} + +
} + >
{t("merge.advancedTo", "{{branch}} advanced to {{sha}}.", { branch: notice.integrationBranch, sha: shortSha(notice.toSha) })}{" "} {t("merge.checkedOutBehind", "Your checked-out copy at {{path}} is behind.", { path: checkout.worktreePath })} @@ -127,22 +144,7 @@ export default function MergeAdvanceNotice({ projectId, apiBase = "/api" }: Merg {pulling ? {t("merge.pulling", "Pulling…")} : null} {renderPushSection()}
-
- {conflictState ? null : ( - - )} - -
- + { diff --git a/packages/dashboard/app/components/MigrationInProgressBanner.css b/packages/dashboard/app/components/MigrationInProgressBanner.css index 63e459c159..993188d499 100644 --- a/packages/dashboard/app/components/MigrationInProgressBanner.css +++ b/packages/dashboard/app/components/MigrationInProgressBanner.css @@ -1,21 +1,3 @@ -/* -FNXC:MigrationHoldingPage 2026-07-17-12:40: -Mirrors the TestModeBanner shape (left accent bar + tinted background) but uses -the info color: a running migration is an informational transient state, not a -warning. The monospace progress line carries the migrator's structured label. -*/ -.migration-in-progress-banner { - display: flex; - align-items: center; - gap: var(--space-sm); - margin-bottom: var(--space-md); - padding: var(--space-sm) var(--space-md); - border-radius: var(--radius-md); - border-inline-start: var(--space-xs) solid var(--color-info); - background: color-mix(in srgb, var(--color-info) 18%, transparent); - color: var(--text); -} - .migration-in-progress-banner-progress { display: block; font-family: var(--font-mono); @@ -23,10 +5,3 @@ warning. The monospace progress line carries the migrator's structured label. color: var(--text-secondary); overflow-wrap: anywhere; } - -@media (max-width: 768px), (max-height: 480px) { - .migration-in-progress-banner { - padding: var(--space-sm); - align-items: flex-start; - } -} diff --git a/packages/dashboard/app/components/MigrationInProgressBanner.tsx b/packages/dashboard/app/components/MigrationInProgressBanner.tsx index 6bb8b384fa..b51be6ce6b 100644 --- a/packages/dashboard/app/components/MigrationInProgressBanner.tsx +++ b/packages/dashboard/app/components/MigrationInProgressBanner.tsx @@ -11,28 +11,21 @@ server. Fresh navigations during migration get the holding page instead. */ import { useTranslation } from "react-i18next"; import { DatabaseZap } from "lucide-react"; +import { Banner } from "./Banner"; import "./MigrationInProgressBanner.css"; -interface MigrationInProgressBannerProps { - isActive: boolean; - progressLabel?: string; -} +interface MigrationInProgressBannerProps { isActive: boolean; progressLabel?: string; } export function MigrationInProgressBanner({ isActive, progressLabel }: MigrationInProgressBannerProps) { const { t } = useTranslation("app"); - if (!isActive) { - return null; - } + if (!isActive) return null; return ( -
-
+ ); } diff --git a/packages/dashboard/app/components/OAuthReloginBanner.css b/packages/dashboard/app/components/OAuthReloginBanner.css index c15a053582..b531dc61e4 100644 --- a/packages/dashboard/app/components/OAuthReloginBanner.css +++ b/packages/dashboard/app/components/OAuthReloginBanner.css @@ -1,50 +1,3 @@ -.oauth-relogin-banner { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-sm); - margin-bottom: var(--space-md); - padding: var(--space-sm) var(--space-md); - border-radius: var(--radius-md); - border-inline-start: var(--space-xs) solid var(--color-warning); - background: color-mix(in srgb, var(--color-warning) 18%, transparent); - color: var(--text); -} - -.oauth-relogin-banner__content { - display: flex; - align-items: center; - gap: var(--space-sm); - min-width: 0; -} - -.oauth-relogin-banner__message { - margin: 0; - line-height: 1.4; -} - -.oauth-relogin-banner__actions { - display: flex; - align-items: center; - gap: var(--space-xs); - flex-shrink: 0; -} - -.oauth-relogin-banner__dismiss { - color: var(--text-muted); -} - -.oauth-relogin-banner__dismiss:hover { - color: var(--text); -} - -@media (max-width: 768px) { - .oauth-relogin-banner { - flex-direction: column; - align-items: stretch; - } - - .oauth-relogin-banner__actions { - justify-content: flex-end; - } -} +.oauth-relogin-banner__content { display: flex; align-items: center; gap: var(--space-sm); min-width: 0; } +.oauth-relogin-banner__message { margin: 0; line-height: 1.4; } +.oauth-relogin-banner__actions { flex-shrink: 0; } diff --git a/packages/dashboard/app/components/OAuthReloginBanner.tsx b/packages/dashboard/app/components/OAuthReloginBanner.tsx index 26358df712..0d534a8ca4 100644 --- a/packages/dashboard/app/components/OAuthReloginBanner.tsx +++ b/packages/dashboard/app/components/OAuthReloginBanner.tsx @@ -1,8 +1,9 @@ import { useCallback, useEffect, useMemo, useState, type JSX } from "react"; import { useTranslation } from "react-i18next"; -import { AlertTriangle, X } from "lucide-react"; +import { AlertTriangle } from "lucide-react"; import { fetchAuthStatus, type AuthProvider } from "../api"; import { OAUTH_RELOGIN_SUCCESS_EVENT } from "../auth"; +import { Banner } from "./Banner"; import "./OAuthReloginBanner.css"; const DISMISS_STORAGE_KEY = "fusion:oauth-relogin-dismissed"; @@ -143,7 +144,9 @@ export function OAuthReloginBanner({ }; return ( -
+ + + } onDismiss={handleDismiss} dismissLabel={t("actions.dismissOAuth", "Dismiss OAuth re-login banner")}>
-
- - -
-
+ ); } diff --git a/packages/dashboard/app/components/SessionNotificationBanner.css b/packages/dashboard/app/components/SessionNotificationBanner.css index 3ec44ad495..a7a2577b9a 100644 --- a/packages/dashboard/app/components/SessionNotificationBanner.css +++ b/packages/dashboard/app/components/SessionNotificationBanner.css @@ -9,16 +9,6 @@ FN-7020 requires the needs-input banner to stay visible when relevant without co --session-notification-list-max-height: min(38vh, calc(var(--space-2xl) * 7)); --session-notification-touch-size: calc(var(--space-xl) + var(--space-xs)); - position: sticky; - top: 0; - z-index: 30; - display: flex; - flex-direction: column; - gap: var(--space-xs); - padding: var(--space-xs) var(--space-md); - border-bottom: var(--btn-border-width) solid var(--border); - border-left: var(--space-xs) solid var(--triage); - background: var(--surface); box-sizing: border-box; max-height: var(--session-notification-banner-max-height); opacity: 1; @@ -26,6 +16,12 @@ FN-7020 requires the needs-input banner to stay visible when relevant without co animation: session-notification-banner-enter var(--transition-fast); } +.session-notification-banner > .banner__copy { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + @keyframes session-notification-banner-enter { from { opacity: 0; diff --git a/packages/dashboard/app/components/SessionNotificationBanner.tsx b/packages/dashboard/app/components/SessionNotificationBanner.tsx index fa430aa4db..d38e06db54 100644 --- a/packages/dashboard/app/components/SessionNotificationBanner.tsx +++ b/packages/dashboard/app/components/SessionNotificationBanner.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { AlertCircle, Lightbulb, Layers, LoaderCircle, Target, Terminal, X } from "lucide-react"; import type { AiSessionSummary, CliNeedsAttentionVariant } from "../api"; +import { Banner } from "./Banner"; export type CliActionId = "advance" | "retry" | "cancel" | "reauthenticate" | "relaunch"; @@ -273,7 +274,7 @@ export function SessionNotificationBanner({ }; return ( -
+
-
+ ); } diff --git a/packages/dashboard/app/components/SetupWarningBanner.css b/packages/dashboard/app/components/SetupWarningBanner.css index de05f46f17..598526bd94 100644 --- a/packages/dashboard/app/components/SetupWarningBanner.css +++ b/packages/dashboard/app/components/SetupWarningBanner.css @@ -1,100 +1,6 @@ -/* === SetupWarningBanner === */ -.setup-warning-banner { - display: flex; - flex-direction: column; - gap: var(--space-sm); - padding: var(--space-sm) var(--space-md); - margin-bottom: var(--space-md); - border-radius: var(--radius-md); - border-inline-start: var(--space-xs) solid var(--color-warning); - background: color-mix(in srgb, var(--color-warning) 8%, transparent); -} - -.setup-warning-banner__item { - display: flex; - flex-direction: column; - gap: var(--space-xs); -} - -.setup-warning-banner__title { - color: var(--text); -} - -.setup-warning-banner__description { - margin: 0; - color: var(--text-muted); -} - -.setup-warning-banner__actions { - display: flex; - flex-wrap: wrap; - gap: var(--space-sm); -} - -.setup-warning-banner--compact { - flex-direction: row; - align-items: flex-start; - justify-content: space-between; - gap: var(--space-sm); -} - -.setup-warning-banner--dismissible:not(.setup-warning-banner--compact) { - position: relative; - padding-inline-end: calc(var(--space-2xl) + var(--space-lg) + var(--space-xs)); -} - -.setup-warning-banner--dismissible:not(.setup-warning-banner--compact) .setup-warning-banner__dismiss { - position: absolute; - top: var(--space-sm); - inset-inline-end: var(--space-sm); -} - -.setup-warning-banner__compact-text { - margin: 0; - color: var(--text); - flex: 1; -} - -.setup-warning-banner__dismiss { - align-self: flex-start; - background: none; - border: none; - color: var(--text-muted); - cursor: pointer; - padding: var(--space-xs); - border-radius: var(--radius-sm); - transition: color var(--transition-fast), background var(--transition-fast); - line-height: 0; - flex-shrink: 0; -} - -.setup-warning-banner__dismiss:hover { - color: var(--text); - background: color-mix(in srgb, var(--color-warning) 12%, transparent); -} - -.setup-warning-banner__dismiss:focus-visible { - outline: none; - box-shadow: var(--focus-ring-strong); -} - -@media (max-width: 768px) { - .setup-warning-banner--compact { - padding: var(--space-xs) var(--space-sm); - } - - .setup-warning-banner__actions .btn { - width: 100%; - } -} - -@media (max-width: 480px) { - .setup-warning-banner--dismissible:not(.setup-warning-banner--compact) { - padding-inline-end: var(--space-md); - } - - .setup-warning-banner--dismissible:not(.setup-warning-banner--compact) .setup-warning-banner__dismiss { - position: static; - align-self: flex-end; - } -} +.setup-warning-banner__item { display: flex; flex-direction: column; gap: var(--space-xs); } +.setup-warning-banner__description { margin: 0; } +.setup-warning-banner__actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); } +.setup-warning-banner--compact { flex-direction: row; align-items: flex-start; justify-content: space-between; } +.setup-warning-banner__compact-text { margin: 0; flex: 1; } +@media (max-width: 768px) { .setup-warning-banner__actions .btn { width: 100%; } } diff --git a/packages/dashboard/app/components/SetupWarningBanner.tsx b/packages/dashboard/app/components/SetupWarningBanner.tsx index d34a94b948..e23df52c08 100644 --- a/packages/dashboard/app/components/SetupWarningBanner.tsx +++ b/packages/dashboard/app/components/SetupWarningBanner.tsx @@ -1,6 +1,6 @@ import "./SetupWarningBanner.css"; -import { X } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { Banner } from "./Banner"; interface SetupWarningBannerProps { /** Whether an AI provider is connected */ @@ -37,29 +37,13 @@ export function SetupWarningBanner({ return null; } - const dismissButton = onDismiss ? ( - - ) : null; - if (compact) { return ( -
+

{t("setup.compactWarning", "⚠ Setup incomplete — AI and/or GitHub features will be limited.")}

- {dismissButton} -
+ ); } @@ -82,12 +66,7 @@ export function SetupWarningBanner({ } return ( -
- {dismissButton} + {warningItems.map((warning) => (
{warning.title} @@ -101,6 +80,6 @@ export function SetupWarningBanner({ ) : null}
))} -
+ ); } diff --git a/packages/dashboard/app/components/SqliteMigrationBanner.css b/packages/dashboard/app/components/SqliteMigrationBanner.css index 2365c3b1e3..3a79b6a3f4 100644 --- a/packages/dashboard/app/components/SqliteMigrationBanner.css +++ b/packages/dashboard/app/components/SqliteMigrationBanner.css @@ -1,44 +1,10 @@ -/* -FNXC:PostgresMigrationBanner 2026-07-12: -Mirrors the TestModeBanner shape (left accent bar + tinted background) but in -the success/info accent since a completed migration is good news, not a -warning. Actions collapse under the text on mobile. -*/ -.sqlite-migration-banner { - display: flex; - align-items: center; - gap: var(--space-sm); - margin-bottom: var(--space-md); - padding: var(--space-sm) var(--space-md); - border-radius: var(--radius-md); - border-inline-start: var(--space-xs) solid var(--color-primary); - background: color-mix(in srgb, var(--color-primary) 12%, transparent); - color: var(--text); -} - .sqlite-migration-banner-body { display: flex; flex-direction: column; - gap: 2px; - flex: 1; + gap: var(--space-3xs); min-width: 0; } -.sqlite-migration-banner-body code { - word-break: break-all; -} +.sqlite-migration-banner-body code { word-break: break-all; } -.sqlite-migration-banner-actions { - display: flex; - align-items: center; - gap: var(--space-sm); - flex-shrink: 0; -} - -@media (max-width: 768px) { - .sqlite-migration-banner { - flex-direction: column; - align-items: flex-start; - padding: var(--space-sm); - } -} +.sqlite-migration-banner-actions { flex-shrink: 0; } diff --git a/packages/dashboard/app/components/SqliteMigrationBanner.tsx b/packages/dashboard/app/components/SqliteMigrationBanner.tsx index a43a63db8d..9527079dd7 100644 --- a/packages/dashboard/app/components/SqliteMigrationBanner.tsx +++ b/packages/dashboard/app/components/SqliteMigrationBanner.tsx @@ -17,6 +17,7 @@ import { useTranslation } from "react-i18next"; import { DatabaseZap } from "lucide-react"; import type { Settings } from "@fusion/core"; import { fetchSettings, updateSettings } from "../api"; +import { Banner } from "./Banner"; import "./SqliteMigrationBanner.css"; export const FUSION_DISCORD_URL = "https://discord.gg/ksrfuy7WYR"; @@ -60,8 +61,21 @@ export function SqliteMigrationBanner({ projectId }: { projectId: string }) { }; return ( -
-
+ ); } diff --git a/packages/dashboard/app/components/TaskIdIntegrityBanner.css b/packages/dashboard/app/components/TaskIdIntegrityBanner.css index 7f6f9d2f2a..fbdd211c3c 100644 --- a/packages/dashboard/app/components/TaskIdIntegrityBanner.css +++ b/packages/dashboard/app/components/TaskIdIntegrityBanner.css @@ -1,16 +1,6 @@ /* === TaskIdIntegrityBanner === */ -.task-id-integrity-banner { - display: flex; - flex-direction: column; - gap: var(--space-md); - padding: var(--space-lg); - margin-bottom: var(--space-md); - border-radius: var(--radius-md); - border: var(--btn-border-width) solid color-mix(in srgb, var(--color-error) 24%, transparent); - border-inline-start: var(--space-xs) solid var(--color-error); - background: color-mix(in srgb, var(--color-error) 10%, transparent); - box-shadow: var(--shadow-md); -} +.task-id-integrity-banner { box-shadow: var(--shadow-md); } +.task-id-integrity-banner > .banner__copy { display: flex; flex-direction: column; gap: var(--space-md); } .task-id-integrity-banner__header { display: flex; @@ -82,10 +72,6 @@ } @media (max-width: 768px) { - .task-id-integrity-banner { - padding: var(--space-md); - } - .task-id-integrity-banner__header { flex-direction: column; align-items: stretch; diff --git a/packages/dashboard/app/components/TaskIdIntegrityBanner.tsx b/packages/dashboard/app/components/TaskIdIntegrityBanner.tsx index 6aa2defbf6..c342d40706 100644 --- a/packages/dashboard/app/components/TaskIdIntegrityBanner.tsx +++ b/packages/dashboard/app/components/TaskIdIntegrityBanner.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import type { TaskIdIntegrityReport } from "@fusion/core"; import { refreshDashboardHealth, type DashboardHealthResponse } from "../api"; +import { Banner } from "./Banner"; import "./TaskIdIntegrityBanner.css"; /* @@ -65,7 +66,7 @@ export function TaskIdIntegrityBanner({ report, recommendedAction, onRefresh }: }; return ( -
+
+ ); } diff --git a/packages/dashboard/app/components/TestModeBanner.css b/packages/dashboard/app/components/TestModeBanner.css index 4f0db41a9b..e69de29bb2 100644 --- a/packages/dashboard/app/components/TestModeBanner.css +++ b/packages/dashboard/app/components/TestModeBanner.css @@ -1,18 +0,0 @@ -.test-mode-banner { - display: flex; - align-items: center; - gap: var(--space-sm); - margin-bottom: var(--space-md); - padding: var(--space-sm) var(--space-md); - border-radius: var(--radius-md); - border-inline-start: var(--space-xs) solid var(--color-warning); - background: color-mix(in srgb, var(--color-warning) 18%, transparent); - color: var(--text); -} - -@media (max-width: 768px) { - .test-mode-banner { - padding: var(--space-sm); - align-items: flex-start; - } -} diff --git a/packages/dashboard/app/components/TestModeBanner.tsx b/packages/dashboard/app/components/TestModeBanner.tsx index aa638a6406..c87fe309bf 100644 --- a/packages/dashboard/app/components/TestModeBanner.tsx +++ b/packages/dashboard/app/components/TestModeBanner.tsx @@ -1,5 +1,6 @@ import { useTranslation } from "react-i18next"; import { FlaskConical } from "lucide-react"; +import { Banner } from "./Banner"; import "./TestModeBanner.css"; interface TestModeBannerProps { @@ -8,14 +9,11 @@ interface TestModeBannerProps { export function TestModeBanner({ isActive }: TestModeBannerProps) { const { t } = useTranslation("app"); - if (!isActive) { - return null; - } + if (!isActive) return null; return ( -
-
+ ); } diff --git a/packages/dashboard/app/components/UpdateAvailableBanner.css b/packages/dashboard/app/components/UpdateAvailableBanner.css index a9ba64ddfe..3579e753cb 100644 --- a/packages/dashboard/app/components/UpdateAvailableBanner.css +++ b/packages/dashboard/app/components/UpdateAvailableBanner.css @@ -1,125 +1,14 @@ -/* === UpdateAvailableBanner === */ -.update-available-banner { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: var(--space-sm); - padding: var(--space-sm) var(--space-md); - margin-bottom: var(--space-md); - border-radius: var(--radius-md); - border: 1px solid color-mix(in srgb, var(--color-info) 35%, var(--border)); - border-inline-start: var(--space-xs) solid var(--color-info); - background: color-mix(in srgb, var(--color-info) 10%, var(--surface)); - color: var(--text); -} - -.update-available-banner__content { - display: flex; - flex-direction: column; - gap: var(--space-xs); - min-width: 0; -} - -.update-available-banner__text { - margin: 0; - color: var(--text); -} - -.update-available-banner__text code { - font-family: var(--font-mono); -} - -.update-available-banner__link { - color: var(--color-info); - text-decoration: underline; - text-underline-offset: var(--space-xs); - transition: color var(--transition-fast); -} - -.update-available-banner__link:hover { - color: var(--text); -} - -.update-available-banner__link:focus-visible { - outline: none; - box-shadow: var(--focus-ring-strong); - border-radius: var(--radius-sm); -} - -.update-available-banner__actions { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: var(--space-xs); -} - -.update-available-banner__update-btn, -.update-available-banner__restart-btn { - display: inline-flex; - align-items: center; - gap: var(--space-xs); -} - -.update-available-banner__update-btn:disabled { - color: var(--color-warning); -} - -.update-available-banner__update-btn svg.spinning, -.update-available-banner__restart-btn svg.spinning { - animation: update-available-banner-spin 1s linear infinite; -} - -.update-available-banner__install-status { - font-weight: 500; -} - -.update-available-banner__install-status--success { - color: var(--color-success); -} - -.update-available-banner__install-status--error { - color: var(--color-error); -} - -.update-available-banner__dismiss { - background: none; - border: none; - color: var(--text-muted); - cursor: pointer; - padding: var(--space-xs); - border-radius: var(--radius-sm); - transition: color var(--transition-fast), background var(--transition-fast); - line-height: 0; - flex-shrink: 0; -} - -.update-available-banner__dismiss:hover { - color: var(--text); - background: color-mix(in srgb, var(--color-info) 14%, transparent); -} - -.update-available-banner__dismiss:focus-visible { - outline: none; - box-shadow: var(--focus-ring-strong); -} - -@keyframes update-available-banner-spin { - to { - transform: rotate(360deg); - } -} - -@media (max-width: 768px) { - .update-available-banner { - flex-direction: column; - align-items: stretch; - } - - .update-available-banner__actions { - align-items: flex-start; - } - - .update-available-banner__dismiss { - align-self: flex-end; - } -} +.update-available-banner__content { display: flex; flex-direction: column; gap: var(--space-xs); min-width: 0; } +.update-available-banner__text { margin: 0; color: var(--text); } +.update-available-banner__text code { font-family: var(--font-mono); } +.update-available-banner__link { color: var(--color-info); text-decoration: underline; text-underline-offset: var(--space-xs); transition: color var(--transition-fast); } +.update-available-banner__link:hover { color: var(--text); } +.update-available-banner__link:focus-visible { outline: none; box-shadow: var(--focus-ring-strong); border-radius: var(--radius-sm); } +.update-available-banner__actions, .update-available-banner__update-btn, .update-available-banner__restart-btn { display: inline-flex; align-items: center; gap: var(--space-xs); } +.update-available-banner__actions { flex-wrap: wrap; } +.update-available-banner__update-btn:disabled { color: var(--color-warning); } +.update-available-banner__update-btn svg.spinning, .update-available-banner__restart-btn svg.spinning { animation: update-available-banner-spin 1s linear infinite; } +.update-available-banner__install-status { font-weight: 500; } +.update-available-banner__install-status--success { color: var(--color-success); } +.update-available-banner__install-status--error { color: var(--color-error); } +@keyframes update-available-banner-spin { to { transform: rotate(360deg); } } diff --git a/packages/dashboard/app/components/UpdateAvailableBanner.tsx b/packages/dashboard/app/components/UpdateAvailableBanner.tsx index 3dd735507b..71a6b77fa2 100644 --- a/packages/dashboard/app/components/UpdateAvailableBanner.tsx +++ b/packages/dashboard/app/components/UpdateAvailableBanner.tsx @@ -1,12 +1,13 @@ import "./UpdateAvailableBanner.css"; import { useEffect, useState } from "react"; -import { Power, RefreshCw, X } from "lucide-react"; +import { Power, RefreshCw } from "lucide-react"; import { useTranslation, Trans } from "react-i18next"; import { getErrorMessage } from "@fusion/core"; import { fetchSystemInfo, installUpdate, requestSystemRestart } from "../api"; import type { UpdateInstallResponse } from "../api"; import { systemRestartRecovery, useSystemRestartRecovery } from "../hooks/useSystemRestartRecovery"; import { pendingUpdateInstallState, usePendingUpdateInstall } from "../hooks/usePendingUpdateInstall"; +import { Banner } from "./Banner"; interface UpdateAvailableBannerProps { latestVersion: string; @@ -113,7 +114,14 @@ export function UpdateAvailableBanner({ latestVersion, currentVersion, onDismiss const restartUnavailable = restartSupported === false; return ( -
+

- -
+ ); } diff --git a/packages/dashboard/app/components/__tests__/Banner.test.tsx b/packages/dashboard/app/components/__tests__/Banner.test.tsx new file mode 100644 index 0000000000..f05ebad70c --- /dev/null +++ b/packages/dashboard/app/components/__tests__/Banner.test.tsx @@ -0,0 +1,48 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { Banner } from "../Banner"; + +describe("Banner", () => { + it("renders title, children, and the selected tone", () => { + render(Banner copy); + + const root = screen.getByText("Banner copy").closest(".banner"); + expect(root).toHaveClass("banner--warning", "banner--inline", "banner--regular"); + expect(root).toHaveTextContent("Attention"); + }); + + it("renders an accessible dismiss action only when provided", () => { + const onDismiss = vi.fn(); + const { rerender } = render(Copy); + expect(screen.queryByRole("button", { name: "Dismiss" })).not.toBeInTheDocument(); + + rerender(Copy); + fireEvent.click(screen.getByRole("button", { name: "Close notice" })); + expect(onDismiss).toHaveBeenCalledOnce(); + }); + + it("omits undefined icon and action slots", () => { + const { container } = render(Copy); + expect(container.querySelector(".banner__icon")).toBeNull(); + expect(container.querySelector(".banner__actions")).toBeNull(); + }); + + it("forwards root semantics and caller classes", () => { + render( + + Copy + , + ); + const root = screen.getByTestId("banner"); + expect(root).toHaveClass("caller-class"); + expect(root).toHaveAttribute("role", "alert"); + expect(root).toHaveAttribute("aria-live", "assertive"); + expect(root).toHaveAttribute("aria-label", "Banner label"); + }); + + it("supports section roots and chrome layout", () => { + render(Copy); + expect(screen.getByText("Copy").closest("section")).toHaveClass("banner--chrome"); + expect(screen.getByText("Copy").closest("section")).not.toHaveClass("banner--inline"); + }); +});