@ponchia/ui 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ponchia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,160 @@
1
+ # @ponchia/ui
2
+
3
+ Shared UI framework for Bronto personal projects. Nothing-inspired:
4
+ monochrome surfaces, a single red accent, dot-matrix display type (Doto),
5
+ flat hairline borders, restrained motion. CSS-first and framework-agnostic.
6
+
7
+ ## Use
8
+
9
+ Install from npm (public, no registry config):
10
+
11
+ ```bash
12
+ npm i @ponchia/ui
13
+ ```
14
+
15
+ > Naming: the **npm package** is `@ponchia/ui` (the `@bronto` scope isn't
16
+ > ownable). The **CSS layer** and behavior attributes stay `bronto`
17
+ > (`@layer bronto`, `data-bronto-*`) — that's the design-system namespace,
18
+ > deliberately distinct from the package name. See
19
+ > [`docs/architecture.md`](docs/architecture.md).
20
+
21
+ > Not published yet — the only remaining blocker is npm-account side
22
+ > (create the `@ponchia` scope + `NPM_TOKEN`; see [Release](#release)).
23
+ > Until the first publish, depend on a `file:` link to a checkout or a
24
+ > pinned git tag:
25
+ >
26
+ > ```json
27
+ > { "dependencies": { "@ponchia/ui": "file:../bronto-ui" } }
28
+ > ```
29
+ >
30
+ > ```json
31
+ > { "dependencies": { "@ponchia/ui": "github:Ponchia/bronto-ui#semver:^0.2.0" } }
32
+ > ```
33
+
34
+ Import the full theme (includes responsive breakpoints):
35
+
36
+ ```css
37
+ @import '@ponchia/ui/css';
38
+ ```
39
+
40
+ Or the core bundle if the app manages its own responsive layer:
41
+
42
+ ```css
43
+ @import '@ponchia/ui/css/core.css';
44
+ ```
45
+
46
+ The Doto `@font-face` ships in `css/fonts.css` (bundled by both `css` and
47
+ `css/core.css`) with URLs relative to the package, so it resolves through a
48
+ bundler or static serving with no `/fonts` path assumption. To self-host the
49
+ font instead, import everything except `fonts.css` and override `--display` /
50
+ `--dot-font`.
51
+
52
+ Everything ships inside a single `@layer bronto`, so any un-layered CSS in
53
+ your app overrides the framework without a specificity fight or `!important`.
54
+ The layer is applied by the `css` and `css/core.css` bundles only —
55
+ importing an individual leaf such as `@ponchia/ui/css/primitives.css`
56
+ directly is **unlayered** (full specificity), which is an intentional
57
+ escape hatch, not the default path.
58
+
59
+ Set `data-theme="light"` or `data-theme="dark"` on `<html>`; defaults follow
60
+ `prefers-color-scheme`.
61
+
62
+ ## Entrypoints
63
+
64
+ The CSS is the framework. These optional sibling entrypoints are thin layers
65
+ on top of it — none pull in a UI framework. See
66
+ [`docs/architecture.md`](docs/architecture.md) for the rationale.
67
+
68
+ ```js
69
+ import tokens, { cssVars, themeColor } from '@ponchia/ui/tokens'; // tokens as data (+ /tokens.json)
70
+ import { ui, cx } from '@ponchia/ui/classes'; // typed class-name recipes
71
+ import { initThemeToggle, dismissible } from '@ponchia/ui/behaviors'; // vanilla, SSR-safe
72
+ ```
73
+
74
+ ```js
75
+ ui.button({ variant: 'ghost' }); // → "ui-button ui-button--ghost"
76
+ themeColor('dark').accent; // → "#ff3b41"
77
+ ```
78
+
79
+ `behaviors` wires `[data-bronto-theme-toggle]`, `[data-bronto-dismiss]` /
80
+ `[data-bronto-dismissible]`, and `[data-bronto-disclosure]`. Each initializer
81
+ is SSR-safe and returns a cleanup function. `demo/index.html` drives itself
82
+ with these modules, so it is also a live integration test.
83
+
84
+ ## Layout
85
+
86
+ | File | Contents |
87
+ | ---------------- | ------------------------------------------------------------- |
88
+ | `tokens.css` | palette (dual light/dark), spacing, type, motion, dot tokens |
89
+ | `fonts.css` | Doto `@font-face` (relative URLs; optional if self-hosting) |
90
+ | `base.css` | reset, element defaults, focus, scrollbars |
91
+ | `motion.css` | keyframes + animation utilities + reduced-motion |
92
+ | `dots.css` | dot-grid, dot rule, status dot, dot loader/bar, matrix reveal |
93
+ | `primitives.css` | `ui-*` buttons, cards, chips, badges, links, key/value |
94
+ | `forms.css` | inputs, select, textarea, search, switch, checkbox |
95
+ | `table.css` | `ui-table` dense / comfortable |
96
+ | `app.css` | admin shell: rail, topbar, toolbar, panel, metrics |
97
+ | `navigation.css` | site nav, menu, theme toggle (dot indicator) |
98
+ | `typography.css` | display headings, eyebrows, legacy `.button` |
99
+ | `cards.css` | personal-site semantic cards (token-driven) |
100
+ | `layout.css` | site shell, hero, grids |
101
+ | `responsive.css` | breakpoint overrides |
102
+
103
+ ## Demo
104
+
105
+ `demo/index.html` is a kitchen sink covering every primitive in both themes.
106
+ Serve the package root and open `/demo/`:
107
+
108
+ ```bash
109
+ python3 -m http.server -d . 8080 # then open http://localhost:8080/demo/
110
+ ```
111
+
112
+ ## Develop
113
+
114
+ ```bash
115
+ npm install # stylelint + jsdom (test only)
116
+ npm run check # lint + 4 integrity checks (exports, tokens, classes, pack)
117
+ npm test # node:test — pure modules + jsdom behavior tests
118
+ npm run lint:fix # auto-fix the safe stylistic rules
119
+ npm run tokens:build # regenerate tokens/index.json from tokens/index.js
120
+ ```
121
+
122
+ `npm run check` enforces that the data mirrors cannot drift from the CSS:
123
+ exports/import-graph integrity, `tokens.css` ⇄ `tokens/index.{js,json}`, the
124
+ `classes` registry ⇄ the `.ui-*` selectors, and that the published tarball
125
+ ships only the intended files. CI
126
+ (`.github/workflows/ci.yml`) runs it on every branch push and PR. It never
127
+ publishes — a push to `main` ships nothing.
128
+
129
+ ## Release
130
+
131
+ Releases publish to npm and are tag-driven:
132
+
133
+ ```bash
134
+ # 1. bump "version" in package.json, land on main, let CI go green
135
+ git tag vX.Y.Z && git push origin vX.Y.Z
136
+ ```
137
+
138
+ The tag triggers `.github/workflows/release.yml`: `validate` (read-only
139
+ checks + tag↔version match) → `publish-npm` (only if validate passes) +
140
+ `release-notes`. **The npm publish is the gate** — a failing check means
141
+ the version never reaches npm, so consumers never resolve it. GitHub also
142
+ serves the raw tag tarball ungated, but that is a legacy/fallback path, not
143
+ the documented install. See [`docs/architecture.md`](docs/architecture.md).
144
+
145
+ **Before the first real publish** — one npm-account step remains:
146
+
147
+ - Create the `@ponchia` npm scope and add an `NPM_TOKEN` repo secret
148
+ (an npm automation token with publish rights to the scope).
149
+
150
+ Done already: MIT `LICENSE`, `version` `0.2.0`, `publishConfig`, the
151
+ gated publish workflow. After publishing, switch the consumer repos'
152
+ dependency specifier to `@ponchia/ui`.
153
+
154
+ ## Consumers
155
+
156
+ - `personal-site` — imports `@ponchia/ui/css/core.css`
157
+ - `polpo-admin` — imports `@ponchia/ui/css`
158
+
159
+ Both still pin the old tarball URL; they switch their dependency to
160
+ `@ponchia/ui` once it is published (separate repos — not changed here).
@@ -0,0 +1,40 @@
1
+ /** @ponchia/ui — optional, framework-agnostic behaviors. */
2
+
3
+ /** Cleanup function returned by every initializer. */
4
+ export type Cleanup = () => void;
5
+
6
+ export interface ThemeStorageOpts {
7
+ /** localStorage key for the persisted theme. Default: "bronto-theme". */
8
+ storageKey?: string;
9
+ }
10
+
11
+ export interface ApplyThemeOpts extends ThemeStorageOpts {
12
+ /** Element to set `data-theme` on. Default: <html>. */
13
+ root?: Element;
14
+ }
15
+
16
+ export interface DelegateOpts {
17
+ /** Event-delegation root; also scopes which controls are queried. Default: document. */
18
+ root?: Document | Element;
19
+ }
20
+
21
+ /** `bronto:themechange` CustomEvent detail. */
22
+ export interface ThemeChangeDetail {
23
+ theme: 'light' | 'dark';
24
+ }
25
+
26
+ /** Apply the persisted theme to <html data-theme>. Call before paint. */
27
+ export declare function applyStoredTheme(opts?: ApplyThemeOpts): void;
28
+
29
+ /**
30
+ * Wire `[data-bronto-theme-toggle]` controls. Theme is always applied to
31
+ * <html>; `root` only scopes delegation/queried controls. Dispatches
32
+ * `bronto:themechange` on <html>. Returns a cleanup function.
33
+ */
34
+ export declare function initThemeToggle(opts?: ThemeStorageOpts & DelegateOpts): Cleanup;
35
+
36
+ /** Wire `[data-bronto-dismiss]` controls. Returns a cleanup function. */
37
+ export declare function dismissible(opts?: DelegateOpts): Cleanup;
38
+
39
+ /** Wire `[data-bronto-disclosure]` triggers. Returns a cleanup function. */
40
+ export declare function initDisclosure(opts?: DelegateOpts): Cleanup;
@@ -0,0 +1,139 @@
1
+ /**
2
+ * @ponchia/ui — optional behaviors.
3
+ *
4
+ * The framework is CSS-first. This is the sanctioned home for the small
5
+ * amount of JS that genuinely needs scripting (theme persistence, dismiss,
6
+ * disclosure), so consumers don't each reimplement it.
7
+ *
8
+ * Framework-agnostic, dependency-free, side-effect-free on import, and
9
+ * SSR-safe (every entry no-ops without a DOM). Each initializer uses event
10
+ * delegation off a root and returns a cleanup function.
11
+ *
12
+ * import { applyStoredTheme, initThemeToggle } from '@ponchia/ui/behaviors';
13
+ * applyStoredTheme(); // before paint, avoids theme flash
14
+ * const stop = initThemeToggle(); // wire [data-bronto-theme-toggle]
15
+ */
16
+
17
+ const THEMES = ['light', 'dark'];
18
+ const noop = () => {};
19
+ const hasDom = () => typeof document !== 'undefined';
20
+
21
+ /**
22
+ * Apply the persisted theme to <html data-theme>. Call as early as
23
+ * possible (an inline module in <head>) to avoid a flash before the
24
+ * toggle wires up. No stored value → leaves prefers-color-scheme to act.
25
+ */
26
+ export function applyStoredTheme({ storageKey = 'bronto-theme', root } = {}) {
27
+ if (!hasDom()) return;
28
+ const el = root || document.documentElement;
29
+ let stored = null;
30
+ try {
31
+ stored = localStorage.getItem(storageKey);
32
+ } catch {
33
+ /* storage blocked (private mode / sandbox) — fall through to OS default */
34
+ }
35
+ if (stored && THEMES.includes(stored)) el.setAttribute('data-theme', stored);
36
+ }
37
+
38
+ /**
39
+ * Wire `[data-bronto-theme-toggle]` controls. Click toggles light/dark,
40
+ * persists to localStorage, and **always** sets `data-theme` on <html>
41
+ * (a theme is document-global). State is reflected via `aria-pressed`
42
+ * and a `bronto:themechange` CustomEvent ({ detail: { theme } }) is
43
+ * dispatched on <html> so consumers can sync their own UI without
44
+ * racing the click handler. A control may set
45
+ * `data-bronto-theme-toggle="dark"` to force a specific theme.
46
+ *
47
+ * `root` scopes event delegation and which controls are queried/reflected
48
+ * (default `document`); it does not change where the theme is applied.
49
+ */
50
+ export function initThemeToggle({ storageKey = 'bronto-theme', root } = {}) {
51
+ if (!hasDom()) return noop;
52
+ const host = root || document;
53
+ const docEl = document.documentElement;
54
+
55
+ const prefersDark = () =>
56
+ typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: dark)').matches;
57
+
58
+ const current = () => {
59
+ const attr = docEl.getAttribute('data-theme');
60
+ if (THEMES.includes(attr)) return attr;
61
+ return prefersDark() ? 'dark' : 'light';
62
+ };
63
+
64
+ const reflect = () => {
65
+ const c = current();
66
+ host.querySelectorAll('[data-bronto-theme-toggle]').forEach((el) => {
67
+ const forced = el.getAttribute('data-bronto-theme-toggle');
68
+ // A forced control is "pressed" when its theme is the active one;
69
+ // a plain toggle reflects whether dark is active.
70
+ const pressed = THEMES.includes(forced) ? c === forced : c === 'dark';
71
+ el.setAttribute('aria-pressed', String(pressed));
72
+ });
73
+ };
74
+
75
+ const onClick = (e) => {
76
+ const trigger = e.target.closest('[data-bronto-theme-toggle]');
77
+ if (!trigger || !host.contains(trigger)) return;
78
+ const forced = trigger.getAttribute('data-bronto-theme-toggle');
79
+ const next = THEMES.includes(forced) ? forced : current() === 'dark' ? 'light' : 'dark';
80
+ docEl.setAttribute('data-theme', next);
81
+ try {
82
+ localStorage.setItem(storageKey, next);
83
+ } catch {
84
+ /* storage blocked — theme still applies for this session */
85
+ }
86
+ reflect();
87
+ docEl.dispatchEvent(
88
+ new CustomEvent('bronto:themechange', { detail: { theme: next }, bubbles: true })
89
+ );
90
+ };
91
+
92
+ applyStoredTheme({ storageKey });
93
+ reflect();
94
+ host.addEventListener('click', onClick);
95
+ return () => host.removeEventListener('click', onClick);
96
+ }
97
+
98
+ /**
99
+ * Click on `[data-bronto-dismiss]` removes the nearest ancestor matching
100
+ * `[data-bronto-dismissible]` (or the selector given as the attribute
101
+ * value). Emits a cancelable `bronto:dismiss` event first.
102
+ */
103
+ export function dismissible({ root } = {}) {
104
+ if (!hasDom()) return noop;
105
+ const host = root || document;
106
+ const onClick = (e) => {
107
+ const btn = e.target.closest('[data-bronto-dismiss]');
108
+ if (!btn || !host.contains(btn)) return;
109
+ const sel = btn.getAttribute('data-bronto-dismiss');
110
+ const target = sel ? btn.closest(sel) : btn.closest('[data-bronto-dismissible]');
111
+ if (!target) return;
112
+ const ev = new CustomEvent('bronto:dismiss', { bubbles: true, cancelable: true });
113
+ if (target.dispatchEvent(ev)) target.remove();
114
+ };
115
+ host.addEventListener('click', onClick);
116
+ return () => host.removeEventListener('click', onClick);
117
+ }
118
+
119
+ /**
120
+ * Disclosure: a `[data-bronto-disclosure]` trigger toggles the element
121
+ * referenced by its `aria-controls` id, keeping `aria-expanded` and the
122
+ * panel's `hidden` attribute in sync.
123
+ */
124
+ export function initDisclosure({ root } = {}) {
125
+ if (!hasDom()) return noop;
126
+ const host = root || document;
127
+ const onClick = (e) => {
128
+ const trigger = e.target.closest('[data-bronto-disclosure]');
129
+ if (!trigger || !host.contains(trigger)) return;
130
+ const id = trigger.getAttribute('aria-controls');
131
+ const panel = id && document.getElementById(id);
132
+ if (!panel) return;
133
+ const open = trigger.getAttribute('aria-expanded') === 'true';
134
+ trigger.setAttribute('aria-expanded', String(!open));
135
+ panel.hidden = open;
136
+ };
137
+ host.addEventListener('click', onClick);
138
+ return () => host.removeEventListener('click', onClick);
139
+ }
@@ -0,0 +1,69 @@
1
+ /** @ponchia/ui — typed class-name contract. */
2
+
3
+ export type ClassValue = string | false | null | undefined | ClassValue[];
4
+
5
+ /** The flat registry of every class @ponchia/ui defines. */
6
+ export declare const cls: Readonly<Record<string, string>>;
7
+
8
+ /** classnames-style joiner: skips falsy, flattens arrays. */
9
+ export declare function cx(...parts: ClassValue[]): string;
10
+
11
+ export interface ButtonOpts {
12
+ variant?: 'ghost' | 'subtle' | 'danger';
13
+ icon?: boolean;
14
+ }
15
+ export interface CardOpts {
16
+ accent?: boolean;
17
+ interactive?: boolean;
18
+ }
19
+ export interface BadgeOpts {
20
+ tone?: 'accent' | 'success' | 'warning' | 'danger';
21
+ }
22
+ export interface ChipOpts {
23
+ accent?: boolean;
24
+ }
25
+ export interface LinkOpts {
26
+ arrow?: boolean;
27
+ }
28
+ export interface DotOpts {
29
+ tone?: 'accent' | 'success' | 'warning' | 'danger';
30
+ live?: boolean;
31
+ }
32
+ export interface DotgridOpts {
33
+ accent?: boolean;
34
+ dense?: boolean;
35
+ }
36
+ export interface TableOpts {
37
+ density?: 'dense' | 'comfortable';
38
+ lined?: boolean;
39
+ }
40
+ export interface EyebrowOpts {
41
+ muted?: boolean;
42
+ }
43
+ export interface HintOpts {
44
+ error?: boolean;
45
+ }
46
+ export interface ClusterOpts {
47
+ between?: boolean;
48
+ }
49
+ export interface StaggerOpts {
50
+ auto?: boolean;
51
+ }
52
+
53
+ export interface Ui {
54
+ button(opts?: ButtonOpts): string;
55
+ card(opts?: CardOpts): string;
56
+ badge(opts?: BadgeOpts): string;
57
+ chip(opts?: ChipOpts): string;
58
+ link(opts?: LinkOpts): string;
59
+ dot(opts?: DotOpts): string;
60
+ dotgrid(opts?: DotgridOpts): string;
61
+ table(opts?: TableOpts): string;
62
+ eyebrow(opts?: EyebrowOpts): string;
63
+ hint(opts?: HintOpts): string;
64
+ cluster(opts?: ClusterOpts): string;
65
+ stagger(opts?: StaggerOpts): string;
66
+ }
67
+
68
+ export declare const ui: Ui;
69
+ export default ui;
@@ -0,0 +1,159 @@
1
+ /**
2
+ * @ponchia/ui — typed class-name contract.
3
+ *
4
+ * The framework's real API is its class vocabulary. Hand-writing
5
+ * "ui-button ui-button--ghost" everywhere is untyped and typo-prone, so
6
+ * this module turns that contract into data + tiny recipe builders:
7
+ *
8
+ * import { ui, cx } from '@ponchia/ui/classes';
9
+ * <button class={ui.button({ variant: 'ghost' })}>
10
+ * <span class={cx(ui.dot({ tone: 'success' }), 'my-extra')}>
11
+ *
12
+ * Framework-agnostic (returns strings). `cls` is the flat registry of
13
+ * every class the framework defines; recipes only ever emit from it, and
14
+ * scripts/check-classes.mjs fails CI if any entry is missing from the
15
+ * stylesheet — so this file cannot drift from the CSS.
16
+ */
17
+
18
+ /** Every class @ponchia/ui defines. The single source the recipes draw from. */
19
+ export const cls = Object.freeze({
20
+ // primitives
21
+ button: 'ui-button',
22
+ buttonGhost: 'ui-button--ghost',
23
+ buttonSubtle: 'ui-button--subtle',
24
+ buttonDanger: 'ui-button--danger',
25
+ buttonIcon: 'ui-button--icon',
26
+ card: 'ui-card',
27
+ cardHead: 'ui-card__head',
28
+ cardAccent: 'ui-card--accent',
29
+ cardInteractive: 'ui-card--interactive',
30
+ badge: 'ui-badge',
31
+ badgeAccent: 'ui-badge--accent',
32
+ badgeSuccess: 'ui-badge--success',
33
+ badgeWarning: 'ui-badge--warning',
34
+ badgeDanger: 'ui-badge--danger',
35
+ chip: 'ui-chip',
36
+ chipAccent: 'ui-chip--accent',
37
+ link: 'ui-link',
38
+ linkArrow: 'ui-link--arrow',
39
+ keyValue: 'ui-key-value',
40
+ // dots
41
+ dot: 'ui-dot',
42
+ dotAccent: 'ui-dot--accent',
43
+ dotSuccess: 'ui-dot--success',
44
+ dotWarning: 'ui-dot--warning',
45
+ dotDanger: 'ui-dot--danger',
46
+ dotLive: 'ui-dot--live',
47
+ dotgrid: 'ui-dotgrid',
48
+ dotgridAccent: 'ui-dotgrid--accent',
49
+ dotgridDense: 'ui-dotgrid--dense',
50
+ dotfield: 'ui-dotfield',
51
+ dotrule: 'ui-dotrule',
52
+ dotbar: 'ui-dotbar',
53
+ dotloader: 'ui-dotloader',
54
+ // forms
55
+ field: 'ui-field',
56
+ label: 'ui-label',
57
+ input: 'ui-input',
58
+ select: 'ui-select',
59
+ textarea: 'ui-textarea',
60
+ search: 'ui-search',
61
+ check: 'ui-check',
62
+ switch: 'ui-switch',
63
+ switchTrack: 'ui-switch__track',
64
+ switchThumb: 'ui-switch__thumb',
65
+ hint: 'ui-hint',
66
+ hintError: 'ui-hint--error',
67
+ // table
68
+ table: 'ui-table',
69
+ tableDense: 'ui-table--dense',
70
+ tableComfortable: 'ui-table--comfortable',
71
+ tableLined: 'ui-table--lined',
72
+ tableWrap: 'ui-table-wrap',
73
+ tableEmpty: 'ui-table__empty',
74
+ // shell / layout
75
+ panel: 'ui-panel',
76
+ panelHead: 'ui-panel__head',
77
+ surface: 'ui-surface',
78
+ stack: 'ui-stack',
79
+ cluster: 'ui-cluster',
80
+ clusterBetween: 'ui-cluster--between',
81
+ grid: 'ui-grid',
82
+ divider: 'ui-divider',
83
+ status: 'ui-status',
84
+ // typography / utilities
85
+ eyebrow: 'ui-eyebrow',
86
+ eyebrowMuted: 'ui-eyebrow--muted',
87
+ display: 'ui-display',
88
+ mono: 'ui-mono',
89
+ muted: 'ui-muted',
90
+ visuallyHidden: 'ui-visually-hidden',
91
+ // motion
92
+ reveal: 'ui-reveal',
93
+ stagger: 'ui-stagger',
94
+ staggerAuto: 'ui-stagger--auto',
95
+ matrix: 'ui-matrix',
96
+ skeleton: 'ui-skeleton',
97
+ spinner: 'ui-spinner',
98
+ caret: 'ui-caret',
99
+ animateIn: 'ui-animate-in',
100
+ animateFade: 'ui-animate-fade',
101
+ animateDot: 'ui-animate-dot',
102
+ animateMatrix: 'ui-animate-matrix',
103
+ });
104
+
105
+ /** classnames-style joiner: skips falsy, flattens nested arrays of any depth. */
106
+ export function cx(...parts) {
107
+ const out = [];
108
+ for (const p of parts.flat(Infinity)) if (p) out.push(p);
109
+ return out.join(' ');
110
+ }
111
+
112
+ const j = (...p) => p.filter(Boolean).join(' ');
113
+
114
+ export const ui = {
115
+ button: ({ variant, icon } = {}) =>
116
+ j(
117
+ cls.button,
118
+ variant === 'ghost' && cls.buttonGhost,
119
+ variant === 'subtle' && cls.buttonSubtle,
120
+ variant === 'danger' && cls.buttonDanger,
121
+ icon && cls.buttonIcon
122
+ ),
123
+ card: ({ accent, interactive } = {}) =>
124
+ j(cls.card, accent && cls.cardAccent, interactive && cls.cardInteractive),
125
+ badge: ({ tone } = {}) =>
126
+ j(
127
+ cls.badge,
128
+ tone === 'accent' && cls.badgeAccent,
129
+ tone === 'success' && cls.badgeSuccess,
130
+ tone === 'warning' && cls.badgeWarning,
131
+ tone === 'danger' && cls.badgeDanger
132
+ ),
133
+ chip: ({ accent } = {}) => j(cls.chip, accent && cls.chipAccent),
134
+ link: ({ arrow } = {}) => j(cls.link, arrow && cls.linkArrow),
135
+ dot: ({ tone, live } = {}) =>
136
+ j(
137
+ cls.dot,
138
+ tone === 'accent' && cls.dotAccent,
139
+ tone === 'success' && cls.dotSuccess,
140
+ tone === 'warning' && cls.dotWarning,
141
+ tone === 'danger' && cls.dotDanger,
142
+ live && cls.dotLive
143
+ ),
144
+ dotgrid: ({ accent, dense } = {}) =>
145
+ j(cls.dotgrid, accent && cls.dotgridAccent, dense && cls.dotgridDense),
146
+ table: ({ density, lined } = {}) =>
147
+ j(
148
+ cls.table,
149
+ density === 'dense' && cls.tableDense,
150
+ density === 'comfortable' && cls.tableComfortable,
151
+ lined && cls.tableLined
152
+ ),
153
+ eyebrow: ({ muted } = {}) => j(cls.eyebrow, muted && cls.eyebrowMuted),
154
+ hint: ({ error } = {}) => j(cls.hint, error && cls.hintError),
155
+ cluster: ({ between } = {}) => j(cls.cluster, between && cls.clusterBetween),
156
+ stagger: ({ auto } = {}) => j(cls.stagger, auto && cls.staggerAuto),
157
+ };
158
+
159
+ export default ui;