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
28 changes: 28 additions & 0 deletions .changeset/isometric-cube-faces.md
Original file line number Diff line number Diff line change
@@ -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.
54 changes: 54 additions & 0 deletions src/components/other/NoDataIcon/NoDataIcon.docs.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Meta } from '@storybook/addon-docs/blocks';

import * as NoDataIconStories from './NoDataIcon.stories';

<Meta of={NoDataIconStories} />

# 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
<NoDataIcon size="8x" />
```

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
<Flow gap="1x" placeItems="center">
<NoDataIcon size="8x" />
<Paragraph>No invoices yet.</Paragraph>
</Flow>
```
76 changes: 76 additions & 0 deletions src/components/other/NoDataIcon/NoDataIcon.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof NoDataIcon> = {
title: 'Other/NoDataIcon',
component: NoDataIcon,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
args: {
size: '8x',
},
};

export default meta;
type Story = StoryObj<typeof NoDataIcon>;

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: () => (
<Space gap="2x" placeItems="center start">
{['4x', '6x', '8x', '12x'].map((size) => (
<NoDataIcon key={size} size={size} />
))}
</Space>
),
};

/**
* 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: () => (
<Space gap="4x" placeItems="center">
<Flow gap="1x" placeItems="center">
<LoadingAnimation size="large" />
<Text preset="t4">LoadingAnimation</Text>
</Flow>
<Flow gap="1x" placeItems="center">
<NoDataIcon size="96px" />
<Text preset="t4">NoDataIcon</Text>
</Flow>
</Space>
),
};

/**
* 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],
};
51 changes: 51 additions & 0 deletions src/components/other/NoDataIcon/NoDataIcon.tsx
Original file line number Diff line number Diff line change
@@ -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 `<path>` 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',
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 65 65">
{/* Inner right wall — catches the light, same tone as the lit top face. */}
<path
fill="var(--loading-face-1-color)"
d="m32.512 3.186 31.993 18.46.002 22.15-31.994-18.46z"
/>
{/* Floor of the crate — deepest in shadow. */}
<path
fill="var(--loading-face-3-color)"
d="m.518 43.798 32-18.462 31.99 18.462-31.995 18.456z"
/>
{/* Back-left and front-right walls — the mid step. */}
<path
fill="var(--loading-face-2-color)"
d="M.519 21.642 32.513 3.183l-.001 22.15L.518 43.791zM32.513 40.1l31.994-18.46-.002 22.15-31.993 18.46z"
/>
{/* Front-left wall, with the folded-open flap. */}
<path
fill="var(--loading-face-1-color)"
d="m.518 21.637 6.932 4c.59.34 1.066 1.166 1.067 1.845v5.656c0 .68.477 1.506 1.066 1.846l13.865 7.999c.589.34 1.066.064 1.066-.616v-5.655c0-.68.477-.956 1.066-.616l6.933 4 .001 22.159L.519 43.795z"
/>
</svg>,
);
1 change: 1 addition & 0 deletions src/components/other/NoDataIcon/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { NoDataIcon } from './NoDataIcon';
12 changes: 12 additions & 0 deletions src/components/status/LoadingAnimation/LoadingAnimation.docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,15 @@ These properties allow direct style application without using the `styles` prop:

<LoadingAnimation size="large" />
```

## 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.
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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];
6 changes: 6 additions & 0 deletions src/components/status/LoadingAnimation/LoadingAnimation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
7 changes: 7 additions & 0 deletions src/stories/Usage.docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,13 @@ Icons live in `src/icons/`. Use existing icons when possible; otherwise use `@ta
- Always wrap tabler icons with `<Icon>` 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
Expand Down
6 changes: 3 additions & 3 deletions src/tokens/__snapshots__/palette.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
63 changes: 63 additions & 0 deletions src/tokens/palette.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading
Loading