@widgetic/creator 0.3.72 → 0.3.73

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 (33) hide show
  1. package/dist/CreatorApp.svelte +821 -73
  2. package/dist/components/BrandKitPanel.svelte +556 -0
  3. package/dist/components/BrandKitPanel.svelte.d.ts +32 -0
  4. package/dist/components/DesignQuickEdit.svelte +218 -0
  5. package/dist/components/DesignQuickEdit.svelte.d.ts +33 -0
  6. package/dist/components/FileUrlSourceControl.svelte +69 -0
  7. package/dist/components/FileUrlSourceControl.svelte.d.ts +15 -0
  8. package/dist/components/VersionDropdownRow.svelte +86 -1
  9. package/dist/components/VersionDropdownRow.svelte.d.ts +10 -0
  10. package/dist/components/WidgetDetails.svelte +19 -2
  11. package/dist/components/WidgetShapeLivePreviews.svelte +8 -3
  12. package/dist/retiredSurface.test.d.ts +1 -0
  13. package/dist/retiredSurface.test.js +113 -0
  14. package/dist/stores/websocketStore.d.ts +3 -0
  15. package/dist/stores/websocketStore.js +4 -0
  16. package/dist/utils/canvasShapeSnapClient.d.ts +27 -0
  17. package/dist/utils/canvasShapeSnapClient.js +67 -0
  18. package/dist/utils/designIntentJevClient.d.ts +32 -0
  19. package/dist/utils/designIntentJevClient.js +51 -0
  20. package/dist/utils/designIntentResolver.d.ts +107 -0
  21. package/dist/utils/designIntentResolver.js +509 -0
  22. package/dist/utils/designIntentResolver.test.d.ts +1 -0
  23. package/dist/utils/designIntentResolver.test.js +246 -0
  24. package/dist/utils/designIntentTelemetry.d.ts +176 -0
  25. package/dist/utils/designIntentTelemetry.js +474 -0
  26. package/dist/utils/designIntentTelemetry.test.d.ts +1 -0
  27. package/dist/utils/designIntentTelemetry.test.js +385 -0
  28. package/dist/utils/designQuickEditValues.d.ts +51 -0
  29. package/dist/utils/designQuickEditValues.js +77 -0
  30. package/dist/utils/designQuickEditValues.test.d.ts +1 -0
  31. package/dist/utils/designQuickEditValues.test.js +100 -0
  32. package/dist/utils/pinScrollToBottom.js +9 -2
  33. package/package.json +2 -2
@@ -0,0 +1,218 @@
1
+ <!--
2
+ * DesignQuickEdit
3
+ *
4
+ * Inline design-property controls rendered in the canvas chat under the
5
+ * assistant bubble that applied a deterministic (model-free) design tweak.
6
+ * The user can keep adjusting the property by hand; every change is applied
7
+ * to the live preview through the same `widgetic:update` channel the editor
8
+ * uses, and persisted like any other design change — no codegen involved.
9
+ *
10
+ * The chat package stays design-agnostic: it renders this via a host-provided
11
+ * snippet and only passes the resolution payload (see `renderDesignControls`).
12
+ * Controls are intentionally lightweight and mirror `inputController.type`
13
+ * rather than importing the editor's controller components, so the production
14
+ * build does not depend on editor internals.
15
+ *
16
+ * Values are not frozen at insert: the card reads the host's live design state
17
+ * so a property dragged in the PropsEditor panel moves the control here too,
18
+ * while edits made here flow back through the same `onChange` → host path.
19
+ * See `designQuickEditValues.ts` for the resolution rules.
20
+ -->
21
+ <script lang="ts">
22
+ import type { DesignControlRequest, DesignControlProperty } from '../utils/designIntentResolver';
23
+ import {
24
+ isQuickEditPropertyAvailable,
25
+ resolveQuickEditValues
26
+ } from '../utils/designQuickEditValues';
27
+
28
+ interface Props {
29
+ spec: DesignControlRequest;
30
+ /**
31
+ * The host's live design values, keyed by property id. When a key is
32
+ * present it wins over the resolution snapshot, so panel → card stays in
33
+ * sync. Optional: without it the card shows the snapshot it was built with.
34
+ */
35
+ liveValues?: Record<string, unknown> | null;
36
+ /**
37
+ * Property ids still present in the widget's design schema. When supplied,
38
+ * a control whose property no longer exists (e.g. after a codegen
39
+ * regeneration) is disabled instead of writing a dead value. `undefined`
40
+ * means the roster is unknown and every control stays live.
41
+ */
42
+ knownPropertyIds?: ReadonlySet<string> | null;
43
+ /**
44
+ * True when this card's composition is not the one currently loaded.
45
+ *
46
+ * A persisted card re-attaches to the live design state of the composition
47
+ * it was authored against (see `DesignControlRequest.compositionId`). When a
48
+ * different one is loaded the host passes no live state, because the values
49
+ * on screen belong to that other composition — and then editing here would
50
+ * write this card's property into it. So the controls render as a read-only
51
+ * record of what was applied, with the values the card was created with.
52
+ */
53
+ detached?: boolean;
54
+ /** Fired on every control change; the host applies + persists it. */
55
+ onChange: (propertyId: string, value: unknown) => void;
56
+ }
57
+
58
+ let { spec, liveValues, knownPropertyIds, detached = false, onChange }: Props = $props();
59
+
60
+ let values = $state<Record<string, unknown>>({});
61
+
62
+ // Re-seed whenever a new resolution card arrives or the host's live design
63
+ // state moves (a drag in the PropsEditor panel included). `values` is only
64
+ // used for the optimistic feedback of the card's own edits; the effect and
65
+ // the controls' `value` bindings stay primitives, so re-seeding never
66
+ // clobbers text the user is still typing (those commit on change/blur).
67
+ $effect(() => {
68
+ values = resolveQuickEditValues(spec.properties, liveValues);
69
+ });
70
+
71
+ function commit(property: DesignControlProperty, value: unknown) {
72
+ values = { ...values, [property.id]: value };
73
+ onChange(property.id, value);
74
+ }
75
+
76
+ function currentValue(property: DesignControlProperty): unknown {
77
+ return Object.prototype.hasOwnProperty.call(values, property.id) ? values[property.id] : property.value;
78
+ }
79
+
80
+ function asString(property: DesignControlProperty): string {
81
+ const value = currentValue(property);
82
+ return value === null || value === undefined ? '' : String(value);
83
+ }
84
+
85
+ function asNumber(property: DesignControlProperty): number {
86
+ const value = Number(currentValue(property));
87
+ return Number.isFinite(value) ? value : 0;
88
+ }
89
+
90
+ function asBool(property: DesignControlProperty): boolean {
91
+ return currentValue(property) === true;
92
+ }
93
+ </script>
94
+
95
+ <div class="design-quick-edit flex flex-col gap-2 overflow-visible rounded-lg border border-gray-200 bg-white p-3 shadow-sm">
96
+ <p class="text-xs leading-relaxed text-gray-600">{spec.summary}</p>
97
+ {#if detached}
98
+ <p class="text-[10px] leading-relaxed text-gray-400">
99
+ Shown as applied. These controls were made for a different composition — reopen that one to
100
+ keep adjusting them.
101
+ </p>
102
+ {/if}
103
+ {#each spec.properties as property (property.id)}
104
+ {@const type = String(property.controllerType ?? '').toLowerCase()}
105
+ {@const available = isQuickEditPropertyAvailable(property.id, knownPropertyIds)}
106
+ {@const editable = !detached && available}
107
+ <div class="flex items-center justify-between gap-3">
108
+ <label
109
+ class="text-xs font-medium text-gray-700 shrink-0"
110
+ for={editable ? `design-quick-${property.id}` : undefined}
111
+ >
112
+ {property.label}
113
+ </label>
114
+
115
+ {#if detached}
116
+ <span class="text-xs tabular-nums text-gray-500">{asString(property)}</span>
117
+ {:else if !available}
118
+ <span class="text-[10px] italic text-gray-400">Not available in this widget</span>
119
+ {:else if type === 'colorpicker'}
120
+ <div class="flex items-center gap-2">
121
+ <input
122
+ id={`design-quick-${property.id}`}
123
+ type="color"
124
+ class="design-quick-color h-6 w-8 shrink-0 cursor-pointer rounded border border-gray-300 bg-white p-0"
125
+ value={/^#[0-9a-fA-F]{6}$/.test(asString(property)) ? asString(property) : '#000000'}
126
+ onchange={(e) => commit(property, (e.currentTarget as HTMLInputElement).value)}
127
+ />
128
+ <input
129
+ type="text"
130
+ class="w-24 rounded border border-gray-300 px-1.5 py-1 text-xs font-mono text-gray-700"
131
+ value={asString(property)}
132
+ onchange={(e) => commit(property, (e.currentTarget as HTMLInputElement).value.trim())}
133
+ />
134
+ </div>
135
+ {:else if type === 'slider' && property.min !== undefined && property.max !== undefined}
136
+ <div class="flex items-center gap-2">
137
+ <input
138
+ id={`design-quick-${property.id}`}
139
+ type="range"
140
+ class="w-36"
141
+ min={property.min}
142
+ max={property.max}
143
+ step={property.step ?? 1}
144
+ value={asNumber(property)}
145
+ onchange={(e) => commit(property, Number((e.currentTarget as HTMLInputElement).value))}
146
+ />
147
+ <span class="w-8 text-right text-xs tabular-nums text-gray-700">{asNumber(property)}</span>
148
+ </div>
149
+ {:else if type === 'number' || type === 'angle'}
150
+ <input
151
+ id={`design-quick-${property.id}`}
152
+ type="number"
153
+ class="w-24 rounded border border-gray-300 px-1.5 py-1 text-xs tabular-nums text-gray-700"
154
+ value={asNumber(property)}
155
+ min={property.min}
156
+ max={property.max}
157
+ step={property.step ?? 1}
158
+ onchange={(e) => commit(property, Number((e.currentTarget as HTMLInputElement).value))}
159
+ />
160
+ {:else if type === 'switch' || type === 'checkbox'}
161
+ <input
162
+ id={`design-quick-${property.id}`}
163
+ type="checkbox"
164
+ class="h-4 w-4 cursor-pointer"
165
+ checked={asBool(property)}
166
+ onchange={(e) => commit(property, (e.currentTarget as HTMLInputElement).checked)}
167
+ />
168
+ {:else if (type === 'dropdown' || type === 'radiogroup') && property.options?.length}
169
+ <select
170
+ id={`design-quick-${property.id}`}
171
+ class="w-36 rounded border border-gray-300 bg-white px-1.5 py-1 text-xs text-gray-700"
172
+ value={asString(property)}
173
+ onchange={(e) => commit(property, (e.currentTarget as HTMLSelectElement).value)}
174
+ >
175
+ {#each property.options as option (option.value)}
176
+ <option value={option.value}>{option.label}</option>
177
+ {/each}
178
+ </select>
179
+ {:else if type === 'textarea'}
180
+ <textarea
181
+ id={`design-quick-${property.id}`}
182
+ class="w-48 rounded border border-gray-300 px-1.5 py-1 text-xs text-gray-700"
183
+ rows="2"
184
+ value={asString(property)}
185
+ onchange={(e) => commit(property, (e.currentTarget as HTMLTextAreaElement).value)}
186
+ ></textarea>
187
+ {:else}
188
+ <input
189
+ id={`design-quick-${property.id}`}
190
+ type="text"
191
+ class="w-40 rounded border border-gray-300 px-1.5 py-1 text-xs text-gray-700"
192
+ value={asString(property)}
193
+ onchange={(e) => commit(property, (e.currentTarget as HTMLInputElement).value)}
194
+ />
195
+ {/if}
196
+ </div>
197
+ {#if property.helpText}
198
+ <p class="text-[10px] text-gray-400">{property.helpText}</p>
199
+ {/if}
200
+ {/each}
201
+ </div>
202
+
203
+ <style>
204
+ /* Native color inputs paint a swatch outside their box and the chat column
205
+ clips that overflow, which cuts the card's bottom border. */
206
+ .design-quick-color {
207
+ appearance: none;
208
+ -webkit-appearance: none;
209
+ overflow: hidden;
210
+ }
211
+ .design-quick-color::-webkit-color-swatch-wrapper {
212
+ padding: 0;
213
+ }
214
+ .design-quick-color::-webkit-color-swatch {
215
+ border: none;
216
+ border-radius: 3px;
217
+ }
218
+ </style>
@@ -0,0 +1,33 @@
1
+ import type { DesignControlRequest } from '../utils/designIntentResolver';
2
+ interface Props {
3
+ spec: DesignControlRequest;
4
+ /**
5
+ * The host's live design values, keyed by property id. When a key is
6
+ * present it wins over the resolution snapshot, so panel → card stays in
7
+ * sync. Optional: without it the card shows the snapshot it was built with.
8
+ */
9
+ liveValues?: Record<string, unknown> | null;
10
+ /**
11
+ * Property ids still present in the widget's design schema. When supplied,
12
+ * a control whose property no longer exists (e.g. after a codegen
13
+ * regeneration) is disabled instead of writing a dead value. `undefined`
14
+ * means the roster is unknown and every control stays live.
15
+ */
16
+ knownPropertyIds?: ReadonlySet<string> | null;
17
+ /**
18
+ * True when this card's composition is not the one currently loaded.
19
+ *
20
+ * A persisted card re-attaches to the live design state of the composition
21
+ * it was authored against (see `DesignControlRequest.compositionId`). When a
22
+ * different one is loaded the host passes no live state, because the values
23
+ * on screen belong to that other composition — and then editing here would
24
+ * write this card's property into it. So the controls render as a read-only
25
+ * record of what was applied, with the values the card was created with.
26
+ */
27
+ detached?: boolean;
28
+ /** Fired on every control change; the host applies + persists it. */
29
+ onChange: (propertyId: string, value: unknown) => void;
30
+ }
31
+ declare const DesignQuickEdit: import("svelte").Component<Props, {}, "">;
32
+ type DesignQuickEdit = ReturnType<typeof DesignQuickEdit>;
33
+ export default DesignQuickEdit;
@@ -0,0 +1,69 @@
1
+ <script lang="ts">
2
+ import HoverTooltip from './HoverTooltip.svelte';
3
+
4
+ interface Props {
5
+ class?: string;
6
+ style?: string;
7
+ fileName?: string;
8
+ fileContent?: string;
9
+ fileEncoding?: string;
10
+ url?: string;
11
+ fileLabel?: string;
12
+ fileTitle?: string;
13
+ urlPlaceholder?: string;
14
+ accept?: string;
15
+ }
16
+
17
+ let {
18
+ class: className = '',
19
+ style = '',
20
+ fileName = $bindable(''),
21
+ fileContent = $bindable(''),
22
+ fileEncoding = $bindable('utf8'),
23
+ url = $bindable(''),
24
+ fileLabel = 'Upload file',
25
+ fileTitle = '',
26
+ urlPlaceholder = 'URL',
27
+ accept = '.js,.mjs',
28
+ }: Props = $props();
29
+
30
+ function onFile(event: Event) {
31
+ const input = event.currentTarget as HTMLInputElement;
32
+ const file = input.files?.[0];
33
+ fileName = file?.name || '';
34
+ fileContent = '';
35
+ if (!file) return;
36
+ const reader = new FileReader();
37
+ const binary = /\.(zip|tar|tgz|gz|woff2?|ttf|otf|eot)$/i.test(file.name);
38
+ fileEncoding = binary ? 'base64' : 'utf8';
39
+ reader.onload = () => {
40
+ const result = String(reader.result || '');
41
+ fileContent = archived ? result.slice(result.indexOf(',') + 1) : result;
42
+ };
43
+ if (binary) reader.readAsDataURL(file);
44
+ else reader.readAsText(file);
45
+ }
46
+ </script>
47
+
48
+ <div class="file-url-source flex min-w-0 flex-1 items-center gap-2 {className}" {style}>
49
+ <HoverTooltip class="inline-flex shrink-0" text={fileTitle || fileLabel}>
50
+ <label class="file-url-source-upload flex h-10 max-w-40 shrink-0 cursor-pointer items-center truncate rounded-lg border border-gray-300 bg-white px-2 text-xs text-gray-700">
51
+ {fileName || fileLabel}
52
+ <input class="sr-only" type="file" {accept} onchange={onFile} />
53
+ </label>
54
+ </HoverTooltip>
55
+ <input
56
+ class="file-url-source-url h-10 min-w-0 flex-1 rounded-lg border border-gray-300 bg-white px-2 text-xs text-gray-900"
57
+ type="text"
58
+ placeholder={urlPlaceholder}
59
+ bind:value={url}
60
+ />
61
+ </div>
62
+
63
+ <style>
64
+ .file-url-source input:focus,
65
+ .file-url-source input:focus-visible {
66
+ outline: none;
67
+ box-shadow: none;
68
+ }
69
+ </style>
@@ -0,0 +1,15 @@
1
+ interface Props {
2
+ class?: string;
3
+ style?: string;
4
+ fileName?: string;
5
+ fileContent?: string;
6
+ fileEncoding?: string;
7
+ url?: string;
8
+ fileLabel?: string;
9
+ fileTitle?: string;
10
+ urlPlaceholder?: string;
11
+ accept?: string;
12
+ }
13
+ declare const FileUrlSourceControl: import("svelte").Component<Props, {}, "fileName" | "fileContent" | "fileEncoding" | "url">;
14
+ type FileUrlSourceControl = ReturnType<typeof FileUrlSourceControl>;
15
+ export default FileUrlSourceControl;
@@ -17,6 +17,10 @@
17
17
  commitSha?: string | null;
18
18
  /** Show the commit cell (debug mode). */
19
19
  showCommit?: boolean;
20
+ /** What the change shipped, in the pipeline's own words — the same title
21
+ * the chat turn's commit chrome shows. Trimmed; null on pre-title
22
+ * versions, which simply get no title line. */
23
+ commitTitle?: string | null;
20
24
  /** Date label; the cell shows an em dash when missing. */
21
25
  dateLabel?: string | null;
22
26
  /** Row is the currently live version — LIVE badge in the action slot. */
@@ -32,12 +36,48 @@
32
36
  publishDisabledReason?: string | null;
33
37
  /** Set-live disabled for this row. */
34
38
  setLiveDisabled?: boolean;
39
+ /** The owner may edit the version's title inline (pencil affordance on the title line). */
40
+ editableTitle?: boolean;
41
+ /** Rename in flight for this row — the input commits with '…' until it resolves. */
42
+ renameDisabled?: boolean;
35
43
  onselect?: () => void;
36
44
  onpublish?: () => void;
37
45
  onsetlive?: () => void;
46
+ /** Commit the edited title (the owner's correction of the pipeline's wording). */
47
+ onrenametitle?: (title: string) => void;
38
48
  }
39
49
 
40
- const { versionLabel, commitSha = null, showCommit = false, dateLabel = null, isLive = false, isDraft = false, selected = false, busy = false, publishDisabled = false, publishDisabledReason = null, setLiveDisabled = false, onselect, onpublish, onsetlive }: Props = $props();
50
+ const { versionLabel, commitSha = null, showCommit = false, commitTitle = null, dateLabel = null, isLive = false, isDraft = false, selected = false, busy = false, publishDisabled = false, publishDisabledReason = null, setLiveDisabled = false, editableTitle = false, renameDisabled = false, onselect, onpublish, onsetlive, onrenametitle }: Props = $props();
51
+
52
+ // The change's own name, bounded to one line in the row's flexible middle
53
+ // column — pre-title versions have nothing to show. `$derived`, not a plain
54
+ // const: an optimistic publish merge can fill a row's title in place, and
55
+ // the line must follow the prop, not its initial value.
56
+ const TITLE_MAX_CHARS = 48;
57
+ const trimmedTitle = $derived(
58
+ typeof commitTitle === 'string' ? commitTitle.trim().slice(0, TITLE_MAX_CHARS) : ''
59
+ );
60
+
61
+ // Inline rename state. The draft starts from the FULL title (the prop is
62
+ // untruncated; only the display slices), so editing does not lose the tail.
63
+ let editingTitle = $state(false);
64
+ let titleDraft = $state('');
65
+ let titleInputEl: HTMLInputElement | null = $state(null);
66
+
67
+ function openTitleEditor(): void {
68
+ if (!editableTitle || renameDisabled) return;
69
+ titleDraft = typeof commitTitle === 'string' ? commitTitle : '';
70
+ editingTitle = true;
71
+ // Focus after the input mounts.
72
+ queueMicrotask(() => titleInputEl?.focus());
73
+ }
74
+
75
+ function commitTitleEditor(): void {
76
+ const next = titleDraft.trim();
77
+ editingTitle = false;
78
+ if (next === (typeof commitTitle === 'string' ? commitTitle.trim() : '')) return;
79
+ onrenametitle?.(next);
80
+ }
41
81
 
42
82
  // One fixed width per column — identical on every row and in both dropdowns.
43
83
  // Explicit PIXEL widths that hug the content (vN, 7-char SHA, dd/mm/yyyy
@@ -65,6 +105,51 @@
65
105
  }}
66
106
  >
67
107
  <span class="{COL_VERSION} flex shrink-0 justify-end font-semibold tabular-nums {isDraft ? 'text-amber-600' : 'text-emerald-700'}">{versionLabel}</span>
108
+ <!-- The change's own name, in its own flexible column between the version
109
+ label and the metadata cells: the same title the chat turn's commit chrome
110
+ shows, so history reads as a list of what happened. Rendered only when a
111
+ title exists (or the owner is renaming), so pre-title rows keep their
112
+ former layout until the edit affordance is used. -->
113
+ {#if editingTitle}
114
+ <span class="min-w-0 flex-1 flex items-center gap-1">
115
+ <!-- svelte-ignore a11y_autofocus -->
116
+ <input
117
+ bind:this={titleInputEl}
118
+ bind:value={titleDraft}
119
+ maxlength={200}
120
+ class="min-w-0 flex-1 rounded border border-emerald-300 bg-white px-1 py-0.5 text-[10px] leading-tight text-gray-800 focus:outline-none focus:ring-1 focus:ring-emerald-400"
121
+ onclick={(e) => e.stopPropagation()}
122
+ onkeydown={(e) => {
123
+ e.stopPropagation();
124
+ if (e.key === 'Enter') { e.preventDefault(); commitTitleEditor(); }
125
+ else if (e.key === 'Escape') { e.preventDefault(); editingTitle = false; }
126
+ }}
127
+ />
128
+ </span>
129
+ {:else if trimmedTitle}
130
+ <span class="group/title min-w-0 flex-1 flex items-center gap-1">
131
+ <span class="min-w-0 truncate text-[10px] leading-tight text-slate-500" title={commitTitle ?? trimmedTitle}>{trimmedTitle}</span>
132
+ {#if editableTitle}
133
+ <button
134
+ type="button"
135
+ aria-label="Rename version"
136
+ title="Rename version"
137
+ class="shrink-0 hidden group-hover/title:inline text-[9px] text-slate-400 hover:text-emerald-600 {renameDisabled ? 'cursor-wait opacity-50' : 'cursor-pointer'}"
138
+ onclick={(e) => { e.stopPropagation(); openTitleEditor(); }}
139
+ >✎</button>
140
+ {/if}
141
+ </span>
142
+ {:else if editableTitle}
143
+ <span class="min-w-0 flex-1">
144
+ <button
145
+ type="button"
146
+ aria-label="Add version title"
147
+ title="Add a title for this version"
148
+ class="hidden group-hover/title:inline text-[9px] text-slate-300 hover:text-emerald-600 {renameDisabled ? 'cursor-wait opacity-50' : 'cursor-pointer'}"
149
+ onclick={(e) => { e.stopPropagation(); openTitleEditor(); }}
150
+ >+ title</button>
151
+ </span>
152
+ {/if}
68
153
  {#if showCommit}
69
154
  <span class="{COL_COMMIT} flex shrink-0 font-mono text-slate-400" title={commitSha ?? ''}>{commitSha ? commitSha.slice(0, 7) : '—'}</span>
70
155
  {/if}
@@ -5,6 +5,10 @@ interface Props {
5
5
  commitSha?: string | null;
6
6
  /** Show the commit cell (debug mode). */
7
7
  showCommit?: boolean;
8
+ /** What the change shipped, in the pipeline's own words — the same title
9
+ * the chat turn's commit chrome shows. Trimmed; null on pre-title
10
+ * versions, which simply get no title line. */
11
+ commitTitle?: string | null;
8
12
  /** Date label; the cell shows an em dash when missing. */
9
13
  dateLabel?: string | null;
10
14
  /** Row is the currently live version — LIVE badge in the action slot. */
@@ -20,9 +24,15 @@ interface Props {
20
24
  publishDisabledReason?: string | null;
21
25
  /** Set-live disabled for this row. */
22
26
  setLiveDisabled?: boolean;
27
+ /** The owner may edit the version's title inline (pencil affordance on the title line). */
28
+ editableTitle?: boolean;
29
+ /** Rename in flight for this row — the input commits with '…' until it resolves. */
30
+ renameDisabled?: boolean;
23
31
  onselect?: () => void;
24
32
  onpublish?: () => void;
25
33
  onsetlive?: () => void;
34
+ /** Commit the edited title (the owner's correction of the pipeline's wording). */
35
+ onrenametitle?: (title: string) => void;
26
36
  }
27
37
  declare const VersionDropdownRow: import("svelte").Component<Props, {}, "">;
28
38
  type VersionDropdownRow = ReturnType<typeof VersionDropdownRow>;
@@ -15,7 +15,8 @@
15
15
  import { Button, showToast, GenerateLoader } from '@widgetic/design-system/components';
16
16
  import {
17
17
  AgentApi,
18
- WidgetBuilderApi
18
+ WidgetBuilderApi,
19
+ isRetiredSurfaceError
19
20
  } from '@widgetic/api-sdk';
20
21
  import { maxCharsFromId } from '../constants.js';
21
22
  import {
@@ -1509,7 +1510,23 @@
1509
1510
  return;
1510
1511
  } catch (buildErr: any) {
1511
1512
  let buildErrorCode = '';
1512
- lastBuildErrorMsg = buildErr?.message || 'Build request failed';
1513
+ lastBuildErrorMsg = buildErr?.message || 'Build request failed'; // Retired-surface 410 (PLANNER_REMOVED / VM_REMOVED): this running
1514
+ // bundle predates a removal — retrying cannot fix it, surface the
1515
+ // upgrade prompt instead of burning the retry loop.
1516
+ if (isRetiredSurfaceError(buildErr)) {
1517
+ lastBuildErrorMsg = buildErr.message;
1518
+ showToast('warning', 'This app needs a refresh', {
1519
+ description: buildErr.message,
1520
+ duration: 12000,
1521
+ action: {
1522
+ label: 'Reload',
1523
+ onClick: () => window.location.reload()
1524
+ }
1525
+ });
1526
+ // Break, not return: the failure UI below (Step 3) must still
1527
+ // reset the preview loading state with the envelope message.
1528
+ break;
1529
+ }
1513
1530
 
1514
1531
  // SDK ResponseError may carry API error payload
1515
1532
  const errBody = buildErr?.response?.value?.() ?? buildErr?.body ?? null;
@@ -479,11 +479,16 @@
479
479
  versionMenuBox = null;
480
480
  return;
481
481
  }
482
- const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
482
+ const trigger = event.currentTarget as HTMLElement;
483
+ const triggerRect = trigger.getBoundingClientRect();
484
+ const cluster = trigger.closest('.widget-shape-version-ct');
485
+ const alignRect = cluster?.getBoundingClientRect() ?? triggerRect;
486
+ // The row action sits inside the menu border (1px), menu padding (4px) and row padding (8px).
487
+ const actionInset = 13;
483
488
  versionMenuBox = {
484
489
  widgetId,
485
- top: rect.bottom + 2,
486
- right: window.innerWidth - rect.right,
490
+ top: triggerRect.bottom + 2,
491
+ right: Math.max(0, window.innerWidth - alignRect.right - actionInset),
487
492
  };
488
493
  openVersionMenuWidgetId = widgetId;
489
494
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { HealthApi, Configuration, ResponseError, isRetiredSurfaceError, readEnvelopeErrorCode, RETIRED_SURFACE_CODES } from '@widgetic/api-sdk';
3
+ /**
4
+ * The SDK must recognize the API's retired-surface 410 envelope so stale
5
+ * bundles can surface an upgrade prompt instead of a generic failure.
6
+ *
7
+ * The API answers permanently retired routes (planner endpoints, VM flows,
8
+ * VM metrics) with 410 Gone and the house error envelope:
9
+ *
10
+ * { success: false, error: { code: 'PLANNER_REMOVED', message: '… Use POST /agent/code/generate …', timestamp: '…' } }
11
+ *
12
+ * Two properties are pinned:
13
+ *
14
+ * 1. **End to end through the real request pipeline** — a fetch mocked to
15
+ * return that envelope produces a ResponseError whose `code` is the
16
+ * machine-readable value and whose `message` carries the replacement
17
+ * hint; `isRetiredSurfaceError` discriminates it from every lookalike
18
+ * (other statuses, other codes, non-ResponseError throws).
19
+ * 2. **The envelope reader is safe by contract** — null (never a throw) for
20
+ * non-JSON bodies, envelopes without a code, or empty code strings.
21
+ *
22
+ * These tests import the package the app imports — the built `dist` when
23
+ * resolved through node_modules, or the aliased sibling source in dev —
24
+ * so what is tested is exactly what a stale bundle would ship.
25
+ */
26
+ const RETIRED_ENVELOPE = (code) => {
27
+ const message = `The ${code} surface was removed. Use POST /agent/code/generate (Direct LLM + Widget Builder).`;
28
+ return {
29
+ success: false,
30
+ message,
31
+ error: {
32
+ code,
33
+ message,
34
+ timestamp: new Date().toISOString()
35
+ }
36
+ };
37
+ };
38
+ /** 410 response whose body is the house error envelope. */
39
+ function retiredResponse(code) {
40
+ return new Response(JSON.stringify(RETIRED_ENVELOPE(code)), {
41
+ status: 410,
42
+ headers: { 'content-type': 'application/json' }
43
+ });
44
+ }
45
+ function apiWithFetch(fetchImpl) {
46
+ return new HealthApi(new Configuration({ basePath: 'https://api.test', fetchApi: fetchImpl }));
47
+ }
48
+ describe('retired-surface 410 envelopes surface as typed ResponseErrors', () => {
49
+ it.each(RETIRED_SURFACE_CODES)('carries the machine-readable %s code end to end', async (code) => {
50
+ const api = apiWithFetch((async () => retiredResponse(code)));
51
+ const err = await api.checkHealth().catch((e) => e);
52
+ expect(err).toBeInstanceOf(ResponseError);
53
+ const responseError = err;
54
+ expect(responseError.response.status).toBe(410);
55
+ expect(responseError.code).toBe(code);
56
+ // The replacement hint rides the envelope message, which the SDK
57
+ // already surfaces as the error message.
58
+ expect(responseError.message).toContain('POST /agent/code/generate');
59
+ // And the public helper discriminates exactly this shape.
60
+ expect(isRetiredSurfaceError(responseError)).toBe(true);
61
+ });
62
+ it('does not flag a 410 without a retired code', async () => {
63
+ const body = JSON.stringify({ success: false, error: { code: 'SOMETHING_ELSE', message: 'gone', timestamp: 't' } });
64
+ const api = apiWithFetch((async () => new Response(body, { status: 410 })));
65
+ const err = await api.checkHealth().catch((e) => e);
66
+ expect(err).toBeInstanceOf(ResponseError);
67
+ expect(err.code).toBe('SOMETHING_ELSE');
68
+ expect(isRetiredSurfaceError(err)).toBe(false);
69
+ });
70
+ it('does not flag a 410 whose body has no envelope code', async () => {
71
+ const api = apiWithFetch((async () => new Response('{"message":"gone"}', { status: 410 })));
72
+ const err = await api.checkHealth().catch((e) => e);
73
+ expect(err.code).toBeNull();
74
+ expect(isRetiredSurfaceError(err)).toBe(false);
75
+ });
76
+ it('does not flag retired codes on other statuses', async () => {
77
+ const body = JSON.stringify(RETIRED_ENVELOPE('VM_REMOVED'));
78
+ const api = apiWithFetch((async () => new Response(body, { status: 503 })));
79
+ const err = await api.checkHealth().catch((e) => e);
80
+ // The message still surfaces (the SDK parses envelope messages), but a
81
+ // non-410 must not read as "your bundle is stale" — 503 means retry.
82
+ expect(isRetiredSurfaceError(err)).toBe(false);
83
+ });
84
+ it('rejects non-ResponseError throws and unrelated values', () => {
85
+ expect(isRetiredSurfaceError(new Error('boom'))).toBe(false);
86
+ expect(isRetiredSurfaceError('PLANNER_REMOVED')).toBe(false);
87
+ expect(isRetiredSurfaceError(null)).toBe(false);
88
+ expect(isRetiredSurfaceError(undefined)).toBe(false);
89
+ });
90
+ });
91
+ describe('readEnvelopeErrorCode', () => {
92
+ it('reads the code from a house envelope', async () => {
93
+ expect(await readEnvelopeErrorCode(retiredResponse('VM_METRICS_REMOVED'))).toBe('VM_METRICS_REMOVED');
94
+ });
95
+ it('returns null for non-JSON bodies instead of throwing', async () => {
96
+ const html = new Response('<!DOCTYPE html><html><body>error</body></html>', {
97
+ status: 410,
98
+ headers: { 'content-type': 'text/html' }
99
+ });
100
+ expect(await readEnvelopeErrorCode(html)).toBeNull();
101
+ });
102
+ it('returns null for JSON without an envelope code', async () => {
103
+ const bare = new Response(JSON.stringify({ message: 'no code here' }), { status: 410 });
104
+ expect(await readEnvelopeErrorCode(bare)).toBeNull();
105
+ });
106
+ it('does not consume the body it reads', async () => {
107
+ const response = retiredResponse('PLANNER_REMOVED');
108
+ await readEnvelopeErrorCode(response);
109
+ // A clone is used internally, so the original body stays readable for
110
+ // later handlers (error toasts, logging).
111
+ await expect(response.json()).resolves.toBeTruthy();
112
+ });
113
+ });
@@ -9,6 +9,9 @@ export interface WidgetPublishStatus {
9
9
  status: 'success' | 'failure' | 'building' | null;
10
10
  version?: number;
11
11
  artifactUrl?: string;
12
+ /** Title of the published version row — lets the optimistic history merge label the new version without a refetch. */
13
+ commitTitle?: string | null;
14
+ commitSha?: string | null;
12
15
  message?: string;
13
16
  error?: {
14
17
  code?: string;