nudge-ui 0.2.3 → 0.2.4

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 (43) hide show
  1. package/dist/client.mjs +17 -9
  2. package/dist/inspector/agent/verification.d.ts +4 -6
  3. package/dist/inspector/agent/verification.js +60 -16
  4. package/dist/inspector/canvas/CanvasCard.js +2 -0
  5. package/dist/inspector/canvas/CanvasElementOverlay.js +17 -3
  6. package/dist/inspector/canvas/CanvasWorkspace.js +46 -66
  7. package/dist/inspector/canvas/frameScrollbarStyles.d.ts +3 -0
  8. package/dist/inspector/canvas/frameScrollbarStyles.js +21 -0
  9. package/dist/inspector/changes/changesLog.d.ts +3 -1
  10. package/dist/inspector/changes/changesLog.js +8 -0
  11. package/dist/inspector/changes/codecs.d.ts +1 -0
  12. package/dist/inspector/changes/codecs.js +10 -1
  13. package/dist/inspector/changes/draftChanges.d.ts +2 -0
  14. package/dist/inspector/changes/draftChanges.js +4 -0
  15. package/dist/inspector/changes/editModel.d.ts +10 -0
  16. package/dist/inspector/changes/editModel.js +9 -2
  17. package/dist/inspector/changes/types.d.ts +7 -0
  18. package/dist/inspector/comments/element.js +10 -3
  19. package/dist/inspector/componentSemantics/adapterRegistry.d.ts +6 -1
  20. package/dist/inspector/componentSemantics/adapterRegistry.js +23 -1
  21. package/dist/inspector/componentSemantics/reactRuntime.d.ts +2 -1
  22. package/dist/inspector/componentSemantics/reactRuntime.js +38 -0
  23. package/dist/inspector/componentSemantics/runtimeBridge.d.ts +4 -2
  24. package/dist/inspector/componentSemantics/runtimeBridge.js +17 -0
  25. package/dist/inspector/componentSemantics/types.d.ts +11 -0
  26. package/dist/inspector/iterations/capture.js +2 -1
  27. package/dist/inspector/projection/renderedInstance.d.ts +15 -2
  28. package/dist/inspector/projection/renderedInstance.js +53 -6
  29. package/dist/inspector/projection/structuralProjection.js +2 -2
  30. package/dist/inspector/projection/textProjection.js +56 -11
  31. package/dist/inspector/prompt/clipboardHandoff.d.ts +5 -0
  32. package/dist/inspector/prompt/clipboardHandoff.js +5 -0
  33. package/dist/inspector/prompt/generatePrompt.js +5 -1
  34. package/dist/inspector/selection/sourceSite.d.ts +12 -0
  35. package/dist/inspector/selection/sourceSite.js +28 -0
  36. package/dist/inspector/shell/CopyPromptButton.js +49 -7
  37. package/dist/inspector/styleEditors/BackgroundEditor.js +1 -1
  38. package/dist/inspector/tokens/TokenField.js +6 -4
  39. package/dist/inspector/tokens/editActions.js +6 -0
  40. package/dist/inspector/ui/styles.js +1 -1
  41. package/dist/inspector/workspace/sentItems.d.ts +19 -0
  42. package/dist/inspector/workspace/sentItems.js +51 -0
  43. package/package.json +2 -2
@@ -1,17 +1,24 @@
1
1
  import { resolveRenderedInstance } from "../projection/renderedInstance.js";
2
- import { sourceSiteSelector } from "../selection/sourceSite.js";
2
+ import { relocateSourceSite, sourceSiteSelector } from "../selection/sourceSite.js";
3
3
  /** Retains a changed target only when its source site still identifies one output. */
4
4
  export function resolveCommentElement(doc, target) {
5
5
  const exact = resolveRenderedInstance(doc, target);
6
6
  if (exact.status === "resolved")
7
7
  return exact.element;
8
- const selector = sourceSiteSelector(target.sourceSite.cid, target.sourceSite.src);
8
+ const { props, ariaLabel } = target.locator;
9
+ const sourceSite = relocateSourceSite(doc, target.sourceSite, (element) => element.getAttribute("data-cprops") === props && element.getAttribute("aria-label") === (ariaLabel ?? null));
10
+ if (sourceSite) {
11
+ const moved = resolveRenderedInstance(doc, { ...target, sourceSite });
12
+ if (moved.status === "resolved")
13
+ return moved.element;
14
+ }
15
+ const site = sourceSite ?? target.sourceSite;
16
+ const selector = sourceSiteSelector(site.cid, site.src);
9
17
  if (!selector)
10
18
  return null;
11
19
  const candidates = Array.from(doc.querySelectorAll(selector));
12
20
  if (candidates.length === 1)
13
21
  return candidates[0];
14
- const { props, ariaLabel } = target.locator;
15
22
  if (!props && !ariaLabel)
16
23
  return null;
17
24
  const matching = candidates.filter((element) => element.getAttribute("data-cprops") === props
@@ -1,7 +1,12 @@
1
- import type { ComponentOverride, ComponentRuntimeAdapter, EditableComponentTarget, RuntimeComponentTarget } from "./types.js";
1
+ import type { ComponentOverride, ComponentRootInvocation, ComponentRuntimeAdapter, EditableComponentTarget, RuntimeComponentTarget } from "./types.js";
2
2
  /** Register a framework adapter without coupling semantic resolution to React. */
3
3
  export declare function registerComponentRuntimeAdapter(adapter: ComponentRuntimeAdapter): () => void;
4
4
  export declare function inspectComponentTargets(element: HTMLElement): RuntimeComponentTarget[];
5
+ /**
6
+ * Returns the invocations, nearest first, whose entire rendered output is this
7
+ * element. Null means no enabled runtime could read the element's ancestry.
8
+ */
9
+ export declare function inspectRootInvocations(element: HTMLElement): ComponentRootInvocation[] | null;
5
10
  /**
6
11
  * Resolve multiplicity through the owning runtime Adapter. The inspector does
7
12
  * not infer it from DOM order or retain a React fiber as durable identity.
@@ -1,5 +1,5 @@
1
1
  import { getNudgeUiRuntimeConfig } from "../runtime/runtimeConfig.js";
2
- import { copyComponentOverrides, copyRuntimeTarget, getHostRuntimeAdapters, registerHostRuntimeAdapter, replaceHostRuntimeAdapterOverrides, } from "./runtimeBridge.js";
2
+ import { copyComponentOverrides, copyRootInvocations, copyRuntimeTarget, getHostRuntimeAdapters, registerHostRuntimeAdapter, replaceHostRuntimeAdapterOverrides, } from "./runtimeBridge.js";
3
3
  const diagnosedAdapters = new WeakSet();
4
4
  function enabledRuntimeAdapters(ownerGlobal) {
5
5
  if (!getNudgeUiRuntimeConfig().capabilities.componentSemantics)
@@ -42,6 +42,28 @@ export function inspectComponentTargets(element) {
42
42
  }
43
43
  return targets;
44
44
  }
45
+ /**
46
+ * Returns the invocations, nearest first, whose entire rendered output is this
47
+ * element. Null means no enabled runtime could read the element's ancestry.
48
+ */
49
+ export function inspectRootInvocations(element) {
50
+ let invocations = null;
51
+ const ownerGlobal = element.ownerDocument.defaultView ?? globalThis;
52
+ for (const adapter of enabledRuntimeAdapters(ownerGlobal)) {
53
+ if (!adapter.rootInvocations)
54
+ continue;
55
+ try {
56
+ const inspected = adapter.rootInvocations(element);
57
+ if (inspected === null || inspected === undefined)
58
+ continue;
59
+ invocations = [...(invocations ?? []), ...copyRootInvocations(inspected)];
60
+ }
61
+ catch (error) {
62
+ diagnoseAdapter(adapter, error instanceof Error ? error.message : String(error));
63
+ }
64
+ }
65
+ return invocations;
66
+ }
45
67
  function diagnoseAdapter(adapter, detail) {
46
68
  if (diagnosedAdapters.has(adapter))
47
69
  return;
@@ -1,10 +1,11 @@
1
1
  import { type ReactElement } from "react";
2
- import type { ComponentInvocationMeta, ComponentOverride, ComponentRuntimeAdapter, RuntimeComponentTarget } from "./types.js";
2
+ import type { ComponentInvocationMeta, ComponentOverride, ComponentRootInvocation, ComponentRuntimeAdapter, RuntimeComponentTarget } from "./types.js";
3
3
  /** Returns null when this runtime has no mounted boundary for the callsite. */
4
4
  export declare function getReactCallsiteMultiplicity(callsiteId: string): number | null;
5
5
  /** Test/runtime teardown hook; canonical changes never depend on this state. */
6
6
  export declare function resetReactComponentRuntime(): void;
7
7
  export declare function instrumentReactComponent(element: ReactElement<Record<string, unknown>>, meta: ComponentInvocationMeta): ReactElement;
8
8
  export declare function inspectReactComponentTargets(element: HTMLElement): RuntimeComponentTarget[];
9
+ export declare function inspectReactRootInvocations(element: HTMLElement): ComponentRootInvocation[] | null;
9
10
  export declare function replaceReactComponentOverrides(overrides: ComponentOverride[]): void;
10
11
  export declare const reactComponentRuntimeAdapter: ComponentRuntimeAdapter;
@@ -4,6 +4,9 @@ import { getNudgeUiRuntimeConfig } from "../runtime/runtimeConfig.js";
4
4
  const BOUNDARY_MARKER = Symbol.for("nudge-ui.react-component-boundary");
5
5
  const EMPTY_OVERRIDE = Object.freeze({});
6
6
  const REACT_MAJOR_VERSION = Number.parseInt(reactVersion, 10);
7
+ /** React work tags for host output that has no string element type. */
8
+ const HOST_PORTAL_TAG = 4;
9
+ const HOST_TEXT_TAG = 6;
7
10
  let overridesByCallsite = new Map();
8
11
  const listeners = new Set();
9
12
  /**
@@ -161,6 +164,40 @@ export function inspectReactComponentTargets(element) {
161
164
  }
162
165
  return targets;
163
166
  }
167
+ function isHostOutput(fiber) {
168
+ return typeof fiber.type === "string" || fiber.tag === HOST_TEXT_TAG || fiber.tag === HOST_PORTAL_TAG;
169
+ }
170
+ /** Counts top-level host outputs below a fiber, stopping once there are two. */
171
+ function hostOutputCount(fiber) {
172
+ let count = 0;
173
+ const visit = (parent) => {
174
+ for (let child = parent.child ?? null; child && count < 2; child = child.sibling ?? null) {
175
+ if (isHostOutput(child))
176
+ count += 1;
177
+ else
178
+ visit(child);
179
+ }
180
+ };
181
+ visit(fiber);
182
+ return count;
183
+ }
184
+ export function inspectReactRootInvocations(element) {
185
+ if (!getNudgeUiRuntimeConfig().capabilities.componentSemantics)
186
+ return null;
187
+ const host = findFiber(element);
188
+ if (!host)
189
+ return null;
190
+ const invocations = [];
191
+ for (let fiber = host.return ?? null; fiber && !isHostOutput(fiber); fiber = fiber.return ?? null) {
192
+ if (!isBoundaryType(fiber.type) || !fiber.memoizedProps?.meta)
193
+ continue;
194
+ if (hostOutputCount(fiber) !== 1)
195
+ break;
196
+ const { callsiteId, componentName } = fiber.memoizedProps.meta;
197
+ invocations.push({ callsiteId, componentName });
198
+ }
199
+ return invocations;
200
+ }
164
201
  export function replaceReactComponentOverrides(overrides) {
165
202
  if (!getNudgeUiRuntimeConfig().capabilities.componentSemantics)
166
203
  return;
@@ -182,5 +219,6 @@ export const reactComponentRuntimeAdapter = {
182
219
  inspect: inspectReactComponentTargets,
183
220
  replaceOverrides: replaceReactComponentOverrides,
184
221
  getCallsiteMultiplicity: getReactCallsiteMultiplicity,
222
+ rootInvocations: inspectReactRootInvocations,
185
223
  };
186
224
  registerHostRuntimeAdapter(reactComponentRuntimeAdapter);
@@ -1,5 +1,5 @@
1
- import type { ComponentOverride, ComponentRuntimeAdapter, RuntimeComponentTarget } from "./types.js";
2
- export type { ComponentFramework, ComponentInvocationMeta, ComponentOverride, ComponentRuntimeAdapter, RuntimeComponentTarget, } from "./types.js";
1
+ import type { ComponentOverride, ComponentRootInvocation, ComponentRuntimeAdapter, RuntimeComponentTarget } from "./types.js";
2
+ export type { ComponentFramework, ComponentInvocationMeta, ComponentOverride, ComponentRootInvocation, ComponentRuntimeAdapter, RuntimeComponentTarget, } from "./types.js";
3
3
  /** Version of the page-global host runtime Adapter Interface. */
4
4
  export declare const NUDGE_UI_HOST_RUNTIME_VERSION = 1;
5
5
  /**
@@ -21,5 +21,7 @@ export declare function replaceHostRuntimeAdapterOverrides(overrides: readonly C
21
21
  * the host/client seam.
22
22
  */
23
23
  export declare function copyRuntimeTarget(target: RuntimeComponentTarget): RuntimeComponentTarget;
24
+ /** Copies a host Adapter's root invocations into plain protocol data. */
25
+ export declare function copyRootInvocations(invocations: readonly ComponentRootInvocation[]): ComponentRootInvocation[];
24
26
  /** Copies override commands before dispatching them into a host Adapter. */
25
27
  export declare function copyComponentOverrides(overrides: readonly ComponentOverride[]): ComponentOverride[];
@@ -104,6 +104,23 @@ export function copyRuntimeTarget(target) {
104
104
  ...(target.mountedCount === undefined ? {} : { mountedCount: target.mountedCount }),
105
105
  };
106
106
  }
107
+ /** Copies a host Adapter's root invocations into plain protocol data. */
108
+ export function copyRootInvocations(invocations) {
109
+ if (!Array.isArray(invocations)) {
110
+ throw new TypeError("Nudge UI received invalid root invocations from a host Adapter.");
111
+ }
112
+ return invocations.map((invocation) => {
113
+ if (invocation === null
114
+ || typeof invocation !== "object"
115
+ || typeof invocation.callsiteId !== "string"
116
+ || invocation.callsiteId.length === 0
117
+ || typeof invocation.componentName !== "string"
118
+ || invocation.componentName.length === 0) {
119
+ throw new TypeError("Nudge UI received an invalid root invocation from a host Adapter.");
120
+ }
121
+ return { callsiteId: invocation.callsiteId, componentName: invocation.componentName };
122
+ });
123
+ }
107
124
  /** Copies override commands before dispatching them into a host Adapter. */
108
125
  export function copyComponentOverrides(overrides) {
109
126
  return overrides.map((override) => {
@@ -61,6 +61,11 @@ export interface RuntimeComponentTarget {
61
61
  /** Adapter-reported mounted invocation count for this callsite. */
62
62
  mountedCount?: number;
63
63
  }
64
+ /** A component invocation whose entire rendered output is one DOM element. */
65
+ export interface ComponentRootInvocation {
66
+ callsiteId: string;
67
+ componentName: string;
68
+ }
64
69
  export interface ComponentPropContract {
65
70
  name: string;
66
71
  control: "select" | "boolean" | "text";
@@ -93,4 +98,10 @@ export interface ComponentRuntimeAdapter {
93
98
  * fail closed instead of broadening a semantic override.
94
99
  */
95
100
  getCallsiteMultiplicity?(callsiteId: string): number | null | undefined;
101
+ /**
102
+ * Return, nearest first, the invocations whose entire rendered output is
103
+ * this element. A runtime that cannot read the element's ancestry must
104
+ * return null so structural identity fails closed.
105
+ */
106
+ rootInvocations?(element: HTMLElement): ComponentRootInvocation[] | null;
96
107
  }
@@ -1,4 +1,5 @@
1
1
  import { HTML_ARTIFACT_REVISION_ATTRIBUTE } from "../../transport/artifacts.js";
2
+ import { FRAME_SCROLLBAR_STYLE_ID } from "../canvas/frameScrollbarStyles.js";
2
3
  /** Captures the visible DOM of a same-origin frame as a script-free HTML iteration. */
3
4
  export async function captureHtmlIteration(frame) {
4
5
  const source = frame.contentDocument;
@@ -52,7 +53,7 @@ export async function captureHtmlIteration(frame) {
52
53
  copy.querySelectorAll("base").forEach((element) => element.remove());
53
54
  (copy.querySelector("head") ?? copy).prepend(base);
54
55
  copy.querySelectorAll('script, iframe, object, embed, meta[http-equiv="refresh"], meta[http-equiv="Content-Security-Policy"], link[rel=modulepreload], link[rel=preload]').forEach((element) => element.remove());
55
- copy.querySelectorAll("[data-nudge-ui-mount], #nudge-ui-root").forEach((element) => element.remove());
56
+ copy.querySelectorAll(`[data-nudge-ui-mount], #nudge-ui-root, #${FRAME_SCROLLBAR_STYLE_ID}`).forEach((element) => element.remove());
56
57
  copy.querySelectorAll("a, form").forEach((element) => {
57
58
  element.removeAttribute("href");
58
59
  element.removeAttribute("action");
@@ -23,10 +23,23 @@ export declare function captureRenderedInstance(el: HTMLElement): RenderedInstan
23
23
  /**
24
24
  * Resolves exactly one element, or declines to apply. Occurrence documents the
25
25
  * captured placement, but structural previews can legitimately change it.
26
- * Evidence must therefore identify one current candidate before an edit can
27
- * be applied.
26
+ * Evidence, narrowed by a recorded invocation when present, must therefore
27
+ * identify one current candidate before an edit can be applied.
28
28
  */
29
29
  export declare function resolveRenderedInstance(doc: Document, ref: RenderedInstanceRef): ResolutionResult;
30
+ /**
31
+ * Resolves a reference whose source position may have moved since capture.
32
+ * A relocated site still renders matching authored evidence, so an evidence
33
+ * miss there is reported as ambiguous rather than as proof of absence.
34
+ */
35
+ export declare function resolveMovedRenderedInstance(doc: Document, ref: RenderedInstanceRef): ResolutionResult;
36
+ /**
37
+ * Captures identity for a structural target. When evidence matches several
38
+ * identical outputs, the nearest component invocation whose entire output is
39
+ * this element can still identify it. Otherwise the evidence-only reference
40
+ * is returned and resolves as ambiguous.
41
+ */
42
+ export declare function captureStructuralTarget(el: HTMLElement): RenderedInstanceRef | null;
30
43
  /** Checks stable source/evidence fields without treating a post-move ordinal as identity. */
31
44
  export declare function matchesRenderedInstanceEvidence(el: HTMLElement, ref: RenderedInstanceRef): boolean;
32
45
  /** Builds an instance override without changing the element's transient edit scope. */
@@ -1,7 +1,8 @@
1
1
  import { getActiveDraftChanges, getDraftChanges } from "../changes/draftChanges.js";
2
+ import { inspectRootInvocations } from "../componentSemantics/adapterRegistry.js";
2
3
  import { escapeAttrValue } from "./cssEscapes.js";
3
4
  import { isNudgeUiDev } from "../runtime/devFlag.js";
4
- import { sourceSiteSelector } from "../selection/sourceSite.js";
5
+ import { relocateSourceSite, sourceSiteSelector } from "../selection/sourceSite.js";
5
6
  import { isRenderedInstanceOverride } from "../changes/editModel.js";
6
7
  export { isRenderedInstanceOverride, isRenderedInstanceRef } from "../changes/editModel.js";
7
8
  const PROJECTION_ATTR = "data-projection-instance";
@@ -50,19 +51,31 @@ export function captureRenderedInstance(el) {
50
51
  /**
51
52
  * Resolves exactly one element, or declines to apply. Occurrence documents the
52
53
  * captured placement, but structural previews can legitimately change it.
53
- * Evidence must therefore identify one current candidate before an edit can
54
- * be applied.
54
+ * Evidence, narrowed by a recorded invocation when present, must therefore
55
+ * identify one current candidate before an edit can be applied.
55
56
  */
56
57
  export function resolveRenderedInstance(doc, ref) {
57
58
  const sourceCandidates = candidates(doc, ref.sourceSite);
58
- const { props, text, ariaLabel = null } = ref.locator;
59
+ const { props, text, ariaLabel = null, invocation } = ref.locator;
59
60
  // An ordinal alone is not an identity for a repeated source site.
60
- if (sourceCandidates.length > 1 && props === null && text === null && ariaLabel === null) {
61
+ if (!invocation && sourceCandidates.length > 1 && props === null && text === null && ariaLabel === null) {
61
62
  return { status: "ambiguous" };
62
63
  }
63
- const matchingEvidence = sourceCandidates.filter((element) => element.getAttribute("data-cprops") === props
64
+ let matchingEvidence = sourceCandidates.filter((element) => element.getAttribute("data-cprops") === props
64
65
  && normalizedText(element) === text
65
66
  && element.getAttribute("aria-label") === ariaLabel);
67
+ if (invocation) {
68
+ const scoped = [];
69
+ for (const element of matchingEvidence) {
70
+ const invocations = inspectRootInvocations(element);
71
+ // Without readable ancestry, absence of the invocation proves nothing.
72
+ if (invocations === null)
73
+ return { status: "ambiguous" };
74
+ if (invocations.some((candidate) => candidate.callsiteId === invocation.callsiteId))
75
+ scoped.push(element);
76
+ }
77
+ matchingEvidence = scoped;
78
+ }
66
79
  if (matchingEvidence.length === 0)
67
80
  return { status: "missing" };
68
81
  // Evidence alone is never enough to choose between two identical rendered
@@ -72,6 +85,40 @@ export function resolveRenderedInstance(doc, ref) {
72
85
  return { status: "ambiguous" };
73
86
  return { status: "resolved", element: matchingEvidence[0] };
74
87
  }
88
+ /**
89
+ * Resolves a reference whose source position may have moved since capture.
90
+ * A relocated site still renders matching authored evidence, so an evidence
91
+ * miss there is reported as ambiguous rather than as proof of absence.
92
+ */
93
+ export function resolveMovedRenderedInstance(doc, ref) {
94
+ const exact = resolveRenderedInstance(doc, ref);
95
+ if (exact.status !== "missing")
96
+ return exact;
97
+ const { props, ariaLabel = null } = ref.locator;
98
+ const sourceSite = relocateSourceSite(doc, ref.sourceSite, (element) => element.getAttribute("data-cprops") === props && element.getAttribute("aria-label") === ariaLabel);
99
+ if (!sourceSite)
100
+ return exact;
101
+ const moved = resolveRenderedInstance(doc, { ...ref, sourceSite });
102
+ return moved.status === "missing" ? { status: "ambiguous" } : moved;
103
+ }
104
+ /**
105
+ * Captures identity for a structural target. When evidence matches several
106
+ * identical outputs, the nearest component invocation whose entire output is
107
+ * this element can still identify it. Otherwise the evidence-only reference
108
+ * is returned and resolves as ambiguous.
109
+ */
110
+ export function captureStructuralTarget(el) {
111
+ const ref = captureRenderedInstance(el);
112
+ if (!ref || resolveRenderedInstance(el.ownerDocument, ref).status !== "ambiguous")
113
+ return ref;
114
+ for (const invocation of inspectRootInvocations(el) ?? []) {
115
+ const scoped = { ...ref, locator: { ...ref.locator, invocation } };
116
+ const resolved = resolveRenderedInstance(el.ownerDocument, scoped);
117
+ if (resolved.status === "resolved" && resolved.element === el)
118
+ return scoped;
119
+ }
120
+ return ref;
121
+ }
75
122
  /** Checks stable source/evidence fields without treating a post-move ordinal as identity. */
76
123
  export function matchesRenderedInstanceEvidence(el, ref) {
77
124
  const { props, text, ariaLabel = null } = ref.locator;
@@ -1,5 +1,5 @@
1
1
  import { getActiveDraftChanges, getDraftChanges } from "../changes/draftChanges.js";
2
- import { captureRenderedInstance, matchesRenderedInstanceEvidence, resolveRenderedInstance, } from "./renderedInstance.js";
2
+ import { captureRenderedInstance, captureStructuralTarget, matchesRenderedInstanceEvidence, resolveRenderedInstance, } from "./renderedInstance.js";
3
3
  import { isStructuralProjectionReport } from "./structuralProjectionBoundary.js";
4
4
  import { resetDraftChanges, draftChangeStore, } from "../changes/draftChanges.js";
5
5
  export { isStructuralChange, isStructuralDelete, isStructuralMove, isStructuralProjectionReport, } from "./structuralProjectionBoundary.js";
@@ -182,7 +182,7 @@ function projectStructuralChanges(snapshot) {
182
182
  }
183
183
  /** Captures a delete intent once, in the controller, at the user gesture. */
184
184
  export function createStructuralDelete(element, id = structuralId()) {
185
- const target = captureRenderedInstance(element);
185
+ const target = captureStructuralTarget(element);
186
186
  if (!target)
187
187
  return null;
188
188
  const route = documentRoute(element.ownerDocument);
@@ -49,6 +49,39 @@ function isEmptyTextAffordance(node) {
49
49
  function hasInlineTextEditor(element) {
50
50
  return element.querySelector('[data-inline-editor="true"]') !== null;
51
51
  }
52
+ /**
53
+ * What the application last rendered into each projected Text node. A source
54
+ * update can render the projected value itself, so restoring `before` would
55
+ * overwrite real application output that the framework will not repaint.
56
+ */
57
+ let applicationText = new WeakMap();
58
+ function isInlineEditorNode(node) {
59
+ return node.nodeType === 1 && (node.matches('[data-inline-editor="true"]')
60
+ || hasInlineTextEditor(node));
61
+ }
62
+ function recordApplicationWrites(records) {
63
+ // The inline editor swaps its wrapper back for the original node, writing
64
+ // the draft, in one task. That write belongs to the editor.
65
+ if (records.some((record) => record.type === "childList"
66
+ && [...record.addedNodes, ...record.removedNodes].some(isInlineEditorNode)))
67
+ return;
68
+ for (const record of records) {
69
+ if (record.type !== "characterData" || !isTextNode(record.target))
70
+ continue;
71
+ if (applicationText.has(record.target))
72
+ applicationText.set(record.target, record.target.nodeValue ?? "");
73
+ }
74
+ }
75
+ /** Writes projection-owned text without mistaking it for an application render. */
76
+ function writeProjectedText(state, node, value) {
77
+ const pending = state.observer?.takeRecords() ?? [];
78
+ if (pending.length > 0) {
79
+ recordApplicationWrites(pending);
80
+ scheduleValidation(state.doc, state);
81
+ }
82
+ node.nodeValue = value;
83
+ state.observer?.takeRecords();
84
+ }
52
85
  function readProjectionMarkers(element) {
53
86
  const raw = element.getAttribute(TEXT_PROJECTION_ATTR);
54
87
  if (!raw)
@@ -411,7 +444,7 @@ function textProjectionIdentityMatches(element, change) {
411
444
  && element.getAttribute("data-cprops") === change.target.props
412
445
  && element.getAttribute("aria-label") === change.target.ariaLabel;
413
446
  }
414
- function restoreAppliedProjection(applied) {
447
+ function restoreAppliedProjection(state, applied) {
415
448
  const element = applied.element;
416
449
  if (!element || !element.isConnected)
417
450
  return;
@@ -426,7 +459,9 @@ function restoreAppliedProjection(applied) {
426
459
  if (!textHostIsSafe(element, applied.change.after.length === 0, applied.change.target))
427
460
  return;
428
461
  if (textNode) {
429
- textNode.nodeValue = applied.change.before;
462
+ const rendered = applicationText.get(textNode);
463
+ applicationText.delete(textNode);
464
+ writeProjectedText(state, textNode, rendered ?? applied.change.before);
430
465
  }
431
466
  else if (applied.change.target.textNodePath === undefined
432
467
  && applied.change.after.length === 0
@@ -467,7 +502,7 @@ function validationStatus(applied) {
467
502
  * mutates; an overridden projection remains authoritative and is never
468
503
  * re-applied.
469
504
  */
470
- function resolvePendingProjection(doc, applied) {
505
+ function resolvePendingProjection(doc, state, applied) {
471
506
  if (applied.status !== "missing" && applied.status !== "ambiguous") {
472
507
  return validationStatus(applied);
473
508
  }
@@ -484,7 +519,9 @@ function resolvePendingProjection(doc, applied) {
484
519
  element.textContent = applied.change.after;
485
520
  }
486
521
  else {
487
- textNode.nodeValue = applied.change.after;
522
+ if (!applicationText.has(textNode))
523
+ applicationText.set(textNode, textNode.nodeValue ?? "");
524
+ writeProjectedText(state, textNode, applied.change.after);
488
525
  }
489
526
  applied.element = element;
490
527
  return "applied";
@@ -492,7 +529,7 @@ function resolvePendingProjection(doc, applied) {
492
529
  function validateAppliedProjection(doc, state) {
493
530
  let changed = false;
494
531
  for (const applied of state.applied.values()) {
495
- const status = resolvePendingProjection(doc, applied);
532
+ const status = resolvePendingProjection(doc, state, applied);
496
533
  if (status !== applied.status) {
497
534
  applied.status = status;
498
535
  changed = true;
@@ -516,6 +553,7 @@ function getDocumentState(doc) {
516
553
  if (existing)
517
554
  return existing;
518
555
  const state = {
556
+ doc,
519
557
  applied: new Map(),
520
558
  emptyAffordances: new Map(),
521
559
  snapshotKey: null,
@@ -524,7 +562,10 @@ function getDocumentState(doc) {
524
562
  };
525
563
  const Observer = doc.defaultView?.MutationObserver;
526
564
  if (Observer && doc.documentElement) {
527
- state.observer = new Observer(() => scheduleValidation(doc, state));
565
+ state.observer = new Observer((records) => {
566
+ recordApplicationWrites(records);
567
+ scheduleValidation(doc, state);
568
+ });
528
569
  state.observer.observe(doc.documentElement, {
529
570
  childList: true,
530
571
  subtree: true,
@@ -571,7 +612,7 @@ export function applyTextContentProjection(doc, changes) {
571
612
  if (incomingIds.has(id))
572
613
  continue;
573
614
  removeEmptyTextAffordance(state, id);
574
- restoreAppliedProjection(applied);
615
+ restoreAppliedProjection(state, applied);
575
616
  if (applied.element)
576
617
  removeProjectionMarker(applied.element, applied.change.target, id);
577
618
  }
@@ -579,13 +620,13 @@ export function applyTextContentProjection(doc, changes) {
579
620
  for (const change of changes) {
580
621
  const existing = previous.get(change.id);
581
622
  if (existing && JSON.stringify(existing.change) === JSON.stringify(change)) {
582
- existing.status = resolvePendingProjection(doc, existing);
623
+ existing.status = resolvePendingProjection(doc, state, existing);
583
624
  next.set(change.id, existing);
584
625
  continue;
585
626
  }
586
627
  if (existing) {
587
628
  removeEmptyTextAffordance(state, change.id);
588
- restoreAppliedProjection(existing);
629
+ restoreAppliedProjection(state, existing);
589
630
  if (existing.element)
590
631
  removeProjectionMarker(existing.element, existing.change.target, change.id);
591
632
  }
@@ -603,8 +644,11 @@ export function applyTextContentProjection(doc, changes) {
603
644
  next.set(change.id, { change, element: null, status: "missing" });
604
645
  continue;
605
646
  }
606
- if (textNode)
607
- textNode.nodeValue = change.after;
647
+ if (textNode) {
648
+ if (!applicationText.has(textNode))
649
+ applicationText.set(textNode, textNode.nodeValue ?? "");
650
+ writeProjectedText(state, textNode, change.after);
651
+ }
608
652
  else
609
653
  element.textContent = change.after;
610
654
  next.set(change.id, { change, element, status: "applied" });
@@ -708,6 +752,7 @@ export function resetTextProjectionState() {
708
752
  }
709
753
  }
710
754
  documentStates = new Map();
755
+ applicationText = new WeakMap();
711
756
  canonicalChanges = new Map();
712
757
  reportsByDocument = new WeakMap();
713
758
  reportsByCanvasCard.clear();
@@ -21,6 +21,11 @@ export declare function clearClipboardHandoff(): void;
21
21
  export declare function subscribeClipboardHandoff(listener: () => void): () => void;
22
22
  export declare function getClipboardHandoffRevision(): number;
23
23
  export declare function getLastClipboardReconciledCount(): number;
24
+ /** Records in one draft that still equal its copied, unverified snapshot. */
25
+ export declare function getClipboardHandoffRecords(draftId: string): {
26
+ changes: ChangeRecord[];
27
+ structuralChanges: StructuralChange[];
28
+ };
24
29
  export declare function reconcileClipboardHandoff(doc?: Document): Promise<number>;
25
30
  export declare function startClipboardHandoffController(doc?: Document): () => void;
26
31
  export {};
@@ -87,6 +87,11 @@ function matchingRecords(snapshot) {
87
87
  structuralChanges: workspace.structuralChanges.filter((change) => structures.get(change.id) === handoffStructuralFingerprint(change)),
88
88
  };
89
89
  }
90
+ /** Records in one draft that still equal its copied, unverified snapshot. */
91
+ export function getClipboardHandoffRecords(draftId) {
92
+ const snapshot = checkpoints.get(draftId);
93
+ return snapshot ? matchingRecords(snapshot) : { changes: [], structuralChanges: [] };
94
+ }
90
95
  function pruneCheckpoints() {
91
96
  let changed = false;
92
97
  for (const [id, snapshot] of checkpoints) {
@@ -232,7 +232,10 @@ function renderedInstanceDescription(ref) {
232
232
  function structuralChangeLine(change) {
233
233
  const target = renderedInstanceDescription(change.target);
234
234
  if (change.kind === "delete") {
235
- return `- Remove ${target} from the source.`;
235
+ const invocation = change.target.locator.invocation;
236
+ return invocation
237
+ ? `- Remove the ${promptText(`<${invocation.componentName}>`)} invocation at ${invocation.callsiteId} from the source; keep its other invocations. It renders ${target}.`
238
+ : `- Remove ${target} from the source.`;
236
239
  }
237
240
  const before = change.destination.before;
238
241
  const sourceParent = sourceSiteLabel(change.source.parent);
@@ -254,6 +257,7 @@ function renderedInstanceKey(ref) {
254
257
  locator.props,
255
258
  locator.text,
256
259
  locator.ariaLabel ?? null,
260
+ locator.invocation?.callsiteId ?? null,
257
261
  ]);
258
262
  }
259
263
  /** Export final intent, not the gesture history required to replay the preview. */
@@ -1,2 +1,14 @@
1
1
  /** Stable JSX instrumentation selector, shared by source and instance edits. */
2
2
  export declare function sourceSiteSelector(cid: string, src: string): string | null;
3
+ /**
4
+ * Finds a source site whose position moved because its file gained or lost
5
+ * lines. It returns a site only when the original renders nothing and exactly
6
+ * one other position in the same file and component renders matching elements.
7
+ */
8
+ export declare function relocateSourceSite(doc: Document, site: {
9
+ readonly cid: string;
10
+ readonly src: string;
11
+ }, matches: (element: HTMLElement) => boolean): {
12
+ cid: string;
13
+ src: string;
14
+ } | null;
@@ -1,4 +1,5 @@
1
1
  import { escapeAttrValue } from "../projection/cssEscapes.js";
2
+ const SOURCE_POSITION = /^(.+):\d+:\d+$/;
2
3
  /** Stable JSX instrumentation selector, shared by source and instance edits. */
3
4
  export function sourceSiteSelector(cid, src) {
4
5
  if (!cid)
@@ -10,3 +11,30 @@ export function sourceSiteSelector(cid, src) {
10
11
  // single formatted line share a managed rule.
11
12
  return `[data-cid="${escapeAttrValue(cid)}"][data-src="${escapeAttrValue(src)}"]`;
12
13
  }
14
+ /**
15
+ * Finds a source site whose position moved because its file gained or lost
16
+ * lines. It returns a site only when the original renders nothing and exactly
17
+ * one other position in the same file and component renders matching elements.
18
+ */
19
+ export function relocateSourceSite(doc, site, matches) {
20
+ const file = SOURCE_POSITION.exec(site.src)?.[1];
21
+ const exact = sourceSiteSelector(site.cid, site.src);
22
+ if (!file || !exact)
23
+ return null;
24
+ try {
25
+ if (doc.querySelector(exact))
26
+ return null;
27
+ const sites = new Set();
28
+ const sameFile = `[data-cid="${escapeAttrValue(site.cid)}"][data-src^="${escapeAttrValue(`${file}:`)}"]`;
29
+ for (const element of doc.querySelectorAll(sameFile)) {
30
+ const src = element.getAttribute("data-src") ?? "";
31
+ if (SOURCE_POSITION.exec(src)?.[1] === file && matches(element))
32
+ sites.add(src);
33
+ }
34
+ const [src] = sites;
35
+ return sites.size === 1 && src ? { cid: site.cid, src } : null;
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }