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
12 changes: 12 additions & 0 deletions design-system/packages/design-tokens/src/system.tokens.json
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,18 @@
"overflowText": {
"fadeExtent": { "$value": "{space.4}" }
},
"empty": {
"$description": "Shared empty-state media geometry. Callers select a semantic size and do not size artwork directly.",
"gap": { "$value": "10px" },
"paddingBlock": { "$value": "{space.8}" },
"paddingInline": { "$value": "{space.4}" },
"mediaSizeSm": { "$value": "{control.icon.sizeLg}" },
"mediaSizeMd": { "$value": "{control.height.sm}" },
"mediaSizeLg": { "$value": "{control.height.md}" },
"iconSizeSm": { "$value": "{control.icon.sizeLg}" },
"iconSizeMd": { "$value": "{control.height.sm}" },
"iconSizeLg": { "$value": "{control.height.md}" }
},
"field": {
"rootGap": { "$value": "{space.2}" },
"horizontalGap": { "$value": "{space.5}" },
Expand Down
12 changes: 12 additions & 0 deletions design-system/packages/design-tokens/tests/contract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,18 @@ test("search height preserves the compact reference and follows other density sc
assert.equal(value("space.component.inline", "compact"), "10px");
});

test("Empty media geometry owns semantic artwork sizes", () => {
assert.equal(tokens["layout.empty.gap"], "10px");
assert.equal(tokens["layout.empty.paddingBlock"], "32px");
assert.equal(tokens["layout.empty.paddingInline"], "16px");
assert.equal(tokens["layout.empty.mediaSizeSm"], "24px");
assert.equal(tokens["layout.empty.mediaSizeMd"], "32px");
assert.equal(tokens["layout.empty.mediaSizeLg"], "40px");
assert.equal(tokens["layout.empty.iconSizeSm"], "24px");
assert.equal(tokens["layout.empty.iconSizeMd"], "32px");
assert.equal(tokens["layout.empty.iconSizeLg"], "40px");
});

test("Switch geometry preserves the compact reference contract", () => {
assert.equal(tokens["control.switch.trackWidth"], "28px");
assert.equal(tokens["control.switch.trackHeight"], "16px");
Expand Down
7 changes: 7 additions & 0 deletions design-system/packages/theme-openbitfun/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ IconButton, and other controls. Dark and high-contrast mappings retain their
mode-specific feedback and outline contrast. Color entries remain editable in
Design Lab's Colors catalog; Button geometry is independent of this palette.

`component.empty.media` owns the low-emphasis color for decorative Empty
artwork. It resolves to an opaque neutral in every mode because Lucide icons can
contain overlapping paths; reusing translucent disabled-content colors would
make those intersections visibly darker. The color matches muted content at 35%
over the tertiary empty-state surface, while `layout.empty.*` owns the reference
10px rhythm, 32/16px padding, and 24/32/40px media sizes.

The default Web UI appearances consume these published component values.
Branded presets and imported appearances may still supply the existing action
tokens: the Web UI inherits explicit old values only when the corresponding
Expand Down
10 changes: 9 additions & 1 deletion design-system/packages/theme-openbitfun/scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,14 @@ const highContrastDarkTokens = resolveTokens(
mergeTokenDocuments(reference, dark, highContrastDark),
);

const PUBLIC_THEME_TOKEN_PREFIXES = ["color.", "component.button.", "effect.", "opacity.", "shadow."];
const PUBLIC_THEME_TOKEN_PREFIXES = [
"color.",
"component.button.",
"component.empty.",
"effect.",
"opacity.",
"shadow.",
];
const REFERENCE_COLOR_TOKEN_PATTERN = /^ref\.color\.([a-z][a-z0-9-]*)\.(\d+)$/;

function createReferenceColorArtifacts(document, tokens) {
Expand Down Expand Up @@ -106,6 +113,7 @@ const semanticThemes = {
};

const requiredSemanticTokens = [
"component.empty.media",
"color.surface.canvas",
"color.surface.panel",
"color.surface.raised",
Expand Down
7 changes: 7 additions & 0 deletions design-system/packages/theme-openbitfun/src/dark.tokens.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@
"primaryBackgroundHover": { "$type": "color", "$value": "{color.action.primary.hover}" },
"primaryBackgroundPressed": { "$type": "color", "$value": "{color.action.primary.pressed}" },
"primaryContentDisabled": { "$type": "color", "$value": "{color.action.neutral.contentDisabled}" }
},
"empty": {
"media": {
"$description": "Opaque low-emphasis color for decorative empty-state artwork, matching muted content at 35% over the tertiary empty-state surface without alpha accumulation.",
"$type": "color",
"$value": "color-mix(in srgb, {color.content.muted} 35%, {color.surface.tertiary})"
}
}
},
"color": {
Expand Down
7 changes: 7 additions & 0 deletions design-system/packages/theme-openbitfun/src/light.tokens.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@
"primaryBackgroundHover": { "$type": "color", "$value": "rgba(0, 0, 0, 0.60)" },
"primaryBackgroundPressed": { "$type": "color", "$value": "rgba(0, 0, 0, 0.90)" },
"primaryContentDisabled": { "$type": "color", "$value": "rgba(0, 0, 0, 0.20)" }
},
"empty": {
"media": {
"$description": "Opaque low-emphasis color for decorative empty-state artwork, matching muted content at 35% over the tertiary empty-state surface without alpha accumulation.",
"$type": "color",
"$value": "color-mix(in srgb, {color.content.muted} 35%, {color.surface.tertiary})"
}
}
},
"color": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,14 @@ test("Button states have a mode-complete palette independent from shared actions
}
});

test("Empty artwork uses a mode-complete opaque component color", () => {
assert.equal(themes.light["component.empty.media"], "color-mix(in srgb, #6a6a6a 35%, #f7f7f7)");
assert.equal(themes.dark["component.empty.media"], "color-mix(in srgb, #858585 35%, #0e0e10)");
for (const values of Object.values(themes)) {
assert.match(values["component.empty.media"], /^color-mix\(in srgb, #[0-9a-f]{6} 35%, #[0-9a-f]{6}\)$/i);
}
});

test("default modes preserve the built-in Appearance anchor values", () => {
assert.equal(themes.light["color.surface.canvas"], "#fdfdfd");
assert.equal(themes.light["color.content.primary"], "rgba(0, 0, 0, 0.80)");
Expand Down Expand Up @@ -381,11 +389,15 @@ test("public theme catalog contains only semantic theme tokens for every mode",
assert.equal(themeTokenCatalog.length, Object.keys(themes.light).length);
for (const token of themeTokenCatalog) {
assert.equal(
["color.", "component.button.", "effect.", "opacity.", "shadow."].some((prefix) => token.name.startsWith(prefix)),
["color.", "component.button.", "component.empty.", "effect.", "opacity.", "shadow."].some((prefix) => token.name.startsWith(prefix)),
true,
);
assert.equal(token.name.startsWith("ref."), false);
if (token.name.startsWith("color.") || token.name.startsWith("component.button.")) {
if (
token.name.startsWith("color.")
|| token.name.startsWith("component.button.")
|| token.name.startsWith("component.empty.")
) {
assert.equal(token.type, "color");
}
assert.deepEqual(Object.keys(token.values), themeModes);
Expand Down
16 changes: 12 additions & 4 deletions design-system/packages/ui/src/components/Empty/Empty.meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,18 @@ export const emptyMeta = {
],
states: ["default", "with-title", "with-actions"],
tokens: [
"color.content.primary",
"color.content.secondary",
"component.empty.media",
"color.content.muted",
"control.height.md",
"control.height.lg",
"layout.empty.gap",
"layout.empty.paddingBlock",
"layout.empty.paddingInline",
"layout.empty.mediaSizeSm",
"layout.empty.mediaSizeMd",
"layout.empty.mediaSizeLg",
"layout.empty.iconSizeSm",
"layout.empty.iconSizeMd",
"layout.empty.iconSizeLg",
"type.label.md.fontSize",
"type.body.sm.fontSize",
],
} as const satisfies ComponentMeta;
41 changes: 25 additions & 16 deletions design-system/packages/ui/src/components/Empty/Empty.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,46 +4,55 @@
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--openbitfun-space-3);
gap: var(--openbitfun-layout-empty-gap);
min-inline-size: 0;
padding: var(--openbitfun-space-8) var(--openbitfun-space-5);
color: var(--openbitfun-color-content-secondary);
padding: var(--openbitfun-layout-empty-padding-block) var(--openbitfun-layout-empty-padding-inline);
color: var(--openbitfun-color-content-muted);
text-align: center;
}

.media {
--_empty-media-size: var(--openbitfun-layout-empty-media-size-md);
--_empty-icon-size: var(--openbitfun-layout-empty-icon-size-md);

display: inline-flex;
align-items: center;
justify-content: center;
inline-size: var(--openbitfun-control-height-lg);
block-size: var(--openbitfun-control-height-lg);
color: var(--openbitfun-color-content-muted);
inline-size: var(--_empty-media-size);
block-size: var(--_empty-media-size);
color: var(--openbitfun-component-empty-media);
}

.media[data-size="sm"] {
inline-size: var(--openbitfun-control-height-md);
block-size: var(--openbitfun-control-height-md);
--_empty-media-size: var(--openbitfun-layout-empty-media-size-sm);
--_empty-icon-size: var(--openbitfun-layout-empty-icon-size-sm);
}

.media[data-size="lg"] {
inline-size: calc(var(--openbitfun-control-height-lg) + var(--openbitfun-space-4));
block-size: calc(var(--openbitfun-control-height-lg) + var(--openbitfun-space-4));
--_empty-media-size: var(--openbitfun-layout-empty-media-size-lg);
--_empty-icon-size: var(--openbitfun-layout-empty-icon-size-lg);
}

.media > :where(svg, img),
.media > [data-openbitfun-component="icon"] {
display: block;
inline-size: var(--_empty-icon-size);
block-size: var(--_empty-icon-size);
max-inline-size: 100%;
max-block-size: 100%;
}

.media > [data-openbitfun-component="icon"] {
color: inherit;
}

.title {
color: var(--openbitfun-color-content-primary);
font-family: var(--openbitfun-type-heading-card-font-family);
font-size: var(--openbitfun-type-heading-card-font-size);
font-weight: var(--openbitfun-type-heading-card-font-weight);
line-height: var(--openbitfun-type-heading-card-line-height);
letter-spacing: var(--openbitfun-type-heading-card-letter-spacing);
color: var(--openbitfun-color-content-muted);
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-label-md-line-height);
letter-spacing: var(--openbitfun-type-label-md-letter-spacing);
}

.description {
Expand Down
2 changes: 1 addition & 1 deletion design-system/packages/ui/src/components/Empty/Empty.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const Empty = forwardRef<HTMLDivElement, EmptyProps>(function Empty({
title,
...props
}, ref) {
const media = icon ?? image ?? <Icon name="folder" size="lg" tone="muted" />;
const media = icon ?? image ?? <Icon name="folder" />;
const footer = actions ?? children;

return (
Expand Down
43 changes: 35 additions & 8 deletions design-system/packages/ui/tests/empty.test.mjs
Original file line number Diff line number Diff line change
@@ -1,21 +1,48 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import test from "node:test";
import { Empty } from "../dist/index.js";

test("Empty exposes independent media, copy, and action regions", () => {
test("Empty exposes media, copy, and action anatomy", () => {
const markup = renderToStaticMarkup(createElement(Empty, {
actions: createElement("button", null, "Create"),
description: "Create an item to get started.",
imageSize: "sm",
title: "No items",
description: "Created items will appear here.",
icon: createElement("svg", { "aria-hidden": true }),
title: "No items yet",
}));

assert.match(markup, /data-openbitfun-component="empty"/);
assert.match(markup, /data-openbitfun-part="media"/);
assert.match(markup, /data-size="sm"/);
assert.match(markup, /data-openbitfun-part="title"/);
assert.match(markup, /data-openbitfun-part="description"/);
assert.match(markup, /data-openbitfun-part="title">No items yet/);
assert.match(markup, /data-openbitfun-part="description">Created items will appear here\./);
assert.match(markup, /data-openbitfun-part="actions"/);
});

test("Empty keeps decorative media and copy on the quiet content hierarchy", async () => {
const styles = await readFile(
new URL("../src/components/Empty/Empty.module.css", import.meta.url),
"utf8",
);
const media = styles.match(/\.media\s*\{([^}]+)\}/)?.[1];
const title = styles.match(/\.title\s*\{([^}]+)\}/)?.[1];

assert.ok(media);
assert.match(media, /color: var\(--openbitfun-component-empty-media\)/);
assert.match(styles, /gap: var\(--openbitfun-layout-empty-gap\)/);
assert.match(styles, /padding: var\(--openbitfun-layout-empty-padding-block\) var\(--openbitfun-layout-empty-padding-inline\)/);
assert.match(media, /--_empty-media-size: var\(--openbitfun-layout-empty-media-size-md\)/);
assert.match(media, /--_empty-icon-size: var\(--openbitfun-layout-empty-icon-size-md\)/);
assert.match(styles, /\.media\[data-size="sm"\][^{]*\{[^}]*--_empty-icon-size: var\(--openbitfun-layout-empty-icon-size-sm\)/);
assert.match(styles, /\.media\[data-size="lg"\][^{]*\{[^}]*--_empty-icon-size: var\(--openbitfun-layout-empty-icon-size-lg\)/);
assert.match(styles, /inline-size: var\(--_empty-icon-size\)/);
assert.match(styles, /block-size: var\(--_empty-icon-size\)/);
assert.doesNotMatch(styles, /opacity:/);
assert.ok(title);
assert.match(title, /color: var\(--openbitfun-color-content-muted\)/);
assert.match(title, /font-family: var\(--openbitfun-type-label-md-font-family\)/);
assert.match(title, /font-size: var\(--openbitfun-type-label-md-font-size\)/);
assert.match(title, /font-weight: var\(--openbitfun-type-label-md-font-weight\)/);
assert.doesNotMatch(title, /color-content-primary|type-heading-card/);
});
2 changes: 1 addition & 1 deletion design-system/packages/ui/tests/icon-slots.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const slots = [
["Select", "indicator", "inline-size", "100%"],
["StatusPill", "leading", "inline-size", "100%"],
["Avatar", "content", "inline-size", "55%"],
["Empty", "media", "max-inline-size", "100%"],
["Empty", "media", "inline-size", "var(--_empty-icon-size)"],
["ConfirmDialog", "icon", "inline-size", "var(--openbitfun-layout-confirm-dialog-icon-glyph-size)"],
["Listbox", "leading", "inline-size", "100%"],
["Listbox", "indicator", "inline-size", "100%"],
Expand Down
1 change: 1 addition & 0 deletions design-system/packages/ui/tests/registry.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ test("every registered component declares states and owned tokens", () => {
token.startsWith("border.") ||
token.startsWith("color.") ||
(component.name === "Button" && token.startsWith("component.button.")) ||
(component.name === "Empty" && token.startsWith("component.empty.")) ||
(component.name === "TabGroup" && [
"component.button.outlineBorder",
"component.button.outlineBorderInteractive",
Expand Down
6 changes: 5 additions & 1 deletion scripts/audit-theme-colors.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,11 @@ test('theme CSS var contract registry is explicit and non-overlapping', () => {
assert.equal(
CANONICAL_THEME_COLOR_TOKENS.every(token => (
/^--openbitfun-[a-z0-9-]+$/.test(token.cssVariable)
&& (token.name.startsWith('color.') || token.name.startsWith('component.button.'))
&& (
token.name.startsWith('color.')
|| token.name.startsWith('component.button.')
|| token.name.startsWith('component.empty.')
)
&& typeof token.value === 'string'
)),
true,
Expand Down
9 changes: 8 additions & 1 deletion scripts/theme-css-var-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,14 @@ const openbitfunThemeTokens = resolveTokens(mergeTokenDocuments(
readDesignSystemTokenDocument('design-system/packages/theme-openbitfun/src/reference.tokens.json'),
readDesignSystemTokenDocument('design-system/packages/theme-openbitfun/src/light.tokens.json'),
));
const publicThemePrefixes = ['color.', 'component.button.', 'effect.', 'opacity.', 'shadow.'];
const publicThemePrefixes = [
'color.',
'component.button.',
'component.empty.',
'effect.',
'opacity.',
'shadow.',
];

export const PACKAGE_CSS_VAR_DEFINITION_CONTRACTS = Object.freeze([
Object.freeze({
Expand Down
Loading
Loading