@ponchia/ui 0.2.0 → 0.2.2

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/README.md CHANGED
@@ -18,19 +18,6 @@ npm i @ponchia/ui
18
18
  > deliberately distinct from the package name. See
19
19
  > [`docs/architecture.md`](docs/architecture.md).
20
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
21
  Import the full theme (includes responsive breakpoints):
35
22
 
36
23
  ```css
@@ -43,6 +30,27 @@ Or the core bundle if the app manages its own responsive layer:
43
30
  @import '@ponchia/ui/css/core.css';
44
31
  ```
45
32
 
33
+ **Prebuilt single file (recommended for apps without a CSS bundler).**
34
+ `@ponchia/ui/css` is a 17-deep `@import` graph — fine through a bundler,
35
+ a load waterfall over plain HTTP. The package also ships flattened,
36
+ minified bundles with no `@import` chain:
37
+
38
+ ```css
39
+ @import '@ponchia/ui'; /* → dist/bronto.css, full */
40
+ @import '@ponchia/ui/dist/bronto-core.css'; /* core, no responsive */
41
+ ```
42
+
43
+ ~62 kB raw / ~11 kB gzip, one request, same `@layer bronto`. Source CSS,
44
+ tokens/classes/behaviors entrypoints are unchanged — use whichever fits.
45
+
46
+ ### Browser support
47
+
48
+ Evergreen only — the framework relies on cascade layers (`@layer`),
49
+ `:has()`, `color-mix()`, CSS logical properties and native `<dialog>`.
50
+ Floor: **Chrome/Edge 111+, Safari 16.4+, Firefox 121+** (early 2023
51
+ onward). No build-time fallback is shipped; pin an older tag if you must
52
+ support below this.
53
+
46
54
  The Doto `@font-face` ships in `css/fonts.css` (bundled by both `css` and
47
55
  `css/core.css`) with URLs relative to the package, so it resolves through a
48
56
  bundler or static serving with no `/fonts` path assumption. To self-host the
@@ -59,6 +67,11 @@ escape hatch, not the default path.
59
67
  Set `data-theme="light"` or `data-theme="dark"` on `<html>`; defaults follow
60
68
  `prefers-color-scheme`.
61
69
 
70
+ **Re-brand with one knob:** `--accent` drives the whole accent family
71
+ (`color-mix`-derived). `:root { --accent: #2f6df6 }` — or scope it to a
72
+ subtree — restyles everything, both themes. Plus `data-density` and
73
+ `data-contrast` presets. Full contract: [`docs/theming.md`](docs/theming.md).
74
+
62
75
  ## Entrypoints
63
76
 
64
77
  The CSS is the framework. These optional sibling entrypoints are thin layers
@@ -77,9 +90,12 @@ themeColor('dark').accent; // → "#ff3b41"
77
90
  ```
78
91
 
79
92
  `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.
93
+ `[data-bronto-dismissible]`, `[data-bronto-disclosure]`, and native
94
+ `<dialog>` glue (`initDialog`: `[data-bronto-open]` / `[data-bronto-close]`
95
+ / `[data-bronto-dialog-light]`). `toast(message, { tone, title, duration })`
96
+ pushes into a shared, body-anchored stack. Each initializer is SSR-safe and
97
+ returns a cleanup function. `demo/index.html` drives itself with these
98
+ modules, so it is also a live integration test.
83
99
 
84
100
  ## Layout
85
101
 
@@ -89,14 +105,18 @@ with these modules, so it is also a live integration test.
89
105
  | `fonts.css` | Doto `@font-face` (relative URLs; optional if self-hosting) |
90
106
  | `base.css` | reset, element defaults, focus, scrollbars |
91
107
  | `motion.css` | keyframes + animation utilities + reduced-motion |
92
- | `dots.css` | dot-grid, dot rule, status dot, dot loader/bar, matrix reveal |
108
+ | `dots.css` | dot-grid, dot rule, status dot, dot loader, orbital dot spinner, dot bar (+ indeterminate), matrix reveal |
93
109
  | `primitives.css` | `ui-*` buttons, cards, chips, badges, links, key/value |
94
110
  | `forms.css` | inputs, select, textarea, search, switch, checkbox |
111
+ | `feedback.css` | alert / callout, toast, tooltip, linear progress |
112
+ | `overlay.css` | modal + drawer (native `<dialog>`), dropdown menu |
113
+ | `disclosure.css` | tabs, accordion (`<details>`), segmented, breadcrumb, pagination, avatar |
95
114
  | `table.css` | `ui-table` dense / comfortable |
96
115
  | `app.css` | admin shell: rail, topbar, toolbar, panel, metrics |
97
116
  | `navigation.css` | site nav, menu, theme toggle (dot indicator) |
98
117
  | `typography.css` | display headings, eyebrows, legacy `.button` |
99
- | `cards.css` | personal-site semantic cards (token-driven) |
118
+ | `content.css` | `.ui-prose` Markdown/raw-HTML long-form, zero per-element classes |
119
+ | `cards.css` | semantic content cards (token-driven) |
100
120
  | `layout.css` | site shell, hero, grids |
101
121
  | `responsive.css` | breakpoint overrides |
102
122
 
@@ -113,18 +133,30 @@ python3 -m http.server -d . 8080 # then open http://localhost:8080/demo/
113
133
 
114
134
  ```bash
115
135
  npm install # stylelint + jsdom (test only)
116
- npm run check # lint + 4 integrity checks (exports, tokens, classes, pack)
136
+ npm run check # lint + 5 integrity checks (exports, tokens, classes, dist, pack)
117
137
  npm test # node:test — pure modules + jsdom behavior tests
118
- npm run lint:fix # auto-fix the safe stylistic rules
138
+ npm run lint:fix # auto-fix the safe stylistic rules + logical properties
119
139
  npm run tokens:build # regenerate tokens/index.json from tokens/index.js
140
+ npm run dist:build # rebuild the flattened dist/ bundles
120
141
  ```
121
142
 
122
143
  `npm run check` enforces that the data mirrors cannot drift from the CSS:
123
144
  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.
145
+ `classes` registry ⇄ the `.ui-*` selectors, `dist/` `css/` (fresh + in
146
+ budget), and that the published tarball ships only the intended files.
147
+
148
+ Visual + a11y regression is a separate suite (real browser, so not in the
149
+ zero-dep core check):
150
+
151
+ ```bash
152
+ npx playwright test # demo screenshots (light/dark/RTL) + axe-core WCAG
153
+ ```
154
+
155
+ It is pinned to a Playwright container so baselines are byte-stable; the
156
+ committed baselines under `test/e2e/__screenshots__` were authored in that
157
+ same image. CI (`.github/workflows/ci.yml`) runs both the `check` job and a
158
+ containerised `visual` job on every branch push and PR. It never publishes —
159
+ a push to `main` ships nothing.
128
160
 
129
161
  ## Release
130
162
 
@@ -142,19 +174,12 @@ the version never reaches npm, so consumers never resolve it. GitHub also
142
174
  serves the raw tag tarball ungated, but that is a legacy/fallback path, not
143
175
  the documented install. See [`docs/architecture.md`](docs/architecture.md).
144
176
 
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`.
177
+ Published: `@ponchia/ui` is live on npm (latest `0.2.1`), released by CI
178
+ with provenance. The `@ponchia` scope and the `NPM_TOKEN` repo secret are
179
+ in place, so a pushed `vX.Y.Z` tag is all a release needs.
153
180
 
154
181
  ## Consumers
155
182
 
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).
183
+ Built for two shapes of app: a content/marketing site (imports
184
+ `@ponchia/ui/css/core.css`) and an admin dashboard (imports the full
185
+ `@ponchia/ui/css`). Consuming apps depend on it via `@ponchia/ui`.
@@ -38,3 +38,32 @@ export declare function dismissible(opts?: DelegateOpts): Cleanup;
38
38
 
39
39
  /** Wire `[data-bronto-disclosure]` triggers. Returns a cleanup function. */
40
40
  export declare function initDisclosure(opts?: DelegateOpts): Cleanup;
41
+
42
+ /**
43
+ * Wire `[data-bronto-tabs]` groups with the WAI-ARIA Tabs keyboard
44
+ * pattern (roving tabindex, Arrow/Home/End, aria-selected, panel sync).
45
+ * Returns a cleanup function.
46
+ */
47
+ export declare function initTabs(opts?: DelegateOpts): Cleanup;
48
+
49
+ /**
50
+ * Wire native <dialog> open/close glue: `[data-bronto-open="id"]`,
51
+ * `[data-bronto-close]`, and backdrop light-dismiss for dialogs marked
52
+ * `[data-bronto-dialog-light]`. Returns a cleanup function.
53
+ */
54
+ export declare function initDialog(opts?: DelegateOpts): Cleanup;
55
+
56
+ export interface ToastOpts {
57
+ /** Status tone — maps to `ui-toast--<tone>`. */
58
+ tone?: 'accent' | 'success' | 'warning' | 'danger';
59
+ /** Optional uppercase label rendered above the message. */
60
+ title?: string;
61
+ /** Auto-dismiss delay in ms. 0 keeps it until dismissed. Default: 4000. */
62
+ duration?: number;
63
+ }
64
+
65
+ /**
66
+ * Push a transient toast into a shared, body-anchored stack. Returns a
67
+ * function that dismisses it early. No-op (returns noop) without a DOM.
68
+ */
69
+ export declare function toast(message: string, opts?: ToastOpts): Cleanup;
@@ -116,6 +116,178 @@ export function dismissible({ root } = {}) {
116
116
  return () => host.removeEventListener('click', onClick);
117
117
  }
118
118
 
119
+ /**
120
+ * Wire `[data-bronto-tabs]` groups for full keyboard a11y. The framework
121
+ * ships the look + the ARIA/`.is-active` contract; this adds the WAI-ARIA
122
+ * Tabs pattern: roving `tabindex`, `aria-selected`, Arrow/Home/End
123
+ * navigation with automatic activation, and panel `hidden` sync. Tabs are
124
+ * `.ui-tab[data-tab]`; panels are `.ui-tabs__panel[data-panel]` with
125
+ * matching values. SSR-safe; returns a cleanup function.
126
+ */
127
+ export function initTabs({ root } = {}) {
128
+ if (!hasDom()) return noop;
129
+ const host = root || document;
130
+ const cleanups = [];
131
+ let uid = 0;
132
+ // querySelectorAll only matches descendants, so a `root` that *is* a
133
+ // tab group would be skipped — include it explicitly.
134
+ const groups = [];
135
+ if (host !== document && host.matches?.('[data-bronto-tabs]')) groups.push(host);
136
+ groups.push(...host.querySelectorAll('[data-bronto-tabs]'));
137
+ for (const group of groups) {
138
+ // Own group only — a tab/panel inside a nested [data-bronto-tabs]
139
+ // belongs to that inner group, not this one.
140
+ const owned = (el) => el.closest('[data-bronto-tabs]') === group;
141
+ const tabs = [...group.querySelectorAll('.ui-tab')].filter(owned);
142
+ const panels = [...group.querySelectorAll('.ui-tabs__panel')].filter(owned);
143
+ if (!tabs.length) continue;
144
+ const list = group.querySelector('.ui-tabs__list');
145
+ if (list) list.setAttribute('role', 'tablist');
146
+
147
+ // APG: bind each tab to its panel (aria-controls) and back
148
+ // (aria-labelledby), minting stable ids only where absent.
149
+ for (const t of tabs) {
150
+ const p = panels.find((x) => x.dataset.panel === t.dataset.tab);
151
+ if (!p) continue;
152
+ const n = ++uid;
153
+ if (!t.id) t.id = `bronto-tab-${n}`;
154
+ if (!p.id) p.id = `bronto-tabpanel-${n}`;
155
+ t.setAttribute('aria-controls', p.id);
156
+ p.setAttribute('aria-labelledby', t.id);
157
+ }
158
+
159
+ const select = (tab) => {
160
+ for (const t of tabs) {
161
+ const on = t === tab;
162
+ t.classList.toggle('is-active', on);
163
+ t.setAttribute('role', 'tab');
164
+ t.setAttribute('aria-selected', String(on));
165
+ t.tabIndex = on ? 0 : -1;
166
+ }
167
+ for (const p of panels) {
168
+ p.setAttribute('role', 'tabpanel');
169
+ p.hidden = p.dataset.panel !== tab.dataset.tab;
170
+ }
171
+ };
172
+ const onClick = (e) => {
173
+ // `tabs` is filtered to this group, so membership (not mere DOM
174
+ // containment) is what isolates nested [data-bronto-tabs] groups.
175
+ const tab = e.target.closest('.ui-tab');
176
+ if (tab && tabs.includes(tab)) {
177
+ select(tab);
178
+ tab.focus();
179
+ }
180
+ };
181
+ const onKey = (e) => {
182
+ const i = tabs.indexOf(e.target.closest('.ui-tab'));
183
+ if (i < 0) return;
184
+ let n = i;
185
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') n = (i + 1) % tabs.length;
186
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') n = (i - 1 + tabs.length) % tabs.length;
187
+ else if (e.key === 'Home') n = 0;
188
+ else if (e.key === 'End') n = tabs.length - 1;
189
+ else return;
190
+ e.preventDefault();
191
+ select(tabs[n]);
192
+ tabs[n].focus();
193
+ };
194
+ group.addEventListener('click', onClick);
195
+ group.addEventListener('keydown', onKey);
196
+ select(tabs.find((t) => t.classList.contains('is-active')) || tabs[0]);
197
+ cleanups.push(() => {
198
+ group.removeEventListener('click', onClick);
199
+ group.removeEventListener('keydown', onKey);
200
+ });
201
+ }
202
+ return () => cleanups.forEach((fn) => fn());
203
+ }
204
+
205
+ /**
206
+ * Wire native <dialog> open/close glue (the one bit <dialog> can't do
207
+ * declaratively). Click `[data-bronto-open="dialogId"]` calls
208
+ * `showModal()` on `#dialogId`; click `[data-bronto-close]` closes the
209
+ * nearest enclosing <dialog>. Clicking the backdrop of a dialog that has
210
+ * `[data-bronto-dialog-light]` closes it too. SSR-safe; returns cleanup.
211
+ *
212
+ * `root` scopes which triggers are delegated (default `document`); the
213
+ * dialog itself is still resolved by id document-wide, because a modal
214
+ * <dialog> is promoted to the top layer and is inherently document-global
215
+ * (same model as `initThemeToggle`, where `root` scopes controls but the
216
+ * theme applies to <html>).
217
+ */
218
+ export function initDialog({ root } = {}) {
219
+ if (!hasDom()) return noop;
220
+ const host = root || document;
221
+ const onClick = (e) => {
222
+ const opener = e.target.closest('[data-bronto-open]');
223
+ if (opener && host.contains(opener)) {
224
+ const dlg = document.getElementById(opener.getAttribute('data-bronto-open'));
225
+ if (dlg && typeof dlg.showModal === 'function' && !dlg.open) dlg.showModal();
226
+ return;
227
+ }
228
+ const closer = e.target.closest('[data-bronto-close]');
229
+ if (closer && host.contains(closer)) {
230
+ const dlg = closer.closest('dialog');
231
+ if (dlg && dlg.open) dlg.close();
232
+ return;
233
+ }
234
+ // Light-dismiss: a click whose target is the <dialog> itself is the
235
+ // backdrop (content sits in child elements).
236
+ const dlg = e.target;
237
+ if (
238
+ dlg.tagName === 'DIALOG' &&
239
+ dlg.open &&
240
+ dlg.hasAttribute('data-bronto-dialog-light') &&
241
+ host.contains(dlg)
242
+ ) {
243
+ dlg.close();
244
+ }
245
+ };
246
+ host.addEventListener('click', onClick);
247
+ return () => host.removeEventListener('click', onClick);
248
+ }
249
+
250
+ /**
251
+ * Push a transient toast into a shared, screen-anchored stack (created
252
+ * once and appended to <body>, `aria-live="polite"`). `tone` is one of
253
+ * accent/success/warning/danger; `title` is an optional uppercase label;
254
+ * `duration` ms before auto-dismiss (0 keeps it until dismissed). Returns
255
+ * a function that dismisses the toast early. SSR-safe (no-op).
256
+ */
257
+ export function toast(message, { tone, title, duration = 4000 } = {}) {
258
+ if (!hasDom()) return noop;
259
+ let stack = document.querySelector('.ui-toast-stack');
260
+ if (!stack) {
261
+ stack = document.createElement('div');
262
+ stack.className = 'ui-toast-stack';
263
+ stack.setAttribute('aria-live', 'polite');
264
+ document.body.appendChild(stack);
265
+ }
266
+ const el = document.createElement('div');
267
+ el.className = tone ? `ui-toast ui-toast--${tone}` : 'ui-toast';
268
+ // No per-item role: the stack is already aria-live=polite; a nested
269
+ // status live region risks double announcement in some SRs.
270
+ if (title) {
271
+ const t = document.createElement('p');
272
+ t.className = 'ui-toast__title';
273
+ t.textContent = title;
274
+ el.appendChild(t);
275
+ }
276
+ const body = document.createElement('div');
277
+ body.textContent = message;
278
+ el.appendChild(body);
279
+ stack.appendChild(el);
280
+
281
+ let timer;
282
+ const dismiss = () => {
283
+ if (timer) clearTimeout(timer);
284
+ el.remove();
285
+ if (stack && !stack.childElementCount) stack.remove();
286
+ };
287
+ if (duration > 0) timer = setTimeout(dismiss, duration);
288
+ return dismiss;
289
+ }
290
+
119
291
  /**
120
292
  * Disclosure: a `[data-bronto-disclosure]` trigger toggles the element
121
293
  * referenced by its `aria-controls` id, keeping `aria-expanded` and the
@@ -49,6 +49,34 @@ export interface ClusterOpts {
49
49
  export interface StaggerOpts {
50
50
  auto?: boolean;
51
51
  }
52
+ export type Tone = 'accent' | 'success' | 'warning' | 'danger';
53
+ export interface AlertOpts {
54
+ tone?: Tone;
55
+ }
56
+ export interface ToastOpts {
57
+ tone?: Tone;
58
+ }
59
+ export interface ProgressOpts {
60
+ indeterminate?: boolean;
61
+ }
62
+ export interface DotspinnerOpts {
63
+ size?: 'sm' | 'lg';
64
+ }
65
+ export interface DotbarOpts {
66
+ indeterminate?: boolean;
67
+ }
68
+ export interface ModalOpts {
69
+ drawer?: boolean;
70
+ }
71
+ export interface TabOpts {
72
+ active?: boolean;
73
+ }
74
+ export interface AvatarOpts {
75
+ size?: 'sm' | 'lg';
76
+ }
77
+ export interface ProseOpts {
78
+ compact?: boolean;
79
+ }
52
80
 
53
81
  export interface Ui {
54
82
  button(opts?: ButtonOpts): string;
@@ -63,6 +91,15 @@ export interface Ui {
63
91
  hint(opts?: HintOpts): string;
64
92
  cluster(opts?: ClusterOpts): string;
65
93
  stagger(opts?: StaggerOpts): string;
94
+ alert(opts?: AlertOpts): string;
95
+ toast(opts?: ToastOpts): string;
96
+ progress(opts?: ProgressOpts): string;
97
+ dotspinner(opts?: DotspinnerOpts): string;
98
+ dotbar(opts?: DotbarOpts): string;
99
+ modal(opts?: ModalOpts): string;
100
+ tab(opts?: TabOpts): string;
101
+ avatar(opts?: AvatarOpts): string;
102
+ prose(opts?: ProseOpts): string;
66
103
  }
67
104
 
68
105
  export declare const ui: Ui;
package/classes/index.js CHANGED
@@ -50,7 +50,11 @@ export const cls = Object.freeze({
50
50
  dotfield: 'ui-dotfield',
51
51
  dotrule: 'ui-dotrule',
52
52
  dotbar: 'ui-dotbar',
53
+ dotbarIndeterminate: 'ui-dotbar--indeterminate',
53
54
  dotloader: 'ui-dotloader',
55
+ dotspinner: 'ui-dotspinner',
56
+ dotspinnerSm: 'ui-dotspinner--sm',
57
+ dotspinnerLg: 'ui-dotspinner--lg',
54
58
  // forms
55
59
  field: 'ui-field',
56
60
  label: 'ui-label',
@@ -64,6 +68,58 @@ export const cls = Object.freeze({
64
68
  switchThumb: 'ui-switch__thumb',
65
69
  hint: 'ui-hint',
66
70
  hintError: 'ui-hint--error',
71
+ // feedback
72
+ alert: 'ui-alert',
73
+ alertTitle: 'ui-alert__title',
74
+ alertBody: 'ui-alert__body',
75
+ alertDismiss: 'ui-alert__dismiss',
76
+ alertAccent: 'ui-alert--accent',
77
+ alertSuccess: 'ui-alert--success',
78
+ alertWarning: 'ui-alert--warning',
79
+ alertDanger: 'ui-alert--danger',
80
+ toastStack: 'ui-toast-stack',
81
+ toast: 'ui-toast',
82
+ toastTitle: 'ui-toast__title',
83
+ toastAccent: 'ui-toast--accent',
84
+ toastSuccess: 'ui-toast--success',
85
+ toastWarning: 'ui-toast--warning',
86
+ toastDanger: 'ui-toast--danger',
87
+ tooltip: 'ui-tooltip',
88
+ tooltipBubble: 'ui-tooltip__bubble',
89
+ progress: 'ui-progress',
90
+ progressBar: 'ui-progress__bar',
91
+ progressIndeterminate: 'ui-progress--indeterminate',
92
+ // overlay
93
+ modal: 'ui-modal',
94
+ modalHead: 'ui-modal__head',
95
+ modalTitle: 'ui-modal__title',
96
+ modalBody: 'ui-modal__body',
97
+ modalFoot: 'ui-modal__foot',
98
+ modalClose: 'ui-modal__close',
99
+ modalDrawer: 'ui-modal--drawer',
100
+ menu: 'ui-menu',
101
+ menuLabel: 'ui-menu__label',
102
+ menuItem: 'ui-menu__item',
103
+ menuSep: 'ui-menu__sep',
104
+ // disclosure
105
+ tabs: 'ui-tabs',
106
+ tabsList: 'ui-tabs__list',
107
+ tab: 'ui-tab',
108
+ tabsPanel: 'ui-tabs__panel',
109
+ accordion: 'ui-accordion',
110
+ accordionItem: 'ui-accordion__item',
111
+ accordionSummary: 'ui-accordion__summary',
112
+ accordionBody: 'ui-accordion__body',
113
+ segmented: 'ui-segmented',
114
+ segmentedOption: 'ui-segmented__option',
115
+ breadcrumb: 'ui-breadcrumb',
116
+ breadcrumbItem: 'ui-breadcrumb__item',
117
+ pagination: 'ui-pagination',
118
+ paginationItem: 'ui-pagination__item',
119
+ avatar: 'ui-avatar',
120
+ avatarSm: 'ui-avatar--sm',
121
+ avatarLg: 'ui-avatar--lg',
122
+ avatarGroup: 'ui-avatar-group',
67
123
  // table
68
124
  table: 'ui-table',
69
125
  tableDense: 'ui-table--dense',
@@ -84,6 +140,8 @@ export const cls = Object.freeze({
84
140
  // typography / utilities
85
141
  eyebrow: 'ui-eyebrow',
86
142
  eyebrowMuted: 'ui-eyebrow--muted',
143
+ prose: 'ui-prose',
144
+ proseCompact: 'ui-prose--compact',
87
145
  display: 'ui-display',
88
146
  mono: 'ui-mono',
89
147
  muted: 'ui-muted',
@@ -154,6 +212,33 @@ export const ui = {
154
212
  hint: ({ error } = {}) => j(cls.hint, error && cls.hintError),
155
213
  cluster: ({ between } = {}) => j(cls.cluster, between && cls.clusterBetween),
156
214
  stagger: ({ auto } = {}) => j(cls.stagger, auto && cls.staggerAuto),
215
+ alert: ({ tone } = {}) =>
216
+ j(
217
+ cls.alert,
218
+ tone === 'accent' && cls.alertAccent,
219
+ tone === 'success' && cls.alertSuccess,
220
+ tone === 'warning' && cls.alertWarning,
221
+ tone === 'danger' && cls.alertDanger
222
+ ),
223
+ toast: ({ tone } = {}) =>
224
+ j(
225
+ cls.toast,
226
+ tone === 'accent' && cls.toastAccent,
227
+ tone === 'success' && cls.toastSuccess,
228
+ tone === 'warning' && cls.toastWarning,
229
+ tone === 'danger' && cls.toastDanger
230
+ ),
231
+ progress: ({ indeterminate } = {}) =>
232
+ j(cls.progress, indeterminate && cls.progressIndeterminate),
233
+ dotspinner: ({ size } = {}) =>
234
+ j(cls.dotspinner, size === 'sm' && cls.dotspinnerSm, size === 'lg' && cls.dotspinnerLg),
235
+ dotbar: ({ indeterminate } = {}) =>
236
+ j(cls.dotbar, indeterminate && cls.dotbarIndeterminate),
237
+ modal: ({ drawer } = {}) => j(cls.modal, drawer && cls.modalDrawer),
238
+ tab: ({ active } = {}) => j(cls.tab, active && 'is-active'),
239
+ avatar: ({ size } = {}) =>
240
+ j(cls.avatar, size === 'sm' && cls.avatarSm, size === 'lg' && cls.avatarLg),
241
+ prose: ({ compact } = {}) => j(cls.prose, compact && cls.proseCompact),
157
242
  };
158
243
 
159
244
  export default ui;
package/css/app.css CHANGED
@@ -1,12 +1,12 @@
1
1
  /* ==========================================================================
2
2
  app — admin shell (sidebar + topbar + panels + metrics)
3
- Adopted by polpo-admin and future operator UIs.
3
+ For admin / operator dashboards.
4
4
  ========================================================================== */
5
5
 
6
6
  .app-shell {
7
7
  display: grid;
8
8
  grid-template-columns: var(--app-rail, 14rem) minmax(0, 1fr);
9
- min-height: 100vh;
9
+ min-block-size: 100vh;
10
10
  }
11
11
 
12
12
  .app-shell--full {
@@ -17,14 +17,14 @@
17
17
 
18
18
  .app-rail {
19
19
  background: var(--panel);
20
- border-right: 1px solid var(--line);
20
+ border-inline-end: 1px solid var(--line);
21
21
  display: flex;
22
22
  flex-direction: column;
23
23
  gap: var(--space-md);
24
24
  padding: var(--space-md);
25
25
  position: sticky;
26
- top: 0;
27
- height: 100vh;
26
+ inset-block-start: 0;
27
+ block-size: 100vh;
28
28
  overflow-y: auto;
29
29
  }
30
30
 
@@ -44,8 +44,8 @@
44
44
  background: var(--accent);
45
45
  border-radius: 50%;
46
46
  content: '';
47
- height: 0.5rem;
48
- width: 0.5rem;
47
+ block-size: 0.5rem;
48
+ inline-size: 0.5rem;
49
49
  }
50
50
 
51
51
  .app-nav {
@@ -65,7 +65,7 @@
65
65
 
66
66
  .app-nav a {
67
67
  align-items: center;
68
- border-left: 2px solid transparent;
68
+ border-inline-start: 2px solid transparent;
69
69
  color: var(--text-soft);
70
70
  display: flex;
71
71
  font-family: var(--mono);
@@ -82,9 +82,9 @@
82
82
  background: currentColor;
83
83
  border-radius: 50%;
84
84
  content: '';
85
- height: 0.34rem;
85
+ block-size: 0.34rem;
86
86
  opacity: 0.45;
87
- width: 0.34rem;
87
+ inline-size: 0.34rem;
88
88
  }
89
89
 
90
90
  .app-nav a:hover {
@@ -94,7 +94,7 @@
94
94
 
95
95
  .app-nav a.is-active {
96
96
  background: var(--accent-soft);
97
- border-left-color: var(--accent);
97
+ border-inline-start-color: var(--accent);
98
98
  color: var(--accent);
99
99
  }
100
100
 
@@ -103,12 +103,12 @@
103
103
  }
104
104
 
105
105
  .app-rail__foot {
106
- border-top: 1px solid var(--line);
106
+ border-block-start: 1px solid var(--line);
107
107
  color: var(--text-dim);
108
108
  font-family: var(--mono);
109
109
  font-size: var(--text-2xs);
110
- margin-top: auto;
111
- padding-top: var(--space-sm);
110
+ margin-block-start: auto;
111
+ padding-block-start: var(--space-sm);
112
112
  }
113
113
 
114
114
  /* --- Main column --- */
@@ -116,20 +116,20 @@
116
116
  .app-main {
117
117
  display: flex;
118
118
  flex-direction: column;
119
- min-width: 0;
119
+ min-inline-size: 0;
120
120
  }
121
121
 
122
122
  .app-topbar {
123
123
  align-items: center;
124
124
  background: color-mix(in srgb, var(--bg) 86%, transparent);
125
125
  backdrop-filter: blur(8px);
126
- border-bottom: 1px solid var(--line);
126
+ border-block-end: 1px solid var(--line);
127
127
  display: flex;
128
128
  gap: var(--space-md);
129
129
  justify-content: space-between;
130
130
  padding: 0.75rem var(--space-lg);
131
131
  position: sticky;
132
- top: 0;
132
+ inset-block-start: 0;
133
133
  z-index: 10;
134
134
  }
135
135
 
@@ -272,10 +272,10 @@
272
272
 
273
273
  .app-rail {
274
274
  flex-direction: row;
275
- height: auto;
275
+ block-size: auto;
276
276
  overflow-x: auto;
277
277
  position: sticky;
278
- top: 0;
278
+ inset-block-start: 0;
279
279
  z-index: 11;
280
280
  }
281
281
 
@@ -293,14 +293,14 @@
293
293
  }
294
294
 
295
295
  .app-nav a {
296
- border-left: 0;
297
- border-bottom: 2px solid transparent;
296
+ border-inline-start: 0;
297
+ border-block-end: 2px solid transparent;
298
298
  white-space: nowrap;
299
299
  }
300
300
 
301
301
  .app-nav a.is-active {
302
- border-left: 0;
303
- border-bottom-color: var(--accent);
302
+ border-inline-start: 0;
303
+ border-block-end-color: var(--accent);
304
304
  }
305
305
 
306
306
  .app-content {