@domainkit/react 0.11.0 → 0.12.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.
package/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # @domainkit/react
2
2
 
3
- React flows for custom-domain setup, over a transport your server owns. Provider credentials never
4
- reach the browser.
3
+ Headless React hooks for custom-domain setup, over a transport your server owns. The package
4
+ supplies state, copy, and accessibility; your application supplies the markup. Provider credentials
5
+ never reach the browser.
5
6
 
6
7
  ## Install
7
8
 
@@ -11,13 +12,20 @@ npm install @domainkit/react domainkit effect@rc react react-dom
11
12
 
12
13
  React 19 is required. Install `domainkit` and `@domainkit/react` at the same release version.
13
14
 
14
- ## Two components
15
+ For the styled composition, add the flow from the DomainKit registry into your own shadcn kit:
16
+
17
+ ```sh
18
+ npx shadcn@latest add https://domain-kit.dev/r/domain-flow.json
19
+ ```
20
+
21
+ ## One hook
15
22
 
16
23
  ```tsx
24
+ "use client";
25
+
17
26
  import { DnsRecord } from "domainkit";
18
27
  import { Transport } from "domainkit/client";
19
28
  import { Domain, DomainKit } from "@domainkit/react";
20
- import "@domainkit/react/styles.css";
21
29
 
22
30
  const transport = Transport.fromFetch("/api/domainkit");
23
31
 
@@ -30,18 +38,24 @@ const requirements = [
30
38
  }),
31
39
  ];
32
40
 
41
+ function DomainSetup() {
42
+ const flow = Domain.useFlow({ domain: "app.example.com", requirements });
43
+ return <DomainFlowCard flow={flow} />;
44
+ }
45
+
33
46
  export function DomainSettings() {
34
47
  return (
35
- <DomainKit.Root transport={transport} colorScheme="inherit">
36
- <Domain.Flow domain="app.example.com" requirements={requirements} />
48
+ <DomainKit.Root transport={transport}>
49
+ <DomainSetup />
37
50
  </DomainKit.Root>
38
51
  );
39
52
  }
40
53
  ```
41
54
 
42
- `Domain.Flow` connects a provider, plans the DNS changes, takes the customer's approval or refusal,
43
- applies the plan, observes the records, and cleans them up against the apply receipt. It renders
44
- only the capability groups the transport declares.
55
+ `Domain.useFlow` connects a provider, plans the DNS changes, takes the customer's approval or
56
+ refusal, applies the plan, observes the records, and cleans them up against the apply receipt. It
57
+ returns the four controllers, the plan the rows report, the latest readiness, the capability groups
58
+ the transport declares, and a `FlowState` your own surface reads.
45
59
 
46
60
  `Transport.fromFetch` points at the routes you mounted from `domainkit/server`. Writing it inline in
47
61
  JSX is fine: `DomainKit.Root` keeps the transport's identity for the whole mount, so the controllers
@@ -51,80 +65,88 @@ the UI did not make.
51
65
  An interactive provider returns the customer to the page they started from. Pass `returnTo` to name
52
66
  a different destination, or `null` to leave the server's `defaultReturnTo` in charge.
53
67
 
54
- ## The disconnected offer
68
+ ## What the flow says about a domain
55
69
 
56
- Discovery names the provider whose nameservers serve the domain. `Connect.Prompt` states it beside a
57
- trigger, and the dialog behind that trigger is narrowed to that provider, with the rest behind a
58
- disclosure.
70
+ `flow.state` is what DomainKit has to say, so your own offers can be ordered beside it rather than
71
+ competing with it.
59
72
 
60
- When no registered provider serves the zone there is nothing DomainKit can connect, so the flow
61
- offers nothing. Pass `connect="always"` to offer the all-providers dialog anyway, or `connect="never"`
62
- for a domain your application has already settled another way. A domain DomainKit holds keeps its
63
- status and its disconnect whatever the invitation says.
73
+ | Field | Says |
74
+ | ---------------------------- | ------------------------------------------------------------- |
75
+ | `connected` | DomainKit holds a connection for this domain |
76
+ | `offering` | The connect surface has something to offer |
77
+ | `provider`, `label` | Who holds the connection, and which account the records go to |
78
+ | `receiptId`, `applied` | The apply this domain's records came from |
79
+ | `connection`, `provisioning` | The tag each controller's state carries |
80
+ | `readOnly` | The customer may read this domain but not write to it |
64
81
 
65
- ```tsx
66
- <Domain.Flow domain="app.example.com" requirements={requirements} connect="always" />
67
- ```
82
+ Discovery names the provider whose nameservers serve the domain, and `Connect.hostProvider` returns
83
+ its descriptor. When no registered provider serves the zone there is nothing DomainKit can connect,
84
+ so `offering` is false. Pass `connect: "always"` to offer every provider anyway, or
85
+ `connect: "never"` for a domain your application has already settled another way.
68
86
 
69
- Connecting is the customer saying yes to the records, so the plan opens itself the moment a
70
- connection lands — after a token connect, and after the customer returns from a provider — and one
71
- action adds them. `review="manual"` waits for the trigger instead.
87
+ Connecting is the customer saying yes to the records, so the plan builds itself the moment a
88
+ connection lands, after a token connect and after the customer returns from a provider. One
89
+ `approve` adds them.
72
90
 
73
- `onState` reports what DomainKit has to say about the domain, so your own offers can be ordered
74
- beside it rather than competing with it:
91
+ ## Controllers
92
+
93
+ Compose the flow yourself from the same hooks. Each takes one options object and returns a named
94
+ `Controller` whose `State` is a tagged union.
75
95
 
76
96
  ```tsx
77
- <Domain.Flow
78
- domain="app.example.com"
79
- requirements={requirements}
80
- onState={({ connected, offering, provider }) => setDomainKitOffers(connected || offering)}
81
- />
97
+ const connection = Connect.useController({ domain });
98
+ const provisioning = Provision.useController({ domain, requirements, onApplied });
99
+ const cleanup = Cleanup.useController({ domain, receiptId, onCleaned });
100
+ const verification = Verify.useController({ domain, polling: true });
82
101
  ```
83
102
 
84
- Letting a provider go is one dialog: `Disconnect` asks whether to remove the records an apply
85
- receipt proves DomainKit created, removes them when the option stays checked, then releases the
86
- connection. `Cleanup.Flow` is still exported for a host that wants removal on its own.
103
+ `approve` authorizes the plan digest and applies it. `reject` records the customer's refusal; the
104
+ attempt is terminal and a new plan is needed. `retry` builds a new plan when the reason says the old
105
+ one is gone, and re-runs the failed step otherwise.
106
+
107
+ `Connect.describeMethods` arranges one provider's auth methods into the interactive ones a customer
108
+ clicks through and the ones that ask for credentials, so a connect surface asks for one decision at
109
+ a time. `Connect.rejectedField` names the one field a rejection was about.
87
110
 
88
- ## Own one piece, keep the rest
111
+ ## Adding a domain
89
112
 
90
- Every part of the flow is a slot with a default.
113
+ `Connect.useZones` lists every zone the workspace's accounts reach, `Connect.useAccounts` adds
114
+ another account without naming a domain, and `Connect.useDomainField` turns a text input into a
115
+ combobox over those zones: it returns `inputProps`, `listboxProps`, and `optionProps`, moves the
116
+ highlight with the arrow keys, and completes on Tab or Enter while keeping whatever subdomain was
117
+ typed in front of the zone.
91
118
 
92
119
  ```tsx
93
- <Domain.Flow
94
- domain="app.example.com"
95
- requirements={requirements}
96
- slots={{
97
- records: ({ records, readiness }) => <MyTable records={records} readiness={readiness} />,
98
- }}
99
- onApplied={(receipt) => track("dns.applied", { receiptId: receipt.id })}
100
- />
120
+ const { zones } = Connect.useZones();
121
+ const field = Connect.useDomainField({ zones, value, onChange: setValue, onResolve });
122
+
123
+ <input {...field.inputProps} />
124
+ <ul {...field.listboxProps}>
125
+ {field.suggestions.map((zone) => (
126
+ <li key={`${zone.connectionId}:${zone.zone}`} {...field.optionProps(zone)}>
127
+ {zone.zone}
128
+ </li>
129
+ ))}
130
+ </ul>;
101
131
  ```
102
132
 
103
- | Slot | Receives | Default |
104
- | -------------- | ----------------------------------------------- | ---------------------------------------------------------- |
105
- | `connection` | `{ controller, domain, connect }` | `Connect.Card` once connected, `Connect.Prompt` until then |
106
- | `records` | `{ records, readiness, controller, domain }` | `Records.Table` |
107
- | `verification` | `{ controller, domain }` | `Verify.Status` with per-requirement evidence |
108
- | `actions` | `{ connection, provisioning, cleanup, domain }` | Review changes, Approve, Decline |
109
-
110
- `Domain.Flow` adds no layout container around a slot: its output is a direct child of the flow root,
111
- so your own grid can place it without `display: contents`.
112
-
113
133
  ## The member view
114
134
 
115
- A customer who may read a domain but not change it gets `readOnly`. Status renders; every control
116
- that would start a write does not.
135
+ A customer who may read a domain but not change it gets `readOnly`. State still reports; every
136
+ command that would change the domain refuses to run, at the controller rather than in your markup,
137
+ so a control you render anyway cannot reach the transport.
117
138
 
118
139
  ```tsx
119
140
  <DomainKit.Root transport={transport} readOnly>
120
- <Domain.Flow domain="app.example.com" requirements={requirements} />
141
+ <DomainSetup />
121
142
  </DomainKit.Root>
122
143
  ```
123
144
 
124
- `Domain.Flow` takes the same flag when only one domain on the page is read-only, and `useReadOnly()`
125
- tells a part of your own which mode it is in. Capability gating is the other half: a group the
126
- transport does not declare never renders, so a transport built with
127
- `Transport.fromFetch(url, { capabilities: ["connection", "verification"] })` already hides
145
+ `Domain.useFlow` takes the same flag when only one domain on the page is read-only, and it rides on
146
+ `flow.state.readOnly`, so your surface can say who may connect instead of rendering nothing.
147
+ Capability gating is the other half: a group the transport does not declare is absent from
148
+ `flow.capabilities`, so a transport built with
149
+ `Transport.fromFetch(url, { capabilities: ["connection", "verification"] })` already rules out
128
150
  provisioning and cleanup. `readOnly` covers the authorization a transport cannot express, such as a
129
151
  member of an organisation who reaches the same routes.
130
152
 
@@ -132,71 +154,26 @@ Verification does not wait for a connection: the flow observes the requirements
132
154
  domain with no provider attached still reports which records are in place.
133
155
 
134
156
  Observation stays available in read-only, because checking DNS reads the world rather than changing
135
- the domain. Retrying is not: a flow that becomes read-only after a write failed keeps the failure on
136
- screen and drops the retry.
137
- If your `Identity.authorize` denies `observe` to members, pass `slots={{ verification: () => null }}`.
157
+ the domain. Retrying is not: a flow that becomes read-only after a write failed keeps the failure
158
+ and re-inspects instead of resending the command.
138
159
 
139
- ## Controllers
160
+ ## Words
140
161
 
141
- Compose the flow yourself from the same hooks. Each takes one options object and returns a named
142
- `Controller` whose `State` is a tagged union.
162
+ `Messages.Catalog` holds every user-visible string, including a title and a description per
163
+ `DomainKit.Error` reason; nothing renders a tag, a status literal, or a reason name. Pass
164
+ `messages` to `DomainKit.Root` to override any key.
143
165
 
144
166
  ```tsx
145
- const connection = Connect.useController({ domain });
146
- const provisioning = Provision.useController({ domain, requirements, onApplied });
147
- const cleanup = Cleanup.useController({ domain, receiptId, onCleaned });
148
- const verification = Verify.useController({ domain, polling: true });
167
+ const describe = Outcome.useDescribe();
168
+ const words = describe(error, { provider: "Cloudflare" });
149
169
  ```
150
170
 
151
- `approve` authorizes the plan digest and applies it. `reject` records the customer's refusal; the
152
- attempt is terminal and a new plan is needed. `retry` builds a new plan when the reason says the old
153
- one is gone, and re-runs the failed step otherwise.
154
-
155
- ## Presentation
156
-
157
- `DomainKit.Root` takes `messages`, `marks`, `icons`, `theme`, `colorScheme`, and `portalContainer`,
158
- so branding stays in your app. `Messages.Catalog` holds every user-visible string, including a
159
- title and a description per `DomainKit.Error` reason; nothing renders a tag. Provider artwork comes from `marks`,
160
- with the provider's initial as the fallback and no request at render time. The stylesheet is
161
- opt-in and every color is a `--domainkit-*` custom property.
162
-
163
- Every rule ships inside `@layer domainkit`. An unlayered rule of yours already beats every layer, so
164
- plain CSS needs nothing here.
165
-
166
- Once your app uses layers, name the order yourself: a layer's priority is its position in that list,
167
- and a layer you never declare lands wherever it first appears, so `domainkit` can sort under
168
- Tailwind's preflight and the package's buttons render as bare text.
169
-
170
- ```css
171
- @layer theme, base, components, domainkit, utilities;
172
- ```
173
-
174
- `domainkit` sits after `base` so preflight cannot strip it, and before `utilities` so a utility class
175
- still wins. Any layer you declare after it wins the same way:
176
-
177
- ```css
178
- @layer utilities {
179
- [data-domainkit-part="records-panel"] {
180
- border-radius: 0;
181
- }
182
- }
183
- ```
184
-
185
- A failed step renders as `Outcome`: media, the catalog's title and description, and the retry the
186
- flow allows, as a card or on one line. Pass your own parts as children and the words still come from
187
- the catalog.
188
-
189
- ```tsx
190
- <Connect.Outcome controller={connection} layout="inline">
191
- <Outcome.Media variant="default">
192
- <MyIcon />
193
- </Outcome.Media>
194
- <Outcome.Title />
195
- <Outcome.Content />
196
- </Connect.Outcome>
197
- ```
171
+ ## Records
198
172
 
199
- Every dialog and popover takes a `render` prop that replaces the surface with your own.
173
+ `Records.statusOf` answers what one row has to say: the operation a pending plan holds for it, or
174
+ the status the last observation read back. `Records.useCopy` is the clipboard control a value
175
+ needs, and `Records.toZoneFile` and `Records.downloadZoneFile` spell the whole requirement set for a
176
+ customer who edits DNS by hand.
200
177
 
201
178
  ## Next.js
202
179
 
@@ -205,6 +182,7 @@ a client module rather than passing one from a server component.
205
182
 
206
183
  ## Learn more
207
184
 
185
+ - [Registry](https://domain-kit.dev/components/registry)
208
186
  - [React source](https://github.com/AryaLabsHQ/domainkit/tree/main/packages/react)
209
187
  - [Transport contract](https://github.com/AryaLabsHQ/domainkit/blob/main/packages/domainkit/src/Transport.ts)
210
188
  - [Issues](https://github.com/AryaLabsHQ/domainkit/issues)