@apliteni/apliteni-ui 0.11.4 → 0.23.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apliteni/apliteni-ui",
3
- "version": "0.11.4",
3
+ "version": "0.23.1",
4
4
  "workspaces": [
5
5
  "react"
6
6
  ],
@@ -1,3 +1,22 @@
1
+ /* ../src/styles/reduced-motion.css */
2
+ @media (prefers-reduced-motion: reduce) {
3
+ html {
4
+ scroll-behavior: auto !important;
5
+ }
6
+ *,
7
+ ::before,
8
+ ::after {
9
+ animation-duration: 0.01ms !important;
10
+ animation-iteration-count: 1 !important;
11
+ transition-duration: 0.01ms !important;
12
+ scroll-behavior: auto !important;
13
+ }
14
+ [data-reveal] {
15
+ opacity: 1 !important;
16
+ transform: none !important;
17
+ }
18
+ }
19
+
1
20
  /* src/Modal.css */
2
21
  .rx-scrim {
3
22
  position: fixed;
@@ -12,9 +12,11 @@ type ButtonProps = {
12
12
  iconRight?: string;
13
13
  iconOnly?: boolean;
14
14
  block?: boolean;
15
+ /** In flight: aria-busy, disabled, and the kit's indeterminate bars. */
16
+ busy?: boolean;
15
17
  children?: ReactNode;
16
18
  } & ButtonHTMLAttributes<HTMLButtonElement>;
17
- declare function Button({ variant, size, icon, iconRight, iconOnly, block, children, type, ...rest }: ButtonProps): react.JSX.Element;
19
+ declare function Button({ variant, size, icon, iconRight, iconOnly, block, busy, children, type, disabled, ...rest }: ButtonProps): react.JSX.Element;
18
20
 
19
21
  declare function Badge({ variant, children }: {
20
22
  variant?: string;
@@ -55,4 +57,43 @@ declare function DataTable<T extends {
55
57
  name: string;
56
58
  }>({ columns, rows, pageSize, selected, onToggle, onTogglePage, }: DataTableProps<T>): react.JSX.Element;
57
59
 
58
- export { Badge, Button, type ButtonProps, Card, type Column, DataTable, type DataTableProps, Icon, Modal, type ModalProps };
60
+ type SkeletonProps = {
61
+ /** A count of bars, or explicit widths when a ragged prose edge matters. */
62
+ lines?: number | string[];
63
+ width?: string;
64
+ height?: string;
65
+ radius?: string;
66
+ className?: string;
67
+ };
68
+ declare function Skeleton({ lines, width, height, radius, className }: SkeletonProps): react.JSX.Element;
69
+ type SkeletonTableProps = {
70
+ rows?: number;
71
+ cols?: number;
72
+ head?: boolean;
73
+ };
74
+ declare function SkeletonTable({ rows, cols, head }: SkeletonTableProps): react.JSX.Element;
75
+ type BusyRegionProps = {
76
+ busy: boolean;
77
+ /** Spoken while it works. */
78
+ label?: string;
79
+ /** Spoken when it finishes — the specific line ("14 invoices") beats "Loaded". */
80
+ message?: string;
81
+ /** Placeholder while busy. Defaults to a three-bar skeleton. */
82
+ placeholder?: ReactNode;
83
+ className?: string;
84
+ children?: ReactNode;
85
+ };
86
+ declare function BusyRegion({ busy, label, message, placeholder, className, children, }: BusyRegionProps): react.JSX.Element;
87
+ type DeniedProps = {
88
+ title?: string;
89
+ sub?: string;
90
+ /** The scope or role the reader is missing, verbatim. */
91
+ need?: string;
92
+ icon?: string;
93
+ className?: string;
94
+ /** Buttons. Nothing is assumed about what a reader can do next. */
95
+ children?: ReactNode;
96
+ };
97
+ declare function Denied({ title, sub, need, icon, className, children, }: DeniedProps): react.JSX.Element;
98
+
99
+ export { Badge, BusyRegion, type BusyRegionProps, Button, type ButtonProps, Card, type Column, DataTable, type DataTableProps, Denied, type DeniedProps, Icon, Modal, type ModalProps, Skeleton, type SkeletonProps, SkeletonTable, type SkeletonTableProps };
@@ -24,8 +24,10 @@ function Button({
24
24
  iconRight,
25
25
  iconOnly,
26
26
  block,
27
+ busy,
27
28
  children,
28
29
  type = "button",
30
+ disabled,
29
31
  ...rest
30
32
  }) {
31
33
  const cls = cx(
@@ -38,11 +40,27 @@ function Button({
38
40
  const labelled = rest["aria-label"] != null || rest["aria-labelledby"] != null;
39
41
  const fallback = typeof children === "string" && children.trim() ? children.trim() : icon2;
40
42
  const named = iconOnly && !labelled && fallback ? { "aria-label": fallback, title: rest.title ?? fallback } : {};
41
- return /* @__PURE__ */ jsxs("button", { type, className: cls, ...rest, ...named, children: [
42
- icon2 && /* @__PURE__ */ jsx2(Icon, { name: icon2 }),
43
- !iconOnly && children != null && /* @__PURE__ */ jsx2("span", { children }),
44
- iconRight && /* @__PURE__ */ jsx2(Icon, { name: iconRight })
45
- ] });
43
+ return /* @__PURE__ */ jsxs(
44
+ "button",
45
+ {
46
+ type,
47
+ className: cls,
48
+ disabled: disabled || busy,
49
+ "aria-disabled": disabled || busy ? true : void 0,
50
+ "aria-busy": busy ? true : void 0,
51
+ ...rest,
52
+ ...named,
53
+ children: [
54
+ icon2 && /* @__PURE__ */ jsx2(Icon, { name: icon2 }),
55
+ !iconOnly && children != null && /* @__PURE__ */ jsx2("span", { children }),
56
+ iconRight && /* @__PURE__ */ jsx2(Icon, { name: iconRight }),
57
+ busy && /* @__PURE__ */ jsxs("span", { className: "ui-btn__bars", children: [
58
+ /* @__PURE__ */ jsx2("i", {}),
59
+ /* @__PURE__ */ jsx2("i", {})
60
+ ] })
61
+ ]
62
+ }
63
+ );
46
64
  }
47
65
 
48
66
  // src/primitives/Badge.tsx
@@ -244,11 +262,85 @@ function DataTable({
244
262
  ] })
245
263
  ] });
246
264
  }
265
+
266
+ // src/Loading.tsx
267
+ import { jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
268
+ var cx2 = (...a) => a.filter(Boolean).join(" ");
269
+ function Skeleton({ lines = 3, width, height, radius, className }) {
270
+ const widths = Array.isArray(lines) ? lines : null;
271
+ const n = widths ? widths.length : Math.max(1, lines);
272
+ const styleFor = (i) => {
273
+ const w = widths ? widths[i] : width;
274
+ if (!w && !height && !radius) return void 0;
275
+ return { ...w && { width: w }, ...height && { height }, ...radius && { borderRadius: radius } };
276
+ };
277
+ return /* @__PURE__ */ jsx7("div", { className: cx2("ui-skel", className), "aria-hidden": "true", children: Array.from({ length: n }, (_, i) => /* @__PURE__ */ jsx7("span", { className: "ui-skel__bar m-skeleton", style: styleFor(i) }, i)) });
278
+ }
279
+ function SkeletonTable({ rows = 5, cols = 4, head = true }) {
280
+ const cells = Array.from({ length: Math.max(1, cols) }, (_, i) => /* @__PURE__ */ jsx7("span", { className: "ui-skel__bar m-skeleton" }, i));
281
+ return /* @__PURE__ */ jsxs5(
282
+ "div",
283
+ {
284
+ className: "ui-skel ui-skel--table",
285
+ style: { "--skel-cols": Math.max(1, cols) },
286
+ "aria-hidden": "true",
287
+ children: [
288
+ head && /* @__PURE__ */ jsx7("div", { className: "ui-skel__row ui-skel__row--head", children: cells }),
289
+ Array.from({ length: Math.max(1, rows) }, (_, i) => /* @__PURE__ */ jsx7("div", { className: "ui-skel__row", children: cells }, i))
290
+ ]
291
+ }
292
+ );
293
+ }
294
+ function BusyRegion({
295
+ busy,
296
+ label = "Loading\u2026",
297
+ message = "Loaded",
298
+ placeholder,
299
+ className,
300
+ children
301
+ }) {
302
+ return /* @__PURE__ */ jsxs5(
303
+ "div",
304
+ {
305
+ className: cx2("ui-busy", className),
306
+ role: "status",
307
+ "aria-live": "polite",
308
+ "aria-busy": busy,
309
+ children: [
310
+ /* @__PURE__ */ jsx7("span", { className: "ui-sr", children: busy ? label : message }),
311
+ /* @__PURE__ */ jsx7("div", { className: "ui-busy__body", children: busy ? placeholder ?? /* @__PURE__ */ jsx7(Skeleton, { lines: 3 }) : children })
312
+ ]
313
+ }
314
+ );
315
+ }
316
+ function Denied({
317
+ title = "You don\u2019t have access",
318
+ sub,
319
+ need,
320
+ icon: icon2 = "lock",
321
+ className,
322
+ children
323
+ }) {
324
+ return /* @__PURE__ */ jsxs5("div", { className: cx2("ui-denied", className), children: [
325
+ /* @__PURE__ */ jsx7("div", { className: "ui-denied__seal", children: /* @__PURE__ */ jsx7(Icon, { name: icon2 }) }),
326
+ /* @__PURE__ */ jsx7("div", { className: "ui-denied__title", children: title }),
327
+ sub && /* @__PURE__ */ jsx7("div", { className: "ui-denied__sub", children: sub }),
328
+ need && /* @__PURE__ */ jsxs5("div", { className: "ui-denied__need", children: [
329
+ "Needs ",
330
+ /* @__PURE__ */ jsx7("code", { className: "ui-code", children: need })
331
+ ] }),
332
+ children && /* @__PURE__ */ jsx7("div", { className: "ui-denied__actions", children })
333
+ ] });
334
+ }
247
335
  export {
248
336
  Badge,
337
+ BusyRegion,
249
338
  Button,
250
339
  Card,
251
340
  DataTable,
341
+ Denied,
252
342
  Icon,
253
- Modal
343
+ Modal,
344
+ Skeleton,
345
+ SkeletonTable
254
346
  };
@@ -1,12 +1,13 @@
1
- // Line-icon set — 24×24, stroke=currentColor, 1.7 weight, round caps/joins.
2
- // House style is Feather/Lucide (Lucide is the maintained Feather; our glyphs
3
- // match it 1:1). Delivery is inline SVG strings: no runtime dependency, works
4
- // in any framework, and every glyph inherits `currentColor` + a consistent
5
- // stroke so it sits right next to our type.
1
+ // Line-icon set — 24×24, stroke=currentColor, 1.7 weight, round caps/joins,
2
+ // Lucide house style. Delivery is inline SVG strings, so there is no runtime
3
+ // dependency and every glyph inherits `currentColor`.
6
4
  //
7
5
  // Each value is the INNER markup; icon() wraps it in the shared <svg>. Glyphs
8
6
  // are grouped by domain — the flat ICONS map is what icon() looks up, and
9
- // iconCategories drives the Storybook grid. Add a glyph to the right group.
7
+ // iconCategories drives the Storybook grid.
8
+ //
9
+ // Naming, grouping and provenance, and the gates that hold them:
10
+ // why: CONTRIBUTING.md#add-a-glyph
10
11
 
11
12
  const NAV = {
12
13
  chevronDown: '<path d="M6 9l6 6 6-6" stroke-linecap="round" stroke-linejoin="round"/>',
@@ -101,9 +102,6 @@ const COMMS = {
101
102
  logout: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
102
103
  plug: '<path d="M12 22v-5"/><path d="M15 8V2"/><path d="M17 8a1 1 0 0 1 1 1v4a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1z"/><path d="M9 8V2"/>',
103
104
  sparkle: '<path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/><path d="M20 2v4"/><path d="M22 4h-4"/><circle cx="4" cy="20" r="2"/>',
104
- card: '<rect x="1" y="4" width="22" height="16" rx="2" ry="2"/><line x1="1" y1="10" x2="23" y2="10"/>',
105
- doc: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>',
106
- chart: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
107
105
  };
108
106
 
109
107
  const BRAND = {
@@ -140,5 +138,49 @@ export const icon = (name, cls = '') =>
140
138
 
141
139
  export const iconNames = Object.keys(ICONS);
142
140
 
141
+ // Which controls may be an icon and nothing else — a closed list, not a
142
+ // judgement call in review.
143
+ //
144
+ // The accessibility half was already held: every glyph is aria-hidden, so
145
+ // button({ iconOnly }) names itself from `label`. That says a nameless icon
146
+ // button cannot ship; it never said when a wordless one SHOULD. This does.
147
+ //
148
+ // A control whose action is on this list may drop its visible text. Everything
149
+ // else carries a label — including glyphs that feel obvious in isolation, like
150
+ // `gear` or `externalLink`, because a reader meets them one at a time and a
151
+ // toolbar is not a legend. Adding an entry is a decision recorded here, which
152
+ // is the point: the alternative rules read well and could not be gated.
153
+ export const iconOnlyAllowed = {
154
+ x: 'close or dismiss',
155
+ copy: 'copy to clipboard',
156
+ moreHorizontal: 'overflow menu',
157
+ moreVertical: 'overflow menu',
158
+ chevronDown: 'expand or collapse',
159
+ chevronUp: 'expand or collapse',
160
+ };
161
+
162
+ // What a glyph means when a component picks it for the reader rather than a
163
+ // caller naming it. Two rules hold the map together:
164
+ //
165
+ // a CIRCLED glyph is a state the system is in — circleCheck, circleX,
166
+ // circleAlert. It is reported to you and you cannot click it.
167
+ // a BARE glyph is an action you can take — x dismisses, check confirms,
168
+ // trash deletes.
169
+ //
170
+ // That split is what a danger toast needed: it used to render the same bare `x`
171
+ // twice, once meaning "this failed" and once meaning "make this go away". The
172
+ // bare x is now reserved for the close button, and status took the circle
173
+ // family the kit was already shipping and never using.
174
+ export const iconMeanings = {
175
+ circleCheck: 'a state: it succeeded',
176
+ circleX: 'a state: it failed',
177
+ circleAlert: 'a state: it needs attention',
178
+ info: 'a state: something worth knowing, no action required',
179
+ bolt: 'a state: it happened, with no verdict attached',
180
+ x: 'an action: close or dismiss this',
181
+ check: 'an action: confirm this',
182
+ trash: 'an action: delete this',
183
+ };
184
+
143
185
  export const sun = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>';
144
186
  export const moon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>';
@@ -3,7 +3,7 @@
3
3
  // shell.js and topbar.js need it and shell.js already imports topbar.js — the
4
4
  // other direction would be a cycle. shell.js re-exports ACCOUNT_NAV, which is
5
5
  // the published name docs/library.md documents.
6
- // why: docs/adr/0007-one-page-shell-built-from-the-kits-own-nav.md
6
+ // why: docs/specification.md#the-page-shell
7
7
  import { esc } from './index.js';
8
8
 
9
9
  // nav.js item objects. Labels are raw text — every nav primitive escapes, so a
@@ -16,6 +16,10 @@ export const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => (
16
16
  // stops being decoration and becomes the control's ONLY accessible name. It is
17
17
  // mirrored into aria-label + title, and an empty label falls back to the icon
18
18
  // name rather than shipping a nameless button.
19
+ //
20
+ // That is the accessibility half, and it is not permission. WHEN a control may
21
+ // go wordless is a closed list — `iconOnlyAllowed` in src/assets/icons.js — and
22
+ // stories/guidelines/iconography.test.js reviews call sites against it.
19
23
  export function button({
20
24
  label = 'Button', variant = 'secondary', size = 'md', icon: ic, iconSvg, iconRight,
21
25
  block = false, disabled = false, busy = false, type = 'button', href, iconOnly = false,
@@ -217,8 +221,18 @@ export function switchToggle({ checked = false, disabled = false, name, label =
217
221
  export function callout({ variant, icon: ic = 'info', body } = {}) {
218
222
  return `<div class="${cx('ui-callout', variant && `ui-callout--${variant}`)}"><span class="ui-callout__icon">${icon(ic)}</span><div>${body}</div></div>`;
219
223
  }
220
- // Default icon per status — overridable with `icon`.
221
- const TOAST_ICON = { success: 'check', danger: 'x', warn: 'alert', info: 'info', neutral: 'bolt' };
224
+ // Default icon per status — overridable with `icon`. The circle family is not
225
+ // decoration: a circled glyph is a STATE the system reports, a bare one is an
226
+ // ACTION you can take (iconMeanings in src/assets/icons.js).
227
+ //
228
+ // This used to read `success: 'check', danger: 'x', warn: 'alert'`, which made
229
+ // a danger toast render the same bare `x` twice — once as the status, meaning
230
+ // "this failed", once as the close button, meaning "make this go away". Status
231
+ // took the circle glyphs the kit already shipped and never used, and the bare
232
+ // `x` now belongs to the close button alone.
233
+ const TOAST_ICON = {
234
+ success: 'circleCheck', danger: 'circleX', warn: 'circleAlert', info: 'info', neutral: 'bolt',
235
+ };
222
236
  // A toast carries a status (colour) and a style (surface). Everything visual is
223
237
  // token-driven: the status modifier sets --toast-accent/-glow/-on, the style
224
238
  // modifier consumes them. `action` adds a trailing button ("Undo"/"Retry"),
@@ -0,0 +1,126 @@
1
+ // The pending and denied states of a SCREEN, not of one control.
2
+ //
3
+ // busyRegion() is one live region that outlives what it reports on; setBusy()
4
+ // swaps its body and writes a line into the sr-only node already inside it.
5
+ // Three things land in that body: skeleton() while it fetches, your markup once
6
+ // the rows arrive, deniedState() when the answer came back 403.
7
+ //
8
+ // why: docs/specification.md#pending-and-denied-states
9
+ import { icon } from '../assets/icons.js';
10
+ import { button, esc } from './index.js';
11
+
12
+ const cx = (...a) => a.filter(Boolean).join(' ');
13
+
14
+ // ---- Skeleton ------------------------------------------------------------
15
+ // The placeholder shape. `lines` is a count, or an array of widths when the
16
+ // varied ragged edge of real prose matters (['100%','92%','60%']). `height`
17
+ // makes it one solid block instead — a chart, a map, an avatar. The shimmer is
18
+ // .m-skeleton from the motion library, so there is one animation to own and it
19
+ // is already inside that library's reduced-motion net.
20
+ export function skeleton({ lines = 3, width, height, radius, className = '' } = {}) {
21
+ const widths = Array.isArray(lines) ? lines : Array.isArray(width) ? width : null;
22
+ const n = widths ? widths.length : Math.max(1, lines | 0);
23
+ const styleFor = (i) => {
24
+ const w = widths ? widths[i] : (typeof width === 'string' ? width : null);
25
+ const bits = [w && `width:${w}`, height && `height:${height}`, radius && `border-radius:${radius}`];
26
+ const s = bits.filter(Boolean).join(';');
27
+ return s ? ` style="${esc(s)}"` : '';
28
+ };
29
+ const bars = Array.from({ length: n }, (_, i) =>
30
+ `<span class="ui-skel__bar m-skeleton"${styleFor(i)}></span>`).join('');
31
+ return `<div class="${cx('ui-skel', className)}" aria-hidden="true">${bars}</div>`;
32
+ }
33
+
34
+ // A table's worth of skeleton — `rows` × `cols` of bar, laid out on a grid so
35
+ // the placeholder has the column rhythm the real table will have. Screens that
36
+ // load a table are the common case, and hand-rolling this per screen is how
37
+ // four slightly different loading tables get shipped.
38
+ export function skeletonTable({ rows = 5, cols = 4, head = true } = {}) {
39
+ const row = (cls) => `<div class="${cls}">`
40
+ + Array.from({ length: Math.max(1, cols | 0) }, () => '<span class="ui-skel__bar m-skeleton"></span>').join('')
41
+ + '</div>';
42
+ const body = Array.from({ length: Math.max(1, rows | 0) }, () => row('ui-skel__row')).join('');
43
+ return `<div class="ui-skel ui-skel--table" style="--skel-cols:${Math.max(1, cols | 0)}" aria-hidden="true">`
44
+ + `${head ? row('ui-skel__row ui-skel__row--head') : ''}${body}</div>`;
45
+ }
46
+
47
+ // ---- The region ----------------------------------------------------------
48
+ // `label` is what is spoken while it works, `readyLabel` the fallback for when
49
+ // it finishes without the caller supplying a line. Both are parked on the
50
+ // element so setBusy() can find them and the caller never repeats itself.
51
+ //
52
+ // `body` overrides the default skeleton (pass your own placeholder, or the
53
+ // already-loaded content when the region starts ready).
54
+ export function busyRegion({
55
+ label = 'Loading…', readyLabel = 'Loaded', busy = true,
56
+ body, lines = 3, className = '',
57
+ } = {}) {
58
+ const inner = body != null ? body : skeleton({ lines });
59
+ return `<div class="${cx('ui-busy', className)}" data-busy`
60
+ + ` data-busy-label="${esc(label)}" data-busy-ready="${esc(readyLabel)}"`
61
+ + ` role="status" aria-live="polite" aria-busy="${busy ? 'true' : 'false'}">`
62
+ + `<span class="ui-sr" data-busy-msg>${esc(busy ? label : readyLabel)}</span>`
63
+ + `<div class="ui-busy__body" data-busy-body>${inner}</div>`
64
+ + '</div>';
65
+ }
66
+
67
+ // Flip a region between busy and ready, and say so. Writing into
68
+ // [data-busy-msg] IS the announcement. Callers pass `message` for the specific
69
+ // line ("14 invoices", "You don't have access to this report"); the region's
70
+ // own labels are the fallback.
71
+ //
72
+ // Accepts the region, a selector, or any ancestor of it. Returns the region, or
73
+ // null when there is nothing to update — safe to call against a torn-down view.
74
+ export function setBusy(root, { busy = false, message, body } = {}) {
75
+ const el = typeof root === 'string' ? document.querySelector(root) : root;
76
+ if (!el || typeof el.querySelector !== 'function') return null;
77
+ const region = el.matches && el.matches('[data-busy]') ? el : el.querySelector('[data-busy]');
78
+ if (!region) return null;
79
+
80
+ region.setAttribute('aria-busy', busy ? 'true' : 'false');
81
+ if (body != null) {
82
+ const slot = region.querySelector('[data-busy-body]');
83
+ if (slot) slot.innerHTML = body;
84
+ }
85
+ const msg = region.querySelector('[data-busy-msg]');
86
+ if (msg) {
87
+ const fallback = busy
88
+ ? (region.dataset.busyLabel || 'Loading…')
89
+ : (region.dataset.busyReady || 'Loaded');
90
+ msg.textContent = message == null ? fallback : String(message);
91
+ }
92
+ return region;
93
+ }
94
+
95
+ // ---- Permission denied ---------------------------------------------------
96
+ // The 403 screen. Same shape as emptyState() — a mark, a title, a line, some
97
+ // actions — because to a reader they are the same event: the thing you came for
98
+ // is not here. What separates them is that this one owes an explanation, so
99
+ // `need` is a first-class slot rather than something to bury in `sub`.
100
+ //
101
+ // `need` names the scope or role the reader is missing, verbatim, as code. A
102
+ // reader who can act on "you need reports.read" acts on it; "insufficient
103
+ // permissions" sends them to open a ticket to find out what to ask for.
104
+ //
105
+ // No role and no live region here — see the file header. When this lands as the
106
+ // answer to a fetch, put it inside a busyRegion() and the region announces it.
107
+ export function deniedState({
108
+ title = 'You don’t have access',
109
+ sub = '',
110
+ need = '',
111
+ actions = [],
112
+ icon: ic = 'lock',
113
+ className = '',
114
+ } = {}) {
115
+ const needEl = need
116
+ ? `<div class="ui-denied__need">Needs <code class="ui-code">${esc(need)}</code></div>`
117
+ : '';
118
+ const actionsEl = actions.length
119
+ ? `<div class="ui-denied__actions">${actions.map((a) => button({ size: 'md', ...a })).join('')}</div>`
120
+ : '';
121
+ return `<div class="${cx('ui-denied', className)}">`
122
+ + `<div class="ui-denied__seal" aria-hidden="true">${icon(ic)}</div>`
123
+ + `<div class="ui-denied__title">${esc(title)}</div>`
124
+ + `${sub ? `<div class="ui-denied__sub">${esc(sub)}</div>` : ''}`
125
+ + `${needEl}${actionsEl}</div>`;
126
+ }
@@ -3,7 +3,7 @@
3
3
  // with a breadcrumb trail the caller owns. `accountShell()` is a thin preset
4
4
  // over it that keeps the topbar, so the published /account API still works.
5
5
  // Call wireTopbar() once after mounting to wire the account menu + theme toggle.
6
- // why: docs/adr/0007-one-page-shell-built-from-the-kits-own-nav.md
6
+ // why: docs/specification.md#the-page-shell
7
7
  import { topbar as productTopbar } from './topbar.js';
8
8
  import { esc, icon } from './index.js';
9
9
  import { sidebarNav, breadcrumbs } from './nav.js';
@@ -89,16 +89,17 @@ const toTopbar = (t) => {
89
89
  return out;
90
90
  };
91
91
 
92
- // `maxWidth` lands inside a style attribute, which is a declaration list: esc()
93
- // stops a quote closing the attribute, and `;` is the character that matters
94
- // there. So a length is all this accepts a number and a unit the reading
95
- // column can use, or `none`. Anything else falls back to the default rather
96
- // than throwing, because a shell that throws mid-render takes the page with it.
97
- const MAIN_MAX = '860px';
92
+ // `maxWidth` lands inside a style attribute, so a length is all this accepts
93
+ // a number and a unit, or `none`. Anything else yields '' and the caller writes
94
+ // no style attribute, letting layout.css fall through to --measure. It must
95
+ // REMOVE the property rather than pass a default or a bad value on: a custom
96
+ // property accepts any token stream, so garbage is a valid declaration that
97
+ // drops the column to `none`, the full track.
98
+ // why: docs/specification.md#widths
98
99
  const LENGTH = /^(?:\d+|\d*\.\d+)(?:px|rem|em|ch|%|vw)$/;
99
100
  const mainMax = (v) => {
100
101
  const s = str(v).trim();
101
- return s === 'none' || LENGTH.test(s) ? s : MAIN_MAX;
102
+ return s === 'none' || LENGTH.test(s) ? s : '';
102
103
  };
103
104
 
104
105
  // The one pass. Each key names the function that settles it; nothing else in
@@ -194,7 +195,7 @@ export function appShell(options = {}) {
194
195
  ${rail}
195
196
  ${railUser(account)}
196
197
  </div>
197
- <main class="ui-app__main" style="--ui-app-main: ${maxWidth}">
198
+ <main class="ui-app__main"${maxWidth ? ` style="--ui-app-main: ${maxWidth}"` : ''}>
198
199
  ${crumbs.length ? breadcrumbs({ items: crumbs }) : ''}
199
200
  ${title ? `<h1>${title}</h1>` : ''}
200
201
  ${sub ? `<p class="ui-app__sub">${sub}</p>` : ''}
package/src/index.css CHANGED
@@ -1,12 +1,16 @@
1
1
  /* ============================================================================
2
2
  * apliteni-ui — full stylesheet.
3
- * Import once (`import 'apliteni-ui/css'`) or cherry-pick from src/styles/*.
3
+ * Import once: `import 'apliteni-ui/css'`. A single component sheet is NOT
4
+ * addressable — package.json `exports` has no wildcard for ./styles/*, so a deep
5
+ * path into src/styles/ does not resolve. To take one component, read it by name
6
+ * through `apliteni-ui/inline` (`import { topbarCss } from …`).
4
7
  * Requires the Poppins font (loaded by the host page or Storybook preview).
5
8
  * ========================================================================== */
6
9
  @import "./tokens/brand.generated.css";
7
10
  @import "./tokens/tokens.css";
8
11
  @import "./tokens/accents.css";
9
12
  @import "./styles/base.css";
13
+ @import "./styles/reduced-motion.css";
10
14
  @import "./styles/motion.css";
11
15
  @import "./styles/button.css";
12
16
  @import "./styles/card.css";
@@ -27,3 +31,4 @@
27
31
  @import "./styles/layout.css";
28
32
  @import "./styles/feedback.css";
29
33
  @import "./styles/success.css";
34
+ @import "./styles/loading.css";
package/src/index.js CHANGED
@@ -12,6 +12,7 @@ export * from './components/footer.js';
12
12
  export * from './components/feedback.js';
13
13
  export * from './components/toasts.js';
14
14
  export * from './components/success.js';
15
+ export * from './components/loading.js';
15
16
  export * from './assets/icons.js';
16
17
  export * from './assets/brand.js';
17
18
  export * from './motion.js';
package/src/inline.js CHANGED
@@ -28,6 +28,7 @@ export const topbarCss = read('styles/topbar.css');
28
28
  // Individual component stylesheets, addressable by name.
29
29
  export const styles = {
30
30
  base: baseCss,
31
+ reducedMotion: read('styles/reduced-motion.css'),
31
32
  motion: read('styles/motion.css'),
32
33
  button: read('styles/button.css'),
33
34
  card: read('styles/card.css'),
@@ -48,12 +49,14 @@ export const styles = {
48
49
  layout: read('styles/layout.css'),
49
50
  feedback: read('styles/feedback.css'),
50
51
  success: read('styles/success.css'),
52
+ loading: read('styles/loading.css'),
51
53
  };
52
54
 
53
55
  // Everything, in the same order as index.css. `tokensCss` first so cascade is right.
54
56
  export const cssText = [
55
57
  tokensCss,
56
58
  styles.base,
59
+ styles.reducedMotion,
57
60
  styles.motion,
58
61
  styles.button,
59
62
  styles.card,
@@ -74,4 +77,5 @@ export const cssText = [
74
77
  styles.layout,
75
78
  styles.feedback,
76
79
  styles.success,
80
+ styles.loading,
77
81
  ].join('\n');
@@ -72,7 +72,7 @@
72
72
  .ui-dot.is-live {
73
73
  background: var(--green);
74
74
  box-shadow: 0 0 0 0 var(--glow-green);
75
- animation: ui-pulse 2s var(--ease) infinite;
75
+ animation: ui-pulse 2s var(--ease) infinite; /* motion: ambient — a liveness heartbeat, not a response to anything the reader did */
76
76
  }
77
77
  @keyframes ui-pulse {
78
78
  0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--green) 55%, transparent); }
@@ -88,7 +88,7 @@ a {
88
88
  }
89
89
 
90
90
  /* Layout helpers used by the example apps */
91
- .ui-container { width: 100%; max-width: 1180px; margin: 0 auto; padding: 0 clamp(14px, 2.4vw, 26px); }
91
+ .ui-container { width: 100%; max-width: var(--container); margin: 0 auto; padding: 0 clamp(14px, 2.4vw, 26px); }
92
92
  .ui-stack > * + * { margin-top: var(--space-4); }
93
93
 
94
94
  /* Section eyebrow — uppercase caption used across cards and headers */