@widgetic/creator 0.3.71 → 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 +882 -78
  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,385 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { classifyDesignIntent, recordDesignIntentTelemetry, recordDesignIntentJevOutcome, getDesignIntentTelemetrySnapshot, formatDesignIntentTelemetryReport, parsePersistedTelemetry, resetDesignIntentTelemetry, isJevCandidateBucket, JEV_CANDIDATE_BUCKET } from './designIntentTelemetry';
3
+ const SCHEMA = {
4
+ componentGroups: [
5
+ {
6
+ name: 'Design',
7
+ components: [
8
+ {
9
+ id: 'appearance',
10
+ propertyGroups: [
11
+ {
12
+ name: 'Colors',
13
+ properties: [
14
+ {
15
+ id: 'backgroundColor',
16
+ type: 'string',
17
+ defaultValue: '#ffffff',
18
+ inputController: { type: 'colorPicker', description: 'Background Color' }
19
+ },
20
+ {
21
+ id: 'titleColor',
22
+ type: 'string',
23
+ defaultValue: '#111827',
24
+ inputController: { type: 'colorPicker', description: 'Title Color' }
25
+ }
26
+ ]
27
+ },
28
+ {
29
+ name: 'Layout',
30
+ properties: [
31
+ {
32
+ id: 'padding',
33
+ type: 'number',
34
+ defaultValue: 16,
35
+ inputController: { type: 'slider', description: 'Padding', config: { min: 0, max: 64, step: 1 } }
36
+ },
37
+ {
38
+ id: 'showShadow',
39
+ type: 'boolean',
40
+ defaultValue: true,
41
+ inputController: { type: 'switch', description: 'Drop Shadow' }
42
+ },
43
+ {
44
+ id: 'widgetTitle',
45
+ type: 'string',
46
+ defaultValue: 'My Widget',
47
+ inputController: { type: 'textInput', description: 'Widget Title' }
48
+ }
49
+ ]
50
+ }
51
+ ]
52
+ }
53
+ ]
54
+ }
55
+ ]
56
+ };
57
+ beforeEach(() => {
58
+ vi.spyOn(console, 'log').mockImplementation(() => { });
59
+ resetDesignIntentTelemetry();
60
+ });
61
+ describe('classifyDesignIntent — literal/arithmetic (deterministic)', () => {
62
+ it.each([
63
+ 'make the background #1a1a2e',
64
+ 'increase the padding by 8',
65
+ 'double the padding',
66
+ 'turn the drop shadow off',
67
+ 'set the padding to 24'
68
+ ])('%s', (message) => {
69
+ expect(classifyDesignIntent(message, SCHEMA).bucket).toBe('literal_arithmetic');
70
+ });
71
+ });
72
+ describe('classifyDesignIntent — bounded-set semantic (JEV-shaped)', () => {
73
+ it.each([
74
+ 'make it pop',
75
+ 'give it more breathing room',
76
+ 'make the background softer',
77
+ 'make it darker',
78
+ 'change the color to red'
79
+ ])('%s', (message) => {
80
+ expect(classifyDesignIntent(message, SCHEMA).bucket).toBe('bounded_set_semantic');
81
+ });
82
+ it('records the properties the message named', () => {
83
+ const result = classifyDesignIntent('change the color to red', SCHEMA);
84
+ expect(result.propertyIds.length).toBeGreaterThan(1);
85
+ });
86
+ });
87
+ describe('classifyDesignIntent — free text (LLM)', () => {
88
+ it.each(['change the widget title to grocery list', 'set the title to hello world'])('%s', (message) => {
89
+ expect(classifyDesignIntent(message, SCHEMA).bucket).toBe('free_text');
90
+ });
91
+ });
92
+ describe('classifyDesignIntent — new property (LLM/generation)', () => {
93
+ it.each(['add a border radius property', 'make the title editable', 'add a shadow setting'])('%s', (message) => {
94
+ expect(classifyDesignIntent(message, SCHEMA).bucket).toBe('new_property');
95
+ });
96
+ });
97
+ describe('classifyDesignIntent — not a design ask (codegen as today)', () => {
98
+ it.each([
99
+ 'add a confetti animation when the timer ends',
100
+ 'how many items are in my list?',
101
+ 'fetch the data from my spreadsheet every morning'
102
+ ])('%s', (message) => {
103
+ expect(classifyDesignIntent(message, SCHEMA).bucket).toBe('not_design');
104
+ });
105
+ });
106
+ describe('shadow telemetry — records without changing behavior', () => {
107
+ it('counts each bucket and reports fractions over classified sends', () => {
108
+ recordDesignIntentTelemetry('make the background #1a1a2e', SCHEMA); // literal
109
+ recordDesignIntentTelemetry('increase the padding by 8', SCHEMA); // literal
110
+ recordDesignIntentTelemetry('make it pop', SCHEMA); // bounded-set
111
+ recordDesignIntentTelemetry('change the title to grocery list', SCHEMA); // free text
112
+ recordDesignIntentTelemetry('add a shadow property', SCHEMA); // new property
113
+ recordDesignIntentTelemetry('add a confetti animation', SCHEMA); // not design
114
+ const snap = getDesignIntentTelemetrySnapshot();
115
+ expect(snap.total).toBe(6);
116
+ expect(snap.counted).toBe(6);
117
+ expect(snap.noSchema).toBe(0);
118
+ expect(snap.buckets).toMatchObject({
119
+ literal_arithmetic: 2,
120
+ bounded_set_semantic: 1,
121
+ free_text: 1,
122
+ new_property: 1,
123
+ not_design: 1
124
+ });
125
+ expect(snap.deterministicShare).toBeCloseTo(2 / 6, 5);
126
+ expect(snap.modelShare).toBeCloseTo(3 / 6, 5);
127
+ });
128
+ it('counts sends with no design schema separately (unclassifiable)', () => {
129
+ recordDesignIntentTelemetry('make the background red', {});
130
+ const snap = getDesignIntentTelemetrySnapshot();
131
+ expect(snap.total).toBe(1);
132
+ expect(snap.counted).toBe(0);
133
+ expect(snap.noSchema).toBe(1);
134
+ expect(snap.deterministicShare).toBe(0);
135
+ });
136
+ it('formats a readable report', () => {
137
+ recordDesignIntentTelemetry('make the background #1a1a2e', SCHEMA);
138
+ recordDesignIntentTelemetry('make it pop', SCHEMA);
139
+ const report = formatDesignIntentTelemetryReport(getDesignIntentTelemetrySnapshot());
140
+ expect(report).toContain('literal/arithmetic (deterministic): 1 (50.0%)');
141
+ expect(report).toContain('bounded-set semantic (JEV-shaped): 1 (50.0%)');
142
+ expect(report).toContain('could skip the LLM (deterministic share): 50.0%');
143
+ });
144
+ });
145
+ describe('Jev candidate bucket', () => {
146
+ it('is only the bounded-set-semantic bucket', () => {
147
+ expect(JEV_CANDIDATE_BUCKET).toBe('bounded_set_semantic');
148
+ expect(isJevCandidateBucket('bounded_set_semantic')).toBe(true);
149
+ expect(isJevCandidateBucket('free_text')).toBe(false);
150
+ expect(isJevCandidateBucket('new_property')).toBe(false);
151
+ expect(isJevCandidateBucket('literal_arithmetic')).toBe(false);
152
+ expect(isJevCandidateBucket('not_design')).toBe(false);
153
+ expect(isJevCandidateBucket(null)).toBe(false);
154
+ expect(isJevCandidateBucket(undefined)).toBe(false);
155
+ });
156
+ it('matches the bucket the classifier gives a semantic design ask', () => {
157
+ expect(isJevCandidateBucket(classifyDesignIntent('make it pop', SCHEMA).bucket)).toBe(true);
158
+ expect(isJevCandidateBucket(classifyDesignIntent('add a confetti animation', SCHEMA).bucket)).toBe(false);
159
+ });
160
+ });
161
+ describe('Jev outcomes split by ask shape', () => {
162
+ /** The classification of a send, as the caller would hold it. */
163
+ const classified = (message) => classifyDesignIntent(message, SCHEMA);
164
+ it('tags the two bounded-set shapes the property question turns on', () => {
165
+ // A property is named, so the *value* question carries the weight.
166
+ expect(classified('make the background softer').shape).toBe('property_named');
167
+ // Only a value is stated, so the *property* question does.
168
+ expect(classified('make it pop').shape).toBe('value_only');
169
+ });
170
+ it('leaves every other bucket shapeless', () => {
171
+ expect(classified('make the background #1a1a2e').shape).toBeUndefined();
172
+ expect(classified('set the title to hello world').shape).toBeUndefined();
173
+ expect(classified('add a shadow property').shape).toBeUndefined();
174
+ expect(classified('add a confetti animation').shape).toBeUndefined();
175
+ });
176
+ it('splits the same reason count by the shape that produced it', () => {
177
+ recordDesignIntentJevOutcome({
178
+ source: 'jev',
179
+ acted: false,
180
+ reason: 'low_confidence_value',
181
+ classification: classified('make the background softer')
182
+ });
183
+ recordDesignIntentJevOutcome({
184
+ source: 'jev',
185
+ acted: false,
186
+ reason: 'low_confidence',
187
+ classification: classified('make it pop')
188
+ });
189
+ recordDesignIntentJevOutcome({
190
+ source: 'jev',
191
+ acted: false,
192
+ reason: 'low_confidence',
193
+ classification: classified('make it pop')
194
+ });
195
+ recordDesignIntentJevOutcome({
196
+ source: 'jev',
197
+ acted: true,
198
+ reason: 'resolved',
199
+ classification: classified('give it more breathing room')
200
+ });
201
+ const { jev } = getDesignIntentTelemetrySnapshot();
202
+ expect(jev.attempts).toBe(4);
203
+ expect(jev.byReason).toMatchObject({ low_confidence: 2, low_confidence_value: 1, resolved: 1 });
204
+ // The overall total is unchanged; the split is what the report reads.
205
+ expect(jev.byShape.property_named).toMatchObject({
206
+ attempts: 1,
207
+ acted: 0,
208
+ fellThrough: 1,
209
+ resolvedShare: 0
210
+ });
211
+ expect(jev.byShape.property_named.byReason).toMatchObject({ low_confidence_value: 1 });
212
+ expect(jev.byShape.value_only).toMatchObject({
213
+ attempts: 3,
214
+ acted: 1,
215
+ fellThrough: 2,
216
+ resolvedShare: 1 / 3
217
+ });
218
+ expect(jev.byShape.value_only.byReason).toMatchObject({ low_confidence: 2, resolved: 1 });
219
+ });
220
+ it('counts a dial with no classification as unknown rather than guessing a shape', () => {
221
+ recordDesignIntentJevOutcome({ source: 'degraded', acted: false, reason: 'timeout' });
222
+ const { jev } = getDesignIntentTelemetrySnapshot();
223
+ expect(jev.byShape.unknown).toMatchObject({ attempts: 1, fellThrough: 1, resolvedShare: 0 });
224
+ expect(jev.byShape.unknown.byReason).toMatchObject({ timeout: 1 });
225
+ expect(jev.byShape.property_named.attempts).toBe(0);
226
+ expect(jev.byShape.value_only.attempts).toBe(0);
227
+ });
228
+ it('counts a dial on a bucket that is never dialed as the anomaly it is', () => {
229
+ recordDesignIntentJevOutcome({
230
+ source: 'jev',
231
+ acted: false,
232
+ reason: 'low_confidence',
233
+ classification: classified('make the background #1a1a2e')
234
+ });
235
+ expect(getDesignIntentTelemetrySnapshot().jev.offBucketDials).toBe(1);
236
+ recordDesignIntentJevOutcome({
237
+ source: 'jev',
238
+ acted: false,
239
+ reason: 'low_confidence',
240
+ classification: classified('make it pop')
241
+ });
242
+ expect(getDesignIntentTelemetrySnapshot().jev.offBucketDials).toBe(1);
243
+ });
244
+ it('attributes the client-side no-response dial to the shape it was dialed for', () => {
245
+ recordDesignIntentJevOutcome({
246
+ source: 'client_unavailable',
247
+ acted: false,
248
+ reason: 'no_response',
249
+ classification: classified('make it darker')
250
+ });
251
+ const { jev } = getDesignIntentTelemetrySnapshot();
252
+ expect(jev.byShape.value_only.byReason).toMatchObject({ no_response: 1 });
253
+ // A client failure is not evidence about the option strings.
254
+ expect(jev.byShape.value_only.attempts).toBe(1);
255
+ });
256
+ });
257
+ describe('formatDesignIntentTelemetryReport — the Jev breakdown', () => {
258
+ it('prints the reason counts per ask shape with the knob each points at', () => {
259
+ recordDesignIntentJevOutcome({
260
+ source: 'jev',
261
+ acted: true,
262
+ reason: 'resolved',
263
+ classification: classifyDesignIntent('make the background softer', SCHEMA)
264
+ });
265
+ recordDesignIntentJevOutcome({
266
+ source: 'jev',
267
+ acted: false,
268
+ reason: 'low_confidence_value',
269
+ classification: classifyDesignIntent('make the background softer', SCHEMA)
270
+ });
271
+ recordDesignIntentJevOutcome({
272
+ source: 'jev',
273
+ acted: false,
274
+ reason: 'none_of_these',
275
+ classification: classifyDesignIntent('make it pop', SCHEMA)
276
+ });
277
+ const report = formatDesignIntentTelemetryReport(getDesignIntentTelemetrySnapshot());
278
+ expect(report).toContain('Jev fallback on the bounded-set-semantic bucket: 1/3 resolved (33.3%), 2 fell through');
279
+ // Ties are ordered by name, so the same input always prints the same line.
280
+ expect(report).toContain('reasons overall: low_confidence_value=1, none_of_these=1, resolved=1');
281
+ expect(report).toContain('by ask shape (which question carried the ask):');
282
+ expect(report).toContain('property_named — the value question was under load: 1/2 resolved (50.0%), 1 fell through');
283
+ expect(report).toContain('value_only — the property question was under load: 0/1 resolved (0.0%), 1 fell through');
284
+ // The reason is annotated with the lever it implicates, which is the point.
285
+ expect(report).toContain('low_confidence_value=1 — the confidence floor on the *value* question');
286
+ expect(report).toContain('none_of_these=1 — the property option strings (nothing matched the ask)');
287
+ });
288
+ it('flags a reason the report has no lever for instead of inventing one', () => {
289
+ recordDesignIntentJevOutcome({ source: 'jev', acted: false, reason: 'brand_new_reason' });
290
+ const report = formatDesignIntentTelemetryReport(getDesignIntentTelemetrySnapshot());
291
+ expect(report).toContain('brand_new_reason=1 — unrecognised reason');
292
+ });
293
+ it('keeps the report quiet when nothing was dialed', () => {
294
+ recordDesignIntentTelemetry('make it pop', SCHEMA);
295
+ const report = formatDesignIntentTelemetryReport(getDesignIntentTelemetrySnapshot());
296
+ expect(report).toContain('Jev fallback on the bounded-set-semantic bucket: 0/0 resolved (0.0%)');
297
+ expect(report).not.toContain('by ask shape');
298
+ });
299
+ it('surfaces an off-bucket dial only when there is one', () => {
300
+ expect(formatDesignIntentTelemetryReport(getDesignIntentTelemetrySnapshot())).not.toContain('another bucket');
301
+ recordDesignIntentJevOutcome({
302
+ source: 'jev',
303
+ acted: false,
304
+ reason: 'low_confidence',
305
+ classification: classifyDesignIntent('make the background #1a1a2e', SCHEMA)
306
+ });
307
+ expect(formatDesignIntentTelemetryReport(getDesignIntentTelemetrySnapshot())).toContain('1 dials were attributed to another bucket');
308
+ });
309
+ });
310
+ describe('parsePersistedTelemetry — the upgrade path', () => {
311
+ it('loads a payload stored before the per-shape breakdown existed', () => {
312
+ // The shape the first version of this module wrote: no byShape, no
313
+ // offBucketDials. Existing users hold exactly this, so it must not reset.
314
+ const legacy = JSON.stringify({
315
+ total: 40,
316
+ noSchema: 5,
317
+ buckets: { literal_arithmetic: 20, bounded_set_semantic: 8, free_text: 4, new_property: 2, not_design: 1 },
318
+ jev: { attempts: 6, acted: 3, fellThrough: 3, byReason: { resolved: 3, low_confidence: 2, no_change: 1 } }
319
+ });
320
+ const parsed = parsePersistedTelemetry(legacy);
321
+ expect(parsed.total).toBe(40);
322
+ expect(parsed.noSchema).toBe(5);
323
+ expect(parsed.buckets.bounded_set_semantic).toBe(8);
324
+ expect(parsed.jev).toMatchObject({ attempts: 6, acted: 3, fellThrough: 3, offBucketDials: 0 });
325
+ expect(parsed.jev.byReason).toMatchObject({ resolved: 3, low_confidence: 2, no_change: 1 });
326
+ expect(parsed.jev.byShape).toMatchObject({
327
+ property_named: { attempts: 0, byReason: {} },
328
+ value_only: { attempts: 0, byReason: {} },
329
+ unknown: { attempts: 0, byReason: {} }
330
+ });
331
+ });
332
+ it('round-trips a payload that does have the breakdown', () => {
333
+ const parsed = parsePersistedTelemetry(JSON.stringify({
334
+ total: 1,
335
+ noSchema: 0,
336
+ buckets: { bounded_set_semantic: 1 },
337
+ jev: {
338
+ attempts: 1,
339
+ acted: 0,
340
+ fellThrough: 1,
341
+ byReason: { low_confidence: 1 },
342
+ byShape: { value_only: { attempts: 1, acted: 0, fellThrough: 1, byReason: { low_confidence: 1 } } },
343
+ offBucketDials: 0
344
+ }
345
+ }));
346
+ expect(parsed.jev.byShape.value_only.byReason).toMatchObject({ low_confidence: 1 });
347
+ expect(parsed.jev.byShape.value_only).toMatchObject({ attempts: 1, acted: 0, fellThrough: 1 });
348
+ });
349
+ it('reads garbage, negative and non-numeric counters as zero', () => {
350
+ expect(parsePersistedTelemetry('not json')).toMatchObject({ total: 0, noSchema: 0 });
351
+ expect(parsePersistedTelemetry(null).jev.attempts).toBe(0);
352
+ const hostile = parsePersistedTelemetry(JSON.stringify({ total: -3, buckets: { bounded_set_semantic: 'eight' }, jev: { attempts: null } }));
353
+ expect(hostile.total).toBe(0);
354
+ expect(hostile.buckets.bounded_set_semantic).toBe(0);
355
+ expect(hostile.jev.attempts).toBe(0);
356
+ expect(hostile.jev.byShape.unknown.attempts).toBe(0);
357
+ });
358
+ });
359
+ describe('recordDesignIntentJevOutcome — the resolution rate', () => {
360
+ it('counts attempts, acts, fall-throughs, and reasons', () => {
361
+ recordDesignIntentJevOutcome({ source: 'jev', acted: true, reason: 'resolved' });
362
+ recordDesignIntentJevOutcome({ source: 'jev', acted: false, reason: 'low_confidence' });
363
+ recordDesignIntentJevOutcome({ source: 'jev', acted: false, reason: 'low_confidence' });
364
+ recordDesignIntentJevOutcome({ source: 'degraded', acted: false, reason: 'timeout' });
365
+ const snap = getDesignIntentTelemetrySnapshot();
366
+ expect(snap.jev).toMatchObject({ attempts: 4, acted: 1, fellThrough: 3 });
367
+ expect(snap.jev.resolvedShare).toBeCloseTo(0.25, 5);
368
+ expect(snap.jev.byReason).toMatchObject({ resolved: 1, low_confidence: 2, timeout: 1 });
369
+ // Unclassified dials are still counted — under `unknown`, never guessed.
370
+ expect(snap.jev.byShape.unknown).toMatchObject({ attempts: 4, acted: 1, fellThrough: 3 });
371
+ expect(snap.jev.byShape.unknown.resolvedShare).toBeCloseTo(0.25, 5);
372
+ });
373
+ it('reports zero rather than dividing by zero with no attempts', () => {
374
+ const snap = getDesignIntentTelemetrySnapshot();
375
+ expect(snap.jev).toMatchObject({ attempts: 0, acted: 0, fellThrough: 0, resolvedShare: 0 });
376
+ });
377
+ it('surfaces the Jev line and reason breakdown in the report', () => {
378
+ recordDesignIntentJevOutcome({ source: 'jev', acted: true, reason: 'resolved' });
379
+ recordDesignIntentJevOutcome({ source: 'jev', acted: false, reason: 'none_of_these' });
380
+ const report = formatDesignIntentTelemetryReport(getDesignIntentTelemetrySnapshot());
381
+ expect(report).toContain('Jev fallback on the bounded-set-semantic bucket: 1/2 resolved (50.0%)');
382
+ expect(report).toContain('reasons overall:');
383
+ expect(report).toContain('none_of_these=1');
384
+ });
385
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * designQuickEditValues — value resolution for the inline design-control card
3
+ * (`DesignQuickEdit.svelte`).
4
+ *
5
+ * The card is created from a resolution snapshot, but the widget's live design
6
+ * state keeps moving: while the card sits in the chat the user can drag the
7
+ * very same property in the PropsEditor panel. The card therefore reads its
8
+ * current value from the host's live design values and only falls back to the
9
+ * snapshot it was built with, so the two stay in sync in both directions.
10
+ *
11
+ * Kept pure so the resolution rules are testable without a DOM.
12
+ */
13
+ import type { DesignControlProperty, DesignControlRequest } from './designIntentResolver';
14
+ /**
15
+ * The editor stores some design values as `{ value }` envelopes (option
16
+ * objects, aspect ratios). The card renders primitives, so unwrap one level;
17
+ * everything else is returned as-is.
18
+ */
19
+ export declare function unwrapQuickEditValue(value: unknown): unknown;
20
+ /**
21
+ * The value a single control should show: the host's live value when one
22
+ * exists for the property, otherwise the value from the resolution snapshot.
23
+ */
24
+ export declare function pickQuickEditValue(property: DesignControlProperty, liveValues?: Record<string, unknown> | null): unknown;
25
+ /** Resolve every control's display value in one pass (drives the card's seed). */
26
+ export declare function resolveQuickEditValues(properties: readonly DesignControlProperty[], liveValues?: Record<string, unknown> | null): Record<string, unknown>;
27
+ /**
28
+ * Whether the property the card controls still exists in the widget's current
29
+ * design schema. `undefined` means the host did not supply the roster (or the
30
+ * schema is not loaded yet) — then every control stays live rather than being
31
+ * disabled on a guess.
32
+ */
33
+ export declare function isQuickEditPropertyAvailable(propertyId: string, knownPropertyIds?: ReadonlySet<string> | null): boolean;
34
+ /**
35
+ * Whether a card may read (and write) the live design values of the composition
36
+ * that is loaded right now.
37
+ *
38
+ * A card records the composition it was authored against; the host's live
39
+ * values belong to whatever composition is loaded *now*. Comparing the two is
40
+ * the whole guard: an older card under a newly selected composition must not
41
+ * show — or write — that composition's value under a shared property id.
42
+ *
43
+ * A card with no recorded composition (created before the field existed, or by
44
+ * a host that edits design values with no saved composition) counts as
45
+ * attached. That is the behaviour that preceded re-attachment, and detaching it
46
+ * would freeze cards that were working. A card that *did* record one, read back
47
+ * while no composition is selected, is detached instead: the live values cannot
48
+ * be shown to belong to it, and reopening the composition is cheap while a
49
+ * wrong write is not.
50
+ */
51
+ export declare function isDesignCardAttached(card: Pick<DesignControlRequest, 'compositionId'>, currentCompositionId?: string | null): boolean;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * designQuickEditValues — value resolution for the inline design-control card
3
+ * (`DesignQuickEdit.svelte`).
4
+ *
5
+ * The card is created from a resolution snapshot, but the widget's live design
6
+ * state keeps moving: while the card sits in the chat the user can drag the
7
+ * very same property in the PropsEditor panel. The card therefore reads its
8
+ * current value from the host's live design values and only falls back to the
9
+ * snapshot it was built with, so the two stay in sync in both directions.
10
+ *
11
+ * Kept pure so the resolution rules are testable without a DOM.
12
+ */
13
+ /**
14
+ * The editor stores some design values as `{ value }` envelopes (option
15
+ * objects, aspect ratios). The card renders primitives, so unwrap one level;
16
+ * everything else is returned as-is.
17
+ */
18
+ export function unwrapQuickEditValue(value) {
19
+ if (value &&
20
+ typeof value === 'object' &&
21
+ !Array.isArray(value) &&
22
+ 'value' in value) {
23
+ return value.value;
24
+ }
25
+ return value;
26
+ }
27
+ /**
28
+ * The value a single control should show: the host's live value when one
29
+ * exists for the property, otherwise the value from the resolution snapshot.
30
+ */
31
+ export function pickQuickEditValue(property, liveValues) {
32
+ if (liveValues && Object.prototype.hasOwnProperty.call(liveValues, property.id)) {
33
+ const live = unwrapQuickEditValue(liveValues[property.id]);
34
+ if (live !== undefined)
35
+ return live;
36
+ }
37
+ return property.value;
38
+ }
39
+ /** Resolve every control's display value in one pass (drives the card's seed). */
40
+ export function resolveQuickEditValues(properties, liveValues) {
41
+ const next = {};
42
+ for (const property of properties)
43
+ next[property.id] = pickQuickEditValue(property, liveValues);
44
+ return next;
45
+ }
46
+ /**
47
+ * Whether the property the card controls still exists in the widget's current
48
+ * design schema. `undefined` means the host did not supply the roster (or the
49
+ * schema is not loaded yet) — then every control stays live rather than being
50
+ * disabled on a guess.
51
+ */
52
+ export function isQuickEditPropertyAvailable(propertyId, knownPropertyIds) {
53
+ return !knownPropertyIds || knownPropertyIds.has(propertyId);
54
+ }
55
+ /**
56
+ * Whether a card may read (and write) the live design values of the composition
57
+ * that is loaded right now.
58
+ *
59
+ * A card records the composition it was authored against; the host's live
60
+ * values belong to whatever composition is loaded *now*. Comparing the two is
61
+ * the whole guard: an older card under a newly selected composition must not
62
+ * show — or write — that composition's value under a shared property id.
63
+ *
64
+ * A card with no recorded composition (created before the field existed, or by
65
+ * a host that edits design values with no saved composition) counts as
66
+ * attached. That is the behaviour that preceded re-attachment, and detaching it
67
+ * would freeze cards that were working. A card that *did* record one, read back
68
+ * while no composition is selected, is detached instead: the live values cannot
69
+ * be shown to belong to it, and reopening the composition is cheap while a
70
+ * wrong write is not.
71
+ */
72
+ export function isDesignCardAttached(card, currentCompositionId) {
73
+ const cardCompositionId = card.compositionId ?? null;
74
+ if (cardCompositionId === null)
75
+ return true;
76
+ return cardCompositionId === (currentCompositionId ?? null);
77
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { unwrapQuickEditValue, pickQuickEditValue, resolveQuickEditValues, isQuickEditPropertyAvailable, isDesignCardAttached } from './designQuickEditValues';
3
+ function property(overrides) {
4
+ return {
5
+ label: overrides.id,
6
+ controllerType: 'slider',
7
+ value: 0,
8
+ ...overrides
9
+ };
10
+ }
11
+ describe('unwrapQuickEditValue', () => {
12
+ it('leaves primitives alone', () => {
13
+ expect(unwrapQuickEditValue('#ff0000')).toBe('#ff0000');
14
+ expect(unwrapQuickEditValue(24)).toBe(24);
15
+ expect(unwrapQuickEditValue(true)).toBe(true);
16
+ expect(unwrapQuickEditValue(null)).toBeNull();
17
+ });
18
+ it('unwraps a value envelope', () => {
19
+ expect(unwrapQuickEditValue({ value: 'wide' })).toBe('wide');
20
+ });
21
+ it('does not unwrap arrays or objects without a value key', () => {
22
+ expect(unwrapQuickEditValue([1, 2])).toEqual([1, 2]);
23
+ expect(unwrapQuickEditValue({ family: 'Inter' })).toEqual({ family: 'Inter' });
24
+ });
25
+ });
26
+ describe('pickQuickEditValue', () => {
27
+ it('falls back to the resolution snapshot when the host has no live value', () => {
28
+ expect(pickQuickEditValue(property({ id: 'padding', value: 8 }), {})).toBe(8);
29
+ expect(pickQuickEditValue(property({ id: 'padding', value: 8 }), undefined)).toBe(8);
30
+ expect(pickQuickEditValue(property({ id: 'padding', value: 8 }), null)).toBe(8);
31
+ });
32
+ it('prefers the live value — this is the panel → card direction', () => {
33
+ expect(pickQuickEditValue(property({ id: 'padding', value: 8 }), { padding: 32 })).toBe(32);
34
+ });
35
+ it('reads a live value of 0 / false / empty string rather than the snapshot', () => {
36
+ expect(pickQuickEditValue(property({ id: 'padding', value: 8 }), { padding: 0 })).toBe(0);
37
+ expect(pickQuickEditValue(property({ id: 'shadow', value: true }), { shadow: false })).toBe(false);
38
+ expect(pickQuickEditValue(property({ id: 'title', value: 'Hi' }), { title: '' })).toBe('');
39
+ });
40
+ it('unwraps an enveloped live value', () => {
41
+ expect(pickQuickEditValue(property({ id: 'layout', value: 'narrow' }), { layout: { value: 'wide' } })).toBe('wide');
42
+ });
43
+ it('ignores an explicit undefined live value', () => {
44
+ expect(pickQuickEditValue(property({ id: 'padding', value: 8 }), { padding: undefined })).toBe(8);
45
+ });
46
+ });
47
+ describe('resolveQuickEditValues', () => {
48
+ it('merges live values over the snapshot per property', () => {
49
+ const properties = [
50
+ property({ id: 'padding', value: 8 }),
51
+ property({ id: 'background', value: '#000000', controllerType: 'colorpicker' })
52
+ ];
53
+ expect(resolveQuickEditValues(properties, { background: '#ffffff' })).toEqual({
54
+ padding: 8,
55
+ background: '#ffffff'
56
+ });
57
+ });
58
+ it('returns the snapshot values when there is no live state', () => {
59
+ const properties = [property({ id: 'padding', value: 8 })];
60
+ expect(resolveQuickEditValues(properties, null)).toEqual({ padding: 8 });
61
+ });
62
+ });
63
+ describe('isQuickEditPropertyAvailable', () => {
64
+ it('treats a missing roster as "cannot know" and keeps the control live', () => {
65
+ expect(isQuickEditPropertyAvailable('padding', undefined)).toBe(true);
66
+ expect(isQuickEditPropertyAvailable('padding', null)).toBe(true);
67
+ });
68
+ it('uses the roster when supplied', () => {
69
+ const known = new Set(['padding']);
70
+ expect(isQuickEditPropertyAvailable('padding', known)).toBe(true);
71
+ expect(isQuickEditPropertyAvailable('droppedProperty', known)).toBe(false);
72
+ });
73
+ it('disables everything for an empty roster (schema with no design properties)', () => {
74
+ expect(isQuickEditPropertyAvailable('padding', new Set())).toBe(false);
75
+ });
76
+ });
77
+ describe('isDesignCardAttached', () => {
78
+ const A = 'composition-a';
79
+ const B = 'composition-b';
80
+ it('attaches a card to the composition it recorded (this is the reload case)', () => {
81
+ expect(isDesignCardAttached({ compositionId: A }, A)).toBe(true);
82
+ });
83
+ it('detaches a card whose composition is not the one loaded', () => {
84
+ expect(isDesignCardAttached({ compositionId: A }, B)).toBe(false);
85
+ });
86
+ it('detaches a card that recorded a composition while no composition is loaded', () => {
87
+ // The live values cannot be shown to belong to this card, so it must not
88
+ // read or write them.
89
+ expect(isDesignCardAttached({ compositionId: A }, null)).toBe(false);
90
+ expect(isDesignCardAttached({ compositionId: A }, undefined)).toBe(false);
91
+ });
92
+ it('attaches a card that never recorded one — the behaviour before re-attachment', () => {
93
+ expect(isDesignCardAttached({}, null)).toBe(true);
94
+ expect(isDesignCardAttached({}, A)).toBe(true);
95
+ expect(isDesignCardAttached({ compositionId: null }, A)).toBe(true);
96
+ });
97
+ it('attaches an unattributed card and an unsaved composition to each other', () => {
98
+ expect(isDesignCardAttached({ compositionId: null }, null)).toBe(true);
99
+ });
100
+ });
@@ -13,8 +13,13 @@ export function pinScrollToBottom(node) {
13
13
  const scrollToBottom = () => {
14
14
  if (!pinned)
15
15
  return;
16
- // rAF so the layout pass has settled before reading scrollHeight.
16
+ // One frame only. Streaming status text must not queue a scroll per character.
17
+ if (rafId !== null)
18
+ return;
17
19
  rafId = requestAnimationFrame(() => {
20
+ rafId = null;
21
+ if (!pinned)
22
+ return;
18
23
  node.scrollTo({ top: node.scrollHeight, behavior: 'instant' });
19
24
  });
20
25
  };
@@ -26,7 +31,9 @@ export function pinScrollToBottom(node) {
26
31
  observer.observe(node);
27
32
  // Content growing also needs re-pinning; observe children changes.
28
33
  const contentObserver = new MutationObserver(scrollToBottom);
29
- contentObserver.observe(node, { childList: true, subtree: true, characterData: true });
34
+ // childList only. characterData fired on every streamed token and on the
35
+ // "48%" status line, and each one forced a layout of this panel.
36
+ contentObserver.observe(node, { childList: true, subtree: true });
30
37
  node.addEventListener('scroll', onScroll, { passive: true });
31
38
  // Initial pin after the first layout pass.
32
39
  const initialRaf = requestAnimationFrame(scrollToBottom);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@widgetic/creator",
3
- "version": "0.3.71",
3
+ "version": "0.3.73",
4
4
  "type": "module",
5
5
  "peerDependencies": {
6
6
  "@sveltejs/kit": "^2.20.0",
@@ -35,7 +35,7 @@
35
35
  "i": "rm -rf node_modules && npm i && npm run lps",
36
36
  "start": "node build/index.js",
37
37
  "render-build": "bash render-build.sh",
38
- "build": "NODE_ENV=production vite build && npm run package",
38
+ "build": "NODE_ENV=production NODE_OPTIONS=--max-old-space-size=8192 vite build && npm run package",
39
39
  "package": "NODE_ENV=production svelte-package",
40
40
  "package:watch": "NODE_ENV=development svelte-package --watch",
41
41
  "stop-dev": "lsof -i :5174 | awk 'NR>1 {print $2}' | xargs kill",