@yourrealm/sdk 0.0.0-alpha.1378.ce03c8dc

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 (230) hide show
  1. package/LICENSE-EXCEPTION +26 -0
  2. package/README.md +34 -0
  3. package/dist/chunk-3R6KEFOI.js +22 -0
  4. package/dist/chunk-67BDAMYX.js +376 -0
  5. package/dist/chunk-AITGS27R.js +139 -0
  6. package/dist/chunk-CMAEHQ4F.js +6 -0
  7. package/dist/chunk-MLKGABMK.js +9 -0
  8. package/dist/chunk-O3UIK34B.js +1430 -0
  9. package/dist/chunk-VHTUSZUX.js +176 -0
  10. package/dist/cli.js +9006 -0
  11. package/dist/docker.js +54 -0
  12. package/dist/index.js +1053 -0
  13. package/dist/ingress.js +436 -0
  14. package/dist/jsx-runtime.js +2 -0
  15. package/dist/preact.js +22 -0
  16. package/dist/service-url.js +11 -0
  17. package/dist/services.js +213 -0
  18. package/dist/test.js +5069 -0
  19. package/dist/ui.js +254 -0
  20. package/dist/worker-protocol.js +89 -0
  21. package/package.json +112 -0
  22. package/src/app-icon.ts +74 -0
  23. package/src/apply.ts +52 -0
  24. package/src/bundle-ui.ts +211 -0
  25. package/src/bundle.ts +546 -0
  26. package/src/cli/build-dev-landing.ts +84 -0
  27. package/src/cli/build-install-panel.ts +90 -0
  28. package/src/cli/build-runtime.ts +133 -0
  29. package/src/cli/build.ts +63 -0
  30. package/src/cli/dev.ts +107 -0
  31. package/src/cli/down.ts +60 -0
  32. package/src/cli/main.ts +56 -0
  33. package/src/cli/test.ts +61 -0
  34. package/src/cli/validate.ts +37 -0
  35. package/src/compile.ts +100 -0
  36. package/src/create-app.ts +982 -0
  37. package/src/deep-merge.ts +33 -0
  38. package/src/dev/fixture.ts +48 -0
  39. package/src/dev/install-handler.ts +383 -0
  40. package/src/dev/server.ts +968 -0
  41. package/src/dev/stack.ts +108 -0
  42. package/src/dev/state.ts +159 -0
  43. package/src/docker.ts +96 -0
  44. package/src/errors.ts +61 -0
  45. package/src/factory.ts +481 -0
  46. package/src/garage.ts +56 -0
  47. package/src/generator/hash.ts +6 -0
  48. package/src/generator/resolve-refs.ts +207 -0
  49. package/src/grants-builder.ts +100 -0
  50. package/src/grants.ts +260 -0
  51. package/src/home-api.ts +172 -0
  52. package/src/home-cookie.ts +10 -0
  53. package/src/home-registry.ts +120 -0
  54. package/src/home.ts +187 -0
  55. package/src/host-registry.ts +143 -0
  56. package/src/http-header.ts +20 -0
  57. package/src/identifiers.ts +49 -0
  58. package/src/index.ts +66 -0
  59. package/src/info.ts +64 -0
  60. package/src/ini.ts +50 -0
  61. package/src/json.ts +14 -0
  62. package/src/jsx-runtime/index.ts +5 -0
  63. package/src/keys.ts +29 -0
  64. package/src/mysql.ts +56 -0
  65. package/src/pack-build.ts +108 -0
  66. package/src/pack.ts +115 -0
  67. package/src/peer-registry.ts +121 -0
  68. package/src/peer-world.ts +117 -0
  69. package/src/permission-meta.ts +116 -0
  70. package/src/permissions.ts +108 -0
  71. package/src/pkg-root.ts +34 -0
  72. package/src/postgres.ts +79 -0
  73. package/src/preact/index.ts +27 -0
  74. package/src/redis.ts +43 -0
  75. package/src/ref.ts +271 -0
  76. package/src/remote-registry.ts +33 -0
  77. package/src/required-host-kinds.ts +110 -0
  78. package/src/resolve-context.ts +111 -0
  79. package/src/resolve.ts +471 -0
  80. package/src/runtime/docker/compose/compose-schema.ts +118 -0
  81. package/src/runtime/docker/compose/convert-to-docker-compose.ts +278 -0
  82. package/src/runtime/docker/compose/default-hardening.ts +21 -0
  83. package/src/runtime/docker/compose/effects.ts +107 -0
  84. package/src/runtime/docker/compose/index.ts +17 -0
  85. package/src/runtime/docker/compose/labels.ts +39 -0
  86. package/src/runtime/docker/compose/types.ts +78 -0
  87. package/src/runtime/docker/home-proxy-server/index.ts +177 -0
  88. package/src/runtime/docker/home-proxy-server/protocol.ts +53 -0
  89. package/src/runtime/docker/ingress/build-config.ts +819 -0
  90. package/src/runtime/docker/ingress/index.ts +16 -0
  91. package/src/runtime/docker/ingress/types.ts +182 -0
  92. package/src/runtime/docker/log-frames.ts +121 -0
  93. package/src/runtime/docker/logs-server/index.ts +366 -0
  94. package/src/runtime/docker/spawn-server/context-key.ts +82 -0
  95. package/src/runtime/docker/spawn-server/index.ts +492 -0
  96. package/src/runtime/docker/spawn-server/protocol.ts +70 -0
  97. package/src/runtime/docker/spawn-server/spec.ts +122 -0
  98. package/src/runtime/docker/stats-server/disks.ts +161 -0
  99. package/src/runtime/docker/stats-server/index.ts +322 -0
  100. package/src/runtime/home-client/index.ts +71 -0
  101. package/src/runtime/log-line.ts +24 -0
  102. package/src/runtime/logs-client/index.ts +175 -0
  103. package/src/runtime/spawn-client/index.ts +93 -0
  104. package/src/runtime/spawn-client/kaniko.ts +79 -0
  105. package/src/runtime/stats-client/index.ts +65 -0
  106. package/src/runtime/stats-sample.ts +95 -0
  107. package/src/safe-json.ts +37 -0
  108. package/src/sandbox.ts +1209 -0
  109. package/src/schema/build-apps.ts +38 -0
  110. package/src/schema/home.ts +217 -0
  111. package/src/schema/host.ts +792 -0
  112. package/src/schema/inputs.ts +26 -0
  113. package/src/schema/nodes.ts +75 -0
  114. package/src/schema/peer.ts +400 -0
  115. package/src/schema/public-domain.ts +116 -0
  116. package/src/schema/remote.ts +74 -0
  117. package/src/security.ts +594 -0
  118. package/src/serialize.ts +21 -0
  119. package/src/service-definition.ts +284 -0
  120. package/src/service-url.ts +64 -0
  121. package/src/service.ts +39 -0
  122. package/src/services/index.ts +5 -0
  123. package/src/shared.ts +65 -0
  124. package/src/speculative-silo.ts +96 -0
  125. package/src/stable-secret.ts +14 -0
  126. package/src/surfaces.ts +264 -0
  127. package/src/tailwind-bin.ts +56 -0
  128. package/src/test/boot.ts +192 -0
  129. package/src/test/compile.ts +501 -0
  130. package/src/test/describe.ts +117 -0
  131. package/src/test/index.ts +11 -0
  132. package/src/test/ingress-fixture.ts +37 -0
  133. package/src/test/types.ts +318 -0
  134. package/src/trusted-headers.ts +79 -0
  135. package/src/types.ts +126 -0
  136. package/src/ui/components/aside.tsx +96 -0
  137. package/src/ui/components/badge.tsx +42 -0
  138. package/src/ui/components/button.tsx +59 -0
  139. package/src/ui/components/card.tsx +106 -0
  140. package/src/ui/components/checkbox.tsx +37 -0
  141. package/src/ui/components/code-block.tsx +29 -0
  142. package/src/ui/components/combobox.tsx +119 -0
  143. package/src/ui/components/config-field.tsx +284 -0
  144. package/src/ui/components/dialog.tsx +137 -0
  145. package/src/ui/components/domain-editor.tsx +317 -0
  146. package/src/ui/components/domain-list.ts +205 -0
  147. package/src/ui/components/dropdown-menu.tsx +73 -0
  148. package/src/ui/components/empty-state.tsx +51 -0
  149. package/src/ui/components/highlight.ts +59 -0
  150. package/src/ui/components/icons.tsx +101 -0
  151. package/src/ui/components/image-posture.tsx +85 -0
  152. package/src/ui/components/input.tsx +28 -0
  153. package/src/ui/components/label.tsx +16 -0
  154. package/src/ui/components/line-chart.tsx +113 -0
  155. package/src/ui/components/page.tsx +39 -0
  156. package/src/ui/components/permission-row.tsx +262 -0
  157. package/src/ui/components/permission-section.tsx +51 -0
  158. package/src/ui/components/pie-chart.tsx +245 -0
  159. package/src/ui/components/schema-block.tsx +60 -0
  160. package/src/ui/components/separator.tsx +42 -0
  161. package/src/ui/components/sheet.tsx +130 -0
  162. package/src/ui/components/sidebar.tsx +650 -0
  163. package/src/ui/components/silo-picker.tsx +510 -0
  164. package/src/ui/components/skeleton.tsx +13 -0
  165. package/src/ui/components/sonner.tsx +33 -0
  166. package/src/ui/components/switch.tsx +58 -0
  167. package/src/ui/components/table-view.ts +219 -0
  168. package/src/ui/components/table.tsx +506 -0
  169. package/src/ui/components/tabs.tsx +59 -0
  170. package/src/ui/components/timeago.tsx +84 -0
  171. package/src/ui/components/tooltip.tsx +56 -0
  172. package/src/ui/components.json +21 -0
  173. package/src/ui/dev/dev-shell.tsx +145 -0
  174. package/src/ui/home/home-grid.tsx +177 -0
  175. package/src/ui/hooks/use-mobile.ts +19 -0
  176. package/src/ui/index.ts +189 -0
  177. package/src/ui/install/adapter.ts +193 -0
  178. package/src/ui/install/deploy-targets-picker.tsx +185 -0
  179. package/src/ui/install/install-app-panel.tsx +912 -0
  180. package/src/ui/install/install-error.tsx +62 -0
  181. package/src/ui/install/perms-inactive.ts +50 -0
  182. package/src/ui/install/required-fields.ts +37 -0
  183. package/src/ui/install/tabs-form.tsx +557 -0
  184. package/src/ui/tokens.css +279 -0
  185. package/src/ui/utils.ts +78 -0
  186. package/src/ui-error.ts +139 -0
  187. package/src/ui-runtime/dev-landing-entry.tsx +258 -0
  188. package/src/ui-runtime/index.ts +83 -0
  189. package/src/ui-runtime/install-entry.tsx +119 -0
  190. package/src/ui-runtime/mount.ts +367 -0
  191. package/src/ui-runtime/runtime.css +56 -0
  192. package/src/ui-surface.ts +451 -0
  193. package/src/valkey.ts +43 -0
  194. package/src/worker/extract-tar.ts +125 -0
  195. package/src/worker/identity.ts +183 -0
  196. package/src/worker/index.ts +16 -0
  197. package/src/worker/public-ip.ts +92 -0
  198. package/src/worker/queue.ts +54 -0
  199. package/src/worker/reconcile.ts +127 -0
  200. package/src/worker/start-worker.ts +227 -0
  201. package/src/worker/ui-proxy.ts +335 -0
  202. package/src/worker/uplink.ts +523 -0
  203. package/src/worker/validate.ts +34 -0
  204. package/src/worker/verify-relay.ts +102 -0
  205. package/src/worker-protocol/app-dirs.ts +94 -0
  206. package/src/worker-protocol/home-proxy-paths.ts +76 -0
  207. package/src/worker-protocol/index.ts +55 -0
  208. package/src/worker-protocol/ingress.ts +166 -0
  209. package/src/worker-protocol/logs-socket-path.ts +50 -0
  210. package/src/worker-protocol/networks.ts +39 -0
  211. package/src/worker-protocol/node-version.ts +70 -0
  212. package/src/worker-protocol/silo-paths.ts +114 -0
  213. package/src/worker-protocol/spawn-paths.ts +165 -0
  214. package/src/worker-protocol/stats-socket-path.ts +40 -0
  215. package/src/worker-protocol/update-files.ts +77 -0
  216. package/src/worker-protocol/uplink/constants.ts +86 -0
  217. package/src/worker-protocol/uplink/errors.ts +61 -0
  218. package/src/worker-protocol/uplink/frame.ts +114 -0
  219. package/src/worker-protocol/uplink/identity.ts +40 -0
  220. package/src/worker-protocol/uplink/index.ts +63 -0
  221. package/src/worker-protocol/uplink/methods.ts +462 -0
  222. package/src/worker-protocol/uplink/mux.ts +646 -0
  223. package/src/worker-protocol/uplink/protocol.ts +11 -0
  224. package/src/worker-protocol/uplink/rpc.ts +309 -0
  225. package/src/worker-protocol/uplink/session.ts +540 -0
  226. package/src/worker-protocol/uplink/test-support.ts +188 -0
  227. package/src/worker-protocol/worker-mount.ts +27 -0
  228. package/src/worker-protocol/worker-state.ts +190 -0
  229. package/src/xml.ts +49 -0
  230. package/src/yaml.ts +15 -0
@@ -0,0 +1,262 @@
1
+ import type { ComponentChildren } from "preact";
2
+ import { useState } from "preact/hooks";
3
+ import { ArrowSquareOutIcon, QuestionIcon, resolveIcon } from "./icons.tsx";
4
+ import { Checkbox } from "./checkbox.tsx";
5
+ import { Label } from "./label.tsx";
6
+ import { Switch } from "./switch.tsx";
7
+ import { Tooltip, TooltipTrigger, TooltipContent } from "./tooltip.tsx";
8
+ import { permissionMeta, type PermissionKind } from "../../permissions.ts";
9
+
10
+ const ICON_SIZE = "w-[22px] h-[22px]";
11
+
12
+ // Pass the raw string verbatim. Coercion happens server-side via the
13
+ // grantSchema (z.coerce.number()...), so an empty input stays empty and
14
+ // gets rejected at sanitizeGrants time, not silently coerced to 0 here.
15
+ // type="number" + min/max stay for browser-level steppers and validation
16
+ // hints; the value still flows as a string.
17
+ function PortInput({ value, onChange }: { value: string | number; onChange: (v: string) => void }) {
18
+ return (
19
+ <span className="bg-surface-light shadow-sm rounded-lg px-1.5 py-0.5 inline-flex ml-1.5">
20
+ <input
21
+ type="number"
22
+ value={value}
23
+ onChange={(e) => onChange(e.currentTarget.value)}
24
+ min={1}
25
+ max={65535}
26
+ className="bg-transparent border-none outline-none text-sm font-mono font-bold text-text-body w-[50px] text-center [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
27
+ />
28
+ </span>
29
+ );
30
+ }
31
+
32
+ function buildTitle(kind: PermissionKind, name: string, label?: string): string {
33
+ if (!label) return name;
34
+ // host.port renders the port number in a separate PortInput pill, so the
35
+ // title shouldn't repeat it.
36
+ if (kind === "host.port") return name;
37
+ return `${name} ${label}`;
38
+ }
39
+
40
+ function PermissionRow({
41
+ kind,
42
+ reason,
43
+ required,
44
+ checked,
45
+ onCheckedChange,
46
+ label,
47
+ port,
48
+ onPortChange,
49
+ control,
50
+ url,
51
+ choices,
52
+ selectedChoices,
53
+ onSelectedChoicesChange,
54
+ scopes,
55
+ networks,
56
+ }: {
57
+ kind: PermissionKind;
58
+ reason: string;
59
+ /** Omit to hide the required/optional badge (e.g. when the parent
60
+ * optional section is toggled off and requirements don't apply). */
61
+ required?: boolean;
62
+ checked: boolean;
63
+ onCheckedChange: (v: boolean) => void;
64
+ label?: string;
65
+ port?: string | number;
66
+ onPortChange?: (v: string) => void;
67
+ /**
68
+ * Per-kind control rendered under the reason. One slot rather than a
69
+ * prop per kind, so a kind with a richer choice than a text field
70
+ * (host.silo's picker) doesn't teach this component - or the docs site
71
+ * that also renders it - what a Silo is.
72
+ */
73
+ control?: ComponentChildren;
74
+ url?: string;
75
+ /**
76
+ * Sub-capabilities the admin can toggle independently. Each entry
77
+ * carries a name, an optional per-choice reason shown next to the
78
+ * checkbox, and an optional `required` flag that locks the checkbox
79
+ * on (and hides it from the Select-all deselect behavior).
80
+ */
81
+ choices?: Array<{ name: string; reason?: string; required?: boolean }>;
82
+ /** Controlled list of which choice names are currently selected. */
83
+ selectedChoices?: string[];
84
+ onSelectedChoicesChange?: (next: string[]) => void;
85
+ scopes?: string[];
86
+ networks?: string[];
87
+ }) {
88
+ const meta = permissionMeta(kind);
89
+ const danger = meta.danger === true;
90
+ const title = buildTitle(kind, meta.name, label);
91
+ const Icon = resolveIcon(meta.icon);
92
+
93
+ const [portValue, setPortValue] = useState<string | number>(port ?? "");
94
+
95
+ const handlePortChange = onPortChange ?? setPortValue;
96
+ const currentPort = onPortChange ? (port ?? "") : portValue;
97
+
98
+ // Uncontrolled fallback: callers that pass `choices` without a matching
99
+ // `selectedChoices` + `onSelectedChoicesChange` pair (e.g. the docs-site
100
+ // PermissionPreview) still get a working toggle via internal state.
101
+ const [internalSelected, setInternalSelected] = useState<string[]>([]);
102
+ const isControlled = selectedChoices !== undefined && onSelectedChoicesChange !== undefined;
103
+ const effectiveSelected = isControlled ? (selectedChoices ?? []) : internalSelected;
104
+ const selectedSet = new Set(effectiveSelected);
105
+ const allNames = choices?.map((c) => c.name) ?? [];
106
+ const requiredSet = new Set(choices?.filter((c) => c.required === true).map((c) => c.name) ?? []);
107
+ const allChoicesSelected =
108
+ choices != null && choices.length > 0 && allNames.every((n) => selectedSet.has(n));
109
+
110
+ const emit = (next: string[]) => {
111
+ if (isControlled) onSelectedChoicesChange!(next);
112
+ else setInternalSelected(next);
113
+ };
114
+
115
+ const handleSelectAll = (v: boolean) => {
116
+ if (!choices) return;
117
+ // Deselect-all keeps required choices checked - they're locked and
118
+ // can't be turned off at the row level.
119
+ emit(v ? [...allNames] : allNames.filter((n) => requiredSet.has(n)));
120
+ };
121
+
122
+ const handleSelect = (choice: string, v: boolean) => {
123
+ if (!choices) return;
124
+ if (requiredSet.has(choice)) return; // locked, ignore attempts to toggle
125
+ const next = new Set(selectedSet);
126
+ if (v) next.add(choice);
127
+ else next.delete(choice);
128
+ emit(allNames.filter((n) => next.has(n)));
129
+ };
130
+
131
+ return (
132
+ <div className="bg-card rounded-2xl shadow p-3.5 px-4 flex items-start gap-3">
133
+ <span className={danger ? "text-danger" : "text-perm"}>
134
+ <Icon className={ICON_SIZE} />
135
+ </span>
136
+ <div className="flex-1 min-w-0">
137
+ <div className="flex items-center gap-2">
138
+ <h3 className={`text-sm font-bold ${danger ? "text-danger-deep" : "text-foreground"}`}>
139
+ {title}
140
+ {port != null && <PortInput value={currentPort} onChange={handlePortChange} />}
141
+ </h3>
142
+ {required !== undefined && (
143
+ <span
144
+ className={`text-[10px] font-bold px-[7px] py-0.5 rounded uppercase tracking-wide ${
145
+ danger ? "text-danger bg-danger-bg" : "text-muted-foreground bg-surface-light"
146
+ }`}
147
+ >
148
+ {required ? "required" : "optional"}
149
+ </span>
150
+ )}
151
+ <Tooltip>
152
+ <TooltipTrigger className="opacity-50 hover:opacity-100 transition-opacity">
153
+ <QuestionIcon
154
+ className={`w-[15px] h-[15px] ${danger ? "text-danger" : "text-perm"}`}
155
+ />
156
+ </TooltipTrigger>
157
+ <TooltipContent side="top" className="max-w-xs">
158
+ {meta.helpText}
159
+ </TooltipContent>
160
+ </Tooltip>
161
+ </div>
162
+ <p className="text-xs mt-1 leading-snug text-foreground">{reason}</p>
163
+ {control}
164
+ {scopes && scopes.length > 0 && (
165
+ <div className="flex gap-1.5 mt-1.5 flex-wrap">
166
+ {scopes.map((scope) => (
167
+ <span
168
+ key={scope}
169
+ className="text-[10px] font-mono font-semibold text-muted-foreground bg-surface-light px-2 py-0.5 rounded"
170
+ >
171
+ {scope}
172
+ </span>
173
+ ))}
174
+ </div>
175
+ )}
176
+ {networks && networks.length > 0 && (
177
+ <div className="flex gap-1.5 mt-1.5 flex-wrap">
178
+ {networks.map((net) => (
179
+ <span
180
+ key={net}
181
+ className="text-[10px] font-mono font-semibold text-muted-foreground bg-surface-light px-2 py-0.5 rounded"
182
+ >
183
+ {net}
184
+ </span>
185
+ ))}
186
+ </div>
187
+ )}
188
+ {url && (
189
+ <a
190
+ href={url}
191
+ target="_blank"
192
+ rel="noopener noreferrer"
193
+ className="text-[11px] mt-1 font-mono text-muted-foreground truncate block hover:underline group/url"
194
+ >
195
+ {url}
196
+ <ArrowSquareOutIcon className="w-3 h-3 inline ml-1 opacity-0 group-hover/url:opacity-100 transition-opacity" />
197
+ </a>
198
+ )}
199
+ {choices && (
200
+ <div className="flex flex-col gap-2 mt-3 pt-3 border-t border-border/50">
201
+ {choices.map((choice) => {
202
+ const isRequired = choice.required === true;
203
+ return (
204
+ <div key={choice.name} className="flex items-start gap-2">
205
+ <Checkbox
206
+ checked={isRequired || selectedSet.has(choice.name)}
207
+ disabled={isRequired}
208
+ onCheckedChange={(details: { checked: boolean | "indeterminate" }) =>
209
+ handleSelect(choice.name, details.checked === true)
210
+ }
211
+ id={`perm-${choice.name}`}
212
+ className="mt-0.5"
213
+ />
214
+ <div className="flex flex-col gap-0.5 min-w-0">
215
+ <Label
216
+ htmlFor={`perm-${choice.name}`}
217
+ className={`text-xs ${isRequired ? "text-muted-foreground" : ""}`}
218
+ >
219
+ {choice.name}
220
+ {isRequired && (
221
+ <span className="ml-1.5 text-[10px] uppercase tracking-wide opacity-70">
222
+ required
223
+ </span>
224
+ )}
225
+ </Label>
226
+ {choice.reason && (
227
+ <p className="text-[11px] leading-snug text-muted-foreground">
228
+ {choice.reason}
229
+ </p>
230
+ )}
231
+ </div>
232
+ </div>
233
+ );
234
+ })}
235
+ {choices.some((c) => c.required !== true) && (
236
+ <div className="flex items-center gap-2">
237
+ <Checkbox
238
+ checked={allChoicesSelected}
239
+ onCheckedChange={(details: { checked: boolean | "indeterminate" }) =>
240
+ handleSelectAll(details.checked === true)
241
+ }
242
+ id="perm-select-all"
243
+ />
244
+ <Label htmlFor="perm-select-all" className="text-xs text-muted-foreground">
245
+ Select all
246
+ </Label>
247
+ </div>
248
+ )}
249
+ </div>
250
+ )}
251
+ </div>
252
+ <Switch
253
+ checked={checked}
254
+ onCheckedChange={(d: { checked: boolean }) => onCheckedChange(d.checked === true)}
255
+ label={title}
256
+ labelClassName="sr-only"
257
+ />
258
+ </div>
259
+ );
260
+ }
261
+
262
+ export { PermissionRow };
@@ -0,0 +1,51 @@
1
+ import type { ComponentChildren } from "preact";
2
+ import { ShieldIcon } from "./icons.tsx";
3
+ import { cn } from "../utils.ts";
4
+
5
+ function SectionLabel({
6
+ icon,
7
+ children,
8
+ className,
9
+ }: {
10
+ icon?: ComponentChildren;
11
+ children: ComponentChildren;
12
+ className?: string;
13
+ }) {
14
+ return (
15
+ <div
16
+ className={cn(
17
+ "text-[11px] font-bold text-muted-foreground uppercase tracking-wide flex items-center gap-1.5",
18
+ className,
19
+ )}
20
+ >
21
+ {icon}
22
+ {children}
23
+ </div>
24
+ );
25
+ }
26
+
27
+ function PermissionSection({
28
+ danger,
29
+ label,
30
+ children,
31
+ }: {
32
+ danger?: boolean;
33
+ label?: ComponentChildren;
34
+ children: ComponentChildren;
35
+ }) {
36
+ return (
37
+ <div
38
+ className={`${danger ? "bg-danger-bg rounded-3xl shadow-clay-danger" : "bg-perm-bg rounded-3xl shadow-clay-amber"} p-4 flex flex-col gap-3`}
39
+ >
40
+ <SectionLabel
41
+ icon={<ShieldIcon className={`w-4 h-4 ${danger ? "text-danger" : "text-perm"}`} />}
42
+ className={danger ? "text-danger" : "text-perm pl-0.5"}
43
+ >
44
+ {label ?? (danger ? "Dangerous permissions" : "Permissions")}
45
+ </SectionLabel>
46
+ {children}
47
+ </div>
48
+ );
49
+ }
50
+
51
+ export { PermissionSection, SectionLabel };
@@ -0,0 +1,245 @@
1
+ // Pie / donut chart. A stroke-dasharray ring: one <circle> per segment, drawn
2
+ // as an arc by its dash length + accumulated offset, so there's no arc-path
3
+ // math and no dependency. Colors default to the clay `--chart-*` palette, so it
4
+ // themes with the surface (and flips in dark mode) — SVG references the CSS var
5
+ // directly, no canvas, no redraw hook.
6
+ //
7
+ // Sizing: pass `size` for a fixed diameter, or omit it to fill the parent — it
8
+ // measures the box and draws the largest square that fits (the center label is
9
+ // font-scaled to the hole, so it grows with the donut). Pass `thickness` near
10
+ // half the diameter for a solid pie. Opt into a `legend` and a hover `tooltip`
11
+ // (dims the other slices, floats above the cursor); hovering a legend row
12
+ // highlights its slice too.
13
+
14
+ import type { ComponentChildren } from "preact";
15
+ import { useLayoutEffect, useRef, useState } from "preact/hooks";
16
+ import { cn } from "../utils.ts";
17
+
18
+ export interface PieSegment {
19
+ label: string;
20
+ value: number;
21
+ /** Any CSS color; defaults to the `--chart-N` palette, cycled by index. */
22
+ color?: string;
23
+ /** Pre-formatted value for the legend + tooltip (e.g. "1.9 GB"). Falls back to `value`. */
24
+ valueLabel?: string;
25
+ }
26
+
27
+ const PALETTE = [
28
+ "var(--chart-1)",
29
+ "var(--chart-2)",
30
+ "var(--chart-3)",
31
+ "var(--chart-4)",
32
+ "var(--chart-5)",
33
+ "var(--chart-6)",
34
+ "var(--chart-7)",
35
+ "var(--chart-8)",
36
+ ];
37
+
38
+ function colorOf(seg: PieSegment, i: number): string {
39
+ return seg.color ?? PALETTE[i % PALETTE.length]!;
40
+ }
41
+
42
+ interface PieChartProps {
43
+ data: PieSegment[];
44
+ /** Outer diameter in px. Omit to fill (and measure) the parent, staying square. */
45
+ size?: number;
46
+ /** Ring width in px. Default 20% of the diameter. Pass ~half the diameter for a solid pie. */
47
+ thickness?: number;
48
+ /** Big value in the hole (donut only). */
49
+ centerLabel?: ComponentChildren;
50
+ /** Caption under the center label. */
51
+ centerCaption?: ComponentChildren;
52
+ /** Ring shown behind the segments — the unfilled remainder reads as this. */
53
+ trackColor?: string;
54
+ /** Render a legend (dot + label + value) beside the donut. */
55
+ legend?: boolean;
56
+ /** Hover a slice → dim the others and float a tooltip above the cursor. */
57
+ tooltip?: boolean;
58
+ className?: string;
59
+ }
60
+
61
+ export function PieChart({
62
+ data,
63
+ size,
64
+ thickness,
65
+ centerLabel,
66
+ centerCaption,
67
+ trackColor = "var(--muted)",
68
+ legend = false,
69
+ tooltip = false,
70
+ className,
71
+ }: PieChartProps) {
72
+ // Hover keyed by the segment's label, not its index: the loader swaps `data`
73
+ // out on a refetch (and may re-sort it), so an index would point at the wrong
74
+ // slice — or out of range — while the cursor rests. A label follows the slice.
75
+ const [hoveredLabel, setHoveredLabel] = useState<string | null>(null);
76
+ const [pos, setPos] = useState<{ x: number; y: number } | null>(null);
77
+ const rootRef = useRef<HTMLDivElement>(null);
78
+ const [box, setBox] = useState<{ w: number; h: number } | null>(null);
79
+ const responsive = size == null;
80
+
81
+ // Measure the fill container. useLayoutEffect fires before paint, so the first
82
+ // real frame already has a size — no empty flash.
83
+ useLayoutEffect(() => {
84
+ if (!responsive) return;
85
+ const el = rootRef.current;
86
+ if (!el) return;
87
+ const read = () => setBox({ w: el.clientWidth, h: el.clientHeight });
88
+ read();
89
+ const ro = new ResizeObserver(read);
90
+ ro.observe(el);
91
+ return () => ro.disconnect();
92
+ }, [responsive]);
93
+
94
+ // Donut diameter: the fixed `size`, or the largest square that fits the
95
+ // measured parent — leaving half the width for the legend when there is one.
96
+ const s =
97
+ size ?? (box ? Math.max(0, Math.floor(Math.min(box.h, legend ? box.w * 0.5 : box.w))) : 0);
98
+
99
+ const w = thickness ?? s * 0.2;
100
+ const r = (s - w) / 2; // centerline radius, so the stroke stays inside the box
101
+ const hole = s - 2 * w; // inner diameter — the center label has to fit inside this
102
+ const circumference = 2 * Math.PI * r;
103
+ const c = s / 2;
104
+ const total = data.reduce((sum, d) => sum + Math.max(0, d.value), 0);
105
+ const hoverable = legend || tooltip;
106
+ const hovered = hoveredLabel != null ? data.findIndex((d) => d.label === hoveredLabel) : -1;
107
+ const active = hovered >= 0 ? data[hovered] : undefined;
108
+
109
+ let consumed = 0;
110
+ const donut = (
111
+ <div
112
+ class="relative shrink-0"
113
+ style={{ width: s, height: s }}
114
+ onPointerMove={tooltip ? (e) => setPos({ x: e.offsetX, y: e.offsetY }) : undefined}
115
+ onPointerLeave={
116
+ hoverable
117
+ ? () => {
118
+ setHoveredLabel(null);
119
+ setPos(null);
120
+ }
121
+ : undefined
122
+ }
123
+ >
124
+ <svg width={s} height={s} viewBox={`0 0 ${s} ${s}`}>
125
+ {/* rotate -90° around the center so segments start at 12 o'clock */}
126
+ <g transform={`rotate(-90 ${c} ${c})`}>
127
+ <circle
128
+ cx={c}
129
+ cy={c}
130
+ r={r}
131
+ fill="none"
132
+ stroke={trackColor}
133
+ stroke-width={w}
134
+ pointer-events="none"
135
+ />
136
+ {total > 0 &&
137
+ data.map((d, i) => {
138
+ const len = (Math.max(0, d.value) / total) * circumference;
139
+ const dash = (
140
+ <circle
141
+ key={i}
142
+ cx={c}
143
+ cy={c}
144
+ r={r}
145
+ fill="none"
146
+ stroke={colorOf(d, i)}
147
+ stroke-width={w}
148
+ stroke-dasharray={`${len} ${circumference - len}`}
149
+ stroke-dashoffset={-consumed}
150
+ opacity={hovered >= 0 && hovered !== i ? 0.3 : 1}
151
+ style={{ transition: "opacity 120ms", cursor: hoverable ? "pointer" : undefined }}
152
+ onPointerEnter={hoverable ? () => setHoveredLabel(d.label) : undefined}
153
+ />
154
+ );
155
+ consumed += len;
156
+ return dash;
157
+ })}
158
+ </g>
159
+ </svg>
160
+ {(centerLabel != null || centerCaption != null) && (
161
+ // Confined to the hole (inset by the ring width) and font-scaled to it,
162
+ // so the label always clears the ring instead of crowding it.
163
+ <div
164
+ class="absolute flex flex-col items-center justify-center gap-1 text-center"
165
+ style={{ inset: w, pointerEvents: "none" }}
166
+ >
167
+ {centerLabel != null && (
168
+ <div
169
+ class="font-semibold leading-none tabular-nums"
170
+ style={{ fontSize: Math.round(hole * 0.24) }}
171
+ >
172
+ {centerLabel}
173
+ </div>
174
+ )}
175
+ {centerCaption != null && (
176
+ <div
177
+ class="leading-none text-muted-foreground"
178
+ style={{ fontSize: Math.max(9, Math.round(hole * 0.13)) }}
179
+ >
180
+ {centerCaption}
181
+ </div>
182
+ )}
183
+ </div>
184
+ )}
185
+ {tooltip && active && pos && (
186
+ // Centered above the cursor (outer div positions, inner is the pill) with
187
+ // a caret pointing down at it — symmetric, so it rarely clips on an edge.
188
+ <div
189
+ class="pointer-events-none absolute z-10"
190
+ style={{ left: pos.x, top: pos.y, transform: "translate(-50%, calc(-100% - 10px))" }}
191
+ >
192
+ <div class="relative flex items-center gap-1.5 whitespace-nowrap rounded-md bg-foreground px-2.5 py-1.5 text-xs text-background">
193
+ <span
194
+ class="size-2 shrink-0 rounded-full"
195
+ style={{ background: colorOf(active, hovered) }}
196
+ />
197
+ <span class="font-medium">{active.label}</span>
198
+ <span class="tabular-nums text-background/70">{active.valueLabel ?? active.value}</span>
199
+ {total > 0 && (
200
+ <span class="tabular-nums text-background/50">
201
+ {Math.round((Math.max(0, active.value) / total) * 100)}%
202
+ </span>
203
+ )}
204
+ <span class="absolute left-1/2 top-full size-2 -translate-x-1/2 -translate-y-1/2 rotate-45 rounded-[2px] bg-foreground" />
205
+ </div>
206
+ </div>
207
+ )}
208
+ </div>
209
+ );
210
+
211
+ const rootClass = cn(
212
+ responsive
213
+ ? "flex h-full w-full items-center justify-center"
214
+ : legend
215
+ ? "flex items-center"
216
+ : "inline-block",
217
+ legend && "gap-4",
218
+ className,
219
+ );
220
+
221
+ return (
222
+ <div ref={responsive ? rootRef : undefined} class={rootClass} data-slot="pie-chart">
223
+ {s > 0 && donut}
224
+ {legend && s > 0 && (
225
+ <ul class="flex min-w-0 flex-col gap-1 text-xs">
226
+ {data.map((d, i) => (
227
+ <li
228
+ key={i}
229
+ class="flex items-center gap-1.5"
230
+ style={{ opacity: hovered >= 0 && hovered !== i ? 0.4 : 1 }}
231
+ onPointerEnter={() => setHoveredLabel(d.label)}
232
+ onPointerLeave={() => setHoveredLabel(null)}
233
+ >
234
+ <span class="size-2 shrink-0 rounded-full" style={{ background: colorOf(d, i) }} />
235
+ <span class="truncate">{d.label}</span>
236
+ <span class="ml-auto shrink-0 tabular-nums text-muted-foreground">
237
+ {d.valueLabel ?? d.value}
238
+ </span>
239
+ </li>
240
+ ))}
241
+ </ul>
242
+ )}
243
+ </div>
244
+ );
245
+ }
@@ -0,0 +1,60 @@
1
+ import type { ComponentChildren } from "preact";
2
+ import { GearIcon } from "./icons.tsx";
3
+ import { SectionLabel } from "./permission-section.tsx";
4
+ import { Switch } from "./switch.tsx";
5
+
6
+ // Visual chrome for an `r.tab(...)` block in the install/edit form.
7
+ // Both Home's TabsForm and the docs site's SchemaPreviews render
8
+ // through this so the two can't drift.
9
+ export function TabBlock({ name, children }: { name: string; children: ComponentChildren }) {
10
+ return (
11
+ <section className="bg-trust-bg rounded-3xl shadow-clay-blue p-4 flex flex-col gap-3">
12
+ <SectionLabel
13
+ icon={<GearIcon className="w-4 h-4 text-trust" />}
14
+ className="text-trust pl-0.5"
15
+ >
16
+ {name}
17
+ </SectionLabel>
18
+ {children}
19
+ </section>
20
+ );
21
+ }
22
+
23
+ // Visual chrome for an `r.section(...)` block. Pass `optional` to render
24
+ // the on/off toggle; controlled via `on` + `onChange`. Children are
25
+ // always rendered regardless of `on` - the toggle only flips the section's
26
+ // declared state, leaves keep showing so the user can still see what's
27
+ // inside.
28
+ export function SectionBlock({
29
+ name,
30
+ optional,
31
+ on,
32
+ onChange,
33
+ disabled,
34
+ children,
35
+ }: {
36
+ name: string;
37
+ optional?: boolean;
38
+ on?: boolean;
39
+ onChange?: (next: boolean) => void;
40
+ disabled?: boolean;
41
+ children: ComponentChildren;
42
+ }) {
43
+ return (
44
+ <fieldset className="bg-card rounded-2xl shadow p-4 flex flex-col gap-3">
45
+ <legend className="flex items-center gap-2 text-sm font-bold text-foreground px-1">
46
+ <span>{name}</span>
47
+ {optional && (
48
+ <Switch
49
+ checked={on === true}
50
+ onCheckedChange={(d: { checked: boolean }) => onChange?.(d.checked === true)}
51
+ disabled={disabled}
52
+ label={name}
53
+ labelClassName="sr-only"
54
+ />
55
+ )}
56
+ </legend>
57
+ {children}
58
+ </fieldset>
59
+ );
60
+ }
@@ -0,0 +1,42 @@
1
+ import { cn, type ElementProps } from "../utils.ts";
2
+
3
+ function Separator({
4
+ className,
5
+ orientation = "horizontal",
6
+ children,
7
+ ...props
8
+ }: ElementProps<"div"> & {
9
+ orientation?: "horizontal" | "vertical";
10
+ }) {
11
+ if (children) {
12
+ return (
13
+ <div
14
+ data-slot="separator"
15
+ role="separator"
16
+ className={cn("flex items-center gap-4", className)}
17
+ {...props}
18
+ >
19
+ <div className="h-px flex-1 bg-gradient-to-r from-transparent to-border" />
20
+ <span className="text-sm font-bold uppercase tracking-wider text-muted-foreground">
21
+ {children}
22
+ </span>
23
+ <div className="h-px flex-1 bg-gradient-to-l from-transparent to-border" />
24
+ </div>
25
+ );
26
+ }
27
+
28
+ return (
29
+ <div
30
+ data-slot="separator"
31
+ role="separator"
32
+ data-orientation={orientation}
33
+ className={cn(
34
+ "shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=horizontal]:bg-gradient-to-r data-[orientation=horizontal]:from-transparent data-[orientation=horizontal]:via-border data-[orientation=horizontal]:to-transparent data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch data-[orientation=vertical]:bg-border",
35
+ className,
36
+ )}
37
+ {...props}
38
+ />
39
+ );
40
+ }
41
+
42
+ export { Separator };