@unitninety/site-kit 1.6.5 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +68 -4
  2. package/package.json +5 -4
  3. package/src/bridge/DraftBlocks.tsx +10 -0
  4. package/src/bridge/U90Bridge.tsx +4 -2
  5. package/src/bridge/caret.ts +7 -8
  6. package/src/bridge/createBridge.ts +30 -10
  7. package/src/bridge/diagnostics.ts +61 -0
  8. package/src/bridge/index.ts +3 -0
  9. package/src/bridge/nativeDraft.ts +47 -36
  10. package/src/bridge/nativeOrder.ts +72 -0
  11. package/src/bridge/nativePath.ts +45 -0
  12. package/src/bridge/registry.ts +3 -2
  13. package/src/bridge/selection.ts +59 -10
  14. package/src/bridge/styles.ts +12 -0
  15. package/src/bridge/useDraftContent.tsx +68 -29
  16. package/src/components/BlockLink.tsx +28 -0
  17. package/src/components/Image.tsx +8 -4
  18. package/src/components/Link.tsx +8 -1
  19. package/src/components/block-navigation.ts +24 -0
  20. package/src/components/draft.ts +19 -0
  21. package/src/components/index.ts +1 -1
  22. package/src/custom-code/fragments.ts +64 -0
  23. package/src/custom-code/index.ts +30 -0
  24. package/src/elements/blockAnchor.ts +7 -0
  25. package/src/elements/index.ts +1 -0
  26. package/src/elements/ops/draftPaths.ts +23 -0
  27. package/src/elements/ops/expand.ts +8 -6
  28. package/src/elements/ops/validate.ts +9 -2
  29. package/src/features/client/README.md +1 -2
  30. package/src/features/client/SiteFeatures.tsx +15 -25
  31. package/src/features/client/index.ts +0 -6
  32. package/src/features/client/verification.ts +3 -0
  33. package/src/features/contracts/base.ts +1 -5
  34. package/src/features/contracts/index.ts +0 -20
  35. package/src/features/contracts/verificationConfig.ts +7 -0
  36. package/src/features/server/gateway.ts +1 -3
  37. package/src/features/server/index.ts +0 -2
  38. package/src/legacy/cli.mjs +2 -2
  39. package/src/legacy/content.ts +1 -1
  40. package/src/server/BlockAnchors.tsx +92 -0
  41. package/src/server/CustomCode.tsx +29 -0
  42. package/src/server/index.ts +1 -0
  43. package/src/server/read.ts +13 -0
  44. package/src/server/render.tsx +12 -6
  45. package/src/site/fields.ts +2 -2
  46. package/src/site/types.ts +4 -2
  47. package/src/site/version.ts +1 -1
  48. package/src/site/wire.ts +3 -0
  49. package/src/features/client/ConsentRuntime.tsx +0 -95
  50. package/src/features/client/useConsent.ts +0 -15
  51. package/src/features/consent/README.md +0 -70
  52. package/src/features/consent/cookieScript.ts +0 -53
  53. package/src/features/consent/custom.ts +0 -89
  54. package/src/features/consent/defaults.ts +0 -40
  55. package/src/features/consent/drivers.ts +0 -233
  56. package/src/features/consent/loader.ts +0 -248
  57. package/src/features/consent/registry.ts +0 -398
  58. package/src/features/contracts/consent.ts +0 -150
  59. package/src/features/server/ConsentDefaults.tsx +0 -15
package/README.md CHANGED
@@ -5,18 +5,41 @@ The library a UnitNinety site is built on. It reads the site's content from Unit
5
5
  You do not usually add it by hand. The `u90` tool adds it when it converts a Next.js App Router site, at the one version that release of the tool works with:
6
6
 
7
7
  ```bash
8
- npx --yes @unitninety/u90@1.0.5 --version
8
+ npx --yes @unitninety/u90@1.1.0 --version
9
9
  ```
10
10
 
11
11
  UnitNinety imports packages made for that version only, so keep the version the tool names.
12
12
 
13
13
  Documentation: [converting a site](https://unitninety.com/docs/convert), [importing it](https://unitninety.com/docs/import) and [the U90 package](https://unitninety.com/docs/package).
14
14
 
15
- ### Layout reordering in Canvas
15
+ ### Keep cleared fields editable
16
16
 
17
- Site Kit 1.6.5 gives every draft layout Slot its own preview address. Moving the Slot or placing a block before it updates immediately through the shared renderer, including nested layouts. The optimistic order stays visible while saving, and older responses cannot undo a newer move. Published output is unchanged.
17
+ Site Kit 1.7.0 preserves real draft addresses on groups and block props. Nullable images still read as `null`; pass their owner address separately so Canvas can select them after a clear or reload:
18
18
 
19
- Use Site Kit 1.6.5 with `@unitninety/u90@1.0.5` for new sites. Existing sites must update their Site Kit dependency and redeploy to receive the fix; updating the platform alone does not update a site's bundled renderer.
19
+ ```tsx
20
+ import { Image, Text, draftFieldPath } from '@unitninety/site-kit/components'
21
+
22
+ export function Hero(props) {
23
+ return <section>
24
+ <Text as="h1" value={props.title} />
25
+ <div style={{ position: 'relative', aspectRatio: '4 / 3' }}>
26
+ <Image value={props.image} draftPath={draftFieldPath(props, 'image')} fill />
27
+ </div>
28
+ </section>
29
+ }
30
+ ```
31
+
32
+ For a nested group or list row, pass that group or row to `draftFieldPath`. For a collection image, pass `entry.fields`. Keep the complete native object, including reserved `$` metadata, when handing it to another component. Iterate declared fields rather than every group property. A null leaf has no identity of its own: prefer `list(group({ image: image() }))` for editable image lists so each row retains its stable address, including when its image is empty.
33
+
34
+ For an existing `list(image())`, use `draftListItemPath(props, 'photos', index)` as the image's `draftPath`. This reads the original stable item ID from the owner's draft metadata, including after clearing and reloading; the index only identifies the current rendered item, not its saved address. Keep the owner metadata with the list. A detached array of null values alone cannot identify its fields. Shared Single aliases likewise retain their canonical addresses separately from null image values.
35
+
36
+ The standard image renderer keeps a bounded missing-image target only when it has a real draft address. Published empty images render nothing. Custom image components must preserve the same target, respect literal clearing, and never reintroduce a seed image as fallback for an explicitly empty field. The bridge also allows blank declared text, rich-text and link-label fields to resume direct editing. Formatted dates and derived values can remain inspector-only.
37
+
38
+ Use the real root `U90Bridge`; inline editing is enabled by default. Deliberate inspector-only integrations may set `inlineEditing={false}` and verify with `u90 check --canvas-mode inspector`. Ordinary checks require inline mode. The bridge exposes only its effective mode booleans in a draft-only diagnostic marker; no session or credentials are included.
39
+
40
+ The local checker verifies the actual root mode, exact field bindings, and observed image replacement → literal clear → restore against isolated content. New packages must record these checks. This does not prove client hydration or CSS hit targets: in a browser, click and type into real headings, clear and replace images, and repeat on mobile and inactive tabs/carousels. Decorative overlays must not intercept editable images; enable pointer events only on their actual interactive content. Preserve test content and report untested states explicitly.
41
+
42
+ For a custom integration's browser diagnostics, `inspectCanvas(document)` from `@unitninety/site-kit/bridge` reads the existing frame without starting another bridge or editing content. It reports the root mode, mounted bridge stylesheet, sampled click targets and blockers. Hidden, offscreen and zero-size fields are reported separately; a reachable sample does not prove typing, saving, or another viewport. Run it inside the site frame, not the outer dashboard.
20
43
 
21
44
  ### Fixed field values
22
45
 
@@ -61,6 +84,19 @@ Call the hook on native reads before converting them for a presentation componen
61
84
 
62
85
  Canvas prepares locally resolvable list defaults with a stable random item ID, previews the same list in both inspector and site, then saves through the normal validated queue. Nested lists, empty lists and duplicate responsive renderings work without DOM cloning. Later edits survive older refresh responses; rejected or ambiguous structural saves reconcile with an authoritative read. Draft IDs cannot collide with an existing item. Media/reference defaults requiring resolution still use the server path, and references continue using their existing entry previews. This opt-in requires the updated Site Kit; older bridges retain their ordinary DOM previews and refresh behavior.
63
86
 
87
+ ### Stable block reorder previews
88
+
89
+ Site Kit 1.6.6 keeps existing block content mounted while Canvas changes its order, including hidden destinations and repeated responsive views. The default `useDraftPreview(source)` preserves native field objects and pending edits instead of expanding a whole zone snapshot again.
90
+
91
+ Custom presentation adapters retain their existing snapshot behavior. If every rendered block collection keeps its native `$path` values, opt into the shared order adapter:
92
+
93
+ ```tsx
94
+ import { useDraftPreview, reorderNativeDraft } from '@unitninety/site-kit/bridge'
95
+ const current = useDraftPreview(source, applyPresentationPatch, reorderNativeDraft)
96
+ ```
97
+
98
+ For plain presentation arrays without native paths, provide a third callback `(source, { path, ids }) => source` that reorders the existing objects by stable block ID. Keep membership, content and unrelated arrays unchanged. Test both the instant preview and the later server refresh; a complete zone snapshot must never replace formatted presentation props with raw native blocks.
99
+
64
100
  ### Immediate collection-list previews
65
101
 
66
102
  Render a query's `EntriesRead` with `Entries` from `@unitninety/site-kit/components` when its
@@ -122,3 +158,31 @@ For example, KAW's Home declares `query('industries', { previewFields: ['name',
122
158
  The site component must reveal the selected entry in its own tabs/carousels, including when its data arrives after selection. `useEditing()` exposes `selected` and `selectedVia`: the latter contains outer-to-inner reference field paths, disambiguating an entry reused in several parents. Mark a displayed list container with `data-u90-reference={reference.$path}` in draft mode to preserve this context when selecting its content on the canvas. Keep `data-u90-field` on each entry's own fields and a root marker on the revealed entry for row highlighting. A relation's target entry path is not its parent relation field path. These attributes do not introduce visitor-facing editing controls.
123
159
 
124
160
  Deploy matching Site Kit/platform code before importing this metadata, and restart a running dev server after changing registry options so Payload validation hooks reload.
161
+ ## Site-wide custom code
162
+
163
+ Settings → Custom code stores three embed fields as a draft. The workspace’s existing Publish control makes that draft live and revalidates the cached site read. Saving a snippet never rebuilds the website. Editing requires Code permission; disabling Code also hides these settings.
164
+
165
+ Install the server renderer in each root layout, using one snapshot for all three placements. Keep the site’s existing head content, body attributes, providers, and children:
166
+
167
+ ```tsx
168
+ import { U90Bridge, U90CustomCode } from '@unitninety/site-kit/server'
169
+ import { u90 } from '@/u90'
170
+
171
+ export default async function RootLayout({ children }: { children: React.ReactNode }) {
172
+ const customCode = await u90.read.customCode()
173
+ return (
174
+ <html lang="en">
175
+ <head><U90CustomCode code={customCode} placement="head" /></head>
176
+ <body>
177
+ <U90CustomCode code={customCode} placement="bodyStart" />
178
+ <U90Bridge>{children}</U90Bridge>
179
+ <U90CustomCode code={customCode} placement="bodyEnd" />
180
+ </body>
181
+ </html>
182
+ )
183
+ }
184
+ ```
185
+
186
+ Keep these slots as direct children of the native head and body; do not wrap them in an HTML container or load the code after hydration. `read.customCode()` returns only published code and suppresses output in authenticated draft and test previews, including Canvas. The website’s existing Content Security Policy continues to apply.
187
+
188
+ Existing sites need one initial code deployment to install a compatible Site Kit and these slots in every root layout. Subsequent custom-code changes use Publish and on-demand revalidation, without another deployment. New converter output includes the installation automatically.
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@unitninety/site-kit",
3
- "version": "1.6.5",
3
+ "version": "1.7.0",
4
4
  "description": "The client-site half of the UnitNinety platform contract: schema authoring, content fetching, the editing bridge and the pairing CLI.",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./src/site/index.ts",
8
8
  "./server": "./src/server/index.ts",
9
+ "./custom-code": "./src/custom-code/index.ts",
9
10
  "./features/server": "./src/features/server/index.ts",
10
11
  "./features/contracts": "./src/features/contracts/index.ts",
11
12
  "./preview-lock": "./src/preview-lock/index.ts",
@@ -46,8 +47,7 @@
46
47
  "!src/elements/fixtures",
47
48
  "!src/elements/harness.ts",
48
49
  "!src/site/fixtures",
49
- "!src/test-preview/fixtures.ts",
50
- "!src/features/consent/test-fixtures.ts"
50
+ "!src/test-preview/fixtures.ts"
51
51
  ],
52
52
  "license": "UNLICENSED",
53
53
  "homepage": "https://unitninety.com",
@@ -60,7 +60,8 @@
60
60
  "access": "public"
61
61
  },
62
62
  "dependencies": {
63
- "zod": "4.6.6"
63
+ "parse5": "7.3.0",
64
+ "zod": "4.4.3"
64
65
  },
65
66
  "peerDependencies": {
66
67
  "react": ">=19",
@@ -6,6 +6,7 @@ import { DraftContentPath } from './useDraftContent'
6
6
  import { registry } from './registry'
7
7
  import { needsEmbedReload, preserveBlockMoves } from './preserveBlockMoves'
8
8
  import { PreviewCommitBoundary } from './PreviewCommitBoundary'
9
+ import { ordered, type DraftOrder } from './nativeOrder'
9
10
 
10
11
  type Item = { id: string; path?: string; content: ReactNode }
11
12
  type Move = { path: string; before: string | null }
@@ -55,6 +56,15 @@ export function DraftBlocks({ items }: { items: Item[] }) {
55
56
  setShown(value => ({ ...value, items: value.items.filter(item => item.path !== path), removed: new Set([...value.removed, path]) }))
56
57
  return true
57
58
  },
59
+ reorder: ({ path, ids }: DraftOrder) => {
60
+ if (!current.some(item => item.path === `${path}/${item.id}`)) return false
61
+ if (host.current && needsEmbedReload(host.current)) {
62
+ reload.current = true
63
+ return true
64
+ }
65
+ setShown(value => ({ ...value, items: ordered(value.items, ids, item => item.id), order: ids }))
66
+ return true
67
+ },
58
68
  move: (path: string, before: string | null) => {
59
69
  if (!current.some((item) => item.path === path)) return false
60
70
  if (before !== null && !current.some((item) => item.path === before)) return false
@@ -22,8 +22,10 @@ import { EditingProvider } from './hooks'
22
22
  type BridgeProps = { platformOrigin: string; children: ReactNode; draft: NextAdapters['draft']; inlineEditing?: boolean; testPreview?: () => Promise<TestPreviewContext | null> }
23
23
  async function Gate({ platformOrigin, children, draft, testPreview, inlineEditing }: BridgeProps) {
24
24
  const runtime = createTestPreviewRuntime({ env: { PLATFORM_URL: process.env.PLATFORM_URL, PAYLOAD_API_KEY: process.env.PAYLOAD_API_KEY, SITE_URL: process.env.SITE_URL } })
25
- const props = await resolveBridgePreview({ platformOrigin, draft, resolve: testPreview ? () => testPreview() : token => runtime.resolve(token) })
26
- return <EditingProvider {...props} inlineEditing={inlineEditing}>{children}</EditingProvider>
25
+ const state = await draft()
26
+ const props = await resolveBridgePreview({ platformOrigin, draft: async () => state, resolve: testPreview ? () => testPreview() : token => runtime.resolve(token) })
27
+ const diagnostic = state.enabled || props.testPreview ? { version: 1, editing: props.editing, inlineEditing: inlineEditing !== false, testPreview: props.testPreview } : null
28
+ return <>{diagnostic ? <span hidden data-u90-bridge={JSON.stringify(diagnostic)} /> : null}<EditingProvider {...props} inlineEditing={inlineEditing}>{children}</EditingProvider></>
27
29
  }
28
30
 
29
31
  /**
@@ -89,12 +89,13 @@ export const MEDIA_SELECTOR = `[${FIELD_ATTR}]:is(${MEDIA})`
89
89
  */
90
90
  export const isEditableLeaf = (element: HTMLElement): boolean => {
91
91
  if (element.matches(MEDIA)) return false
92
- if (element.getAttribute('data-u90-edit') === 'rich') return Boolean(element.textContent?.trim())
92
+ const kind = element.getAttribute('data-u90-edit')
93
+ if (kind === 'rich') return true
93
94
 
94
95
  return (
95
96
  !element.querySelector(`[${FIELD_ATTR}]`) &&
96
97
  !element.querySelector(NON_TEXT) &&
97
- Boolean(element.textContent?.trim())
98
+ (Boolean(element.textContent?.trim()) || kind === 'text' || kind === 'link-label')
98
99
  )
99
100
  }
100
101
 
@@ -110,10 +111,8 @@ export const isEditableLeaf = (element: HTMLElement): boolean => {
110
111
  * `:not(:is(${MEDIA}))` still runs unconditionally, outside that `:is()` — a marked image is
111
112
  * a picture, rich or not, and the caret has no business there either.
112
113
  *
113
- * The one clause CSS still cannot express is "and it has some text in it" — an empty marked
114
- * element therefore offers an I-beam and does nothing when clicked, which is the least
115
- * interesting way for the two to fall out of step and beats making every site component
116
- * carry a class.
114
+ * Explicit native edit kinds remain editable when blank. The bridge marks those empty
115
+ * surfaces so draft CSS can give them a clickable box without adding saved placeholder text.
117
116
  *
118
117
  * v4 change: `[${RICH_ATTR}]` (legacy's presence selector for the standalone rich attribute)
119
118
  * becomes the value selector `[data-u90-edit="rich"]`.
@@ -235,8 +234,8 @@ const endOf = (element: HTMLElement): Range => {
235
234
  const last = lastWordsIn(element)
236
235
 
237
236
  if (!last) {
238
- // No text at all. `isEditableLeaf` rules this out for the click path; a direct caller
239
- // gets the old behaviour, which is the only thing available.
237
+ // An empty native field has no text node yet. Place the caret in its element;
238
+ // the browser creates the first text node when the person types.
240
239
  range.selectNodeContents(element)
241
240
  range.collapse(false)
242
241
  return range
@@ -4,7 +4,8 @@ import { toRichText, type DomLike } from './lexicalDom'
4
4
  import { applyPreview, type Undo } from './preview'
5
5
  import { BRIDGE_VERSION, MAX_OFFENDER_LENGTH, MAX_OFFENDERS, parseCanvasMessage, type BridgeStamp, type PreviewInstruction, type SiteMessage } from './protocol'
6
6
  import { registry } from './registry'
7
- import { createSelection } from './selection'
7
+ import { snapshotOrder } from './nativeOrder'
8
+ import { createSelection, selectionVia } from './selection'
8
9
  import { EDITING_CSS } from './styles'
9
10
 
10
11
  export type BridgeOptions = {
@@ -98,6 +99,15 @@ export function createBridge(win: Window, opts: BridgeOptions) {
98
99
 
99
100
  let pending: { path: string; kind: 'text' | 'rich' | 'link-label'; el: HTMLElement } | null = null
100
101
  let timer: ReturnType<typeof setTimeout> | null = null
102
+ let emptyFields: MutationObserver | null = null
103
+ const markEmptyField = (el: HTMLElement) => {
104
+ const kind = el.getAttribute('data-u90-edit')
105
+ const empty = (kind === 'text' || kind === 'rich' || kind === 'link-label') && !el.textContent?.trim() && isEditableLeaf(el)
106
+ el.toggleAttribute('data-u90-empty', empty)
107
+ }
108
+ const markEmptyFields = () => {
109
+ for (const el of doc.querySelectorAll<HTMLElement>('[data-u90-field][data-u90-edit], [data-u90-empty]')) markEmptyField(el)
110
+ }
101
111
  const valueOf = (el: HTMLElement, kind: 'text' | 'rich' | 'link-label') => (kind === 'rich' ? toRichText(asDomLike(el)) : textWithTokens(el))
102
112
  const flush = () => {
103
113
  if (timer) clearTimeout(timer)
@@ -249,7 +259,7 @@ export function createBridge(win: Window, opts: BridgeOptions) {
249
259
  /** Outline the element at `path` (field or block) and clear any previous outline. */
250
260
  const selection = createSelection(win)
251
261
  const markSelected = (path: string | null, scroll = false, via?: string[]) => {
252
- selection.select(path, scroll)
262
+ selection.select(path, scroll, via)
253
263
  opts.onSelect?.(path, via)
254
264
  }
255
265
 
@@ -386,12 +396,7 @@ export function createBridge(win: Window, opts: BridgeOptions) {
386
396
  // (a menu toggle) still works while the Canvas is open.
387
397
  e.preventDefault()
388
398
  }
389
- const ancestors: string[] = []
390
- for (let node: Element | null = el ?? null; node; node = node.parentElement) {
391
- const reference = node.getAttribute('data-u90-reference')
392
- if (reference && reference !== path) ancestors.unshift(reference)
393
- }
394
- const via = ancestors.length > 0 && ancestors.length <= 5 ? ancestors : undefined
399
+ const via = selectionVia(el ?? null, path)
395
400
  markSelected(path, false, via)
396
401
  send({ type: 'u90:select', path, ...(via ? {via} : {}) })
397
402
  const edit = el?.getAttribute('data-u90-edit') as 'text' | 'rich' | 'link-label' | null
@@ -408,6 +413,7 @@ export function createBridge(win: Window, opts: BridgeOptions) {
408
413
  if (opts.inlineEditing === false) return
409
414
  const el = (e.target as Element | null)?.closest<HTMLElement>('[data-u90-edit]')
410
415
  if (el) {
416
+ markEmptyField(el)
411
417
  schedule(el)
412
418
  // The Canvas owns save debouncing. Its inspector needs each input immediately, including
413
419
  // during continuous typing and just before focus crosses into the parent document.
@@ -571,7 +577,10 @@ export function createBridge(win: Window, opts: BridgeOptions) {
571
577
  const draft = instruction.kind === 'value' ? instruction : instruction.draft
572
578
  if (draft) {
573
579
  let handled = false
574
- for (const owner of registry.draftValues) if (owner.document === doc) handled = owner.preview(draft) || handled
580
+ const order = instruction.kind === 'move' ? snapshotOrder(draft, instruction.path) : undefined
581
+ for (const owner of registry.draftValues) if (owner.document === doc) handled = owner.preview(draft, order) || handled
582
+ // Update every rendered instance; hidden destinations only exist in the full snapshot.
583
+ if (order) for (const owner of registry.blockOrders) if (owner.document === doc) handled = !!owner.reorder?.(order) || handled
575
584
  if (handled || instruction.kind === 'value') return
576
585
  }
577
586
  const list = instruction.kind === 'list' ? instruction : instruction.list
@@ -591,9 +600,11 @@ export function createBridge(win: Window, opts: BridgeOptions) {
591
600
  if (handled) return
592
601
  }
593
602
  if (instruction.kind === 'move') {
603
+ let handled = false
594
604
  for (const owner of registry.blockOrders) {
595
- if (owner.document === doc && owner.move(instruction.path, instruction.before)) return
605
+ if (owner.document === doc) handled = owner.move(instruction.path, instruction.before) || handled
596
606
  }
607
+ if (handled) return
597
608
  }
598
609
  if (refreshing && MOVES_NODES.has(instruction.kind)) return
599
610
  if ((instruction.kind === 'text' || instruction.kind === 'rich' || instruction.kind === 'link-label') && editing?.getAttribute('data-u90-field') === instruction.path && !doc.hasFocus()) {
@@ -962,6 +973,12 @@ export function createBridge(win: Window, opts: BridgeOptions) {
962
973
 
963
974
  doc.head.appendChild(style)
964
975
  if (opts.inlineEditing === false) doc.documentElement.setAttribute('data-u90-inspector-only', '')
976
+ markEmptyFields()
977
+ const Observer = (win as Window & typeof globalThis).MutationObserver
978
+ if (Observer) {
979
+ emptyFields = new Observer(markEmptyFields)
980
+ emptyFields.observe(doc.body, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ['data-u90-field', 'data-u90-edit'] })
981
+ }
965
982
  selection.start()
966
983
  applyDraftLanguage()
967
984
  doc.addEventListener('click', onClick, true)
@@ -996,6 +1013,9 @@ export function createBridge(win: Window, opts: BridgeOptions) {
996
1013
  stopped = true
997
1014
  endEditing() // what was typed goes out, and no element is left contenteditable
998
1015
  flush()
1016
+ emptyFields?.disconnect()
1017
+ emptyFields = null
1018
+ doc.querySelectorAll('[data-u90-empty]').forEach(el => el.removeAttribute('data-u90-empty'))
999
1019
  releaseViewport()
1000
1020
  style.remove()
1001
1021
  if (opts.inlineEditing === false) doc.documentElement.removeAttribute('data-u90-inspector-only')
@@ -0,0 +1,61 @@
1
+ type Runtime = { version: 1; editing: boolean; inlineEditing: boolean; testPreview: boolean }
2
+ export type CanvasFieldDiagnostic = {
3
+ path: string
4
+ status: 'reachable' | 'occluded' | 'hidden' | 'no-box' | 'offscreen' | 'untested'
5
+ samples: number
6
+ hits: number
7
+ blockers: string[]
8
+ }
9
+ export type CanvasDiagnostic = {
10
+ runtime: Runtime | null
11
+ bridgeMounted: boolean
12
+ viewport: { width: number; height: number }
13
+ fields: CanvasFieldDiagnostic[]
14
+ }
15
+
16
+ /** Read-only snapshot of the existing frame. It neither starts a bridge nor changes selection.
17
+ * Reachability samples only the current viewport/state, not typing, saving or hidden slides. */
18
+ export function inspectCanvas(doc: Document = document): CanvasDiagnostic {
19
+ const win = doc.defaultView
20
+ const viewport = { width: win?.innerWidth ?? 0, height: win?.innerHeight ?? 0 }
21
+ let runtime: Runtime | null = null
22
+ const markers = doc.querySelectorAll('[data-u90-bridge]')
23
+ if (markers.length === 1) try {
24
+ const value = JSON.parse(markers[0].getAttribute('data-u90-bridge') ?? '')
25
+ if (value.version === 1 && ['editing', 'inlineEditing', 'testPreview'].every(key => typeof value[key] === 'boolean'))
26
+ runtime = { version: 1, editing: value.editing, inlineEditing: value.inlineEditing, testPreview: value.testPreview }
27
+ } catch { /* An absent or invalid root is not a verified runtime. */ }
28
+ const fields = Array.from(doc.querySelectorAll<HTMLElement>('[data-u90-field]'), el => {
29
+ const path = el.getAttribute('data-u90-field') ?? ''
30
+ const result: CanvasFieldDiagnostic = { path, status: 'untested', samples: 0, hits: 0, blockers: [] }
31
+ // Visibility is inherited but can be overridden by a visible descendant.
32
+ const visibility = win?.getComputedStyle(el).visibility
33
+ if (visibility === 'hidden' || visibility === 'collapse') return { ...result, status: 'hidden' as const }
34
+ for (let node: HTMLElement | null = el; node; node = node.parentElement) {
35
+ const style = win?.getComputedStyle(node)
36
+ if (node.hidden || style?.display === 'none' || style?.opacity === '0') return { ...result, status: 'hidden' as const }
37
+ }
38
+ let rects = Array.from(el.getClientRects()).filter(rect => rect.width > 0 && rect.height > 0)
39
+ if (!rects.length && win?.getComputedStyle(el).display === 'contents') {
40
+ rects = Array.from(el.querySelectorAll('*')).filter(child => child.closest('[data-u90-field]') === el)
41
+ .flatMap(child => Array.from(child.getClientRects())).filter(rect => rect.width > 0 && rect.height > 0)
42
+ }
43
+ if (!rects.length) return { ...result, status: 'no-box' as const }
44
+ const visible = rects.map(rect => ({ left: Math.max(0, rect.left), right: Math.min(viewport.width, rect.right), top: Math.max(0, rect.top), bottom: Math.min(viewport.height, rect.bottom) })).filter(rect => rect.right > rect.left && rect.bottom > rect.top)
45
+ if (!visible.length) return { ...result, status: 'offscreen' as const }
46
+ if (typeof doc.elementFromPoint !== 'function') return result
47
+ const blockers = new Set<string>()
48
+ for (const rect of visible.slice(0, 3)) for (const x of [.2, .5, .8]) for (const y of [.2, .5, .8]) {
49
+ result.samples++
50
+ const hit = doc.elementFromPoint(rect.left + (rect.right - rect.left) * x, rect.top + (rect.bottom - rect.top) * y)
51
+ const selected = hit?.closest('[data-u90-field], [data-u90-block]')
52
+ if (selected?.getAttribute('data-u90-field') === path) result.hits++
53
+ else if (hit) blockers.add(`${hit.tagName.toLowerCase()}${Array.from(hit.classList).slice(0, 3).map(name => `.${name}`).join('')}`)
54
+ else blockers.add('(no element)')
55
+ }
56
+ result.blockers = [...blockers].slice(0, 8)
57
+ result.status = result.hits ? 'reachable' : 'occluded'
58
+ return result
59
+ })
60
+ return { runtime, bridgeMounted: !!doc.querySelector('head style[data-u90-editing]'), viewport, fields }
61
+ }
@@ -13,3 +13,6 @@ export { DraftBlocks } from './DraftBlocks'
13
13
 
14
14
  /** React-owned previews for client components reading list content inside a draft block. */
15
15
  export { useDraftContent, useDraftPreview, DraftReferences, applyNativeDraft, type NativeDraftPatch } from './useDraftContent'
16
+ export { reorderNativeDraft, type NativeDraftOrder } from './nativeOrder'
17
+ /** Read-only diagnostics for the current frame; hidden/offscreen states remain untested. */
18
+ export { inspectCanvas, type CanvasDiagnostic, type CanvasFieldDiagnostic } from './diagnostics'
@@ -1,57 +1,44 @@
1
1
  import { isLeaf, type EntryRead, type ExpandCtx, type FieldDecl, type Id, type MediaRecord, type PageRecord, type Schema } from '../elements/define'
2
2
  import { getContainer } from '../elements/registry'
3
3
  import { expandField } from '../elements/ops/expand'
4
+ import { draftGroupMetadata, rememberDraftList } from '../elements/ops/draftPaths'
4
5
  import { applyQueryPicks, type QueryStored } from '../elements/leaves/query'
5
6
  import type { EntriesRead } from '../elements/leaves/selection'
6
7
  import type { DraftSnapshot } from './protocol'
8
+ import { reorderNativeDraft, type DraftOrder } from './nativeOrder'
9
+ import { nativeChildPath, nativeListItemPath, nativeListPaths, nativePath, safeDraftPath } from './nativePath'
10
+ export { nativePath, safeDraftPath } from './nativePath'
7
11
 
8
12
  const unsafe = new Set(['__proto__', 'constructor', 'prototype'])
9
13
  const object = (value: unknown): value is Record<string, any> => !!value && typeof value === 'object' && !Array.isArray(value)
10
- export const safeDraftPath = (path: string) => /^(page|entry|layout|single):[^/]+(?:\/[^/]+)*$/.test(path) && !path.split('/').some(key => unsafe.has(key))
11
-
12
- /** Native groups do not carry paths; their children do. Infer only when the children agree. */
13
- export function nativePath(value: unknown, seen = new Set<object>()): string | undefined {
14
- if (!value || typeof value !== 'object' || seen.has(value)) return
15
- seen.add(value)
16
- if (object(value) && typeof value.$path === 'string') return safeDraftPath(value.$path) ? value.$path : undefined
17
- const candidates = new Set<string>()
18
- for (const [key, child] of Object.entries(value)) {
19
- if (unsafe.has(key) || key.startsWith('$')) continue
20
- const at = nativePath(child, seen)
21
- if (!at) continue
22
- if (Array.isArray(value) && at.includes('/')) candidates.add(at.slice(0, at.lastIndexOf('/')))
23
- else if (at.endsWith(`/${key}`)) candidates.add(at.slice(0, -key.length - 1))
24
- }
25
- return candidates.size === 1 ? [...candidates][0] : undefined
26
- }
27
-
28
14
  /** Immutable path replacement across any mixture of native document, entry and field reads. */
29
- export function applyNativeDraft<T>(source: T, path: string, replacement: unknown): T {
15
+ export function applyNativeDraft<T>(source: T, path: string, replacement: unknown, rootPath?: string): T {
30
16
  if (!safeDraftPath(path)) return source
31
- const visit = (value: unknown, parent?: string, seen = new Set<object>()): unknown => {
17
+ const visit = (value: unknown, parent?: string, seen = new Set<object>(), inheritedListPaths?: readonly string[]): unknown => {
32
18
  const at = nativePath(value) ?? parent
33
19
  if (at === path || parent === path) return replacement
34
20
  if (!value || typeof value !== 'object' || seen.has(value)) return value
35
21
  const nextSeen = new Set(seen).add(value)
22
+ const paths = Array.isArray(value) ? nativeListPaths(value) ?? inheritedListPaths : undefined
36
23
  let result: any = value
37
24
  for (const [key, child] of Object.entries(value)) {
38
25
  if (unsafe.has(key) || key.startsWith('$')) continue
39
- const id = Array.isArray(value) ? (object(child) ? child._id : undefined) : key
40
- const next = visit(child, at && typeof id === 'string' ? `${at}/${id}` : undefined, nextSeen)
26
+ const childPath = Array.isArray(value) ? nativeListItemPath(value, Number(key), paths, at) : nativeChildPath(value, key, at)
27
+ const next = visit(child, childPath, nextSeen, Array.isArray(child) ? nativeListPaths(child, value, key) : undefined)
41
28
  if (next !== child) {
42
- if (result === value) result = Array.isArray(value) ? [...value] : { ...value }
29
+ if (result === value) result = Array.isArray(value) ? rememberDraftList([...value], paths) : { ...value }
43
30
  result[key] = next
44
31
  }
45
32
  }
46
33
  return result
47
34
  }
48
- return visit(source) as T
35
+ return visit(source, nativePath(source) ?? (rootPath && safeDraftPath(rootPath) ? rootPath : undefined)) as T
49
36
  }
50
37
 
51
38
  /** A local index of real native reads. Missing references remain missing until the server answers. */
52
39
  export class NativeDraftReferences {
53
40
  private reads = new Map<string, unknown>()
54
- private pending = new Map<string, unknown>()
41
+ private pending = new Map<string, { kind: 'value'; path: string; value: unknown } | { kind: 'order'; path: string; ids: string[] }>()
55
42
  private media = new Map<string, MediaRecord>()
56
43
  private pages = new Map<string, PageRecord>()
57
44
  private entries = new Map<string, EntryRead>()
@@ -60,11 +47,14 @@ export class NativeDraftReferences {
60
47
 
61
48
  add(values: unknown): void {
62
49
  const seen = new Set<object>()
63
- const visit = (value: unknown, parent?: string) => {
50
+ const visit = (value: unknown, parent?: string, inheritedListPaths?: readonly string[]) => {
64
51
  if (parent) this.reads.set(parent, value)
52
+ if (parent?.startsWith('single:') && !parent.includes('/')) this.singles.set(parent.slice(7), value)
65
53
  if (!value || typeof value !== 'object' || seen.has(value)) return
66
54
  seen.add(value)
67
55
  const at = nativePath(value) ?? parent
56
+ const paths = Array.isArray(value) ? nativeListPaths(value) ?? inheritedListPaths : undefined
57
+ if (Array.isArray(value)) rememberDraftList(value, paths)
68
58
  if (at) this.reads.set(at, value)
69
59
  if (object(value)) {
70
60
  if (at?.startsWith('single:') && !at.includes('/')) this.singles.set(at.slice(7), value)
@@ -96,21 +86,38 @@ export class NativeDraftReferences {
96
86
  }
97
87
  for (const [key, child] of Object.entries(value)) {
98
88
  if (unsafe.has(key) || key.startsWith('$')) continue
99
- const id = Array.isArray(value) ? (object(child) ? child._id : undefined) : key
100
- visit(child, at && typeof id === 'string' ? `${at}/${id}` : undefined)
89
+ const childPath = Array.isArray(value) ? nativeListItemPath(value, Number(key), paths, at) : nativeChildPath(value, key, at)
90
+ visit(child, childPath, Array.isArray(child) ? nativeListPaths(child, value, key) : undefined)
101
91
  }
102
92
  }
103
93
  visit(values)
104
- for (const [path, value] of this.pending) this.applyPatch(path, value)
94
+ for (const patch of this.pending.values()) {
95
+ if (patch.kind === 'value') this.applyPatch(patch.path, patch.value)
96
+ else this.applyOrder(patch)
97
+ }
105
98
  }
106
99
 
107
100
  patch(path: string, value: unknown): void {
108
101
  if (!path.startsWith('entry:') && !path.startsWith('single:')) return
109
- for (const key of this.pending.keys()) if (key === path || key.startsWith(`${path}/`)) this.pending.delete(key)
110
- this.pending.set(path, value)
102
+ for (const [key, patch] of this.pending) if (patch.path === path || patch.path.startsWith(`${path}/`)) this.pending.delete(key)
103
+ this.pending.set(`value:${path}`, { kind: 'value', path, value })
111
104
  this.applyPatch(path, value)
112
105
  }
113
106
 
107
+ order(order: DraftOrder): void {
108
+ const key = `order:${order.path}`
109
+ this.pending.delete(key)
110
+ this.pending.set(key, { kind: 'order', ...order })
111
+ this.applyOrder(order)
112
+ }
113
+
114
+ private applyOrder(order: DraftOrder): void {
115
+ for (const [key, read] of this.reads) this.reads.set(key, reorderNativeDraft(read, order))
116
+ for (const [key, entry] of this.entries) this.entries.set(key, reorderNativeDraft(entry, order))
117
+ for (const [key, single] of this.singles) this.singles.set(key, reorderNativeDraft(single, order))
118
+ for (const [key, query] of this.queries) this.queries.set(key, reorderNativeDraft(query, order))
119
+ }
120
+
114
121
  beforeRefresh(): void { this.pending.clear() }
115
122
 
116
123
  settle(values: unknown): void {
@@ -124,9 +131,9 @@ export class NativeDraftReferences {
124
131
  }
125
132
 
126
133
  private applyPatch(path: string, value: unknown): void {
127
- for (const [key, read] of this.reads) this.reads.set(key, key === path ? value : applyNativeDraft(read, path, value))
128
- for (const [key, entry] of this.entries) this.entries.set(key, applyNativeDraft(entry, path, value))
129
- for (const [key, single] of this.singles) this.singles.set(key, applyNativeDraft(single, path, value))
134
+ for (const [key, read] of this.reads) this.reads.set(key, key === path ? value : applyNativeDraft(read, path, value, key))
135
+ for (const [key, entry] of this.entries) this.entries.set(key, applyNativeDraft(entry, path, value, `entry:${entry.id}`))
136
+ for (const [key, single] of this.singles) this.singles.set(key, applyNativeDraft(single, path, value, `single:${key}`))
130
137
  for (const [key, query] of this.queries) this.queries.set(key, applyNativeDraft(query, path, value))
131
138
  }
132
139
 
@@ -162,8 +169,11 @@ export class NativeDraftReferences {
162
169
  return expandField(schema, field, value, ctx(at))
163
170
  }
164
171
  const children = getContainer(field.container).children(field, field.container === 'u90/group' && !object(value) ? {} : value, schema)
165
- if (field.container === 'u90/group') return Object.fromEntries(children.map(child => [child.key, expand(child.decl, child.value, `${at}/${child.key}`)]))
166
- return children.map((child, index) => {
172
+ if (field.container === 'u90/group') {
173
+ const fields = Object.fromEntries(children.map(child => [child.key, expand(child.decl, child.value, `${at}/${child.key}`)]))
174
+ return { ...draftGroupMetadata(field.fields, at, fields), ...fields }
175
+ }
176
+ const expanded = children.map((child, index) => {
167
177
  const path = `${at}/${child.key}`
168
178
  const inner = expand(child.decl, child.value, path)
169
179
  if (field.container === 'u90/list') return isLeaf(field.item) ? inner : { _id: child.key, $path: path, ...inner as object }
@@ -171,6 +181,7 @@ export class NativeDraftReferences {
171
181
  const metadata = { _id: child.key, _block: item._block, ...(typeof item._hidden === 'boolean' ? { _hidden: item._hidden } : {}) }
172
182
  return Object.hasOwn(schema.blocks, String(item._block)) ? { ...metadata, $path: path, ...inner as object } : metadata
173
183
  })
184
+ return field.container === 'u90/list' ? rememberDraftList(expanded, children.map(child => `${at}/${child.key}`)) : expanded
174
185
  }
175
186
  return expand(snapshot.field, snapshot.value, snapshot.path)
176
187
  }
@@ -0,0 +1,72 @@
1
+ import { rememberDraftList } from '../elements/ops/draftPaths'
2
+ import { nativeChildPath, nativeListItemPath, nativeListPaths, nativePath, safeDraftPath } from './nativePath'
3
+ import type { DraftSnapshot } from './protocol'
4
+
5
+ export type DraftOrder = { path: string; ids: string[] }
6
+ export type NativeDraftOrder = DraftOrder
7
+ const unsafe = new Set(['__proto__', 'constructor', 'prototype'])
8
+
9
+ /** A move's snapshot may start above its zone (for example a zone inside a group).
10
+ * Read only the target's IDs; reconstructing its fields would discard site adapters. */
11
+ export function snapshotOrder(snapshot: DraftSnapshot, movedPath: string): DraftOrder | undefined {
12
+ const path = movedPath.slice(0, movedPath.lastIndexOf('/'))
13
+ if (!safeDraftPath(path) || (path !== snapshot.path && !path.startsWith(`${snapshot.path}/`))) return
14
+ let value: unknown = snapshot.value
15
+ for (const key of path.slice(snapshot.path.length).split('/').filter(Boolean)) {
16
+ if (unsafe.has(key)) return
17
+ value = Array.isArray(value) ? value.find(item => item?._id === key)
18
+ : value && typeof value === 'object' && Object.hasOwn(value, key) ? (value as Record<string, unknown>)[key] : undefined
19
+ }
20
+ if (!Array.isArray(value) || !value.every(item => item && typeof item._id === 'string' && typeof item._block === 'string')) return
21
+ const ids = value.map(item => item._id as string)
22
+ if (new Set(ids).size !== ids.length || !ids.includes(movedPath.slice(path.length + 1))) return
23
+ return { path, ids }
24
+ }
25
+
26
+ /** Preserve membership and object identity, including items a newer render introduced. */
27
+ export function ordered<T>(items: T[], ids: string[], id: (item: T) => string | undefined): T[] {
28
+ const ranks = new Map(ids.map((key, index) => [key, index]))
29
+ const known = items.filter(item => ranks.has(id(item)!)).sort((a, b) => ranks.get(id(a)!)! - ranks.get(id(b)!)!)
30
+ let index = 0
31
+ const next = items.map(item => ranks.has(id(item)!) ? known[index++]! : item)
32
+ return next.every((item, i) => item === items[i]) ? items : next
33
+ }
34
+
35
+ /** Reorder each native/presentation alias of a zone, leaving its content intact. */
36
+ export function applyNativeOrder<T>(source: T, order: DraftOrder): { value: T; matched: boolean } {
37
+ if (!safeDraftPath(order.path)) return { value: source, matched: false }
38
+ let matched = false
39
+ const visit = (value: unknown, parent?: string, seen = new Set<object>(), inheritedListPaths?: readonly string[]): unknown => {
40
+ if (!value || typeof value !== 'object' || seen.has(value) || '$$typeof' in value) return value
41
+ const at = nativePath(value) ?? parent
42
+ const paths = Array.isArray(value) ? nativeListPaths(value) ?? inheritedListPaths : undefined
43
+ if (Array.isArray(value) && at === order.path) {
44
+ matched = true
45
+ const indexed = value.map((item, index) => ({ item, index }))
46
+ const sorted = ordered(indexed, order.ids, ({ item, index }) => {
47
+ const path = paths?.[index] ?? nativePath(item)
48
+ return path?.startsWith(`${order.path}/`) ? path.slice(order.path.length + 1)
49
+ : typeof item?._id === 'string' ? item._id : undefined
50
+ })
51
+ return sorted === indexed ? value : rememberDraftList(sorted.map(({ item }) => item), paths ? sorted.map(({ index }) => paths[index]!) : undefined)
52
+ }
53
+ const nextSeen = new Set(seen).add(value)
54
+ let result: any = value
55
+ for (const [key, child] of Object.entries(value)) {
56
+ if (unsafe.has(key) || key.startsWith('$')) continue
57
+ const childPath = Array.isArray(value) ? nativeListItemPath(value, Number(key), paths, at) : nativeChildPath(value, key, at)
58
+ const next = visit(child, childPath, nextSeen, Array.isArray(child) ? nativeListPaths(child, value, key) : undefined)
59
+ if (next !== child) {
60
+ if (result === value) result = Array.isArray(value) ? rememberDraftList([...value], paths) : { ...value }
61
+ result[key] = next
62
+ }
63
+ }
64
+ return result
65
+ }
66
+ return { value: visit(source) as T, matched }
67
+ }
68
+
69
+ /** Opt-in reorder adapter for presentation collections whose blocks retain native $path values. */
70
+ export function reorderNativeDraft<T>(source: T, order: NativeDraftOrder): T {
71
+ return applyNativeOrder(source, order).value
72
+ }