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', + + {/* Inner right wall — catches the light, same tone as the lit top face. */} + + {/* Floor of the crate — deepest in shadow. */} + + {/* Back-left and front-right walls — the mid step. */} + + {/* Front-left wall, with the folded-open flap. */} + + , +); 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, },