smoodly 0.0.50 → 0.0.52

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 (285) hide show
  1. package/README.md +14 -13
  2. package/dist/admin/auth-config.d.ts +6 -5
  3. package/dist/admin/auth.d.ts +3 -11
  4. package/dist/admin/auth.js +1 -30
  5. package/dist/admin/browser/fetch-op.js +3 -2
  6. package/dist/admin/browser/main.js +6 -2
  7. package/dist/admin/client-ops.d.ts +5 -4
  8. package/dist/admin/client-ops.js +14 -11
  9. package/dist/admin/delivery-mode.d.ts +1 -1
  10. package/dist/admin/delivery-mode.js +2 -2
  11. package/dist/admin/drift.d.ts +0 -2
  12. package/dist/admin/drift.js +1 -1
  13. package/dist/admin/editor/EditorView.d.ts +0 -3
  14. package/dist/admin/editor/EditorView.js +11 -16
  15. package/dist/admin/editor/FloatingToolbar.d.ts +0 -1
  16. package/dist/admin/editor/FloatingToolbar.js +1 -1
  17. package/dist/admin/editor/SeoSettings.d.ts +0 -2
  18. package/dist/admin/editor/SeoSettings.js +2 -2
  19. package/dist/admin/editor/canvas-address.d.ts +8 -0
  20. package/dist/admin/editor/canvas-address.js +18 -2
  21. package/dist/admin/editor/canvas-diagnosis.d.ts +2 -0
  22. package/dist/admin/editor/canvas-diagnosis.js +3 -0
  23. package/dist/admin/editor/inline-dom.d.ts +0 -4
  24. package/dist/admin/editor/inline-dom.js +1 -1
  25. package/dist/admin/editor/preview-grant.d.ts +6 -0
  26. package/dist/admin/editor/preview-grant.js +25 -0
  27. package/dist/admin/editor/useCanvasDiagnosis.js +2 -2
  28. package/dist/admin/files.generated.js +1 -1
  29. package/dist/admin/fixed-nodes.d.ts +0 -6
  30. package/dist/admin/fixed-nodes.js +1 -81
  31. package/dist/admin/forms/CropEditor.d.ts +4 -2
  32. package/dist/admin/forms/CropEditor.js +58 -5
  33. package/dist/admin/forms/ImagePreview.d.ts +3 -1
  34. package/dist/admin/forms/ImagePreview.js +9 -3
  35. package/dist/admin/forms/MediaGrid.js +2 -1
  36. package/dist/admin/forms/MediaPicker.js +9 -6
  37. package/dist/admin/forms/MediaWidget.js +11 -9
  38. package/dist/admin/forms/assets-context.d.ts +7 -3
  39. package/dist/admin/forms/assets-context.js +5 -3
  40. package/dist/admin/forms/crop-editor.d.ts +24 -2
  41. package/dist/admin/forms/crop-editor.js +36 -2
  42. package/dist/admin/forms/date-picker.d.ts +0 -1
  43. package/dist/admin/forms/date-picker.js +1 -1
  44. package/dist/admin/forms/tabs.d.ts +0 -1
  45. package/dist/admin/forms/tabs.js +1 -1
  46. package/dist/admin/forms/upload.d.ts +18 -11
  47. package/dist/admin/forms/upload.js +62 -24
  48. package/dist/admin/global-items.d.ts +0 -3
  49. package/dist/admin/global-items.js +0 -54
  50. package/dist/admin/handler.d.ts +22 -28
  51. package/dist/admin/handler.js +146 -47
  52. package/dist/admin/health.d.ts +0 -4
  53. package/dist/admin/index.d.ts +5 -8
  54. package/dist/admin/index.js +3 -6
  55. package/dist/admin/ops.d.ts +36 -22
  56. package/dist/admin/page-data.d.ts +14 -6
  57. package/dist/admin/page-data.js +1 -1
  58. package/dist/admin/session-flow.d.ts +43 -0
  59. package/dist/admin/session-flow.js +94 -0
  60. package/dist/admin/shell/AdminApp.d.ts +8 -3
  61. package/dist/admin/shell/AdminApp.js +37 -58
  62. package/dist/admin/shell/ContentHealthPage.js +1 -3
  63. package/dist/admin/shell/GlobalFields.d.ts +0 -3
  64. package/dist/admin/shell/GlobalFields.js +1 -1
  65. package/dist/admin/shell/IconRail.js +5 -4
  66. package/dist/admin/shell/MediaScreen.js +9 -6
  67. package/dist/admin/shell/MembersPage.d.ts +1 -2
  68. package/dist/admin/shell/MembersPage.js +19 -90
  69. package/dist/admin/shell/SettingsView.d.ts +1 -2
  70. package/dist/admin/shell/SettingsView.js +2 -2
  71. package/dist/admin/shell/SignedOutView.d.ts +5 -0
  72. package/dist/admin/shell/SignedOutView.js +9 -0
  73. package/dist/admin/shell/console-links.d.ts +5 -0
  74. package/dist/admin/shell/console-links.js +11 -0
  75. package/dist/admin/shell/members-list.d.ts +1 -14
  76. package/dist/admin/shell/members-list.js +2 -23
  77. package/dist/admin/shell/pages-tree.d.ts +0 -2
  78. package/dist/admin/shell/pages-tree.js +1 -1
  79. package/dist/admin/shell/presence-socket.d.ts +10 -0
  80. package/dist/admin/shell/presence-socket.js +191 -0
  81. package/dist/admin/shell/presence.d.ts +6 -18
  82. package/dist/admin/shell/presence.js +4 -11
  83. package/dist/admin/shell/session.d.ts +20 -12
  84. package/dist/admin/shell/session.js +113 -47
  85. package/dist/admin/ui/LocaleSwitcher.d.ts +0 -4
  86. package/dist/admin/ui/LocaleSwitcher.js +2 -2
  87. package/dist/admin/ui/primitives.d.ts +4 -14
  88. package/dist/admin/ui/primitives.js +5 -8
  89. package/dist/admin/ui/theme.js +13 -2
  90. package/dist/admin/validate.js +10 -1
  91. package/dist/api-client.d.ts +26 -0
  92. package/dist/api-client.js +107 -0
  93. package/dist/assemble.d.ts +4 -41
  94. package/dist/assemble.js +4 -302
  95. package/dist/asset-store.d.ts +28 -30
  96. package/dist/asset-store.js +26 -121
  97. package/dist/astro/components/Head.astro +4 -1
  98. package/dist/astro/components/Image.astro +12 -17
  99. package/dist/astro/components/Video.astro +5 -2
  100. package/dist/astro/head.d.ts +3 -0
  101. package/dist/astro/head.js +24 -0
  102. package/dist/astro/index.js +54 -25
  103. package/dist/astro/locale-paths.d.ts +5 -2
  104. package/dist/astro/locale-paths.js +10 -2
  105. package/dist/astro/routes/admin.js +9 -7
  106. package/dist/astro/runtime.d.ts +2 -1
  107. package/dist/astro/runtime.js +4 -3
  108. package/dist/astro/server.d.ts +7 -11
  109. package/dist/astro/server.js +22 -28
  110. package/dist/astro/shell.d.ts +2 -2
  111. package/dist/astro/static-media-node.d.ts +4 -0
  112. package/dist/astro/static-media-node.js +25 -0
  113. package/dist/astro/static-media.d.ts +17 -0
  114. package/dist/astro/static-media.js +55 -0
  115. package/dist/astro/static.d.ts +5 -10
  116. package/dist/astro/static.js +13 -29
  117. package/dist/canvas-marker.d.ts +14 -0
  118. package/dist/canvas-marker.js +36 -0
  119. package/dist/cli/api.d.ts +14 -0
  120. package/dist/cli/api.js +48 -0
  121. package/dist/cli/app-env.d.ts +2 -0
  122. package/dist/cli/app-env.js +20 -0
  123. package/dist/cli/export.d.ts +16 -2
  124. package/dist/cli/export.js +52 -35
  125. package/dist/cli/import.d.ts +7 -11
  126. package/dist/cli/import.js +50 -54
  127. package/dist/cli/load-config.d.ts +0 -1
  128. package/dist/cli/load-config.js +1 -1
  129. package/dist/cli/main.d.ts +4 -12
  130. package/dist/cli/main.js +26 -72
  131. package/dist/cli/push.d.ts +6 -0
  132. package/dist/cli/push.js +31 -0
  133. package/dist/collections.d.ts +0 -1
  134. package/dist/collections.js +1 -1
  135. package/dist/config.d.ts +20 -5
  136. package/dist/config.js +18 -1
  137. package/dist/content/content-api.d.ts +22 -23
  138. package/dist/content/content-api.js +46 -56
  139. package/dist/content/mime.d.ts +1 -0
  140. package/dist/content/mime.js +6 -0
  141. package/dist/deploy-store.d.ts +19 -0
  142. package/dist/deploy-store.js +1 -0
  143. package/dist/entry-store.d.ts +3 -89
  144. package/dist/entry-store.js +2 -550
  145. package/dist/env.d.ts +27 -20
  146. package/dist/env.js +38 -50
  147. package/dist/fields.d.ts +11 -3
  148. package/dist/fields.js +33 -2
  149. package/dist/global-id.d.ts +2 -0
  150. package/dist/global-id.js +4 -2
  151. package/dist/image/SmoodlyImage.d.ts +9 -5
  152. package/dist/image/SmoodlyImage.js +29 -15
  153. package/dist/image/SmoodlyVideo.d.ts +1 -1
  154. package/dist/image/SmoodlyVideo.js +3 -2
  155. package/dist/image/index.d.ts +2 -3
  156. package/dist/image/index.js +0 -2
  157. package/dist/image/markup.d.ts +53 -0
  158. package/dist/image/markup.js +111 -0
  159. package/dist/index.d.ts +12 -22
  160. package/dist/index.js +7 -20
  161. package/dist/media-cut.d.ts +44 -0
  162. package/dist/media-cut.js +70 -0
  163. package/dist/media.d.ts +57 -3
  164. package/dist/media.js +125 -15
  165. package/dist/member-store.d.ts +3 -22
  166. package/dist/member-store.js +1 -53
  167. package/dist/next/admin-route.d.ts +1 -1
  168. package/dist/next/admin-route.js +5 -3
  169. package/dist/next/meta.js +2 -3
  170. package/dist/next/page-renderer.d.ts +2 -2
  171. package/dist/next/page-renderer.js +28 -15
  172. package/dist/next/queries.d.ts +2 -2
  173. package/dist/path-index.d.ts +0 -24
  174. package/dist/path-index.js +0 -98
  175. package/dist/push.d.ts +30 -0
  176. package/dist/push.js +52 -0
  177. package/dist/read-counter.d.ts +2 -0
  178. package/dist/read-counter.js +6 -0
  179. package/dist/reads.d.ts +9 -8
  180. package/dist/reads.js +7 -3
  181. package/dist/registry-json.d.ts +11 -0
  182. package/dist/registry-json.js +81 -0
  183. package/dist/render.d.ts +0 -1
  184. package/dist/render.js +1 -1
  185. package/dist/resolve.d.ts +12 -6
  186. package/dist/resolve.js +11 -8
  187. package/dist/revalidate.d.ts +8 -1
  188. package/dist/revalidate.js +8 -1
  189. package/dist/richtext/index.d.ts +1 -1
  190. package/dist/section-wrapper.d.ts +1 -1
  191. package/dist/section-wrapper.js +0 -9
  192. package/dist/signed-message.d.ts +26 -0
  193. package/dist/signed-message.js +103 -0
  194. package/dist/site.d.ts +59 -18
  195. package/dist/site.js +21 -252
  196. package/dist/space-transfer.d.ts +5 -2
  197. package/dist/space-transfer.js +17 -5
  198. package/dist/store.d.ts +2 -80
  199. package/dist/store.js +3 -489
  200. package/dist/toolset.d.ts +2 -1
  201. package/dist/toolset.js +1 -1
  202. package/dist/version.d.ts +1 -1
  203. package/dist/version.js +1 -1
  204. package/package.json +4 -15
  205. package/dist/admin/ops-impl.d.ts +0 -51
  206. package/dist/admin/ops-impl.js +0 -1400
  207. package/dist/admin/shell/InviteModal.d.ts +0 -6
  208. package/dist/admin/shell/InviteModal.js +0 -28
  209. package/dist/admin/shell/LoginView.d.ts +0 -5
  210. package/dist/admin/shell/LoginView.js +0 -80
  211. package/dist/admin/shell/SchemaNotice.d.ts +0 -4
  212. package/dist/admin/shell/SchemaNotice.js +0 -4
  213. package/dist/admin/shell/SetPasswordView.d.ts +0 -7
  214. package/dist/admin/shell/SetPasswordView.js +0 -41
  215. package/dist/admin/shell/presence-realtime.d.ts +0 -3
  216. package/dist/admin/shell/presence-realtime.js +0 -32
  217. package/dist/admin/supabase-auth.d.ts +0 -7
  218. package/dist/admin/supabase-auth.js +0 -30
  219. package/dist/astro/deploy-deps.d.ts +0 -10
  220. package/dist/astro/deploy-deps.js +0 -8
  221. package/dist/astro/image.d.ts +0 -27
  222. package/dist/astro/image.js +0 -29
  223. package/dist/bind.d.ts +0 -19
  224. package/dist/bind.js +0 -34
  225. package/dist/cli/add-admin.d.ts +0 -11
  226. package/dist/cli/add-admin.js +0 -25
  227. package/dist/cli/load-collections.d.ts +0 -2
  228. package/dist/cli/load-collections.js +0 -27
  229. package/dist/cli/migrate.d.ts +0 -43
  230. package/dist/cli/migrate.js +0 -138
  231. package/dist/cli/rebuild.d.ts +0 -23
  232. package/dist/cli/rebuild.js +0 -22
  233. package/dist/cli/space-io.d.ts +0 -41
  234. package/dist/cli/space-io.js +0 -84
  235. package/dist/client.d.ts +0 -11
  236. package/dist/client.js +0 -54
  237. package/dist/current-space.d.ts +0 -4
  238. package/dist/current-space.js +0 -9
  239. package/dist/document-stamp.d.ts +0 -7
  240. package/dist/document-stamp.js +0 -48
  241. package/dist/image/loaders.d.ts +0 -15
  242. package/dist/image/loaders.js +0 -5
  243. package/dist/image/next-config.d.ts +0 -18
  244. package/dist/image/next-config.js +0 -32
  245. package/dist/image/remote-pattern.d.ts +0 -1
  246. package/dist/image/remote-pattern.js +0 -5
  247. package/dist/migrations.d.ts +0 -37
  248. package/dist/migrations.js +0 -73
  249. package/dist/project.d.ts +0 -88
  250. package/dist/project.js +0 -231
  251. package/dist/published.d.ts +0 -142
  252. package/dist/published.js +0 -164
  253. package/dist/ref-index.d.ts +0 -19
  254. package/dist/ref-index.js +0 -0
  255. package/dist/schema-read.d.ts +0 -23
  256. package/dist/schema-read.js +0 -50
  257. package/dist/schema-version.d.ts +0 -19
  258. package/dist/schema-version.js +0 -34
  259. package/dist/sql-space.d.ts +0 -44
  260. package/dist/sql-space.js +0 -289
  261. package/dist/sql.d.ts +0 -15
  262. package/dist/sql.js +0 -556
  263. package/dist/supabase-asset-store.d.ts +0 -21
  264. package/dist/supabase-asset-store.js +0 -170
  265. package/dist/supabase-entry-store.d.ts +0 -92
  266. package/dist/supabase-entry-store.js +0 -824
  267. package/dist/supabase-member-store.d.ts +0 -21
  268. package/dist/supabase-member-store.js +0 -96
  269. package/dist/supabase-path-index.d.ts +0 -30
  270. package/dist/supabase-path-index.js +0 -138
  271. package/dist/supabase-published-store.d.ts +0 -56
  272. package/dist/supabase-published-store.js +0 -314
  273. package/dist/supabase-store.d.ts +0 -103
  274. package/dist/supabase-store.js +0 -678
  275. package/migrations/20260926120000_smoodly_v1.sql +0 -535
  276. package/migrations/20260926130000_smoodly_v2.sql +0 -26
  277. package/migrations/20260926140000_smoodly_v3.sql +0 -38
  278. package/migrations/20260926150000_smoodly_v4.sql +0 -41
  279. package/migrations/20260929120000_smoodly_v5.sql +0 -56
  280. package/migrations/20260929135523_smoodly_v6.sql +0 -200
  281. package/migrations/20260930040956_smoodly_v7.sql +0 -84
  282. package/migrations/20261007120000_smoodly_v8.sql +0 -154
  283. package/migrations/20261007200000_smoodly_v9.sql +0 -30
  284. package/migrations/20261008120000_smoodly_v10.sql +0 -47
  285. package/migrations/20261009120000_smoodly_v11.sql +0 -49
@@ -2,7 +2,7 @@ import type { PageRecord } from "../store.ts";
2
2
  import type { PageTree } from "../page-context.ts";
3
3
  import type { EntryRecord, EntryUsage } from "../entry-store.ts";
4
4
  import type { AssetRecord, AssetMeta } from "../media.ts";
5
- import type { AssetPage, AssetQuery, FinishInput, UploadTicket } from "../asset-store.ts";
5
+ import type { AssetPage, AssetQuery } from "../asset-store.ts";
6
6
  import type { InviteInput, InviteResult, MemberRecord } from "../member-store.ts";
7
7
  import type { MemberRole } from "../permissions.ts";
8
8
  import type { FieldError } from "./validate.ts";
@@ -11,9 +11,11 @@ import type { DraftToken } from "../draft-token.ts";
11
11
  import type { PathTarget } from "../paths.ts";
12
12
  import type { Finding } from "./drift.ts";
13
13
  import type { HealthReport } from "./health.ts";
14
+ import type { DeployStore } from "../deploy-store.ts";
14
15
  export type OpError = {
15
16
  ok: false;
16
- code: "not_found" | "conflict" | "blocked" | "validation" | "internal" | "unauthorized";
17
+ /** `no_schema`: the space has no registry yet — its site has not pushed one (spec 2026-10-16 the control plane §4). */
18
+ code: "not_found" | "conflict" | "blocked" | "validation" | "internal" | "unauthorized" | "no_schema";
17
19
  message: string;
18
20
  fields?: FieldError[];
19
21
  /** A `conflict` from the token guard (spec 2026-09-26 concurrency §2): the draft moved since the client loaded it. */
@@ -28,6 +30,15 @@ export type OpResult<T> = {
28
30
  ok: true;
29
31
  data: T;
30
32
  } | OpError;
33
+ /** A draft-cookie grant: the service signs it under the site's secret key (purpose "preview", body the
34
+ * action), the browser posts it to the site's preview route, which sets the cookie (spec 2026-10-14 §1). */
35
+ /** The service's signature over the preview action, for the site to verify (signed-message.ts), and the space it names. */
36
+ export type PreviewGrant = {
37
+ ts: number;
38
+ kid: string;
39
+ sig: string;
40
+ space: string;
41
+ };
31
42
  export type PageCreateInput = {
32
43
  parentId: string | null;
33
44
  /** The one locale the page is created in; others are added with addLocale. */
@@ -121,10 +132,6 @@ export type MembersListResult = {
121
132
  consoleUrl: string | null;
122
133
  };
123
134
  /** `database` null = no smoodly_meta table (older than v1). Behind blocks the shell; ahead is silent (spec 2026-09-26 §5). */
124
- export type SchemaStatus = {
125
- database: number | null;
126
- required: number;
127
- };
128
135
  /** The Deploy tile's state (spec 2026-10-09 static mode §6); `hook` false = SMOODLY_DEPLOY_HOOK is unset, Deploy disabled. */
129
136
  export type DeployStatus = DeployState & {
130
137
  hook: boolean;
@@ -241,14 +248,6 @@ export type AdminOps = {
241
248
  /** Ready assets, newest first, paged by cursor; `kind` and `search` filter. */
242
249
  list(query?: AssetQuery): Promise<OpResult<AssetPage>>;
243
250
  get(id: string): Promise<OpResult<AssetRecord>>;
244
- /** Validates type and size against config.media, inserts a pending row, mints the signed upload URL (spec 2026-09-08 §6). */
245
- beginUpload(input: {
246
- name: string;
247
- mime: string;
248
- size: number;
249
- }): Promise<OpResult<UploadTicket>>;
250
- /** Marks the row ready with what the browser measured. */
251
- finishUpload(id: string, dims: FinishInput): Promise<OpResult<AssetRecord>>;
252
251
  update(id: string, meta: AssetMeta): Promise<OpResult<AssetRecord>>;
253
252
  /** Blocked while any draft or published version references the asset. */
254
253
  delete(id: string): Promise<OpResult<null>>;
@@ -284,12 +283,9 @@ export type AdminOps = {
284
283
  /** Every page, entry and global item against the registry, draft and live; unregistered collections counted. */
285
284
  scan(): Promise<OpResult<HealthReport>>;
286
285
  };
287
- schema: {
288
- /** Every member. The shell calls it once on mount and blocks when behind. */
289
- status(): Promise<OpResult<SchemaStatus>>;
290
- };
286
+ /** enable answers the grant the service signed, which the browser posts to the site's preview route. */
291
287
  preview: {
292
- enable(): Promise<OpResult<null>>;
288
+ enable(): Promise<OpResult<PreviewGrant | null>>;
293
289
  disable(): Promise<OpResult<null>>;
294
290
  };
295
291
  /** Static mode's Deploy tile (spec 2026-10-09 static mode §5–§7); on a live site both answer not_found. */
@@ -299,12 +295,30 @@ export type AdminOps = {
299
295
  trigger(): Promise<OpResult<DeployStatus>>;
300
296
  };
301
297
  };
302
- /** `token`: the editor's Supabase Auth session token, attached by the
303
- * client facade, sent as the Authorization header and verified by the
304
- * server gate (admin/auth.ts). */
298
+ /** `token`: the editor's pass for this space, attached by the client facade, sent as the Authorization header and
299
+ * verified by the service (spec 2026-10-16 sign-in and the dashboard §2). */
305
300
  export type AdminOpCall = {
306
301
  path: string;
307
302
  args: unknown[];
308
303
  token?: string;
309
304
  };
310
305
  export type AdminOpAction = (call: AdminOpCall) => Promise<OpResult<unknown>>;
306
+ export type AdminEffects = {
307
+ /** Expire one cache tag (`pageTag(id)` / `entryTag(id)`). */
308
+ revalidate?(tag: string): void;
309
+ /** enable may answer a grant (the service's effect); a site's own effect sets the cookie and answers nothing. */
310
+ draft?: {
311
+ enable(): Promise<PreviewGrant | void> | PreviewGrant | void;
312
+ disable(): void | Promise<void>;
313
+ };
314
+ };
315
+ /** What the deploy operations need (spec 2026-10-09 static mode §7): the live site's origin, where the marker is
316
+ * read from; the host's hook from SMOODLY_DEPLOY_HOOK (absent = Deploy disabled); the deploy store's four facts. */
317
+ export type DeployDeps = {
318
+ siteUrl: string;
319
+ hook?: string;
320
+ store: DeployStore;
321
+ /** Test seams. */
322
+ fetch?: typeof fetch;
323
+ now?: () => number;
324
+ };
@@ -1,23 +1,31 @@
1
1
  import type { PresenceConfig } from "../env.ts";
2
2
  import type { AdminRegistry } from "./serialize.ts";
3
- import type { AuthConfig } from "./auth-config.ts";
3
+ import type { SignInConfig } from "./auth-config.ts";
4
4
  export type AdminPageData = {
5
5
  registry: AdminRegistry;
6
- /** What the browser needs to sign in: public values only. */
7
- auth: AuthConfig;
8
- /** What the browser needs to join the editing-presence channel; null = presence is off. */
6
+ /** Where the browser sends a person to sign in and the dashboard it links to: public values only. */
7
+ signIn: SignInConfig;
8
+ /** Smoodly's API, without a trailing slash, and the site's space: the admin's session routes live under it. */
9
+ apiUrl: string;
10
+ spaceId: string;
11
+ /** Where the browser opens the editing-presence socket; null = presence is off. */
9
12
  presence: PresenceConfig | null;
10
- /** Where operations are posted. */
13
+ /** Where operations are posted: the service's admin API. */
11
14
  opUrl: string;
15
+ /** The site's own route the browser posts a preview grant to, which sets the draft cookie. */
16
+ previewUrl: string;
12
17
  /** The folder the package's files are served from, without a trailing slash. */
13
18
  filesBase: string;
14
19
  /** What the editor puts before a page's address to load its draft: "" where the framework serves drafts
15
20
  * at the page's own address (Next), the preview address's base where it does not (Astro). */
16
21
  previewBase: string;
22
+ /** How the canvas and History's frame mark what they want of a draft render: in the query, or in the path's
23
+ * last segment where the host strips the query (Next: Vercel keeps only a prerendered route's own params). */
24
+ canvasMarks: "path" | "query";
17
25
  /** Static mode shows the Deploy tile (spec 2026-10-09 static mode §6); live by default. */
18
26
  delivery: "live" | "static";
19
27
  };
20
28
  export declare const ADMIN_DATA_ID = "smoodly-admin-data";
21
29
  export declare const ADMIN_ROOT_ID = "smoodly-admin-root";
22
- /** The reserved segment under the admin path: the endpoint and the files live here. */
30
+ /** The reserved segment under the admin path: the files and the revalidate and preview routes live here. */
23
31
  export declare const ADMIN_INTERNAL = "_smoodly";
@@ -1,4 +1,4 @@
1
1
  export const ADMIN_DATA_ID = "smoodly-admin-data";
2
2
  export const ADMIN_ROOT_ID = "smoodly-admin-root";
3
- /** The reserved segment under the admin path: the endpoint and the files live here. */
3
+ /** The reserved segment under the admin path: the files and the revalidate and preview routes live here. */
4
4
  export const ADMIN_INTERNAL = "_smoodly";
@@ -0,0 +1,43 @@
1
+ import type { MemberRole } from "../permissions.ts";
2
+ import type { SignInConfig } from "./auth-config.ts";
3
+ export declare const STORAGE_KEY: (space: string) => string;
4
+ /** The PKCE verifier, in this tab's `sessionStorage` only: the code is good for the browser tab that asked for it. */
5
+ export declare const VERIFIER_KEY = "smoodly-verifier";
6
+ /** Renew this long before the pass expires (spec §2). */
7
+ export declare const RENEW_BEFORE_MS = 300000;
8
+ export type StoredSession = {
9
+ pass: string;
10
+ refresh: string;
11
+ expiresAt: number;
12
+ user: {
13
+ userId: string;
14
+ email: string | null;
15
+ role: MemberRole;
16
+ };
17
+ };
18
+ /** Where the session lives (`localStorage`, shared by the site's tabs) and where the verifier waits (`sessionStorage`). */
19
+ export type SessionStores = {
20
+ local: Storage;
21
+ tab: Storage;
22
+ };
23
+ /** The session the site's tabs share, or null (none, or a value that is not one). */
24
+ export declare function readSession(local: Storage, space: string): StoredSession | null;
25
+ /** Where to send the person to sign in: a fresh verifier kept in this tab, its S256 as the challenge. */
26
+ export declare function signInAddress(config: SignInConfig, space: string, returnUrl: string, stores: Pick<SessionStores, "tab">): Promise<string>;
27
+ /** The one-time code the sign-in page put in the fragment, or null. */
28
+ export declare function codeFrom(hash: string): string | null;
29
+ /** The code and this tab's verifier for a session, stored; or why not. The verifier is spent either way. */
30
+ export declare function exchange(api: string, space: string, code: string, stores: SessionStores, doFetch?: typeof fetch): Promise<StoredSession | {
31
+ refused: string;
32
+ }>;
33
+ /** Runs `fn` while holding a lock no other tab of this origin holds (Web Locks); with no Web Locks (an insecure
34
+ * context), at once. */
35
+ export type TabLock = <T>(name: string, fn: () => Promise<T>) => Promise<T>;
36
+ /** A renewed session, or null when the space refused the refresh (the stored session is then cleared). `held` is the
37
+ * refresh this tab holds: when the stored one differs, another tab renewed first and its session is adopted rather
38
+ * than posting a refresh that is already spent. The read and the post run under one lock per space across the site's
39
+ * tabs, so the tab that renews writes before the others read (spec §2), even when every tab's timer fires at once
40
+ * after a laptop wakes. A network failure throws and keeps the session. */
41
+ export declare function renew(api: string, space: string, stores: Pick<SessionStores, "local">, held: string | null, doFetch?: typeof fetch, lock?: TabLock): Promise<StoredSession | null>;
42
+ /** Sign out of this site: the space ends the session, and this browser forgets it even when the call fails. */
43
+ export declare function endSession(api: string, space: string, stores: Pick<SessionStores, "local">, doFetch?: typeof fetch): Promise<void>;
@@ -0,0 +1,94 @@
1
+ export const STORAGE_KEY = (space) => `smoodly-session:${space}`;
2
+ /** The PKCE verifier, in this tab's `sessionStorage` only: the code is good for the browser tab that asked for it. */
3
+ export const VERIFIER_KEY = "smoodly-verifier";
4
+ /** Renew this long before the pass expires (spec §2). */
5
+ export const RENEW_BEFORE_MS = 300000;
6
+ const base64url = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
7
+ const s256 = async (verifier) => base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))));
8
+ const sessionUrl = (api, space, path) => `${api.replace(/\/+$/, "")}/v1/spaces/${encodeURIComponent(space)}/admin/${path}`;
9
+ const isSession = (v) => {
10
+ const s = v;
11
+ return typeof s === "object" && s !== null && typeof s.pass === "string" && typeof s.refresh === "string" && typeof s.expiresAt === "number"
12
+ && typeof s.user === "object" && s.user !== null && typeof s.user.userId === "string";
13
+ };
14
+ /** The session the site's tabs share, or null (none, or a value that is not one). */
15
+ export function readSession(local, space) {
16
+ try {
17
+ const value = JSON.parse(local.getItem(STORAGE_KEY(space)) ?? "null");
18
+ return isSession(value) ? value : null;
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ const writeSession = (local, space, s) => local.setItem(STORAGE_KEY(space), JSON.stringify(s));
25
+ /** Where to send the person to sign in: a fresh verifier kept in this tab, its S256 as the challenge. */
26
+ export async function signInAddress(config, space, returnUrl, stores) {
27
+ const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
28
+ stores.tab.setItem(VERIFIER_KEY, verifier);
29
+ const query = new URLSearchParams({ space, return: returnUrl, challenge: await s256(verifier) });
30
+ return `${config.signInUrl}?${query}`;
31
+ }
32
+ /** The one-time code the sign-in page put in the fragment, or null. */
33
+ export function codeFrom(hash) {
34
+ return new URLSearchParams(hash.replace(/^#/, "")).get("smoodly_code") || null;
35
+ }
36
+ async function post(doFetch, url, body) {
37
+ const res = await doFetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
38
+ const json = (await res.json().catch(() => null));
39
+ return { status: res.status, data: json?.ok ? json.data : null, message: typeof json?.message === "string" ? json.message : null };
40
+ }
41
+ /** The code and this tab's verifier for a session, stored; or why not. The verifier is spent either way. */
42
+ export async function exchange(api, space, code, stores, doFetch = fetch) {
43
+ const verifier = stores.tab.getItem(VERIFIER_KEY);
44
+ stores.tab.removeItem(VERIFIER_KEY);
45
+ if (!verifier)
46
+ return { refused: "smoodly: this sign-in was started in another tab or has already been used — sign in again." };
47
+ const answer = await post(doFetch, sessionUrl(api, space, "session"), { code, verifier });
48
+ if (answer.status !== 200 || !isSession(answer.data))
49
+ return { refused: answer.message ?? `smoodly: the sign-in was refused (${answer.status}).` };
50
+ writeSession(stores.local, space, answer.data);
51
+ return answer.data;
52
+ }
53
+ const tabLock = (name, fn) => {
54
+ const locks = globalThis.navigator?.locks;
55
+ return locks ? locks.request(name, fn) : fn();
56
+ };
57
+ /** A renewed session, or null when the space refused the refresh (the stored session is then cleared). `held` is the
58
+ * refresh this tab holds: when the stored one differs, another tab renewed first and its session is adopted rather
59
+ * than posting a refresh that is already spent. The read and the post run under one lock per space across the site's
60
+ * tabs, so the tab that renews writes before the others read (spec §2), even when every tab's timer fires at once
61
+ * after a laptop wakes. A network failure throws and keeps the session. */
62
+ export function renew(api, space, stores, held, doFetch = fetch, lock = tabLock) {
63
+ return lock(`smoodly-renew:${space}`, () => renewHeld(api, space, stores, held, doFetch));
64
+ }
65
+ async function renewHeld(api, space, stores, held, doFetch) {
66
+ const stored = readSession(stores.local, space);
67
+ if (!stored)
68
+ return null;
69
+ if (held !== null && stored.refresh !== held)
70
+ return stored;
71
+ const answer = await post(doFetch, sessionUrl(api, space, "session/refresh"), { refresh: stored.refresh });
72
+ if (answer.status === 200 && isSession(answer.data)) {
73
+ writeSession(stores.local, space, answer.data);
74
+ return answer.data;
75
+ }
76
+ if (answer.status === 401 || answer.status === 403) {
77
+ // Cleared only if no other tab stored a newer one meanwhile.
78
+ if (readSession(stores.local, space)?.refresh === stored.refresh)
79
+ stores.local.removeItem(STORAGE_KEY(space));
80
+ return null;
81
+ }
82
+ throw new Error(answer.message ?? `smoodly: the session could not be renewed (${answer.status}).`);
83
+ }
84
+ /** Sign out of this site: the space ends the session, and this browser forgets it even when the call fails. */
85
+ export async function endSession(api, space, stores, doFetch = fetch) {
86
+ const stored = readSession(stores.local, space);
87
+ stores.local.removeItem(STORAGE_KEY(space));
88
+ if (!stored)
89
+ return;
90
+ try {
91
+ await post(doFetch, sessionUrl(api, space, "session/end"), { refresh: stored.refresh });
92
+ }
93
+ catch { /* the session lapses on its own */ }
94
+ }
@@ -1,10 +1,15 @@
1
1
  import type { AdminOpAction } from "../ops.ts";
2
2
  import type { AdminRegistry } from "../serialize.ts";
3
- import { type AuthConfig } from "./session.ts";
3
+ import type { SignInConfig } from "../auth-config.ts";
4
4
  import type { PresenceConfig } from "../../env.ts";
5
- export declare function AdminApp({ registry, op, auth, presenceConfig }: {
5
+ export declare function AdminApp({ registry, op, signIn, api, presenceConfig }: {
6
6
  registry: AdminRegistry;
7
7
  op: AdminOpAction;
8
- auth: AuthConfig;
8
+ signIn: SignInConfig;
9
+ /** Smoodly's API and the site's space: the session routes live under it. */
10
+ api: {
11
+ apiUrl: string;
12
+ spaceId: string;
13
+ };
9
14
  presenceConfig?: PresenceConfig;
10
15
  }): import("react").JSX.Element;
@@ -20,9 +20,8 @@ import { EntryForm } from "./EntryForm.js";
20
20
  import { GlobalList } from "./GlobalList.js";
21
21
  import { GlobalForm } from "./GlobalForm.js";
22
22
  import { EditorView } from "../editor/EditorView.js";
23
- import { LoginView } from "./LoginView.js";
24
- import { SetPasswordView } from "./SetPasswordView.js";
25
- import { useAdminSession } from "./session.js";
23
+ import { SignedOutView } from "./SignedOutView.js";
24
+ import { useSpaceSession } from "./session.js";
26
25
  import { IconRail } from "./IconRail.js";
27
26
  import { ConfirmHost } from "../ui/ConfirmDialog.js";
28
27
  import { notify } from "../ui/confirm.js";
@@ -33,23 +32,27 @@ import { ErrorPane } from "./ErrorPane.js";
33
32
  import { MediaScreen } from "./MediaScreen.js";
34
33
  import { SettingsView } from "./SettingsView.js";
35
34
  import { AssetsProvider } from "../forms/assets-context.js";
35
+ import { xhrSend } from "../forms/upload.js";
36
36
  import { ElementsProvider } from "../forms/elements-context.js";
37
37
  import { TimeZoneProvider } from "../forms/time-zone-context.js";
38
- import { SchemaNotice } from "./SchemaNotice.js";
39
- import { schemaBehind } from "../../schema-version.js";
40
38
  import { delivery, onPublished } from "../delivery-mode.js";
41
39
  import { pollEvery } from "./deploy-tile.js";
42
- import { createPresence, followTokenRefresh } from "./presence.js";
43
- import { connectRealtime } from "./presence-realtime.js";
40
+ import { createPresence } from "./presence.js";
41
+ import { connectSocket } from "./presence-socket.js";
44
42
  import { PresenceProvider } from "./presence-context.js";
45
- export function AdminApp({ registry, op, auth, presenceConfig }) {
46
- const session = useAdminSession(auth);
47
- // An expired session signs out here, locally: the user it names may no longer exist to log out.
48
- const [ops] = useState(() => makeClientOps(op, session.token, () => { void session.client.auth.signOut({ scope: "local" }); }));
43
+ export function AdminApp({ registry, op, signIn, api, presenceConfig }) {
44
+ const session = useSpaceSession({ apiUrl: api.apiUrl, spaceId: api.spaceId, signIn });
45
+ // The session's getters read refs, so the facade made once always sends the current pass; a refused pass renews
46
+ // once and the call is sent again, or the person is sent to sign in.
47
+ const renewRef = useRef(session.renew);
48
+ renewRef.current = session.renew;
49
+ const [ops] = useState(() => makeClientOps(op, session.token, () => renewRef.current()));
50
+ // Uploads go to the service's media route with the same pass and the same renewal (spec 2026-10-18 media §4).
51
+ const [upload] = useState(() => ({ send: xhrSend(`${api.apiUrl.replace(/\/+$/, "")}/v1/spaces/${encodeURIComponent(api.spaceId)}/admin/media`, session.token, () => renewRef.current()) }));
49
52
  const route = useRoute(registry.adminPath);
50
53
  const [head = "pages", tail] = route.segments.length ? route.segments : ["pages"];
51
54
  const isCollection = registry.collections.some((c) => c.name === head);
52
- const email = session.state.status === "signed_in" ? session.state.session.user.email : undefined;
55
+ const email = session.state.status === "signed_in" ? session.state.user.email ?? undefined : undefined;
53
56
  // The role rides on members.me (members management §2): Settings hides
54
57
  // from an editor, and the area itself refuses. Unknown until it answers.
55
58
  const signedIn = session.state.status === "signed_in";
@@ -67,28 +70,13 @@ export function AdminApp({ registry, op, auth, presenceConfig }) {
67
70
  setRole("editor"); });
68
71
  return () => { live = false; };
69
72
  }, [signedIn, ops]);
70
- // The one version check (spec 2026-09-26 §5): behind blocks the shell,
71
- // equal or ahead renders as usual. Unknown until it answers; a failed
72
- // call renders as usual too — the notice is for a known-behind schema.
73
- const [schema, setSchema] = useState(null);
74
- useEffect(() => {
75
- if (!signedIn) {
76
- setSchema(null);
77
- return;
78
- }
79
- let live = true;
80
- ops.schema.status()
81
- .then((r) => { if (live && r.ok)
82
- setSchema(r.data); })
83
- .catch(() => { });
84
- return () => { live = false; };
85
- }, [signedIn, ops]);
86
73
  // The Deploy tile (spec 2026-10-09 static mode §6): on a static site, deploy.status every 30 s, every 5 s
87
74
  // while a build is in flight, and at once after any publish; the press records and re-reads.
88
75
  const [deploy, setDeploy] = useState(null);
89
76
  const [deployBusy, setDeployBusy] = useState(false);
90
- const [publishes, setPublishes] = useState(0);
91
- useEffect(() => onPublished(() => setPublishes((n) => n + 1)), []);
77
+ // A publish is done when its pointer moved (spec 2026-10-15 the space object §3): the tile re-reads at once.
78
+ const [deployTick, setDeployTick] = useState(0);
79
+ useEffect(() => onPublished(() => setDeployTick((n) => n + 1)), []);
92
80
  useEffect(() => {
93
81
  if (!signedIn || delivery() !== "static")
94
82
  return;
@@ -112,7 +100,7 @@ export function AdminApp({ registry, op, auth, presenceConfig }) {
112
100
  void tick();
113
101
  return () => { live = false; if (timer)
114
102
  clearTimeout(timer); };
115
- }, [signedIn, ops, publishes]);
103
+ }, [signedIn, ops, deployTick]);
116
104
  const onDeploy = async () => {
117
105
  setDeployBusy(true);
118
106
  try {
@@ -126,37 +114,31 @@ export function AdminApp({ registry, op, auth, presenceConfig }) {
126
114
  setDeployBusy(false);
127
115
  }
128
116
  };
129
- const userId = session.state.status === "signed_in" ? session.state.session.user.id : "";
130
- // Presence (spec 2026-09-26 concurrency §5; gated 2026-09-26 security pass §1):
131
- // one channel per signed-in session, the content project's public config as the
132
- // gateway and the SESSION token on the socket; absent config = presence off.
133
- // The token getter is a new closure every render — read it through a ref so
134
- // the effect keys on the session, not on the render.
117
+ // Presence (spec 2026-09-26 concurrency §5; spec 2026-10-16 sign-in and the dashboard §3): one socket per signed-in
118
+ // tab to the service, opened with a one-time ticket asked with the current pass; every renewal reconnects with a
119
+ // fresh one. Absent config = presence off. The getters are new closures every render — read them through refs so the
120
+ // effect keys on the session, not on the render.
135
121
  const [presence, setPresence] = useState(null);
136
122
  const tokenRef = useRef(session.token);
137
123
  tokenRef.current = session.token;
138
- const authClient = session.client;
139
- const contentUrl = presenceConfig?.url, contentKey = presenceConfig?.key, contentSpace = presenceConfig?.spaceId;
124
+ const onRenew = useRef(session.onRenew);
125
+ onRenew.current = session.onRenew;
126
+ const presenceApi = presenceConfig?.apiUrl, presenceSpace = presenceConfig?.spaceId;
140
127
  useEffect(() => {
141
- if (!signedIn || !contentUrl || !contentKey || !contentSpace || !email) {
128
+ if (!signedIn || !presenceApi || !presenceSpace || !email) {
142
129
  setPresence(null);
143
130
  return;
144
131
  }
145
- const config = { url: contentUrl, key: contentKey, spaceId: contentSpace };
132
+ const config = { apiUrl: presenceApi, spaceId: presenceSpace };
146
133
  const p = createPresence({
147
134
  email,
148
- connect: () => {
149
- const token = tokenRef.current();
150
- if (!token)
151
- throw new Error("smoodly: no session token for presence");
152
- return connectRealtime(config, email, token);
153
- },
135
+ connect: () => connectSocket(config, email, { pass: () => tokenRef.current(), renew: () => renewRef.current() }),
154
136
  });
155
137
  p.join();
156
138
  setPresence(p);
157
- const stop = followTokenRefresh(authClient.auth, p);
139
+ const stop = onRenew.current((pass) => p.setAuth(pass));
158
140
  return () => { stop(); p.dispose(); setPresence(null); };
159
- }, [signedIn, contentUrl, contentKey, contentSpace, email, authClient]);
141
+ }, [signedIn, presenceApi, presenceSpace, email]);
160
142
  if (session.state.status === "loading") {
161
143
  return (_jsx("div", { style: { height: "100vh", display: "grid", placeItems: "center", color: "var(--text-muted)" }, children: "Loading\u2026" }));
162
144
  }
@@ -164,13 +146,10 @@ export function AdminApp({ registry, op, auth, presenceConfig }) {
164
146
  if (mismatch) {
165
147
  return _jsx("div", { style: { height: "100vh", display: "flex" }, children: _jsx(ErrorPane, { message: mismatch, back: false }) });
166
148
  }
167
- if (session.state.status === "signed_out")
168
- return _jsx(LoginView, { client: session.client, providers: auth.providers });
169
- // An invite or reset link signed the person in: Set password before the shell (members management §6).
170
- if (session.arrival)
171
- return _jsx(SetPasswordView, { client: session.client, mode: session.arrival, onDone: session.clearArrival });
172
- if (schema && schemaBehind(schema.database, schema.required))
173
- return _jsx(SchemaNotice, { status: schema });
149
+ if (session.state.status === "signed_out") {
150
+ const message = session.state.reason === "refused" ? session.state.message : undefined;
151
+ return _jsx(SignedOutView, { host: window.location.host, message: message, onSignIn: () => { void session.signInAgain(); } });
152
+ }
174
153
  let main;
175
154
  if (head === "pages" && !tail)
176
155
  main = _jsx(PagesList, { ops: ops, registry: registry });
@@ -185,10 +164,10 @@ export function AdminApp({ registry, op, auth, presenceConfig }) {
185
164
  else if (head === "globals" && tail)
186
165
  main = _jsx(GlobalForm, { ops: ops, registry: registry, name: decodeURIComponent(tail) });
187
166
  else if (head === "settings")
188
- main = _jsx(SettingsView, { ops: ops, registry: registry, tail: tail, role: role, userId: userId });
167
+ main = _jsx(SettingsView, { ops: ops, registry: registry, tail: tail, role: role });
189
168
  else if (head === "media")
190
169
  main = _jsx(MediaScreen, {});
191
170
  else
192
171
  main = _jsx(ErrorPane, { message: "Not found." });
193
- return (_jsx(AdminPathProvider, { value: registry.adminPath, children: _jsx(PresenceProvider, { value: presence, children: _jsx(ElementsProvider, { elements: registry.elements, children: _jsx(TimeZoneProvider, { zone: registry.timeZone, children: _jsxs(AssetsProvider, { ops: ops, media: registry.media, children: [_jsxs("div", { className: "sm-shell", children: [_jsx(IconRail, { registry: registry, active: head, email: email, settings: role !== null, deploy: deploy ? { status: deploy, busy: deployBusy, onDeploy: () => { void onDeploy(); } } : undefined, onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main }, `${head}/${tail ?? ""}`)] }), _jsx(ConfirmHost, {})] }) }) }) }) }));
172
+ return (_jsx(AdminPathProvider, { value: registry.adminPath, children: _jsx(PresenceProvider, { value: presence, children: _jsx(ElementsProvider, { elements: registry.elements, children: _jsx(TimeZoneProvider, { zone: registry.timeZone, children: _jsxs(AssetsProvider, { ops: ops, media: registry.media, upload: upload, children: [_jsxs("div", { className: "sm-shell", children: [_jsx(IconRail, { registry: registry, active: head, email: email, settings: role !== null, deploy: deploy ? { status: deploy, busy: deployBusy, onDeploy: () => { void onDeploy(); } } : undefined, onSignOut: () => { void session.signOut(); } }), _jsx("main", { className: "sm-main", children: main }, `${head}/${tail ?? ""}`)] }), _jsx(ConfirmHost, {})] }) }) }) }) }));
194
173
  }
@@ -26,9 +26,7 @@ export function ContentHealthPage({ ops, registry, initial = null }) {
26
26
  return _jsx(ErrorPane, { message: error, back: false, onRetry: scan });
27
27
  const sections = report ? healthSections(report, registry) : null;
28
28
  const brokenCount = sections ? sections.broken.length + sections.collections.length : 0;
29
- return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx("span", { className: "sm-title", children: "Content health" }), right: _jsxs(_Fragment, { children: [report && _jsx("span", { className: "sm-hint", children: "Checked just now" }), _jsx(Button, { variant: "secondary", onClick: scan, disabled: !report, children: "Rescan" })] }) }), _jsx("div", { className: "sm-listwrap", children: _jsxs("div", { className: "sm-table sm-health", children: [!sections && _jsx("div", { className: "sm-empty", children: "Checking content\u2026" }), report?.delivery && (_jsx("p", { className: "sm-health__delivery", children: report.delivery.behind === 0
30
- ? "Every published document is current."
31
- : _jsxs(_Fragment, { children: [report.delivery.behind, " ", report.delivery.behind === 1 ? "document is" : "documents are", " behind the current stamp. Run ", _jsx("code", { children: "npx smoodly rebuild" }), " in the site's folder."] }) })), sections && brokenCount === 0 && sections.orphan.length === 0 && _jsx("div", { className: "sm-empty", children: "Everything matches the code." }), sections && brokenCount > 0 && (_jsxs("section", { children: [_jsxs("h2", { className: "sm-health__section", children: ["Blocks publishing \u00B7 ", brokenCount] }), sections.collections.map((c) => _jsx("p", { className: "sm-health__headline", children: c.headline }, c.name)), sections.broken.map((g) => _jsx(Group, { group: g }, g.key))] })), sections && sections.orphan.length > 0 && (_jsxs("section", { children: [_jsxs("h2", { className: "sm-health__section", children: ["Left over \u00B7 ", sections.orphan.length] }), sections.orphan.map((g) => _jsx(Group, { group: g }, g.key))] }))] }) })] }));
29
+ return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx("span", { className: "sm-title", children: "Content health" }), right: _jsxs(_Fragment, { children: [report && _jsx("span", { className: "sm-hint", children: "Checked just now" }), _jsx(Button, { variant: "secondary", onClick: scan, disabled: !report, children: "Rescan" })] }) }), _jsx("div", { className: "sm-listwrap", children: _jsxs("div", { className: "sm-table sm-health", children: [!sections && _jsx("div", { className: "sm-empty", children: "Checking content\u2026" }), sections && brokenCount === 0 && sections.orphan.length === 0 && _jsx("div", { className: "sm-empty", children: "Everything matches the code." }), sections && brokenCount > 0 && (_jsxs("section", { children: [_jsxs("h2", { className: "sm-health__section", children: ["Blocks publishing \u00B7 ", brokenCount] }), sections.collections.map((c) => _jsx("p", { className: "sm-health__headline", children: c.headline }, c.name)), sections.broken.map((g) => _jsx(Group, { group: g }, g.key))] })), sections && sections.orphan.length > 0 && (_jsxs("section", { children: [_jsxs("h2", { className: "sm-health__section", children: ["Left over \u00B7 ", sections.orphan.length] }), sections.orphan.map((g) => _jsx(Group, { group: g }, g.key))] }))] }) })] }));
32
30
  }
33
31
  function Group({ group }) {
34
32
  const adminHref = useAdminHref();
@@ -1,10 +1,7 @@
1
1
  import type { AdminGlobal } from "../serialize.ts";
2
2
  import type { Group } from "../forms/tabs.ts";
3
- import { type NodeValues } from "../editor/InspectorFields.tsx";
4
3
  import type { RefOption } from "../forms/ref-picker.ts";
5
4
  export type { RefOption };
6
- /** The item's value map as the inspector reads it: the reserved keys pulled out into their namespaces. */
7
- export declare function globalValues(fields: Record<string, unknown>): NodeValues;
8
5
  export declare function GlobalFields({ item, groups, fields, errors, refOptions, multilingual, inlinePath, onEditHere, onChange }: {
9
6
  item: AdminGlobal;
10
7
  groups: Group[];
@@ -5,7 +5,7 @@ import { FieldWidget } from "../forms/FieldWidget.js";
5
5
  import { InspectorFields, valueOf } from "../editor/InspectorFields.js";
6
6
  import { UnlocalizedMark } from "../ui/primitives.js";
7
7
  /** The item's value map as the inspector reads it: the reserved keys pulled out into their namespaces. */
8
- export function globalValues(fields) {
8
+ function globalValues(fields) {
9
9
  const ns = (key) => (fields[key] && typeof fields[key] === "object" ? fields[key] : {});
10
10
  return { fields, settings: ns(SETTINGS_KEY), styles: ns(STYLES_KEY), css: typeof fields[CSS_KEY] === "string" ? fields[CSS_KEY] : undefined };
11
11
  }
@@ -12,9 +12,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
12
12
  // the route is /globals), and Settings (the Settings area, spec
13
13
  // 2026-09-25; the route is /settings; every member has a page there since
14
14
  // Redirects, 2026-09-26, so the shell passes `settings` once the role is
15
- // known). The user avatar is pinned to the bottom and opens the same
16
- // flyout with the theme toggle and Sign out, which the old nav's footer
17
- // held.
15
+ // known). The user avatar is pinned to the bottom and opens the account
16
+ // flyout: the email, the theme toggle, "Your sites at smoodly.io" and Sign
17
+ // out (spec 2026-10-16 sign-in and the dashboard §6).
18
18
  //
19
19
  // Flyouts: click toggles, Esc closes and returns focus to the trigger,
20
20
  // ↑/↓ move between entries, a pointer down outside closes. A child being
@@ -24,6 +24,7 @@ import { Globe, Image, LibraryBig, PanelTop, RadioTower, Settings } from "lucide
24
24
  import { tileCopy } from "./deploy-tile.js";
25
25
  import { ThemeToggle } from "../ui/primitives.js";
26
26
  import { useAdminHref } from "./base.js";
27
+ import { consoleLinks } from "./console-links.js";
27
28
  import { RouteLink } from "./RouteLink.js";
28
29
  /** Lucide at the rail's size and stroke (the tile's CSS restates both). */
29
30
  const ICON = { size: 18, strokeWidth: 2, "aria-hidden": true };
@@ -94,5 +95,5 @@ export function IconRail({ registry, active, email, settings = true, deploy, onS
94
95
  const copy = tileCopy(deploy.status, Date.now());
95
96
  const id = menuId("deploy");
96
97
  return (_jsxs("div", { className: "sm-rail__item sm-rail__deploy", "data-state": deploy.status.state, children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-haspopup": "menu", "aria-expanded": open === "deploy", "aria-controls": id, onClick: () => toggle("deploy"), title: copy.detail, children: [_jsxs("span", { className: `sm-rail__tile${copy.lit ? " sm-rail__tile--lit" : ""}${copy.warn ? " sm-rail__tile--warn" : ""}`, children: [_jsx(RadioTower, { ...ICON }), copy.badge && _jsx("span", { className: "sm-rail__badge", "aria-label": `${copy.badge} changes waiting`, children: copy.badge })] }), _jsx("span", { className: "sm-rail__label", children: copy.label })] }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--up", role: "menu", id: id, "data-open": open === "deploy", children: [_jsx("li", { className: "sm-rail__menu-detail", role: "presentation", children: copy.detail }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", disabled: copy.action === null || deploy.busy, title: copy.disabledReason ?? undefined, onClick: () => { close(); deploy.onDeploy(); }, children: copy.action === "retry" ? "Deploy again" : "Deploy now" }) }), copy.disabledReason && _jsx("li", { className: "sm-rail__menu-note", role: "presentation", children: copy.disabledReason })] })] }));
97
- })(), _jsxs("div", { className: "sm-rail__item sm-rail__foot", children: [_jsx("button", { type: "button", className: "sm-rail__user", title: name, "aria-haspopup": "menu", "aria-expanded": open === "user", "aria-controls": menuId("user"), onClick: () => toggle("user"), children: initials }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--up", role: "menu", id: menuId("user"), "data-open": open === "user", children: [_jsx("li", { className: "sm-rail__menu-title", role: "presentation", children: name }), _jsx("li", { role: "none", children: _jsx(ThemeToggle, {}) }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", onClick: () => { close(); onSignOut(); }, children: "Sign out" }) })] })] })] }));
98
+ })(), _jsxs("div", { className: "sm-rail__item sm-rail__foot", children: [_jsx("button", { type: "button", className: "sm-rail__user", title: name, "aria-haspopup": "menu", "aria-expanded": open === "user", "aria-controls": menuId("user"), onClick: () => toggle("user"), children: initials }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--up", role: "menu", id: menuId("user"), "data-open": open === "user", children: [_jsx("li", { className: "sm-rail__menu-title", role: "presentation", children: email ?? name }), _jsx("li", { role: "none", children: _jsx(ThemeToggle, {}) }), _jsx("li", { role: "none", children: _jsx("a", { role: "menuitem", className: "sm-rail__menu-item", href: consoleLinks().sites, target: "_blank", rel: "noreferrer", onClick: close, children: "Your sites at smoodly.io" }) }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", onClick: () => { close(); onSignOut(); }, children: "Sign out" }) })] })] })] }));
98
99
  }