@tanishqkancharla/maui 0.0.61 → 0.0.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
2
+ export type BannerTone = "neutral" | "info" | "success" | "warning" | "danger";
3
+ export type BannerLayout = "inline" | "stacked";
4
+ export type BannerProps = Omit<ComponentPropsWithoutRef<"div">, "title"> & {
5
+ tone?: BannerTone;
6
+ /** Use stacked for multi-sentence messages or a title with supporting text. */
7
+ layout?: BannerLayout;
8
+ title?: ReactNode;
9
+ /** A decorative icon. Defaults to the tone's icon; false hides it. */
10
+ icon?: ReactNode | false;
11
+ actions?: ReactNode;
12
+ /** Adds a close button. The caller owns visibility. */
13
+ onDismiss?: () => void;
14
+ dismissLabel?: string;
15
+ };
16
+ /** Plain div parts; icon is decorative. Layout changes action placement, not semantics. */
17
+ export declare const bannerStyles: {
18
+ readonly root: (args_0?: {
19
+ tone?: BannerTone;
20
+ } | undefined) => import("purse-styles").StyleElement;
21
+ readonly icon: import("purse-styles").StyleElement;
22
+ readonly body: (args_0?: {
23
+ layout?: BannerLayout;
24
+ } | undefined) => import("purse-styles").StyleElement;
25
+ readonly content: import("purse-styles").StyleElement;
26
+ readonly title: import("purse-styles").StyleElement;
27
+ readonly description: import("purse-styles").StyleElement;
28
+ readonly actions: import("purse-styles").StyleElement;
29
+ readonly dismiss: import("purse-styles").StyleElement;
30
+ };
31
+ /** Persistent in-flow message. Set role="status" or role="alert" explicitly to announce it. */
32
+ export declare function Banner({ tone, layout, title, children, icon, actions, onDismiss, dismissLabel, className, ...props }: BannerProps): import("react").JSX.Element;
@@ -0,0 +1,106 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { style, useStyles } from "purse-styles";
3
+ import { Button } from "./Button";
4
+ import { Text } from "./Text";
5
+ import { CheckCircle } from "../icons/CheckCircle";
6
+ import { Close } from "../icons/Close";
7
+ import { CircleX } from "../icons/CircleX";
8
+ import { Information } from "../icons/Information";
9
+ import { WarningTriangle } from "../icons/WarningTriangle";
10
+ import { colors } from "../tokens/colors";
11
+ import { radius } from "../tokens/radius";
12
+ import { controlSize } from "../tokens/sizing";
13
+ import { spacing } from "../tokens/spacing";
14
+ import { text } from "../tokens/text";
15
+ import { cls } from "../utils/cls";
16
+ import { memoize } from "../utils/memoize";
17
+ const palettes = {
18
+ neutral: "gray",
19
+ info: "blue",
20
+ success: "green",
21
+ warning: "amber",
22
+ danger: "red",
23
+ };
24
+ const icons = {
25
+ neutral: Information,
26
+ info: Information,
27
+ success: CheckCircle,
28
+ warning: WarningTriangle,
29
+ danger: CircleX,
30
+ };
31
+ const root = memoize(({ tone = "neutral" } = {}) => style(text({ size: "sm" }), radius.lg, spacing.padding({ all: 3 }), {
32
+ display: "flex",
33
+ alignItems: "flex-start",
34
+ gap: spacing.value(3),
35
+ minWidth: 0,
36
+ backgroundColor: colors[palettes[tone]][2],
37
+ border: `1px solid ${colors[palettes[tone]][6]}`,
38
+ // Give text and icons the same inset beside standard-height controls.
39
+ "--banner-text-inset": `max(0px, calc((${controlSize.height} - 1lh) / 2))`,
40
+ "--banner-icon-color": colors[palettes[tone]][11],
41
+ }));
42
+ const icon = style({
43
+ flex: "0 0 auto",
44
+ width: "1lh",
45
+ height: "1lh",
46
+ marginBlockStart: "var(--banner-text-inset)",
47
+ color: "var(--banner-icon-color)",
48
+ display: "flex",
49
+ alignItems: "center",
50
+ justifyContent: "center",
51
+ "& > svg": { width: "100%", height: "100%" },
52
+ });
53
+ const body = memoize(({ layout = "inline" } = {}) => style({
54
+ display: "flex",
55
+ flex: "1 1 auto",
56
+ minWidth: 0,
57
+ flexDirection: layout === "stacked" ? "column" : "row",
58
+ flexWrap: "wrap",
59
+ alignItems: "flex-start",
60
+ gap: spacing.value(4),
61
+ "& > :first-child": {
62
+ flex: layout === "inline" ? "1 1 12rem" : "0 1 auto",
63
+ },
64
+ }));
65
+ const content = style({
66
+ minWidth: 0,
67
+ maxWidth: "100%",
68
+ paddingBlock: "var(--banner-text-inset)",
69
+ overflowWrap: "anywhere",
70
+ });
71
+ const title = style({ display: "block" });
72
+ const description = style({ display: "block" });
73
+ const actions = style({
74
+ display: "flex",
75
+ alignItems: "center",
76
+ justifyContent: "flex-end",
77
+ flexWrap: "wrap",
78
+ gap: spacing.value(4),
79
+ maxWidth: "100%",
80
+ marginInlineStart: "auto",
81
+ });
82
+ const dismiss = style({ flexShrink: 0 });
83
+ /** Plain div parts; icon is decorative. Layout changes action placement, not semantics. */
84
+ export const bannerStyles = {
85
+ root,
86
+ icon,
87
+ body,
88
+ content,
89
+ title,
90
+ description,
91
+ actions,
92
+ dismiss,
93
+ };
94
+ /** Persistent in-flow message. Set role="status" or role="alert" explicitly to announce it. */
95
+ export function Banner({ tone = "neutral", layout = "inline", title, children, icon, actions, onDismiss, dismissLabel = "Dismiss notification", className, ...props }) {
96
+ const rootClassName = useStyles(bannerStyles.root({ tone }));
97
+ const iconClassName = useStyles(bannerStyles.icon);
98
+ const bodyClassName = useStyles(bannerStyles.body({ layout }));
99
+ const contentClassName = useStyles(bannerStyles.content);
100
+ const titleClassName = useStyles(bannerStyles.title);
101
+ const descriptionClassName = useStyles(bannerStyles.description, title != null && style({ color: colors.gray[11] }));
102
+ const actionsClassName = useStyles(bannerStyles.actions);
103
+ const dismissClassName = useStyles(bannerStyles.dismiss);
104
+ const Icon = icons[tone];
105
+ return (_jsxs("div", { ...props, className: cls(rootClassName, className), children: [icon !== false && (_jsx("div", { className: iconClassName, "aria-hidden": "true", children: icon ?? _jsx(Icon, {}) })), _jsxs("div", { className: bodyClassName, children: [_jsxs("div", { className: contentClassName, children: [title != null && (_jsx(Text, { size: "sm", fontWeight: 500, className: titleClassName, children: title })), children != null && (_jsx("div", { className: descriptionClassName, children: children }))] }), actions != null && actions !== false && (_jsx("div", { className: actionsClassName, children: actions }))] }), onDismiss && (_jsx(Button, { variant: "quiet", variantColor: palettes[tone], "aria-label": dismissLabel, onPress: onDismiss, className: dismissClassName, children: _jsx(Close, { size: "sm" }) }))] }));
106
+ }
@@ -9,7 +9,7 @@ import { backgroundColor, surfaceMixPercent, surfaceWash, } from "../tokens/back
9
9
  import { colors } from "../tokens/colors";
10
10
  import { focusRing } from "../tokens/focusRing";
11
11
  import { radius } from "../tokens/radius";
12
- import { shadowVars } from "../tokens/shadow";
12
+ import { shadowVars, tintedControl } from "../tokens/shadow";
13
13
  import { controlSize, iconSizeValues } from "../tokens/sizing";
14
14
  import { spacing } from "../tokens/spacing";
15
15
  import { text } from "../tokens/text";
@@ -146,10 +146,6 @@ function onSolidText(color) {
146
146
  function fillShade(color, lightnessOffset, alpha = 1) {
147
147
  return `oklch(from ${color} calc(l - ${lightnessOffset}) c h / ${alpha})`;
148
148
  }
149
- /** Dark-only top highlight, a fill-tinted lift (half strength in dark mode), and an edge painted beneath the lift: the fill's alpha step 6 in light mode, a near-black tint of the fill in dark mode. */
150
- function primaryButtonShadow(color, lightEdge) {
151
- return `inset 0 .5px light-dark(transparent, rgb(255 255 255 / 0.15)), 0 1px 2px light-dark(${fillShade(color, 0, 0.35)}, ${fillShade(color, 0, 0.18)}), 0 3px 8px -3px light-dark(${fillShade(color, 0, 0.6)}, ${fillShade(color, 0, 0.3)}), 0 0 0 .5px light-dark(${lightEdge}, oklch(from ${color} 0.18 calc(c * 0.5) h))`;
152
- }
153
149
  /**
154
150
  * Diagonal from a slightly darker, more saturated, warmer-rotated fill to the
155
151
  * fill. The offsets are the OKLCH delta between Campsite's #FB432C → #FF591E.
@@ -160,7 +156,7 @@ function primaryButtonGradient(color) {
160
156
  /** Hover lightens the top of the gradient instead of replacing the fill. */
161
157
  const primaryHoverSheen = `linear-gradient(to bottom, rgb(255 255 255 / 0.12), rgb(255 255 255 / 0))`;
162
158
  const primaryClass = memoize((fill, textColor, lightEdge) => {
163
- const primaryShadow = primaryButtonShadow(fill, lightEdge);
159
+ const primaryShadow = tintedControl(fill, lightEdge);
164
160
  const gradient = primaryButtonGradient(fill);
165
161
  return style(buttonBaseClass, focusRing("&:focus-visible", primaryShadow), {
166
162
  color: textColor,
@@ -27,11 +27,11 @@ const check = style(motion.standard("opacity", "transform"), {
27
27
  height: checkboxSize.checkmark,
28
28
  paddingTop: "0.5px",
29
29
  paddingLeft: "0.5px",
30
- fill: colors.gray[3],
30
+ fill: "white",
31
31
  opacity: 0,
32
32
  transform: "scale(0)",
33
33
  });
34
- const root = style(flex({ alignItems: "center", gap: 3 }), focusRing(`&[data-focus-visible] ${box}`, shadowVars.control), {
34
+ const root = style(flex({ alignItems: "center", gap: 3 }), focusRing(`&[data-focus-visible] ${box}`, shadowVars.control), focusRing(`&[data-selected][data-focus-visible] ${box}`, shadowVars.selectedControl), {
35
35
  position: "relative",
36
36
  width: "fit-content",
37
37
  minHeight: controlSize.minTarget,
@@ -41,6 +41,7 @@ const root = style(flex({ alignItems: "center", gap: 3 }), focusRing(`&[data-foc
41
41
  },
42
42
  [`&[data-selected] ${box}`]: {
43
43
  backgroundColor: colors.accent[9],
44
+ boxShadow: shadowVars.selectedControl,
44
45
  },
45
46
  [`&[data-selected][data-hovered] ${box}`]: {
46
47
  backgroundColor: surfaceWash(colors.gray[12], surfaceMixPercent.hover, colors.accent[9]),
@@ -22,10 +22,10 @@ const dot = style(radius.circle, motion.standard("transform"), {
22
22
  left: "4px",
23
23
  width: "6px",
24
24
  height: "6px",
25
- backgroundColor: colors.gray[3],
25
+ backgroundColor: "white",
26
26
  transform: "scale(0)",
27
27
  });
28
- const root = style(flex({ alignItems: "center", gap: 3 }), focusRing(`&[data-focus-visible] ${circle}`, shadowVars.control), {
28
+ const root = style(flex({ alignItems: "center", gap: 3 }), focusRing(`&[data-focus-visible] ${circle}`, shadowVars.control), focusRing(`&[data-selected][data-focus-visible] ${circle}`, shadowVars.selectedControl), {
29
29
  position: "relative",
30
30
  width: "fit-content",
31
31
  [`&[data-hovered] ${circle}`]: {
@@ -33,6 +33,7 @@ const root = style(flex({ alignItems: "center", gap: 3 }), focusRing(`&[data-foc
33
33
  },
34
34
  [`&[data-selected] ${circle}`]: {
35
35
  backgroundColor: colors.accent[9],
36
+ boxShadow: shadowVars.selectedControl,
36
37
  },
37
38
  [`&[data-selected][data-hovered] ${circle}`]: {
38
39
  backgroundColor: surfaceWash(colors.gray[12], surfaceMixPercent.hover, colors.accent[9]),
@@ -42,7 +42,7 @@ const thumb = style(radius.circle, motion.standard("transform"), {
42
42
  backgroundColor: "#ffffff",
43
43
  boxShadow: "0 1px 2px rgba(0, 0, 0, 0.16)",
44
44
  });
45
- const root = style(flex({ alignItems: "center", gap: 3 }), focusRing(`&[data-focus-visible] ${track}`, shadowVars.control), {
45
+ const root = style(flex({ alignItems: "center", gap: 3 }), focusRing(`&[data-focus-visible] ${track}`, shadowVars.control), focusRing(`&[data-selected][data-focus-visible] ${track}`, shadowVars.selectedControl), {
46
46
  position: "relative",
47
47
  width: "fit-content",
48
48
  minHeight: controlSize.minTarget,
@@ -51,6 +51,7 @@ const root = style(flex({ alignItems: "center", gap: 3 }), focusRing(`&[data-foc
51
51
  },
52
52
  [`&[data-selected] ${track}`]: {
53
53
  backgroundColor: colors.accent[9],
54
+ boxShadow: shadowVars.selectedControl,
54
55
  },
55
56
  [`&[data-selected][data-hovered] ${track}`]: {
56
57
  backgroundColor: switchOnHover,
package/dist/maui.d.ts CHANGED
@@ -20,6 +20,7 @@ export { prose, proseRhythm, proseHtml, proseStreamingMarkers, type ProseSize, }
20
20
  export { visuallyHidden } from "./tokens/visuallyHidden";
21
21
  export { Avatar, avatarStyles } from "./components/Avatar";
22
22
  export { Badge, badgeStyles } from "./components/Badge";
23
+ export { Banner, bannerStyles, type BannerProps, type BannerTone, type BannerLayout, } from "./components/Banner";
23
24
  export { LogoIcon, type LogoIconProps } from "./components/LogoIcon";
24
25
  export { Button, buttonStyles, useButton, type ButtonCssColor, type ButtonProps, type ButtonSize, type ButtonVariant, type ButtonVariantColor, } from "./components/Button";
25
26
  export { TabBar, tabBarStyles, type TabBarItem, type TabBarProps, } from "./components/TabBar";
package/dist/maui.js CHANGED
@@ -23,6 +23,7 @@ export { visuallyHidden } from "./tokens/visuallyHidden";
23
23
  // Components
24
24
  export { Avatar, avatarStyles } from "./components/Avatar";
25
25
  export { Badge, badgeStyles } from "./components/Badge";
26
+ export { Banner, bannerStyles, } from "./components/Banner";
26
27
  export { LogoIcon } from "./components/LogoIcon";
27
28
  export { Button, buttonStyles, useButton, } from "./components/Button";
28
29
  export { TabBar, tabBarStyles, } from "./components/TabBar";
@@ -1,8 +1,11 @@
1
+ /** Dark-only top highlight, fill-tinted lift, and a 0.5px edge beneath the lift. */
2
+ export declare function tintedControl(color: string, lightEdge: string): string;
1
3
  export declare const shadowVars: import("purse-styles").VariableGroup<{
2
4
  readonly control: {
3
5
  readonly default: `0 .5px 1px #00000018, 0 0 4px -1px #0001, 0 0 0 .5px ${string}`;
4
6
  readonly ":root[data-theme=\"dark\"]": "inset 0 .5px rgb(255 255 255 / 0.09), 0 1px .5px #00000018, 0 0 3px -1px #0005, 0 0 0 .5px rgb(0 0 0 / 0.8)";
5
7
  };
8
+ readonly selectedControl: string;
6
9
  readonly subtle: `rgba(var(--${string}), 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, var(--${string})) 0px 1px 1px -0.5px, rgba(0, 0, 0, var(--${string})) 0px 3px 3px -1.5px`;
7
10
  readonly medium: `rgba(var(--${string}), 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, var(--${string})) 0px 1px 1px -0.5px, rgba(0, 0, 0, var(--${string})) 0px 3px 3px -1.5px, rgba(0, 0, 0, var(--${string})) 0px 6px 6px -3px`;
8
11
  readonly strong: `rgba(var(--${string}), 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, var(--${string})) 0px 1px 1px -0.5px, rgba(0, 0, 0, var(--${string})) 0px 3px 3px -1.5px, rgba(0, 0, 0, var(--${string})) 0px 6px 6px -3px, rgba(0, 0, 0, calc(var(--${string}) * 0.67)) 0px 12px 12px -6px, rgba(0, 0, 0, calc(var(--${string}) * 0.67)) 0px 24px 24px -12px`;
@@ -13,6 +13,10 @@ const shadowParams = defineVars({
13
13
  });
14
14
  const rgb = shadowParams.foregroundRgb;
15
15
  const blur = shadowParams.blurOpacity;
16
+ /** Dark-only top highlight, fill-tinted lift, and a 0.5px edge beneath the lift. */
17
+ export function tintedControl(color, lightEdge) {
18
+ return `inset 0 .5px light-dark(transparent, rgb(255 255 255 / 0.15)), 0 1px 2px light-dark(oklch(from ${color} l c h / 0.35), oklch(from ${color} l c h / 0.18)), 0 3px 8px -3px light-dark(oklch(from ${color} l c h / 0.6), oklch(from ${color} l c h / 0.3)), 0 0 0 .5px light-dark(${lightEdge}, oklch(from ${color} 0.18 calc(c * 0.5) h))`;
19
+ }
16
20
  export const shadowVars = defineVars({
17
21
  control: {
18
22
  // 0.5px edge listed last, so it paints beneath the drop: light reads as a
@@ -20,6 +24,7 @@ export const shadowVars = defineVars({
20
24
  default: `0 .5px 1px #00000018, 0 0 4px -1px #0001, 0 0 0 .5px ${colors.grayAlpha[6]}`,
21
25
  [DARK_THEME]: `inset 0 .5px rgb(255 255 255 / 0.09), 0 1px .5px #00000018, 0 0 3px -1px #0005, 0 0 0 .5px rgb(0 0 0 / 0.8)`,
22
26
  },
27
+ selectedControl: tintedControl(colors.accent[9], colors.accentAlpha[6]),
23
28
  subtle: `rgba(${rgb}, 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, ${blur}) 0px 1px 1px -0.5px, rgba(0, 0, 0, ${blur}) 0px 3px 3px -1.5px`,
24
29
  medium: `rgba(${rgb}, 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, ${blur}) 0px 1px 1px -0.5px, rgba(0, 0, 0, ${blur}) 0px 3px 3px -1.5px, rgba(0, 0, 0, ${blur}) 0px 6px 6px -3px`,
25
30
  strong: `rgba(${rgb}, 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, ${blur}) 0px 1px 1px -0.5px, rgba(0, 0, 0, ${blur}) 0px 3px 3px -1.5px, rgba(0, 0, 0, ${blur}) 0px 6px 6px -3px, rgba(0, 0, 0, calc(${blur} * 0.67)) 0px 12px 12px -6px, rgba(0, 0, 0, calc(${blur} * 0.67)) 0px 24px 24px -12px`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanishqkancharla/maui",
3
- "version": "0.0.61",
3
+ "version": "0.0.63",
4
4
  "description": "Maui design system",
5
5
  "type": "module",
6
6
  "main": "./dist/maui.js",
@@ -5,10 +5,8 @@ description: Conventions for installing, updating, and consuming the Maui design
5
5
 
6
6
  # Maui
7
7
 
8
- Read the reference for the work you are doing:
9
-
10
8
  - When installing or updating the Maui package, read [installing_maui.md](references/installing_maui.md).
11
- - When designing, implementing, or reviewing UI with Maui, read [design_conventions.md](references/design_conventions.md), then open every matching component, pattern, or app reference below.
9
+ - For Maui UI work, read [design_conventions.md](references/design_conventions.md) and all matching references below.
12
10
 
13
11
  Use Maui components. When one can't do what you need, build from the matching `react-aria-components` element with that component's exported styles (`buttonStyles`, `listBoxStyles`, …).
14
12
 
@@ -24,6 +22,7 @@ Use Maui components. When one can't do what you need, build from the matching `r
24
22
  | ---------------- | -------------------------------------------------------------------------------------------- |
25
23
  | Avatar | [avatar.md](references/components/avatar.md) |
26
24
  | Badge | [badge.md](references/components/badge.md) |
25
+ | Banner | [banner.md](references/components/banner.md) |
27
26
  | Buttons | [buttons.md](references/components/buttons.md) (`Button`, `Overlay`, `Dialog`) |
28
27
  | Tab bar | [tab-bar.md](references/components/tab-bar.md) |
29
28
  | Drawer | [drawer.md](references/components/drawer.md) |
@@ -0,0 +1,42 @@
1
+ # Banner
2
+
3
+ Gallery: `/components/banner`. Import `Banner` from `"maui"`.
4
+
5
+ Persistent in-flow message. Use `layout="inline"` (default) for one sentence,
6
+ even when it wraps. Always use `layout="stacked"` for multiple sentences or a
7
+ title with supporting text. Choose layout explicitly; Banner does not parse copy.
8
+
9
+ ```tsx
10
+ <Banner
11
+ tone="info"
12
+ actions={<Button variant="quiet" variantColor="blue" onPress={reload}>Reload</Button>}
13
+ >
14
+ A new version is available.
15
+ </Banner>
16
+
17
+ <Banner
18
+ tone="warning"
19
+ layout="stacked"
20
+ title="Your connection was interrupted."
21
+ actions={<Button variant="quiet" variantColor="amber" onPress={reconnect}>Reconnect</Button>}
22
+ >
23
+ Changes are saved on this device. Reconnect to sync.
24
+ </Banner>
25
+ ```
26
+
27
+ - `tone`: `neutral` (default), `info`, `success`, `warning`, `danger`.
28
+ - Compose `actions` with quiet buttons in the matching palette: gray, blue, green,
29
+ amber, red respectively. Banner does not restyle supplied actions. Stacked and
30
+ wrapped actions align right.
31
+ - `title` and children accept React nodes. `icon` defaults to the tone's icon;
32
+ supply a decorative SVG or `false` to hide it. Icons match the first line height.
33
+ Icon-to-text gap and outer padding share spacing step 3 (6px; 8px at large scale).
34
+ - `onDismiss` adds a close button; caller owns visibility. `dismissLabel` defaults
35
+ to `"Dismiss notification"`. No timer or automatic dismissal.
36
+ - No automatic announcements. Set `role="status"` for live updates or
37
+ `role="alert"` for urgent failures; tone does not determine urgency.
38
+
39
+ ## Styles
40
+
41
+ **Banner** — `bannerStyles`: `root({ tone })`, `icon`, `body({ layout })`, `content`,
42
+ `title`, `description`, `actions`, `dismiss`. Plain div parts; icon is decorative.
@@ -1,5 +1,6 @@
1
1
  import { Avatar } from "../../components/Avatar"
2
2
  import { Badge } from "../../components/Badge"
3
+ import { Banner } from "../../components/Banner"
3
4
  import { Button } from "../../components/Button"
4
5
  import { Checkbox } from "../../components/Checkbox"
5
6
  import { Code, Kbd } from "../../components/Code"
@@ -337,6 +338,21 @@ export const catalog: CatalogComponent[] = [
337
338
  html: true,
338
339
  attributes: [],
339
340
  },
341
+ {
342
+ name: "Banner",
343
+ info: "Persistent in-flow message. Inline or stacked actions; caller owns dismissal. Icons match the first line height.",
344
+ html: true,
345
+ attributes: [
346
+ { name: "tone", values: ["neutral", "info", "success", "warning", "danger"] },
347
+ { name: "layout", values: ["inline", "stacked"] },
348
+ { name: "title" },
349
+ { name: "icon" },
350
+ { name: "actions" },
351
+ { name: "onDismiss" },
352
+ { name: "dismissLabel" },
353
+ { name: "role", values: ["status", "alert"] },
354
+ ],
355
+ },
340
356
  {
341
357
  name: "Avatar",
342
358
  info: "Initials avatar from a name.",
@@ -569,6 +585,7 @@ export const catalog: CatalogComponent[] = [
569
585
  export const previewScope: Record<string, unknown> = {
570
586
  Avatar,
571
587
  Badge,
588
+ Banner,
572
589
  Button,
573
590
  Checkbox,
574
591
  Code,
@@ -3,10 +3,21 @@ import { evaluateJsx } from "./evaluate"
3
3
 
4
4
  describe("evaluateJsx", () => {
5
5
  test("evaluates Flex justifyContent and background", () => {
6
- const result = evaluateJsx(`<Flex row alignItems="center" justifyContent="between" background="element" p={4} radius="lg">
6
+ const result =
7
+ evaluateJsx(`<Flex row alignItems="center" justifyContent="between" background="element" p={4} radius="lg">
7
8
  <Text>Left</Text>
8
9
  <Text>Right</Text>
9
10
  </Flex>`)
10
11
  expect(result.ok).toBe(true)
11
12
  })
13
+
14
+ test("evaluates Banner layouts with composed actions", () => {
15
+ for (const layout of ["inline", "stacked"]) {
16
+ const result =
17
+ evaluateJsx(`<Banner layout="${layout}" tone="warning" title="Connection interrupted" actions={<Button>Reconnect</Button>}>
18
+ Changes are saved on this device.
19
+ </Banner>`)
20
+ expect(result.ok).toBe(true)
21
+ }
22
+ })
12
23
  })
@@ -0,0 +1,52 @@
1
+ import { renderToStaticMarkup } from "react-dom/server"
2
+ import { PurseProvider } from "purse-styles"
3
+ import type { ReactNode } from "react"
4
+ import { afterAll, beforeAll, expect, it, vi } from "vitest"
5
+ import { Banner } from "./Banner"
6
+
7
+ beforeAll(() => {
8
+ vi.stubGlobal("document", { createElement: () => ({}) })
9
+ })
10
+
11
+ afterAll(() => vi.unstubAllGlobals())
12
+
13
+ function render(children: ReactNode) {
14
+ return renderToStaticMarkup(<PurseProvider>{children}</PurseProvider>)
15
+ }
16
+
17
+ it("keeps tone separate from announcement urgency and makes the icon optional", () => {
18
+ const quiet = render(<Banner tone="danger">Sync failed.</Banner>)
19
+ expect(quiet).toContain("Sync failed.")
20
+ expect(quiet).not.toMatch(/role="(?:alert|status|banner)"/)
21
+ expect(quiet).not.toContain("<button")
22
+ expect(quiet).toContain('aria-hidden="true"')
23
+
24
+ for (const role of ["status", "alert"] as const) {
25
+ const announced = render(
26
+ <Banner role={role} icon={false}>
27
+ Sync failed.
28
+ </Banner>,
29
+ )
30
+ expect(announced).toContain(`role="${role}"`)
31
+ expect(announced).not.toContain("<svg")
32
+ }
33
+ })
34
+
35
+ it("preserves composed content, actions, and custom icons in both layouts", () => {
36
+ for (const layout of ["inline", "stacked"] as const) {
37
+ const html = render(
38
+ <Banner
39
+ layout={layout}
40
+ title="Connection interrupted"
41
+ icon={<svg data-custom-icon="bell" />}
42
+ actions={<a href="/connection">Reconnect</a>}
43
+ >
44
+ Your <strong>local changes</strong> are safe.
45
+ </Banner>,
46
+ )
47
+ expect(html).toContain("Connection interrupted")
48
+ expect(html).toContain("<strong>local changes</strong>")
49
+ expect(html).toContain('<a href="/connection">Reconnect</a>')
50
+ expect(html).toContain('data-custom-icon="bell"')
51
+ }
52
+ })
@@ -0,0 +1,184 @@
1
+ import type { ComponentPropsWithoutRef, ReactNode } from "react"
2
+ import { style, useStyles } from "purse-styles"
3
+ import { Button } from "./Button"
4
+ import { Text } from "./Text"
5
+ import { CheckCircle } from "../icons/CheckCircle"
6
+ import { Close } from "../icons/Close"
7
+ import { CircleX } from "../icons/CircleX"
8
+ import { Information } from "../icons/Information"
9
+ import { WarningTriangle } from "../icons/WarningTriangle"
10
+ import { colors } from "../tokens/colors"
11
+ import { radius } from "../tokens/radius"
12
+ import { controlSize } from "../tokens/sizing"
13
+ import { spacing } from "../tokens/spacing"
14
+ import { text } from "../tokens/text"
15
+ import { cls } from "../utils/cls"
16
+ import { memoize } from "../utils/memoize"
17
+
18
+ export type BannerTone = "neutral" | "info" | "success" | "warning" | "danger"
19
+ export type BannerLayout = "inline" | "stacked"
20
+
21
+ export type BannerProps = Omit<ComponentPropsWithoutRef<"div">, "title"> & {
22
+ tone?: BannerTone
23
+ /** Use stacked for multi-sentence messages or a title with supporting text. */
24
+ layout?: BannerLayout
25
+ title?: ReactNode
26
+ /** A decorative icon. Defaults to the tone's icon; false hides it. */
27
+ icon?: ReactNode | false
28
+ actions?: ReactNode
29
+ /** Adds a close button. The caller owns visibility. */
30
+ onDismiss?: () => void
31
+ dismissLabel?: string
32
+ }
33
+
34
+ const palettes = {
35
+ neutral: "gray",
36
+ info: "blue",
37
+ success: "green",
38
+ warning: "amber",
39
+ danger: "red",
40
+ } as const
41
+
42
+ const icons = {
43
+ neutral: Information,
44
+ info: Information,
45
+ success: CheckCircle,
46
+ warning: WarningTriangle,
47
+ danger: CircleX,
48
+ } as const
49
+
50
+ const root = memoize(({ tone = "neutral" }: { tone?: BannerTone } = {}) =>
51
+ style(text({ size: "sm" }), radius.lg, spacing.padding({ all: 3 }), {
52
+ display: "flex",
53
+ alignItems: "flex-start",
54
+ gap: spacing.value(3),
55
+ minWidth: 0,
56
+ backgroundColor: colors[palettes[tone]][2],
57
+ border: `1px solid ${colors[palettes[tone]][6]}`,
58
+ // Give text and icons the same inset beside standard-height controls.
59
+ "--banner-text-inset": `max(0px, calc((${controlSize.height} - 1lh) / 2))`,
60
+ "--banner-icon-color": colors[palettes[tone]][11],
61
+ }),
62
+ )
63
+
64
+ const icon = style({
65
+ flex: "0 0 auto",
66
+ width: "1lh",
67
+ height: "1lh",
68
+ marginBlockStart: "var(--banner-text-inset)",
69
+ color: "var(--banner-icon-color)",
70
+ display: "flex",
71
+ alignItems: "center",
72
+ justifyContent: "center",
73
+ "& > svg": { width: "100%", height: "100%" },
74
+ })
75
+
76
+ const body = memoize(({ layout = "inline" }: { layout?: BannerLayout } = {}) =>
77
+ style({
78
+ display: "flex",
79
+ flex: "1 1 auto",
80
+ minWidth: 0,
81
+ flexDirection: layout === "stacked" ? "column" : "row",
82
+ flexWrap: "wrap",
83
+ alignItems: "flex-start",
84
+ gap: spacing.value(4),
85
+ "& > :first-child": {
86
+ flex: layout === "inline" ? "1 1 12rem" : "0 1 auto",
87
+ },
88
+ }),
89
+ )
90
+
91
+ const content = style({
92
+ minWidth: 0,
93
+ maxWidth: "100%",
94
+ paddingBlock: "var(--banner-text-inset)",
95
+ overflowWrap: "anywhere",
96
+ })
97
+
98
+ const title = style({ display: "block" })
99
+ const description = style({ display: "block" })
100
+ const actions = style({
101
+ display: "flex",
102
+ alignItems: "center",
103
+ justifyContent: "flex-end",
104
+ flexWrap: "wrap",
105
+ gap: spacing.value(4),
106
+ maxWidth: "100%",
107
+ marginInlineStart: "auto",
108
+ })
109
+ const dismiss = style({ flexShrink: 0 })
110
+
111
+ /** Plain div parts; icon is decorative. Layout changes action placement, not semantics. */
112
+ export const bannerStyles = {
113
+ root,
114
+ icon,
115
+ body,
116
+ content,
117
+ title,
118
+ description,
119
+ actions,
120
+ dismiss,
121
+ } as const
122
+
123
+ /** Persistent in-flow message. Set role="status" or role="alert" explicitly to announce it. */
124
+ export function Banner({
125
+ tone = "neutral",
126
+ layout = "inline",
127
+ title,
128
+ children,
129
+ icon,
130
+ actions,
131
+ onDismiss,
132
+ dismissLabel = "Dismiss notification",
133
+ className,
134
+ ...props
135
+ }: BannerProps) {
136
+ const rootClassName = useStyles(bannerStyles.root({ tone }))
137
+ const iconClassName = useStyles(bannerStyles.icon)
138
+ const bodyClassName = useStyles(bannerStyles.body({ layout }))
139
+ const contentClassName = useStyles(bannerStyles.content)
140
+ const titleClassName = useStyles(bannerStyles.title)
141
+ const descriptionClassName = useStyles(
142
+ bannerStyles.description,
143
+ title != null && style({ color: colors.gray[11] }),
144
+ )
145
+ const actionsClassName = useStyles(bannerStyles.actions)
146
+ const dismissClassName = useStyles(bannerStyles.dismiss)
147
+ const Icon = icons[tone]
148
+
149
+ return (
150
+ <div {...props} className={cls(rootClassName, className)}>
151
+ {icon !== false && (
152
+ <div className={iconClassName} aria-hidden="true">
153
+ {icon ?? <Icon />}
154
+ </div>
155
+ )}
156
+ <div className={bodyClassName}>
157
+ <div className={contentClassName}>
158
+ {title != null && (
159
+ <Text size="sm" fontWeight={500} className={titleClassName}>
160
+ {title}
161
+ </Text>
162
+ )}
163
+ {children != null && (
164
+ <div className={descriptionClassName}>{children}</div>
165
+ )}
166
+ </div>
167
+ {actions != null && actions !== false && (
168
+ <div className={actionsClassName}>{actions}</div>
169
+ )}
170
+ </div>
171
+ {onDismiss && (
172
+ <Button
173
+ variant="quiet"
174
+ variantColor={palettes[tone]}
175
+ aria-label={dismissLabel}
176
+ onPress={onDismiss}
177
+ className={dismissClassName}
178
+ >
179
+ <Close size="sm" />
180
+ </Button>
181
+ )}
182
+ </div>
183
+ )
184
+ }
@@ -15,7 +15,7 @@ import {
15
15
  import { colors, type ColorName, type ColorScale } from "../tokens/colors"
16
16
  import { focusRing } from "../tokens/focusRing"
17
17
  import { radius } from "../tokens/radius"
18
- import { shadowVars } from "../tokens/shadow"
18
+ import { shadowVars, tintedControl } from "../tokens/shadow"
19
19
  import { controlSize, iconSizeValues } from "../tokens/sizing"
20
20
  import { spacing } from "../tokens/spacing"
21
21
  import { text } from "../tokens/text"
@@ -193,11 +193,6 @@ function fillShade(color: string, lightnessOffset: number, alpha = 1) {
193
193
  return `oklch(from ${color} calc(l - ${lightnessOffset}) c h / ${alpha})`
194
194
  }
195
195
 
196
- /** Dark-only top highlight, a fill-tinted lift (half strength in dark mode), and an edge painted beneath the lift: the fill's alpha step 6 in light mode, a near-black tint of the fill in dark mode. */
197
- function primaryButtonShadow(color: string, lightEdge: string) {
198
- return `inset 0 .5px light-dark(transparent, rgb(255 255 255 / 0.15)), 0 1px 2px light-dark(${fillShade(color, 0, 0.35)}, ${fillShade(color, 0, 0.18)}), 0 3px 8px -3px light-dark(${fillShade(color, 0, 0.6)}, ${fillShade(color, 0, 0.3)}), 0 0 0 .5px light-dark(${lightEdge}, oklch(from ${color} 0.18 calc(c * 0.5) h))`
199
- }
200
-
201
196
  /**
202
197
  * Diagonal from a slightly darker, more saturated, warmer-rotated fill to the
203
198
  * fill. The offsets are the OKLCH delta between Campsite's #FB432C → #FF591E.
@@ -211,7 +206,7 @@ const primaryHoverSheen = `linear-gradient(to bottom, rgb(255 255 255 / 0.12), r
211
206
 
212
207
  const primaryClass = memoize(
213
208
  (fill: string, textColor: string, lightEdge: string) => {
214
- const primaryShadow = primaryButtonShadow(fill, lightEdge)
209
+ const primaryShadow = tintedControl(fill, lightEdge)
215
210
  const gradient = primaryButtonGradient(fill)
216
211
  return style(buttonBaseClass, focusRing("&:focus-visible", primaryShadow), {
217
212
  color: textColor,
@@ -44,7 +44,7 @@ const check = style(motion.standard("opacity", "transform"), {
44
44
  height: checkboxSize.checkmark,
45
45
  paddingTop: "0.5px",
46
46
  paddingLeft: "0.5px",
47
- fill: colors.gray[3],
47
+ fill: "white",
48
48
  opacity: 0,
49
49
  transform: "scale(0)",
50
50
  })
@@ -52,6 +52,10 @@ const check = style(motion.standard("opacity", "transform"), {
52
52
  const root = style(
53
53
  flex({ alignItems: "center", gap: 3 }),
54
54
  focusRing(`&[data-focus-visible] ${box}`, shadowVars.control),
55
+ focusRing(
56
+ `&[data-selected][data-focus-visible] ${box}`,
57
+ shadowVars.selectedControl,
58
+ ),
55
59
  {
56
60
  position: "relative",
57
61
  width: "fit-content",
@@ -62,6 +66,7 @@ const root = style(
62
66
  },
63
67
  [`&[data-selected] ${box}`]: {
64
68
  backgroundColor: colors.accent[9],
69
+ boxShadow: shadowVars.selectedControl,
65
70
  },
66
71
  [`&[data-selected][data-hovered] ${box}`]: {
67
72
  backgroundColor: surfaceWash(
@@ -40,13 +40,17 @@ const dot = style(radius.circle, motion.standard("transform"), {
40
40
  left: "4px",
41
41
  width: "6px",
42
42
  height: "6px",
43
- backgroundColor: colors.gray[3],
43
+ backgroundColor: "white",
44
44
  transform: "scale(0)",
45
45
  })
46
46
 
47
47
  const root = style(
48
48
  flex({ alignItems: "center", gap: 3 }),
49
49
  focusRing(`&[data-focus-visible] ${circle}`, shadowVars.control),
50
+ focusRing(
51
+ `&[data-selected][data-focus-visible] ${circle}`,
52
+ shadowVars.selectedControl,
53
+ ),
50
54
  {
51
55
  position: "relative",
52
56
  width: "fit-content",
@@ -55,6 +59,7 @@ const root = style(
55
59
  },
56
60
  [`&[data-selected] ${circle}`]: {
57
61
  backgroundColor: colors.accent[9],
62
+ boxShadow: shadowVars.selectedControl,
58
63
  },
59
64
  [`&[data-selected][data-hovered] ${circle}`]: {
60
65
  backgroundColor: surfaceWash(
@@ -58,6 +58,10 @@ const thumb = style(radius.circle, motion.standard("transform"), {
58
58
  const root = style(
59
59
  flex({ alignItems: "center", gap: 3 }),
60
60
  focusRing(`&[data-focus-visible] ${track}`, shadowVars.control),
61
+ focusRing(
62
+ `&[data-selected][data-focus-visible] ${track}`,
63
+ shadowVars.selectedControl,
64
+ ),
61
65
  {
62
66
  position: "relative",
63
67
  width: "fit-content",
@@ -67,6 +71,7 @@ const root = style(
67
71
  },
68
72
  [`&[data-selected] ${track}`]: {
69
73
  backgroundColor: colors.accent[9],
74
+ boxShadow: shadowVars.selectedControl,
70
75
  },
71
76
  [`&[data-selected][data-hovered] ${track}`]: {
72
77
  backgroundColor: switchOnHover,
package/src/maui.ts CHANGED
@@ -58,6 +58,13 @@ export { visuallyHidden } from "./tokens/visuallyHidden"
58
58
  // Components
59
59
  export { Avatar, avatarStyles } from "./components/Avatar"
60
60
  export { Badge, badgeStyles } from "./components/Badge"
61
+ export {
62
+ Banner,
63
+ bannerStyles,
64
+ type BannerProps,
65
+ type BannerTone,
66
+ type BannerLayout,
67
+ } from "./components/Banner"
61
68
  export { LogoIcon, type LogoIconProps } from "./components/LogoIcon"
62
69
  export {
63
70
  Button,
@@ -0,0 +1,214 @@
1
+ import { useState } from "react"
2
+ import {
3
+ Banner,
4
+ type BannerLayout,
5
+ type BannerTone,
6
+ } from "../components/Banner"
7
+ import { Button } from "../components/Button"
8
+ import { Code } from "../components/Code"
9
+ import { CodeBlock } from "../components/CodeBlock"
10
+ import { Prose } from "../components/Prose"
11
+ import { Text } from "../components/Text"
12
+ import { H2, H3, P } from "../components/Typography"
13
+ import { Flex } from "../components/Utils"
14
+ import { Bell } from "../icons/Bell"
15
+ import { Panel } from "./Panel"
16
+
17
+ const tones: { tone: BannerTone; message: string; layout?: BannerLayout }[] = [
18
+ { tone: "neutral", message: "Maintenance is scheduled for Sunday." },
19
+ { tone: "info", message: "A new version is available." },
20
+ { tone: "success", message: "All changes are synced." },
21
+ { tone: "warning", message: "You’re approaching your storage limit." },
22
+ {
23
+ tone: "danger",
24
+ message: "Sync is unavailable. Your changes are safe on this device.",
25
+ layout: "stacked",
26
+ },
27
+ ]
28
+
29
+ export function BannerPage() {
30
+ const [showUpdate, setShowUpdate] = useState(true)
31
+ const [updated, setUpdated] = useState(false)
32
+ const [connected, setConnected] = useState(false)
33
+
34
+ return (
35
+ <Prose style={{ paddingBottom: "32px" }}>
36
+ <H2>Banner</H2>
37
+ <P>
38
+ Persistent messages that stay in the page flow. Use inline for a single
39
+ sentence, even when it wraps. Always use stacked for multi-sentence
40
+ messages or a title with supporting text. Icons match the first line
41
+ height, including at the large platform scale.
42
+ </P>
43
+
44
+ <H3>Inline</H3>
45
+ <Panel data-example="inline">
46
+ <Flex column gap={8}>
47
+ {showUpdate ? (
48
+ <Banner
49
+ tone={updated ? "success" : "info"}
50
+ onDismiss={() => setShowUpdate(false)}
51
+ dismissLabel="Dismiss update notice"
52
+ actions={
53
+ !updated && (
54
+ <Button
55
+ variant="quiet"
56
+ variantColor="blue"
57
+ onPress={() => setUpdated(true)}
58
+ >
59
+ Reload
60
+ </Button>
61
+ )
62
+ }
63
+ >
64
+ {updated
65
+ ? "You’re on the latest version."
66
+ : "A new version is available."}
67
+ </Banner>
68
+ ) : (
69
+ <Text size="sm" color="lowContrast">
70
+ Update notice dismissed.
71
+ </Text>
72
+ )}
73
+ <Button
74
+ variant="quiet"
75
+ onPress={() => {
76
+ setShowUpdate(true)
77
+ setUpdated(false)
78
+ }}
79
+ >
80
+ Reset update example
81
+ </Button>
82
+ </Flex>
83
+ </Panel>
84
+
85
+ <H3>Stacked</H3>
86
+ <Panel data-example="stacked">
87
+ <Flex column gap={8}>
88
+ <Banner
89
+ layout="stacked"
90
+ tone={connected ? "success" : "warning"}
91
+ title={
92
+ connected
93
+ ? "You’re connected."
94
+ : "Your connection was interrupted."
95
+ }
96
+ actions={
97
+ !connected && (
98
+ <Button
99
+ variant="quiet"
100
+ variantColor="amber"
101
+ onPress={() => setConnected(true)}
102
+ >
103
+ Reconnect
104
+ </Button>
105
+ )
106
+ }
107
+ >
108
+ {connected
109
+ ? "All changes are synced."
110
+ : "Changes are saved on this device. Reconnect to sync."}
111
+ </Banner>
112
+ <Button variant="quiet" onPress={() => setConnected(false)}>
113
+ Reset connection example
114
+ </Button>
115
+ </Flex>
116
+ </Panel>
117
+
118
+ <H3>Tones</H3>
119
+ <Panel data-example="tones">
120
+ <Flex column gap={6}>
121
+ {tones.map(({ tone, message, layout }) => (
122
+ <Banner key={tone} tone={tone} layout={layout}>
123
+ {message}
124
+ </Banner>
125
+ ))}
126
+ </Flex>
127
+ </Panel>
128
+
129
+ <H3>Narrow layouts</H3>
130
+ <P>
131
+ Messages wrap without truncation, with stacked actions on the right.
132
+ </P>
133
+ <Panel data-example="narrow">
134
+ <Flex column gap={6} style={{ maxWidth: "320px" }}>
135
+ <Banner
136
+ layout="stacked"
137
+ tone="info"
138
+ actions={
139
+ <Button
140
+ variant="quiet"
141
+ variantColor="blue"
142
+ onPress={() => setUpdated(true)}
143
+ >
144
+ {updated ? "Up to date" : "Reload"}
145
+ </Button>
146
+ }
147
+ >
148
+ A new version is available. Reload to get the latest improvements.
149
+ </Banner>
150
+ <Banner
151
+ layout="stacked"
152
+ tone="warning"
153
+ title="Your connection was interrupted."
154
+ actions={
155
+ <Button
156
+ variant="quiet"
157
+ variantColor="amber"
158
+ onPress={() => setConnected(true)}
159
+ >
160
+ {connected ? "Connected" : "Reconnect"}
161
+ </Button>
162
+ }
163
+ >
164
+ Changes are saved on this device. Reconnect to sync.
165
+ </Banner>
166
+ </Flex>
167
+ </Panel>
168
+
169
+ <H3>Custom icon and no icon</H3>
170
+ <Panel data-example="icons">
171
+ <Flex column gap={6}>
172
+ <Banner icon={<Bell />}>
173
+ Notifications are paused until tomorrow.
174
+ </Banner>
175
+ <Banner icon={false}>Maintenance is scheduled for Sunday.</Banner>
176
+ </Flex>
177
+ </Panel>
178
+
179
+ <H3>Usage</H3>
180
+ <CodeBlock lang="tsx">{`<Banner
181
+ tone="info"
182
+ layout="inline"
183
+ actions={<Button variant="quiet" variantColor="blue" onPress={reload}>Reload</Button>}
184
+ onDismiss={() => setVisible(false)}
185
+ >
186
+ A new version is available.
187
+ </Banner>
188
+
189
+ <Banner
190
+ tone="warning"
191
+ layout="stacked"
192
+ title="Your connection was interrupted."
193
+ actions={<Button variant="quiet" variantColor="amber" onPress={reconnect}>Reconnect</Button>}
194
+ >
195
+ Changes are saved on this device. Reconnect to sync.
196
+ </Banner>`}</CodeBlock>
197
+ <P>
198
+ Neutral tone and inline layout are the defaults. Actions accept composed
199
+ buttons or links; use quiet buttons with a matching palette: gray for
200
+ neutral, blue for info, green for success, amber for warning, and red
201
+ for danger. Stacked and wrapped actions align to the right. The caller
202
+ controls visibility through <Code>onDismiss</Code>; there is no timer or
203
+ automatic dismissal. Customize the close button’s accessible name with{" "}
204
+ <Code>dismissLabel</Code>.
205
+ </P>
206
+ <P>
207
+ Banners do not announce themselves by default. Use{" "}
208
+ <Code>role="status"</Code> for live updates or <Code>role="alert"</Code>{" "}
209
+ for urgent failures. Tone alone does not determine urgency. Style parts
210
+ are available through <Code>bannerStyles</Code>.
211
+ </P>
212
+ </Prose>
213
+ )
214
+ }
@@ -32,6 +32,7 @@ import { ReadmePage } from "./ReadmePage"
32
32
  import { ActivityIndicatorPage } from "./ActivityIndicatorPage"
33
33
  import { AvatarPage } from "./AvatarPage"
34
34
  import { BadgePage } from "./BadgePage"
35
+ import { BannerPage } from "./BannerPage"
35
36
  import { BackgroundColorTokenPage } from "./BackgroundColorTokenPage"
36
37
  import { BordersTokenPage } from "./BordersTokenPage"
37
38
  import { ButtonsPage } from "./ButtonsPage"
@@ -143,6 +144,7 @@ const navigation: NavEntry[] = [
143
144
  children: [
144
145
  { label: "Avatar", path: "/components/avatar", page: AvatarPage },
145
146
  { label: "Badge", path: "/components/badge", page: BadgePage },
147
+ { label: "Banner", path: "/components/banner", page: BannerPage },
146
148
  { label: "Buttons", path: "/components/buttons", page: ButtonsPage },
147
149
  { label: "Dialog", path: "/components/dialog", page: DialogPage },
148
150
  { label: "Tab bar", path: "/components/tab-bar", page: TabBarPage },
@@ -16,6 +16,11 @@ const shadowParams = defineVars({
16
16
  const rgb = shadowParams.foregroundRgb
17
17
  const blur = shadowParams.blurOpacity
18
18
 
19
+ /** Dark-only top highlight, fill-tinted lift, and a 0.5px edge beneath the lift. */
20
+ export function tintedControl(color: string, lightEdge: string) {
21
+ return `inset 0 .5px light-dark(transparent, rgb(255 255 255 / 0.15)), 0 1px 2px light-dark(oklch(from ${color} l c h / 0.35), oklch(from ${color} l c h / 0.18)), 0 3px 8px -3px light-dark(oklch(from ${color} l c h / 0.6), oklch(from ${color} l c h / 0.3)), 0 0 0 .5px light-dark(${lightEdge}, oklch(from ${color} 0.18 calc(c * 0.5) h))`
22
+ }
23
+
19
24
  export const shadowVars = defineVars({
20
25
  control: {
21
26
  // 0.5px edge listed last, so it paints beneath the drop: light reads as a
@@ -23,6 +28,7 @@ export const shadowVars = defineVars({
23
28
  default: `0 .5px 1px #00000018, 0 0 4px -1px #0001, 0 0 0 .5px ${colors.grayAlpha[6]}`,
24
29
  [DARK_THEME]: `inset 0 .5px rgb(255 255 255 / 0.09), 0 1px .5px #00000018, 0 0 3px -1px #0005, 0 0 0 .5px rgb(0 0 0 / 0.8)`,
25
30
  },
31
+ selectedControl: tintedControl(colors.accent[9], colors.accentAlpha[6]),
26
32
  subtle: `rgba(${rgb}, 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, ${blur}) 0px 1px 1px -0.5px, rgba(0, 0, 0, ${blur}) 0px 3px 3px -1.5px`,
27
33
  medium: `rgba(${rgb}, 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, ${blur}) 0px 1px 1px -0.5px, rgba(0, 0, 0, ${blur}) 0px 3px 3px -1.5px, rgba(0, 0, 0, ${blur}) 0px 6px 6px -3px`,
28
34
  strong: `rgba(${rgb}, 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, ${blur}) 0px 1px 1px -0.5px, rgba(0, 0, 0, ${blur}) 0px 3px 3px -1.5px, rgba(0, 0, 0, ${blur}) 0px 6px 6px -3px, rgba(0, 0, 0, calc(${blur} * 0.67)) 0px 12px 12px -6px, rgba(0, 0, 0, calc(${blur} * 0.67)) 0px 24px 24px -12px`,