affora 0.1.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.
@@ -0,0 +1,134 @@
1
+ import type React from "react"
2
+ import { useId, useState } from "react"
3
+
4
+ export type ConfirmUndoProps = {
5
+ folderName?: string
6
+ fileCount?: number
7
+ onCommit?: (state: "deleted" | "restored") => void
8
+ }
9
+
10
+ /** A recoverable destructive action with a persistent, named reversal. */
11
+ export const ConfirmUndo: React.FC<ConfirmUndoProps> = ({
12
+ folderName = "Drafts",
13
+ fileCount = 12,
14
+ onCommit,
15
+ }) => {
16
+ const [deleted, setDeleted] = useState(false)
17
+ const statusId = useId()
18
+
19
+ const remove = () => {
20
+ setDeleted(true)
21
+ onCommit?.("deleted")
22
+ }
23
+
24
+ const restore = () => {
25
+ setDeleted(false)
26
+ onCommit?.("restored")
27
+ }
28
+
29
+ return (
30
+ <section className="acu-root" aria-labelledby={`${statusId}-title`}>
31
+ <style>{css}</style>
32
+ <header>
33
+ <h3 id={`${statusId}-title`}>Folders</h3>
34
+ <p>Deleted folders remain recoverable for 30 days.</p>
35
+ </header>
36
+
37
+ {!deleted ? (
38
+ <div className="acu-row">
39
+ <div>
40
+ <strong>{folderName}</strong>
41
+ <span>{fileCount} files</span>
42
+ </div>
43
+ <button type="button" className="acu-delete" aria-describedby={`${statusId}-consequence`} onClick={remove}>
44
+ Move {folderName} to trash
45
+ </button>
46
+ <p id={`${statusId}-consequence`} className="acu-consequence">
47
+ This removes the folder from Folders. You can undo this action or restore it from Trash for 30 days.
48
+ </p>
49
+ </div>
50
+ ) : (
51
+ <div className="acu-result">
52
+ <p><strong>{folderName}</strong> moved to Trash.</p>
53
+ <button type="button" onClick={restore}>Undo — restore {folderName}</button>
54
+ </div>
55
+ )}
56
+
57
+ <output id={statusId} className="acu-status" aria-live="polite">
58
+ {deleted ? `${folderName} is in Trash and can be restored for 30 days.` : `${folderName} is available in Folders.`}
59
+ </output>
60
+ </section>
61
+ )
62
+ }
63
+
64
+ export const css = `
65
+ .acu-root {
66
+ box-sizing: border-box;
67
+ display: grid;
68
+ gap: var(--gap);
69
+ width: 100%;
70
+ max-width: 36rem;
71
+ padding: var(--pad-loose);
72
+ color: var(--fg);
73
+ background: var(--glass-bg, var(--surface));
74
+ border: 1px solid var(--glass-border, var(--border));
75
+ border-radius: var(--radius-lg);
76
+ box-shadow: var(--shadow-sm);
77
+ font-family: var(--font);
78
+ font-size: var(--text-base);
79
+ line-height: var(--leading);
80
+ }
81
+ .acu-root * { box-sizing: border-box; }
82
+ .acu-root h3 {
83
+ margin: 0;
84
+ font-family: var(--font-display);
85
+ font-size: calc(var(--text-base) * 1.15);
86
+ font-weight: var(--weight-display);
87
+ letter-spacing: var(--tracking-tight);
88
+ }
89
+ .acu-root header p, .acu-status { margin: 0; color: var(--fg-muted); font-size: calc(var(--text-base) * 0.9); }
90
+ .acu-row {
91
+ display: grid;
92
+ grid-template-columns: minmax(0, 1fr) auto;
93
+ gap: var(--gap);
94
+ align-items: center;
95
+ padding: var(--pad);
96
+ background: var(--surface-2);
97
+ border: 1px solid var(--border);
98
+ border-radius: var(--radius);
99
+ }
100
+ .acu-row > div { display: grid; }
101
+ .acu-row span { color: var(--fg-muted); font-size: calc(var(--text-base) * 0.9); }
102
+ .acu-consequence { grid-column: 1 / -1; margin: 0; color: var(--fg-muted); font-size: calc(var(--text-base) * 0.9); }
103
+ .acu-root button {
104
+ min-height: calc(var(--text-base) * 2.75);
105
+ padding: var(--pad-tight) var(--pad);
106
+ color: var(--accent);
107
+ background: var(--bg);
108
+ border: 1px solid var(--border-strong);
109
+ border-radius: var(--radius);
110
+ font: inherit;
111
+ font-weight: var(--weight-medium);
112
+ cursor: pointer;
113
+ }
114
+ .acu-root button:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--ring); }
115
+ .acu-root .acu-delete { color: var(--danger); border-color: var(--danger); }
116
+ .acu-result {
117
+ display: flex;
118
+ flex-wrap: wrap;
119
+ align-items: center;
120
+ justify-content: space-between;
121
+ gap: var(--gap);
122
+ padding: var(--pad);
123
+ background: var(--accent-weak);
124
+ border: 1px solid var(--accent);
125
+ border-radius: var(--radius);
126
+ }
127
+ .acu-result p { margin: 0; }
128
+ @media (max-width: 30rem) {
129
+ .acu-row { grid-template-columns: 1fr; }
130
+ .acu-row .acu-delete { width: 100%; }
131
+ }
132
+ `
133
+
134
+ export default ConfirmUndo
@@ -0,0 +1,155 @@
1
+ import type React from "react"
2
+ import { useId, useRef, useState } from "react"
3
+
4
+ export type ErrorRemedyProps = {
5
+ onCommit?: (value: string) => void
6
+ destination?: string
7
+ }
8
+
9
+ const INVALID_METHOD = "International courier"
10
+ const VALID_METHOD = "Domestic ground"
11
+
12
+ /** A form whose failure remains visible and names the exact corrective action. */
13
+ export const ErrorRemedy: React.FC<ErrorRemedyProps> = ({
14
+ onCommit,
15
+ destination = "500 Howard St, San Francisco, United States",
16
+ }) => {
17
+ const [method, setMethod] = useState(INVALID_METHOD)
18
+ const [error, setError] = useState("")
19
+ const [saved, setSaved] = useState(false)
20
+ const methodRef = useRef<HTMLSelectElement>(null)
21
+ const methodId = useId()
22
+ const errorId = useId()
23
+ const statusId = useId()
24
+
25
+ const save = () => {
26
+ if (method === INVALID_METHOD) {
27
+ setSaved(false)
28
+ setError(`${INVALID_METHOD} is not available for United States. Choose “${VALID_METHOD}”, then save again.`)
29
+ methodRef.current?.focus()
30
+ return
31
+ }
32
+ setError("")
33
+ setSaved(true)
34
+ onCommit?.("SHIP-OK")
35
+ }
36
+
37
+ return (
38
+ <form className="aer-root" onSubmit={(event) => { event.preventDefault(); save() }} noValidate>
39
+ <style>{css}</style>
40
+ <header className="aer-heading">
41
+ <h3>Shipping preferences</h3>
42
+ <p>Deliver to: {destination}</p>
43
+ </header>
44
+
45
+ <div className="aer-field">
46
+ <label htmlFor={methodId}>Shipping method</label>
47
+ <select
48
+ ref={methodRef}
49
+ id={methodId}
50
+ value={method}
51
+ aria-invalid={error ? "true" : undefined}
52
+ aria-describedby={error ? errorId : undefined}
53
+ onChange={(event) => {
54
+ setMethod(event.target.value)
55
+ setSaved(false)
56
+ if (event.target.value === VALID_METHOD) setError("")
57
+ }}
58
+ >
59
+ <option>{INVALID_METHOD}</option>
60
+ <option>{VALID_METHOD}</option>
61
+ </select>
62
+ {error && <p id={errorId} className="aer-error" role="alert">{error}</p>}
63
+ </div>
64
+
65
+ <label className="aer-check">
66
+ <input type="checkbox" />
67
+ <span>Gift wrap</span>
68
+ </label>
69
+
70
+ <div className="aer-field">
71
+ <label htmlFor={`${methodId}-notes`}>Delivery notes</label>
72
+ <input id={`${methodId}-notes`} type="text" placeholder="For example, leave at door" />
73
+ </div>
74
+
75
+ <div className="aer-field">
76
+ <label htmlFor={`${methodId}-promo`}>Promo code</label>
77
+ <input id={`${methodId}-promo`} type="text" />
78
+ </div>
79
+
80
+ <button type="submit">Save preferences</button>
81
+ <output id={statusId} className="aer-status" aria-live="polite">
82
+ {saved ? `Saved. Shipping method: ${method}.` : "Preferences not saved yet."}
83
+ </output>
84
+ </form>
85
+ )
86
+ }
87
+
88
+ export const css = `
89
+ .aer-root {
90
+ box-sizing: border-box;
91
+ display: grid;
92
+ gap: var(--gap);
93
+ width: 100%;
94
+ max-width: 32rem;
95
+ padding: var(--pad-loose);
96
+ color: var(--fg);
97
+ background: var(--glass-bg, var(--surface));
98
+ border: 1px solid var(--glass-border, var(--border));
99
+ border-radius: var(--radius-lg);
100
+ box-shadow: var(--shadow-sm);
101
+ font-family: var(--font);
102
+ font-size: var(--text-base);
103
+ line-height: var(--leading);
104
+ }
105
+ .aer-root * { box-sizing: border-box; }
106
+ .aer-heading h3 {
107
+ margin: 0;
108
+ font-family: var(--font-display);
109
+ font-size: calc(var(--text-base) * 1.15);
110
+ font-weight: var(--weight-display);
111
+ letter-spacing: var(--tracking-tight);
112
+ }
113
+ .aer-heading p, .aer-status { margin: 0; color: var(--fg-muted); font-size: calc(var(--text-base) * 0.9); }
114
+ .aer-field { display: grid; gap: calc(var(--gap) * 0.5); }
115
+ .aer-field label { font-weight: var(--weight-medium); }
116
+ .aer-field input, .aer-field select {
117
+ width: 100%;
118
+ min-height: calc(var(--text-base) * 2.75);
119
+ padding: var(--pad-tight) var(--pad);
120
+ color: var(--fg);
121
+ background: var(--bg);
122
+ border: 1px solid var(--border-strong);
123
+ border-radius: var(--radius);
124
+ font: inherit;
125
+ }
126
+ .aer-field input:focus-visible, .aer-field select:focus-visible, .aer-root button:focus-visible {
127
+ outline: none;
128
+ box-shadow: 0 0 0 3px var(--ring);
129
+ }
130
+ .aer-field [aria-invalid='true'] { border-color: var(--danger); }
131
+ .aer-error {
132
+ margin: 0;
133
+ padding: var(--pad-tight) var(--pad);
134
+ color: var(--danger);
135
+ background: var(--surface-2);
136
+ border-left: 3px solid var(--danger);
137
+ border-radius: var(--radius-sm);
138
+ font-size: calc(var(--text-base) * 0.9);
139
+ }
140
+ .aer-check { display: flex; align-items: center; gap: var(--pad-tight); min-height: calc(var(--text-base) * 2.75); }
141
+ .aer-check input { width: calc(var(--text-base) * 1.25); height: calc(var(--text-base) * 1.25); margin: 0; accent-color: var(--accent); }
142
+ .aer-root button {
143
+ min-height: calc(var(--text-base) * 2.75);
144
+ padding: var(--pad-tight) var(--pad);
145
+ color: var(--accent-fg);
146
+ background: var(--accent);
147
+ border: 1px solid var(--accent);
148
+ border-radius: var(--radius);
149
+ font: inherit;
150
+ font-weight: var(--weight-medium);
151
+ cursor: pointer;
152
+ }
153
+ `
154
+
155
+ export default ErrorRemedy
@@ -0,0 +1,179 @@
1
+ import type React from "react"
2
+ import { useId, useMemo, useRef, useState } from "react"
3
+
4
+ type Values = {
5
+ name: string
6
+ email: string
7
+ region: string
8
+ plan: string
9
+ terms: boolean
10
+ }
11
+
12
+ type Field = keyof Values
13
+
14
+ export type FlowFormProps = {
15
+ onCommit?: (value: string) => void
16
+ }
17
+
18
+ const EMPTY: Values = { name: "", email: "", region: "", plan: "", terms: false }
19
+ const STEP_FIELDS: Field[][] = [["name", "email"], ["region", "plan"], ["terms"]]
20
+ const STEP_TITLES = ["Account", "Preferences", "Review"]
21
+
22
+ function validate(values: Values) {
23
+ const errors: Partial<Record<Field, string>> = {}
24
+ if (!values.name.trim()) errors.name = "Enter your full name."
25
+ if (!/^\S+@\S+\.\S+$/.test(values.email)) errors.email = "Enter an email address in the form name@example.com."
26
+ if (!values.region) errors.region = "Choose a region."
27
+ if (!values.plan) errors.plan = "Choose a plan."
28
+ if (!values.terms) errors.terms = "Accept the terms of service before registering."
29
+ return errors
30
+ }
31
+
32
+ /** A visually guided form whose complete, directly submittable substrate stays mounted. */
33
+ export const FlowForm: React.FC<FlowFormProps> = ({ onCommit }) => {
34
+ const [values, setValues] = useState(EMPTY)
35
+ const [errors, setErrors] = useState<Partial<Record<Field, string>>>({})
36
+ const [activeStep, setActiveStep] = useState(0)
37
+ const [submitted, setSubmitted] = useState(false)
38
+ const formRef = useRef<HTMLFormElement>(null)
39
+ const baseId = useId()
40
+ const summaryId = useId()
41
+
42
+ const completed = useMemo(
43
+ () => STEP_FIELDS.map((fields) => fields.every((field) => !validate(values)[field])),
44
+ [values],
45
+ )
46
+
47
+ const set = <K extends Field>(field: K, value: Values[K]) => {
48
+ setValues((current) => ({ ...current, [field]: value }))
49
+ setErrors((current) => ({ ...current, [field]: undefined }))
50
+ setSubmitted(false)
51
+ }
52
+
53
+ const submit = (event: React.FormEvent) => {
54
+ event.preventDefault()
55
+ const nextErrors = validate(values)
56
+ setErrors(nextErrors)
57
+ if (Object.keys(nextErrors).length) {
58
+ const firstInvalid = STEP_FIELDS.findIndex((fields) => fields.some((field) => nextErrors[field]))
59
+ if (firstInvalid >= 0) setActiveStep(firstInvalid)
60
+ requestAnimationFrame(() => formRef.current?.querySelector<HTMLElement>("[data-affora-invalid='true']")?.focus())
61
+ return
62
+ }
63
+ setSubmitted(true)
64
+ onCommit?.("REG-2049")
65
+ }
66
+
67
+ const error = (field: Field) => errors[field]
68
+ const describedBy = (field: Field) => error(field) ? `${baseId}-${field}-error` : undefined
69
+
70
+ return (
71
+ <form ref={formRef} className="aff-root" onSubmit={submit} noValidate aria-describedby={summaryId}>
72
+ <style>{css}</style>
73
+ <header className="aff-heading">
74
+ <h3>Create account</h3>
75
+ <p>All fields are available below. Use the step headings for guidance or complete the form directly.</p>
76
+ </header>
77
+
78
+ <ol className="aff-steps" aria-label="Registration progress">
79
+ {STEP_TITLES.map((title, index) => (
80
+ <li key={title}>
81
+ <button
82
+ type="button"
83
+ aria-current={activeStep === index ? "step" : undefined}
84
+ onClick={() => setActiveStep(index)}
85
+ >
86
+ <span>Step {index + 1}</span>
87
+ {title}
88
+ <small>{completed[index] ? "Complete" : "Incomplete"}</small>
89
+ </button>
90
+ </li>
91
+ ))}
92
+ </ol>
93
+
94
+ {Object.keys(errors).length > 0 && (
95
+ <div className="aff-summary" role="alert">
96
+ <strong>Registration not submitted.</strong>
97
+ <span>Correct the named fields below, then select Register.</span>
98
+ </div>
99
+ )}
100
+
101
+ <section className="aff-section" data-active={activeStep === 0} aria-labelledby={`${baseId}-account`}>
102
+ <h4 id={`${baseId}-account`}>1. Account</h4>
103
+ <div className="aff-field">
104
+ <label htmlFor={`${baseId}-name`}>Full name</label>
105
+ <input id={`${baseId}-name`} value={values.name} onChange={(e) => set("name", e.target.value)} aria-invalid={!!error("name")} aria-describedby={describedBy("name")} data-affora-invalid={!!error("name")} />
106
+ {error("name") && <p id={`${baseId}-name-error`} className="aff-error">{error("name")}</p>}
107
+ </div>
108
+ <div className="aff-field">
109
+ <label htmlFor={`${baseId}-email`}>Email address</label>
110
+ <input id={`${baseId}-email`} type="email" value={values.email} onChange={(e) => set("email", e.target.value)} aria-invalid={!!error("email")} aria-describedby={describedBy("email")} data-affora-invalid={!!error("email")} />
111
+ {error("email") && <p id={`${baseId}-email-error`} className="aff-error">{error("email")}</p>}
112
+ </div>
113
+ </section>
114
+
115
+ <section className="aff-section" data-active={activeStep === 1} aria-labelledby={`${baseId}-preferences`}>
116
+ <h4 id={`${baseId}-preferences`}>2. Preferences</h4>
117
+ <div className="aff-field">
118
+ <label htmlFor={`${baseId}-region`}>Region</label>
119
+ <select id={`${baseId}-region`} value={values.region} onChange={(e) => set("region", e.target.value)} aria-invalid={!!error("region")} aria-describedby={describedBy("region")} data-affora-invalid={!!error("region")}>
120
+ <option value="">Choose a region</option>
121
+ <option>Americas</option><option>Europe</option><option>Asia Pacific</option>
122
+ </select>
123
+ {error("region") && <p id={`${baseId}-region-error`} className="aff-error">{error("region")}</p>}
124
+ </div>
125
+ <fieldset className="aff-field" aria-describedby={describedBy("plan")}>
126
+ <legend>Plan</legend>
127
+ <div className="aff-options" data-affora-invalid={!!error("plan")} tabIndex={error("plan") ? -1 : undefined}>
128
+ {["Starter", "Pro", "Enterprise"].map((plan) => <label key={plan}><input type="radio" name={`${baseId}-plan`} value={plan} checked={values.plan === plan} onChange={() => set("plan", plan)} />{plan}</label>)}
129
+ </div>
130
+ {error("plan") && <p id={`${baseId}-plan-error`} className="aff-error">{error("plan")}</p>}
131
+ </fieldset>
132
+ </section>
133
+
134
+ <section className="aff-section" data-active={activeStep === 2} aria-labelledby={`${baseId}-review`}>
135
+ <h4 id={`${baseId}-review`}>3. Review</h4>
136
+ <p className="aff-review">Registering {values.name.trim() || "name not entered"} ({values.email.trim() || "email not entered"}) · {values.region || "region not chosen"} · {values.plan || "plan not chosen"} plan.</p>
137
+ <label className="aff-check" data-affora-invalid={!!error("terms")} tabIndex={error("terms") ? -1 : undefined}>
138
+ <input type="checkbox" checked={values.terms} onChange={(e) => set("terms", e.target.checked)} aria-describedby={describedBy("terms")} />
139
+ I accept the terms of service
140
+ </label>
141
+ {error("terms") && <p id={`${baseId}-terms-error`} className="aff-error">{error("terms")}</p>}
142
+ </section>
143
+
144
+ <button className="aff-submit" type="submit">Register</button>
145
+ <output id={summaryId} className="aff-status" aria-live="polite">
146
+ {submitted ? `Registration complete for ${values.name}. Confirmation REG-2049.` : `Progress: ${STEP_TITLES.filter((_, index) => completed[index]).join(", ") || "no sections"} complete.`}
147
+ </output>
148
+ </form>
149
+ )
150
+ }
151
+
152
+ export const css = `
153
+ .aff-root { box-sizing: border-box; display: grid; gap: var(--gap); width: 100%; max-width: 44rem; padding: var(--pad-loose); color: var(--fg); background: var(--glass-bg, var(--surface)); border: 1px solid var(--glass-border, var(--border)); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); font-family: var(--font); font-size: var(--text-base); line-height: var(--leading); }
154
+ .aff-root * { box-sizing: border-box; }
155
+ .aff-heading h3, .aff-section h4 { margin: 0; font-family: var(--font-display); letter-spacing: var(--tracking-tight); }
156
+ .aff-heading h3 { font-size: calc(var(--text-base) * 1.2); font-weight: var(--weight-display); }
157
+ .aff-heading p, .aff-status, .aff-review { margin: 0; color: var(--fg-muted); font-size: calc(var(--text-base) * 0.9); }
158
+ .aff-steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--gap); margin: 0; padding: 0; list-style: none; }
159
+ .aff-steps button { display: grid; width: 100%; min-height: calc(var(--text-base) * 3.5); padding: var(--pad-tight); text-align: left; color: var(--fg); background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius); font: inherit; cursor: pointer; }
160
+ .aff-steps button[aria-current='step'] { border-color: var(--accent); box-shadow: inset 0 -3px var(--accent); }
161
+ .aff-steps span, .aff-steps small { color: var(--fg-muted); font-size: calc(var(--text-base) * 0.78); }
162
+ .aff-summary, .aff-error { color: var(--danger); background: var(--surface-2); border-left: 3px solid var(--danger); border-radius: var(--radius-sm); }
163
+ .aff-summary { display: grid; padding: var(--pad-tight) var(--pad); }
164
+ .aff-section { display: grid; gap: var(--gap); padding: var(--pad); border: 1px solid var(--border); border-radius: var(--radius); opacity: 0.75; }
165
+ .aff-section[data-active='true'] { border-color: var(--accent); opacity: 1; }
166
+ .aff-field { display: grid; gap: calc(var(--gap) * 0.5); margin: 0; padding: 0; border: 0; }
167
+ .aff-field label, .aff-field legend { font-weight: var(--weight-medium); }
168
+ .aff-field input:not([type='radio']), .aff-field select { width: 100%; min-height: calc(var(--text-base) * 2.75); padding: var(--pad-tight) var(--pad); color: var(--fg); background: var(--bg); border: 1px solid var(--border-strong); border-radius: var(--radius); font: inherit; }
169
+ .aff-options { display: flex; flex-wrap: wrap; gap: var(--gap); }
170
+ .aff-options label, .aff-check { display: flex; align-items: center; gap: var(--pad-tight); min-height: calc(var(--text-base) * 2.75); }
171
+ .aff-options input, .aff-check input { width: calc(var(--text-base) * 1.25); height: calc(var(--text-base) * 1.25); accent-color: var(--accent); }
172
+ .aff-error { margin: 0; padding: var(--pad-tight) var(--pad); font-size: calc(var(--text-base) * 0.9); }
173
+ .aff-root [aria-invalid='true'], .aff-root [data-affora-invalid='true'] { border-color: var(--danger); }
174
+ .aff-root input:focus-visible, .aff-root select:focus-visible, .aff-root button:focus-visible, .aff-root [tabindex='-1']:focus { outline: none; box-shadow: 0 0 0 3px var(--ring); }
175
+ .aff-submit { min-height: calc(var(--text-base) * 2.75); padding: var(--pad-tight) var(--pad); color: var(--accent-fg); background: var(--accent); border: 1px solid var(--accent); border-radius: var(--radius); font: inherit; font-weight: var(--weight-medium); cursor: pointer; }
176
+ @media (max-width: 34rem) { .aff-steps { grid-template-columns: 1fr; } }
177
+ `
178
+
179
+ export default FlowForm
@@ -0,0 +1,157 @@
1
+ import type React from "react"
2
+ import { useId, useState } from "react"
3
+
4
+ export type GatedActionProps = {
5
+ onCommit?: (invoice: string) => void
6
+ invoice?: string
7
+ }
8
+
9
+ /** A gated action whose disabled state names both its blocker and remedy. */
10
+ export const GatedAction: React.FC<GatedActionProps> = ({
11
+ onCommit,
12
+ invoice = "INV-1002",
13
+ }) => {
14
+ const [acknowledged, setAcknowledged] = useState(false)
15
+ const [downloaded, setDownloaded] = useState(false)
16
+ const reasonId = useId()
17
+ const statusId = useId()
18
+
19
+ const download = () => {
20
+ if (!acknowledged) return
21
+ setDownloaded(true)
22
+ onCommit?.(invoice)
23
+ }
24
+
25
+ return (
26
+ <section className="aga-root" aria-labelledby={`${reasonId}-title`}>
27
+ <style>{css}</style>
28
+ <div className="aga-heading">
29
+ <div>
30
+ <h3 id={`${reasonId}-title`} className="aga-title">Order #1002</h3>
31
+ <p className="aga-summary">2 items · $84.00 · shipped 20 August 2026</p>
32
+ </div>
33
+ <span className="aga-invoice">{invoice}</span>
34
+ </div>
35
+
36
+ <p className="aga-policy">
37
+ Invoices are issued under the billing policy and include VAT where applicable.
38
+ </p>
39
+
40
+ <label className="aga-check">
41
+ <input
42
+ type="checkbox"
43
+ checked={acknowledged}
44
+ onChange={(event) => {
45
+ setAcknowledged(event.target.checked)
46
+ setDownloaded(false)
47
+ }}
48
+ />
49
+ <span>I acknowledge the billing policy</span>
50
+ </label>
51
+
52
+ <p id={reasonId} className="aga-reason">
53
+ {acknowledged
54
+ ? "Ready: the billing policy is acknowledged."
55
+ : 'Download unavailable: select “I acknowledge the billing policy” first.'}
56
+ </p>
57
+
58
+ <button
59
+ type="button"
60
+ className="aga-button"
61
+ disabled={!acknowledged}
62
+ aria-describedby={`${reasonId} ${statusId}`}
63
+ onClick={download}
64
+ >
65
+ Download invoice
66
+ </button>
67
+
68
+ <output id={statusId} className="aga-status" aria-live="polite">
69
+ {downloaded ? `Downloaded invoice ${invoice}.` : "No invoice downloaded yet."}
70
+ </output>
71
+ </section>
72
+ )
73
+ }
74
+
75
+ export const css = `
76
+ .aga-root {
77
+ box-sizing: border-box;
78
+ display: grid;
79
+ gap: var(--gap);
80
+ width: 100%;
81
+ max-width: 32rem;
82
+ padding: var(--pad-loose);
83
+ color: var(--fg);
84
+ background: var(--glass-bg, var(--surface));
85
+ border: 1px solid var(--glass-border, var(--border));
86
+ border-radius: var(--radius-lg);
87
+ box-shadow: var(--shadow-sm);
88
+ font-family: var(--font);
89
+ font-size: var(--text-base);
90
+ line-height: var(--leading);
91
+ }
92
+ .aga-root * { box-sizing: border-box; }
93
+ .aga-heading { display: flex; align-items: start; justify-content: space-between; gap: var(--gap); }
94
+ .aga-title {
95
+ margin: 0;
96
+ font-family: var(--font-display);
97
+ font-size: calc(var(--text-base) * 1.15);
98
+ font-weight: var(--weight-display);
99
+ letter-spacing: var(--tracking-tight);
100
+ }
101
+ .aga-summary, .aga-policy, .aga-reason, .aga-status { margin: 0; }
102
+ .aga-summary, .aga-policy, .aga-status { color: var(--fg-muted); }
103
+ .aga-summary, .aga-invoice, .aga-reason, .aga-status { font-size: calc(var(--text-base) * 0.9); }
104
+ .aga-invoice {
105
+ flex: none;
106
+ padding: calc(var(--pad-tight) * 0.5) var(--pad-tight);
107
+ color: var(--accent);
108
+ background: var(--accent-weak);
109
+ border: 1px solid var(--border);
110
+ border-radius: var(--radius-full);
111
+ font-weight: var(--weight-medium);
112
+ }
113
+ .aga-policy { padding-top: var(--gap); border-top: 1px solid var(--border); }
114
+ .aga-check {
115
+ display: flex;
116
+ align-items: center;
117
+ gap: var(--pad-tight);
118
+ min-height: calc(var(--text-base) * 2.75);
119
+ cursor: pointer;
120
+ }
121
+ .aga-check input {
122
+ width: calc(var(--text-base) * 1.25);
123
+ height: calc(var(--text-base) * 1.25);
124
+ margin: 0;
125
+ accent-color: var(--accent);
126
+ }
127
+ .aga-check:focus-within { border-radius: var(--radius-sm); box-shadow: 0 0 0 3px var(--ring); }
128
+ .aga-reason {
129
+ padding: var(--pad-tight) var(--pad);
130
+ color: var(--fg);
131
+ background: var(--surface-2);
132
+ border-left: 3px solid var(--accent);
133
+ border-radius: var(--radius-sm);
134
+ }
135
+ .aga-button {
136
+ min-height: calc(var(--text-base) * 2.75);
137
+ padding: var(--pad-tight) var(--pad);
138
+ color: var(--accent-fg);
139
+ background: var(--accent);
140
+ border: 1px solid var(--accent);
141
+ border-radius: var(--radius);
142
+ font: inherit;
143
+ font-weight: var(--weight-medium);
144
+ cursor: pointer;
145
+ transition: opacity var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease);
146
+ }
147
+ .aga-button:disabled {
148
+ color: var(--fg-muted);
149
+ background: var(--surface-2);
150
+ border-color: var(--border);
151
+ cursor: not-allowed;
152
+ opacity: 0.72;
153
+ }
154
+ .aga-button:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--ring); }
155
+ `
156
+
157
+ export default GatedAction