@tonycodes-ui/mortar 0.0.0-stage → 0.11.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.
Files changed (104) hide show
  1. package/AGENTS.md +10 -0
  2. package/CHANGELOG.md +276 -0
  3. package/CONTRIBUTING.md +48 -0
  4. package/DESIGN.md +51 -0
  5. package/LICENSE +21 -0
  6. package/README.md +155 -2
  7. package/TESTING.md +83 -0
  8. package/baseline-policy.json +16 -0
  9. package/bin/mortar.mjs +258 -0
  10. package/catalog.json +9703 -0
  11. package/cli/audit.mjs +943 -0
  12. package/cli/data.mjs +176 -0
  13. package/cli/mcp.mjs +216 -0
  14. package/cli/migrations.mjs +128 -0
  15. package/cli/packet.mjs +267 -0
  16. package/cli/search.mjs +246 -0
  17. package/cli/tools.mjs +229 -0
  18. package/design-principles.json +30 -0
  19. package/dist/agent-data.json +1 -0
  20. package/dist/application.d.ts +340 -0
  21. package/dist/application.js +240 -0
  22. package/dist/auth.d.ts +121 -0
  23. package/dist/auth.js +97 -0
  24. package/dist/brand.d.ts +69 -0
  25. package/dist/brand.js +266 -0
  26. package/dist/calendar.d.ts +75 -0
  27. package/dist/calendar.js +310 -0
  28. package/dist/choice.d.ts +36 -0
  29. package/dist/choice.js +36 -0
  30. package/dist/composer.d.ts +28 -0
  31. package/dist/composer.js +47 -0
  32. package/dist/display.d.ts +136 -0
  33. package/dist/display.js +75 -0
  34. package/dist/docs.d.ts +145 -0
  35. package/dist/docs.js +141 -0
  36. package/dist/ecommerce.d.ts +261 -0
  37. package/dist/ecommerce.js +177 -0
  38. package/dist/feedback.d.ts +80 -0
  39. package/dist/feedback.js +98 -0
  40. package/dist/foundation.d.ts +42 -0
  41. package/dist/foundation.js +121 -0
  42. package/dist/heading.d.ts +5 -0
  43. package/dist/heading.js +9 -0
  44. package/dist/icons.d.ts +50 -0
  45. package/dist/icons.js +70 -0
  46. package/dist/index.d.ts +34 -0
  47. package/dist/index.js +17 -0
  48. package/dist/inspection.d.ts +24 -0
  49. package/dist/inspection.js +14 -0
  50. package/dist/internal.d.ts +492 -0
  51. package/dist/internal.js +273 -0
  52. package/dist/layout.d.ts +107 -0
  53. package/dist/layout.js +66 -0
  54. package/dist/marketing-sections.d.ts +161 -0
  55. package/dist/marketing-sections.js +58 -0
  56. package/dist/marketing.d.ts +254 -0
  57. package/dist/marketing.js +150 -0
  58. package/dist/navigation.d.ts +35 -0
  59. package/dist/navigation.js +44 -0
  60. package/dist/primitives.d.ts +258 -0
  61. package/dist/primitives.js +244 -0
  62. package/dist/showcase.d.ts +29 -0
  63. package/dist/showcase.js +10 -0
  64. package/dist/workspace.d.ts +80 -0
  65. package/dist/workspace.js +48 -0
  66. package/docs/agents.md +127 -0
  67. package/docs/auth-flows.md +71 -0
  68. package/docs/migrating-to-0.6.md +94 -0
  69. package/docs/theming.md +170 -0
  70. package/package.json +94 -4
  71. package/skills/mortar/SKILL.md +36 -0
  72. package/src/application.css +1487 -0
  73. package/src/application.tsx +1413 -0
  74. package/src/auth.tsx +452 -0
  75. package/src/brand.ts +335 -0
  76. package/src/calendar.css +468 -0
  77. package/src/calendar.tsx +868 -0
  78. package/src/choice.tsx +240 -0
  79. package/src/composer.tsx +158 -0
  80. package/src/data.css +690 -0
  81. package/src/display.tsx +452 -0
  82. package/src/docs.css +704 -0
  83. package/src/docs.tsx +737 -0
  84. package/src/ecommerce.css +810 -0
  85. package/src/ecommerce.tsx +1033 -0
  86. package/src/feedback.tsx +388 -0
  87. package/src/foundation.css +1867 -0
  88. package/src/foundation.tsx +227 -0
  89. package/src/heading.ts +13 -0
  90. package/src/icons.tsx +237 -0
  91. package/src/index.ts +230 -0
  92. package/src/inspection.tsx +91 -0
  93. package/src/internal.tsx +614 -0
  94. package/src/layout.tsx +342 -0
  95. package/src/marketing-sections.tsx +621 -0
  96. package/src/marketing.css +2400 -0
  97. package/src/marketing.tsx +1156 -0
  98. package/src/navigation.tsx +118 -0
  99. package/src/overlays.css +350 -0
  100. package/src/primitives.tsx +1135 -0
  101. package/src/showcase.tsx +120 -0
  102. package/src/tokens.css +449 -0
  103. package/src/workspace.tsx +333 -0
  104. package/styles.css +56 -0
@@ -0,0 +1,388 @@
1
+ import { useEffect, useId, useRef, useState, type ReactNode } from "react";
2
+ import { DialogPanel, DialogTitle, Description } from "@headlessui/react";
3
+ import { Button, Input, type LinkAction, type Tone } from "./primitives.js";
4
+ import { KitLink } from "./navigation.js";
5
+ import { Check, Close } from "./icons.js";
6
+ import { DialogFrame, componentRoot, cx, focusAway } from "./internal.js";
7
+
8
+ export interface ToastAction {
9
+ label: string;
10
+ onSelect: () => void;
11
+ }
12
+
13
+ export interface ToastItem {
14
+ id: string;
15
+ title: string;
16
+ description?: string;
17
+ tone?: Tone;
18
+ action?: ToastAction;
19
+ /** A second action shown after the first, e.g. Decline next to Accept. */
20
+ secondaryAction?: ToastAction;
21
+ /** Avatar or icon shown instead of the tone icon, e.g. who replied. */
22
+ leading?: ReactNode;
23
+ }
24
+
25
+ const glyph: Record<Tone, ReactNode> = {
26
+ neutral: "i",
27
+ info: "i",
28
+ success: <Check />,
29
+ warning: "!",
30
+ danger: "!",
31
+ };
32
+
33
+ export function Toast({
34
+ title,
35
+ description,
36
+ tone = "neutral",
37
+ action,
38
+ onDismiss,
39
+ leading,
40
+ secondaryAction,
41
+ className,
42
+ }: Omit<ToastItem, "id"> & {
43
+ /** Shows a dismiss button. Focus then moves to the next control, never to the page body. */
44
+ onDismiss?: () => void;
45
+ className?: string;
46
+ }) {
47
+ const root = useRef<HTMLDivElement>(null);
48
+ return (
49
+ <div
50
+ ref={root}
51
+ {...componentRoot("Toast")}
52
+ className={cx("m-toast", className)}
53
+ data-tone={tone === "neutral" ? undefined : tone}
54
+ >
55
+ {leading ? (
56
+ <span className="m-toast-leading">{leading}</span>
57
+ ) : (
58
+ <span className="m-toast-icon" aria-hidden="true">
59
+ {glyph[tone]}
60
+ </span>
61
+ )}
62
+ <div>
63
+ <strong>{title}</strong>
64
+ {description && <p>{description}</p>}
65
+ </div>
66
+ <div className="m-actions">
67
+ {action && (
68
+ <Button variant="ghost" onClick={action.onSelect}>
69
+ {action.label}
70
+ </Button>
71
+ )}
72
+ {secondaryAction && (
73
+ <Button variant="ghost" onClick={secondaryAction.onSelect}>
74
+ {secondaryAction.label}
75
+ </Button>
76
+ )}
77
+ {onDismiss && (
78
+ <Button
79
+ variant="ghost"
80
+ icon={<Close />}
81
+ iconOnly
82
+ onClick={() => {
83
+ onDismiss();
84
+ focusAway(root.current);
85
+ }}
86
+ >
87
+ {`Dismiss ${title}`}
88
+ </Button>
89
+ )}
90
+ </div>
91
+ </div>
92
+ );
93
+ }
94
+
95
+ const spoken = (items: ToastItem[]) =>
96
+ items
97
+ .map((item) => [item.title, item.description].filter(Boolean).join(". "))
98
+ .join(". ");
99
+
100
+ /**
101
+ * Controlled toast region. The consumer owns the queue and timeouts. New
102
+ * toasts are announced once, through two persistent live regions: danger
103
+ * assertively, every other tone politely.
104
+ */
105
+ export function Toaster({
106
+ items,
107
+ onDismiss,
108
+ label = "Notifications",
109
+ id,
110
+ className,
111
+ }: {
112
+ items: ToastItem[];
113
+ onDismiss: (id: string) => void;
114
+ label?: string;
115
+ id?: string;
116
+ className?: string;
117
+ }) {
118
+ const region = useRef<HTMLElement>(null);
119
+ const seen = useRef<Set<string> | null>(null);
120
+ const [announcement, setAnnouncement] = useState({
121
+ polite: "",
122
+ assertive: "",
123
+ count: 0,
124
+ });
125
+ useEffect(() => {
126
+ const known = seen.current;
127
+ seen.current = new Set(items.map((item) => item.id));
128
+ // Toasts present on mount are content, not news.
129
+ if (!known) return;
130
+ const fresh = items.filter((item) => !known.has(item.id));
131
+ if (!fresh.length) return;
132
+ const urgent = fresh.filter((item) => item.tone === "danger");
133
+ const calm = fresh.filter((item) => item.tone !== "danger");
134
+ setAnnouncement((previous) => ({
135
+ polite: calm.length ? spoken(calm) : "",
136
+ assertive: urgent.length ? spoken(urgent) : "",
137
+ count: previous.count + 1,
138
+ }));
139
+ }, [items]);
140
+ return (
141
+ <section
142
+ ref={region}
143
+ id={id}
144
+ {...componentRoot("Toaster")}
145
+ className={cx("m-toaster", className)}
146
+ aria-label={label}
147
+ tabIndex={-1}
148
+ >
149
+ <ol>
150
+ {items.map(({ id, ...item }) => (
151
+ <li key={id}>
152
+ <Toast
153
+ {...item}
154
+ onDismiss={() => {
155
+ const section = region.current;
156
+ const toast = section?.ownerDocument.activeElement?.closest(
157
+ "li",
158
+ ) as HTMLElement | null | undefined;
159
+ if (section && toast && section.contains(toast))
160
+ focusAway(toast, { scope: section, fallback: section });
161
+ onDismiss(id);
162
+ }}
163
+ />
164
+ </li>
165
+ ))}
166
+ </ol>
167
+ {/* A new span per announcement, so a repeated message is read again. */}
168
+ <div className="m-sr-only" role="status">
169
+ {announcement.polite && (
170
+ <span key={announcement.count}>{announcement.polite}</span>
171
+ )}
172
+ </div>
173
+ <div className="m-sr-only" role="alert">
174
+ {announcement.assertive && (
175
+ <span key={announcement.count}>{announcement.assertive}</span>
176
+ )}
177
+ </div>
178
+ </section>
179
+ );
180
+ }
181
+
182
+ export function ConfirmDialog({
183
+ open,
184
+ onClose,
185
+ onConfirm,
186
+ title,
187
+ description,
188
+ confirmLabel,
189
+ cancelLabel = "Cancel",
190
+ confirmationText,
191
+ loading = false,
192
+ tone = "danger",
193
+ className,
194
+ }: {
195
+ open: boolean;
196
+ onClose: () => void;
197
+ onConfirm: () => void;
198
+ title: string;
199
+ description: ReactNode;
200
+ confirmLabel: string;
201
+ cancelLabel?: string;
202
+ /** When set, the user must type this value before confirming. */
203
+ confirmationText?: string;
204
+ /** Busy confirming: the confirm button keeps focus but ignores activation. */
205
+ loading?: boolean;
206
+ /** "danger" (default) for a destructive action; "default" for a primary one. */
207
+ tone?: "default" | "danger";
208
+ /** On the dialog panel. */
209
+ className?: string;
210
+ }) {
211
+ const [typed, setTyped] = useState("");
212
+ useEffect(() => {
213
+ if (!open) setTyped("");
214
+ }, [open]);
215
+ const ready = !confirmationText || typed === confirmationText;
216
+ const danger = tone === "danger";
217
+ return (
218
+ <DialogFrame open={open} onClose={onClose} component="ConfirmDialog">
219
+ <div className="m-dialog-position">
220
+ <DialogPanel className={cx("m-dialog-panel", "m-confirm", className)}>
221
+ <form
222
+ onSubmit={(e) => {
223
+ e.preventDefault();
224
+ if (ready && !loading) onConfirm();
225
+ }}
226
+ >
227
+ <div className="m-confirm-body">
228
+ <DialogTitle>{title}</DialogTitle>
229
+ <Description as="div">{description}</Description>
230
+ {confirmationText && (
231
+ <Input
232
+ label={`Type ${confirmationText} to confirm`}
233
+ value={typed}
234
+ onChange={(e) => setTyped(e.target.value)}
235
+ autoComplete="off"
236
+ spellCheck={false}
237
+ />
238
+ )}
239
+ </div>
240
+ <div className="m-confirm-actions">
241
+ <Button variant="secondary" onClick={onClose}>
242
+ {cancelLabel}
243
+ </Button>
244
+ <Button
245
+ type="submit"
246
+ variant={danger ? "danger" : "primary"}
247
+ data-solid={danger || undefined}
248
+ disabled={!ready}
249
+ loading={loading}
250
+ >
251
+ {confirmLabel}
252
+ </Button>
253
+ </div>
254
+ </form>
255
+ </DialogPanel>
256
+ </div>
257
+ </DialogFrame>
258
+ );
259
+ }
260
+
261
+ export function SaveBar({
262
+ open,
263
+ message = "You have unsaved changes.",
264
+ saveLabel = "Save changes",
265
+ discardLabel = "Discard",
266
+ onSave,
267
+ onDiscard,
268
+ loading = false,
269
+ id,
270
+ className,
271
+ }: {
272
+ /** Shows the bar. Its message is announced each time it opens. */
273
+ open: boolean;
274
+ message?: string;
275
+ saveLabel?: string;
276
+ discardLabel?: string;
277
+ onSave: () => void;
278
+ onDiscard: () => void;
279
+ /** Busy saving: Save keeps focus but ignores activation; Discard is disabled. */
280
+ loading?: boolean;
281
+ id?: string;
282
+ className?: string;
283
+ }) {
284
+ const messageId = useId();
285
+ return (
286
+ <>
287
+ {/* Always rendered, so the message is announced when the bar opens. */}
288
+ <span
289
+ {...componentRoot("SaveBar")}
290
+ id={messageId}
291
+ role="status"
292
+ className="m-sr-only"
293
+ >
294
+ {open ? message : ""}
295
+ </span>
296
+ {open && (
297
+ <div
298
+ id={id}
299
+ className={cx("m-save-bar", className)}
300
+ role="region"
301
+ aria-labelledby={messageId}
302
+ >
303
+ <span aria-hidden="true">{message}</span>
304
+ <div className="m-actions">
305
+ <Button variant="secondary" onClick={onDiscard} disabled={loading}>
306
+ {discardLabel}
307
+ </Button>
308
+ <Button onClick={onSave} loading={loading}>
309
+ {saveLabel}
310
+ </Button>
311
+ </div>
312
+ </div>
313
+ )}
314
+ </>
315
+ );
316
+ }
317
+
318
+ /** Site-wide announcement bar with an optional link and dismiss button. */
319
+ export function Banner({
320
+ children,
321
+ action,
322
+ onDismiss,
323
+ label = "Announcement",
324
+ dismissLabel = "Dismiss announcement",
325
+ tone = "inverse",
326
+ variant = "bar",
327
+ placement = "inline",
328
+ id,
329
+ className,
330
+ }: {
331
+ children: ReactNode;
332
+ action?: LinkAction;
333
+ onDismiss?: () => void;
334
+ label?: string;
335
+ dismissLabel?: string;
336
+ /** "inverse" (default) flips the colours; "default" uses the page surface; "accent" paints the brand's accent fill (its accent gradient when it defines one) with on-accent text. */
337
+ tone?: "default" | "inverse" | "accent";
338
+ /** bar: an edge-to-edge strip. floating: an inset, rounded panel with a shadow. */
339
+ variant?: "bar" | "floating";
340
+ /** inline: in the page flow. bottom: fixed to the bottom of the viewport, always with a dismiss button so it never covers content for good. */
341
+ placement?: "inline" | "bottom";
342
+ id?: string;
343
+ className?: string;
344
+ }) {
345
+ const root = useRef<HTMLElement>(null);
346
+ const [dismissed, setDismissed] = useState(false);
347
+ const fixed = placement === "bottom";
348
+ // A fixed banner hides itself when dismissed, even if the page keeps it mounted.
349
+ if (dismissed) return null;
350
+ const dismiss =
351
+ onDismiss || fixed
352
+ ? () => {
353
+ onDismiss?.();
354
+ focusAway(root.current);
355
+ if (fixed) setDismissed(true);
356
+ }
357
+ : undefined;
358
+ return (
359
+ <section
360
+ ref={root}
361
+ id={id}
362
+ className={cx("m-banner", className)}
363
+ aria-label={label}
364
+ data-tone={tone === "default" ? undefined : tone}
365
+ data-variant={variant === "bar" ? undefined : variant}
366
+ data-placement={fixed ? "bottom" : undefined}
367
+ {...componentRoot("Banner")}
368
+ >
369
+ <p>
370
+ {children}
371
+ {action && (
372
+ <>
373
+ {" "}
374
+ <KitLink href={action.href}>
375
+ {action.label}
376
+ <span aria-hidden="true"> →</span>
377
+ </KitLink>
378
+ </>
379
+ )}
380
+ </p>
381
+ {dismiss && (
382
+ <button type="button" aria-label={dismissLabel} onClick={dismiss}>
383
+ <Close />
384
+ </button>
385
+ )}
386
+ </section>
387
+ );
388
+ }