Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions design-system/apps/design-lab/src/pages/ReferencePatterns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,13 @@ export function NestedMenuPattern() {
const [position, setPosition] = useState<{ x: number; y: number } | undefined>();
const [lastAction, setLastAction] = useState("");
const [pinned, setPinned] = useState(false);
const [scrolling, setScrolling] = useState(false);
return <Card appearance="subtle" padding="md" gap="md" data-openbitfun-pattern="nested-menu" onContextMenu={event => { event.preventDefault(); setPosition({ x: event.clientX, y: event.clientY }); setOpen(true); }}>
<PageHeader level={3} size="sm" title={t("patterns.menu.title")} description={t("patterns.menu.hint")} />
<Button ref={anchorRef} aria-haspopup="menu" aria-expanded={open} leadingIcon={<Icon name="more" />} onClick={() => { setPosition(undefined); setOpen(!open); }}>{t("patterns.menu.open")}</Button>
<Field label={t("detail.option.scrolling")} orientation="horizontal">
<Switch checked={scrolling} onCheckedChange={setScrolling} />
</Field>
<MenuPopover aria-label={t("patterns.menu.title")} open={open} onClose={() => setOpen(false)} anchorRef={position ? undefined : anchorRef} position={position} items={[
{ id: "open", label: t("patterns.actions.openFiles"), icon: <Icon name="files" />, shortcut: <KeyHint>Ctrl O</KeyHint>, onSelect: () => setLastAction(t("patterns.actions.openFiles")) },
{ id: "tools", label: t("patterns.navigation.tools"), icon: <Icon name="extension" />, submenu: [
Expand All @@ -185,6 +189,7 @@ export function NestedMenuPattern() {
] },
{ id: "separator", label: "", separator: true },
{ id: "pin", label: t("patterns.menu.pin"), role: "menuitemcheckbox", checked: pinned, icon: <Icon name={pinned ? "check-line" : "pin"} />, onSelect: () => { setPinned(!pinned); setLastAction(t("patterns.menu.pin")); } },
...(scrolling ? Array.from({ length: 18 }, (_, index) => ({ id: `file-${index}`, label: `${t("patterns.actions.openFiles")} — workspace-${index + 1}`, icon: <Icon name="files" />, onSelect: () => setLastAction(`workspace-${index + 1}`) })) : []),
]} />
<p className="pattern-feedback" role="status">{lastAction ? t("patterns.menu.lastAction", { action: lastAction }) : t("patterns.menu.keyboard")}</p>
</Card>;
Expand Down
6 changes: 5 additions & 1 deletion design-system/packages/design-tokens/src/system.tokens.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,10 @@
"letterSpacing": { "$value": "{letterSpacing.normal}" }
}
},
"actionRow": {
"$description": "Single-line action leading: 16px at the default 13px type size, scaling with user typography.",
"lineHeight": { "$type": "number", "$value": 1.2307692307692308 }
},
"fieldHelper": {
"$description": "Field helper leading: 16px at the default 11px type size, scaling with user typography.",
"lineHeight": { "$type": "number", "$value": 1.4545454545454546 }
Expand Down Expand Up @@ -724,7 +728,7 @@
"surfacePadding": { "$value": "{space.2}" },
"contentGap": { "$value": "{space.4}" },
"sectionGap": { "$value": "{space.1}" },
"headingHeight": { "$value": "22px" },
"headingHeight": { "$value": "24px" },
"headingPaddingInline": { "$value": "{space.2}" },
"headingGap": { "$value": "{space.5}" },
"headingActionSize": { "$value": "22px" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ test("NavigationPanel tokens preserve the grouped sidebar composition contract",
const systemDocument = await readSource("system.tokens.json");

assert.equal(tokens["layout.navigationPanel.inlineSize"], "216px");
assert.equal(tokens["layout.navigationPanel.headingHeight"], "22px");
assert.equal(tokens["layout.navigationPanel.headingHeight"], "24px");
assert.equal(tokens["layout.navigationPanel.itemHeight"], "30px");
assert.equal(tokens["layout.navigationPanel.itemIconSize"], "14px");
assert.equal(tokens["layout.navigationPanel.footerHeight"], "40px");
Expand Down
8 changes: 8 additions & 0 deletions design-system/packages/theme-openbitfun/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,11 @@ component token is absent, and keeps explicit component overrides intact.
- `color.codeChange.added` (`#1aa73e`) and `color.codeChange.removed` (`#ec221f`) also anchor success and danger emphasis. Warning emphasis uses `#ff8c00`; information uses the existing creative-action blue (`#2e7eff`). These clear hues share light tints instead of separate per-component palettes.
- `color.status.*.emphasis` colors icons and short emphasis. `content` derives a readable shade from that anchor for text; `surface` and `border` derive 10% and 30% tints. High-contrast themes may strengthen text contrast without changing the emphasis anchors.
- Status source tokens retain their `color-mix()` references. The theme build resolves these mixes to concrete hex/RGBA values so CSS, plugins, and renderer payloads consume the same palette without relying on renderer-specific CSS color support.

`color.content.caption` distinguishes low-emphasis menu/navigation group headings
from body descriptions and input placeholders. Light mode supplies final black
40%; dark and high-contrast modes reuse readable muted content. An imported
appearance's explicit old muted color is retained when the caption token is absent.
The built-in light Appearance preserves the public neutral action content (80%)
in both root and chrome; generic palette projection previously reduced product
menu labels to secondary text (60%). Explicit imported action colors still win.
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"thumbHover": { "$type": "color", "$value": "{color.action.neutral.surfacePressed}" }
},
"content": {
"caption": { "$description": "Menu and navigation group captions; carries final opacity without attenuating primary text again.", "$type": "color", "$value": "{color.content.muted}" },
"primary": { "$type": "color", "$value": "{ref.color.neutral.200}" },
"secondary": { "$type": "color", "$value": "{ref.color.neutral.350}" },
"muted": { "$type": "color", "$value": "{ref.color.neutral.550}" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"panel": { "$type": "color", "$value": "{ref.color.gray.900}" }
},
"content": {
"caption": { "$description": "Menu and navigation group captions; carries final opacity without attenuating primary text again.", "$type": "color", "$value": "{color.content.muted}" },
"primary": { "$type": "color", "$value": "{ref.color.gray.0}" },
"secondary": { "$type": "color", "$value": "{ref.color.gray.50}" },
"muted": { "$type": "color", "$value": "{ref.color.gray.150}" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"panel": { "$type": "color", "$value": "{ref.color.gray.0}" }
},
"content": {
"caption": { "$description": "Menu and navigation group captions; carries final opacity without attenuating primary text again.", "$type": "color", "$value": "{color.content.muted}" },
"primary": { "$type": "color", "$value": "{ref.color.gray.1000}" },
"secondary": { "$type": "color", "$value": "{ref.color.gray.800}" },
"muted": { "$type": "color", "$value": "{ref.color.gray.700}" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"thumbHover": { "$type": "color", "$value": "{color.action.neutral.surfacePressed}" }
},
"content": {
"caption": { "$description": "Menu and navigation group captions; carries final opacity without attenuating primary text again.", "$type": "color", "$value": "rgba(0, 0, 0, 0.40)" },
"primary": { "$type": "color", "$value": "rgba(0, 0, 0, 0.80)" },
"secondary": { "$type": "color", "$value": "rgba(0, 0, 0, 0.60)" },
"muted": { "$type": "color", "$value": "{ref.color.neutral.650}" },
Expand Down Expand Up @@ -254,7 +255,7 @@
"xl": { "$value": "0 12px 24px rgba(16, 26, 39, 0.11)" },
"raised": { "$value": "{shadow.sm}" },
"composer": { "$value": "0 2px 6px rgba(0, 0, 0, 0.08)" },
"menu": { "$value": "0 4px 10px rgba(0, 0, 0, 0.12)" },
"menu": { "$value": "0 4px 20px rgba(0, 0, 0, 0.12)" },
"overlay": { "$value": "0 4px 20px rgba(0, 0, 0, 0.12)" },
"accentGlow": {
"$description": "Layered emphasis glow derived from the active accent.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ test("default modes preserve the built-in Appearance anchor values", () => {
assert.equal(themes.light["color.status.warning.surface"], "rgba(255, 140, 0, 0.1)");
assert.equal(themes.light["shadow.base"], "0 4px 8px rgba(16, 26, 39, 0.07)");
assert.equal(themes.light["shadow.composer"], "0 2px 6px rgba(0, 0, 0, 0.08)");
assert.equal(themes.light["shadow.menu"], "0 4px 10px rgba(0, 0, 0, 0.12)");
assert.equal(themes.light["shadow.menu"], "0 4px 20px rgba(0, 0, 0, 0.12)");
assert.equal(themes.light["shadow.overlay"], "0 4px 20px rgba(0, 0, 0, 0.12)");
assert.equal(themes.light["opacity.disabled"], 0.55);
assert.equal(themes.dark["color.surface.canvas"], "#0e0e10");
Expand Down
8 changes: 8 additions & 0 deletions design-system/packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,3 +405,11 @@ FormSection descriptions use primary content with 16px leading at 13px. Both
leading roles scale with user typography. FieldGroup uses the form group tint,
retaining its existing row padding, dividers, and radius. The Patterns form
specimen shows both orientations and long values over a tinted container.

Menus keep contiguous 30px rows with no additional list or heading-to-item gap;
separators own their 8px vertical margins. Their keyboard focus indicator is
inset so scrolling does not clip it or require extra permanent padding.
ActionItem hover and pressed surfaces use the semantic neutral hover fill;
pressed text remains semibold. Menu and navigation captions consume the final
caption color directly, avoiding a second opacity multiplier. The nested-menu
Pattern includes a scrolling toggle for keyboard and submenu verification.
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export const actionItemMeta = {
"color.status.danger.surface",
"control.height.sm",
"type.label.md.fontSize",
"type.actionRow.lineHeight",
"color.action.neutral.surfaceHover",
"type.label.selected.fontWeight",
"type.meta.fontSize",
"radius.base",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@

.root:not([data-disabled="true"]):hover,
.root:not([data-disabled="true"]):has(.trigger[data-openbitfun-preview-state="hover"]) {
background: var(--openbitfun-color-action-neutral-surface);
background: var(--openbitfun-color-action-neutral-surface-hover);
}

.root:not([data-disabled="true"]):has(.trigger:active),
.root:not([data-disabled="true"]):has(.trigger[data-openbitfun-preview-state="active"]) {
background: var(--openbitfun-color-action-neutral-surface-pressed);
background: var(--openbitfun-color-action-neutral-surface-hover);
}

.root:not([data-disabled="true"]):has(.trigger:active) .label,
Expand Down Expand Up @@ -98,7 +98,7 @@
font-family: var(--openbitfun-type-label-md-font-family);
font-size: var(--openbitfun-type-label-md-font-size);
font-weight: var(--openbitfun-type-label-md-font-weight);
line-height: var(--openbitfun-type-body-sm-line-height);
line-height: var(--openbitfun-type-action-row-line-height);
letter-spacing: var(--openbitfun-type-label-md-letter-spacing);
white-space: nowrap;
}
Expand Down
4 changes: 3 additions & 1 deletion design-system/packages/ui/src/components/Menu/Menu.meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ export const menuMeta = {
"color.surface.panel",
"color.border.subtle",
"color.content.muted",
"color.content.caption",
"color.action.neutral.content",
"color.action.neutral.contentDisabled",
"color.action.neutral.surface",
"color.action.neutral.surfacePressed",
"color.action.neutral.surfaceHover",
"color.selection.surface",
"color.focus.ring",
"overlay.menu.inlineSize",
"overlay.menu.maxBlockSize",
Expand Down
49 changes: 34 additions & 15 deletions design-system/packages/ui/src/components/Menu/Menu.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,11 @@
display: flex;
min-inline-size: 0;
flex-direction: column;
gap: calc(var(--openbitfun-space-1) / 2);
/* Keep item focus rings inside the scroll viewport on every edge. */
padding: var(--openbitfun-focus-width);
gap: 0;
padding: 0;
/* Balance the end inset with the start inset after the viewport scrollbar gap. */
padding-inline-end: calc(
var(--openbitfun-overlay-menu-surface-padding)
+ var(--openbitfun-focus-width)
- var(--openbitfun-overlay-menu-scrollbar-gap)
);
}
Expand All @@ -60,11 +58,15 @@
}

.section {
gap: var(--openbitfun-overlay-menu-section-gap);
gap: 0;
}

.section + .section {
margin-block-start: var(--openbitfun-overlay-menu-section-gap);
}

.items {
gap: calc(var(--openbitfun-space-1) / 2);
gap: 0;
}

.heading {
Expand All @@ -80,11 +82,11 @@
overflow: hidden;
flex: 1 1 auto;
min-inline-size: 0;
color: var(--openbitfun-color-content-muted);
color: var(--openbitfun-color-content-caption);
font-family: var(--openbitfun-type-meta-font-family);
font-size: var(--openbitfun-type-meta-font-size);
font-weight: var(--openbitfun-type-meta-font-weight);
line-height: var(--openbitfun-type-body-sm-line-height);
font-weight: var(--openbitfun-type-label-xs-font-weight);
line-height: var(--openbitfun-type-label-xs-line-height);
letter-spacing: var(--openbitfun-type-meta-letter-spacing);
white-space: nowrap;
}
Expand Down Expand Up @@ -112,9 +114,15 @@
gap: var(--openbitfun-overlay-menu-item-gap);
}

.item:has(> [data-openbitfun-part="trigger"][aria-checked="true"]) {
.item:has(> [data-openbitfun-part="trigger"]:focus-visible) {
/* Keep the entire focus indicator within a scrolled or clipped item. */
box-shadow: inset 0 0 0 var(--openbitfun-focus-width) var(--openbitfun-color-focus-ring);
}

.item:has(> [data-openbitfun-part="trigger"][aria-checked="true"]),
.item:has(> [data-openbitfun-part="trigger"][aria-expanded="true"]) {
color: var(--openbitfun-color-action-neutral-content);
background: var(--openbitfun-color-action-neutral-surface);
background: var(--openbitfun-color-selection-surface);
}

.item [data-openbitfun-part="leading"] {
Expand All @@ -126,14 +134,25 @@
flex: 0 0 auto;
inline-size: 100%;
block-size: var(--openbitfun-border-width-default);
margin-block: calc(
var(--openbitfun-overlay-menu-section-gap)
- calc(var(--openbitfun-space-1) / 2)
);
margin-block: var(--openbitfun-overlay-menu-section-gap);
background: var(--openbitfun-color-border-subtle);
}

@media (prefers-contrast: more) {
.headingLabel {
color: var(--openbitfun-color-content-muted);
}
}

@media (forced-colors: active) {
.headingLabel {
color: CanvasText;
}

.item:has(> [data-openbitfun-part="trigger"]:focus-visible) {
box-shadow: inset 0 0 0 var(--openbitfun-focus-width) Highlight;
}

.root {
border-color: CanvasText;
box-shadow: none;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,5 @@
}
.popup[data-state="entered"] { opacity: 1; translate: 0 0; pointer-events: auto; }
.popup[data-state="exiting"] { transition-duration: 100ms; }
.popup [aria-expanded="true"] { background: var(--openbitfun-color-action-neutral-surface); }
@media (prefers-reduced-motion: reduce) { .popup { translate: none; transition: none; } }
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ export const navigationPanelMeta = {
"color.border.subtle",
"color.content.primary",
"color.content.muted",
"color.content.caption",
"color.action.neutral.content",
"color.action.neutral.contentDisabled",
"color.action.neutral.surface",
"color.action.neutral.surfacePressed",
"color.action.neutral.surfaceHover",
"color.selection.surface",
"color.focus.ring",
"type.label.selected.fontWeight",
"layout.navigationPanel.inlineSize",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,7 @@
overflow: hidden;
flex: 1 1 auto;
min-inline-size: 0;
/* Keep group captions at 40% semantic ink without borrowing disabled-state color. */
color: color-mix(in srgb, var(--openbitfun-color-content-primary) 40%, transparent);
color: var(--openbitfun-color-content-caption);
font-family: var(--openbitfun-type-label-xs-font-family);
font-size: var(--openbitfun-type-label-xs-font-size);
font-weight: var(--openbitfun-type-label-xs-font-weight);
Expand Down
2 changes: 1 addition & 1 deletion design-system/packages/ui/tests/action-item.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ test("ActionItem styles share action state and focus tokens", async () => {
const styles = await readFile(new URL("../dist/styles.css", import.meta.url), "utf8");

assert.match(styles, /--openbitfun-color-action-neutral-surface/);
assert.match(styles, /--openbitfun-color-action-neutral-surface-pressed/);
assert.match(styles, /--openbitfun-color-action-neutral-surface-hover/);
assert.match(styles, /--openbitfun-color-action-neutral-content-disabled/);
assert.match(styles, /--openbitfun-color-focus-ring/);
assert.match(styles, /--openbitfun-control-height-sm/);
Expand Down
15 changes: 8 additions & 7 deletions design-system/packages/ui/tests/menu.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -74,22 +74,23 @@ test("Menu styling uses only public surface, geometry, action, and scrollbar tok

assert.match(styles, /--openbitfun-overlay-menu-inline-size/);
assert.match(styles, /--openbitfun-overlay-menu-item-height/);
assert.match(styles, /\.list\s*\{[^}]*gap: calc\(var\(--openbitfun-space-1\) \/ 2\)/);
assert.match(styles, /\.items\s*\{[^}]*gap: calc\(var\(--openbitfun-space-1\) \/ 2\)/);
assert.match(styles, /\.separator\s*\{[^}]*margin-block: calc\(/);
assert.match(styles, /\.list\s*\{[^}]*gap: 0/);
assert.match(styles, /\.items\s*\{[^}]*gap: 0/);
assert.match(styles, /\.separator\s*\{[^}]*margin-block: var\(--openbitfun-overlay-menu-section-gap\)/);
assert.match(styles, /--openbitfun-color-surface-panel/);
assert.match(styles, /--openbitfun-shadow-menu/);
assert.match(styles, /--openbitfun-overlay-menu-scrollbar-gap/);
assert.doesNotMatch(styles, /#[0-9a-f]{3,8}/i);
});

test("Menu reserves space inside its scroll viewport for focus rings on all edges", async () => {
test("Menu keeps focus rings inside items without adding a permanent gutter", async () => {
const styles = await readFile(new URL("../src/components/Menu/Menu.module.css", import.meta.url), "utf8");
const scrollStyles = await readFile(new URL("../src/components/ScrollArea/ScrollArea.module.css", import.meta.url), "utf8");
const itemStyles = await readFile(new URL("../src/components/ActionItem/ActionItem.module.css", import.meta.url), "utf8");
assert.match(styles, /\.list\s*\{[^}]*padding:\s*var\(--openbitfun-focus-width\)/);
assert.match(styles, /\.list\s*\{[^}]*padding:\s*0/);
assert.match(itemStyles, /\.root:has\(\.trigger:focus-visible\)\s*\{[^}]*box-shadow:\s*0 0 0 var\(--openbitfun-focus-width\)/);
// Keep clipping and scrolling; the content, not the viewport, owns the gutter.
assert.match(styles, /\.item:has\(> \[data-openbitfun-part="trigger"\]:focus-visible\)\s*\{[^}]*box-shadow:\s*inset 0 0 0 var\(--openbitfun-focus-width\)/);
// Keep clipping and scrolling; the item owns its focus indicator.
assert.match(scrollStyles, /data-openbitfun-orientation="vertical"\]\s*\{[^}]*overflow-x:\s*hidden;[^}]*overflow-y:\s*auto/);
assert.doesNotMatch(styles, /overflow[^:]*:\s*visible/);
});
Expand All @@ -101,7 +102,7 @@ test("Menu keeps equal item insets while its scrollbar stays on the surface edge
assert.match(styles, /\.viewport\s*\{[^}]*padding-inline-end:\s*var\(--openbitfun-overlay-menu-scrollbar-gap\);[^}]*scrollbar-gutter:\s*auto/);
assert.match(
styles,
/\.list\s*\{[^}]*padding-inline-end:\s*calc\(\s*var\(--openbitfun-overlay-menu-surface-padding\)\s*\+ var\(--openbitfun-focus-width\)\s*- var\(--openbitfun-overlay-menu-scrollbar-gap\)\s*\)/,
/\.list\s*\{[^}]*padding-inline-end:\s*calc\(\s*var\(--openbitfun-overlay-menu-surface-padding\)\s*- var\(--openbitfun-overlay-menu-scrollbar-gap\)\s*\)/,
);
assert.doesNotMatch(styles, /scrollbar-gutter:\s*stable/);
});
2 changes: 1 addition & 1 deletion design-system/packages/ui/tests/navigation-panel.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ test("NavigationPanel separates group captions, destinations, and the selected d
);
const heading = styles.match(/\.headingLabel\s*\{([^}]+)\}/)?.[1];
assert.ok(heading);
assert.match(heading, /color: color-mix\(in srgb, var\(--openbitfun-color-content-primary\) 40%, transparent\)/);
assert.match(heading, /color: var\(--openbitfun-color-content-caption\)/);
assert.match(heading, /font-family: var\(--openbitfun-type-label-xs-font-family\)/);
assert.match(heading, /font-size: var\(--openbitfun-type-label-xs-font-size\)/);
assert.match(heading, /font-weight: var\(--openbitfun-type-label-xs-font-weight\)/);
Expand Down
Loading
Loading