diff --git a/.changeset/isometric-cube-faces.md b/.changeset/isometric-cube-faces.md
new file mode 100644
index 000000000..d4c89fdf9
--- /dev/null
+++ b/.changeset/isometric-cube-faces.md
@@ -0,0 +1,28 @@
+---
+'@cube-dev/ui-kit': minor
+---
+
+`LoadingAnimation` is retuned to sit next to the current monochrome `CubeLogo`, and the empty-crate illustration Cube Cloud has been carrying locally ships as `NoDataIcon`, drawn from the same three tokens.
+
+**The faces are near-neutral now.** `loading-face-1..3` used to take a fraction of the *brand* seed saturation (0.3 / 0.62 / 0.66), which put the shadowed face at chroma **0.0676** — eight times `border` — so a spinner rendered as a purple gradient beside a logo drawn in `currentColor`. They now take `baseChroma(0.2)`, the same normalised share the neutral chrome takes (`border`, `placeholder`, the text ramp), landing at **0.0059 / 0.0161 / 0.0248**. The brand hue still carries, as a tint rather than as a color, and still follows a re-seeded palette.
+
+**Contrast, not tone, is the spec.** A relative tone delta is uniform on the OKHST scale, but the dark scheme resolves it inside the `darkTone` window, which compressed the ramp to ~75% of its light span. Measured against `surface`:
+
+| | face-1 | face-2 | face-3 |
+|---|---|---|---|
+| light, before | 1.063 | 1.320 | 1.915 |
+| dark, before | 1.053 | 1.264 | **1.735** |
+| light, after | 1.201 | 1.653 | 2.409 |
+| dark, after | 1.212 | 1.666 | **2.424** |
+
+Glaze has no per-color `darkTone`, so the intent moves into a WCAG floor against `surface` and each scheme solves for it. The authored `tone: '-2'` is deliberately short of every floor, so all three faces are pinned by the ratio rather than by a delta that means something different in each scheme — light and dark now agree to within 1%, and the whole ramp is roughly a third stronger than it was (Oklab ΔL 0.271 in light, 0.231 in dark, against 0.204 / 0.154).
+
+WCAG rather than APCA, against the grain of the accent tokens: APCA's low-contrast clamp scores every step of a ramp this subtle as Lc 0, so it cannot express the difference between these three faces at all. Polarity-blindness — the reason APCA wins for text — costs nothing for a decorative fill whose only job is to separate from the page.
+
+High contrast used to be *identical* to the normal tier here, because an unconstrained tone delta had nothing to escalate. The `[1.35, 2.1, 3.2]` HC entries roughly double each step's distance from the page.
+
+**`NoDataIcon`** is the isometric open crate used for empty tables and empty lists, moved out of `cubejs-enterprise` and onto the shared tokens — the local copy hard-coded `#e5e5ec` / `#b4b4c5` / `#69697c` and re-derived a dark variant in JS on every scheme change.
+
+It ships as an **illustration component** alongside `CubeLogo`, not as a member of the icon set, because it is not an icon in the two ways that matter: it is a three-tone drawing rather than a `currentColor` glyph (so it ignores `color` — flattening the faces to one tone loses the box), and it is drawn full-bleed rather than inset in a 24×24 grid (so it belongs at `size="8x"` and up, not inline with text). It is still built on `Icon`, so sizing and style props behave exactly as they do for one.
+
+The token names stay `loading-face-*` so Cube Cloud's theme color map keeps resolving; they now cover both pieces of artwork, and the recipe comment says so.
diff --git a/src/components/other/NoDataIcon/NoDataIcon.docs.mdx b/src/components/other/NoDataIcon/NoDataIcon.docs.mdx
new file mode 100644
index 000000000..76aa42824
--- /dev/null
+++ b/src/components/other/NoDataIcon/NoDataIcon.docs.mdx
@@ -0,0 +1,54 @@
+import { Meta } from '@storybook/addon-docs/blocks';
+
+import * as NoDataIconStories from './NoDataIcon.stories';
+
+
+
+# NoDataIcon
+
+The empty isometric crate — the kit's "nothing here yet" illustration.
+
+It is an **illustration, not a member of the icon set**, which is why it lives in `Other/` rather than under `Content/Icons`. It is still built on `Icon`, so it sizes and accepts style props exactly like one.
+
+## When to Use
+
+- An empty table, list, or card grid — "No invoices yet", "No deployments yet"
+- A full-page empty state, paired with a short line of copy
+- Anywhere `LoadingAnimation` would sit if the same view were still loading
+
+## Sizing
+
+`size` maps onto `font-size` and drives both axes; the artwork is square.
+
+```jsx
+
+```
+
+Unlike a glyph icon, the drawing is full-bleed in its viewBox rather than inset in a 24×24 grid, so it is meant for illustration sizes — `size="8x"` (64px) and up. At icon sizes it will read as noticeably heavier than the glyphs around it.
+
+## Colour
+
+Not a `currentColor` glyph, so it **ignores `color`**. The three faces come from the shared isometric cube-face tokens (`#loading-face-1` … `#loading-face-3`) that [`LoadingAnimation`](/docs/status-loadinganimation--docs) also draws from, so an empty table and a loading table read as the same object lit the same way.
+
+That ramp is positioned by a WCAG floor against `#surface`, so each face keeps the same separation from the page in light, dark, and high contrast, and follows a re-seeded brand hue as a tint rather than as a colour. Recolouring one face and not the others would break the shading, which is why there is no prop for it.
+
+## Properties
+
+- **`size`** `Styles['fontSize']` — Icon size; drives both axes
+
+### Base Properties
+
+Supports [Base properties](/docs/getting-started-base-properties--docs).
+
+### Style Properties
+
+Accepts the same style props as [`Icon`](/docs/content-icons--docs) — outer, base, and color style props.
+
+## Examples
+
+```jsx
+
+
+ No invoices yet.
+
+```
diff --git a/src/components/other/NoDataIcon/NoDataIcon.stories.tsx b/src/components/other/NoDataIcon/NoDataIcon.stories.tsx
new file mode 100644
index 000000000..2ba900ae7
--- /dev/null
+++ b/src/components/other/NoDataIcon/NoDataIcon.stories.tsx
@@ -0,0 +1,76 @@
+import { Meta, StoryObj } from '@storybook/react-vite';
+
+import {
+ withDarkScheme,
+ withHighContrast,
+} from '../../../stories/decorators/withColorScheme';
+import { Text } from '../../content/Text';
+import { Flow } from '../../layout/Flow';
+import { Space } from '../../layout/Space';
+import { LoadingAnimation } from '../../status/LoadingAnimation';
+
+import { NoDataIcon } from './NoDataIcon';
+
+const meta: Meta = {
+ title: 'Other/NoDataIcon',
+ component: NoDataIcon,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ },
+ args: {
+ size: '8x',
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+/**
+ * `size` drives both axes — the artwork is square. Shown at the range it is
+ * meant for: an inline empty row through a full-page empty state.
+ */
+export const Sizes: Story = {
+ render: () => (
+
+ {['4x', '6x', '8x', '12x'].map((size) => (
+
+ ))}
+
+ ),
+};
+
+/**
+ * The whole reason it shares `LoadingAnimation`'s tokens: a table that is
+ * loading and a table that is empty have to read as the same object under the
+ * same light, not as two different drawings that happen to be nearby.
+ */
+export const WithLoadingAnimation: Story = {
+ render: () => (
+
+
+
+ LoadingAnimation
+
+
+
+ NoDataIcon
+
+
+ ),
+};
+
+/**
+ * The three faces are pinned by a contrast floor against `#surface`, so they
+ * hold the same separation from the page in every scheme rather than flattening
+ * out in dark — this story and the next are what that claim is checked against.
+ */
+export const DarkScheme: Story = {
+ decorators: [withDarkScheme],
+};
+
+export const HighContrast: Story = {
+ decorators: [withHighContrast],
+};
diff --git a/src/components/other/NoDataIcon/NoDataIcon.tsx b/src/components/other/NoDataIcon/NoDataIcon.tsx
new file mode 100644
index 000000000..285e85687
--- /dev/null
+++ b/src/components/other/NoDataIcon/NoDataIcon.tsx
@@ -0,0 +1,51 @@
+import { wrapIcon } from '../../../icons/wrap-icon';
+
+/**
+ * Empty isometric crate — the kit's "nothing here yet" illustration, for empty
+ * tables and empty lists.
+ *
+ * An illustration rather than a member of the icon set, which is why it lives
+ * here next to `CubeLogo` instead of in `src/icons/`. Two things follow from
+ * that, and both are the reason it could not be an icon:
+ *
+ * - It is a **three-tone drawing**, not a `currentColor` glyph, so it does not
+ * follow a `color` prop. Flattening the three faces to one tone loses the box.
+ * - It is drawn **full-bleed** in its own viewBox rather than inset in a 24×24
+ * grid, because it is used at illustration sizes (`size="8x"` and up) rather
+ * than inline with text.
+ *
+ * The three tones are the shared isometric cube-face tokens that
+ * `LoadingAnimation` also draws from (`src/tokens/palette.ts`), so an empty
+ * table and a loading table read as the same object lit the same way. Like the
+ * animation, they ride the SVG `fill` **attribute** rather than a tasty `fill`
+ * style — tasty's `fill` is a typed shorthand for `background-color`, which an
+ * SVG `` ignores.
+ *
+ * It is still built on `wrapIcon`, so sizing, `qa` and the style props work
+ * exactly as they do for an icon.
+ */
+export const NoDataIcon = wrapIcon(
+ 'NoDataIcon',
+ ,
+);
diff --git a/src/components/other/NoDataIcon/index.ts b/src/components/other/NoDataIcon/index.ts
new file mode 100644
index 000000000..2421094f8
--- /dev/null
+++ b/src/components/other/NoDataIcon/index.ts
@@ -0,0 +1 @@
+export { NoDataIcon } from './NoDataIcon';
diff --git a/src/components/status/LoadingAnimation/LoadingAnimation.docs.mdx b/src/components/status/LoadingAnimation/LoadingAnimation.docs.mdx
index 70df3e13a..f8a930e8d 100644
--- a/src/components/status/LoadingAnimation/LoadingAnimation.docs.mdx
+++ b/src/components/status/LoadingAnimation/LoadingAnimation.docs.mdx
@@ -42,3 +42,15 @@ These properties allow direct style application without using the `styles` prop:
```
+
+## Colors
+
+The cube's three faces come from the shared isometric-cube tokens
+(`#loading-face-1` … `#loading-face-3`), a near-neutral ramp positioned by a WCAG
+floor against `#surface`. Each face therefore keeps the same separation from the
+page in light, dark, and high contrast, and follows a re-seeded brand hue as a
+tint rather than as a color. There is no `color` prop to override — recolouring
+one face and not the others would break the shading.
+
+[`NoDataIcon`](/docs/other-nodataicon--docs) draws its empty crate from the same
+three tokens, so a loading table and an empty table read as the same object.
diff --git a/src/components/status/LoadingAnimation/LoadingAnimation.stories.tsx b/src/components/status/LoadingAnimation/LoadingAnimation.stories.tsx
index 91076b465..0a1bb5738 100644
--- a/src/components/status/LoadingAnimation/LoadingAnimation.stories.tsx
+++ b/src/components/status/LoadingAnimation/LoadingAnimation.stories.tsx
@@ -1,6 +1,9 @@
import { Meta, StoryFn } from '@storybook/react-vite';
-import { withDarkScheme } from '../../../stories/decorators/withColorScheme';
+import {
+ withDarkScheme,
+ withHighContrast,
+} from '../../../stories/decorators/withColorScheme';
import { baseProps } from '../../../stories/lists/baseProps';
import {
@@ -32,3 +35,7 @@ Large.args = {
export const DarkScheme = Template.bind({});
DarkScheme.args = {};
DarkScheme.decorators = [withDarkScheme];
+
+export const HighContrast = Template.bind({});
+HighContrast.args = {};
+HighContrast.decorators = [withHighContrast];
diff --git a/src/components/status/LoadingAnimation/LoadingAnimation.tsx b/src/components/status/LoadingAnimation/LoadingAnimation.tsx
index 216363032..009ea7a10 100644
--- a/src/components/status/LoadingAnimation/LoadingAnimation.tsx
+++ b/src/components/status/LoadingAnimation/LoadingAnimation.tsx
@@ -22,6 +22,12 @@ const CubeElement = tasty({
// (defined in `src/tokens/palette.ts`) keeps the three faces driven by
// the design system without going through a style property tasty would
// re-route to the wrong CSS slot.
+ //
+ // Those three tokens are the kit's shared isometric-cube ramp — `NoDataIcon`
+ // draws its crate from the same ones — and they are pinned by a WCAG floor
+ // against `surface` rather than by a tone delta, so the faces hold the same
+ // separation in light, dark, and high contrast. See the recipe's comment for
+ // why that matters.
animationName: {
'': 'none',
diff --git a/src/index.ts b/src/index.ts
index 903a26874..b55d9cd39 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -180,6 +180,7 @@ export { Skeleton } from './components/content/Skeleton/Skeleton';
export type { CubeSkeletonProps } from './components/content/Skeleton/Skeleton';
export { CubeLogo, CubeFullLogo } from './components/other/CubeLogo/CubeLogo';
export type { CubeLogoProps } from './components/other/CubeLogo/CubeLogo';
+export { NoDataIcon } from './components/other/NoDataIcon';
export { Badge } from './components/content/Badge/Badge';
export type { CubeBadgeProps } from './components/content/Badge/Badge';
export { Tag } from './components/content/Tag/Tag';
diff --git a/src/stories/Usage.docs.mdx b/src/stories/Usage.docs.mdx
index 1b7a50b5d..3104228f4 100644
--- a/src/stories/Usage.docs.mdx
+++ b/src/stories/Usage.docs.mdx
@@ -371,6 +371,13 @@ Icons live in `src/icons/`. Use existing icons when possible; otherwise use `@ta
- Always wrap tabler icons with `` for custom size/color. Never add props directly to tabler icons.
- To add new icons run: `pnpm add-icons`
+Illustrations are not icons and do not live in `src/icons/`: `CubeLogo` and
+`NoDataIcon` are `Icon`-based components under `src/components/other/`. They size
+like an icon, but `NoDataIcon` is a three-tone drawing on the shared cube-face
+tokens (`#loading-face-1` … `#loading-face-3`) rather than a `currentColor`
+glyph, so it ignores `color` and belongs at illustration sizes (`size="8x"` and
+up).
+
## Form System
### Form Component
diff --git a/src/tokens/__snapshots__/palette.test.ts.snap b/src/tokens/__snapshots__/palette.test.ts.snap
index 3927d52bc..470eacf4e 100644
--- a/src/tokens/__snapshots__/palette.test.ts.snap
+++ b/src/tokens/__snapshots__/palette.test.ts.snap
@@ -43,9 +43,9 @@ exports[`palette tokens > resolves the default palette to a stable set of values
"#disabled-surface": "(base)=oklch(0.9633 0.0034 280.3) | @dark=oklch(0.2746 0.0093 280.3) | @dark & @hc=oklch(0.1778 0.006 280.3) | @hc=oklch(0.9633 0.0034 280.3)",
"#disabled-surface-text": "(base)=oklch(0.7782 0.0336 280.3) | @dark=oklch(0.41 0.0209 280.3) | @dark & @hc=oklch(0.3701 0.0189 280.3) | @hc=oklch(0.7782 0.0336 280.3)",
"#focus": "(base)=oklch(0.727 0.1066 280.3) | @dark=oklch(0.4474 0.0656 280.3) | @dark & @hc=oklch(0.4139 0.0607 280.3) | @hc=oklch(0.727 0.1066 280.3)",
- "#loading-face-1": "(base)=oklch(0.9789 0.003 280.3) | @dark=oklch(0.2618 0.0134 280.3) | @dark & @hc=oklch(0.1464 0.0075 280.3) | @hc=oklch(0.9789 0.003 280.3)",
- "#loading-face-2": "(base)=oklch(0.9079 0.0274 280.3) | @dark=oklch(0.3171 0.0334 280.3) | @dark & @hc=oklch(0.2507 0.0264 280.3) | @hc=oklch(0.9079 0.0274 280.3)",
- "#loading-face-3": "(base)=oklch(0.7958 0.0676 280.3) | @dark=oklch(0.3974 0.0446 280.3) | @dark & @hc=oklch(0.3551 0.0398 280.3) | @hc=oklch(0.7958 0.0676 280.3)",
+ "#loading-face-1": "(base)=oklch(0.9379 0.0059 280.3) | @dark=oklch(0.3042 0.0103 280.3) | @dark & @hc=oklch(0.2633 0.009 280.3) | @hc=oklch(0.8995 0.0097 280.3)",
+ "#loading-face-2": "(base)=oklch(0.837 0.0161 280.3) | @dark=oklch(0.386 0.0131 280.3) | @dark & @hc=oklch(0.3832 0.013 280.3) | @hc=oklch(0.7663 0.0237 280.3)",
+ "#loading-face-3": "(base)=oklch(0.729 0.0248 280.3) | @dark=oklch(0.474 0.0161 280.3) | @dark & @hc=oklch(0.4821 0.0164 280.3) | @hc=oklch(0.6527 0.0222 280.3)",
"#note-accent-disabled-surface": "(base)=oklch(0.8514 0.0364 302.3) | @dark=oklch(0.3707 0.0315 302.3) | @dark & @hc=oklch(0.3236 0.0275 302.3) | @hc=oklch(0.8319 0.0415 302.3)",
"#note-accent-disabled-surface-text": "(base)=oklch(1 0 0) | @dark=oklch(0.4775 0.0325 302.3) | @dark & @hc=oklch(0.4494 0.0306 302.3) | @hc=oklch(1 0 0)",
"#note-accent-icon": "(base)=oklch(0.6396 0.1019 302.3) | @dark=oklch(0.5151 0.0821 302.3) | @dark & @hc=oklch(0.5061 0.0807 302.3) | @hc=oklch(0.6234 0.0994 302.3)",
diff --git a/src/tokens/palette.test.ts b/src/tokens/palette.test.ts
index c88953c95..dd4174040 100644
--- a/src/tokens/palette.test.ts
+++ b/src/tokens/palette.test.ts
@@ -230,6 +230,69 @@ describe('palette tokens', () => {
'@hc',
]);
});
+
+ /**
+ * The cube-face ramp's whole point is that a `LoadingAnimation` reads with the
+ * same weight in every scheme. It is stated as a WCAG floor against `surface`
+ * rather than as a tone delta precisely because the dark scheme resolves a
+ * delta inside the `darkTone` window and flattened the ramp to ~75% of its
+ * light span. The snapshot above pins the emitted colors; this pins the
+ * property that made them those colors, so a regression reads as "dark went
+ * flat again" rather than as three changed oklch strings.
+ */
+ it('holds the cube faces at one contrast ratio in every scheme', () => {
+ const tokens = getPaletteTokens();
+ const FLOORS = [1.2, 1.65, 2.4];
+ const HC_FLOORS = [1.35, 2.1, 3.2];
+
+ for (const [state, floors] of [
+ ['', FLOORS],
+ ['@dark', FLOORS],
+ ['@hc', HC_FLOORS],
+ ['@dark & @hc', HC_FLOORS],
+ ] as const) {
+ const colors = variant(tokens, state);
+
+ floors.forEach((floor, index) => {
+ const face = colors[`#loading-face-${index + 1}`];
+ const measured = contrastOf(colors['#surface'], face);
+
+ // A floor, so it may only be met or exceeded — and Glaze's solve lands
+ // just above rather than exactly on it.
+ expect(
+ measured,
+ `face ${index + 1} @ '${state || 'light'}'`,
+ ).toBeGreaterThanOrEqual(floor);
+ expect(
+ measured,
+ `face ${index + 1} @ '${state || 'light'}'`,
+ ).toBeLessThan(floor * 1.02);
+ });
+ }
+ });
+
+ /**
+ * And that they stay in the neutral chrome's tint family rather than reading
+ * as a purple gradient beside a `currentColor` `CubeLogo` — the faces used to
+ * take a fraction of the *brand* seed saturation, which put the shadowed one
+ * at eight times `border`'s chroma.
+ *
+ * `#disabled` is the ceiling because it is the most tinted of the greys, and a
+ * face is allowed to be as tinted as the greys are but no more. An absolute
+ * number would not survive a re-seeded palette; the faces and `#disabled` both
+ * take a normalised share of the same surface saturation, so the relation does.
+ */
+ it('keeps the cube faces inside the neutral chrome chroma band', () => {
+ const colors = variant(getPaletteTokens(), '');
+ const ceiling = chromaOf(colors['#disabled']);
+
+ for (const index of [1, 2, 3]) {
+ expect(
+ chromaOf(colors[`#loading-face-${index}`]),
+ `face ${index}`,
+ ).toBeLessThanOrEqual(ceiling);
+ }
+ });
});
describe('setPaletteConfig', () => {
diff --git a/src/tokens/palette.ts b/src/tokens/palette.ts
index 3b4d6be3f..f8a8c5f19 100644
--- a/src/tokens/palette.ts
+++ b/src/tokens/palette.ts
@@ -1210,26 +1210,64 @@ function buildPalette(
mode: 'fixed',
},
- // ---- Loading-animation cube faces ----
- // Decorative gradient steps from `surface` to a saturated mid-tone. Tone
- // deltas are chosen directly on the contrast-uniform scale; no contrast prop
- // is needed for a non-text decorative element.
+ // ---- Isometric cube faces ----
+ // The three shading steps of the kit's isometric cube artwork: the lit top
+ // face, the mid side, and the shadowed side. Shared by `LoadingAnimation`
+ // and `NoDataIcon` so both read as the same object under the same light.
+ //
+ // Two things are deliberate here.
+ //
+ // **Neutral chroma.** These used to take a fraction of the *brand* seed
+ // saturation (0.3 / 0.62 / 0.66), which put the shadowed face at chroma
+ // 0.0676 — eight times `border` — so the animation read as a purple gradient
+ // next to a monochrome `CubeLogo`. They now take `baseChroma(0.2)`, the same
+ // normalised share the neutral chrome (`border`, `placeholder`, the text
+ // ramp) takes, which lands them at 0.0059 / 0.0161 / 0.0248: a tint that
+ // follows a re-seeded brand hue without announcing it.
+ //
+ // **Contrast, not tone, is the spec.** A relative tone delta is uniform on
+ // the OKHST scale but the dark scheme resolves it inside the `darkTone`
+ // window, which compressed the ramp to ~75% of its light span — measurably
+ // flatter, which is exactly how it looked. Glaze has no per-color
+ // `darkTone`, so the fix is to state the intent as a WCAG floor against
+ // `surface` and let each scheme solve for it: the authored `tone: '-2'` is
+ // deliberately short of every floor, so all three faces are pinned by the
+ // ratio in every scheme rather than by a delta that means different things
+ // in each. Measured on the emitted tokens, light comes out 1.201 / 1.653 /
+ // 2.409 and dark 1.212 / 1.666 / 2.424 — within 1% of each other, against
+ // 1.063 / 1.320 / 1.915 vs 1.053 / 1.264 / 1.735 before.
+ //
+ // WCAG rather than APCA, against the grain of the accent tokens above:
+ // APCA's low-contrast clamp scores every step of a ramp this subtle as
+ // Lc 0, so it cannot express the difference between these three faces at
+ // all. Polarity-blindness — the reason APCA wins for text — costs nothing
+ // for a decorative fill whose only job is to separate from the page.
+ //
+ // The high-contrast entries roughly double each step's distance from the
+ // page (1.351 / 2.107 / 3.211) instead of leaving HC identical to the
+ // normal tier, which is what an unconstrained tone delta gave.
'loading-face-1': {
+ hue: baseHue,
base: 'surface',
tone: '-2',
- saturation: 0.3,
+ saturation: baseChroma(0.2),
+ contrast: [1.2, 1.35],
inherit: false,
},
'loading-face-2': {
+ hue: baseHue,
base: 'surface',
- tone: '-9',
- saturation: 0.62,
+ tone: '-2',
+ saturation: baseChroma(0.2),
+ contrast: [1.65, 2.1],
inherit: false,
},
'loading-face-3': {
+ hue: baseHue,
base: 'surface',
- tone: '-21',
- saturation: 0.66,
+ tone: '-2',
+ saturation: baseChroma(0.2),
+ contrast: [2.4, 3.2],
inherit: false,
},