@alexkroman1/aai-ui 6.7.2 → 6.8.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,67 @@
1
+ /**
2
+ * Turning a form's `File`s into stored upload ids, pauses and all.
3
+ *
4
+ * Split out of `use-workflow-form.ts` for the 500-line cap, and the seam is a
5
+ * real one: that module is the two HOOKS and the state between them, where this
6
+ * is the walk over a submitted input — which is the only part of it that knows
7
+ * what a `File` is, holds a loop, and survives being re-entered.
8
+ *
9
+ * `_`-internal. `useWorkflowSubmit` is the only caller; `useWorkflowStream` sends
10
+ * one file rather than walking an input and shares only the gate underneath both
11
+ * (`_upload-session.ts`).
12
+ */
13
+ import type { UploadParallel } from "@alexkroman1/aai/workflow-api";
14
+ import { type UploadGate } from "./_upload-session.ts";
15
+ import type { UploadStatus } from "./use-workflow-form.ts";
16
+ import type { WorkflowApi } from "./workflow-client.ts";
17
+ /**
18
+ * What one submission's uploads know about themselves, across pauses.
19
+ *
20
+ * Held by the hook rather than by the walk below, because the walk RE-RUNS: a
21
+ * pause unwinds nothing, so resuming re-enters `uploadFiles` with the same input
22
+ * and the same session, and every file whose bytes are already in is skipped by
23
+ * `stored` rather than sent again.
24
+ *
25
+ * Which is also why the ids live here. A resumable upload is one whose id
26
+ * outlives the attempt that began it — that is the whole mechanism — so an id
27
+ * minted per attempt would make each round a fresh upload of the whole file.
28
+ */
29
+ export type UploadSession = {
30
+ /** The id each file is being stored under, minted once. */
31
+ ids: Map<File, string>;
32
+ /** Files whose every byte has landed, by the id they landed under. */
33
+ stored: Map<File, string>;
34
+ /** Files that have had an attempt, so the next one must claim the id as its own. */
35
+ tried: Set<File>;
36
+ /** The person's pause. */
37
+ gate: UploadGate;
38
+ };
39
+ /** A fresh session for one submission. */
40
+ export declare function createUploadSession(): UploadSession;
41
+ /**
42
+ * Replace every `File` in a submitted form with the id of a stored upload,
43
+ * reporting how far each one has got.
44
+ *
45
+ * Sequential rather than `Promise.all`: these are large bodies, and a form with
46
+ * two 200 MB recordings should send them one after another rather than compete
47
+ * for the same connection. That is also what makes a single bar honest — one
48
+ * file is in flight at a time, and `index`/`count` say which.
49
+ *
50
+ * Anything that is not a `File` (or an array of them) passes through untouched,
51
+ * so this is invisible to every form that has none — including one whose values
52
+ * are not an object at all, which `submit` accepts.
53
+ *
54
+ * ## `uploadStream`, not `upload`, and the id is the reason
55
+ *
56
+ * The difference between the two calls is only who mints the id — and that is
57
+ * exactly what decides whether an interrupted upload can be picked up again. An
58
+ * `upload` mints its own at the END and hands it back, so a caller whose upload
59
+ * died has nothing to name what was stored and no choice but to send the file
60
+ * again. A `uploadStream` is told the id up front, so the windows already in the
61
+ * store are addressable, which is what both a pause and a server restart need.
62
+ *
63
+ * Nothing else about the submission changes: the run is still started after the
64
+ * last byte lands, so the incomplete record a streamed upload leaves along the
65
+ * way is one nobody reads.
66
+ */
67
+ export declare function uploadFiles(api: WorkflowApi, input: unknown, report: (status: UploadStatus) => void, parallel: UploadParallel | undefined, session: UploadSession): Promise<unknown>;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Driving ONE resumable upload from a page: its id, and the gate a person can
3
+ * close.
4
+ *
5
+ * The SDK already survives the server going away — a round that fails for a
6
+ * reason that looks like an outage re-enters itself with `resume: true` and sends
7
+ * only the windows that are missing (`aai/sdk/_upload-resume.ts`). This is the
8
+ * same mechanism turned around and handed to the PERSON: pausing is an outage
9
+ * they caused, resuming is the round after it, and the store cannot tell the two
10
+ * apart because there is nothing to tell apart.
11
+ *
12
+ * ## Why a gate rather than a boolean
13
+ *
14
+ * Pausing has to do two things that a `paused` flag cannot: stop the bytes that
15
+ * are already on the wire, and hold the loop that would send the next ones. So it
16
+ * is an `AbortController` and a promise — abort what is in flight, and park the
17
+ * uploader on `settle()` until somebody opens the gate again.
18
+ *
19
+ * The controller is REPLACED on resume rather than reused, because an aborted
20
+ * signal stays aborted; a caller therefore has to read {@link UploadGate.signal}
21
+ * fresh for each attempt rather than capturing it once.
22
+ *
23
+ * ## The loop this is built for
24
+ *
25
+ * ```ts no-check
26
+ * while (!gate.cancelled) {
27
+ * await gate.settle();
28
+ * if (gate.cancelled) break;
29
+ * try {
30
+ * await api.uploadStream(id, file, { signal: gate.signal, resume: tried });
31
+ * break;
32
+ * } catch (err) {
33
+ * // An abort that is not a cancel is a PAUSE: go back and wait.
34
+ * if (!isAbort(err) || gate.cancelled) throw err;
35
+ * }
36
+ * }
37
+ * ```
38
+ *
39
+ * **The catch keys off the abort, not off `gate.paused`,** and that is the one
40
+ * subtle part. Pausing and immediately resuming — a double-click, or a person
41
+ * changing their mind inside the round trip — resolves the gate before the
42
+ * rejection this abort caused has even landed, so a `paused` check would read
43
+ * `false` and rethrow an `AbortError` as though the upload had failed. Every
44
+ * abort reaching that catch was caused by this gate, so every one of them is a
45
+ * pause unless the gate was cancelled outright.
46
+ */
47
+ /** A person's pause, as the uploader sees it. */
48
+ export type UploadGate = {
49
+ /** Whether the gate is currently closed. For rendering, not for control flow. */
50
+ readonly paused: boolean;
51
+ /** Whether the upload was abandoned. A cancelled gate never opens again. */
52
+ readonly cancelled: boolean;
53
+ /**
54
+ * The signal for the NEXT attempt. Re-read it per attempt: resuming installs a
55
+ * fresh controller, and the previous one stays aborted forever.
56
+ */
57
+ readonly signal: AbortSignal;
58
+ /** Stop the bytes in flight and hold the uploader. A no-op when already closed. */
59
+ pause: () => void;
60
+ /** Open the gate and install a fresh signal. A no-op when not paused. */
61
+ resume: () => void;
62
+ /**
63
+ * Abandon the upload for good.
64
+ *
65
+ * Distinct from a pause in exactly one way that matters to the loop above: it
66
+ * releases the gate rather than holding it, so an uploader parked on `settle()`
67
+ * wakes up and leaves instead of waiting for a resume that is not coming. What
68
+ * a caller does with a cancelled upload's stored windows is its own business —
69
+ * they stay in the store, addressable by an id only the caller has.
70
+ */
71
+ cancel: () => void;
72
+ /** Resolves as soon as the gate is open — immediately, when it already is. */
73
+ settle: () => Promise<void>;
74
+ };
75
+ /** Whether this rejection is an abort, in either of the two shapes runtimes throw. */
76
+ export declare function isAbortError(err: unknown): boolean;
77
+ /** A fresh upload id: a capability, so it is random rather than derived. */
78
+ export declare function randomUploadId(): string;
79
+ /**
80
+ * Send one file, waiting out however many pauses the person takes.
81
+ *
82
+ * The loop from the module doc, written once: both hooks need exactly this and a
83
+ * second copy of it is a second place for the abort/pause distinction to be got
84
+ * wrong. `send` is handed whether this attempt must CLAIM the id as its own —
85
+ * false the first time, since a fresh id has nothing to resume and saying
86
+ * otherwise waives the refusal that makes a caller-chosen id safe.
87
+ *
88
+ * Throws whatever `send` threw, except an abort the gate caused. A cancelled gate
89
+ * throws too: the caller distinguishes it by reading `gate.cancelled`, which is
90
+ * how an abandoned submission unwinds without being reported as a failure.
91
+ */
92
+ export declare function sendThroughGate(gate: UploadGate, send: (resume: boolean) => Promise<void>): Promise<void>;
93
+ /**
94
+ * A gate, open.
95
+ *
96
+ * One per upload rather than one per hook: the id and the windows already stored
97
+ * belong to a file, so a gate that outlived its file would resume something else.
98
+ */
99
+ export declare function createUploadGate(): UploadGate;
@@ -22,6 +22,14 @@ import type { UploadStatus } from "../use-workflow-form.ts";
22
22
  * - **The file is NAMED, and counted when there is more than one.** Files are
23
23
  * sent one after another, so a single bar otherwise appears to restart from
24
24
  * zero partway through with nothing to say why.
25
+ * - **A paused upload SAYS SO, rather than being a bar that stopped.** Those look
26
+ * identical, which is the whole reason `UploadStatus.paused` exists, and the
27
+ * fill stops animating so the difference is visible without reading.
28
+ *
29
+ * The pause control appears only when a handler for it is passed. That is not
30
+ * politeness about props: a button whose press does nothing is worse than no
31
+ * button, and a page holding an `upload` it did not produce (a saved status, a
32
+ * parent's state) has nothing to pause.
25
33
  *
26
34
  * @example
27
35
  * ```tsx
@@ -41,12 +49,17 @@ import type { UploadStatus } from "../use-workflow-form.ts";
41
49
  *
42
50
  * @param upload - What `useWorkflowSubmit` reports. `undefined` renders nothing,
43
51
  * so a page may pass its state straight through.
52
+ * @param onPause - The hook's `pauseUpload`. Pass it together with `onResume` to
53
+ * get the control; pass neither for a bar that only reports.
54
+ * @param onResume - The hook's `resumeUpload`.
44
55
  * @param className - Replaces the default classes rather than extending them,
45
56
  * so a custom chrome is not fighting a default it did not ask for.
46
57
  *
47
58
  * @public
48
59
  */
49
- export declare function UploadProgressBar({ upload, className, }: {
60
+ export declare function UploadProgressBar({ upload, onPause, onResume, className, }: {
50
61
  upload?: UploadStatus | undefined;
62
+ onPause?: (() => void) | undefined;
63
+ onResume?: (() => void) | undefined;
51
64
  className?: string | undefined;
52
65
  }): ReactNode;
@@ -0,0 +1,2 @@
1
+ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
2
+ @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--tracking-wide:.025em;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--font-aai:"Monument Grotesk", "ABC Monument Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif;--font-aai-serif:"Source Serif 4", "Source Serif Pro", Charter, "Iowan Old Style", Georgia, serif;--font-aai-mono:"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;--radius-aai:4px}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}html,body{margin:0;padding:0}}@layer components;@layer utilities{.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.order-last{order:9999}.container{width:100%}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.m-0{margin:0}.mx-auto{margin-inline:auto}.my-0\.5{margin-block:calc(var(--spacing) * .5)}.my-1\.5{margin-block:calc(var(--spacing) * 1.5)}.my-2\.5{margin-block:calc(var(--spacing) * 2.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.box-border{box-sizing:border-box}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-4{height:calc(var(--spacing) * 4)}.h-6{height:calc(var(--spacing) * 6)}.h-9{height:calc(var(--spacing) * 9)}.h-11{height:calc(var(--spacing) * 11)}.h-\[7px\]{height:7px}.h-full{height:100%}.h-screen{height:100vh}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-\[40vh\]{max-height:40vh}.min-h-0{min-height:0}.min-h-5{min-height:calc(var(--spacing) * 5)}.min-h-screen{min-height:100vh}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-4{width:calc(var(--spacing) * 4)}.w-\[7px\]{width:7px}.w-fit{width:fit-content}.w-full{width:100%}.max-w-75{max-width:calc(var(--spacing) * 75)}.max-w-105{max-width:calc(var(--spacing) * 105)}.max-w-190{max-width:calc(var(--spacing) * 190)}.max-w-\[82\%\]{max-width:82%}.max-w-\[min\(78\%\,64ch\)\]{max-width:min(78%,64ch)}.min-w-0{min-width:0}.flex-1{flex:1}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.basis-full{flex-basis:100%}.border-collapse{border-collapse:collapse}.rotate-90{rotate:90deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-pulse{animation:var(--animate-pulse)}.cursor-pointer{cursor:pointer}.resize{resize:both}.\[scrollbar-width\:none\]{scrollbar-width:none}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.appearance-none{appearance:none}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded-aai{border-radius:var(--radius-aai)}.rounded-full{border-radius:2147483647px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-none{--tw-border-style:none;border-style:none}.border-\(--aai-btn-bd\){border-color:var(--aai-btn-bd)}.bg-\(--aai-btn-bg\){background-color:var(--aai-btn-bg)}.bg-transparent{background-color:#0000}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-7{padding:calc(var(--spacing) * 7)}.p-8{padding:calc(var(--spacing) * 8)}.px-1{padding-inline:var(--spacing)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-7{padding-inline:calc(var(--spacing) * 7)}.px-10{padding-inline:calc(var(--spacing) * 10)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.pl-1\.5{padding-left:calc(var(--spacing) * 1.5)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-5{padding-left:calc(var(--spacing) * 5)}.text-center{text-align:center}.text-left{text-align:left}.font-aai{font-family:var(--font-aai)}.font-aai-mono{font-family:var(--font-aai-mono)}.font-aai-serif{font-family:var(--font-aai-serif)}.font-mono{font-family:var(--font-mono)}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.625rem\]{font-size:.625rem}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[15px\]{font-size:15px}.text-\[16px\]{font-size:16px}.text-\[17px\]{font-size:17px}.text-\[22px\]{font-size:22px}.text-\[32px\]{font-size:32px}.leading-4{--tw-leading:calc(var(--spacing) * 4);line-height:calc(var(--spacing) * 4)}.leading-\[1\.2\]{--tw-leading:1.2;line-height:1.2}.leading-\[1\.15\]{--tw-leading:1.15;line-height:1.15}.leading-\[22px\]{--tw-leading:22px;line-height:22px}.leading-\[23px\]{--tw-leading:23px;line-height:23px}.leading-\[130\%\]{--tw-leading:130%;line-height:130%}.leading-none{--tw-leading:1;line-height:1}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[-0\.2px\]{--tw-tracking:-.2px;letter-spacing:-.2px}.tracking-\[1\.2px\]{--tw-tracking:1.2px;letter-spacing:1.2px}.tracking-\[1\.4px\]{--tw-tracking:1.4px;letter-spacing:1.4px}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.text-balance{text-wrap:balance}.wrap-break-word{overflow-wrap:break-word}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-\(--aai-btn-fg\){color:var(--aai-btn-fg)}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.underline{text-decoration-line:underline}.underline-offset-2{text-underline-offset:2px}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.file\:mr-3::file-selector-button{margin-right:calc(var(--spacing) * 3)}.file\:cursor-pointer::file-selector-button{cursor:pointer}.file\:rounded-aai::file-selector-button{border-radius:var(--radius-aai)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:px-3::file-selector-button{padding-inline:calc(var(--spacing) * 3)}.file\:py-1\.5::file-selector-button{padding-block:calc(var(--spacing) * 1.5)}.file\:font-aai::file-selector-button{font-family:var(--font-aai)}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:\[color\:var\(--aai-file-button-fg\)\]::file-selector-button{color:var(--aai-file-button-fg)}.file\:\[background\:var\(--aai-file-button-bg\)\]::file-selector-button{background:var(--aai-file-button-bg)}.first\:mt-0:first-child{margin-top:0}.last\:mb-0:last-child{margin-bottom:0}.focus-visible\:\[outline\:2px_solid\]:focus-visible{outline:2px solid}.focus-visible\:\[outline-offset\:2px\]:focus-visible{outline-offset:2px}@media (hover:hover){.enabled\:hover\:border-\(--aai-btn-bd-hover\):enabled:hover{border-color:var(--aai-btn-bd-hover)}.enabled\:hover\:bg-\(--aai-btn-bg-hover\):enabled:hover{background-color:var(--aai-btn-bg-hover)}.enabled\:hover\:text-\(--aai-btn-fg-hover\):enabled:hover{color:var(--aai-btn-fg-hover)}}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media (width>=40rem){.sm\:ml-auto{margin-left:auto}.sm\:max-w-\[60\%\]{max-width:60%}.sm\:basis-auto{flex-basis:auto}.sm\:px-16{padding-inline:calc(var(--spacing) * 16)}.sm\:py-14{padding-block:calc(var(--spacing) * 14)}}@media (width>=48rem){.md\:order-none{order:0}.md\:h-screen{height:100vh}.md\:max-h-none{max-height:none}.md\:w-\(--aai-sidebar-w\){width:var(--aai-sidebar-w)}.md\:flex-row{flex-direction:row}.md\:border-t-0{border-top-style:var(--tw-border-style);border-top-width:0}.md\:border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.md\:border-b-0{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.md\:border-l{border-left-style:var(--tw-border-style);border-left-width:1px}}}@keyframes aai-pulse{0%,to{opacity:1;transform:scale(1)}50%{opacity:.45;transform:scale(.82)}}@keyframes aai-bounce{0%,80%,to{opacity:.3;transform:scale(.8)}40%{opacity:1;transform:scale(1)}}@keyframes aai-shimmer{0%{background-position:-200% 0}to{background-position:200% 0}}.tool-shimmer{-webkit-text-fill-color:transparent;background:linear-gradient(90deg,currentColor 25%,#0000 50%,currentColor 75%) 0 0/200% 100%;-webkit-background-clip:text;background-clip:text;animation:2s infinite aai-shimmer}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes pulse{50%{opacity:.5}}
@@ -6,9 +6,9 @@
6
6
  <title>aai</title>
7
7
  <link rel="icon" href="./favicon.ico" />
8
8
  <style>html, body { background: #FBF8F2; margin: 0; }</style>
9
- <script type="module" crossorigin src="./assets/index-CepvxbNU.js"></script>
9
+ <script type="module" crossorigin src="./assets/index-DXODx_9r.js"></script>
10
10
  <link rel="modulepreload" crossorigin href="./assets/client-audio-constants-Ck0IJO4c.js">
11
- <link rel="stylesheet" crossorigin href="./assets/index-BZlePk3y.css">
11
+ <link rel="stylesheet" crossorigin href="./assets/index-DTLrhtTF.css">
12
12
  </head>
13
13
  <body>
14
14
  <main id="app"></main>
package/dist/index.js CHANGED
@@ -590,6 +590,14 @@ function formatBytes(bytes) {
590
590
  * - **The file is NAMED, and counted when there is more than one.** Files are
591
591
  * sent one after another, so a single bar otherwise appears to restart from
592
592
  * zero partway through with nothing to say why.
593
+ * - **A paused upload SAYS SO, rather than being a bar that stopped.** Those look
594
+ * identical, which is the whole reason `UploadStatus.paused` exists, and the
595
+ * fill stops animating so the difference is visible without reading.
596
+ *
597
+ * The pause control appears only when a handler for it is passed. That is not
598
+ * politeness about props: a button whose press does nothing is worse than no
599
+ * button, and a page holding an `upload` it did not produce (a saved status, a
600
+ * parent's state) has nothing to pause.
593
601
  *
594
602
  * @example
595
603
  * ```tsx
@@ -609,16 +617,20 @@ function formatBytes(bytes) {
609
617
  *
610
618
  * @param upload - What `useWorkflowSubmit` reports. `undefined` renders nothing,
611
619
  * so a page may pass its state straight through.
620
+ * @param onPause - The hook's `pauseUpload`. Pass it together with `onResume` to
621
+ * get the control; pass neither for a bar that only reports.
622
+ * @param onResume - The hook's `resumeUpload`.
612
623
  * @param className - Replaces the default classes rather than extending them,
613
624
  * so a custom chrome is not fighting a default it did not ask for.
614
625
  *
615
626
  * @public
616
627
  */
617
- function UploadProgressBar({ upload, className }) {
628
+ function UploadProgressBar({ upload, onPause, onResume, className }) {
618
629
  const theme = useTheme();
619
630
  const labelId = useId();
620
631
  if (!upload) return null;
621
- const { name, index, count, loaded, total, fraction } = upload;
632
+ const { name, index, count, loaded, total, fraction, paused } = upload;
633
+ const control = onPause && onResume ? paused ? onResume : onPause : void 0;
622
634
  const percent = fraction === void 0 ? void 0 : Math.round(fraction * 100);
623
635
  const faint = inkTint(theme.text, theme.surface, 65);
624
636
  return /* @__PURE__ */ jsxs("div", {
@@ -629,11 +641,20 @@ function UploadProgressBar({ upload, className }) {
629
641
  id: labelId,
630
642
  className: "truncate",
631
643
  style: { color: faint },
632
- children: count > 1 ? `Uploading ${name} (${index} of ${count})` : `Uploading ${name}`
633
- }), /* @__PURE__ */ jsx("span", {
634
- className: "shrink-0 tabular-nums",
635
- style: { color: faint },
636
- children: total === void 0 ? formatBytes(loaded) : `${formatBytes(loaded)} of ${formatBytes(total)}`
644
+ children: `${paused ? "Paused" : "Uploading"} ${name}${count > 1 ? ` (${index} of ${count})` : ""}`
645
+ }), /* @__PURE__ */ jsxs("div", {
646
+ className: "flex shrink-0 items-baseline gap-3",
647
+ children: [/* @__PURE__ */ jsx("span", {
648
+ className: "tabular-nums",
649
+ style: { color: faint },
650
+ children: total === void 0 ? formatBytes(loaded) : `${formatBytes(loaded)} of ${formatBytes(total)}`
651
+ }), control && /* @__PURE__ */ jsx(Button, {
652
+ type: "button",
653
+ variant: "ghost",
654
+ className: "h-6 px-2 text-[0.625rem]",
655
+ onClick: control,
656
+ children: paused ? "Resume" : "Pause"
657
+ })]
637
658
  })]
638
659
  }), /* @__PURE__ */ jsx("div", {
639
660
  role: "progressbar",
@@ -644,7 +665,7 @@ function UploadProgressBar({ upload, className }) {
644
665
  className: "h-1.5 w-full overflow-hidden rounded-full",
645
666
  style: { backgroundColor: inkTint(theme.text, theme.surface, TRACK_TINT_PCT) },
646
667
  children: /* @__PURE__ */ jsx("div", {
647
- className: clsx("h-full rounded-full transition-[width] duration-200 ease-out", percent === void 0 && "animate-pulse"),
668
+ className: clsx("h-full rounded-full transition-[width] duration-200 ease-out", percent === void 0 && !paused && "animate-pulse"),
648
669
  style: {
649
670
  backgroundColor: theme.primary,
650
671
  width: percent === void 0 ? "100%" : `${percent}%`
@@ -654,6 +675,243 @@ function UploadProgressBar({ upload, className }) {
654
675
  });
655
676
  }
656
677
  //#endregion
678
+ //#region _upload-session.ts
679
+ /** Whether this rejection is an abort, in either of the two shapes runtimes throw. */
680
+ function isAbortError(err) {
681
+ return err instanceof Error && err.name === "AbortError";
682
+ }
683
+ /** A fresh upload id: a capability, so it is random rather than derived. */
684
+ function randomUploadId() {
685
+ return crypto.randomUUID().replaceAll("-", "");
686
+ }
687
+ /**
688
+ * Send one file, waiting out however many pauses the person takes.
689
+ *
690
+ * The loop from the module doc, written once: both hooks need exactly this and a
691
+ * second copy of it is a second place for the abort/pause distinction to be got
692
+ * wrong. `send` is handed whether this attempt must CLAIM the id as its own —
693
+ * false the first time, since a fresh id has nothing to resume and saying
694
+ * otherwise waives the refusal that makes a caller-chosen id safe.
695
+ *
696
+ * Throws whatever `send` threw, except an abort the gate caused. A cancelled gate
697
+ * throws too: the caller distinguishes it by reading `gate.cancelled`, which is
698
+ * how an abandoned submission unwinds without being reported as a failure.
699
+ */
700
+ async function sendThroughGate(gate, send) {
701
+ let tried = false;
702
+ for (;;) {
703
+ await gate.settle();
704
+ if (gate.cancelled) throw new Error("Upload cancelled.");
705
+ const resume = tried;
706
+ tried = true;
707
+ try {
708
+ await send(resume);
709
+ return;
710
+ } catch (err) {
711
+ if (gate.cancelled || !isAbortError(err)) throw err;
712
+ }
713
+ }
714
+ }
715
+ /**
716
+ * A gate, open.
717
+ *
718
+ * One per upload rather than one per hook: the id and the windows already stored
719
+ * belong to a file, so a gate that outlived its file would resume something else.
720
+ */
721
+ function createUploadGate() {
722
+ let controller = new AbortController();
723
+ let paused = false;
724
+ let cancelled = false;
725
+ let open;
726
+ let closed;
727
+ return {
728
+ get paused() {
729
+ return paused;
730
+ },
731
+ get cancelled() {
732
+ return cancelled;
733
+ },
734
+ get signal() {
735
+ return controller.signal;
736
+ },
737
+ pause() {
738
+ if (paused || cancelled) return;
739
+ paused = true;
740
+ const gate = Promise.withResolvers();
741
+ closed = gate.promise;
742
+ open = gate.resolve;
743
+ controller.abort();
744
+ },
745
+ resume() {
746
+ if (!paused || cancelled) return;
747
+ paused = false;
748
+ controller = new AbortController();
749
+ open?.();
750
+ open = void 0;
751
+ closed = void 0;
752
+ },
753
+ cancel() {
754
+ if (cancelled) return;
755
+ cancelled = true;
756
+ paused = false;
757
+ controller.abort();
758
+ open?.();
759
+ open = void 0;
760
+ closed = void 0;
761
+ },
762
+ async settle() {
763
+ if (closed) await closed;
764
+ }
765
+ };
766
+ }
767
+ //#endregion
768
+ //#region _workflow-files.ts
769
+ /**
770
+ * Which of a submitted form's values are FILES.
771
+ *
772
+ * Its own module because both submit hooks need the identical answer and then do
773
+ * two different things with it — `useWorkflowSubmit` stores each file and passes
774
+ * its id, `useWorkflowStream` cuts it into parts and passes the group they share.
775
+ * A second copy of this predicate would be a form field that one hook treats as a
776
+ * file and the other does not, which is invisible until the run reads the wrong
777
+ * kind of string.
778
+ */
779
+ /**
780
+ * The files a submitted field carries, if that is what it carries.
781
+ *
782
+ * An array counts only when it is files ALL the way through — a mixed array is
783
+ * some other field's value that happens to contain one, and turning half of it
784
+ * into ids would corrupt it silently.
785
+ */
786
+ function filesOf(value) {
787
+ if (value instanceof File) return [value];
788
+ if (!Array.isArray(value)) return [];
789
+ const files = value.filter((one) => one instanceof File);
790
+ return files.length > 0 && files.length === value.length ? files : [];
791
+ }
792
+ /**
793
+ * The input properties still carrying a `File` — i.e. the ones that CANNOT survive
794
+ * being sent.
795
+ *
796
+ * A run input is JSON, and `JSON.stringify(new File(…))` is `{}` — no `toJSON`, no
797
+ * own enumerable properties. So a File left in a payload does not fail to send: it
798
+ * arrives as an empty object, and the workflow rejects it against whatever its own
799
+ * schema says the property should be. Measured in production as
800
+ * `Invalid input for workflow "transcribe": recording: Invalid input` — a message
801
+ * about a type, on a form where the user had picked a perfectly good file.
802
+ *
803
+ * Exported beside {@link filesOf} because it is the same question asked at the
804
+ * other end: that one decides which fields to UPLOAD, this one checks that none
805
+ * were missed. Both hooks are the callers.
806
+ */
807
+ function fileFields(input) {
808
+ if (!isRecord(input)) return [];
809
+ return Object.entries(input).filter(([, value]) => filesOf(value).length > 0).map(([key]) => key);
810
+ }
811
+ //#endregion
812
+ //#region _upload-files.ts
813
+ /**
814
+ * Turning a form's `File`s into stored upload ids, pauses and all.
815
+ *
816
+ * Split out of `use-workflow-form.ts` for the 500-line cap, and the seam is a
817
+ * real one: that module is the two HOOKS and the state between them, where this
818
+ * is the walk over a submitted input — which is the only part of it that knows
819
+ * what a `File` is, holds a loop, and survives being re-entered.
820
+ *
821
+ * `_`-internal. `useWorkflowSubmit` is the only caller; `useWorkflowStream` sends
822
+ * one file rather than walking an input and shares only the gate underneath both
823
+ * (`_upload-session.ts`).
824
+ */
825
+ /** A fresh session for one submission. */
826
+ function createUploadSession() {
827
+ return {
828
+ ids: /* @__PURE__ */ new Map(),
829
+ stored: /* @__PURE__ */ new Map(),
830
+ tried: /* @__PURE__ */ new Set(),
831
+ gate: createUploadGate()
832
+ };
833
+ }
834
+ /**
835
+ * Replace every `File` in a submitted form with the id of a stored upload,
836
+ * reporting how far each one has got.
837
+ *
838
+ * Sequential rather than `Promise.all`: these are large bodies, and a form with
839
+ * two 200 MB recordings should send them one after another rather than compete
840
+ * for the same connection. That is also what makes a single bar honest — one
841
+ * file is in flight at a time, and `index`/`count` say which.
842
+ *
843
+ * Anything that is not a `File` (or an array of them) passes through untouched,
844
+ * so this is invisible to every form that has none — including one whose values
845
+ * are not an object at all, which `submit` accepts.
846
+ *
847
+ * ## `uploadStream`, not `upload`, and the id is the reason
848
+ *
849
+ * The difference between the two calls is only who mints the id — and that is
850
+ * exactly what decides whether an interrupted upload can be picked up again. An
851
+ * `upload` mints its own at the END and hands it back, so a caller whose upload
852
+ * died has nothing to name what was stored and no choice but to send the file
853
+ * again. A `uploadStream` is told the id up front, so the windows already in the
854
+ * store are addressable, which is what both a pause and a server restart need.
855
+ *
856
+ * Nothing else about the submission changes: the run is still started after the
857
+ * last byte lands, so the incomplete record a streamed upload leaves along the
858
+ * way is one nobody reads.
859
+ */
860
+ async function uploadFiles(api, input, report, parallel, session) {
861
+ if (!isRecord(input)) return input;
862
+ const entries = Object.entries(input);
863
+ const count = entries.reduce((total, [, value]) => total + filesOf(value).length, 0);
864
+ let index = 0;
865
+ const store = async (file) => {
866
+ index += 1;
867
+ const done = session.stored.get(file);
868
+ if (done !== void 0) return done;
869
+ let id = session.ids.get(file);
870
+ if (id === void 0) {
871
+ id = randomUploadId();
872
+ session.ids.set(file, id);
873
+ }
874
+ const position = {
875
+ name: file.name,
876
+ index,
877
+ count
878
+ };
879
+ await sendThroughGate(session.gate, async (resume) => {
880
+ await api.uploadStream(id, file, {
881
+ name: file.name,
882
+ signal: session.gate.signal,
883
+ onProgress: (progress) => report({
884
+ ...position,
885
+ ...progress,
886
+ paused: session.gate.paused
887
+ }),
888
+ ...omitUndefined({
889
+ parallel,
890
+ resume: resume ? true : void 0
891
+ })
892
+ });
893
+ });
894
+ session.stored.set(file, id);
895
+ return id;
896
+ };
897
+ const out = {};
898
+ for (const [name, value] of entries) {
899
+ if (value instanceof File) {
900
+ out[name] = await store(value);
901
+ continue;
902
+ }
903
+ const chosen = filesOf(value);
904
+ if (chosen.length === 0) {
905
+ out[name] = value;
906
+ continue;
907
+ }
908
+ const ids = [];
909
+ for (const file of chosen) ids.push(await store(file));
910
+ out[name] = ids;
911
+ }
912
+ return out;
913
+ }
914
+ //#endregion
657
915
  //#region workflow-client.ts
658
916
  /**
659
917
  * Create a workflow API client aimed at the agent serving this page.
@@ -720,50 +978,6 @@ function useWorkflowApiRef(api) {
720
978
  }, []);
721
979
  }
722
980
  //#endregion
723
- //#region _workflow-files.ts
724
- /**
725
- * Which of a submitted form's values are FILES.
726
- *
727
- * Its own module because both submit hooks need the identical answer and then do
728
- * two different things with it — `useWorkflowSubmit` stores each file and passes
729
- * its id, `useWorkflowStream` cuts it into parts and passes the group they share.
730
- * A second copy of this predicate would be a form field that one hook treats as a
731
- * file and the other does not, which is invisible until the run reads the wrong
732
- * kind of string.
733
- */
734
- /**
735
- * The files a submitted field carries, if that is what it carries.
736
- *
737
- * An array counts only when it is files ALL the way through — a mixed array is
738
- * some other field's value that happens to contain one, and turning half of it
739
- * into ids would corrupt it silently.
740
- */
741
- function filesOf(value) {
742
- if (value instanceof File) return [value];
743
- if (!Array.isArray(value)) return [];
744
- const files = value.filter((one) => one instanceof File);
745
- return files.length > 0 && files.length === value.length ? files : [];
746
- }
747
- /**
748
- * The input properties still carrying a `File` — i.e. the ones that CANNOT survive
749
- * being sent.
750
- *
751
- * A run input is JSON, and `JSON.stringify(new File(…))` is `{}` — no `toJSON`, no
752
- * own enumerable properties. So a File left in a payload does not fail to send: it
753
- * arrives as an empty object, and the workflow rejects it against whatever its own
754
- * schema says the property should be. Measured in production as
755
- * `Invalid input for workflow "transcribe": recording: Invalid input` — a message
756
- * about a type, on a form where the user had picked a perfectly good file.
757
- *
758
- * Exported beside {@link filesOf} because it is the same question asked at the
759
- * other end: that one decides which fields to UPLOAD, this one checks that none
760
- * were missed. Both hooks are the callers.
761
- */
762
- function fileFields(input) {
763
- if (!isRecord(input)) return [];
764
- return Object.entries(input).filter(([, value]) => filesOf(value).length > 0).map(([key]) => key);
765
- }
766
- //#endregion
767
981
  //#region _repeat-until.ts
768
982
  /**
769
983
  * A bounded read, re-armed from the SETTLED read — the loop both workflow
@@ -1170,56 +1384,6 @@ function useWorkflows(opts = {}) {
1170
1384
  return state;
1171
1385
  }
1172
1386
  /**
1173
- * Replace every `File` in a submitted form with the id of a stored upload,
1174
- * reporting how far each one has got.
1175
- *
1176
- * Sequential rather than `Promise.all`: these are large bodies, and a form with
1177
- * two 200 MB recordings should send them one after another rather than compete
1178
- * for the same connection. That is also what makes a single bar honest — one
1179
- * file is in flight at a time, and `index`/`count` say which.
1180
- *
1181
- * Anything that is not a `File` (or an array of them) passes through untouched,
1182
- * so this is invisible to every form that has none — including one whose values
1183
- * are not an object at all, which `submit` accepts.
1184
- */
1185
- async function uploadFiles(api, input, report, parallel) {
1186
- if (!isRecord(input)) return input;
1187
- const entries = Object.entries(input);
1188
- const count = entries.reduce((total, [, value]) => total + filesOf(value).length, 0);
1189
- let index = 0;
1190
- const store = async (file) => {
1191
- index += 1;
1192
- const position = {
1193
- name: file.name,
1194
- index,
1195
- count
1196
- };
1197
- return (await api.upload(file, {
1198
- onProgress: (progress) => report({
1199
- ...position,
1200
- ...progress
1201
- }),
1202
- ...omitUndefined({ parallel })
1203
- })).id;
1204
- };
1205
- const out = {};
1206
- for (const [name, value] of entries) {
1207
- if (value instanceof File) {
1208
- out[name] = await store(value);
1209
- continue;
1210
- }
1211
- const chosen = filesOf(value);
1212
- if (chosen.length === 0) {
1213
- out[name] = value;
1214
- continue;
1215
- }
1216
- const ids = [];
1217
- for (const file of chosen) ids.push(await store(file));
1218
- out[name] = ids;
1219
- }
1220
- return out;
1221
- }
1222
- /**
1223
1387
  * Start a workflow from a form, and follow the run it creates.
1224
1388
  *
1225
1389
  * @typeParam R - The workflow's output type, which is what makes
@@ -1250,6 +1414,7 @@ function useWorkflowSubmit(workflow, opts = {}) {
1250
1414
  const [starting, setStarting] = useState(false);
1251
1415
  const [startError, setStartError] = useState(void 0);
1252
1416
  const [upload, setUpload] = useState(void 0);
1417
+ const session = useRef(void 0);
1253
1418
  const getClient = useWorkflowApiRef(api);
1254
1419
  const tracked = useWorkflowRun(runId, omitUndefined({
1255
1420
  api,
@@ -1261,18 +1426,24 @@ function useWorkflowSubmit(workflow, opts = {}) {
1261
1426
  setStarting(true);
1262
1427
  setStartError(void 0);
1263
1428
  setRunId(void 0);
1429
+ session.current?.gate.cancel();
1430
+ const current = createUploadSession();
1431
+ session.current = current;
1264
1432
  try {
1265
1433
  const options = omitUndefined({ key });
1266
- const started = await uploadFiles(client, input, setUpload, parallel);
1434
+ const started = await uploadFiles(client, input, setUpload, parallel, current);
1267
1435
  setRunId(wait === void 0 ? await client.start(workflow, started, options) : (await client.startAndWait(workflow, started, {
1268
1436
  ...options,
1269
1437
  wait
1270
1438
  })).runId);
1271
1439
  } catch (err) {
1272
- setStartError(errorMessage(err));
1440
+ if (!current.gate.cancelled) setStartError(errorMessage(err));
1273
1441
  } finally {
1274
- setStarting(false);
1275
- setUpload(void 0);
1442
+ if (session.current === current) {
1443
+ session.current = void 0;
1444
+ setStarting(false);
1445
+ setUpload(void 0);
1446
+ }
1276
1447
  }
1277
1448
  }, [
1278
1449
  workflow,
@@ -1282,10 +1453,26 @@ function useWorkflowSubmit(workflow, opts = {}) {
1282
1453
  getClient
1283
1454
  ]),
1284
1455
  reset: useCallback(() => {
1456
+ session.current?.gate.cancel();
1457
+ session.current = void 0;
1285
1458
  setRunId(void 0);
1286
1459
  setStartError(void 0);
1287
1460
  setUpload(void 0);
1288
1461
  }, []),
1462
+ pauseUpload: useCallback(() => {
1463
+ session.current?.gate.pause();
1464
+ setUpload((current) => current ? {
1465
+ ...current,
1466
+ paused: true
1467
+ } : current);
1468
+ }, []),
1469
+ resumeUpload: useCallback(() => {
1470
+ session.current?.gate.resume();
1471
+ setUpload((current) => current ? {
1472
+ ...current,
1473
+ paused: false
1474
+ } : current);
1475
+ }, []),
1289
1476
  run: tracked.run,
1290
1477
  pending: starting || tracked.polling,
1291
1478
  upload,
@@ -1944,18 +2131,33 @@ function useWorkflowRuns(workflow, opts = {}) {
1944
2131
  * - **Reporting the bytes.** The same `UploadStatus` `useWorkflowSubmit` reports,
1945
2132
  * so `<UploadProgressBar>` renders either without knowing which hook it came from.
1946
2133
  *
1947
- * ## A failed upload is RESUMED once, and only then cancels the run
2134
+ * ## A failed upload is RESUMED, and only a spent budget cancels the run
1948
2135
  *
1949
2136
  * An upload that dies stays in the store, incomplete, and `complete` never becomes
1950
2137
  * true — so a run left behind polls until its own abandonment bound and then fails,
1951
2138
  * minutes after the page has already reported the error.
1952
2139
  *
1953
2140
  * That used to be the whole story, and it threw away a run and a file together for
1954
- * what is usually one dropped connection near the end. So a failure gets one more
1955
- * attempt with `resume: true`, which sends only the windows the store does not
1956
- * already have (`UploadInfo.ranges`) the run is still waiting on the same id, so
1957
- * a resume that succeeds is invisible to it. Cancelling is what happens when THAT
1958
- * fails too, and it is the honest end to a submission that did not happen.
2141
+ * what is usually one dropped connection near the end. **The resume lives in the
2142
+ * SDK now** (`aai/sdk/_upload-resume.ts`): a round that fails for a reason that
2143
+ * looks like an outage is re-entered with `resume: true`, sending only the windows
2144
+ * the store does not already have, on a budget sized to outlast a redeploy. The run
2145
+ * is still waiting on the same id, so a resume that succeeds is invisible to it.
2146
+ *
2147
+ * This hook used to hand-roll one such retry and no longer does — one resume with
2148
+ * no wait in front of it covers a dropped connection and cannot cover the case that
2149
+ * actually strands people, which is the agent restarting underneath the upload.
2150
+ * Cancelling the run is what happens when the whole budget is spent, and it is the
2151
+ * honest end to a submission that did not happen.
2152
+ *
2153
+ * ## Pausing is the same mechanism, asked for
2154
+ *
2155
+ * `pauseUpload()` aborts the bytes in flight and holds the uploader;
2156
+ * `resumeUpload()` sends what is missing. The store cannot tell that from an
2157
+ * outage, because there is nothing to tell apart — see `_upload-session.ts`. The
2158
+ * RUN is untouched either way: it goes on polling the id it was started with, and
2159
+ * `stream.ts`'s idle bound (five minutes of no new bytes) is what decides that a
2160
+ * pause has become an abandonment.
1959
2161
  */
1960
2162
  /**
1961
2163
  * Start a workflow run and stream a file into it while it works.
@@ -1988,6 +2190,7 @@ function useWorkflowStream(workflow, opts = {}) {
1988
2190
  const [starting, setStarting] = useState(false);
1989
2191
  const [startError, setStartError] = useState(void 0);
1990
2192
  const [upload, setUpload] = useState(void 0);
2193
+ const gateRef = useRef(void 0);
1991
2194
  const getClient = useWorkflowApiRef(api);
1992
2195
  const tracked = useWorkflowRun(runId, omitUndefined({
1993
2196
  api,
@@ -1999,42 +2202,41 @@ function useWorkflowStream(workflow, opts = {}) {
1999
2202
  setStarting(true);
2000
2203
  setStartError(void 0);
2001
2204
  setRunId(void 0);
2205
+ gateRef.current?.cancel();
2206
+ const gate = createUploadGate();
2207
+ gateRef.current = gate;
2002
2208
  let started;
2003
2209
  try {
2004
- const field = await uploadField(client, workflow);
2005
- const chosen = field === void 0 ? void 0 : fileAt(input, field);
2006
2210
  const id = randomUploadId();
2007
- const payload = chosen && field ? {
2008
- ...input,
2009
- [field]: id
2010
- } : input;
2011
- assertSendable(workflow, payload, field);
2012
- started = await client.start(workflow, payload, omitUndefined({ key }));
2211
+ const begun = await beginRun({
2212
+ client,
2213
+ workflow,
2214
+ input,
2215
+ id,
2216
+ ...omitUndefined({ key })
2217
+ });
2218
+ started = begun.runId;
2219
+ const chosen = begun.file;
2013
2220
  setRunId(started);
2014
2221
  if (!chosen) return;
2015
- const send = async (resume) => {
2016
- await client.uploadStream(id, chosen, {
2017
- name: chosen.name,
2018
- onProgress: (progress) => setUpload({
2019
- ...progress,
2020
- name: chosen.name,
2021
- index: 1,
2022
- count: 1
2023
- }),
2024
- ...omitUndefined({
2025
- parallel,
2026
- resume: resume ? true : void 0
2027
- })
2028
- });
2029
- };
2030
- await send(false).catch(async () => await send(true));
2222
+ await streamFile({
2223
+ client,
2224
+ gate,
2225
+ id,
2226
+ file: chosen,
2227
+ parallel,
2228
+ report: setUpload
2229
+ });
2031
2230
  await client.wake(started).catch(() => void 0);
2032
2231
  } catch (err) {
2033
- setStartError(errorMessage(err));
2232
+ if (!gate.cancelled) setStartError(errorMessage(err));
2034
2233
  if (started) await client.cancel(started).catch(() => void 0);
2035
2234
  } finally {
2036
- setStarting(false);
2037
- setUpload(void 0);
2235
+ if (gateRef.current === gate) {
2236
+ gateRef.current = void 0;
2237
+ setStarting(false);
2238
+ setUpload(void 0);
2239
+ }
2038
2240
  }
2039
2241
  }, [
2040
2242
  workflow,
@@ -2043,10 +2245,26 @@ function useWorkflowStream(workflow, opts = {}) {
2043
2245
  getClient
2044
2246
  ]),
2045
2247
  reset: useCallback(() => {
2248
+ gateRef.current?.cancel();
2249
+ gateRef.current = void 0;
2046
2250
  setRunId(void 0);
2047
2251
  setStartError(void 0);
2048
2252
  setUpload(void 0);
2049
2253
  }, []),
2254
+ pauseUpload: useCallback(() => {
2255
+ gateRef.current?.pause();
2256
+ setUpload((current) => current ? {
2257
+ ...current,
2258
+ paused: true
2259
+ } : current);
2260
+ }, []),
2261
+ resumeUpload: useCallback(() => {
2262
+ gateRef.current?.resume();
2263
+ setUpload((current) => current ? {
2264
+ ...current,
2265
+ paused: false
2266
+ } : current);
2267
+ }, []),
2050
2268
  run: tracked.run,
2051
2269
  pending: starting || tracked.polling,
2052
2270
  upload,
@@ -2054,6 +2272,56 @@ function useWorkflowStream(workflow, opts = {}) {
2054
2272
  };
2055
2273
  }
2056
2274
  /**
2275
+ * Read the declaration, substitute the id, and start the run.
2276
+ *
2277
+ * Everything that has to happen BEFORE a byte moves, which is the inversion this
2278
+ * hook exists for. One small `list()` per submit, deliberately: holding the
2279
+ * listing in state would make a submit before it landed a race, and the failure
2280
+ * mode of that race is a `File` reaching a run input.
2281
+ */
2282
+ async function beginRun(opts) {
2283
+ const { client, workflow, input, id } = opts;
2284
+ const field = await uploadField(client, workflow);
2285
+ const chosen = field === void 0 ? void 0 : fileAt(input, field);
2286
+ const payload = chosen && field ? {
2287
+ ...input,
2288
+ [field]: id
2289
+ } : input;
2290
+ assertSendable(workflow, payload, field);
2291
+ return {
2292
+ runId: await client.start(workflow, payload, omitUndefined({ key: opts.key })),
2293
+ file: chosen
2294
+ };
2295
+ }
2296
+ /**
2297
+ * Send the file, waiting out however many pauses the person takes.
2298
+ *
2299
+ * Its own function rather than a block inside `submit` because `submit` is
2300
+ * already carrying the ORDER this hook exists for — read the declaration, mint
2301
+ * the id, start the run, then the bytes, then the wake — and the sending is the
2302
+ * one step of that list with a loop in it.
2303
+ */
2304
+ async function streamFile(opts) {
2305
+ const { client, gate, id, file, parallel, report } = opts;
2306
+ await sendThroughGate(gate, async (resume) => {
2307
+ await client.uploadStream(id, file, {
2308
+ name: file.name,
2309
+ signal: gate.signal,
2310
+ onProgress: (progress) => report({
2311
+ ...progress,
2312
+ name: file.name,
2313
+ index: 1,
2314
+ count: 1,
2315
+ paused: gate.paused
2316
+ }),
2317
+ ...omitUndefined({
2318
+ parallel,
2319
+ resume: resume ? true : void 0
2320
+ })
2321
+ });
2322
+ });
2323
+ }
2324
+ /**
2057
2325
  * Refuse a payload carrying a `File`, before a run is started over it.
2058
2326
  *
2059
2327
  * A File cannot be SENT: a run input is JSON and `JSON.stringify(new File(…))` is
@@ -2080,10 +2348,6 @@ function assertSendable(workflow, payload, field) {
2080
2348
  const declares = field === void 0 ? "" : ` (it declares "${field}")`;
2081
2349
  throw new Error(`Cannot start "${workflow}": ${unsendable.join(", ")} ${carries} the workflow does not declare as an upload${declares}. Add the property to \`workflow({ uploads: [...] })\`, or submit an upload id.`);
2082
2350
  }
2083
- /** A fresh upload id: a capability, so it is random rather than derived. */
2084
- function randomUploadId() {
2085
- return crypto.randomUUID().replaceAll("-", "");
2086
- }
2087
2351
  /**
2088
2352
  * Which input property this workflow says carries an upload id.
2089
2353
  *
@@ -88,6 +88,16 @@ export type UploadStatus = UploadProgress & {
88
88
  index: number;
89
89
  /** How many files this submission sends in total. */
90
90
  count: number;
91
+ /**
92
+ * Whether the person has parked this upload.
93
+ *
94
+ * A paused upload is not a stopped one: the windows already stored stay stored,
95
+ * `loaded` holds where it got to, and resuming sends what is missing rather than
96
+ * the file. So a bar rendering this reads "Paused at 62%", never "62% and
97
+ * frozen" — which is what a page could otherwise only guess from a number that
98
+ * stopped moving, the same ambiguity `complete` exists to remove on the run side.
99
+ */
100
+ paused: boolean;
91
101
  };
92
102
  /** What {@link useWorkflowSubmit} returns. */
93
103
  export type WorkflowSubmission<R = unknown> = {
@@ -122,6 +132,21 @@ export type WorkflowSubmission<R = unknown> = {
122
132
  * for a 200 MB recording is minutes of a page that looks stuck.
123
133
  */
124
134
  upload: UploadStatus | undefined;
135
+ /**
136
+ * Park the upload where it is, stopping the bytes in flight.
137
+ *
138
+ * The windows already stored stay stored, so `resumeUpload()` sends what is
139
+ * missing rather than the file — which is the difference between a pause a
140
+ * person will actually use on a 200 MB recording and a cancel dressed up as one.
141
+ *
142
+ * `submit()`'s promise stays unresolved across a pause, because the submission
143
+ * genuinely has not finished: the run does not exist until the last byte lands,
144
+ * so resolving here would tell a `<Form>` the work was accepted when nothing has
145
+ * been started. A no-op when there is no upload in flight.
146
+ */
147
+ pauseUpload: () => void;
148
+ /** Continue a paused upload, sending only the windows the store does not have. */
149
+ resumeUpload: () => void;
125
150
  /** The submit's own failure (a rejected input), or the watch's. */
126
151
  error: string | undefined;
127
152
  };
@@ -48,18 +48,33 @@
48
48
  * - **Reporting the bytes.** The same `UploadStatus` `useWorkflowSubmit` reports,
49
49
  * so `<UploadProgressBar>` renders either without knowing which hook it came from.
50
50
  *
51
- * ## A failed upload is RESUMED once, and only then cancels the run
51
+ * ## A failed upload is RESUMED, and only a spent budget cancels the run
52
52
  *
53
53
  * An upload that dies stays in the store, incomplete, and `complete` never becomes
54
54
  * true — so a run left behind polls until its own abandonment bound and then fails,
55
55
  * minutes after the page has already reported the error.
56
56
  *
57
57
  * That used to be the whole story, and it threw away a run and a file together for
58
- * what is usually one dropped connection near the end. So a failure gets one more
59
- * attempt with `resume: true`, which sends only the windows the store does not
60
- * already have (`UploadInfo.ranges`) the run is still waiting on the same id, so
61
- * a resume that succeeds is invisible to it. Cancelling is what happens when THAT
62
- * fails too, and it is the honest end to a submission that did not happen.
58
+ * what is usually one dropped connection near the end. **The resume lives in the
59
+ * SDK now** (`aai/sdk/_upload-resume.ts`): a round that fails for a reason that
60
+ * looks like an outage is re-entered with `resume: true`, sending only the windows
61
+ * the store does not already have, on a budget sized to outlast a redeploy. The run
62
+ * is still waiting on the same id, so a resume that succeeds is invisible to it.
63
+ *
64
+ * This hook used to hand-roll one such retry and no longer does — one resume with
65
+ * no wait in front of it covers a dropped connection and cannot cover the case that
66
+ * actually strands people, which is the agent restarting underneath the upload.
67
+ * Cancelling the run is what happens when the whole budget is spent, and it is the
68
+ * honest end to a submission that did not happen.
69
+ *
70
+ * ## Pausing is the same mechanism, asked for
71
+ *
72
+ * `pauseUpload()` aborts the bytes in flight and holds the uploader;
73
+ * `resumeUpload()` sends what is missing. The store cannot tell that from an
74
+ * outage, because there is nothing to tell apart — see `_upload-session.ts`. The
75
+ * RUN is untouched either way: it goes on polling the id it was started with, and
76
+ * `stream.ts`'s idle bound (five minutes of no new bytes) is what decides that a
77
+ * pause has become an abandonment.
63
78
  */
64
79
  import type { UploadParallel } from "@alexkroman1/aai/workflow-api";
65
80
  import type { UploadStatus } from "./use-workflow-form.ts";
@@ -109,6 +124,17 @@ export type WorkflowStreamSubmission<R = unknown> = {
109
124
  pending: boolean;
110
125
  /** How far the upload has got, while it is still going. */
111
126
  upload: UploadStatus | undefined;
127
+ /**
128
+ * Park the upload where it is, stopping the bytes in flight.
129
+ *
130
+ * The RUN keeps going — it is watching an upload id, and a paused upload is one
131
+ * whose `size` has stopped growing, which is exactly what a slow uplink looks
132
+ * like. So a pause costs nothing until the workflow's own idle bound decides the
133
+ * uploader is gone (five minutes in `transcription-workflow`).
134
+ */
135
+ pauseUpload: () => void;
136
+ /** Continue a paused upload, sending only the windows the store does not have. */
137
+ resumeUpload: () => void;
112
138
  /** The submit's own failure (a rejected input, or an upload that would not store). */
113
139
  error: string | undefined;
114
140
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexkroman1/aai-ui",
3
- "version": "6.7.2",
3
+ "version": "6.8.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -29,7 +29,7 @@
29
29
  "remark-gfm": "^4.0.1",
30
30
  "use-stick-to-bottom": "^1.1.6",
31
31
  "use-sync-external-store": "^1.6.0",
32
- "@alexkroman1/aai": "6.7.2"
32
+ "@alexkroman1/aai": "6.8.0"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "react": "^19.0.0",
@@ -1,2 +0,0 @@
1
- /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
2
- @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--tracking-wide:.025em;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--font-aai:"Monument Grotesk", "ABC Monument Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif;--font-aai-serif:"Source Serif 4", "Source Serif Pro", Charter, "Iowan Old Style", Georgia, serif;--font-aai-mono:"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;--radius-aai:4px}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}html,body{margin:0;padding:0}}@layer components;@layer utilities{.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.order-last{order:9999}.container{width:100%}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.m-0{margin:0}.mx-auto{margin-inline:auto}.my-0\.5{margin-block:calc(var(--spacing) * .5)}.my-1\.5{margin-block:calc(var(--spacing) * 1.5)}.my-2\.5{margin-block:calc(var(--spacing) * 2.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.box-border{box-sizing:border-box}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-4{height:calc(var(--spacing) * 4)}.h-9{height:calc(var(--spacing) * 9)}.h-11{height:calc(var(--spacing) * 11)}.h-\[7px\]{height:7px}.h-full{height:100%}.h-screen{height:100vh}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-\[40vh\]{max-height:40vh}.min-h-0{min-height:0}.min-h-5{min-height:calc(var(--spacing) * 5)}.min-h-screen{min-height:100vh}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-4{width:calc(var(--spacing) * 4)}.w-\[7px\]{width:7px}.w-fit{width:fit-content}.w-full{width:100%}.max-w-75{max-width:calc(var(--spacing) * 75)}.max-w-105{max-width:calc(var(--spacing) * 105)}.max-w-190{max-width:calc(var(--spacing) * 190)}.max-w-\[82\%\]{max-width:82%}.max-w-\[min\(78\%\,64ch\)\]{max-width:min(78%,64ch)}.min-w-0{min-width:0}.flex-1{flex:1}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.basis-full{flex-basis:100%}.border-collapse{border-collapse:collapse}.rotate-90{rotate:90deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-pulse{animation:var(--animate-pulse)}.cursor-pointer{cursor:pointer}.resize{resize:both}.\[scrollbar-width\:none\]{scrollbar-width:none}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.appearance-none{appearance:none}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded-aai{border-radius:var(--radius-aai)}.rounded-full{border-radius:2147483647px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-none{--tw-border-style:none;border-style:none}.border-\(--aai-btn-bd\){border-color:var(--aai-btn-bd)}.bg-\(--aai-btn-bg\){background-color:var(--aai-btn-bg)}.bg-transparent{background-color:#0000}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-7{padding:calc(var(--spacing) * 7)}.p-8{padding:calc(var(--spacing) * 8)}.px-1{padding-inline:var(--spacing)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-7{padding-inline:calc(var(--spacing) * 7)}.px-10{padding-inline:calc(var(--spacing) * 10)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.pl-1\.5{padding-left:calc(var(--spacing) * 1.5)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-5{padding-left:calc(var(--spacing) * 5)}.text-center{text-align:center}.text-left{text-align:left}.font-aai{font-family:var(--font-aai)}.font-aai-mono{font-family:var(--font-aai-mono)}.font-aai-serif{font-family:var(--font-aai-serif)}.font-mono{font-family:var(--font-mono)}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[15px\]{font-size:15px}.text-\[16px\]{font-size:16px}.text-\[17px\]{font-size:17px}.text-\[22px\]{font-size:22px}.text-\[32px\]{font-size:32px}.leading-4{--tw-leading:calc(var(--spacing) * 4);line-height:calc(var(--spacing) * 4)}.leading-\[1\.2\]{--tw-leading:1.2;line-height:1.2}.leading-\[1\.15\]{--tw-leading:1.15;line-height:1.15}.leading-\[22px\]{--tw-leading:22px;line-height:22px}.leading-\[23px\]{--tw-leading:23px;line-height:23px}.leading-\[130\%\]{--tw-leading:130%;line-height:130%}.leading-none{--tw-leading:1;line-height:1}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[-0\.2px\]{--tw-tracking:-.2px;letter-spacing:-.2px}.tracking-\[1\.2px\]{--tw-tracking:1.2px;letter-spacing:1.2px}.tracking-\[1\.4px\]{--tw-tracking:1.4px;letter-spacing:1.4px}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.text-balance{text-wrap:balance}.wrap-break-word{overflow-wrap:break-word}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-\(--aai-btn-fg\){color:var(--aai-btn-fg)}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.underline{text-decoration-line:underline}.underline-offset-2{text-underline-offset:2px}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.file\:mr-3::file-selector-button{margin-right:calc(var(--spacing) * 3)}.file\:cursor-pointer::file-selector-button{cursor:pointer}.file\:rounded-aai::file-selector-button{border-radius:var(--radius-aai)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:px-3::file-selector-button{padding-inline:calc(var(--spacing) * 3)}.file\:py-1\.5::file-selector-button{padding-block:calc(var(--spacing) * 1.5)}.file\:font-aai::file-selector-button{font-family:var(--font-aai)}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:\[color\:var\(--aai-file-button-fg\)\]::file-selector-button{color:var(--aai-file-button-fg)}.file\:\[background\:var\(--aai-file-button-bg\)\]::file-selector-button{background:var(--aai-file-button-bg)}.first\:mt-0:first-child{margin-top:0}.last\:mb-0:last-child{margin-bottom:0}.focus-visible\:\[outline\:2px_solid\]:focus-visible{outline:2px solid}.focus-visible\:\[outline-offset\:2px\]:focus-visible{outline-offset:2px}@media (hover:hover){.enabled\:hover\:border-\(--aai-btn-bd-hover\):enabled:hover{border-color:var(--aai-btn-bd-hover)}.enabled\:hover\:bg-\(--aai-btn-bg-hover\):enabled:hover{background-color:var(--aai-btn-bg-hover)}.enabled\:hover\:text-\(--aai-btn-fg-hover\):enabled:hover{color:var(--aai-btn-fg-hover)}}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media (width>=40rem){.sm\:ml-auto{margin-left:auto}.sm\:max-w-\[60\%\]{max-width:60%}.sm\:basis-auto{flex-basis:auto}.sm\:px-16{padding-inline:calc(var(--spacing) * 16)}.sm\:py-14{padding-block:calc(var(--spacing) * 14)}}@media (width>=48rem){.md\:order-none{order:0}.md\:h-screen{height:100vh}.md\:max-h-none{max-height:none}.md\:w-\(--aai-sidebar-w\){width:var(--aai-sidebar-w)}.md\:flex-row{flex-direction:row}.md\:border-t-0{border-top-style:var(--tw-border-style);border-top-width:0}.md\:border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.md\:border-b-0{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.md\:border-l{border-left-style:var(--tw-border-style);border-left-width:1px}}}@keyframes aai-pulse{0%,to{opacity:1;transform:scale(1)}50%{opacity:.45;transform:scale(.82)}}@keyframes aai-bounce{0%,80%,to{opacity:.3;transform:scale(.8)}40%{opacity:1;transform:scale(1)}}@keyframes aai-shimmer{0%{background-position:-200% 0}to{background-position:200% 0}}.tool-shimmer{-webkit-text-fill-color:transparent;background:linear-gradient(90deg,currentColor 25%,#0000 50%,currentColor 75%) 0 0/200% 100%;-webkit-background-clip:text;background-clip:text;animation:2s infinite aai-shimmer}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes pulse{50%{opacity:.5}}