Skip to content
Open
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
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
255 changes: 242 additions & 13 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,37 +13,266 @@ item (see `ROADMAP.md`, Week 4).

## [Unreleased]

Tracking toward **1.9.0**, classified **minor** per [RELEASING.md](RELEASING.md):
additive only — a new package consumers may adopt, new resolution paths, and
internals changes that keep observable behavior identical. Nothing is removed,
narrowed, or deprecated, and no peer-dependency floor moves.
Heading to **2.0**, not to 1.9. `package.json` carries `2.0.0-alpha.0` so
nothing here can be published as a version nobody chose — 1.9.0 was a working
number bumped ahead of any release decision, and it is now skipped entirely.

The `ThemeInit` fix that briefly lived under a 1.9.0 heading shipped instead as
**1.8.4** (published 2026-08-28), cut from the tree that produced the published
1.8.3 so it reached `^1.8.x` consumers with nothing to migrate. Its entry is
below, in its own release.

Classification is **open**. The framework adapter below is a migration step for
Next.js apps, which is not a minor — see the release PR for the options.

**One migration step for Next.js apps.** No component, prop, export, token, or
CSS class is removed or renamed; no type union narrows; no peer-dependency floor
moves; the package installs exactly the same dependency set as 1.8.3. But
`ElementType` (which backs `SmartLink` and any `Button` / `Card` /
`ToggleButton` with `href`), `Media`, `Logo`, `MegaMenu` and `Kbar` now render
through the adapter layer, whose defaults are plain DOM — `<a>`, `<img>`,
`window.location.assign`. Keeping 1.8.x behavior is a one-line change — the
import path for `LayoutProvider`:

```diff
- import { LayoutProvider } from "@once-ui-system/core";
+ import { LayoutProvider } from "@once-ui-system/core/next";
```

That provider is core's `LayoutProvider` with the Next adapters already
installed. No provider is added to the tree and no props change. Apps that
compose `AdapterProvider` themselves can keep using `NextAdapterProvider`
directly.

Without either, internal links full-page reload and images skip `next/image`
optimization. The DOM fallbacks are what make core usable outside Next, and are
covered by `adapter-fallbacks.test.tsx`.

Automatic detection was investigated and rejected on evidence rather than
taste. The bundler half works — a guarded `await import("next/link")` builds
clean under esbuild and Vite with no Next installed, and degrades to the DOM
fallback. React is the blocker: the DOM `useNavigate` returns a closure while
the Next one calls `useRouter` and `useCallback`, so swapping implementations
after mount breaks the rules of hooks. Resolution must therefore settle before
the first render, and a browser bundle has no synchronous way to conditionally
resolve an optional module.

### Breaking

**Prop API standardisation.** Every place where one prop name carried two
meanings, or one meaning went by two names, is resolved. All of it is
mechanical: `scripts/codemod-2.0.mjs` applies the renames component-scoped,
and running it twice is a no-op.

```bash
node scripts/codemod-2.0.mjs src
```

Boolean props that toggle visibility now read `showX`, leaving the plain name
for the thing itself:

| Component | 1.8.x | 2.0 |
| --- | --- | --- |
| `ProgressBar` | `label?: boolean` | `showLabel` |
| `Feedback`, `Toast` | `icon?: boolean` | `showIcon` |
| `DataTooltip` | `colors?: boolean` | `showSwatches` |

State props drop the `is`/`has` prefix, restoring the convention the docs
already prescribed (`basics/components` — "use `open` instead of `isOpen`"):

| Component | 1.8.x | 2.0 |
| --- | --- | --- |
| `Dialog`, `Modal`, `DatePicker`, `DropdownWrapper`, `EmojiPickerDropdown`, `KbarContent` | `isOpen` | `open` |
| `Checkbox`, `RadioButton`, `Switch` | `isChecked` | `checked` |
| `Checkbox` | `isIndeterminate` | `indeterminate` |
| `DatePicker`, `DropdownWrapper` | `isNested` | `nested` |
| `NavIcon` | `isActive` | `active` |
| `Input`, `Textarea`, `Option` | `hasPrefix` / `hasSuffix` | `prefix` / `suffix` |

Four of those names were held by React's own DOM attribute types — `checked`
and `size` on `InputHTMLAttributes`, `prefix` on the base `HTMLAttributes` (the
RDFa attribute) — which is why the prefixes existed at all. Those components
now `Omit` the inherited declaration and declare their own. The cost is that
the native attribute can no longer be forwarded: `<Input size>` is the token
scale, not the HTML character-width attribute.

`radius` now means one thing everywhere — the roundness scale that
`StyleProps` has always defined. Corner selection, which had been overloading
the same name on five components, moves to `corners`:

```diff
- <Button radius="top-left" />
+ <Button corners="top-left" />
<Button radius="none" /> // unchanged — "none" is roundness, not a corner
```

Affects `Button`, `IconButton`, `Input`, `Textarea`, `ToggleButton`. This is
the one rename the codemod decides by value rather than by name; a computed
`radius={expr}` is reported rather than rewritten.

`variant` now means appearance everywhere. `Pulse` and `Tag` were using it
for a **colour scheme**, which is why the prop had seven incompatible value
spaces across thirteen components; both now take `scheme`.

**Timing props are milliseconds, consistently.** An audit found the library was
split three ways: most timings were already ms (`Animation.duration` and
`.delay`, `TypeFx.speed`/`.delay`, `Hover.delay`/`.hideDelay`,
`GlitchFx.interval`, `Carousel.play.interval`, `CountFx.speed`,
`RevealFx.speed`), two were seconds, and four `speed` props on
`CelebrationFx`, `WeatherFx`, `MatrixFx` and `Particle` are unitless
multipliers that are not durations at all and are unchanged. The two outliers
move to ms:

```diff
- <RevealFx delay={0.2} /> <ShineFx speed={0.75} />
+ <RevealFx delay={200} /> <ShineFx speed={750} />
```

`RevealFx` was the sharpest case: its `delay` was seconds while its own
`speed`, on the next line of the same interface, was already milliseconds.

**`Skeleton` drops its second size scale.** It extends `Flex`, so width is now
expressed the way it is on any other element — `width="80%"`, `maxWidth={24}` —
instead of a five-step scale that only ever meant percentages. What remains is
`size`: the height of a `line`, the diameter of a `circle`. `delay` becomes
milliseconds rather than a `"1".."6"` index into six fixed classes.

```diff
- <Skeleton shape="line" height="s" width="l" delay="3" />
+ <Skeleton shape="line" size="s" width="75%" delay={300} />
```

**Colour props that paint into SVG accept tokens again.** `color` on
`LinearGauge`, `RadialGauge`, `Particle` and the chart module, and
`colorStart` / `colorEnd` / `color` on `Background`'s gradient, dots, grid and
lines, were typed as bare `string` — so a design token was accepted but never
suggested, and a typo in one was never caught. They now take `ColorValue`,
which is `Colors | (string & {})`: tokens autocomplete, and a raw `#fff`,
`rgb(...)` or `var(...)` still passes, because these values are painted into
SVG rather than applied through a class.

Other divergences resolved:

- `Input` and `Textarea` take `size` instead of `height`. It was always a
t-shirt scale rather than a dimension, and only spelled `height` because
`size` was inherited from the DOM.
- `SegmentedControl` becomes an ordinary controlled input: `selected` → `value`,
`onToggle` → `onChange`, `defaultSelected` → `defaultValue`. `selected` is a
boolean on the five other components that have it, and `onToggle` is
`() => void` on the other four.
- `RevealFx` takes `revealed` instead of `trigger`. It is controlled state;
`trigger` elsewhere is either the element that opens something or a mode union.
- `ColorInput`'s `onChange` hands back the value, `(value: string) => void`,
like every other `onChange` in the library, instead of a hand-built
`ChangeEvent`. **The codemod flags this one but cannot rewrite the callback
body** — the signature changed, so the handler needs a human.

Components deriving their props from `Input` (`Select`, `NumberInput`,
`TagInput`, `ColorInput`, `DateInput`, `DateRangeInput`, `PasswordInput`) and
from `DropdownWrapper` (`EmojiPickerDropdown`) inherit these renames; the
codemod knows their tags. Property accesses on `ComponentProps<typeof X>`
(`props.isChecked`) are not JSX and are surfaced by `tsc`, not rewritten.

### Fixed

- **Icon-only controls announce what they do, not which glyph they use.**
`IconButton` falls back to the icon *name* as its accessible label when given
no `tooltip` and no `aria-label` — so a carousel control announced
"chevronRight button" and table pagination announced "chevronDoubleLeft
button". The fallback stays, because an unnamed button is worse than a badly
named one, but core's own components no longer rely on it: 21 call sites
across `Table`, `Carousel`, `DatePicker`, `ScrollContainer`, `CompareImage`,
`PasswordInput`, `InteractiveDetails`, `StyleOverlay`, `CodeBlock` and
`ChartHeader` now carry real labels, and a test fails the build if a new one
appears.

`StylePanel`'s four swatch pickers were worse than mislabelled: the click
handler and `tabIndex` sit on a wrapping `Flex`, so the focusable element was
a div with no role and no name at all, while the `IconButton` inside was
decorative. The label, `role="button"` and `aria-pressed` now sit on the
element that is actually the control.

- **`opacity={0}` and `zIndex={0}` now work.** Both are legal values — `Opacity`
includes `0`, `zIndex` includes `-1` and `0` — and `.opacity-0` / `.z-index-0`
ship in the stylesheet, but the class list guarded them on truthiness rather
than presence, so the single most useful value of each prop (hide a layer, pin
to the base stacking level) silently did nothing. The responsive `opacity`
variants already had the correct check; the base value and all four `zIndex`
breakpoints did not. Found while building a hover cross-fade, where both
images rendered at full opacity, stacked.

### Added

- **`Logo` takes per-theme sources.** `icon` and `wordmark` now accept
`{ light, dark }` as well as a plain string, so one element covers both
themes instead of two rendered side by side with the `light` and `dark`
props hiding one of them. A row of four client logos was eight elements and
two places to keep in sync for every change; it is now four and one. Both
assets are rendered and CSS picks, rather than reading the theme at runtime —
that keeps `Logo` server-renderable and avoids a flash of the wrong mark on
first paint. Plain strings are unchanged, and the whole-element `light` /
`dark` props still work for gating a logo to one theme deliberately.
- **`Book`** — a book with a real 3D cover: perspective on the wrapper, a
`preserve-3d` context shared by cover and page block, and pages hinged onto
the cover's right edge, so the hover turn reads correctly from any angle
rather than only head-on. Motion is hover-gated and disabled under
`prefers-reduced-motion`; on touch the cover stays square-on. Links through
`ElementType`, so it routes via the adapter like every other core link.
- **`MediaAudioPlayer`** — play/pause, a scrubbable progress bar and
elapsed/total time, the audio counterpart to `MediaVideoPlayer` and imported
from the same `./components/*` subpath rather than the root barrel. It takes
an `onTimeUpdate` callback so a caller can synchronise something with
playback — narration highlighting, a transcript, chapter markers — without
the player needing to know what is being synchronised.
- `LayoutProvider` is now also exported from `@once-ui-system/core/next`, with the
Next adapters pre-installed. It makes the adapter migration a single import-path
change rather than a new provider in the tree, and it is what a codemod can apply
mechanically. `NextAdapterProvider` is unchanged and still exported for apps that
compose their own adapters.
- **Core installs and runs without Next.js.** `next` (along with `sass` and
`sharp`) is now an optional peer dependency, and the last runtime `next/*`
imports are gone: `Schema` emits a plain `<script type="application/ld+json">`
instead of `next/script`, `server/og-utils` returns a standard `Response`
instead of `NextResponse`, and `Meta.generate` declares its own return type
rather than importing Next's `Metadata`. The only file in the package that
touches `next/*` is the opt-in `@once-ui-system/core/next` adapter, and the
framework-boundary test now pins the allowlist to that one file.

Verified by packing the tarball and server-rendering `SmartLink`, `Button`,
`Media`, `Row`, `Column`, `Text` and `Schema` in a React app with no `next`
in `node_modules`.

Next.js apps are unaffected in every respect except the adapter step above —
the peer range is unchanged when Next *is* present.

- `@once-ui-system/foundations` — tokens, styles, and token-value types extracted
into their own package (RFC Phase 1). Core depends on it at build time only and
inlines its SCSS/CSS into `dist`, so every existing import and CSS entry
into their own package (RFC Phase 1). It is **not published to npm** and is not a
dependency of this release: core consumes it at build time only and inlines its
SCSS/CSS into `dist`, so every existing import and CSS entry
(`@once-ui-system/core/css/tokens.css`) keeps working and consumers install
nothing new. This is the package a non-React consumer can adopt directly and the
base the planned Tailwind token bridge maps onto.
nothing new. Publishing it — so a non-React consumer can adopt it directly, and
as the base the planned Tailwind token bridge maps onto — is a separate decision
on its own timeline.
- Package-contract and boundary test infrastructure (RFC Phase 0): `check:package`
(publint + arethetypeswrong), the exports-integrity test, a framework-boundary
guard (core may not import `next/*`), CSS API-surface snapshots, token
custom-property/attribute-selector snapshots, and seed interaction tests.

### Changed

- Core no longer imports `next/*` at runtime; Next.js users keep identical behavior
through installed defaults. Flipping peer dependencies stays a 2.0 concern.
- Core no longer imports `next/*` at runtime. Next.js apps keep 1.8.x behavior by
installing `NextAdapterProvider` from `@once-ui-system/core/next` in the root
layout; without it the five components listed above fall back to plain DOM.
Flipping peer dependencies stays a 2.0 concern.

### Fixed

- `@once-ui-system/foundations` was declared in core's **`dependencies`** as
`workspace:*`. `pnpm publish` rewrites that protocol to the depended-on package's
literal version, so the 1.9.0 tarball declared a hard runtime dependency on
literal version, so the packed tarball declared a hard runtime dependency on
`@once-ui-system/foundations@2.0.0-alpha.0` — an unpublished package. Every
`npm install @once-ui-system/core@1.9.0` would have failed with E404, and a stable
minor would have pinned consumers to an alpha. Core has no runtime import of
`npm install @once-ui-system/core` would have failed with E404, and a stable
release would have pinned consumers to an alpha. Core has no runtime import of
foundations (the build inlines its SCSS/CSS into `dist`), so it moves to
`devDependencies`, which consumers never install. A new
`publishable-dependencies.test.ts` fails on any workspace-protocol or pre-release
Expand Down
41 changes: 41 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,52 @@ This repo uses a monorepo layout with pnpm workspaces and Turborepo:
| Path | Description |
|------|-------------|
| `packages/core` | The Once UI package [@once-ui-system/core](https://www.npmjs.com/package/@once-ui-system/core) — all components, tokens, and utilities |
| `packages/foundations` | Tokens, styles, and token-value types. Core consumes it at build time and inlines its SCSS/CSS into `dist`; it is not published to npm |
| `apps/dev` | Local sandbox app for testing components (Next.js 16, port 3001) |
| `apps/docs` | Documentation site at [docs.once-ui.com](https://docs.once-ui.com) (Next.js 16, port 3000) |

For the full directory layout and conventions, see [ARCHITECTURE.md](./ARCHITECTURE.md).

## Requirements

**Node `>=22.12.0`** (`.nvmrc` pins 22), and pnpm 10.

Two separate reasons for that floor:

- **22.12.0 is where `require()` of an ESM module landed.** Sass depends on
chokidar 5, which is ESM-only, and loads it through `require()`. On an older
Node the foundations build dies with an `ERR_REQUIRE_ESM` stack trace from
inside Sass that names neither Node nor the real constraint. Node 21.x is the
nastiest case — it satisfies a naive `>=20` range but was retired before the
backport, so it passes the check and still cannot build.
- **Node 20 reached end of life on 2026-04-30**, and Vercel stops building
projects on Node 20 or older from 2026-10-01. The 20.19 line could technically
run the build, but there is no reason to keep a dead runtime in the supported
set.

**macOS / Linux** (nvm reads `.nvmrc`):

```bash
nvm install && nvm use
node --version # expect v22.x
```

**Windows** — nvm-windows does *not* read `.nvmrc`, so name the version:

```powershell
nvm install 22
nvm use 22
node --version # expect v22.x
```

Switching Node in one terminal does not affect terminals that are already
open, and some editors spawn their own shell — if a build still reports the
old version, reopen it.

`pnpm install` and the foundations build both run `scripts/check-node.mjs`,
which fails with the version it found and how to fix it rather than letting
Sass throw `ERR_REQUIRE_ESM` from somewhere deep in `node_modules`.

## Running the dev environment

The dev app is symlinked to the core package for rapid iteration.
Expand Down
6 changes: 3 additions & 3 deletions apps/dev/src/app/(main)/chat/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -409,9 +409,9 @@ export default function ChatPage() {
<Input
id="chat-search"
placeholder="Search conversations"
height="s"
size="s"
variant="ghost"
hasPrefix={<Icon name="search" size="xs" onBackground="neutral-weak" />}
prefix={<Icon name="search" size="xs" onBackground="neutral-weak" />}
/>
</Column>
<Column flex={1} overflowY="auto" paddingBottom="8" gap="1">
Expand Down Expand Up @@ -530,7 +530,7 @@ export default function ChatPage() {
<Input
id="chat-message"
placeholder={threadPlaceholder(activeConversation)}
height="s"
size="s"
variant="ghost"
value={draft}
onChange={(e) => setDraft(e.target.value)}
Expand Down
Loading