@connextar/house 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +213 -2
  2. package/dist/house.css +66 -0
  3. package/dist/ux/errors.d.ts +26 -0
  4. package/dist/ux/errors.d.ts.map +1 -0
  5. package/dist/ux/errors.js +33 -0
  6. package/dist/ux/errors.js.map +1 -0
  7. package/dist/ux/feedback.d.ts +23 -0
  8. package/dist/ux/feedback.d.ts.map +1 -0
  9. package/dist/ux/feedback.js +22 -0
  10. package/dist/ux/feedback.js.map +1 -0
  11. package/dist/ux/index.d.ts +20 -0
  12. package/dist/ux/index.d.ts.map +1 -0
  13. package/dist/ux/index.js +20 -0
  14. package/dist/ux/index.js.map +1 -0
  15. package/dist/ux/navigation-index.d.ts +10 -0
  16. package/dist/ux/navigation-index.d.ts.map +1 -0
  17. package/dist/ux/navigation-index.js +10 -0
  18. package/dist/ux/navigation-index.js.map +1 -0
  19. package/dist/ux/navigation-progress.d.ts +53 -0
  20. package/dist/ux/navigation-progress.d.ts.map +1 -0
  21. package/dist/ux/navigation-progress.js +245 -0
  22. package/dist/ux/navigation-progress.js.map +1 -0
  23. package/dist/ux/optimistic.d.ts +139 -0
  24. package/dist/ux/optimistic.d.ts.map +1 -0
  25. package/dist/ux/optimistic.js +242 -0
  26. package/dist/ux/optimistic.js.map +1 -0
  27. package/dist/ux/use-action.d.ts +60 -0
  28. package/dist/ux/use-action.d.ts.map +1 -0
  29. package/dist/ux/use-action.js +100 -0
  30. package/dist/ux/use-action.js.map +1 -0
  31. package/dist/ux/use-optimistic-list.d.ts +69 -0
  32. package/dist/ux/use-optimistic-list.d.ts.map +1 -0
  33. package/dist/ux/use-optimistic-list.js +115 -0
  34. package/dist/ux/use-optimistic-list.js.map +1 -0
  35. package/dist/ux/use-optimistic-value.d.ts +40 -0
  36. package/dist/ux/use-optimistic-value.d.ts.map +1 -0
  37. package/dist/ux/use-optimistic-value.js +68 -0
  38. package/dist/ux/use-optimistic-value.js.map +1 -0
  39. package/dist/wizard/index.d.ts +26 -0
  40. package/dist/wizard/index.d.ts.map +1 -0
  41. package/dist/wizard/index.js +26 -0
  42. package/dist/wizard/index.js.map +1 -0
  43. package/dist/wizard/invalid.d.ts +91 -0
  44. package/dist/wizard/invalid.d.ts.map +1 -0
  45. package/dist/wizard/invalid.js +121 -0
  46. package/dist/wizard/invalid.js.map +1 -0
  47. package/dist/wizard/react-hook-form-index.d.ts +12 -0
  48. package/dist/wizard/react-hook-form-index.d.ts.map +1 -0
  49. package/dist/wizard/react-hook-form-index.js +12 -0
  50. package/dist/wizard/react-hook-form-index.js.map +1 -0
  51. package/dist/wizard/rhf-adapter.d.ts +32 -0
  52. package/dist/wizard/rhf-adapter.d.ts.map +1 -0
  53. package/dist/wizard/rhf-adapter.js +58 -0
  54. package/dist/wizard/rhf-adapter.js.map +1 -0
  55. package/dist/wizard/rules.d.ts +92 -0
  56. package/dist/wizard/rules.d.ts.map +1 -0
  57. package/dist/wizard/rules.js +105 -0
  58. package/dist/wizard/rules.js.map +1 -0
  59. package/dist/wizard/wizard.d.ts +127 -0
  60. package/dist/wizard/wizard.d.ts.map +1 -0
  61. package/dist/wizard/wizard.js +195 -0
  62. package/dist/wizard/wizard.js.map +1 -0
  63. package/package.json +24 -3
package/README.md CHANGED
@@ -39,6 +39,27 @@ The 0.2.0 modules are the second half of that rule. AltEd built a versioned
39
39
  changelog, a navigation trail, a block editor and guide tables; every other app
40
40
  was about to write each of them for the second time.
41
41
 
42
+ `ux`, in 0.3.0, is the clearest case of that clause yet. Every app in the
43
+ portfolio writes over `fetch` and refreshes afterwards, so every one of them has
44
+ the same dead-click problem — a tick that goes nowhere for half a second, a
45
+ button that looks broken while working perfectly — and none of them had solved
46
+ it. It is also the kind of code nobody should write twice: the rules about when
47
+ to let go of an optimistic change are small, subtle and easy to get quietly
48
+ wrong.
49
+
50
+ `wizard`, in 0.4.0, is the first module extracted from five copies that had
51
+ already been written. It is here because of what those copies cost: four of the
52
+ five shipped a defect in it, and not one of the four was a mistake about
53
+ wizards. PharmaLine created a booking from step two, because a `type="button"`
54
+ Continue and a `type="submit"` Create shared a slot and React switched the one
55
+ DOM node's type mid-click. Three PM Tool forms refused to save with nothing on
56
+ screen saying why. MAS checked no step at all on submit, so a second press after
57
+ a successful create sent a record whose first step had been cleared. Every one of
58
+ those is a mistake about what a form must do before it agrees to send, and there
59
+ is one right answer to that — which is exactly the shape of thing that belongs
60
+ here and nowhere else. The module is the union of the five, not a copy of the
61
+ best one.
62
+
42
63
  ## Modules
43
64
 
44
65
  ### `@connextar/house/errors`
@@ -236,10 +257,166 @@ much for the editor to ask for theirs, and this is not a second design system.
236
257
  If an app also depends on `@tiptap/*` directly, keep it in the same range as this
237
258
  package so npm installs one copy. Two copies of `@tiptap/pm` break the editor.
238
259
 
260
+ ### `@connextar/house/ux`
261
+
262
+ The interface answers the click: optimistic updates, pending state, and
263
+ navigation feedback. Headless — no components, no design system, no toast
264
+ library, no HTTP client.
265
+
266
+ `useOptimisticList(rows, { key })` holds the server's rows as a _base_ and every
267
+ change as a _patch_ beside them, folded in on render. `insert`, `update`,
268
+ `remove`, `move` and `bulk` each take a commit function; `isPending(key)` marks
269
+ one row without disabling the table. `useOptimisticValue` is the same for a
270
+ switch, a status or a settings record; `useAction` and `useKeyedAction` are for
271
+ work that should not be guessed at — a payment, a model call, a sign-in — and
272
+ give a control the `pending` flag it needs and nothing more.
273
+
274
+ ```tsx
275
+ const tasks = useOptimisticList(rows, { key: (t) => t.id, onSettled: router.refresh });
276
+
277
+ tasks.update(task.id, { done: true }, () => save(task.id, { done: true }));
278
+ ```
279
+
280
+ **Why not `useOptimistic`.** React 19's hook is right when the write _is_ the
281
+ transition — a server action. It is wrong for an app that writes over `fetch` and
282
+ then calls `router.refresh()`, because `router.refresh()` is not awaitable: the
283
+ transition ends before the new rows arrive and the optimistic value snaps back to
284
+ the stale one, flashing the old answer on every single write.
285
+
286
+ **When a change is let go of.** A patch is `pending` while its write is in
287
+ flight, then dropped (it failed — the base alone is the truth again) or `settled`
288
+ (it landed, and keeps being applied, because the base is still the _old_ rows
289
+ until a refresh arrives). A settled patch goes when fresh rows account for it:
290
+ either they agree, or they disagree with what they said when the write landed, in
291
+ which case they win and the screen corrects itself. Evidence, never a timer — a
292
+ screen that does not refetch has no truth to fall back to, and snapping to stale
293
+ rows after N seconds is a bug that only appears on a slow connection.
294
+
295
+ **The three seams that keep it portable.** A commit is
296
+ `() => Promise<Row | void>` that **throws** on failure, which is the one failure
297
+ contract every client already has; an app whose client returns a result object
298
+ wraps it once. Failures are reported to an `ActionFeedbackProvider` as
299
+ `{ tone, title, description }`, so the app decides what a message looks like.
300
+ And `NavigationProgress` takes a `className` for its bar, so it never needs a
301
+ token.
302
+
303
+ `NavigationProgress` (from `@connextar/house/ux/navigation` — its own entry
304
+ point, so `next` stays an optional peer) also marks the link that was clicked
305
+ with `data-busy`, which `house.css` turns into a spinner. Two notes on it, both
306
+ learned the hard way:
307
+
308
+ - Its click listener is in the **capture** phase, and deliberately does not check
309
+ `defaultPrevented`. Next's `Link` cancels the click on its way to a
310
+ client-side navigation, so a bubble-phase listener guarding on
311
+ `defaultPrevented` fires for every link _except_ the app's own — which is
312
+ precisely backwards, and is what AltEd shipped unnoticed until it was measured
313
+ in a browser.
314
+ - `useAppRouter()` is `useRouter()` with the same feedback on `push` and
315
+ `replace`. Swapping the import is the whole change; no call site moves.
316
+
317
+ **Not everything should be optimistic.** Money, model calls, sign-in, imports and
318
+ exports: anything whose result the person cannot predict, or would be alarmed to
319
+ see undone. Guessing is only honest when the guess is nearly always right.
320
+
321
+ ### `@connextar/house/wizard`, `/wizard/react-hook-form`
322
+
323
+ `FormWizard` — one long form split into steps that check themselves. **It renders
324
+ the `<form>`**, because two of its promises cannot be kept from inside somebody
325
+ else's: that Enter has one place to land, and that nothing reaches `onSubmit`
326
+ unchecked.
327
+
328
+ ```tsx
329
+ const check = useStepValidator(form); // react-hook-form; or write your own
330
+
331
+ <FormWizard
332
+ steps={[
333
+ { id: "learner", title: "Learner", fields: ["studentId", "level"], content: <LearnerFields /> },
334
+ { id: "period", title: "Period", fields: ["startDate", "endDate"], content: <PeriodFields /> },
335
+ { id: "build", title: "Build", fields: [], content: <ReviewFields /> },
336
+ ]}
337
+ onValidateStep={check}
338
+ onSubmit={() => void form.handleSubmit(submit, report)()}
339
+ onCancel={() => router.push(routes.plans)}
340
+ submitLabel="Build my curriculum"
341
+ pending={pending}
342
+ />;
343
+ ```
344
+
345
+ The rules it keeps, each one because a copy of it did not:
346
+
347
+ - **Submitting checks every step, in order, and lands on the first that fails**,
348
+ with its message visible. Checking only the last step lets a marker jump back,
349
+ a cleared field and a press of Save refuse with the problem three screens away.
350
+ Checking none — MAS — sends the record anyway.
351
+ - **Enter before the last step advances.** It validates the step it is on and
352
+ opens the next; only the last step's Enter submits. A booking that emails a
353
+ patient must not be creatable from the step before Confirm.
354
+ - **One submit button, whose `type` never changes.** Never two controls sharing
355
+ the slot. jsdom's timing hid this, and only Chromium showed it, so the test
356
+ that guards it is structural: one `button[type=submit]`, the same node on every
357
+ step.
358
+ - **Continue is never disabled for being incomplete.** A greyed-out button that
359
+ will not say what is missing is a refusal with no words. It is disabled only
360
+ while something is in flight.
361
+ - **Visited markers are reachable, later steps locked** (`allReachable` opens
362
+ them all, for editing values that are already there).
363
+ - **Every step stays mounted, hidden rather than unmounted**, so there is one
364
+ field register and nothing typed is lost stepping back.
365
+
366
+ **Form-library-agnostic, because the five hosts are not the same.** A step is
367
+ complete when the check says so, and a check may answer either way the cohort
368
+ already writes them: `true`/`null` for complete, `false` for "not complete, and I
369
+ am showing why beside the field", or **a sentence** for "not complete, and this is
370
+ what to say" — which the wizard then shows in a `role="alert"` of its own. So
371
+ react-hook-form, plain `useState` and MAS's state forms all host it unchanged. A
372
+ check may live on the form (`onValidateStep`) or on the step (`step.validate`);
373
+ both are asked, the step's first.
374
+
375
+ `stepValidator(form, report)` and `useStepValidator(form)` are in
376
+ `@connextar/house/wizard/react-hook-form` — its own entry point because
377
+ `react-hook-form` is an **optional** peer dependency. Every import of it there is
378
+ `import type`, so the emitted JavaScript never mentions it; what would break an
379
+ app without it is a declaration file naming a package it has not installed, which
380
+ is why the names sit behind a subpath MAS never reaches for.
381
+ `src/wizard/entry-points.test.ts` keeps that honest against the built output.
382
+
383
+ **When the error is somewhere nobody can look.** A wizard's fields live on steps
384
+ that are mounted but hidden, and a form library will not call a submit handler
385
+ while any field is invalid — so a message beside a field the person cannot see
386
+ makes the button look broken. `useInvalidReporter()` returns the function to pass
387
+ as the invalid handler (`form.handleSubmit(onSubmit, report)`); it waits for the
388
+ form to re-render and the wizard to move, then says the message out loud **only
389
+ if it is not already on screen**. It goes to the `ActionFeedbackProvider` from
390
+ [`ux`](#connextarhouseux), so this module imports no toast library. A field's
391
+ message opts in by carrying `data-error-for="<name>"` — `FieldError` does it, and
392
+ an app's own message component adds one attribute.
393
+
394
+ The look is theme-token classes, replaced part by part through `classNames`, the
395
+ same way the guide renderer allows — there is still no `Button` in this package,
396
+ so the wizard draws plain elements and wears the app's classes. The busy state is
397
+ `data-busy`, which [`house.css`](#styles--connextarhousehousecss) already turns
398
+ into a spinner.
399
+
400
+ **What not to make a wizard.** A form of four fields is shorter than a wizard
401
+ around it, and a stepper over one screen of content is decoration that costs a
402
+ click. This is for a form long enough that a person would otherwise lose their
403
+ place — five steps in the curriculum builder, a booking that has to reach Confirm
404
+ — and specifically for one where **later steps depend on earlier answers**. Three
405
+ independent sections are a form with three headings. A flow whose steps are
406
+ separate saves, each landing before the next opens, is not one form and must not
407
+ pretend to be: this wizard keeps one register and submits once.
408
+
239
409
  ### Styles — `@connextar/house/house.css`
240
410
 
241
411
  What the components need that utility classes cannot say: rich text, the editor's
242
- block gutter and suggestion highlight, the trail's tinted pills.
412
+ block gutter and suggestion highlight, the trail's tinted pills, and the busy
413
+ spinner `ux` puts on whatever was clicked.
414
+
415
+ The spinner is `[data-busy="true"]::after`, drawn in `currentColor` with no theme
416
+ token. A control that renders its own spinner marks it `.house-spinner` so it
417
+ does not end up with two. For `prefers-reduced-motion` the ring stays and stops
418
+ turning, rather than disappearing — it is the only thing on screen saying the
419
+ click was heard.
243
420
 
244
421
  ## Tailwind setup (required for every component)
245
422
 
@@ -331,6 +508,40 @@ Traps found adopting it the first time:
331
508
  module"; delete `.next/dev` when no dev server is running.
332
509
  - If the app also depends on `@tiptap/*` directly, keep the range aligned or drop
333
510
  the direct dependency — two copies of `@tiptap/pm` break the editor.
511
+ - **`FormWizard` renders the `<form>`, so the app's wrapper one has to go.** Every
512
+ local copy but two sat inside a host `<form onSubmit={preventDefault}>`; nested
513
+ forms are invalid HTML and the outer one is where the Enter bug lives. Keep the
514
+ app's `<Form {...form}>` provider if it has one — that is context, not an
515
+ element — and delete the `<form>` inside it. MAS also loses its
516
+ `finishControl`: the submit button is the wizard's now, and passing one back in
517
+ is the defect the wizard exists to prevent.
518
+ - **Jest** (with `next/jest`) does not transform `node_modules`, and this package is
519
+ ESM, so the first import fails with `Unexpected token 'export'`. `next/jest` only
520
+ appends to `transformIgnorePatterns`, so export an async config that rewrites each
521
+ `/node_modules/(?!` rule to `/node_modules/(?!@connextar/house/)(?!`.
522
+ - **Vitest needs one line to mock anything the package imports.** It loads
523
+ `node_modules` through native Node ESM, where `vi.mock` does not reach: a test
524
+ that mocks `next/navigation` keeps passing while the package's components call
525
+ the real router, until one of them throws `invariant expected app router to be
526
+ mounted`. Inline the package so vite transforms it:
527
+
528
+ ```ts
529
+ test: {
530
+ server: {
531
+ deps: {
532
+ inline: [/@connextar\/house/];
533
+ }
534
+ }
535
+ }
536
+ ```
537
+
538
+ - **Mock `next/navigation.js`, not just `next/navigation`.** This package is built
539
+ with `moduleResolution: nodenext`, and `next` ships no `exports` map — so an
540
+ extensionless subpath does not resolve and the source has to say
541
+ `next/navigation.js`. Vitest keys mocks by specifier, so an app's existing mock
542
+ of `next/navigation` does not cover the package. Register both, with the factory
543
+ written out twice: `vi.mock` is hoisted above every `const`, so a shared factory
544
+ variable throws `Cannot access ... before initialization`.
334
545
 
335
546
  **A note on local development.** Installing this with `npm install ../path` makes
336
547
  a symlink, and Next's Turbopack will not resolve a package whose real path is
@@ -340,7 +551,7 @@ behaves exactly like a registry install:
340
551
 
341
552
  ```bash
342
553
  npm pack --pack-destination /tmp # in this repo
343
- npm install /tmp/connextar-house-0.2.0.tgz # in the app
554
+ npm install /tmp/connextar-house-0.4.0.tgz # in the app
344
555
  ```
345
556
 
346
557
  ## Adding to it
package/dist/house.css CHANGED
@@ -260,8 +260,74 @@
260
260
  }
261
261
  }
262
262
 
263
+ /* ── Busy controls (@connextar/house/ux) ────────────────────────────────
264
+ One rule for "this is working on it", wherever the click landed.
265
+
266
+ `data-busy` is set by two things that never meet: the app's own button, for
267
+ an action whose spinner it cannot render inside (a button that forwards to a
268
+ `Link` has nowhere to put one), and `NavigationProgress`, for whichever link
269
+ or menu item was clicked. Neither knows what the other is, so the agreement
270
+ is an attribute and this stylesheet.
271
+
272
+ The spinner is a pseudo-element on purpose: the navigation watcher marks DOM
273
+ nodes it has never rendered and cannot re-render — a sidebar link, a row in
274
+ a table, a dropdown item from a page this package has never heard of — and
275
+ CSS is the only thing that reaches all of them. It lays out as a flex item
276
+ where the control is a flex container, and inline after the label where it
277
+ is not, so it takes its own space instead of sitting on top of the words.
278
+
279
+ `currentColor` throughout: no theme token, so it lands in any app's palette. */
280
+
281
+ @layer components {
282
+ [data-busy="true"] {
283
+ cursor: progress;
284
+ }
285
+
286
+ [data-busy="true"]::after {
287
+ content: "";
288
+ display: inline-block;
289
+ flex: none;
290
+ width: 0.875rem;
291
+ height: 0.875rem;
292
+ margin-inline-start: 0.4rem;
293
+ vertical-align: -0.1875em;
294
+ border-radius: 9999px;
295
+ border: 2px solid currentColor;
296
+ border-inline-end-color: transparent;
297
+ opacity: 0.65;
298
+ animation: house-spin 0.6s linear infinite;
299
+ }
300
+
301
+ /* A flex control already spaces its children with `gap`. */
302
+ [data-busy="true"]:is(.inline-flex, .flex)::after {
303
+ margin-inline-start: 0;
304
+ }
305
+
306
+ /* A control that renders its own spinner marks it `.house-spinner`, so it
307
+ does not end up with two. */
308
+ [data-busy="true"]:has(> .house-spinner)::after {
309
+ content: none;
310
+ }
311
+ }
312
+
313
+ /* Keyframes must sit outside @layer. */
314
+ @keyframes house-spin {
315
+ to {
316
+ transform: rotate(360deg);
317
+ }
318
+ }
319
+
263
320
  @media (prefers-reduced-motion: reduce) {
264
321
  .house-block-handle {
265
322
  transition: none;
266
323
  }
324
+
325
+ /* Stillness was asked for, but silence is not the alternative: the ring
326
+ stays, it just stops going round. Removing it altogether would take away
327
+ the only thing on screen saying the click was heard. */
328
+ [data-busy="true"]::after {
329
+ animation: none !important;
330
+ border-inline-end-color: currentColor;
331
+ opacity: 0.45;
332
+ }
267
333
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The one thing an optimistic commit has to be able to say: "that didn't work,
3
+ * and here is what to tell the person".
4
+ *
5
+ * The hooks in this folder take a commit function that throws on failure —
6
+ * the only failure contract every transport already has. An app whose client
7
+ * returns a result object instead of throwing wraps it once (see
8
+ * `commitApi` in `lib/api.ts`) rather than teaching this layer its envelope.
9
+ */
10
+ export declare class ActionError extends Error {
11
+ /** HTTP status, where the transport had one. */
12
+ readonly status?: number;
13
+ /**
14
+ * Whatever the refusal carried. A conflict usually carries the record as it
15
+ * now is, which is what a caller merges into rather than rolling back to.
16
+ */
17
+ readonly data?: unknown;
18
+ constructor(message: string, options?: {
19
+ status?: number;
20
+ data?: unknown;
21
+ cause?: unknown;
22
+ });
23
+ }
24
+ /** The wording to show for a thrown value, whatever it turned out to be. */
25
+ export declare function errorMessage(error: unknown, fallback?: string): string;
26
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/ux/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,qBAAa,WAAY,SAAQ,KAAK;IACpC,gDAAgD;IAChD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;gBAEZ,OAAO,EAAE,MAAM,EAAE,OAAO,GAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAA;KAAO;CAMhG;AAED,4EAA4E;AAC5E,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,SAAyB,GAAG,MAAM,CAItF"}
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The one thing an optimistic commit has to be able to say: "that didn't work,
3
+ * and here is what to tell the person".
4
+ *
5
+ * The hooks in this folder take a commit function that throws on failure —
6
+ * the only failure contract every transport already has. An app whose client
7
+ * returns a result object instead of throwing wraps it once (see
8
+ * `commitApi` in `lib/api.ts`) rather than teaching this layer its envelope.
9
+ */
10
+ export class ActionError extends Error {
11
+ /** HTTP status, where the transport had one. */
12
+ status;
13
+ /**
14
+ * Whatever the refusal carried. A conflict usually carries the record as it
15
+ * now is, which is what a caller merges into rather than rolling back to.
16
+ */
17
+ data;
18
+ constructor(message, options = {}) {
19
+ super(message, { cause: options.cause });
20
+ this.name = "ActionError";
21
+ this.status = options.status;
22
+ this.data = options.data;
23
+ }
24
+ }
25
+ /** The wording to show for a thrown value, whatever it turned out to be. */
26
+ export function errorMessage(error, fallback = "Something went wrong") {
27
+ if (error instanceof Error && error.message)
28
+ return error.message;
29
+ if (typeof error === "string" && error)
30
+ return error;
31
+ return fallback;
32
+ }
33
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/ux/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,MAAM,OAAO,WAAY,SAAQ,KAAK;IACpC,gDAAgD;IACvC,MAAM,CAAU;IACzB;;;OAGG;IACM,IAAI,CAAW;IAExB,YAAY,OAAe,EAAE,UAAgE,EAAE;QAC7F,KAAK,CAAC,OAAO,EAAE,EAAE,KAAK,EAAE,OAAO,CAAC,KAAK,EAAE,CAAC,CAAC;QACzC,IAAI,CAAC,IAAI,GAAG,aAAa,CAAC;QAC1B,IAAI,CAAC,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;QAC7B,IAAI,CAAC,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAC3B,CAAC;CACF;AAED,4EAA4E;AAC5E,MAAM,UAAU,YAAY,CAAC,KAAc,EAAE,QAAQ,GAAG,sBAAsB;IAC5E,IAAI,KAAK,YAAY,KAAK,IAAI,KAAK,CAAC,OAAO;QAAE,OAAO,KAAK,CAAC,OAAO,CAAC;IAClE,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK;QAAE,OAAO,KAAK,CAAC;IACrD,OAAO,QAAQ,CAAC;AAClB,CAAC"}
@@ -0,0 +1,23 @@
1
+ import * as React from "react";
2
+ /**
3
+ * How this layer tells someone what happened, without knowing what the app
4
+ * uses to tell them.
5
+ *
6
+ * A toast library is a design decision, and the whole point of these hooks is
7
+ * that they can be lifted into a shared package. So the hooks report an event
8
+ * and the app decides what an event looks like — one provider at the root,
9
+ * wired to whatever that app already has.
10
+ */
11
+ export interface ActionFeedback {
12
+ tone: "success" | "error";
13
+ title: string;
14
+ description?: string;
15
+ }
16
+ export type ActionReporter = (feedback: ActionFeedback) => void;
17
+ export declare function ActionFeedbackProvider({ report, children }: {
18
+ report: ActionReporter;
19
+ children: React.ReactNode;
20
+ }): React.JSX.Element;
21
+ /** The app's reporter, or the console fallback. Stable across renders. */
22
+ export declare function useActionReporter(): ActionReporter;
23
+ //# sourceMappingURL=feedback.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"feedback.d.ts","sourceRoot":"","sources":["../../src/ux/feedback.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAE/B;;;;;;;;GAQG;AACH,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,MAAM,cAAc,GAAG,CAAC,QAAQ,EAAE,cAAc,KAAK,IAAI,CAAC;AAchE,wBAAgB,sBAAsB,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,EAAE;IAAE,MAAM,EAAE,cAAc,CAAC;IAAC,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAA;CAAE,qBAEjH;AAED,0EAA0E;AAC1E,wBAAgB,iBAAiB,IAAI,cAAc,CAElD"}
@@ -0,0 +1,22 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ const ReporterContext = React.createContext(null);
5
+ /**
6
+ * Without a provider a failure still has to go somewhere: swallowing it is how
7
+ * an optimistic update becomes a lie. The console is a poor place to tell
8
+ * someone something, but it is not silence, and it shows up in a test.
9
+ */
10
+ const fallbackReporter = (feedback) => {
11
+ if (feedback.tone !== "error")
12
+ return;
13
+ console.error(`[action] ${feedback.title}${feedback.description ? `: ${feedback.description}` : ""}`);
14
+ };
15
+ export function ActionFeedbackProvider({ report, children }) {
16
+ return _jsx(ReporterContext.Provider, { value: report, children: children });
17
+ }
18
+ /** The app's reporter, or the console fallback. Stable across renders. */
19
+ export function useActionReporter() {
20
+ return React.useContext(ReporterContext) ?? fallbackReporter;
21
+ }
22
+ //# sourceMappingURL=feedback.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"feedback.js","sourceRoot":"","sources":["../../src/ux/feedback.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;;AAEb,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAmB/B,MAAM,eAAe,GAAG,KAAK,CAAC,aAAa,CAAwB,IAAI,CAAC,CAAC;AAEzE;;;;GAIG;AACH,MAAM,gBAAgB,GAAmB,CAAC,QAAQ,EAAE,EAAE;IACpD,IAAI,QAAQ,CAAC,IAAI,KAAK,OAAO;QAAE,OAAO;IACtC,OAAO,CAAC,KAAK,CAAC,YAAY,QAAQ,CAAC,KAAK,GAAG,QAAQ,CAAC,WAAW,CAAC,CAAC,CAAC,KAAK,QAAQ,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;AACxG,CAAC,CAAC;AAEF,MAAM,UAAU,sBAAsB,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAyD;IAChH,OAAO,KAAC,eAAe,CAAC,QAAQ,IAAC,KAAK,EAAE,MAAM,YAAG,QAAQ,GAA4B,CAAC;AACxF,CAAC;AAED,0EAA0E;AAC1E,MAAM,UAAU,iBAAiB;IAC/B,OAAO,KAAK,CAAC,UAAU,CAAC,eAAe,CAAC,IAAI,gBAAgB,CAAC;AAC/D,CAAC"}
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `@connextar/house/ux` — the interface answers the click.
3
+ *
4
+ * Optimistic updates and pending state, with no framework in them: React, and
5
+ * nothing else. The half that needs Next — the navigation bar and the spinner
6
+ * on the link you clicked — is `@connextar/house/ux/navigation`, kept apart so
7
+ * that `next` stays the optional peer dependency it is declared to be. A
8
+ * barrel that re-exported both made it mandatory for anyone importing so much
9
+ * as the pure patch engine, which a smoke test caught before 0.3.0 shipped.
10
+ *
11
+ * What this module needs from the host arrives as a function (`commit`), a
12
+ * provider (`ActionFeedbackProvider`) or a class name — never as an import.
13
+ */
14
+ export { ActionError, errorMessage } from "./errors.js";
15
+ export { ActionFeedbackProvider, useActionReporter, type ActionFeedback, type ActionReporter } from "./feedback.js";
16
+ export { applyPatches, isRedundant, isTempKey, reconcile, sameRow, settlePatch, shouldDropSettled, supersede, tempKey, type Patch, type PatchKind, type RowKey, } from "./optimistic.js";
17
+ export { useAction, useKeyedAction, type Action, type ActionOptions, type KeyedAction } from "./use-action.js";
18
+ export { useOptimisticList, type OptimisticList, type OptimisticListOptions, type WriteOptions, } from "./use-optimistic-list.js";
19
+ export { useOptimisticValue, type OptimisticValue, type OptimisticValueOptions } from "./use-optimistic-value.js";
20
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/ux/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AACxD,OAAO,EAAE,sBAAsB,EAAE,iBAAiB,EAAE,KAAK,cAAc,EAAE,KAAK,cAAc,EAAE,MAAM,eAAe,CAAC;AACpH,OAAO,EACL,YAAY,EACZ,WAAW,EACX,SAAS,EACT,SAAS,EACT,OAAO,EACP,WAAW,EACX,iBAAiB,EACjB,SAAS,EACT,OAAO,EACP,KAAK,KAAK,EACV,KAAK,SAAS,EACd,KAAK,MAAM,GACZ,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,SAAS,EAAE,cAAc,EAAE,KAAK,MAAM,EAAE,KAAK,aAAa,EAAE,KAAK,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAC/G,OAAO,EACL,iBAAiB,EACjB,KAAK,cAAc,EACnB,KAAK,qBAAqB,EAC1B,KAAK,YAAY,GAClB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAAE,kBAAkB,EAAE,KAAK,eAAe,EAAE,KAAK,sBAAsB,EAAE,MAAM,2BAA2B,CAAC"}
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `@connextar/house/ux` — the interface answers the click.
3
+ *
4
+ * Optimistic updates and pending state, with no framework in them: React, and
5
+ * nothing else. The half that needs Next — the navigation bar and the spinner
6
+ * on the link you clicked — is `@connextar/house/ux/navigation`, kept apart so
7
+ * that `next` stays the optional peer dependency it is declared to be. A
8
+ * barrel that re-exported both made it mandatory for anyone importing so much
9
+ * as the pure patch engine, which a smoke test caught before 0.3.0 shipped.
10
+ *
11
+ * What this module needs from the host arrives as a function (`commit`), a
12
+ * provider (`ActionFeedbackProvider`) or a class name — never as an import.
13
+ */
14
+ export { ActionError, errorMessage } from "./errors.js";
15
+ export { ActionFeedbackProvider, useActionReporter } from "./feedback.js";
16
+ export { applyPatches, isRedundant, isTempKey, reconcile, sameRow, settlePatch, shouldDropSettled, supersede, tempKey, } from "./optimistic.js";
17
+ export { useAction, useKeyedAction } from "./use-action.js";
18
+ export { useOptimisticList, } from "./use-optimistic-list.js";
19
+ export { useOptimisticValue } from "./use-optimistic-value.js";
20
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/ux/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AACxD,OAAO,EAAE,sBAAsB,EAAE,iBAAiB,EAA4C,MAAM,eAAe,CAAC;AACpH,OAAO,EACL,YAAY,EACZ,WAAW,EACX,SAAS,EACT,SAAS,EACT,OAAO,EACP,WAAW,EACX,iBAAiB,EACjB,SAAS,EACT,OAAO,GAIR,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,SAAS,EAAE,cAAc,EAAqD,MAAM,iBAAiB,CAAC;AAC/G,OAAO,EACL,iBAAiB,GAIlB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAAE,kBAAkB,EAAqD,MAAM,2BAA2B,CAAC"}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `@connextar/house/ux/navigation` — feedback for moving between screens.
3
+ *
4
+ * Its own entry point because it is the only part of `ux` that imports `next`,
5
+ * which is an optional peer dependency: an app on another framework (or any
6
+ * app's Vitest suite running under plain Node) can still have the optimistic
7
+ * hooks from `@connextar/house/ux` without Next installed.
8
+ */
9
+ export { BUSY_ATTRIBUTE, NavigationProgress, startNavigation, startRouteProgress, useAppRouter, useNavigate, } from "./navigation-progress.js";
10
+ //# sourceMappingURL=navigation-index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"navigation-index.d.ts","sourceRoot":"","sources":["../../src/ux/navigation-index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EACL,cAAc,EACd,kBAAkB,EAClB,eAAe,EACf,kBAAkB,EAClB,YAAY,EACZ,WAAW,GACZ,MAAM,0BAA0B,CAAC"}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `@connextar/house/ux/navigation` — feedback for moving between screens.
3
+ *
4
+ * Its own entry point because it is the only part of `ux` that imports `next`,
5
+ * which is an optional peer dependency: an app on another framework (or any
6
+ * app's Vitest suite running under plain Node) can still have the optimistic
7
+ * hooks from `@connextar/house/ux` without Next installed.
8
+ */
9
+ export { BUSY_ATTRIBUTE, NavigationProgress, startNavigation, startRouteProgress, useAppRouter, useNavigate, } from "./navigation-progress.js";
10
+ //# sourceMappingURL=navigation-index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"navigation-index.js","sourceRoot":"","sources":["../../src/ux/navigation-index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EACL,cAAc,EACd,kBAAkB,EAClB,eAAe,EACf,kBAAkB,EAClB,YAAY,EACZ,WAAW,GACZ,MAAM,0BAA0B,CAAC"}
@@ -0,0 +1,53 @@
1
+ import * as React from "react";
2
+ /** The attribute every busy control carries, action or navigation alike. */
3
+ export declare const BUSY_ATTRIBUTE = "data-busy";
4
+ /**
5
+ * Show the navigation feedback for something this module cannot see on its
6
+ * own — a programmatic `router.push`. Pass the control that caused it and it
7
+ * gets the spinner too.
8
+ */
9
+ export declare function startNavigation(element?: Element | null): void;
10
+ /** Backwards-compatible alias for the original name. */
11
+ export declare const startRouteProgress: typeof startNavigation;
12
+ /**
13
+ * `useRouter`, with the navigation feedback attached to `push` and `replace`.
14
+ *
15
+ * A click that runs `router.push(href)` is invisible to the watcher above —
16
+ * there is no anchor and no popstate — so those navigations were the ones with
17
+ * no feedback at all. The worst case is the one right after a save: the
18
+ * button's own spinner stops when the write lands, and then nothing happens
19
+ * for as long as the next screen takes to render.
20
+ *
21
+ * Swapping the import is the whole change; no call site moves. `refresh`,
22
+ * `back`, `forward` and `prefetch` pass straight through — a refresh re-renders
23
+ * the screen you are already on, which needs no bar.
24
+ */
25
+ export declare function useAppRouter(): {
26
+ push: (href: string, options?: import("next/dist/shared/lib/app-router-context.shared-runtime.js").NavigateOptions | undefined) => void;
27
+ replace: (href: string, options?: import("next/dist/shared/lib/app-router-context.shared-runtime.js").NavigateOptions | undefined) => void;
28
+ back(): void;
29
+ forward(): void;
30
+ refresh(): void;
31
+ prefetch(href: string, options?: import("next/dist/shared/lib/app-router-context.shared-runtime.js").PrefetchOptions): void;
32
+ experimental_gesturePush?(href: string, options?: import("next/dist/shared/lib/app-router-context.shared-runtime.js").NavigateOptions): void;
33
+ bfcacheId: string;
34
+ };
35
+ /**
36
+ * `router.push` with the feedback attached, and the control that caused it
37
+ * marked as busy. For a button that navigates rather than saves.
38
+ */
39
+ export declare function useNavigate(): (href: string, options?: {
40
+ replace?: boolean;
41
+ element?: Element | null;
42
+ }) => void;
43
+ /**
44
+ * Mount once, above everything else — including anything that suspends, or the
45
+ * bar disappears for exactly the navigations it is there for.
46
+ *
47
+ * `className` styles the filled part of the bar, so the host app picks its own
48
+ * colour without this module knowing any of its tokens.
49
+ */
50
+ export declare function NavigationProgress({ className }: {
51
+ className?: string;
52
+ }): React.JSX.Element;
53
+ //# sourceMappingURL=navigation-progress.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"navigation-progress.d.ts","sourceRoot":"","sources":["../../src/ux/navigation-progress.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAgC/B,4EAA4E;AAC5E,eAAO,MAAM,cAAc,cAAc,CAAC;AAqB1C;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,OAAO,CAAC,EAAE,OAAO,GAAG,IAAI,GAAG,IAAI,CAI9D;AAED,wDAAwD;AACxD,eAAO,MAAM,kBAAkB,wBAAkB,CAAC;AAElD;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY;;;;;;;;;EAkB3B;AAED;;;GAGG;AACH,wBAAgB,WAAW,WAGhB,MAAM,YAAY;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,OAAO,GAAG,IAAI,CAAA;CAAE,UAO3E;AA0ID;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,EAAE,SAAS,EAAE,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,qBAMvE"}