Fix the top bar styling (#7544)

Every top bar styled itself, so none of them matched the new UI. Also,
colors on the premium banner (and possibly others) clashed since the
theme changes.

## Before Example Issue

<img width="1934" height="348" alt="Screenshot 2026-08-17 at 11 47
20 PM"
src="https://github.com/user-attachments/assets/b6f13207-2f47-4084-bd3b-2392f572c1a1"
/>


## After (all)

<img width="2880" height="800" alt="danger__dark"
src="https://github.com/user-attachments/assets/6b311dec-23e7-4059-a6bb-75527cbd2e34"
/>
<img width="2880" height="800" alt="danger__light"
src="https://github.com/user-attachments/assets/3b04b109-5146-4874-88b3-d99770ea51f8"
/>
<img width="2880" height="800" alt="default-app__dark"
src="https://github.com/user-attachments/assets/b0101b98-fce8-467a-99a6-dd40b8864da1"
/>
<img width="2880" height="800" alt="default-app__light"
src="https://github.com/user-attachments/assets/8aa21f01-6549-4d78-b217-c5547d60ab5b"
/>
<img width="2880" height="800" alt="free-tier-limit__dark"
src="https://github.com/user-attachments/assets/07fd7498-f44f-408f-8c79-9b5ea55e13df"
/>
<img width="2880" height="800" alt="free-tier-limit__light"
src="https://github.com/user-attachments/assets/f38c527b-9f64-4db0-b84c-56ca48e464cc"
/>
<img width="2880" height="800" alt="server-attention__dark"
src="https://github.com/user-attachments/assets/447a36fa-056d-4ca9-8b30-04aa0ccd6ed1"
/>
<img width="2880" height="800" alt="server-attention__light"
src="https://github.com/user-attachments/assets/f0311d45-a218-4846-b0ac-47996e2637c5"
/>
<img width="2880" height="800" alt="team-invitation__dark"
src="https://github.com/user-attachments/assets/cc368473-3b9f-4ab0-878a-67da993874c2"
/>
<img width="2880" height="800" alt="team-invitation__light"
src="https://github.com/user-attachments/assets/19d52a3e-4028-46f0-8562-9bd9cef9397a"
/>
<img width="2880" height="800" alt="upgrade-prompt__dark"
src="https://github.com/user-attachments/assets/e9d20daa-f41f-46d9-b827-a84f276e8af1"
/>
<img width="2880" height="800" alt="upgrade-prompt__light"
src="https://github.com/user-attachments/assets/9b6250c1-6a61-40d6-a7ab-e37398d67322"
/>


## What changed

- `InfoBanner` exposed 8 colour-override props (`background`,
`borderColor`, `textColor`, `iconColor`, `buttonColor`,
`buttonTextColor`, `closeIconColor`, `buttonVariant`), so every caller
invented its own look. Replaced with a closed tone set: `info` · `promo`
· `warning` · `danger`.
- Tone drives the whole bar — fill, border, icon and the button — so a
CTA can't drift from the bar it sits on. Text is neutral in every tone;
only the icon carries the tone colour.
- All colour comes from `--c-*` tokens mixed over `--c-surface`, so the
bars follow light and dark instead of ignoring them. The old bars were
hardcoded: in dark mode the two licence warnings stayed cream-on-white.
- `promo` keeps the gradient it was always meant to have, built from the
existing `--c-hue-indigo`/`--c-hue-purple` stops (documented in
`colors.css` as gradient hues, deliberately not accent-following), with
the existing `premium` button accent on it.
- Deleted the hardcoded colours from all four callers: the purple
gradient (`#667eea`→`#764ba2`), the orange soup (`#FFF4E6` / `#9A3412` /
`#EA580C`) duplicated across the urgent banner and the admin plan
section, and the fixed dark bar (`--mantine-color-dark-7`) on the team
invitation.
- `UpgradeBanner|AdminPlanSection` sat on the theme linter's exemption
list, which is how those colours survived the theme migration. Exemption
removed, so `code-colors` now guards them.
- The banner's class was colliding with `core/ui/Banner.css`'s
`.sui-banner` (16 live rules), which restyled it in the app but not in
Storybook — that's why the two disagreed on radius, border and tone.
Renamed to `.app-banner`; the two surfaces now render identically.
- Bar is square and full-bleed with a single hairline rule underneath;
button labels are optically centred.
- Added `--c-warning-subtle`, matching the existing `--c-danger-subtle`
/ `--c-success-subtle`.
- New `Shared → Top bars` story renders all six bars at once, so a
change to the shared component is visible against the whole set.
- Unrelated one-liner: `frontend/.prettierignore` now ignores the
gitignored `editor/screenshots/` capture artifacts, which were failing
`format:check` locally. Happy to drop it if you'd rather keep this PR to
the bars.

## Testing

- `task frontend:check` — typecheck, lint (oxlint + 4 theme-lint passes
+ stylelint), format, 244 files / 2119 tests.
- `frontend:storybook:a11y:changed` — clean in light and dark.
- The a11y gate caught a real defect mid-change: giving each banner
`role="region"` with the same label produced duplicate landmarks, which
the app hits for real whenever two banners show at once. Landmark
removed.
- All six bars captured in the running editor, light and dark, and
diffed against `origin/main`'s component rendered with each caller's
original props.
This commit is contained in:
EthanHealy01
2026-08-18 13:56:47 +00:00
committed by GitHub
parent fb70fc13da
commit cf49742d97
13 changed files with 453 additions and 342 deletions
@@ -1879,6 +1879,9 @@ width = "Width"
[app]
description = "The Free Adobe Acrobat alternative (10M+ Downloads)"
[appBanner]
dismiss = "Dismiss"
[attachments]
convertToPdfA3b = "Convert to PDF/A-3b"
convertToPdfA3bDescription = "Creates an archival PDF with embedded attachments"
@@ -4827,9 +4830,6 @@ title = "Image to PDF"
[imageToPdf]
tags = "conversion,img,jpg,picture,photo"
[infoBanner]
dismiss = "Dismiss"
[invite]
acceptError = "Failed to create account"
accountFor = "Creating account for"
@@ -642,7 +642,6 @@ const CODE_EXEMPT_PATH = [
/mantineTheme|\/theme\.ts$|toolsTaxonomy|LayoutPreview|PageNumberPreview|CloudStorageIcons|BrandMarks/,
/\/onboarding\//,
/addStamp|addWatermark|\/tooltips\//,
/UpgradeBanner|AdminPlanSection/,
// Stories are checked like app code; colour-as-data lines opt out with
// `theme-allow-color`.
/\.test\.[jt]sx?$|\/types\//,
@@ -3,7 +3,7 @@ import { Group, Text } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { InfoBanner } from "@app/components/shared/InfoBanner";
import { AppBanner } from "@app/components/shared/AppBanner";
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
/**
@@ -105,7 +105,7 @@ export function TeamInvitationBanner() {
);
return (
<InfoBanner
<AppBanner
icon="mail"
message={
<Group
@@ -120,10 +120,6 @@ export function TeamInvitationBanner() {
}
show={shouldShow}
dismissible={false}
background="var(--mantine-color-dark-7)"
borderColor="var(--mantine-color-dark-5)"
textColor="rgba(255, 255, 255, 0.95)"
iconColor="rgba(255, 255, 255, 0.95)"
/>
);
}
@@ -4,7 +4,7 @@ import { AppLayout } from "@app/components/AppLayout";
import { BannerProvider, useBanner } from "@app/contexts/BannerContext";
import { NavigationProvider } from "@app/contexts/NavigationContext";
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
import { InfoBanner } from "@app/components/shared/InfoBanner";
import { AppBanner } from "@app/components/shared/AppBanner";
const meta = {
title: "Components/AppLayout",
@@ -49,7 +49,7 @@ function BannerSetter() {
const { setBanner } = useBanner();
useEffect(() => {
setBanner(
<InfoBanner
<AppBanner
icon="info-rounded"
title="Heads up"
message="This workspace is running in offline mode."
@@ -0,0 +1,115 @@
/* App-wide top bar. One shape, four tones — callers pick a tone, never a colour.
Named `app-banner`, not `sui-banner`: that belongs to the SUI Banner primitive. */
.app-banner {
display: flex;
align-items: center;
gap: 0.75rem;
min-height: 3.5rem;
padding: 0.75rem 1rem;
/* Full-bleed across the top of the app: square corners, one rule underneath. */
border-bottom: 1px solid var(--app-banner-border);
background: var(--app-banner-bg);
color: var(--c-text);
}
.app-banner--compact {
min-height: 2.75rem;
padding: 0.5rem 1rem;
gap: 0.5rem;
}
.app-banner--info {
--app-banner-bg: var(--c-primary-subtle);
--app-banner-border: var(--c-primary-border);
--app-banner-icon: var(--c-accent-fg, var(--c-primary));
}
/* The one bar meant to pop, so it takes the feature gradient rather than a tint.
Fixed hues by design — it doesn't follow the chosen accent. */
.app-banner--promo {
--app-banner-bg: linear-gradient(
135deg,
var(--c-hue-indigo) 0%,
var(--c-hue-purple) 100%
);
--app-banner-border: transparent;
--app-banner-icon: var(--color-text-on-accent);
color: var(--color-text-on-accent);
}
.app-banner--warning {
--app-banner-bg: var(--c-warning-subtle);
--app-banner-border: color-mix(in srgb, var(--c-warning) 32%, transparent);
--app-banner-icon: var(--c-warning);
}
.app-banner--danger {
--app-banner-bg: var(--c-danger-subtle);
--app-banner-border: color-mix(in srgb, var(--c-danger) 32%, transparent);
--app-banner-icon: var(--c-danger);
}
/* Only the icon carries the tone; text stays neutral in every tone. */
.app-banner__icon {
display: flex;
flex-shrink: 0;
align-items: center;
color: var(--app-banner-icon);
}
.app-banner__body {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 0.125rem;
}
.app-banner__title {
font-size: 0.875rem;
font-weight: 600;
}
.app-banner__message {
font-size: 0.8125rem;
line-height: 1.4;
color: var(--c-text-muted);
}
.app-banner__body:not(:has(.app-banner__title)) .app-banner__message {
color: inherit;
font-weight: 500;
}
.app-banner--compact .app-banner__title,
.app-banner--compact .app-banner__message {
font-size: 0.75rem;
}
/* On the gradient everything is white; muted grey would disappear. */
.app-banner--promo .app-banner__message,
.app-banner--promo .app-banner__actions .sui-btn--tertiary,
.app-banner--promo .app-banner__actions .sui-ai {
color: var(--color-text-on-accent);
}
/* Lifts the premium CTA off the gradient it sits on. */
.app-banner--promo .app-banner__actions .sui-btn--primary {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
}
.app-banner__actions {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.5rem;
}
/* Mantine trims the leading padding when a button has a left section, which reads
as off-centre next to the label. Even it back up. */
.app-banner__actions .sui-btn .mantine-Button-inner {
padding-inline: 0;
}
.app-banner__actions .sui-btn {
padding-inline: 0.875rem;
}
@@ -0,0 +1,194 @@
import type { ReactNode } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AppBanner } from "@app/components/shared/AppBanner";
const meta = {
title: "Shared/AppBanner",
component: AppBanner,
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof AppBanner>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Info: Story = {
args: {
icon: "info-rounded",
title: "Heads up",
message: "This document contains form fields that will be flattened.",
},
};
export const Promo: Story = {
args: {
tone: "promo",
icon: "stars-rounded",
title: "Upgrade to Server Plan",
message:
"Get the most out of Stirling PDF with unlimited users and advanced features.",
buttonText: "Upgrade Now",
buttonIcon: "upgrade-rounded",
onButtonClick: () => {},
compact: true,
},
};
export const Warning: Story = {
args: {
tone: "warning",
icon: "warning-rounded",
title: "Action required",
message: "Some pages could not be processed and were skipped.",
buttonText: "Review",
onButtonClick: () => {},
},
};
export const Danger: Story = {
args: {
tone: "danger",
icon: "warning-rounded",
title: "This server needs admin attention",
message: "Review the license requirements to keep this server compliant.",
buttonText: "See info",
buttonIcon: "info-rounded",
onButtonClick: () => {},
dismissible: false,
},
};
export const Compact: Story = {
args: {
compact: true,
icon: "info-rounded",
message: "Autosave is enabled for this file.",
dismissible: false,
},
};
/** Message-only, no title: the message takes the title's weight so the bar still reads. */
export const MessageOnly: Story = {
args: {
icon: "picture-as-pdf-rounded",
message:
"Make Stirling PDF your default application for opening PDF files.",
buttonText: "Set Default",
onButtonClick: () => {},
secondaryButtonText: "Don't remind me again",
onSecondaryButtonClick: () => {},
},
};
function Row({ caption, children }: { caption: string; children: ReactNode }) {
return (
<section
style={{ display: "flex", flexDirection: "column", gap: "0.375rem" }}
>
<span
style={{
fontSize: "0.6875rem",
fontWeight: 600,
letterSpacing: "0.04em",
textTransform: "uppercase",
color: "var(--c-text-subtle)",
padding: "0 1rem",
}}
>
{caption}
</span>
{children}
</section>
);
}
/**
* Every top bar the app can show, in one place: each entry mirrors a real caller,
* so a change to the component is visible against the whole set at once. Renders a
* composition rather than the component, so it takes no args of its own.
*/
export const AllTopBars: StoryObj = {
render: () => (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "1.5rem",
padding: "1.5rem 0",
background: "var(--c-bg)",
}}
>
<Row caption="Upgrade prompt · UpgradeBanner (friendly)">
<AppBanner
tone="promo"
compact
icon="stars-rounded"
title="Upgrade to Server Plan"
message="Get the most out of Stirling PDF with unlimited users and advanced features."
buttonText="Upgrade Now"
buttonIcon="upgrade-rounded"
onButtonClick={() => {}}
/>
</Row>
<Row caption="Server needs attention · UpgradeBanner (urgent)">
<AppBanner
tone="warning"
icon="warning-rounded"
title="This server needs admin attention"
message="Review the license requirements to keep this server compliant."
buttonText="See info"
buttonIcon="info-rounded"
onButtonClick={() => {}}
dismissible={false}
/>
</Row>
<Row caption="Free tier limit reached · AdminPlanSection">
<AppBanner
tone="warning"
icon="warning-rounded"
title="Free self-hosted limit reached"
message="You have 12 users on a plan that covers 10."
buttonText="See plans"
buttonIcon="upgrade-rounded"
onButtonClick={() => {}}
dismissible={false}
/>
</Row>
<Row caption="Team invitation · TeamInvitationBanner">
<AppBanner
icon="mail"
message="You have been invited to join the Acme Legal team."
buttonText="Accept"
onButtonClick={() => {}}
secondaryButtonText="Decline"
onSecondaryButtonClick={() => {}}
dismissible={false}
/>
</Row>
<Row caption="Set as default app · DefaultAppBanner (desktop)">
<AppBanner
icon="picture-as-pdf-rounded"
message="Make Stirling PDF your default application for opening PDF files."
buttonText="Set Default"
onButtonClick={() => {}}
secondaryButtonText="Don't remind me again"
onSecondaryButtonClick={() => {}}
/>
</Row>
<Row caption="Danger tone (available, no caller yet)">
<AppBanner
tone="danger"
icon="warning-rounded"
title="Storage is full"
message="New uploads will fail until space is freed."
buttonText="Manage storage"
onButtonClick={() => {}}
dismissible={false}
/>
</Row>
</div>
),
};
@@ -0,0 +1,124 @@
import React, { ReactNode } from "react";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import "@app/components/shared/AppBanner.css";
/** Picks the whole look. Callers choose meaning, never colours. */
export type AppBannerTone = "info" | "promo" | "warning" | "danger";
/** Tone decides the button too, so the CTA can't drift from the bar it sits on. */
const TONE_BUTTON = {
info: { variant: "secondary", accent: "default" },
promo: { variant: "primary", accent: "premium" },
warning: { variant: "primary", accent: "warning" },
danger: { variant: "primary", accent: "danger" },
} as const;
interface AppBannerProps {
/** A LocalIcon name, or a pre-rendered node (e.g. a logo) dropped in as-is. */
icon?: string | ReactNode;
title?: ReactNode;
message: ReactNode;
buttonText?: string;
buttonIcon?: string;
onButtonClick?: () => void;
/** Muted secondary action, e.g. "Don't remind me again". */
secondaryButtonText?: string;
onSecondaryButtonClick?: () => void;
onDismiss?: () => void;
dismissible?: boolean;
loading?: boolean;
show?: boolean;
tone?: AppBannerTone;
compact?: boolean;
}
/** The app's top bar: dismissible messaging above the workspace. */
export const AppBanner: React.FC<AppBannerProps> = ({
icon,
title,
message,
buttonText,
buttonIcon = "check-circle-rounded",
onButtonClick,
secondaryButtonText,
onSecondaryButtonClick,
onDismiss,
dismissible = true,
loading = false,
show = true,
tone = "info",
compact = false,
}) => {
const { t } = useTranslation();
if (!show) return null;
const iconSize = compact ? "1rem" : "1.25rem";
return (
<div
className={[
"app-banner",
`app-banner--${tone}`,
compact ? "app-banner--compact" : "",
]
.filter(Boolean)
.join(" ")}
>
{icon != null && (
<span className="app-banner__icon" aria-hidden>
{typeof icon === "string" ? (
<LocalIcon icon={icon} width={iconSize} height={iconSize} />
) : (
icon
)}
</span>
)}
<div className="app-banner__body">
{title && <span className="app-banner__title">{title}</span>}
<span className="app-banner__message">{message}</span>
</div>
<div className="app-banner__actions">
{buttonText && onButtonClick && (
<Button
variant={TONE_BUTTON[tone].variant}
accent={TONE_BUTTON[tone].accent}
size="sm"
loading={loading}
onClick={onButtonClick}
leftSection={
<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />
}
>
{buttonText}
</Button>
)}
{secondaryButtonText && onSecondaryButtonClick && (
<Button
variant="tertiary"
accent="neutral"
size="sm"
onClick={onSecondaryButtonClick}
>
{secondaryButtonText}
</Button>
)}
{dismissible && (
<ActionIcon
variant="tertiary"
accent="neutral"
size="sm"
onClick={() => onDismiss?.()}
aria-label={t("appBanner.dismiss", "Dismiss")}
>
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
</ActionIcon>
)}
</div>
</div>
);
};
@@ -1,38 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { InfoBanner } from "@app/components/shared/InfoBanner";
const meta = {
title: "Shared/InfoBanner",
component: InfoBanner,
parameters: { layout: "padded" },
} satisfies Meta<typeof InfoBanner>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
icon: "info-rounded",
title: "Heads up",
message: "This document contains form fields that will be flattened.",
},
};
export const Warning: Story = {
args: {
tone: "warning",
icon: "warning-rounded",
title: "Action required",
message: "Some pages could not be processed and were skipped.",
buttonText: "Review",
onButtonClick: () => {},
},
};
export const Compact: Story = {
args: {
compact: true,
icon: "info-rounded",
message: "Autosave is enabled for this file.",
dismissible: false,
},
};
@@ -1,263 +0,0 @@
import React, { ReactNode } from "react";
import { Paper, Group, Text, Stack } from "@mantine/core";
import { Button, type ButtonVariant, type ButtonAccent } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
type InfoBannerTone = "info" | "warning";
const toneStyles: Record<
InfoBannerTone,
{
background: string;
border: string;
text: string;
icon: string;
buttonColor: string;
}
> = {
info: {
background: "var(--mantine-color-blue-0)",
border: "var(--mantine-color-blue-2)",
text: "var(--mantine-color-blue-9)",
icon: "var(--mantine-color-blue-6)",
buttonColor: "blue",
},
warning: {
background: "var(--mantine-color-orange-0)",
border: "var(--mantine-color-orange-3)",
text: "var(--color-amber-dark)",
icon: "var(--mantine-color-orange-7)",
buttonColor: "orange",
},
};
function toSharedButtonVariant(
variant: "light" | "filled" | "white" | "outline" | "subtle",
): ButtonVariant {
switch (variant) {
case "filled":
return "primary";
case "outline":
return "secondary";
case "subtle":
return "tertiary";
case "light":
case "white":
default:
return "secondary";
}
}
function toSharedButtonAccent(color: string | undefined): ButtonAccent {
// Mantine colours may carry a shade suffix (e.g. "orange.7"); use the hue.
const hue = (color ?? "").split(".")[0];
switch (hue) {
case "red":
return "danger";
case "green":
return "success";
case "yellow":
case "orange":
return "warning";
case "blue":
default:
return "default";
}
}
interface InfoBannerProps {
/**
* Either a LocalIcon name (string) for the standard sized icon slot, or a
* pre-rendered ReactNode (e.g. a logo image) which is dropped in as-is.
*/
icon?: string | ReactNode;
title?: ReactNode;
message: ReactNode;
buttonText?: string;
buttonIcon?: string;
onButtonClick?: () => void;
/** Optional muted secondary action (e.g. "Don't remind me again"). */
secondaryButtonText?: string;
onSecondaryButtonClick?: () => void;
onDismiss?: () => void;
dismissible?: boolean;
loading?: boolean;
show?: boolean;
tone?: InfoBannerTone;
background?: string;
borderColor?: string;
textColor?: string;
iconColor?: string;
buttonColor?: string;
buttonVariant?: "light" | "filled" | "white" | "outline" | "subtle";
/** Override the button label colour (for dark/custom theme variants). */
buttonTextColor?: string;
minHeight?: number | string;
closeIconColor?: string;
compact?: boolean;
}
/**
* Generic info banner component for displaying dismissible messages at the top of the app
*/
export const InfoBanner: React.FC<InfoBannerProps> = ({
icon,
title,
message,
buttonText,
buttonIcon = "check-circle-rounded",
onButtonClick,
secondaryButtonText,
onSecondaryButtonClick,
onDismiss,
dismissible = true,
loading = false,
show = true,
tone = "info",
background,
borderColor,
textColor,
iconColor,
buttonColor,
buttonVariant = "light",
buttonTextColor,
minHeight = 56,
closeIconColor,
compact = false,
}) => {
const { t } = useTranslation();
if (!show) {
return null;
}
const toneStyle = toneStyles[tone] ?? toneStyles.info;
const resolvedTextColor = textColor ?? toneStyle.text;
const handleDismiss = () => {
onDismiss?.();
};
const iconSize = compact ? "1rem" : "1.2rem";
const textSize = compact ? "xs" : "sm";
return (
<Paper
p={compact ? "xs" : "sm"}
radius={0}
style={{
background: background ?? toneStyle.background,
border: "none",
borderBottom:
borderColor === "transparent"
? "none"
: `1px solid ${borderColor ?? toneStyle.border}`,
minHeight,
display: "flex",
alignItems: "center",
}}
>
<Group
gap="sm"
align="center"
wrap="nowrap"
justify="space-between"
style={{ width: "100%" }}
>
<Group
gap={compact ? "xs" : "sm"}
align="center"
wrap="nowrap"
style={{ flex: 1, minWidth: 0 }}
>
{icon != null &&
(typeof icon === "string" ? (
<LocalIcon
icon={icon}
width={iconSize}
height={iconSize}
style={{ color: iconColor ?? toneStyle.icon, flexShrink: 0 }}
/>
) : (
<div
style={{ flexShrink: 0, display: "flex", alignItems: "center" }}
>
{icon}
</div>
))}
<Stack gap={compact ? 1 : 2} style={{ flex: 1, minWidth: 0 }}>
{title && (
<Text
fw={600}
size={textSize}
style={{ color: resolvedTextColor }}
>
{title}
</Text>
)}
<Text
fw={title ? 400 : 500}
size={textSize}
style={{ color: resolvedTextColor }}
lineClamp={compact ? 1 : 2}
>
{message}
</Text>
</Stack>
</Group>
<Group gap="xs" align="center" wrap="nowrap">
{buttonText && onButtonClick && (
<Button
variant={toSharedButtonVariant(buttonVariant)}
accent={toSharedButtonAccent(
buttonColor ?? toneStyle.buttonColor,
)}
size="sm"
loading={loading}
onClick={onButtonClick}
leftSection={
<LocalIcon
icon={buttonIcon}
width={compact ? "0.75rem" : "0.9rem"}
height={compact ? "0.75rem" : "0.9rem"}
/>
}
style={buttonTextColor ? { color: buttonTextColor } : undefined}
>
{buttonText}
</Button>
)}
{secondaryButtonText && onSecondaryButtonClick && (
<Button
variant="tertiary"
accent="neutral"
size="sm"
onClick={onSecondaryButtonClick}
style={{ color: "var(--c-text-muted)" }}
>
{secondaryButtonText}
</Button>
)}
{dismissible && (
<ActionIcon
variant="tertiary"
accent="neutral"
size="sm"
onClick={handleDismiss}
aria-label={t("infoBanner.dismiss", "Dismiss")}
style={{
color: closeIconColor ?? "var(--c-text-muted)",
}}
>
<LocalIcon
icon="close-rounded"
width={compact ? "0.85rem" : "1rem"}
height={compact ? "0.85rem" : "1rem"}
/>
</ActionIcon>
)}
</Group>
</Group>
</Paper>
);
};
@@ -71,6 +71,11 @@ html[data-app-theme="light"] {
var(--c-success) 10%,
var(--c-surface)
);
--c-warning-subtle: color-mix(
in srgb,
var(--c-warning) 10%,
var(--c-surface)
);
/* ── Decorative / brand / categorical palette ──────────────────────────
Fixed hues that intentionally do NOT follow the chosen accent: brand
@@ -1,6 +1,6 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { InfoBanner } from "@app/components/shared/InfoBanner";
import { AppBanner } from "@app/components/shared/AppBanner";
import { useDefaultApp } from "@app/hooks/useDefaultApp";
export const DefaultAppBanner: React.FC = () => {
@@ -15,7 +15,7 @@ export const DefaultAppBanner: React.FC = () => {
const [sessionDismissed, setSessionDismissed] = useState(false);
return (
<InfoBanner
<AppBanner
icon="picture-as-pdf-rounded"
message={t(
"defaultApp.prompt.message",
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { useNavigate, useLocation } from "react-router-dom";
import { isAuthRoute } from "@core/constants/routes";
import { useCheckout } from "@app/contexts/CheckoutContext";
import { InfoBanner } from "@app/components/shared/InfoBanner";
import { AppBanner } from "@app/components/shared/AppBanner";
import {
SERVER_LICENSE_REQUEST_EVENT,
type ServerLicenseRequestPayload,
@@ -300,7 +300,7 @@ const UpgradeBanner: React.FC = () => {
);
return (
<InfoBanner
<AppBanner
icon="warning-rounded"
tone="warning"
title={t(
@@ -312,13 +312,6 @@ const UpgradeBanner: React.FC = () => {
buttonIcon="info-rounded"
onButtonClick={buttonText ? handleSeeInfo : undefined}
dismissible={false}
minHeight={60}
background="#FFF4E6"
borderColor="var(--mantine-color-orange-7)"
textColor="#9A3412"
iconColor="#EA580C"
buttonVariant="filled"
buttonColor="orange.7"
/>
);
};
@@ -341,7 +334,7 @@ const UpgradeBanner: React.FC = () => {
return (
<>
{friendlyVisible && (
<InfoBanner
<AppBanner
icon="stars-rounded"
title={t("upgradeBanner.title", "Upgrade to Server Plan")}
message={t(
@@ -353,14 +346,7 @@ const UpgradeBanner: React.FC = () => {
onButtonClick={handleUpgrade}
onDismiss={handleFriendlyDismiss}
show={friendlyVisible}
background="linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
borderColor="transparent"
textColor="#fff"
iconColor="#fff"
closeIconColor="#fff"
buttonVariant="filled"
buttonColor="blue"
minHeight={48}
tone="promo"
compact
/>
)}
@@ -12,7 +12,7 @@ import AvailablePlansSection from "@app/components/shared/config/configSections/
import StaticPlanSection from "@app/components/shared/config/configSections/plan/StaticPlanSection";
import LicenseKeySection from "@app/components/shared/config/configSections/plan/LicenseKeySection";
import { alert } from "@app/components/toast";
import { InfoBanner } from "@app/components/shared/InfoBanner";
import { AppBanner } from "@app/components/shared/AppBanner";
import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
import {
getPreferredCurrency,
@@ -200,7 +200,7 @@ const AdminPlanSection: React.FC = () => {
<LoginRequiredBanner show={!loginEnabled} />
{shouldShowLicenseWarning && (
<InfoBanner
<AppBanner
icon="warning-rounded"
tone="warning"
title={t(
@@ -215,13 +215,6 @@ const AdminPlanSection: React.FC = () => {
buttonIcon="upgrade-rounded"
onButtonClick={scrollToPlans}
dismissible={false}
minHeight={68}
background="#FFF4E6"
borderColor="var(--mantine-color-orange-7)"
textColor="#9A3412"
iconColor="#EA580C"
buttonVariant="filled"
buttonColor="orange.7"
/>
)}