diff --git a/.changeset/accent-apca-floors.md b/.changeset/accent-apca-floors.md deleted file mode 100644 index 38aee1ccf..000000000 --- a/.changeset/accent-apca-floors.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@cube-dev/ui-kit': patch ---- - -A pinned brand color is held to an APCA floor instead of a WCAG ratio, which softens the constraint where it was over-tight. - -Custom color mode only. The shipped palette is untouched — it runs the `null` accent arrangement, whose `['AA','AAA']` floors are unchanged and still snapshotted. - -WCAG 2.x is polarity-blind, so the old `[3, 7]` meant two very different things depending on the scheme. Measured with Glaze's own `apcaContrast` across 12 hues at 30° steps, the fill sitting exactly at the old floor comes out at **Lc 56.2 in light** (55.8–56.5) but only **Lc 23.3 in dark** (22.7–24.4). Hue is not a factor — the spread is under 2 Lc across the whole wheel — polarity is. One number was therefore 2.4× stricter in light than in dark, which is why light brands kept getting crushed while dark ones sailed through, and it left dark-mode fills below APCA's own `non-text` floor of 30. - -- `accent-surface` → `{ apca: [45, 85] }`. Lc 45 is APCA's `large` tier. The base stays `surface`, so with the `bg` polarity Glaze solves `apcaContrast(surface, fill)` — in light, where `surface` is `oklch(1 0 0)`, that is white-on-fill, the pair every `type="primary"` label rides on. -- `accent-text-soft` → `{ apca: [60, 85] }` and `accent-text` → `{ apca: [75, 92] }`, APCA's `content` and `body` tiers, one step apart so the rest→hover intensify cannot collapse onto one color. - -In light this is a real relaxation: the floor drops from ~WCAG 3.0 to ~WCAG 2.3, giving a brand about 8 tone points more headroom before it is darkened. `#0EA5E9` now renders at 2.77 against the page instead of being pushed to 3.0. - -Two things worth knowing. The high-contrast tier can no longer say "AAA in both schemes": WCAG 7 is Lc 83.5 in light but Lc 54.4 in dark, so no single Lc restates it. `85` is the closest — ~6.1 in light, a shade under AAA, and ~15 in dark. And the tier has to be APCA at all because Glaze rejects a `contrast` pair that switches metric between its normal and high-contrast entries, which is a fair guard rather than something to work around. - -Anchoring the fill to `accent-surface-text` to make "from white" literal in both schemes was tried and rejected: in dark the label root is near-white while the page is not, so the floor stopped constraining the fill against the page and `#111827` came out at WCAG 1.16 against the dark surface — invisible. - -The fill carries **two** floors, not one, because in dark they pull opposite ways and dropping either produces the mirror image of the other's failure. - -Glaze takes one `base` per color, so only the page floor can be expressed as a `contrast`; the label floor is a cap on the seed tone, searched against Glaze's own fixed-mode resolution rather than a reimplementation of the dark tone window. It only ever lowers, so a brand that was already dark enough is emitted unchanged. - -- **Page floor** (`contrast` against `surface`) — the button has to be a visible shape. Without it `#111827` puts the fill at **Lc 0.0 against the dark page**: a blazing white label on a shape that is not there. The border does not stand in for it, being deliberately low-contrast. -- **Label floor** (the seed cap) — the `#white` that every `type="primary"` item paints has to be readable. Without it `#FFFFFF` clears the page floor in dark at WCAG 14.4 while the label lands on **Lc 0** — the label is exactly its own fill. - -In light the page IS white, so the two collapse into the single measurement Glaze already makes and the cap never fires. - -This also reaches the `special` theme, whose `SPECIAL_PRIMARY_STYLES` paints `#white` on the same brand fill. `#FFD400` is one of the colors the cap moves — white on it untouched is Lc 28 — so the brand's hue carries into the hero button but its tone is capped. The test that asserted the literal survived unchanged now asserts the hue arrived, since exact equality there was a demand that the button's own label be unreadable. diff --git a/.changeset/accent-fill-page-floor.md b/.changeset/accent-fill-page-floor.md deleted file mode 100644 index 4912ef55a..000000000 --- a/.changeset/accent-fill-page-floor.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@cube-dev/ui-kit': patch ---- - -Fix a color-seeded accent fill collapsing to one value across the dark tone range. - -The brand fill answers to two APCA constraints, and they were sized the same. The `#white` label it carries needs Lc 45 — text strength, because it is text. The page it sits on was asked for Lc 45 too, escalating to Lc 60 in high contrast, which is a demand that a filled shape reach text-grade contrast against the background. - -Nothing in the palette meets that. Measured on the emitted tokens, the **shipped** `accent-surface` — the white-anchored ladder every primary button used before color seeds existed — sits at **Lc 25.5** off the dark page and **Lc 19.3** in dark high contrast, where the ladder darkens the fill toward its label. So a color-seeded fill was being held to 1.8x and 3.1x what the design system's own button achieves. - -The two look identical in light, which is how it went unnoticed: there `surface` **is** white, so one measurement is both constraints at once and Lc 45 is right for the pair. In dark the page is near-black, and because a floor can only lighten, the surplus flattened the tone axis: every seed below the floor solved to the same fill. Measured across the axis at one hue, the dark fill was pinned at tone 66 for every seed from 5 to 65 — a brand's whole dark half collapsing onto one lavender — while light passed the same seeds through untouched. In dark high contrast the floor met the label cap and left a window of a single value. - -The page floor is now **Lc 25 in both tiers**, calibrated to the shipped fill rather than to a text threshold. The same sweep now tracks the seed from tone 47 up, and 47 is where the shipped fill sits in dark, so the dark range went from 7.7 tones to ~27 against light's ~45. The pair is written with both entries equal in order to suppress APCA's automatic +15 Lc enhancement in high contrast: that tier is a request for separation over brand, but not for separation from the page — the same fill carries the label, and driving it off the page drives the label off it. - -The white label is unaffected. It never depended on this number: it is guaranteed by the tone cap on the seed, which searches all four variants against pure white. A lower page floor lightens less, so it makes that guarantee safer rather than weaker. - -Light mode is unchanged — a dark brand on a white page measures Lc 100+, so this floor never bound there. Palettes with no color seed are untouched: the white-anchored ladder keeps its `['AA','AAA']` floors. diff --git a/.changeset/accent-review-fixes.md b/.changeset/accent-review-fixes.md deleted file mode 100644 index a73aaaf3f..000000000 --- a/.changeset/accent-review-fixes.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@cube-dev/ui-kit': patch ---- - -Four fixes to the pinned-brand accent path, from review. - -**An explicit `hue` now rotates the whole accent ramp.** `resolveConfig` ranks a numeric `hue` above the one an `accentColor` carries, but `buildPalette` handed Glaze the original literal — so `accent-surface` kept the color's hue while `-2`, `-3` and `hover` followed the theme's. A primary button changed hue on hover. The seed is now built from the resolved hue, chroma and tone rather than the string, and the regression test asserts on emitted tokens rather than on `getPaletteConfig().hue`, which was already correct while the ramp was split. - -**The white-label floor now holds on the emitted fill.** The ceiling was computed on the bare seed, but what ships is the fill after the page floor has had its turn — and that floor can only lighten, which is what weakens a white label. A 3072-case sweep over hue, chroma, tone, scheme and tier found 720 failures, the worst putting the label at Lc 20.7. Three things fixed it: the ceiling is now a property of the hue/chroma pair rather than of the tone asked for (it previously only searched when the requested tone already failed, so a dark tone probed first let every later light tone escape), it searches to a measured +3 Lc margin so the page floor cannot eat back through 45, and the high-contrast page floor drops from Lc 85 to 60. - -That last one is geometry, not preference. The two floors pull opposite ways in dark: the page wants a lighter fill, the label a darker one. The window they share is `L ∈ [0.605, 0.735]`, and asking 85 of the page empties it outright — 60 is the largest value that keeps it open, with 65 reopening 768 failures. High contrast escalates the fill only as far as its own label can follow. - -**The accent-cap cache is versioned.** It resolves through Glaze's global settings, including the dark tone window, so a caller running `glaze.configure(...)` then `invalidatePaletteTokens()` had changed the answer without changing the seed. Keyed on the palette version now, which is what makes that API mean what it says. - -**Replacing one unparseable color with another registers.** Both resolve to `null`, and the pin signature recorded only whether the field was present, so `setPaletteConfig({ accentColor: 'bad-two' })` after `'bad-one'` returned early — input kept the first string and no subscriber heard. The signature carries the two color values now. - -Documentation across the JSDoc, the Theme Builder tooltip, `Theming.docs.mdx` and the earlier changeset no longer promises WCAG 3:1. The floors are APCA Lc 45, and an emitted fill can legitimately sit under 3:1 — `#0EA5E9` renders at 2.77:1 and is correct there. diff --git a/.changeset/accent-seed-object-form.md b/.changeset/accent-seed-object-form.md deleted file mode 100644 index a8c02e36e..000000000 --- a/.changeset/accent-seed-object-form.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@cube-dev/ui-kit': patch ---- - -The accent label cap hands Glaze its probe seed as an `OkhstColor` instead of a formatted string. - -`from` accepts `string | OkhslColor | OkhstColor | RgbColor | OklchColor`, so the seed never needed to become text. Dropping `formatOkhst` removes the writers' scale question from this path entirely — and with it the two decimal places `okhst()` rounds to. `#7A4DBF` round-tripped through a string came back `0.450200` against a true `0.450191`. - -No Glaze change: `OkhstColor` is existing 2.0.0 API. - -One test moves with it. The cap's floor is measured on the emitted token, whose `oklch()` string carries four decimals, so `#FFD400` now reads Lc 44.9925 where it used to read a hair over 45 — the string round-trip had been rounding it up. The assertion takes the same epsilon treatment the high-contrast one already had (`84.9` for an 85 target). The shortfall is 0.0075 Lc, three orders of magnitude below anything visible, and the change is in the direction of accuracy. diff --git a/.changeset/base-color-saturation.md b/.changeset/base-color-saturation.md deleted file mode 100644 index 719192584..000000000 --- a/.changeset/base-color-saturation.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@cube-dev/ui-kit': minor ---- - -A `baseColor` now contributes its **saturation** as well as its hue, and `baseSaturation` follows the accent's chroma however that chroma was expressed. - -`baseColor` previously contributed hue only, which left a hole: picking a base color set which way the greys leaned but not how far, so the chrome's chroma still came from the accent seed and had nothing to do with the color chosen. Its **tone** is still discarded — the chrome's own lightness ladder is the design. - -```ts -setPaletteConfig({ baseColor: '#6e7076' }); // near-grey in, near-grey chrome out -setPaletteConfig({ baseColor: '#FFD400' }); // saturation 100 in, clipped to 50 -``` - -The derived saturation is **clipped to `MAX_BASE_SATURATION`** (`50`, newly exported). Naming a base color says "the chrome *is* this color", so it lands near it rather than at the 12% share `baseSaturation` otherwise inherits — but a fully saturated chrome stops being chrome, and the base colors converge above `25` anyway, so the clip costs nothing that was still moving. - -`baseSaturation`'s default also changes shape. Unset, it takes `0.12` of whatever the **accent zone** carries — the `saturation` seed, or an `accentColor`'s own chroma when one is set: - -``` -input.baseSaturation - ?? (baseColor ? min(baseColor.saturation, 50) - : (accentColor?.saturation ?? saturation) * 0.12) -``` - -Reading the accent color there is the one place a brand color reaches the base zone, and it has to: without it, a near-grey brand left the chrome carrying 12% of a saturation nobody asked for. Nothing here touches the palette-level `saturation`, so the status themes still inherit exactly what they did and the guarantee that a brand color cannot re-chromatise them is intact. - -The shipped palette is unchanged: with no color seed the expression is `saturation × 0.12` as before, and the snapshot is byte-identical. diff --git a/.changeset/board-drag-grid-grip-collision.md b/.changeset/board-drag-grid-grip-collision.md deleted file mode 100644 index 84aa366cb..000000000 --- a/.changeset/board-drag-grid-grip-collision.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@cube-dev/ui-kit': minor ---- - -`Board`: scoped drag grid lines, a corner resize-grip placement, and collision modes for the free grid. - -- **`showGridLines="drag"` now scopes the grid to the board taking part in the gesture** — the board owning the drag (its source, or whichever board the widget is currently over) or resizing one of its own widgets. Previously any drag lit up every board sharing a `Board.Provider`, including boards the widget could not land in. The old behaviour is still available, now opt-in, as `showGridLines="any-drag"`, which advertises every board as somewhere to land. -- **`resizeGripPlacement`** (`'inside' | 'corner'`, default `'inside'`) on `Board` and `Board.Widget` positions the corner resize grips. `'corner'` centres each grip on the widget's corner — drawn outside the widget box, which clips its own content — so it lines up with a control centred on the opposite corner. The grip's hit-zone moves out with it, so the half that overhangs is grabbable and hovering it keeps the grip revealed. Edge grips are unaffected. -- **`collisionMode`** (`'revert' | 'downscale' | 'swap'`, default `'revert'`) on `Board` resolves a drop the grid would otherwise refuse, where a collision blocks a move (`compact="free"`, or `preventCollision`). `'downscale'` shrinks the widget into the free space at the drop cell; `'swap'` trades places with one widget — the one the drop covers most, which takes the cell the drag began at — each keeping as much of its own size as fits, and falls back to `'downscale'` then `'revert'`. It never displaces more than that one widget, a drop straddling two widgets trades with one rather than refusing, and dragging back retraces the original arrangement. No mode ever grows a widget, and arrow keys honour the mode without ever resizing anything. diff --git a/.changeset/color-swatch-component.md b/.changeset/color-swatch-component.md deleted file mode 100644 index c83a618d4..000000000 --- a/.changeset/color-swatch-component.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@cube-dev/ui-kit': minor ---- - -`ColorSwatch` is a component of its own, with sizes and automatic fitting. - -It was already exported — as an implementation detail of the color fields, with no size of its own and no docs. It now lives in `src/components/fields/ColorSwatch/`, ships stories and documentation, and takes a `size`: - -```jsx - // tracks the control around it - // 28px -``` - -- **`size`** — `small` / `medium` / `large` = `20px` / `24px` / `28px`. -- **Left unset, the swatch sizes itself to its host.** `Item`, `Button` and the text inputs publish their height as the `$size` custom property, so a swatch in an `icon`, `rightIcon`, `prefix` or `suffix` slot lands `8px` inside it — `20px` in a `small` control, `24px` in a `medium` one, `32px` in a `large` one — with nothing passed between the two. Outside a control it falls back to `medium`. - -Nothing the kit renders changes size. `ColorInput` and `ColorPicker` keep the fixed `20px` swatch they have always drawn, at every field size: it reads as a value the field is showing rather than as part of the control, and a text input hangs its prefix off the border with no padding of its own, so a tracking swatch in a large field would sit against the edge. Automatic fitting is for a swatch you place yourself, where the host has the padding that makes it work. - -The import path `@cube-dev/ui-kit` is unchanged. diff --git a/.changeset/color-swatch-fixed-in-fields.md b/.changeset/color-swatch-fixed-in-fields.md deleted file mode 100644 index 060646e38..000000000 --- a/.changeset/color-swatch-fixed-in-fields.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@cube-dev/ui-kit': patch ---- - -`ColorInput` and `ColorPicker` keep the `20px` swatch they have always drawn. - -Giving `ColorSwatch` a size of its own changed both of them by accident. `ColorInput` passed the field's size straight through, so a `medium` field's swatch went `20px → 24px` and a `large` one `20px → 28px`; the `ColorPicker` trigger was left to track its button and moved `20px → 24px` the same way. Measured in a real browser against `main`, at every field size. - -Neither was a size anyone asked for. The swatch in a color field reads as a value the field is showing, not as part of the control, so it is now pinned at `20px` in both — identical to `main` at `small`, `medium` and `large`. - -The `size` prop and the automatic fitting are unchanged and remain the right thing for a swatch you place yourself in a `Button` or an `Item`, where the host has the padding that makes it work. A text input hangs its prefix off the border with none of its own, which is why the fields opt out. - -Also corrects the docs, which gave the automatic fit in a `large` control as `32px` where it is `28px`. diff --git a/.changeset/contrast-level-composes.md b/.changeset/contrast-level-composes.md deleted file mode 100644 index bb1f21c68..000000000 --- a/.changeset/contrast-level-composes.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@cube-dev/ui-kit': patch ---- - -A manual `contrastLevel` no longer suppresses the high-contrast tier (via `@tenphi/glaze` 1.5.0). - -The level now does one thing: it positions the **normal** colors on the 0–100 slider. The high-contrast tier stays the true high-contrast resolution — identical to what `contrastLevel: 'auto'` emits — at every level, so the two **compose** rather than replace each other: a product's own contrast slider raises the baseline while `` / `prefers-contrast: more` still escalates on top of it. - -Two consequences for anyone who had set a level: - -- `contrastLevel: 0` now reproduces `'auto'` output exactly, high-contrast tier included. Shipping the slider and defaulting it off therefore costs nothing — previously it silently dropped the tier, so `data-contrast="high"` stopped working the moment a level was set. -- At `contrastLevel: 100` the normal colors already _are_ the high-contrast ones, so a separate tier would only duplicate them: a single light/dark set is emitted. That is now the only level at which the tier is absent. - -`renderColorTokens()` / `renderPaletteTokens()` follow the same rule — `highContrast: true` returns the genuine escalated variant at any level below 100. - -The shipped palette is unaffected: it runs at `contrastLevel: 'auto'`, and the default-palette snapshot is unchanged. diff --git a/.changeset/copysnippet-wrap.md b/.changeset/copysnippet-wrap.md deleted file mode 100644 index ad42fb582..000000000 --- a/.changeset/copysnippet-wrap.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@cube-dev/ui-kit': minor ---- - -`PrismCode` and `CopySnippet` take an `isWrapped` prop that soft-wraps long content instead of scrolling it sideways. - -By default both components keep each line on one line and scroll horizontally, which buries long error messages and logs off to the right. `isWrapped` lays the content out on multiple lines instead: unbreakable runs like URLs, tokens and identifiers break too (`overflow-wrap: anywhere`), not just spaces. On `CopySnippet` the block additionally grows vertically to fit — so even a single very long line is fully readable rather than clamped to the collapsed height — and the prop is forwarded to the inner `PrismCode`, which owns the wrapping itself. - -```jsx - - -``` - -On `CopySnippet` it is a different axis from `nowrap` — `nowrap` collapses real newlines into one scrolling line, `isWrapped` breaks long lines — and `nowrap` wins when both are set. The copy button and syntax highlighting are unchanged. diff --git a/.changeset/current-single-fade.md b/.changeset/current-single-fade.md deleted file mode 100644 index ef6a617e2..000000000 --- a/.changeset/current-single-fade.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@cube-dev/ui-kit': patch ---- - -Fix the `current` theme fading a disabled label twice. - -`current.item` and `current.clear` already suppressed their own `.4` label fade when the disabled state was inherited from a host that had faded `currentcolor` already, but `current.outline`, `current.outline-2`, `current.primary` and `current.link` stated it as a bare `disabled`. Two of those — `outline` and `primary` — are reachable `Item.Action` types, so an action inside a disabled row (including `Banner`'s outline actions) multiplied the two fades and rendered at `.16` of the row's color, washing out both the label and the alpha chip. - -Every `current` flavour now gates the fade on `disabled & !inherit-disabled & !inside-wrapper`. The second mod closes the other half of the same hole: `ItemButton` renders its actions as siblings of the row inside a wrapper, and the wrapper reproduces the row's disabled color so those siblings inherit a faded `currentcolor`. It previously could not, because the gated key was skipped when deriving the wrapper's colors — so a disabled `ItemButton` on the `current` theme sat next to full-strength actions. The wrapper now reads the gated value, and the row suppresses its own fade under `inside-wrapper`, leaving exactly one `.4` on every path. diff --git a/.changeset/current-theme-axis.md b/.changeset/current-theme-axis.md deleted file mode 100644 index 45f766230..000000000 --- a/.changeset/current-theme-axis.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -'@cube-dev/ui-kit': minor ---- - -`current` moves from the `type` axis to the `theme` axis on `Button`, `Item` (and `ItemButton`), `Item.Action` and `ItemBadge`. It was never a shape: it names where the colors come from — the inherited `currentcolor` rather than a brand ramp — which is the question `theme` answers. As a type it occupied the slot that decides emphasis, so picking `current` meant giving up the choice between a filled button, an outlined one and a bare label. - -On the `theme` axis it composes instead, and every type now has a `current` flavour: - -- `item` — the old `Item` shape: no border, nothing painted at rest, the fill stepping in on hover, pressed and selected. -- `clear` — the same ramp plus the focus ring a standalone control needs. The default for `Item.Action` and `ItemBadge`. -- `outline` — the old `Button` shape: a resting `#current.03` chip inside a `#current.08` border. -- `outline-2` — `outline` for a container that is already painting something. The brand themes swap an opaque base (`#surface-3` for `#surface-2`); `current` has no opaque base to swap, so the same intent is carried by roughly doubling the tint at every step. -- `primary` — the high-emphasis control, and the one flavour that fills opaquely, like every other theme's `primary`: the fill is the inherited color at full opacity and the label is punched out of it with the new `#current-fill` token, which defaults to `#surface` — the page background, which is always the opposite of the text painted on it and so follows the scheme for free. Hover and pressed lay a translucent `#black` over the same base, since an arbitrary color has no lighter or darker sibling to step to the way the brand ramps walk `accent-surface` to `-2` and `-3`. The rim comes from the same token at `.25` — every other `primary` rims its fill with `accent-surface-border`, cr 1.48 against it, and this measures 1.82 in light and 1.55 in dark. Disabled swaps the rim to `#surface-text.2`, which holds against a `.4` chip that a `#surface` rim would wash into. The label is painted with `-webkit-text-fill-color` rather than `color`: `#current` compiles to the literal `currentcolor`, which in `fill` resolves against the element's own `color`, so setting `color` to the label token would make the fill resolve to the label color and paint a white pill with a white label. - - `#current-fill` is a real color token with a default, not a bare custom property, so it takes the alpha suffix (`#current-fill.5` is the disabled label) and a container overrides it with one declaration — `styles={{ '#current-fill': '#fixed-dark' }}` — moving the label, the icon slots and the rim together. It exists for the one container the `#surface` default is wrong for: a container whose own text color IS the page paints `#white`, which IS `#surface` in light mode, so an unaided label measures cr 1.00 against its own chip. Its own fill is the right value there, since it contrasts with its own text by construction. Ordinary containers set nothing. -- `link` — no chip at all. The brand themes intensify from `accent-text-soft` to `accent-text` on hover; here "soft" is the inherited color at `.8` and "strong" is it at full opacity. -- `card` — the static panel: a `#current.05` fill inside a `#current.2` border (`Item` only). - -`current.outline` and `current.item` are byte-identical to the old `Button` and `Item` flavours, so nothing that used `type="current"` changes appearance. - -The top step of each ramp stops at `#current.24` in light. The dark counterpart is not authored: each `@dark` step is solved so its OKHST tone delta from the surface matches the light step's, which lands the two schemes on the same chip-vs-page contrast (1.084 / 1.083 at hover, 1.959 / 1.961 at the top step). That works out *lower* than the light alpha throughout — `.031 / .046 / .13 / .175 / .221` against `.04 / .06 / .18 / .24 / .3` — because near the dark end of the scale a small sRGB move is a large perceptual one, so the same tint reads stronger on a dark surface than on a light page. - -### Migration - -**`type="current"` is removed with no runtime fallback.** It resolves to no variant and falls back to base styles, the same as any other unknown type — there is no mapping and no deprecation warning. The spelling shipped one release ago and has no consumers outside the kit, so this is a clean break rather than a deprecation: - -| Old | New | -| ------------------------------ | ------------------------------------------------------- | -| `