@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
package/src/auth.tsx ADDED
@@ -0,0 +1,452 @@
1
+ // Sign-in and registration building blocks: the auth page shell, provider
2
+ // buttons and the password and one-time-code fields. See docs/auth-flows.md.
3
+ import {
4
+ forwardRef,
5
+ useId,
6
+ useState,
7
+ type CSSProperties,
8
+ type InputHTMLAttributes,
9
+ type ReactNode,
10
+ } from "react";
11
+ import { Button, FieldLabel, type FieldDescription } from "./primitives.js";
12
+ import { BrandMark } from "./layout.js";
13
+ import { Check, Circle, Eye, EyeOff } from "./icons.js";
14
+ import { headingTag, type HeadingLevel } from "./heading.js";
15
+ import { FieldFeedback, componentRoot, cx, useField } from "./internal.js";
16
+
17
+ /** A sign-in provider such as GitHub or Google; the auth SDK handles `onSelect`. */
18
+ export interface SignInProvider {
19
+ id: string;
20
+ label: string;
21
+ /** Decorative logo; the label stays the accessible name. */
22
+ icon?: ReactNode;
23
+ onSelect: () => void;
24
+ }
25
+ export interface ProviderButtonsProps {
26
+ providers: SignInProvider[];
27
+ /** `grid` sets the buttons side by side where they fit, e.g. two short labels. */
28
+ layout?: "list" | "grid";
29
+ /** Names the group of buttons, e.g. "Sign in with". */
30
+ label?: string;
31
+ className?: string;
32
+ }
33
+ /**
34
+ * One full-width secondary button per sign-in provider. Pair it with a
35
+ * labelled Divider ("or continue with") above or below the email form.
36
+ */
37
+ export function ProviderButtons({
38
+ providers,
39
+ layout = "list",
40
+ label,
41
+ className,
42
+ }: ProviderButtonsProps) {
43
+ return (
44
+ <div
45
+ {...componentRoot("ProviderButtons")}
46
+ className={cx("m-provider-buttons", className)}
47
+ role={label ? "group" : undefined}
48
+ aria-label={label}
49
+ data-layout={layout === "list" ? undefined : layout}
50
+ >
51
+ {providers.map((provider) => (
52
+ <Button
53
+ key={provider.id}
54
+ variant="secondary"
55
+ icon={provider.icon}
56
+ onClick={provider.onSelect}
57
+ >
58
+ {provider.label}
59
+ </Button>
60
+ ))}
61
+ </div>
62
+ );
63
+ }
64
+
65
+ /** The logo, heading and description that open an auth panel (internal). */
66
+ export function AuthHeader({
67
+ logo,
68
+ title,
69
+ description,
70
+ headingLevel,
71
+ headingId,
72
+ className,
73
+ titleClassName,
74
+ }: {
75
+ logo?: ReactNode;
76
+ title: string;
77
+ description?: ReactNode;
78
+ headingLevel: HeadingLevel;
79
+ headingId: string;
80
+ className?: string;
81
+ titleClassName: string;
82
+ }) {
83
+ const Heading = headingTag(headingLevel);
84
+ return (
85
+ <header className={className}>
86
+ {logo && <BrandMark logo={logo} />}
87
+ <Heading id={headingId} className={titleClassName}>
88
+ {title}
89
+ </Heading>
90
+ {description && <p>{description}</p>}
91
+ </header>
92
+ );
93
+ }
94
+
95
+ export interface AuthLayoutProps {
96
+ /** The screen's heading, e.g. "Sign in to your account". */
97
+ title: string;
98
+ description?: ReactNode;
99
+ /** Logo node above the heading. */
100
+ logo?: ReactNode;
101
+ /**
102
+ * `center` sets the form on the page, `cards` on a raised card, and `split`
103
+ * beside the `aside` on wide screens.
104
+ */
105
+ layout?: "center" | "cards" | "split";
106
+ /** The page's h1 by default; lower it only when another h1 precedes the layout. */
107
+ headingLevel?: HeadingLevel;
108
+ /** The form. A `<form>` placed directly here stacks its fields, and its direct Button children fill the width. */
109
+ children: ReactNode;
110
+ /** Text under the form, e.g. "Don't have an account? Sign up". */
111
+ footer?: ReactNode;
112
+ /**
113
+ * Image, quote or product art beside the form; shown only with `layout="split"`.
114
+ * On narrow screens text moves below the form and a lone image or video is hidden.
115
+ */
116
+ aside?: ReactNode;
117
+ id?: string;
118
+ className?: string;
119
+ }
120
+ /**
121
+ * The page shell for sign-in, sign-up, password reset and verification
122
+ * screens. It renders the page's `main` landmark, named by the heading.
123
+ */
124
+ export function AuthLayout({
125
+ title,
126
+ description,
127
+ logo,
128
+ layout = "center",
129
+ headingLevel = 1,
130
+ children,
131
+ footer,
132
+ aside,
133
+ id,
134
+ className,
135
+ }: AuthLayoutProps) {
136
+ const headingId = useId();
137
+ return (
138
+ <div
139
+ id={id}
140
+ {...componentRoot("AuthLayout")}
141
+ className={cx("m-auth", className)}
142
+ data-layout={layout === "center" ? undefined : layout}
143
+ >
144
+ <main className="m-auth-main" aria-labelledby={headingId}>
145
+ <div className="m-auth-panel">
146
+ <AuthHeader
147
+ className="m-auth-header"
148
+ titleClassName="m-auth-title"
149
+ logo={logo}
150
+ title={title}
151
+ description={description}
152
+ headingLevel={headingLevel}
153
+ headingId={headingId}
154
+ />
155
+ <div className="m-auth-body">{children}</div>
156
+ {footer && <footer className="m-auth-footer">{footer}</footer>}
157
+ </div>
158
+ </main>
159
+ {layout === "split" && aside && (
160
+ <aside className="m-auth-aside">{aside}</aside>
161
+ )}
162
+ </div>
163
+ );
164
+ }
165
+
166
+ export interface PasswordRequirement {
167
+ /** Shown before the password is typed, e.g. "At least 12 characters". */
168
+ label: string;
169
+ met: boolean;
170
+ }
171
+ export interface PasswordStrength {
172
+ /** 0 (very weak) to 4 (very strong), e.g. from zxcvbn's score. */
173
+ value: 0 | 1 | 2 | 3 | 4;
174
+ /** Visible and announced value, e.g. "Strong". */
175
+ label: string;
176
+ }
177
+ export interface PasswordInputProps
178
+ extends
179
+ Omit<InputHTMLAttributes<HTMLInputElement>, "type">,
180
+ FieldDescription {
181
+ /**
182
+ * `current` (sign-in) sets autocomplete="current-password"; `new` (sign-up,
183
+ * reset) sets "new-password" so password managers offer to generate one.
184
+ */
185
+ purpose?: "current" | "new";
186
+ /** Rules shown under the field from the start, each marked met or not met as the person types. */
187
+ requirements?: PasswordRequirement[];
188
+ /** Strength meter under the field. */
189
+ strength?: PasswordStrength;
190
+ /** Accessible name of the show/hide toggle; its pressed state says whether the password is visible. */
191
+ toggleLabel?: string;
192
+ }
193
+ /**
194
+ * Password field with a show/hide toggle, optional requirement checklist and
195
+ * strength meter. Paste is never blocked.
196
+ */
197
+ export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
198
+ function PasswordInput(
199
+ {
200
+ label,
201
+ hint,
202
+ error,
203
+ id,
204
+ className,
205
+ purpose = "current",
206
+ requirements,
207
+ strength,
208
+ toggleLabel = "Show password",
209
+ autoComplete,
210
+ ...props
211
+ },
212
+ ref,
213
+ ) {
214
+ const [visible, setVisible] = useState(false);
215
+ const requirementsId = useId();
216
+ const { fieldId, messageId, describedBy } = useField({
217
+ id,
218
+ hint,
219
+ error,
220
+ describedBy: props["aria-describedby"],
221
+ });
222
+ const allMet = !!requirements?.length && requirements.every((r) => r.met);
223
+ // The status region speaks only when the overall state flips, not on
224
+ // every keystroke: silent until every rule has been met once.
225
+ const [everMet, setEverMet] = useState(false);
226
+ if (allMet && !everMet) setEverMet(true);
227
+ const status = allMet
228
+ ? "All password requirements met."
229
+ : everMet
230
+ ? "Password no longer meets every requirement."
231
+ : "";
232
+ return (
233
+ <div
234
+ {...componentRoot("PasswordInput")}
235
+ className={cx("m-field", "m-password", className)}
236
+ >
237
+ <FieldLabel htmlFor={fieldId} label={label} required={props.required} />
238
+ <div className="m-input-group" data-invalid={!!error || undefined}>
239
+ <input
240
+ {...props}
241
+ ref={ref}
242
+ id={fieldId}
243
+ type={visible ? "text" : "password"}
244
+ autoComplete={
245
+ autoComplete ??
246
+ (purpose === "new" ? "new-password" : "current-password")
247
+ }
248
+ autoCapitalize="off"
249
+ spellCheck={false}
250
+ aria-invalid={!!error || props["aria-invalid"]}
251
+ aria-describedby={
252
+ [describedBy, requirements?.length && requirementsId]
253
+ .filter(Boolean)
254
+ .join(" ") || undefined
255
+ }
256
+ />
257
+ <button
258
+ type="button"
259
+ className="m-password-toggle"
260
+ aria-pressed={visible}
261
+ aria-controls={fieldId}
262
+ disabled={props.disabled}
263
+ // A pointer press keeps focus where it was (usually the field).
264
+ onMouseDown={(event) => event.preventDefault()}
265
+ onClick={() => setVisible((v) => !v)}
266
+ >
267
+ {visible ? <EyeOff /> : <Eye />}
268
+ <span className="m-sr-only">{toggleLabel}</span>
269
+ </button>
270
+ </div>
271
+ <FieldFeedback id={messageId} hint={hint} error={error} />
272
+ {strength && <StrengthMeter strength={strength} />}
273
+ {requirements && requirements.length > 0 && (
274
+ <>
275
+ <ul
276
+ id={requirementsId}
277
+ className="m-password-requirements"
278
+ aria-label="Password requirements"
279
+ >
280
+ {requirements.map((rule) => (
281
+ <li key={rule.label} data-met={rule.met || undefined}>
282
+ {rule.met ? <Check /> : <Circle />}
283
+ <span>{rule.label}</span>
284
+ <span className="m-sr-only">
285
+ {rule.met ? ", met" : ", not met"}
286
+ </span>
287
+ </li>
288
+ ))}
289
+ </ul>
290
+ <p role="status" className="m-sr-only">
291
+ {status}
292
+ </p>
293
+ </>
294
+ )}
295
+ </div>
296
+ );
297
+ },
298
+ );
299
+
300
+ function StrengthMeter({ strength }: { strength: PasswordStrength }) {
301
+ const labelId = useId();
302
+ const value = Math.max(0, Math.min(4, Math.round(strength.value)));
303
+ return (
304
+ <div className="m-password-strength" data-value={value}>
305
+ <span id={labelId} className="m-sr-only">
306
+ Password strength
307
+ </span>
308
+ <div
309
+ className="m-password-meter"
310
+ role="meter"
311
+ aria-labelledby={labelId}
312
+ aria-valuemin={0}
313
+ aria-valuemax={4}
314
+ aria-valuenow={value}
315
+ aria-valuetext={strength.label}
316
+ >
317
+ {[1, 2, 3, 4].map((step) => (
318
+ <span key={step} data-on={value >= step || undefined} />
319
+ ))}
320
+ </div>
321
+ <span aria-hidden="true">{strength.label}</span>
322
+ </div>
323
+ );
324
+ }
325
+
326
+ export interface CodeInputProps extends FieldDescription {
327
+ /** Number of characters; one visual cell each. */
328
+ length?: number;
329
+ /** Controlled value; pair with `onChange`. */
330
+ value?: string;
331
+ defaultValue?: string;
332
+ /** The cleaned code after each edit: allowed characters only, at most `length`. */
333
+ onChange?: (value: string) => void;
334
+ /**
335
+ * Called once when the code reaches `length` characters, by typing, paste or
336
+ * autofill. Submitting here is opt-in; without it the person submits the form.
337
+ */
338
+ onComplete?: (code: string) => void;
339
+ /** Accept letters as well as digits (upper-cased); digits only by default. */
340
+ alphanumeric?: boolean;
341
+ /** Id of the input, e.g. the target of an error summary link. */
342
+ id?: string;
343
+ name?: string;
344
+ required?: boolean;
345
+ disabled?: boolean;
346
+ autoFocus?: boolean;
347
+ }
348
+ /**
349
+ * One-time code field: a single input shown as one cell per character, so
350
+ * screen readers, paste and SMS or password-manager autofill all see one
351
+ * field (autocomplete="one-time-code").
352
+ */
353
+ export const CodeInput = forwardRef<HTMLInputElement, CodeInputProps>(
354
+ function CodeInput(
355
+ {
356
+ label,
357
+ hint,
358
+ error,
359
+ className,
360
+ length = 6,
361
+ value: controlled,
362
+ defaultValue = "",
363
+ onChange,
364
+ onComplete,
365
+ alphanumeric = false,
366
+ id,
367
+ name,
368
+ required,
369
+ disabled,
370
+ autoFocus,
371
+ },
372
+ ref,
373
+ ) {
374
+ const clean = (text: string) =>
375
+ (alphanumeric
376
+ ? text.replace(/[^a-z0-9]/gi, "").toUpperCase()
377
+ : text.replace(/\D/g, "")
378
+ ).slice(0, length);
379
+ const [local, setLocal] = useState(() => clean(defaultValue));
380
+ const value = controlled === undefined ? local : clean(controlled);
381
+ const [focused, setFocused] = useState(false);
382
+ const { fieldId, messageId, describedBy } = useField({ id, hint, error });
383
+ const active = focused ? Math.min(value.length, length - 1) : -1;
384
+ return (
385
+ <div
386
+ {...componentRoot("CodeInput")}
387
+ className={cx("m-field", "m-code-input", className)}
388
+ data-disabled={disabled || undefined}
389
+ >
390
+ <FieldLabel htmlFor={fieldId} label={label} required={required} />
391
+ <div
392
+ className="m-code-control"
393
+ data-invalid={!!error || undefined}
394
+ style={{ "--m-code-length": length } as CSSProperties}
395
+ >
396
+ <input
397
+ ref={ref}
398
+ id={fieldId}
399
+ name={name}
400
+ type="text"
401
+ inputMode={alphanumeric ? "text" : "numeric"}
402
+ autoComplete="one-time-code"
403
+ autoCapitalize={alphanumeric ? "characters" : "off"}
404
+ autoCorrect="off"
405
+ spellCheck={false}
406
+ pattern={
407
+ alphanumeric ? `[A-Za-z0-9]{${length}}` : `[0-9]{${length}}`
408
+ }
409
+ required={required}
410
+ disabled={disabled}
411
+ autoFocus={autoFocus}
412
+ value={value}
413
+ aria-invalid={!!error || undefined}
414
+ aria-describedby={describedBy}
415
+ onFocus={() => setFocused(true)}
416
+ onBlur={() => setFocused(false)}
417
+ // Keep the caret after the last character, so typing fills the
418
+ // next cell and Backspace clears the previous one.
419
+ onSelect={(event) => {
420
+ const input = event.currentTarget;
421
+ const end = input.value.length;
422
+ if (
423
+ input.selectionStart === input.selectionEnd &&
424
+ input.selectionStart !== end
425
+ )
426
+ input.setSelectionRange(end, end);
427
+ }}
428
+ onChange={(event) => {
429
+ const next = clean(event.target.value);
430
+ if (controlled === undefined) setLocal(next);
431
+ onChange?.(next);
432
+ if (next !== value && next.length === length) onComplete?.(next);
433
+ }}
434
+ />
435
+ <div className="m-code-cells" aria-hidden="true">
436
+ {Array.from({ length }, (_, index) => (
437
+ <span
438
+ key={index}
439
+ className="m-code-cell"
440
+ data-filled={index < value.length || undefined}
441
+ data-active={index === active || undefined}
442
+ >
443
+ {value[index]}
444
+ </span>
445
+ ))}
446
+ </div>
447
+ </div>
448
+ <FieldFeedback id={messageId} hint={hint} error={error} />
449
+ </div>
450
+ );
451
+ },
452
+ );