smoodly 0.0.52 → 0.0.54

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 (79) hide show
  1. package/dist/admin/client-ops.js +1 -1
  2. package/dist/admin/editor/CanvasOverlay.d.ts +3 -1
  3. package/dist/admin/editor/CanvasOverlay.js +2 -2
  4. package/dist/admin/editor/EditorView.js +243 -63
  5. package/dist/admin/editor/InsertPicker.d.ts +8 -1
  6. package/dist/admin/editor/InsertPicker.js +2 -2
  7. package/dist/admin/editor/InspectorFields.js +1 -1
  8. package/dist/admin/editor/Outline.d.ts +2 -3
  9. package/dist/admin/editor/PageInspector.js +1 -1
  10. package/dist/admin/editor/StructureView.d.ts +31 -0
  11. package/dist/admin/editor/StructureView.js +58 -0
  12. package/dist/admin/editor/card-preview.d.ts +10 -0
  13. package/dist/admin/editor/card-preview.js +50 -0
  14. package/dist/admin/editor/global-edit.js +29 -22
  15. package/dist/admin/editor/history.d.ts +39 -0
  16. package/dist/admin/editor/history.js +64 -0
  17. package/dist/admin/editor/section-clipboard.d.ts +40 -0
  18. package/dist/admin/editor/section-clipboard.js +106 -0
  19. package/dist/admin/editor/undo-keys.d.ts +19 -0
  20. package/dist/admin/editor/undo-keys.js +27 -0
  21. package/dist/admin/editor/useInlineSession.d.ts +4 -1
  22. package/dist/admin/editor/useInlineSession.js +11 -2
  23. package/dist/admin/editor/view-memory.d.ts +9 -0
  24. package/dist/admin/editor/view-memory.js +17 -0
  25. package/dist/admin/files.generated.js +1 -1
  26. package/dist/admin/handler.d.ts +1 -1
  27. package/dist/admin/index.d.ts +2 -2
  28. package/dist/admin/ops.d.ts +4 -10
  29. package/dist/admin/page-data.d.ts +1 -1
  30. package/dist/admin/session-flow.d.ts +24 -2
  31. package/dist/admin/session-flow.js +29 -2
  32. package/dist/admin/shell/AdminApp.d.ts +1 -1
  33. package/dist/admin/shell/IconRail.js +5 -4
  34. package/dist/admin/shell/presence.d.ts +1 -1
  35. package/dist/admin/shell/session.d.ts +1 -2
  36. package/dist/admin/shell/session.js +10 -9
  37. package/dist/admin/ui/ActionMenu.d.ts +2 -0
  38. package/dist/admin/ui/ActionMenu.js +1 -1
  39. package/dist/admin/ui/primitives.d.ts +1 -0
  40. package/dist/admin/ui/primitives.js +3 -2
  41. package/dist/admin/ui/theme.js +70 -0
  42. package/dist/admin/ui/useSortable.js +3 -1
  43. package/dist/api-client.js +1 -1
  44. package/dist/assemble.js +1 -1
  45. package/dist/astro/cache.d.ts +1 -1
  46. package/dist/astro/index.js +11 -6
  47. package/dist/astro/runtime.d.ts +1 -1
  48. package/dist/astro/runtime.js +1 -1
  49. package/dist/astro/shell.d.ts +1 -1
  50. package/dist/astro/shell.js +1 -1
  51. package/dist/content/content-api.d.ts +1 -0
  52. package/dist/content/content-api.js +3 -1
  53. package/dist/css.js +1 -1
  54. package/dist/entry-store.d.ts +5 -5
  55. package/dist/entry-store.js +7 -7
  56. package/dist/image/markup.js +1 -1
  57. package/dist/index.d.ts +2 -2
  58. package/dist/index.js +1 -1
  59. package/dist/media-cut.d.ts +1 -1
  60. package/dist/media-cut.js +2 -2
  61. package/dist/media.d.ts +1 -1
  62. package/dist/media.js +2 -2
  63. package/dist/member-store.d.ts +0 -29
  64. package/dist/member-store.js +0 -4
  65. package/dist/paths.d.ts +1 -1
  66. package/dist/paths.js +1 -1
  67. package/dist/revalidate.d.ts +1 -1
  68. package/dist/revalidate.js +1 -1
  69. package/dist/space-transfer.d.ts +1 -1
  70. package/dist/space-transfer.js +1 -1
  71. package/dist/version.d.ts +1 -1
  72. package/dist/version.js +1 -1
  73. package/package.json +1 -1
  74. package/dist/admin/auth-config.d.ts +0 -7
  75. package/dist/admin/auth-config.js +0 -1
  76. package/dist/admin/browser/fetch-op.d.ts +0 -10
  77. package/dist/admin/browser/fetch-op.js +0 -45
  78. package/dist/admin/browser/main.d.ts +0 -1
  79. package/dist/admin/browser/main.js +0 -27
@@ -3,7 +3,7 @@ import type { PresenceConfig } from "../env.ts";
3
3
  import { type KeySet } from "../signed-message.ts";
4
4
  import { type AdminFiles } from "./files.ts";
5
5
  import type { AdminEffects } from "./ops.ts";
6
- import type { SignInConfig } from "./auth-config.ts";
6
+ import type { SignInConfig } from "./session-flow.ts";
7
7
  export type AdminHandlerDeps = {
8
8
  config: ResolvedConfig;
9
9
  /** Where the browser sends a person to sign in, and the dashboard it links to: public values only. Absent, the page
@@ -1,10 +1,10 @@
1
1
  export type { AdminEffects, AdminOps, AdminOpCall, AdminOpAction, OpResult, OpError, PageCreateInput, PageGetResult, GlobalListItem, GlobalGetResult, AssetUsageItem, MembersListResult } from "./ops.ts";
2
- export type { InviteInput, InviteResult, MemberRecord } from "../member-store.ts";
2
+ export type { MemberRecord } from "../member-store.ts";
3
3
  export type { MemberRole } from "../permissions.ts";
4
4
  export { fixedNodeSpecs, fixedNodeOf, type FixedNode } from "./fixed-nodes.ts";
5
5
  export { initialGlobalFields } from "./global-items.ts";
6
6
  export { makeClientOps } from "./client-ops.ts";
7
- export type { SignInConfig } from "./auth-config.ts";
7
+ export type { SignInConfig } from "./session-flow.ts";
8
8
  export { serializeAdminConfig } from "./serialize.ts";
9
9
  export type { AdminRegistry, AdminSection, AdminCollection, AdminPageTemplate, AdminGlobal, AdminField } from "./serialize.ts";
10
10
  export * from "./tree-ops.ts";
@@ -3,7 +3,7 @@ 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
5
  import type { AssetPage, AssetQuery } from "../asset-store.ts";
6
- import type { InviteInput, InviteResult, MemberRecord } from "../member-store.ts";
6
+ import type { MemberRecord } from "../member-store.ts";
7
7
  import type { MemberRole } from "../permissions.ts";
8
8
  import type { FieldError } from "./validate.ts";
9
9
  import type { DeployState } from "../deploy-state.ts";
@@ -126,10 +126,9 @@ export type AssetUsageItem = {
126
126
  title: string;
127
127
  href: string | null;
128
128
  };
129
- /** The Members page's read (spec 2026-09-25 §4): the rows and, when the control plane manages them, where. */
129
+ /** The Members page's read (spec 2026-09-25 §4): the rows; the dashboard manages them. */
130
130
  export type MembersListResult = {
131
131
  members: MemberRecord[];
132
- consoleUrl: string | null;
133
132
  };
134
133
  /** `database` null = no smoodly_meta table (older than v1). Behind blocks the shell; ahead is silent (spec 2026-09-26 §5). */
135
134
  /** The Deploy tile's state (spec 2026-10-09 static mode §6); `hook` false = SMOODLY_DEPLOY_HOOK is unset, Deploy disabled. */
@@ -254,18 +253,13 @@ export type AdminOps = {
254
253
  usage(id: string): Promise<OpResult<AssetUsageItem[]>>;
255
254
  };
256
255
  members: {
257
- /** The space's members; admins only. `consoleUrl` set means smoodly.io manages them and every write below is blocked. */
256
+ /** The space's members, a read-only list; admins only. Invites, roles and removals are the dashboard's
257
+ * (spec 2026-10-16 sign-in and the dashboard §6). */
258
258
  list(): Promise<OpResult<MembersListResult>>;
259
259
  /** The caller's role; every member. The shell hides Settings from editors with it. */
260
260
  me(): Promise<OpResult<{
261
261
  role: MemberRole;
262
262
  }>>;
263
- /** Admins only, self-host only: invites (or adds an existing account), refuses a duplicate row (spec 2026-09-25 members management §3). */
264
- invite(input: InviteInput): Promise<OpResult<InviteResult>>;
265
- /** Admins only, self-host only; never your own row, never the last admin. */
266
- setRole(userId: string, role: MemberRole): Promise<OpResult<MemberRecord>>;
267
- /** Admins only, self-host only; the same two guards. The Auth account stays. */
268
- remove(userId: string): Promise<OpResult<null>>;
269
263
  };
270
264
  /** Redirects by hand (spec 2026-09-26 redirects §5): open to every member, as renaming is; nothing here expires a cache. */
271
265
  redirects: {
@@ -1,6 +1,6 @@
1
1
  import type { PresenceConfig } from "../env.ts";
2
2
  import type { AdminRegistry } from "./serialize.ts";
3
- import type { SignInConfig } from "./auth-config.ts";
3
+ import type { SignInConfig } from "./session-flow.ts";
4
4
  export type AdminPageData = {
5
5
  registry: AdminRegistry;
6
6
  /** Where the browser sends a person to sign in and the dashboard it links to: public values only. */
@@ -1,5 +1,11 @@
1
1
  import type { MemberRole } from "../permissions.ts";
2
- import type { SignInConfig } from "./auth-config.ts";
2
+ /** Where the admin sends a person to sign in, and the dashboard its account menu and Members page link to (spec
3
+ * 2026-10-16 sign-in and the dashboard §1, §6): `GET /v1/auth` answers it from the Worker's env; the handler and the
4
+ * page data carry it. Public values only. */
5
+ export type SignInConfig = {
6
+ signInUrl: string;
7
+ consoleUrl: string;
8
+ };
3
9
  export declare const STORAGE_KEY: (space: string) => string;
4
10
  /** The PKCE verifier, in this tab's `sessionStorage` only: the code is good for the browser tab that asked for it. */
5
11
  export declare const VERIFIER_KEY = "smoodly-verifier";
@@ -26,10 +32,26 @@ export declare function readSession(local: Storage, space: string): StoredSessio
26
32
  export declare function signInAddress(config: SignInConfig, space: string, returnUrl: string, stores: Pick<SessionStores, "tab">): Promise<string>;
27
33
  /** The one-time code the sign-in page put in the fragment, or null. */
28
34
  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. */
35
+ /** What the admin shows when the service cannot be reached at sign-in: a refusal with a way back, never a blank screen. */
36
+ export declare const UNREACHABLE = "smoodly: couldn't reach Smoodly to sign you in \u2014 check the connection and sign in again.";
37
+ /** The code and this tab's verifier for a session, stored; or why not. The verifier is spent either way. A service
38
+ * that cannot be reached is a refusal too (UNREACHABLE), so the shell shows it rather than waiting forever. */
30
39
  export declare function exchange(api: string, space: string, code: string, stores: SessionStores, doFetch?: typeof fetch): Promise<StoredSession | {
31
40
  refused: string;
32
41
  }>;
42
+ /** The load-time step with no code in the fragment: the stored session adopted as it is while its pass has more than
43
+ * RENEW_BEFORE_MS left, else renewed; a renewal the service refuses sends the person to sign in; one that cannot reach
44
+ * the service keeps a pass that is still valid (the next timer tries again) and refuses with UNREACHABLE only once the
45
+ * pass has expired. No stored session: sign in. */
46
+ export declare function resume(api: string, space: string, stores: Pick<SessionStores, "local">, now: number, doFetch?: typeof fetch, lock?: TabLock): Promise<{
47
+ kind: "adopt";
48
+ session: StoredSession;
49
+ } | {
50
+ kind: "sign_in";
51
+ } | {
52
+ kind: "refused";
53
+ message: string;
54
+ }>;
33
55
  /** Runs `fn` while holding a lock no other tab of this origin holds (Web Locks); with no Web Locks (an insecure
34
56
  * context), at once. */
35
57
  export type TabLock = <T>(name: string, fn: () => Promise<T>) => Promise<T>;
@@ -38,18 +38,45 @@ async function post(doFetch, url, body) {
38
38
  const json = (await res.json().catch(() => null));
39
39
  return { status: res.status, data: json?.ok ? json.data : null, message: typeof json?.message === "string" ? json.message : null };
40
40
  }
41
- /** The code and this tab's verifier for a session, stored; or why not. The verifier is spent either way. */
41
+ /** What the admin shows when the service cannot be reached at sign-in: a refusal with a way back, never a blank screen. */
42
+ export const UNREACHABLE = "smoodly: couldn't reach Smoodly to sign you in — check the connection and sign in again.";
43
+ /** The code and this tab's verifier for a session, stored; or why not. The verifier is spent either way. A service
44
+ * that cannot be reached is a refusal too (UNREACHABLE), so the shell shows it rather than waiting forever. */
42
45
  export async function exchange(api, space, code, stores, doFetch = fetch) {
43
46
  const verifier = stores.tab.getItem(VERIFIER_KEY);
44
47
  stores.tab.removeItem(VERIFIER_KEY);
45
48
  if (!verifier)
46
49
  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 });
50
+ let answer;
51
+ try {
52
+ answer = await post(doFetch, sessionUrl(api, space, "session"), { code, verifier });
53
+ }
54
+ catch {
55
+ return { refused: UNREACHABLE };
56
+ }
48
57
  if (answer.status !== 200 || !isSession(answer.data))
49
58
  return { refused: answer.message ?? `smoodly: the sign-in was refused (${answer.status}).` };
50
59
  writeSession(stores.local, space, answer.data);
51
60
  return answer.data;
52
61
  }
62
+ /** The load-time step with no code in the fragment: the stored session adopted as it is while its pass has more than
63
+ * RENEW_BEFORE_MS left, else renewed; a renewal the service refuses sends the person to sign in; one that cannot reach
64
+ * the service keeps a pass that is still valid (the next timer tries again) and refuses with UNREACHABLE only once the
65
+ * pass has expired. No stored session: sign in. */
66
+ export async function resume(api, space, stores, now, doFetch = fetch, lock = tabLock) {
67
+ const stored = readSession(stores.local, space);
68
+ if (!stored)
69
+ return { kind: "sign_in" };
70
+ if (stored.expiresAt - now > RENEW_BEFORE_MS)
71
+ return { kind: "adopt", session: stored };
72
+ try {
73
+ const renewed = await renew(api, space, stores, stored.refresh, doFetch, lock);
74
+ return renewed ? { kind: "adopt", session: renewed } : { kind: "sign_in" };
75
+ }
76
+ catch {
77
+ return stored.expiresAt > now ? { kind: "adopt", session: stored } : { kind: "refused", message: UNREACHABLE };
78
+ }
79
+ }
53
80
  const tabLock = (name, fn) => {
54
81
  const locks = globalThis.navigator?.locks;
55
82
  return locks ? locks.request(name, fn) : fn();
@@ -1,6 +1,6 @@
1
1
  import type { AdminOpAction } from "../ops.ts";
2
2
  import type { AdminRegistry } from "../serialize.ts";
3
- import type { SignInConfig } from "../auth-config.ts";
3
+ import type { SignInConfig } from "../session-flow.ts";
4
4
  import type { PresenceConfig } from "../../env.ts";
5
5
  export declare function AdminApp({ registry, op, signIn, api, presenceConfig }: {
6
6
  registry: AdminRegistry;
@@ -1,10 +1,11 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // packages/smoodly/src/admin/shell/IconRail.tsx
4
- // The admin's primary navigation: the design system's 72px IconRail
5
- // (design_handoff_smoodly_cms/components/navigation/IconRail), which
6
- // replaced the 200px SideNav so the visual editor gets the width. Ported
7
- // markup-for-markup; the handoff's click handlers are the shell's router.
4
+ // The admin's primary navigation: the design handoff's 72px IconRail (the
5
+ // cool-blue look's components/navigation/IconRail, in the tree until 2026-10-19;
6
+ // the shipped tokens are design_handoff_smoodly_neutral/), which replaced the
7
+ // 200px SideNav so the visual editor gets the width. Ported markup-for-markup;
8
+ // the handoff's click handlers are the shell's router.
8
9
  //
9
10
  // Items are registry-derived, never separately configured: Pages, then
10
11
  // Collections — one rail item whose flyout lists every collection — then
@@ -15,7 +15,7 @@ export type PresenceState = {
15
15
  status: PresenceStatus;
16
16
  others: PresenceEntry[];
17
17
  };
18
- /** The channel presence uses: once a Realtime channel's slice, now the service's socket (presence-socket.ts). */
18
+ /** The channel presence uses: the service's socket (presence-socket.ts). */
19
19
  export type PresenceChannel = {
20
20
  subscribe(onStatus: (status: string) => void): void;
21
21
  /** The session renewed (its new pass): reconnect with a fresh ticket. The space's object admits a live session of a
@@ -1,5 +1,4 @@
1
- import type { SignInConfig } from "../auth-config.ts";
2
- import { type StoredSession } from "../session-flow.ts";
1
+ import { type SignInConfig, type StoredSession } from "../session-flow.ts";
3
2
  export type SessionUser = StoredSession["user"];
4
3
  export type SessionState = {
5
4
  status: "loading";
@@ -7,7 +7,7 @@
7
7
  // straight back in while their smoodly.io session lives. Another tab's renewal or sign out is adopted from the
8
8
  // `storage` event. The pure steps are admin/session-flow.ts.
9
9
  import { useCallback, useEffect, useRef, useState } from "react";
10
- import { codeFrom, endSession, exchange, readSession, renew, RENEW_BEFORE_MS, signInAddress, STORAGE_KEY } from "../session-flow.js";
10
+ import { codeFrom, endSession, exchange, readSession, renew, RENEW_BEFORE_MS, resume, signInAddress, STORAGE_KEY } from "../session-flow.js";
11
11
  /** A renewal's timer fires up to this much later, so two tabs rarely post the same refresh at once. */
12
12
  const JITTER_MS = 30000;
13
13
  export function useSpaceSession({ apiUrl, spaceId, signIn }) {
@@ -69,16 +69,17 @@ export function useSpaceSession({ apiUrl, spaceId, signIn }) {
69
69
  adopt(got);
70
70
  return;
71
71
  }
72
- const stored = readSession(window.localStorage, spaceId);
73
- if (!stored) {
74
- await signInAgain();
72
+ // The stored session, as it is or renewed; a service that cannot be reached keeps a still-valid pass and
73
+ // refuses, with a way back, only once it has expired (session-flow.ts resume).
74
+ const step = await resume(apiUrl, spaceId, stores(), Date.now());
75
+ if (!live)
75
76
  return;
76
- }
77
- held.current = stored;
78
- if (stored.expiresAt - Date.now() > RENEW_BEFORE_MS)
79
- adopt(stored);
77
+ if (step.kind === "adopt")
78
+ adopt(step.session);
79
+ else if (step.kind === "refused")
80
+ setState({ status: "signed_out", reason: "refused", message: step.message });
80
81
  else
81
- await renewNow();
82
+ await signInAgain();
82
83
  })();
83
84
  const onStorage = (e) => {
84
85
  if (e.key !== STORAGE_KEY(spaceId))
@@ -3,6 +3,8 @@ export type ActionMenuItem = {
3
3
  run(): void;
4
4
  disabled?: boolean;
5
5
  danger?: boolean;
6
+ /** The reason on a disabled entry (a paste the zone refuses), shown as the button's title. */
7
+ title?: string;
6
8
  };
7
9
  /** The button's box in viewport pixels, `right` measured from the viewport's right edge. */
8
10
  type Anchor = {
@@ -68,5 +68,5 @@ export function ActionMenu({ label, items, disabled, small = false }) {
68
68
  document.removeEventListener("scroll", onScroll, true);
69
69
  };
70
70
  }, [open]);
71
- return (_jsxs("div", { ref: ref, style: { position: "relative", flex: "none" }, children: [_jsx("button", { ref: button, type: "button", className: cx("sm-dotmenu", small && "sm-dotmenu--sm"), "aria-label": label, title: label, "aria-haspopup": "menu", "aria-expanded": open !== null, disabled: disabled, onClick: toggle, children: "\u00B7\u00B7\u00B7" }), open && (_jsx("div", { ref: menu, role: "menu", className: "sm-picker sm-picker--menu", style: place ?? { top: open.bottom + 6, right: open.right, visibility: "hidden" }, children: items.map((item) => (_jsx("button", { type: "button", role: "menuitem", className: cx("sm-picker__item", item.danger && "sm-picker__item--danger"), disabled: item.disabled, onClick: () => { setOpen(null); item.run(); }, children: item.label }, item.label))) }))] }));
71
+ return (_jsxs("div", { ref: ref, style: { position: "relative", flex: "none" }, children: [_jsx("button", { ref: button, type: "button", className: cx("sm-dotmenu", small && "sm-dotmenu--sm"), "aria-label": label, title: label, "aria-haspopup": "menu", "aria-expanded": open !== null, disabled: disabled, onClick: toggle, children: "\u00B7\u00B7\u00B7" }), open && (_jsx("div", { ref: menu, role: "menu", className: "sm-picker sm-picker--menu", style: place ?? { top: open.bottom + 6, right: open.right, visibility: "hidden" }, children: items.map((item) => (_jsx("button", { type: "button", role: "menuitem", className: cx("sm-picker__item", item.danger && "sm-picker__item--danger"), disabled: item.disabled, title: item.title, onClick: () => { setOpen(null); item.run(); }, children: item.label }, item.label))) }))] }));
72
72
  }
@@ -103,6 +103,7 @@ export declare function Segmented<T extends string>({ options, value, onChange,
103
103
  disabled?: boolean;
104
104
  title?: string;
105
105
  className?: string;
106
+ "aria-label"?: string;
106
107
  } | undefined;
107
108
  }): import("react").JSX.Element;
108
109
  export declare function StatusBadge({ status }: {
@@ -1,7 +1,8 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // The design system's primitives, ported 1:1 from the handoff's
4
- // components/ set (design_handoff_smoodly_cms). Structure and dynamic
4
+ // components/ set (the cool-blue look, in the tree until 2026-10-19; the shipped
5
+ // tokens are design_handoff_smoodly_neutral/). Structure and dynamic
5
6
  // values live here; every static rule, and all hover/focus state, lives in
6
7
  // theme.tsx's stylesheet.
7
8
  import { Fragment, useEffect, useRef } from "react";
@@ -102,7 +103,7 @@ export function Tabs({ tabs, value, onChange }) {
102
103
  export function Segmented({ options, value, onChange, stretch, disabled, label, option, className }) {
103
104
  return (_jsx("div", { className: cx("sm-seg", stretch && "sm-seg--stretch", disabled && "sm-seg--disabled", className), children: options.map((o) => {
104
105
  const extra = option?.(o);
105
- return (_jsx("button", { type: "button", className: cx("sm-seg__opt", extra?.className), "aria-pressed": o === value, disabled: disabled || extra?.disabled, title: extra?.title, onClick: () => onChange(o), children: label ? label(o) : o }, o));
106
+ return (_jsx("button", { type: "button", className: cx("sm-seg__opt", extra?.className), "aria-pressed": o === value, disabled: disabled || extra?.disabled, title: extra?.title, "aria-label": extra?.["aria-label"], onClick: () => onChange(o), children: label ? label(o) : o }, o));
106
107
  }) }));
107
108
  }
108
109
  /* ── data ────────────────────────────────────────────────────────────── */
@@ -638,6 +638,8 @@ body:has(.sm-admin){margin:0}
638
638
  .sm-seg{display:flex;gap:2px;padding:3px;background:var(--seg-bg);border-radius:var(--radius-btn)}
639
639
  /* A locale switch: each option wide enough to read as a tab, not a chip. */
640
640
  .sm-seg--locale .sm-seg__opt{min-width:64px}
641
+ /* The Canvas | Structure switch: two icon options. */
642
+ .sm-seg--view .sm-seg__opt{padding:4px 8px;display:grid;place-items:center}
641
643
  .sm-seg--stretch{width:100%}
642
644
  .sm-seg--disabled{opacity:.6}
643
645
  .sm-seg__opt:disabled{cursor:default}
@@ -768,6 +770,11 @@ body:has(.sm-admin){margin:0}
768
770
  position:relative;background:var(--page-bg);border-radius:var(--page-radius);
769
771
  box-shadow:var(--shadow-page);min-height:100%;
770
772
  }
773
+ /* Structure (spec 2026-10-19 editor at launch §1): the structure layer takes the scroller; the frame stays mounted
774
+ and hidden under it: the wrapper is a zero-height absolute box that clips the frame (so the scroller does not grow by the
775
+ page's height), while the frame's own box keeps its width and height for the bridge. After .sm-page: it overrides its position. */
776
+ .sm-canvas__scroll--structure{position:relative}
777
+ .sm-page.sm-canvas__hidden{visibility:hidden;position:absolute;left:0;top:0;width:100%;height:0;min-height:0;pointer-events:none}
771
778
  .sm-canvas__loading{
772
779
  height:240px;display:grid;place-items:center;color:var(--text-subtle);font-family:var(--meta-font);
773
780
  font-size:var(--meta-size);letter-spacing:var(--caps-tracking);text-transform:var(--caps-transform);
@@ -834,6 +841,67 @@ body:has(.sm-admin){margin:0}
834
841
  /* A row with a grip: the grip takes the left padding's place and the label follows it. */
835
842
  .sm-outline-row--grip{padding-left:2px}
836
843
  .sm-outline-row--grip .sm-outline-row__label{flex:1}
844
+ /* The structure view (spec 2026-10-19 editor at launch §1): the canvas column as rows of cards, the
845
+ outline's boxes grown up — a zone's caps label, a card per section on the outline's white, the
846
+ outline's selected fill and drift stripes. The add affordance between cards is the "+" pill at
847
+ label size, faint until its row is hovered. */
848
+ .sm-structure{max-width:720px;margin:0 auto;padding:var(--canvas-pad);padding-bottom:40px;display:flex;flex-direction:column;gap:20px}
849
+ .sm-structure__group{display:flex;flex-direction:column;gap:4px}
850
+ .sm-structure__zone{display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:24px}
851
+ .sm-structure__zone .sm-sectionlabel{flex:1;min-width:0}
852
+ .sm-structure__rows{display:flex;flex-direction:column}
853
+ .sm-structure__row{display:flex;flex-direction:column}
854
+ .sm-structure__card{
855
+ display:grid;grid-template-columns:24px 32px minmax(0,1fr) auto auto;gap:10px;align-items:center;
856
+ width:100%;min-height:56px;padding:8px 6px 8px 2px;text-align:left;font:inherit;
857
+ border-radius:var(--outline-row-radius);background:var(--outline-row-bg);border:var(--outline-row-border);
858
+ color:var(--text);cursor:pointer;
859
+ }
860
+ .sm-structure__card:hover{background:var(--control-hover)}
861
+ .sm-structure__card:hover .sm-grip{opacity:1}
862
+ .sm-structure__card:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
863
+ .sm-structure__card--selected,.sm-structure__card--selected:hover{background:var(--nav-active-bg);color:var(--nav-active-fg);border-color:transparent}
864
+ .sm-structure__card--locked{background:var(--surface-inset);color:var(--text-muted);border-color:transparent}
865
+ .sm-structure__card--broken{box-shadow:inset 3px 0 0 var(--danger)}
866
+ .sm-structure__card--orphan{box-shadow:inset 3px 0 0 var(--line-dashed)}
867
+ .sm-structure__gripslot{width:24px;height:24px}
868
+ .sm-structure__icon{
869
+ width:32px;height:32px;display:grid;place-items:center;border-radius:var(--radius-sm);
870
+ background:var(--surface-inset);font-size:var(--text-lg);line-height:1;flex:none;
871
+ }
872
+ .sm-structure__card--selected .sm-structure__icon,.sm-structure__card--locked .sm-structure__icon{background:color-mix(in srgb,currentColor 10%,transparent)}
873
+ .sm-structure__text{display:flex;flex-direction:column;gap:2px;min-width:0}
874
+ .sm-structure__title{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:flex;align-items:center;gap:6px;min-width:0}
875
+ .sm-structure__badge{
876
+ font-size:var(--text-sm);font-weight:500;letter-spacing:var(--caps-tracking);text-transform:var(--caps-transform);
877
+ color:var(--text-muted);flex:none;
878
+ }
879
+ .sm-structure__card--selected .sm-structure__badge{color:inherit;opacity:.8}
880
+ .sm-structure__preview{font-size:var(--text-sm);color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:400}
881
+ .sm-structure__card--selected .sm-structure__preview{color:inherit;opacity:.8}
882
+ .sm-structure__thumb{width:44px;height:30px;border-radius:4px;overflow:hidden;background:var(--surface-inset);flex:none;display:block}
883
+ .sm-structure__img{width:100%;height:100%;object-fit:cover;display:block}
884
+ .sm-structure__menu{display:flex;flex:none}
885
+ .sm-structure__slot{display:flex;justify-content:center;padding:4px 0}
886
+ .sm-structure__add{
887
+ display:inline-flex;align-items:center;gap:6px;height:24px;padding:0 10px 0 4px;border-radius:999px;border:0;
888
+ background:var(--plus-bg);color:var(--plus-fg);font:500 var(--text-sm)/1 var(--font-sans);cursor:pointer;
889
+ box-shadow:var(--plus-shadow);white-space:nowrap;opacity:.35;transition:opacity .12s,background .12s;
890
+ }
891
+ .sm-structure__add-icon{
892
+ width:16px;height:16px;flex:none;border-radius:50%;background:var(--plus-icon-bg);color:var(--plus-icon-fg);
893
+ display:grid;place-items:center;font-size:12px;line-height:1;
894
+ }
895
+ .sm-structure__row:hover .sm-structure__add,.sm-structure__slot:hover .sm-structure__add,.sm-structure__add:focus-visible,.sm-structure__empty .sm-structure__add{opacity:1}
896
+ .sm-structure__add:hover{background:var(--plus-hover)}
897
+ /* The lifted row keeps the hook's transform and z-index; its card carries the shadow, its add slot hides. */
898
+ .sm-structure__row.sm-drag--lifted{background:transparent;box-shadow:none}
899
+ .sm-structure__row.sm-drag--lifted .sm-structure__card{box-shadow:var(--shadow-pop)}
900
+ .sm-structure__row.sm-drag--lifted .sm-structure__slot{visibility:hidden}
901
+ .sm-structure__empty{
902
+ display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:var(--outline-row-h);padding:4px 12px;
903
+ border:1px dashed var(--line-dashed);border-radius:var(--outline-row-radius);color:var(--text-muted);font-weight:500;
904
+ }
837
905
 
838
906
  .sm-picker{
839
907
  width:300px;background:var(--menu-bg);border:var(--menu-border);border-radius:var(--radius-md);
@@ -847,6 +915,8 @@ body:has(.sm-admin){margin:0}
847
915
  /* after the base item rule: same specificity, so source order decides (ActionMenu states) */
848
916
  .sm-picker__item:disabled{opacity:.4;cursor:default}
849
917
  .sm-picker__item:disabled:hover{background:transparent}
918
+ /* The clipboard's entry leads the picker (spec 2026-10-19 editor at launch §3), set off from the sections under it. */
919
+ .sm-picker__item--paste{border-bottom:1px solid var(--line);margin-bottom:4px;border-radius:var(--radius-sm) var(--radius-sm) 0 0}
850
920
  .sm-picker__item--danger{color:var(--danger)}
851
921
  .sm-picker__icon{
852
922
  width:26px;height:26px;flex:none;border-radius:var(--radius-sm);background:var(--accent-soft);
@@ -172,7 +172,9 @@ export function useSortable(options) {
172
172
  window.addEventListener("keydown", key);
173
173
  };
174
174
  const onKeyDown = (index) => (e) => {
175
- e.stopPropagation(); // the row's own Enter/Space handler must not see the grip's keys
175
+ // The row's own Enter/Space handler must not see the grip's keys; every other key (the editor's Cmd+Z, review 2026-10-20) bubbles on.
176
+ if (e.key === "Enter" || e.key === " " || e.key === "ArrowUp" || e.key === "ArrowDown")
177
+ e.stopPropagation();
176
178
  if (e.key !== "ArrowUp" && e.key !== "ArrowDown")
177
179
  return;
178
180
  e.preventDefault();
@@ -65,7 +65,7 @@ export function createSmoodlyApi(options) {
65
65
  return (await call("/target-paths", { kind: target.kind, id: target.id })).data;
66
66
  },
67
67
  async load(target, locale, path, o) {
68
- const draft = !!o?.draft || o?.version !== undefined;
68
+ const draft = !!o?.draft || !!o?.version;
69
69
  const r = await call("/load", { kind: target.kind, id: target.id, locale, path, draft: o?.draft ? "1" : undefined, version: o?.version });
70
70
  return withTags(r.data, r.tags, draft);
71
71
  },
package/dist/assemble.js CHANGED
@@ -49,7 +49,7 @@ export function checkEntryQuery(schema, collection, o) {
49
49
  if (OBJECT_FIELD_TYPES.has(schema.fields[key].type ?? "")) {
50
50
  throw new Error(`smoodly: getEntries("${collection}"): "${key}" holds an object — filter on a scalar field instead.`);
51
51
  }
52
- // Any-of reads the text form of the whole field (PostgREST's `->>`), which for
52
+ // Any-of reads the text form of the whole field (an array as its JSON text), which for
53
53
  // an array is its JSON — `["a"]` never matches "a" — so refuse rather than match nothing.
54
54
  if (Array.isArray(o.where[key]) && ARRAY_FIELD_TYPES.has(schema.fields[key].type ?? "")) {
55
55
  throw new Error(`smoodly: getEntries("${collection}"): "${key}" is array-valued — any-of is for scalar fields; pass one value to match entries containing it.`);
@@ -17,7 +17,7 @@ export type Settled = {
17
17
  draft: boolean;
18
18
  /** The request is under the preview address, cookie or not. */
19
19
  preview: boolean;
20
- /** The shell rendered a page read from the database and gave it a lifetime. */
20
+ /** The shell rendered a page read from the service and gave it a lifetime. */
21
21
  cacheable: boolean;
22
22
  /** When the request began, in milliseconds. */
23
23
  started: number;
@@ -79,11 +79,16 @@ export default function smoodly(options = {}) {
79
79
  let staticMedia = new Map();
80
80
  /** astro.config's `i18n`, which the build holds the config's `routing` against. */
81
81
  let i18n;
82
- /** The project's API client at build, from its config file (the one `options.config` names) and the env the setup hook loaded. */
83
- const buildApi = async () => {
84
- const loaded = options.config ? await loadConfigFile(join(root, options.config), root) : await loadConfig(root);
82
+ /** The project's config at build, from its config file (the one `options.config` names), loaded once and shared by
83
+ * every hook; and its API client, from the env the setup hook loaded, made once. Two steps, since the routing
84
+ * check wants the config before any env is needed. */
85
+ let builtConfig = null;
86
+ const buildConfig = () => (builtConfig ?? (builtConfig = options.config ? loadConfigFile(join(root, options.config), root) : loadConfig(root)));
87
+ let builtApi = null;
88
+ const buildApi = () => (builtApi ?? (builtApi = (async () => {
89
+ const loaded = await buildConfig();
85
90
  return { loaded, api: createSmoodlyApi({ ...apiEnv(process.env), config: loaded }) };
86
- };
91
+ })()));
87
92
  /** A build read that fails names the service's address and stops the build before any page renders (a static build
88
93
  * with the Worker down would otherwise fail page by page). */
89
94
  const fromApi = async (read) => {
@@ -115,7 +120,7 @@ export default function smoodly(options = {}) {
115
120
  injectRoute(route);
116
121
  addMiddleware({ entrypoint: here(`./middleware${ext}`), order: "pre" });
117
122
  if (output === "server" && !config.cache?.provider) {
118
- logger.warn("smoodly: no cache provider in astro.config — every page renders from the database. Add `cache: { provider: memoryCache() }` (from \"astro/config\").");
123
+ logger.warn("smoodly: no cache provider in astro.config — every page renders from the service. Add `cache: { provider: memoryCache() }` (from \"astro/config\").");
119
124
  }
120
125
  // astro.config is evaluated before Astro loads .env, so `site: process.env.SITE_URL` there sees only the
121
126
  // shell's value; the env files were read above, and SITE_URL from them sets `site` here. A value
@@ -149,7 +154,7 @@ export default function smoodly(options = {}) {
149
154
  // The schema push (spec 2026-10-16 the control plane §4) comes first, in either output: the space then holds the
150
155
  // registry this build renders with. A failed push never fails the build; the site runs against the schema the space has.
151
156
  "astro:build:start": async ({ logger }) => {
152
- const loaded = options.config ? await loadConfigFile(join(root, options.config), root) : await loadConfig(root);
157
+ const loaded = await buildConfig();
153
158
  // The runtime's startup check, at build in either output: a deploy that builds must not refuse every request.
154
159
  astroRouting(i18n, loaded.locales, loaded.routing);
155
160
  try {
@@ -19,7 +19,7 @@ export type { PageHead } from "./head.ts";
19
19
  export type { ViewFiles } from "./views-check.ts";
20
20
  import { type Shell, type SmoodlyLocals } from "./shell.ts";
21
21
  /** The shell's decision with the project's bound site: what routes/page.astro calls, with `Astro.cache`.
22
- * A page read from the database gets its lifetime here, before its view renders. The first call compares
22
+ * A page read from the service gets its lifetime here, before its view renders. The first call compares
23
23
  * the project's view files to the registry and names every disagreement once. */
24
24
  export declare function shell(pathname: string, search: string, state: SmoodlyLocals, views: ViewFiles, cache?: unknown): Promise<Shell>;
25
25
  /** getStaticPaths for the static page shell (spec 2026-10-09 static mode §3): every published address of the bound site. */
@@ -25,7 +25,7 @@ import { missingViews } from "./views-check.js";
25
25
  import { resolveShell } from "./shell.js";
26
26
  let viewsChecked = false;
27
27
  /** The shell's decision with the project's bound site: what routes/page.astro calls, with `Astro.cache`.
28
- * A page read from the database gets its lifetime here, before its view renders. The first call compares
28
+ * A page read from the service gets its lifetime here, before its view renders. The first call compares
29
29
  * the project's view files to the registry and names every disagreement once. */
30
30
  export async function shell(pathname, search, state, views, cache) {
31
31
  if (!viewsChecked) {
@@ -14,7 +14,7 @@ export type SmoodlyLocals = {
14
14
  editor: boolean;
15
15
  /** History's stored version, inside a preview only. */
16
16
  version?: string;
17
- /** Set by the shell when it rendered a page read from the database: the response may be cached under its tags. */
17
+ /** Set by the shell when it rendered a page read from the service: the response may be cached under its tags. */
18
18
  cacheable: boolean;
19
19
  /** Set by the shell when it rendered a page at all: in a preview, only that response is a canvas. */
20
20
  shell: boolean;
@@ -52,7 +52,7 @@ export async function resolveShell(deps, pathname, search, state, views) {
52
52
  console.warn(`smoodly: no src/page-types/${hit.template}.astro — the record at ${hit.path} is a 404.`);
53
53
  return NOT_FOUND;
54
54
  }
55
- // Only a page read from the database has a tag to expire it by.
55
+ // Only a page read from the service has a tag to expire it by.
56
56
  state.cacheable = !hit.record.id.startsWith("code:") && !state.draft;
57
57
  state.shell = true;
58
58
  const zoneNames = Object.keys(schema.zones ?? {});
@@ -22,6 +22,7 @@ export declare class SmoodlyContentError extends Error {
22
22
  /** What content() writes through: the operations, the paths index's lookup, an upload. */
23
23
  export type ContentTransport = {
24
24
  op(call: AdminOpCall): Promise<OpResult<unknown>>;
25
+ /** The target at an address, as a draft read: what the script is writing, never an edge copy. */
25
26
  locate(locale: string, path: string): Promise<PathHit | null>;
26
27
  upload(bytes: Uint8Array, filename: string, mime: string | undefined, meta: UploadMeta): Promise<ImageValue>;
27
28
  };
@@ -45,7 +45,9 @@ export function apiTransport(options) {
45
45
  const json = (body) => ({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
46
46
  return {
47
47
  op: (call) => send("/op", json({ path: call.path, args: call.args })),
48
- locate: (locale, path) => must(send(`/locate?${new URLSearchParams({ locale, path })}`)),
48
+ // A draft read (DESIGN §1 Content API › Writes): the caller holds the secret key and asks about what it is writing,
49
+ // and a draft read is never stored at the edge, so a script sees its own create at once.
50
+ locate: (locale, path) => must(send(`/locate?${new URLSearchParams({ locale, path, draft: "1" })}`)),
49
51
  // The bytes as the body with their length, the rest in the query (spec 2026-10-18 media on R2 and Images §4).
50
52
  async upload(bytes, filename, mime, meta) {
51
53
  const type = mime ?? mimeOf(filename);
package/dist/css.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // The custom-CSS box (spec 2026-09-14 §4): a per-section snippet the
2
2
  // wrapper nests under the section's own [data-smd-node] selector. CSS
3
3
  // can do two things beyond styling — pull a remote stylesheet and beacon
4
- // to a host — so those, the two legacy code hooks and the tag-closing
4
+ // to a host — so those, the two old code hooks (`expression()`, `behavior:`) and the tag-closing
5
5
  // sequence are refused at save and stripped at render, in that order.
6
6
  // The checks run on the snippet with its CSS escapes decoded, so
7
7
  // `\75 rl(` cannot spell `url(` past them; and the braces must balance,
@@ -58,8 +58,8 @@ export type Where = Record<string, WhereValue | WhereValue[]>;
58
58
  * adapters read the text form of the whole array, never its elements). */
59
59
  export declare const ARRAY_FIELD_TYPES: ReadonlySet<string>;
60
60
  /** The memory adapter's and the tests' reading of `where`: a scalar matches an
61
- * equal value or an array holding it (JSONB containment); an array is any-of,
62
- * compared as text like PostgREST's `->>`. */
61
+ * equal value or an array holding it (containment); an array is any-of,
62
+ * compared as text (textForm). */
63
63
  export declare function matchesWhere(fields: Record<string, unknown>, where: Where): boolean;
64
64
  export type ListOptions = {
65
65
  /** Equality / any-of on shared fields, executed by the store (spec 2026-09-11 §3). */
@@ -256,8 +256,8 @@ export declare function entryTitleIn(record: EntryRecord, locale: string, schema
256
256
  * directions (the SQLite list compiler sorts nulls last to match);
257
257
  * the creation tiebreak follows `direction` for non-manual orders.
258
258
  * Declared fields compare numerically when both values are numbers,
259
- * otherwise as strings via `localeCompare` — the same order Postgres
260
- * gives the jsonb VALUE (`fields->x`) for one type; see MAINTAINERS.md's
261
- * follow-ups for the mixed-type and JSON-null edges.
259
+ * otherwise as strings via `localeCompare` — the order the SQLite store's
260
+ * `COLLATE NOCASE` gives for one type (ASCII only; see MAINTAINERS.md's
261
+ * follow-ups for the mixed-type, JSON-null and non-ASCII edges).
262
262
  */
263
263
  export declare function compareEntries(order: EntryOrder, seq: (e: EntryRecord) => number): (a: EntryRecord, b: EntryRecord) => number;
@@ -15,12 +15,12 @@ import { slugOf, unsupportedLocale } from "./paths.js";
15
15
  * containment, and any-of on one is refused by the site layer (both
16
16
  * adapters read the text form of the whole array, never its elements). */
17
17
  export const ARRAY_FIELD_TYPES = new Set(["checkboxes", "refList", "list", "tags"]);
18
- /** PostgREST's `->>`: a scalar's text, an array's or object's JSON text (Postgres
19
- * spaces an object's text differently, but no scalar equals either form). */
18
+ /** A field's text form: a scalar's text, an array's or object's JSON text (no scalar
19
+ * equals either form), the same in every store. */
20
20
  const textForm = (value) => (value !== null && typeof value === "object" ? JSON.stringify(value) : String(value));
21
21
  /** The memory adapter's and the tests' reading of `where`: a scalar matches an
22
- * equal value or an array holding it (JSONB containment); an array is any-of,
23
- * compared as text like PostgREST's `->>`. */
22
+ * equal value or an array holding it (containment); an array is any-of,
23
+ * compared as text (textForm). */
24
24
  export function matchesWhere(fields, where) {
25
25
  return Object.entries(where).every(([key, want]) => {
26
26
  const have = fields[key];
@@ -134,9 +134,9 @@ export function entryTitleIn(record, locale, schema) {
134
134
  * directions (the SQLite list compiler sorts nulls last to match);
135
135
  * the creation tiebreak follows `direction` for non-manual orders.
136
136
  * Declared fields compare numerically when both values are numbers,
137
- * otherwise as strings via `localeCompare` — the same order Postgres
138
- * gives the jsonb VALUE (`fields->x`) for one type; see MAINTAINERS.md's
139
- * follow-ups for the mixed-type and JSON-null edges.
137
+ * otherwise as strings via `localeCompare` — the order the SQLite store's
138
+ * `COLLATE NOCASE` gives for one type (ASCII only; see MAINTAINERS.md's
139
+ * follow-ups for the mixed-type, JSON-null and non-ASCII edges).
140
140
  */
141
141
  export function compareEntries(order, seq) {
142
142
  return (a, b) => {