@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.
- package/dist/CreatorApp.svelte +882 -78
- package/dist/components/BrandKitPanel.svelte +556 -0
- package/dist/components/BrandKitPanel.svelte.d.ts +32 -0
- package/dist/components/DesignQuickEdit.svelte +218 -0
- package/dist/components/DesignQuickEdit.svelte.d.ts +33 -0
- package/dist/components/FileUrlSourceControl.svelte +69 -0
- package/dist/components/FileUrlSourceControl.svelte.d.ts +15 -0
- package/dist/components/VersionDropdownRow.svelte +86 -1
- package/dist/components/VersionDropdownRow.svelte.d.ts +10 -0
- package/dist/components/WidgetDetails.svelte +19 -2
- package/dist/components/WidgetShapeLivePreviews.svelte +8 -3
- package/dist/retiredSurface.test.d.ts +1 -0
- package/dist/retiredSurface.test.js +113 -0
- package/dist/stores/websocketStore.d.ts +3 -0
- package/dist/stores/websocketStore.js +4 -0
- package/dist/utils/canvasShapeSnapClient.d.ts +27 -0
- package/dist/utils/canvasShapeSnapClient.js +67 -0
- package/dist/utils/designIntentJevClient.d.ts +32 -0
- package/dist/utils/designIntentJevClient.js +51 -0
- package/dist/utils/designIntentResolver.d.ts +107 -0
- package/dist/utils/designIntentResolver.js +509 -0
- package/dist/utils/designIntentResolver.test.d.ts +1 -0
- package/dist/utils/designIntentResolver.test.js +246 -0
- package/dist/utils/designIntentTelemetry.d.ts +176 -0
- package/dist/utils/designIntentTelemetry.js +474 -0
- package/dist/utils/designIntentTelemetry.test.d.ts +1 -0
- package/dist/utils/designIntentTelemetry.test.js +385 -0
- package/dist/utils/designQuickEditValues.d.ts +51 -0
- package/dist/utils/designQuickEditValues.js +77 -0
- package/dist/utils/designQuickEditValues.test.d.ts +1 -0
- package/dist/utils/designQuickEditValues.test.js +100 -0
- package/dist/utils/pinScrollToBottom.js +9 -2
- 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
|
-
//
|
|
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
|
-
|
|
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.
|
|
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",
|