@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,506 @@
1
+ import type { ComponentChildren, JSX } from "preact";
2
+ import { useState } from "preact/hooks";
3
+
4
+ import { cn, type ElementProps } from "../utils.ts";
5
+ import { buttonVariants } from "./button.tsx";
6
+ import {
7
+ CaretDownIcon,
8
+ CaretLeftIcon,
9
+ CaretRightIcon,
10
+ CaretUpDownIcon,
11
+ CaretUpIcon,
12
+ } from "./icons.tsx";
13
+ import {
14
+ clampPage,
15
+ pageCount,
16
+ pageSlice,
17
+ pageSummary,
18
+ pageWindow,
19
+ type SortDirection,
20
+ type SortState,
21
+ type SortValue,
22
+ sortRows,
23
+ toggleSort,
24
+ } from "./table-view.ts";
25
+
26
+ // Data table, ported from shadcn's Table (`data-slot="table"`). Plain HTML —
27
+ // there is no Ark primitive to wrap — so the port is the styling only, plus
28
+ // three additions shadcn has no answer for.
29
+ //
30
+ // 1. `<Table>` is two boxes: an outer card that clips, and an inner viewport
31
+ // that scrolls sideways. Scrolling has to live inside the card or the page
32
+ // body scrolls sideways instead — and the card has to be its own box or
33
+ // `footer` (the pager) has nowhere to sit that isn't inside the scroller.
34
+ // 2. `truncate` on a cell + `width` on a head. Auto table layout hands a long
35
+ // unbroken value all the width it asks for and wraps the short columns
36
+ // instead; `max-w-0` on the cell caps that greed and the inner div clips,
37
+ // while `width` on the head states the intent up front. `layout="fixed"`
38
+ // is the stronger form when every column is known.
39
+ // 3. Sorting and paging. Both are controlled: the caller owns the state, and
40
+ // `TableHead` / `TablePagination` render anchors or buttons depending on
41
+ // which callback they were handed. A plain Fresh route that never hydrates
42
+ // (`/admin/audit`) drives them with `<a href>` and query params; a widget
43
+ // or island drives them with `useTableView`. The rules behind both live in
44
+ // `table-view.ts`, so neither path owns a decision the other doesn't have.
45
+
46
+ type TableProps = ElementProps<"table"> & {
47
+ /** `fixed` sizes columns from the header row alone, ignoring cell content. */
48
+ layout?: "auto" | "fixed";
49
+ /**
50
+ * Rendered under the scroll viewport, inside the card. A `<TablePagination>`
51
+ * in practice.
52
+ *
53
+ * Not a `<tfoot>`: a tfoot lives inside the scroll viewport, so on a table
54
+ * wide enough to scroll the pager sits off the right edge until you find it,
55
+ * and under `layout="fixed"` it inherits the header's column widths, so a
56
+ * full-width bar needs a `colSpan` every call site has to keep in step.
57
+ */
58
+ footer?: ComponentChildren;
59
+ /**
60
+ * Fill the parent's height and scroll inside, with the header pinned.
61
+ *
62
+ * Sticky is folded in here rather than being its own prop because the card
63
+ * clips: a sticky header is only reachable when the viewport is the thing
64
+ * that scrolls. A header that scrolls away in a filled table leaves a screen
65
+ * of unlabelled columns.
66
+ */
67
+ fill?: boolean;
68
+ /** `compact` tightens cell padding and drops to `text-xs`, for dense monitors. */
69
+ density?: "default" | "compact";
70
+ };
71
+
72
+ function Table({
73
+ className,
74
+ layout = "auto",
75
+ density = "default",
76
+ fill,
77
+ footer,
78
+ ...props
79
+ }: TableProps) {
80
+ return (
81
+ // The split is visually a no-op for a plain table: `overflow-x: auto`
82
+ // already forces `overflow-y` to compute to `auto`, and `overflow: hidden`
83
+ // here clips at the same padding box — the rounded corners still cut the
84
+ // header's tint, and the scrollbar still sits inside the border.
85
+ <div
86
+ data-slot="table-card"
87
+ className={cn(
88
+ "w-full overflow-hidden rounded-2xl border border-border bg-card",
89
+ fill && "flex h-full min-h-0 flex-col",
90
+ )}
91
+ >
92
+ <div
93
+ data-slot="table-container"
94
+ className={cn("w-full overflow-x-auto", fill && "min-h-0 flex-1 overflow-auto")}
95
+ >
96
+ <table
97
+ data-slot="table"
98
+ data-layout={layout}
99
+ data-density={density}
100
+ data-fill={fill ? "true" : undefined}
101
+ className={cn(
102
+ "w-full text-sm data-[layout=fixed]:table-fixed data-[density=compact]:text-xs",
103
+ className,
104
+ )}
105
+ {...props}
106
+ />
107
+ </div>
108
+ {footer}
109
+ </div>
110
+ );
111
+ }
112
+
113
+ function TableHeader({ className, ...props }: ElementProps<"thead">) {
114
+ return (
115
+ <thead
116
+ data-slot="table-header"
117
+ className={cn(
118
+ "bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground",
119
+ // Under `fill` the viewport is the scroller, so the header pins to it.
120
+ // Read off the table's `data-fill` rather than a prop, which keeps the
121
+ // three leaf components stateless and prop-free.
122
+ "[[data-fill]_&]:sticky [[data-fill]_&]:top-0 [[data-fill]_&]:z-10 [[data-fill]_&]:bg-muted/60 [[data-fill]_&]:backdrop-blur",
123
+ className,
124
+ )}
125
+ {...props}
126
+ />
127
+ );
128
+ }
129
+
130
+ function TableBody({ className, ...props }: ElementProps<"tbody">) {
131
+ return <tbody data-slot="table-body" className={cn(className)} {...props} />;
132
+ }
133
+
134
+ function TableRow({ className, ...props }: ElementProps<"tr">) {
135
+ return (
136
+ <tr
137
+ data-slot="table-row"
138
+ className={cn("border-t border-border first:border-t-0", className)}
139
+ {...props}
140
+ />
141
+ );
142
+ }
143
+
144
+ type TableHeadProps = ElementProps<"th"> & {
145
+ /** Any CSS length or percentage. A hint under `auto`, binding under `fixed`. */
146
+ width?: string;
147
+ /** This column's direction, or `null` for "sortable, but not the sort". */
148
+ sorted?: SortDirection | null;
149
+ /** Renders the label as an `<a>`. The only form that works unhydrated. */
150
+ sortHref?: string;
151
+ /** Renders the label as a `<button>`. Ignored when `sortHref` is given. */
152
+ onSort?: (event: JSX.TargetedMouseEvent<HTMLElement>) => void;
153
+ };
154
+
155
+ const ARIA_SORT = { asc: "ascending", desc: "descending" } as const;
156
+
157
+ function TableHead({
158
+ className,
159
+ width,
160
+ style,
161
+ sorted = null,
162
+ sortHref,
163
+ onSort,
164
+ children,
165
+ ...props
166
+ }: TableHeadProps) {
167
+ const sortable = sortHref !== undefined || onSort !== undefined;
168
+ // The trigger is the label, not the `<th>`: a click target covering a 14rem
169
+ // cell fires on dead space, and a `<th>` is not keyboard reachable however
170
+ // many handlers it carries.
171
+ //
172
+ // `-mx-1.5` cancels the trigger's own padding, so a sortable label sits on
173
+ // the same pixel as a plain one in the header beside it.
174
+ const triggerClass = cn(
175
+ "group/sort -mx-1.5 inline-flex max-w-full cursor-pointer items-center gap-1 rounded-md px-1.5 py-0.5 text-left align-middle outline-none transition-colors hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50",
176
+ sorted && "text-foreground",
177
+ );
178
+ const caret =
179
+ sorted === "asc" ? (
180
+ <CaretUpIcon className="size-3 shrink-0" />
181
+ ) : sorted === "desc" ? (
182
+ <CaretDownIcon className="size-3 shrink-0" />
183
+ ) : (
184
+ // Revealed on hover rather than always drawn: six permanent arrows compete
185
+ // with the one that says what the sort actually is. `opacity-0` and not
186
+ // `hidden`, so the column doesn't jump wider the moment it becomes sorted.
187
+ <CaretUpDownIcon className="size-3 shrink-0 opacity-0 transition-opacity group-hover/sort:opacity-60" />
188
+ );
189
+ return (
190
+ <th
191
+ data-slot="table-head"
192
+ // "none" rather than omitted on the unsorted columns: that is what tells
193
+ // a screen reader the other headers are sortable at all.
194
+ aria-sort={sortable ? (sorted ? ARIA_SORT[sorted] : "none") : undefined}
195
+ style={width ? { width, ...(style as Record<string, unknown>) } : style}
196
+ className={cn(
197
+ "whitespace-nowrap px-4 py-2.5 text-left font-semibold",
198
+ "[[data-density=compact]_&]:px-3 [[data-density=compact]_&]:py-2",
199
+ className,
200
+ )}
201
+ {...props}
202
+ >
203
+ {!sortable ? (
204
+ children
205
+ ) : sortHref !== undefined ? (
206
+ // `data-sorted` and not the glyph: an e2e selector shouldn't depend on
207
+ // which caret rendered.
208
+ <a
209
+ href={sortHref}
210
+ data-slot="table-sort"
211
+ data-sorted={sorted ?? "none"}
212
+ class={triggerClass}
213
+ >
214
+ {children}
215
+ {caret}
216
+ </a>
217
+ ) : (
218
+ <button
219
+ type="button"
220
+ data-slot="table-sort"
221
+ data-sorted={sorted ?? "none"}
222
+ class={triggerClass}
223
+ onClick={onSort}
224
+ >
225
+ {children}
226
+ {caret}
227
+ </button>
228
+ )}
229
+ </th>
230
+ );
231
+ }
232
+
233
+ type TableCellProps = ElementProps<"td"> & {
234
+ /** Clip to one line with an ellipsis instead of wrapping. Sets `title` from `text`. */
235
+ truncate?: boolean;
236
+ /** Tooltip + ellipsis source when the cell's children aren't a plain string. */
237
+ text?: string;
238
+ };
239
+
240
+ function TableCell({ className, truncate, text, children, ...props }: TableCellProps) {
241
+ const title = text ?? (typeof children === "string" ? children : undefined);
242
+ return (
243
+ <td
244
+ data-slot="table-cell"
245
+ // max-w-0 stops an auto-layout column claiming width from its content;
246
+ // the cap is the share the header widths leave it, and the inner div
247
+ // clips to that. Without it `truncate` on the cell alone does nothing.
248
+ className={cn(
249
+ "px-4 py-3 align-middle",
250
+ "[[data-density=compact]_&]:px-3 [[data-density=compact]_&]:py-1",
251
+ truncate && "max-w-0",
252
+ className,
253
+ )}
254
+ {...props}
255
+ >
256
+ {truncate ? (
257
+ <div class="truncate" title={title}>
258
+ {children}
259
+ </div>
260
+ ) : (
261
+ children
262
+ )}
263
+ </td>
264
+ );
265
+ }
266
+
267
+ type PageControlProps = {
268
+ page: number;
269
+ label: string;
270
+ /** The page being rendered is the one on screen. */
271
+ current?: boolean;
272
+ /** No such page. Renders inert markup, never a link. */
273
+ disabled?: boolean;
274
+ hrefFor?: (page: number) => string;
275
+ onPageChange?: (page: number) => void;
276
+ children: ComponentChildren;
277
+ };
278
+
279
+ function PageControl({
280
+ page,
281
+ label,
282
+ current,
283
+ disabled,
284
+ hrefFor,
285
+ onPageChange,
286
+ children,
287
+ }: PageControlProps) {
288
+ const className = cn(
289
+ buttonVariants({ variant: current ? "secondary" : "ghost", size: "xs" }),
290
+ // tabular-nums + a floor on the width so the box doesn't resize between
291
+ // 9 and 10 and shuffle every control after it.
292
+ "min-w-7 justify-center tabular-nums",
293
+ );
294
+ // A dead control is a `<span>`, never an anchor: anchors have no `disabled`,
295
+ // `pointer-events-none` does nothing for the keyboard, and an `href` that
296
+ // names a page which doesn't exist is a link that lies.
297
+ if (disabled || current) {
298
+ return (
299
+ <span
300
+ aria-current={current ? "page" : undefined}
301
+ aria-disabled={disabled ? "true" : undefined}
302
+ aria-label={current ? undefined : label}
303
+ className={cn(className, disabled && "opacity-40")}
304
+ >
305
+ {children}
306
+ </span>
307
+ );
308
+ }
309
+ if (hrefFor) {
310
+ return (
311
+ // `aria-current="false"` is spelled out rather than left off: Fresh stamps
312
+ // its own `aria-current` onto every root-relative `<a>` whose PATH matches
313
+ // the current one, backing off only when the prop is already set
314
+ // (`setActiveUrl` in fresh/runtime). Every control in a pager shares that
315
+ // path and differs only in the query, so without this Fresh marks them all
316
+ // and announces a control the reader is NOT on as the current page.
317
+ // "false" is the ARIA default, so it is inert everywhere else.
318
+ <a href={hrefFor(page)} aria-label={label} aria-current="false" className={className}>
319
+ {children}
320
+ </a>
321
+ );
322
+ }
323
+ return (
324
+ <button
325
+ type="button"
326
+ aria-label={label}
327
+ className={className}
328
+ onClick={() => onPageChange?.(page)}
329
+ >
330
+ {children}
331
+ </button>
332
+ );
333
+ }
334
+
335
+ type TablePaginationBase = {
336
+ page: number;
337
+ pageSize: number;
338
+ total: number;
339
+ /** Pages either side of the current one. The row is `2 * siblings + 5` wide. */
340
+ siblings?: number;
341
+ /** Extra controls between the summary and the nav, e.g. a rows-per-page picker. */
342
+ children?: ComponentChildren;
343
+ className?: string;
344
+ };
345
+
346
+ /**
347
+ * The pager, for `<Table footer={...}>`.
348
+ *
349
+ * Exactly one of `hrefFor` / `onPageChange`, enforced in the type: a pager that
350
+ * looks live and does nothing is a compile error rather than a bug report. There
351
+ * is no `pageCount` prop either — it derives from `total` and `pageSize`, and two
352
+ * inputs that can disagree is one too many.
353
+ */
354
+ type TablePaginationProps = TablePaginationBase &
355
+ (
356
+ | { hrefFor: (page: number) => string; onPageChange?: never }
357
+ | { onPageChange: (page: number) => void; hrefFor?: never }
358
+ );
359
+
360
+ function TablePagination({
361
+ page,
362
+ pageSize,
363
+ total,
364
+ siblings = 1,
365
+ children,
366
+ className,
367
+ hrefFor,
368
+ onPageChange,
369
+ }: TablePaginationProps) {
370
+ const count = pageCount(total, pageSize);
371
+ const current = clampPage(page, count);
372
+ const pages = pageWindow(current, count, siblings);
373
+ return (
374
+ <div
375
+ data-slot="table-pagination"
376
+ className={cn(
377
+ "flex flex-wrap items-center justify-between gap-3 border-t border-border px-4 py-2 text-xs text-muted-foreground",
378
+ className,
379
+ )}
380
+ >
381
+ <span data-slot="table-pagination-summary" className="tabular-nums">
382
+ {pageSummary(current, pageSize, total)}
383
+ </span>
384
+ {children ? <div className="flex flex-wrap items-center gap-2">{children}</div> : null}
385
+ <nav aria-label="Pagination" className="flex items-center gap-1">
386
+ <PageControl
387
+ page={current - 1}
388
+ label="Previous page"
389
+ disabled={current <= 1}
390
+ hrefFor={hrefFor}
391
+ onPageChange={onPageChange}
392
+ >
393
+ <CaretLeftIcon className="size-3" />
394
+ </PageControl>
395
+ {pages.map((entry, index) =>
396
+ entry === "ellipsis" ? (
397
+ <span key={`gap-${index}`} aria-hidden="true" className="px-1">
398
+ …
399
+ </span>
400
+ ) : (
401
+ <PageControl
402
+ key={entry}
403
+ page={entry}
404
+ label={`Page ${entry}`}
405
+ current={entry === current}
406
+ hrefFor={hrefFor}
407
+ onPageChange={onPageChange}
408
+ >
409
+ {entry}
410
+ </PageControl>
411
+ ),
412
+ )}
413
+ <PageControl
414
+ page={current + 1}
415
+ label="Next page"
416
+ disabled={current >= count}
417
+ hrefFor={hrefFor}
418
+ onPageChange={onPageChange}
419
+ >
420
+ <CaretRightIcon className="size-3" />
421
+ </PageControl>
422
+ </nav>
423
+ </div>
424
+ );
425
+ }
426
+
427
+ export interface TableView<T, K extends string> {
428
+ /** The rows to render: sorted, and sliced to the current page. */
429
+ rows: readonly T[];
430
+ /** Rows before paging, which is what `TablePagination` wants. */
431
+ total: number;
432
+ page: number;
433
+ pageCount: number;
434
+ pageSize: number;
435
+ setPage: (page: number) => void;
436
+ /** This column's direction, or `null` — feeds `TableHead`'s `sorted`. */
437
+ sorted: (key: K) => SortDirection | null;
438
+ toggle: (key: K, initial?: SortDirection) => void;
439
+ }
440
+
441
+ /**
442
+ * Client-side sorting and paging for a hydrated table.
443
+ *
444
+ * Both halves live in one hook because "sorting resets to page 1" is the one
445
+ * invariant a call site can't see: an offset into a re-sorted list is noise,
446
+ * and splitting the hook means repeating `setPage(1)` next to every column.
447
+ *
448
+ * Safe inside a widget iframe, and deliberately so. The runtime's boot gate
449
+ * blanks the whole author subtree while a loader has no data and is fetching,
450
+ * and a loader's query key includes its `request`, so paging by `setState`
451
+ * would re-key the query, blank the data, trip the gate and destroy every
452
+ * nested component's state. Sorting and slicing what's already loaded never
453
+ * touches `request`, so it never fires. Page on the server only when the row
454
+ * count genuinely can't be held in memory.
455
+ *
456
+ * A non-island Fresh route can't use this at all — `useState` there renders its
457
+ * initial value and a header click does nothing. Drive `TableHead`'s `sortHref`
458
+ * and `TablePagination`'s `hrefFor` off query params instead, the same way
459
+ * `timeago.tsx` points non-islands at their own shell.
460
+ */
461
+ export function useTableView<T, K extends string>(
462
+ rows: readonly T[],
463
+ options: {
464
+ columns: Record<K, (row: T) => SortValue>;
465
+ // NoInfer, or `K` gets pinned to the single key spelled here instead of the
466
+ // union of the `columns` literal, and every other column stops typechecking.
467
+ initial?: { key: NoInfer<K>; direction: SortDirection };
468
+ /** Omit for sort-only: rows come back whole and `pageCount` is 1. */
469
+ pageSize?: number;
470
+ },
471
+ ): TableView<T, K> {
472
+ const [sort, setSort] = useState<SortState | null>(options.initial ?? null);
473
+ const [page, setPage] = useState(1);
474
+
475
+ // No useMemo: this is O(n log n) over a list the caller then walks anyway,
476
+ // and the `columns` object literal every call site writes is a new identity
477
+ // per render, which would make the memo a permanent miss with extra steps.
478
+ const sorted = sortRows(rows, sort, options.columns);
479
+ const wanted = options.pageSize ?? 0;
480
+ const paged = wanted > 0;
481
+ const size = paged ? wanted : sorted.length;
482
+ const count = pageCount(sorted.length, size);
483
+ // Clamped on read rather than corrected in an effect: a row deleted off the
484
+ // end of the last page would otherwise paint one empty frame first, and
485
+ // inside a widget iframe that frame is a blank card. The cost is that the
486
+ // stored page survives the shrink, so a list that shrinks below the current
487
+ // page and grows back returns the reader to where they were, not where the
488
+ // shrink left them.
489
+ const current = clampPage(page, count);
490
+
491
+ return {
492
+ rows: paged ? pageSlice(sorted, current, size) : sorted,
493
+ total: sorted.length,
494
+ page: current,
495
+ pageCount: count,
496
+ pageSize: size,
497
+ setPage,
498
+ sorted: (key) => (sort?.key === key ? sort.direction : null),
499
+ toggle: (key, initial) => {
500
+ setSort((cur) => toggleSort(cur, key, initial));
501
+ setPage(1);
502
+ },
503
+ };
504
+ }
505
+
506
+ export { Table, TableBody, TableCell, TableHead, TableHeader, TablePagination, TableRow };
@@ -0,0 +1,59 @@
1
+ import type { ComponentProps } from "preact";
2
+ import { Tabs as TabsPrimitive } from "@ark-ui/react/tabs";
3
+
4
+ import { cn } from "../utils.ts";
5
+
6
+ // shadcn-style Tabs ported onto Ark UI's tabs machine (Zag). Radix's
7
+ // `asChild` polymorphism is dropped (preact/compat) — same convention as the
8
+ // rest of this dir. Active styling keys off `aria-selected` (the ARIA tabs
9
+ // attribute Zag sets on the active trigger) — NOT Radix's `data-state=active`,
10
+ // which Ark never emits. Triggers accept arbitrary children, so a caller can
11
+ // append a count badge next to the label.
12
+
13
+ function Tabs({ className, ...props }: ComponentProps<typeof TabsPrimitive.Root>) {
14
+ return (
15
+ <TabsPrimitive.Root
16
+ data-slot="tabs"
17
+ className={cn("flex flex-col gap-3", className)}
18
+ {...props}
19
+ />
20
+ );
21
+ }
22
+
23
+ function TabsList({ className, ...props }: ComponentProps<typeof TabsPrimitive.List>) {
24
+ return (
25
+ <TabsPrimitive.List
26
+ data-slot="tabs-list"
27
+ className={cn(
28
+ "inline-flex w-fit max-w-full flex-wrap items-center gap-1 rounded-2xl bg-muted p-1 text-muted-foreground",
29
+ className,
30
+ )}
31
+ {...props}
32
+ />
33
+ );
34
+ }
35
+
36
+ function TabsTrigger({ className, ...props }: ComponentProps<typeof TabsPrimitive.Trigger>) {
37
+ return (
38
+ <TabsPrimitive.Trigger
39
+ data-slot="tabs-trigger"
40
+ className={cn(
41
+ "inline-flex cursor-pointer items-center justify-center gap-1.5 rounded-xl px-3 py-1.5 text-sm font-medium whitespace-nowrap text-muted-foreground transition-all outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-selected:bg-card aria-selected:font-semibold aria-selected:text-foreground aria-selected:shadow",
42
+ className,
43
+ )}
44
+ {...props}
45
+ />
46
+ );
47
+ }
48
+
49
+ function TabsContent({ className, ...props }: ComponentProps<typeof TabsPrimitive.Content>) {
50
+ return (
51
+ <TabsPrimitive.Content
52
+ data-slot="tabs-content"
53
+ className={cn("flex flex-col gap-3 outline-none", className)}
54
+ {...props}
55
+ />
56
+ );
57
+ }
58
+
59
+ export { Tabs, TabsList, TabsTrigger, TabsContent };
@@ -0,0 +1,84 @@
1
+ // Relative-time helper + presentational <TimeAgo /> wrapper. Renders the
2
+ // relative string ("5 minutes ago", "in 2 days") inside an Ark Tooltip
3
+ // that reveals the absolute UTC timestamp on hover.
4
+ //
5
+ // Pure render-time computation by default: we don't tick on a timer because
6
+ // most callers either re-render naturally (island state changes) or are fine
7
+ // with the value staying frozen until next navigation. Pass `live` for the
8
+ // case that isn't - a duration that must advance while its subject is still
9
+ // running, where nothing else re-renders because nothing else changed.
10
+ //
11
+ // The Ark Tooltip needs hydration to open; consumers rendering this in a
12
+ // non-island Fresh route should wrap it in their own island shell (e.g.
13
+ // `platform/home/src/islands/TimeAgo.tsx`).
14
+
15
+ import { useEffect, useState } from "preact/hooks";
16
+
17
+ const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
18
+
19
+ const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
20
+ ["year", 60 * 60 * 24 * 365],
21
+ ["month", 60 * 60 * 24 * 30],
22
+ ["week", 60 * 60 * 24 * 7],
23
+ ["day", 60 * 60 * 24],
24
+ ["hour", 60 * 60],
25
+ ["minute", 60],
26
+ ["second", 1],
27
+ ];
28
+
29
+ export function timeago(date: Date, now = new Date()): string {
30
+ const diffSeconds = (date.getTime() - now.getTime()) / 1000;
31
+ for (const [unit, secondsInUnit] of UNITS) {
32
+ if (Math.abs(diffSeconds) >= secondsInUnit || unit === "second") {
33
+ return rtf.format(Math.round(diffSeconds / secondsInUnit), unit);
34
+ }
35
+ }
36
+ return rtf.format(0, "second");
37
+ }
38
+
39
+ // Fixed locale + UTC so SSR and client produce identical strings.
40
+ function formatAbsolute(date: Date): string {
41
+ return date.toLocaleString("en-US", {
42
+ year: "numeric",
43
+ month: "short",
44
+ day: "numeric",
45
+ hour: "2-digit",
46
+ minute: "2-digit",
47
+ timeZone: "UTC",
48
+ timeZoneName: "short",
49
+ });
50
+ }
51
+
52
+ import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip.tsx";
53
+
54
+ export function TimeAgo({
55
+ value,
56
+ className,
57
+ live,
58
+ }: {
59
+ value: Date | string;
60
+ className?: string;
61
+ live?: boolean;
62
+ }) {
63
+ const date = typeof value === "string" ? new Date(value) : value;
64
+ // `timeago()` reads the clock at render, so the string only moves when
65
+ // something re-renders. A polled loader whose payload is unchanged doesn't
66
+ // re-render (the query keeps the same data by structural sharing), which is
67
+ // exactly the in-flight case - so `live` drives its own tick.
68
+ const [, tick] = useState(0);
69
+ useEffect(() => {
70
+ if (!live) return;
71
+ const id = setInterval(() => tick((n) => n + 1), 1000);
72
+ return () => clearInterval(id);
73
+ }, [live]);
74
+ return (
75
+ <Tooltip>
76
+ <TooltipTrigger>
77
+ <time className={className} dateTime={date.toISOString()}>
78
+ {timeago(date)}
79
+ </time>
80
+ </TooltipTrigger>
81
+ <TooltipContent side="top">{formatAbsolute(date)}</TooltipContent>
82
+ </Tooltip>
83
+ );
84
+ }