From 7f599726e7533078106171b8ea6115cc82f02938 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Mon, 15 Jun 2026 22:38:43 +0700 Subject: [PATCH 1/4] Toolbar: density as an explicit axis Add an optional density prop to Toolbar that overrides the variant-derived default. When omitted, density still falls back to DENSITY_BY_VARIANT[variant] (floater -> compact, topbar/bottom-bar -> comfortable), so existing usage is unchanged. This unblocks Figma's flat fixed + compact bar: a topbar forced to compact (24px) without the floater surface. Adds a FixedCompact story for that case. --- .../propel/src/components/toolbar/index.tsx | 29 ++++++++++++------- .../components/toolbar/toolbar.stories.tsx | 22 +++++++++++++- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/packages/propel/src/components/toolbar/index.tsx b/packages/propel/src/components/toolbar/index.tsx index 2bc7ac6f..26af4048 100644 --- a/packages/propel/src/components/toolbar/index.tsx +++ b/packages/propel/src/components/toolbar/index.tsx @@ -42,10 +42,12 @@ const toolbarVariants = cva("flex w-fit items-center gap-2 p-1.5 text-secondary" export type ToolbarVariant = NonNullable["variant"]>; -// The density of a toolbar follows its placement: the `floater` is compact (24px -// hit targets), while `topbar`/`bottom-bar` are comfortable (28px). It's derived -// from `variant` and shared with the child controls (buttons, toggles, dropdown -// trigger) through context so they size themselves to match the root. +// The density of a toolbar defaults from its placement: the `floater` is compact +// (24px hit targets), while `topbar`/`bottom-bar` are comfortable (28px). The +// resolved density is shared with the child controls (buttons, toggles, dropdown +// trigger) through context so they size themselves to match the root. Callers can +// pass `density` on the root to override the placement default — e.g. Figma's flat +// "fixed + compact" bar is a `topbar` forced to `compact`. type ToolbarDensity = "compact" | "comfortable"; const DENSITY_BY_VARIANT: Record = { @@ -61,12 +63,19 @@ export type ToolbarProps = Omit< "className" | "render" | "style" > & { /** - * Where the toolbar is placed, which controls its surface and density. `floater` - * is a self-contained card with a border + shadow that hovers over content and - * packs its controls tightly (24px). `topbar` and `bottom-bar` are flat, sit flush - * inside an existing bar, and use the roomier 28px density. + * Where the toolbar is placed, which controls its surface and the default density. + * `floater` is a self-contained card with a border + shadow that hovers over content + * and packs its controls tightly (24px). `topbar` and `bottom-bar` are flat, sit flush + * inside an existing bar, and default to the roomier 28px density. */ variant: ToolbarVariant; + /** + * Hit-target density of the toolbar's controls. Defaults to the placement default + * (`floater` -> `compact`, `topbar`/`bottom-bar` -> `comfortable`); pass it to override + * the default and decouple density from `variant` — e.g. a flat `topbar` forced to + * `compact` for Figma's "fixed + compact" bar. + */ + density?: ToolbarDensity; }; /** @@ -76,9 +85,9 @@ export type ToolbarProps = Omit< * carries `role="toolbar"`. Compose it from `ToolbarGroup`, `ToolbarButton`, * `ToolbarToggle`, `ToolbarSeparator` and `ToolbarDropdown`. */ -export function Toolbar({ variant, ...props }: ToolbarProps) { +export function Toolbar({ variant, density, ...props }: ToolbarProps) { return ( - + ); diff --git a/packages/propel/src/components/toolbar/toolbar.stories.tsx b/packages/propel/src/components/toolbar/toolbar.stories.tsx index bc2b5031..40aceb6b 100644 --- a/packages/propel/src/components/toolbar/toolbar.stories.tsx +++ b/packages/propel/src/components/toolbar/toolbar.stories.tsx @@ -159,7 +159,11 @@ type Story = StoryObj; /** The default `floater`: a self-contained card with a border + shadow. */ export const Default: Story = {}; -/** The three placements: a floating card, a flat topbar, and a flat bottom bar. */ +/** + * Each placement at its default density: the floating card is `compact` (24px), the + * flat topbar and bottom bar are `comfortable` (28px). Density follows `variant` + * unless overridden (see `FixedCompact`). + */ export const Variants: Story = { parameters: { controls: { disable: true } }, render: () => ( @@ -171,6 +175,22 @@ export const Variants: Story = { ), }; +/** + * Density is its own axis: pass `density` to decouple it from `variant`. Here a flat + * `topbar` is forced to `compact` (24px) for Figma's "fixed + compact" bar — a + * non-floating surface at the tight floater density. Without the override a `topbar` + * would default to `comfortable` (28px). + */ +export const FixedCompact: Story = { + args: { variant: "topbar", density: "compact" }, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/ioN74zM1xMGbcPemsxs4J1/Global-components?node-id=2842-3905", + }, + }, +}; + /** * Because `ToolbarDropdown` composes propel's `Dropdown`, a toolbar menu can hold * richer rows than the old `items[]` config allowed: per-row leading icons, a From 76fe3bb086815addb08b7d5f5fa0e252c0c921ee Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Mon, 15 Jun 2026 23:29:17 +0700 Subject: [PATCH 2/4] test(toolbar): exercise density override in a play test Add a DensityOverride story that renders a flat topbar forced to density="compact" and asserts its controls measure 24px (size-6) rather than the comfortable 28px default -> the override is now covered. --- .../components/toolbar/toolbar.stories.tsx | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/propel/src/components/toolbar/toolbar.stories.tsx b/packages/propel/src/components/toolbar/toolbar.stories.tsx index 40aceb6b..b84a0fc8 100644 --- a/packages/propel/src/components/toolbar/toolbar.stories.tsx +++ b/packages/propel/src/components/toolbar/toolbar.stories.tsx @@ -191,6 +191,30 @@ export const FixedCompact: Story = { }, }; +/** + * Density override check that runs in the browser: a flat `topbar` defaults to + * `comfortable` (28px), but passing `density="compact"` decouples density from + * `variant` and shrinks its controls to 24px. Tagged out of the sidebar/docs/manifest + * — it's a test, not a designer- or agent-facing example — but still runs under the + * default `test` tag. + */ +export const DensityOverride: Story = { + tags: ["!dev", "!autodocs", "!manifest"], + render: () => ( + + + + + + ), + play: async ({ canvas }) => { + // A flat topbar would default to comfortable (28px); the compact override wins. + const bold = canvas.getByRole("button", { name: "Bold" }); + await expect(bold).toHaveClass("size-6"); + await expect(getComputedStyle(bold).height).toBe("24px"); + }, +}; + /** * Because `ToolbarDropdown` composes propel's `Dropdown`, a toolbar menu can hold * richer rows than the old `items[]` config allowed: per-row leading icons, a From 73e3e672440aee0a6c7a5dc472843f026ebb678a Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Tue, 16 Jun 2026 15:55:32 +0700 Subject: [PATCH 3/4] Toolbar: elevation + density as orthogonal axes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the placement-named `variant` (floater/topbar/bottom-bar) with two axes named for what they actually express: - `elevation`: `raised` draws its own card (border + shadow) so it hovers over content; `flat` draws no surface and sits flush in a host bar. topbar and bottom-bar rendered identically, so they fold into one `flat`. - `density`: `compact` (24px hit targets) vs `comfortable` (28px). Both are required props, so the variant->density derivation table and the `density ?? ...` fallback are gone. "Fixed + compact" stops being a special case — it's just `elevation="flat" density="compact"`. Placement is the consumer's job, not the primitive's, so it no longer lives on the component. Also drop the now-dead `defaultVariants` on the internal item/dropdown recipes: density always arrives through context. Breaking: `variant` is removed. floater -> elevation="raised" density="compact"; topbar/bottom-bar -> elevation="flat" density="comfortable". --- .../propel/src/components/toolbar/index.tsx | 81 +++++++------------ .../components/toolbar/toolbar.stories.tsx | 64 +++++++++------ .../propel/src/patterns/comment.stories.tsx | 4 +- 3 files changed, 70 insertions(+), 79 deletions(-) diff --git a/packages/propel/src/components/toolbar/index.tsx b/packages/propel/src/components/toolbar/index.tsx index 26af4048..d573102e 100644 --- a/packages/propel/src/components/toolbar/index.tsx +++ b/packages/propel/src/components/toolbar/index.tsx @@ -17,45 +17,36 @@ import { type DropdownSeparatorProps, } from "../dropdown/index"; -// The Figma "Toolbar" component has a single `variant` axis describing where the -// toolbar is placed (Figma: Floater / Pages - Topbar / Comments bottom bar). The -// variants differ in two ways: their *surface* and their *density*. Only the -// floater is a self-contained surface — a white `surface-1` card with a subtle -// border, `radius/lg` corners and an `Overlay-100` shadow — so it can hover over -// content. Topbar and bottom-bar sit flush inside an existing bar, so they're flat -// (no surface, no shadow) and are therefore visually identical to each other. +// A toolbar is described by two orthogonal axes — `elevation` and `density` — named +// for what they actually express rather than where the toolbar is placed (placement +// is the consumer's job, not a primitive's). The Figma "Toolbar" component models +// these as placements (Floater / Pages - Topbar / Comments bottom bar), but those +// collapse to combinations of the two axes here. // -// Density differs too: the floater packs controls tighter (24px hit targets, a -// 14px chevron) while the topbar/bottom-bar are roomier (28px hit targets, a 16px -// chevron). Every placement shares the root `p-1.5` (6px) padding and `gap-2` (8px) -// item gap; clusters inside `ToolbarGroup`/`ToolbarToggleGroup` keep a tight -// `gap-0.5` (2px). +// `elevation`: whether the toolbar draws its own surface. `raised` is a +// self-contained card — a white `surface-1` fill with a subtle border, `radius/lg` +// corners and an `Overlay-100` shadow — so it can hover over content (the Figma +// floater). `flat` draws no surface and sits flush inside an existing bar (the Figma +// topbar / bottom-bar, which were visually identical). const toolbarVariants = cva("flex w-fit items-center gap-2 p-1.5 text-secondary", { variants: { - variant: { - floater: surfaceVariants({ elevation: "raised", radius: "lg" }), - topbar: "", - "bottom-bar": "", + elevation: { + raised: surfaceVariants({ elevation: "raised", radius: "lg" }), + flat: "", }, }, }); -export type ToolbarVariant = NonNullable["variant"]>; +export type ToolbarElevation = NonNullable["elevation"]>; -// The density of a toolbar defaults from its placement: the `floater` is compact -// (24px hit targets), while `topbar`/`bottom-bar` are comfortable (28px). The -// resolved density is shared with the child controls (buttons, toggles, dropdown -// trigger) through context so they size themselves to match the root. Callers can -// pass `density` on the root to override the placement default — e.g. Figma's flat -// "fixed + compact" bar is a `topbar` forced to `compact`. +// `density`: how tightly the controls pack. `compact` is 24px hit targets (a 14px +// chevron), `comfortable` is 28px (a 16px chevron). It's shared with the child +// controls (buttons, toggles, dropdown trigger) through context so they size +// themselves to match the root. Both axes share the root `p-1.5` (6px) padding and +// `gap-2` (8px) item gap; clusters inside `ToolbarGroup`/`ToolbarToggleGroup` keep a +// tight `gap-0.5` (2px). type ToolbarDensity = "compact" | "comfortable"; -const DENSITY_BY_VARIANT: Record = { - floater: "compact", - topbar: "comfortable", - "bottom-bar": "comfortable", -}; - const ToolbarDensityContext = React.createContext("compact"); export type ToolbarProps = Omit< @@ -63,19 +54,16 @@ export type ToolbarProps = Omit< "className" | "render" | "style" > & { /** - * Where the toolbar is placed, which controls its surface and the default density. - * `floater` is a self-contained card with a border + shadow that hovers over content - * and packs its controls tightly (24px). `topbar` and `bottom-bar` are flat, sit flush - * inside an existing bar, and default to the roomier 28px density. + * Whether the toolbar draws its own surface. `raised` is a self-contained card + * with a border + shadow that hovers over content; `flat` draws no surface and + * sits flush inside an existing bar. Independent of `density`. */ - variant: ToolbarVariant; + elevation: ToolbarElevation; /** - * Hit-target density of the toolbar's controls. Defaults to the placement default - * (`floater` -> `compact`, `topbar`/`bottom-bar` -> `comfortable`); pass it to override - * the default and decouple density from `variant` — e.g. a flat `topbar` forced to - * `compact` for Figma's "fixed + compact" bar. + * How tightly the controls pack. `compact` is 24px hit targets, `comfortable` is + * 28px. Independent of `elevation`, so a flat bar can still be compact. */ - density?: ToolbarDensity; + density: ToolbarDensity; }; /** @@ -85,10 +73,10 @@ export type ToolbarProps = Omit< * carries `role="toolbar"`. Compose it from `ToolbarGroup`, `ToolbarButton`, * `ToolbarToggle`, `ToolbarSeparator` and `ToolbarDropdown`. */ -export function Toolbar({ variant, density, ...props }: ToolbarProps) { +export function Toolbar({ elevation, density, ...props }: ToolbarProps) { return ( - - + + ); } @@ -130,9 +118,6 @@ const itemVariants = cva( comfortable: "size-7 [&_svg]:size-4", }, }, - defaultVariants: { - density: "compact", - }, }, ); @@ -235,9 +220,6 @@ const dropdownTriggerVariants = cva( comfortable: "h-7", }, }, - defaultVariants: { - density: "compact", - }, }, ); @@ -248,9 +230,6 @@ const dropdownChevronVariants = cva("shrink-0 text-icon-secondary", { comfortable: "size-4", }, }, - defaultVariants: { - density: "compact", - }, }); /** diff --git a/packages/propel/src/components/toolbar/toolbar.stories.tsx b/packages/propel/src/components/toolbar/toolbar.stories.tsx index b84a0fc8..900c8305 100644 --- a/packages/propel/src/components/toolbar/toolbar.stories.tsx +++ b/packages/propel/src/components/toolbar/toolbar.stories.tsx @@ -143,7 +143,7 @@ const meta = { ToolbarDropdownItem, ToolbarDropdownSeparator, }, - args: { variant: "floater" }, + args: { elevation: "raised", density: "compact" }, render: (args) => , parameters: { design: { @@ -156,33 +156,46 @@ const meta = { export default meta; type Story = StoryObj; -/** The default `floater`: a self-contained card with a border + shadow. */ +/** The default: a `raised`, `compact` floater — a self-contained card with a border + shadow. */ export const Default: Story = {}; /** - * Each placement at its default density: the floating card is `compact` (24px), the - * flat topbar and bottom bar are `comfortable` (28px). Density follows `variant` - * unless overridden (see `FixedCompact`). + * The `elevation` axis: `raised` draws its own card (border + shadow) so it can hover + * over content; `flat` draws no surface and sits flush inside an existing bar. + * Independent of `density` — both rows keep the story's current density. */ -export const Variants: Story = { - parameters: { controls: { disable: true } }, - render: () => ( +export const Elevations: Story = { + argTypes: { elevation: { control: false } }, + render: (args) => ( +
+ + +
+ ), +}; + +/** + * The `density` axis: `compact` packs the controls to 24px hit targets, `comfortable` + * gives them 28px. Independent of `elevation` — both rows keep the story's current + * elevation. + */ +export const Densities: Story = { + argTypes: { density: { control: false } }, + render: (args) => (
- - - + +
), }; /** - * Density is its own axis: pass `density` to decouple it from `variant`. Here a flat - * `topbar` is forced to `compact` (24px) for Figma's "fixed + compact" bar — a - * non-floating surface at the tight floater density. Without the override a `topbar` - * would default to `comfortable` (28px). + * `elevation` and `density` are orthogonal: a `flat` bar can still be `compact`. This + * is Figma's "fixed + compact" bar — a non-floating surface at the tight 24px density + * the raised floater also uses. */ -export const FixedCompact: Story = { - args: { variant: "topbar", density: "compact" }, +export const FlatCompact: Story = { + args: { elevation: "flat", density: "compact" }, parameters: { design: { type: "figma", @@ -192,23 +205,22 @@ export const FixedCompact: Story = { }; /** - * Density override check that runs in the browser: a flat `topbar` defaults to - * `comfortable` (28px), but passing `density="compact"` decouples density from - * `variant` and shrinks its controls to 24px. Tagged out of the sidebar/docs/manifest - * — it's a test, not a designer- or agent-facing example — but still runs under the - * default `test` tag. + * Density wiring check that runs in the browser: `density` drives the child controls' + * size through context, independent of `elevation`, so a `flat` + `compact` toolbar + * renders 24px controls. Tagged out of the sidebar/docs/manifest — it's a test, not a + * designer- or agent-facing example — but still runs under the default `test` tag. */ -export const DensityOverride: Story = { +export const DensityDrivesControlSize: Story = { tags: ["!dev", "!autodocs", "!manifest"], render: () => ( - + ), play: async ({ canvas }) => { - // A flat topbar would default to comfortable (28px); the compact override wins. + // density="compact" sizes the controls to 24px regardless of the flat elevation. const bold = canvas.getByRole("button", { name: "Bold" }); await expect(bold).toHaveClass("size-6"); await expect(getComputedStyle(bold).height).toBe("24px"); @@ -284,7 +296,7 @@ export const Behavior: Story = { export const KeyboardRovingFocus: Story = { tags: ["!dev", "!autodocs", "!manifest"], render: () => ( - + diff --git a/packages/propel/src/patterns/comment.stories.tsx b/packages/propel/src/patterns/comment.stories.tsx index 920b822b..ec5ab011 100644 --- a/packages/propel/src/patterns/comment.stories.tsx +++ b/packages/propel/src/patterns/comment.stories.tsx @@ -70,7 +70,7 @@ const bodyVariants = cva( // editor; here they just compose propel's Toolbar parts. function FormattingToolbar() { return ( - + @@ -248,7 +248,7 @@ const RECIPE_SOURCE = `function CommentComposer() { className="min-h-10 w-full resize-none bg-transparent p-3 text-14 leading-snug text-primary outline-none placeholder:text-placeholder" />
- + From 463635dadd264df99c03dc057631383c8dc8ae76 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Wed, 17 Jun 2026 00:21:55 +0700 Subject: [PATCH 4/4] Toolbar: export ToolbarDensity and fix comment recipe density Export ToolbarDensity for parity with ToolbarElevation so consumers can reference the allowed density values. The bottom-bar formatting toolbar (Figma 2842-3905) is the fixed + compact bar, but it was rendered with density="comfortable". Set it to compact in both the live story and the recipe source snippet. --- packages/propel/src/components/toolbar/index.tsx | 2 +- packages/propel/src/patterns/comment.stories.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/propel/src/components/toolbar/index.tsx b/packages/propel/src/components/toolbar/index.tsx index d573102e..c3dbf594 100644 --- a/packages/propel/src/components/toolbar/index.tsx +++ b/packages/propel/src/components/toolbar/index.tsx @@ -45,7 +45,7 @@ export type ToolbarElevation = NonNullable[ // themselves to match the root. Both axes share the root `p-1.5` (6px) padding and // `gap-2` (8px) item gap; clusters inside `ToolbarGroup`/`ToolbarToggleGroup` keep a // tight `gap-0.5` (2px). -type ToolbarDensity = "compact" | "comfortable"; +export type ToolbarDensity = "compact" | "comfortable"; const ToolbarDensityContext = React.createContext("compact"); diff --git a/packages/propel/src/patterns/comment.stories.tsx b/packages/propel/src/patterns/comment.stories.tsx index ec5ab011..9b884520 100644 --- a/packages/propel/src/patterns/comment.stories.tsx +++ b/packages/propel/src/patterns/comment.stories.tsx @@ -70,7 +70,7 @@ const bodyVariants = cva( // editor; here they just compose propel's Toolbar parts. function FormattingToolbar() { return ( - + @@ -248,7 +248,7 @@ const RECIPE_SOURCE = `function CommentComposer() { className="min-h-10 w-full resize-none bg-transparent p-3 text-14 leading-snug text-primary outline-none placeholder:text-placeholder" />
- +