react-pebble 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/lib/compiler.cjs +3 -0
  2. package/dist/lib/compiler.cjs.map +1 -0
  3. package/dist/lib/compiler.js +54 -0
  4. package/dist/lib/compiler.js.map +1 -0
  5. package/dist/lib/components.cjs +2 -0
  6. package/dist/lib/components.cjs.map +1 -0
  7. package/dist/lib/components.js +80 -0
  8. package/dist/lib/components.js.map +1 -0
  9. package/dist/lib/hooks.cjs +2 -0
  10. package/dist/lib/hooks.cjs.map +1 -0
  11. package/dist/lib/hooks.js +99 -0
  12. package/dist/lib/hooks.js.map +1 -0
  13. package/dist/lib/index.cjs +2 -0
  14. package/dist/lib/index.cjs.map +1 -0
  15. package/dist/lib/index.js +585 -0
  16. package/dist/lib/index.js.map +1 -0
  17. package/dist/lib/platform.cjs +2 -0
  18. package/dist/lib/platform.cjs.map +1 -0
  19. package/dist/lib/platform.js +52 -0
  20. package/dist/lib/platform.js.map +1 -0
  21. package/dist/lib/plugin.cjs +60 -0
  22. package/dist/lib/plugin.cjs.map +1 -0
  23. package/dist/lib/plugin.js +102 -0
  24. package/dist/lib/plugin.js.map +1 -0
  25. package/dist/lib/src/compiler/index.d.ts +40 -0
  26. package/dist/lib/src/components/index.d.ts +129 -0
  27. package/dist/lib/src/hooks/index.d.ts +75 -0
  28. package/dist/lib/src/index.d.ts +36 -0
  29. package/dist/lib/src/pebble-dom-shim.d.ts +45 -0
  30. package/dist/lib/src/pebble-dom.d.ts +59 -0
  31. package/dist/lib/src/pebble-output.d.ts +44 -0
  32. package/dist/lib/src/pebble-reconciler.d.ts +16 -0
  33. package/dist/lib/src/pebble-render.d.ts +31 -0
  34. package/dist/lib/src/platform.d.ts +30 -0
  35. package/dist/lib/src/plugin/index.d.ts +20 -0
  36. package/package.json +90 -0
  37. package/scripts/compile-to-piu.ts +1794 -0
  38. package/scripts/deploy.sh +46 -0
  39. package/src/compiler/index.ts +114 -0
  40. package/src/components/index.tsx +280 -0
  41. package/src/hooks/index.ts +311 -0
  42. package/src/index.ts +126 -0
  43. package/src/pebble-dom-shim.ts +266 -0
  44. package/src/pebble-dom.ts +190 -0
  45. package/src/pebble-output.ts +310 -0
  46. package/src/pebble-reconciler.ts +54 -0
  47. package/src/pebble-render.ts +311 -0
  48. package/src/platform.ts +50 -0
  49. package/src/plugin/index.ts +274 -0
  50. package/src/types/moddable.d.ts +156 -0
@@ -0,0 +1,1794 @@
1
+ /**
2
+ * scripts/compile-to-piu.ts — "React → piu" compiler.
3
+ *
4
+ * Phase 1: renders the component in mock mode, snapshots the pebble-dom
5
+ * tree, emits piu Application.template JS code.
6
+ *
7
+ * Phase 2: renders at TWO different mock times, diffs the label texts to
8
+ * identify time-dependent labels, and emits a piu Behavior that updates
9
+ * them every second via `onTimeChanged`.
10
+ *
11
+ * Phase 3 (this version): intercepts useState and useButton to detect
12
+ * state-dependent labels and button bindings, emits a unified AppBehavior
13
+ * that handles both time ticks and state reactivity (counter patterns).
14
+ *
15
+ * Usage:
16
+ * EXAMPLE=watchface npx tsx scripts/compile-to-piu.ts > pebble-spike/src/embeddedjs/main.js
17
+ * EXAMPLE=counter npx tsx scripts/compile-to-piu.ts > pebble-spike/src/embeddedjs/main.js
18
+ */
19
+
20
+ import ts from 'typescript';
21
+ import { createRequire } from 'node:module';
22
+ import { readFileSync } from 'node:fs';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { dirname, resolve } from 'node:path';
25
+ import { render } from '../src/index.js';
26
+ import type { DOMElement, AnyNode } from '../src/pebble-dom.js';
27
+ import { getTextContent } from '../src/pebble-dom.js';
28
+ import { COLOR_PALETTE } from '../src/pebble-output.js';
29
+ import { _setUseStateImpl, _restoreUseState } from '../src/hooks/index.js';
30
+ import { useState as realUseState } from 'preact/hooks';
31
+
32
+ const require = createRequire(import.meta.url);
33
+ const __dirname = dirname(fileURLToPath(import.meta.url));
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Dynamic example import
37
+ // ---------------------------------------------------------------------------
38
+
39
+ const exampleName = process.env.EXAMPLE ?? 'watchface';
40
+ const settleMs = Number(process.env.SETTLE_MS ?? '0');
41
+ const platform = process.env.PEBBLE_PLATFORM ?? 'emery';
42
+ const settle = () =>
43
+ settleMs > 0 ? new Promise<void>((r) => setTimeout(r, settleMs)) : Promise.resolve();
44
+
45
+ // Set platform screen dimensions before importing the example
46
+ // (so SCREEN.width/height are correct when the component renders)
47
+ import { _setPlatform, SCREEN } from '../src/platform.js';
48
+ _setPlatform(platform);
49
+
50
+ const exampleMod = await import(`../examples/${exampleName}.js`);
51
+ const exampleMain: (...args: unknown[]) => ReturnType<typeof render> =
52
+ exampleMod.main ?? exampleMod.default;
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Helpers
56
+ // ---------------------------------------------------------------------------
57
+
58
+ function colorToHex(name: string): string {
59
+ const rgb = COLOR_PALETTE[name];
60
+ if (!rgb) return name;
61
+ const r = rgb.r.toString(16).padStart(2, '0');
62
+ const g = rgb.g.toString(16).padStart(2, '0');
63
+ const b = rgb.b.toString(16).padStart(2, '0');
64
+ return `#${r}${g}${b}`;
65
+ }
66
+
67
+ const FONT_TO_PIU: Record<string, string> = {
68
+ gothic14: '14px Gothic',
69
+ gothic14Bold: 'bold 14px Gothic',
70
+ gothic18: '18px Gothic',
71
+ gothic18Bold: 'bold 18px Gothic',
72
+ gothic24: '24px Gothic',
73
+ gothic24Bold: 'bold 24px Gothic',
74
+ gothic28: '28px Gothic',
75
+ gothic28Bold: 'bold 28px Gothic',
76
+ bitham30Black: 'black 30px Bitham',
77
+ bitham42Bold: 'bold 42px Bitham',
78
+ bitham42Light: 'light 42px Bitham',
79
+ };
80
+
81
+ function fontToPiu(name: string | undefined): string {
82
+ if (!name) return '18px Gothic';
83
+ return FONT_TO_PIU[name] ?? '18px Gothic';
84
+ }
85
+
86
+ function num(p: Record<string, unknown>, key: string): number {
87
+ const v = p[key];
88
+ return typeof v === 'number' ? v : 0;
89
+ }
90
+
91
+ function str(p: Record<string, unknown>, key: string): string | undefined {
92
+ const v = p[key];
93
+ return typeof v === 'string' ? v : undefined;
94
+ }
95
+
96
+ function pad2(n: number): string {
97
+ return n.toString().padStart(2, '0');
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // useState interception
102
+ // ---------------------------------------------------------------------------
103
+
104
+ interface StateSlot {
105
+ index: number;
106
+ initialValue: unknown;
107
+ setter: (v: unknown) => void;
108
+ currentValue: unknown;
109
+ }
110
+
111
+ const stateSlots: StateSlot[] = [];
112
+ const forcedStateValues: Map<number, unknown> = new Map();
113
+ let stateCallCounter = 0;
114
+
115
+ function resetStateTracking() {
116
+ stateCallCounter = 0;
117
+ }
118
+
119
+ function installUseStateInterceptor() {
120
+ // Swap our hooks module's useState implementation via the exposed
121
+ // _setUseStateImpl setter. This works because counter.tsx imports
122
+ // useState from src/hooks/index.ts, which delegates to a mutable
123
+ // internal reference.
124
+ _setUseStateImpl(function interceptedUseState<T>(
125
+ init: T | (() => T),
126
+ ): [T, (v: T | ((prev: T) => T)) => void] {
127
+ const idx = stateCallCounter++;
128
+ const [realVal, realSetter] = realUseState(init);
129
+
130
+ // First render — record the slot
131
+ if (idx >= stateSlots.length) {
132
+ const initialValue = typeof init === 'function' ? (init as () => T)() : init;
133
+ stateSlots.push({
134
+ index: idx,
135
+ initialValue,
136
+ setter: realSetter as (v: unknown) => void,
137
+ currentValue: realVal,
138
+ });
139
+ } else {
140
+ stateSlots[idx]!.currentValue = realVal;
141
+ }
142
+
143
+ // If we're in a perturbed render, override the value
144
+ if (forcedStateValues.has(idx)) {
145
+ const forced = forcedStateValues.get(idx) as T;
146
+ return [forced, realSetter];
147
+ }
148
+
149
+ return [realVal, realSetter];
150
+ });
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // useButton interception
155
+ // ---------------------------------------------------------------------------
156
+
157
+ interface ButtonBinding {
158
+ button: string;
159
+ handlerSource: string;
160
+ }
161
+
162
+ const buttonBindings: ButtonBinding[] = [];
163
+
164
+ /**
165
+ * Parse an example source file into a TypeScript AST SourceFile.
166
+ */
167
+ function parseExampleSource(exName: string): ts.SourceFile | null {
168
+ for (const ext of ['.tsx', '.ts', '.jsx']) {
169
+ const srcPath = resolve(__dirname, '..', 'examples', `${exName}${ext}`);
170
+ try {
171
+ const source = readFileSync(srcPath, 'utf-8');
172
+ return ts.createSourceFile(srcPath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
173
+ } catch { continue; }
174
+ }
175
+ return null;
176
+ }
177
+
178
+ /**
179
+ * Walk all nodes in a TypeScript AST.
180
+ */
181
+ function walkAST(node: ts.Node, visitor: (node: ts.Node) => void): void {
182
+ visitor(node);
183
+ ts.forEachChild(node, child => walkAST(child, visitor));
184
+ }
185
+
186
+ /**
187
+ * Statically analyze the example source file to extract useButton calls
188
+ * using TypeScript AST parsing.
189
+ */
190
+ function extractButtonBindingsFromSource(exName: string): void {
191
+ const sf = parseExampleSource(exName);
192
+ if (!sf) return;
193
+
194
+ walkAST(sf, (node) => {
195
+ if (!ts.isCallExpression(node)) return;
196
+ // Check callee is identifier `useButton`
197
+ if (!ts.isIdentifier(node.expression) || node.expression.text !== 'useButton') return;
198
+ if (node.arguments.length < 2) return;
199
+ const firstArg = node.arguments[0]!;
200
+ if (!ts.isStringLiteral(firstArg)) return;
201
+ const button = firstArg.text;
202
+ const handlerNode = node.arguments[1]!;
203
+ const handlerSource = handlerNode.getText(sf);
204
+ if (!buttonBindings.some((b) => b.button === button)) {
205
+ buttonBindings.push({ button, handlerSource });
206
+ }
207
+ });
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Button handler analysis
212
+ // ---------------------------------------------------------------------------
213
+
214
+ interface HandlerAction {
215
+ type: 'increment' | 'decrement' | 'reset' | 'toggle' | 'set_string';
216
+ slotIndex: number;
217
+ value: number;
218
+ stringValue?: string; // for set_string
219
+ }
220
+
221
+ /**
222
+ * Extract a setter→slot index map from source by walking the AST for
223
+ * `const [name, setName] = useState(...)` variable declarations.
224
+ */
225
+ function buildSetterSlotMap(exName: string): Map<string, number> {
226
+ const map = new Map<string, number>();
227
+ const sf = parseExampleSource(exName);
228
+ if (!sf) return map;
229
+
230
+ let idx = 0;
231
+ walkAST(sf, (node) => {
232
+ if (!ts.isVariableDeclaration(node)) return;
233
+ // Must have an initializer that calls useState
234
+ if (!node.initializer || !ts.isCallExpression(node.initializer)) return;
235
+ const callee = node.initializer.expression;
236
+ if (!ts.isIdentifier(callee) || callee.text !== 'useState') return;
237
+ // Must be an array binding pattern with 2 elements: [value, setter]
238
+ if (!ts.isArrayBindingPattern(node.name)) return;
239
+ const elements = node.name.elements;
240
+ if (elements.length < 2) return;
241
+ const setterElement = elements[1]!;
242
+ if (ts.isOmittedExpression(setterElement)) return;
243
+ const setterName = setterElement.name;
244
+ if (ts.isIdentifier(setterName)) {
245
+ map.set(setterName.text, idx++);
246
+ }
247
+ });
248
+ return map;
249
+ }
250
+
251
+ const setterSlotMap = buildSetterSlotMap(exampleName);
252
+ const listInfo = detectListPatterns(exampleName);
253
+ if (listInfo) {
254
+ process.stderr.write(`List detected: array="${listInfo.dataArrayName}" visible=${listInfo.visibleCount} labelsPerItem=${listInfo.labelsPerItem}\n`);
255
+ if (listInfo.dataArrayValues) process.stderr.write(` values: ${JSON.stringify(listInfo.dataArrayValues)}\n`);
256
+ if (listInfo.scrollSetterName) process.stderr.write(` scroll setter: ${listInfo.scrollSetterName}\n`);
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // useMessage detection — runtime async data loading
261
+ // ---------------------------------------------------------------------------
262
+
263
+ interface MessageInfo {
264
+ key: string; // Message key name (e.g., "items")
265
+ mockDataArrayName: string | null; // Variable name of mockData
266
+ }
267
+
268
+ function detectUseMessage(exName: string): MessageInfo | null {
269
+ const sf = parseExampleSource(exName);
270
+ if (!sf) return null;
271
+
272
+ let key: string | null = null;
273
+ let mockDataArrayName: string | null = null;
274
+
275
+ walkAST(sf, (node) => {
276
+ // Find: useMessage({ key: "...", mockData: ... })
277
+ if (
278
+ ts.isCallExpression(node) &&
279
+ ts.isIdentifier(node.expression) &&
280
+ node.expression.text === 'useMessage' &&
281
+ node.arguments.length > 0 &&
282
+ ts.isObjectLiteralExpression(node.arguments[0]!)
283
+ ) {
284
+ const objLit = node.arguments[0]!;
285
+ for (const prop of (objLit as ts.ObjectLiteralExpression).properties) {
286
+ if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) continue;
287
+ if (prop.name.text === 'key' && ts.isStringLiteral(prop.initializer)) {
288
+ key = prop.initializer.text;
289
+ }
290
+ if (prop.name.text === 'mockData' && ts.isIdentifier(prop.initializer)) {
291
+ mockDataArrayName = prop.initializer.text;
292
+ }
293
+ }
294
+ }
295
+ });
296
+
297
+ if (!key) return null;
298
+ return { key, mockDataArrayName };
299
+ }
300
+
301
+ const messageInfo = detectUseMessage(exampleName);
302
+ if (messageInfo) {
303
+ process.stderr.write(`useMessage detected: key="${messageInfo.key}"${messageInfo.mockDataArrayName ? ` mockData=${messageInfo.mockDataArrayName}` : ''}\n`);
304
+ }
305
+
306
+ /** Module-level map collecting string enum values per slot from handler analysis */
307
+ const stringEnumValues = new Map<number, Set<string>>();
308
+
309
+ /**
310
+ * Analyze a button handler AST node (or source string) to determine
311
+ * what action it performs (increment, decrement, reset, toggle, set_string).
312
+ */
313
+ function analyzeButtonHandler(source: string): HandlerAction | null {
314
+ // Parse the handler source as an expression statement
315
+ const wrapper = `(${source});`;
316
+ const sf = ts.createSourceFile('__handler__.ts', wrapper, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
317
+ const stmt = sf.statements[0];
318
+ if (!stmt || !ts.isExpressionStatement(stmt)) return null;
319
+ const expr = ts.isParenthesizedExpression(stmt.expression) ? stmt.expression.expression : stmt.expression;
320
+ return analyzeHandlerNode(expr, sf);
321
+ }
322
+
323
+ function analyzeHandlerNode(node: ts.Node, sf: ts.SourceFile): HandlerAction | null {
324
+ // We expect an arrow function: () => setXxx(...) or (param => expr)
325
+ if (ts.isArrowFunction(node)) {
326
+ const body = node.body;
327
+ // () => setXxx(...) — body is a call expression
328
+ if (ts.isCallExpression(body)) {
329
+ return analyzeSetterCall(body, sf);
330
+ }
331
+ // () => someExpr — try recursing
332
+ if (ts.isBlock(body)) {
333
+ // Walk statements for a setter call
334
+ for (const stmt of body.statements) {
335
+ if (ts.isExpressionStatement(stmt) && ts.isCallExpression(stmt.expression)) {
336
+ const result = analyzeSetterCall(stmt.expression, sf);
337
+ if (result) return result;
338
+ }
339
+ }
340
+ }
341
+ }
342
+ // Maybe it's a bare setter call expression (unlikely but handle it)
343
+ if (ts.isCallExpression(node)) {
344
+ return analyzeSetterCall(node, sf);
345
+ }
346
+ return null;
347
+ }
348
+
349
+ function analyzeSetterCall(call: ts.CallExpression, sf: ts.SourceFile): HandlerAction | null {
350
+ // Get setter name to look up slot index
351
+ if (!ts.isIdentifier(call.expression)) return null;
352
+ const setterName = call.expression.text;
353
+ const slotIndex = setterSlotMap.has(setterName) ? setterSlotMap.get(setterName)! : 0;
354
+
355
+ if (call.arguments.length !== 1) return null;
356
+ const arg = call.arguments[0]!;
357
+
358
+ // setXxx(numericLiteral) — reset
359
+ if (ts.isNumericLiteral(arg)) {
360
+ return { type: 'reset', slotIndex, value: Number(arg.text) };
361
+ }
362
+
363
+ // setXxx('stringLiteral') — set_string
364
+ if (ts.isStringLiteral(arg)) {
365
+ // Collect for string enum extraction
366
+ if (!stringEnumValues.has(slotIndex)) stringEnumValues.set(slotIndex, new Set());
367
+ stringEnumValues.get(slotIndex)!.add(arg.text);
368
+ return { type: 'set_string', slotIndex, value: 0, stringValue: arg.text };
369
+ }
370
+
371
+ // setXxx(arrow function) — functional update
372
+ if (ts.isArrowFunction(arg)) {
373
+ const body = arg.body;
374
+ // param => param + N (increment)
375
+ // param => param - N (decrement)
376
+ if (ts.isBinaryExpression(body)) {
377
+ if (body.operatorToken.kind === ts.SyntaxKind.PlusToken && ts.isNumericLiteral(body.right)) {
378
+ return { type: 'increment', slotIndex, value: Number(body.right.text) };
379
+ }
380
+ if (body.operatorToken.kind === ts.SyntaxKind.MinusToken && ts.isNumericLiteral(body.right)) {
381
+ return { type: 'decrement', slotIndex, value: Number(body.right.text) };
382
+ }
383
+ }
384
+ // param => !param (toggle)
385
+ if (ts.isPrefixUnaryExpression(body) && body.operator === ts.SyntaxKind.ExclamationToken) {
386
+ return { type: 'toggle', slotIndex, value: 0 };
387
+ }
388
+ }
389
+
390
+ return null;
391
+ }
392
+
393
+ // ---------------------------------------------------------------------------
394
+ // List (.map()) detection via AST
395
+ // ---------------------------------------------------------------------------
396
+
397
+ interface ListInfo {
398
+ dataArrayName: string;
399
+ dataArrayValues: string[] | null;
400
+ /** For object arrays: array of plain objects with string values */
401
+ dataArrayObjects: Record<string, string>[] | null;
402
+ /** Property names in the order they appear as labels per item */
403
+ propertyOrder: string[] | null;
404
+ visibleCount: number;
405
+ scrollSetterName: string | null;
406
+ labelsPerItem: number;
407
+ }
408
+
409
+ function detectListPatterns(exName: string): ListInfo | null {
410
+ const sf = parseExampleSource(exName);
411
+ if (!sf) return null;
412
+
413
+ let mapCallFound = false;
414
+ let dataArrayName: string | null = null;
415
+ let visibleCount = 3;
416
+ let scrollSetterName: string | null = null;
417
+ let labelsPerItem = 1;
418
+ let dataArrayValues: string[] | null = null;
419
+ let dataArrayObjects: Record<string, string>[] | null = null;
420
+
421
+ // First pass: find array literals to know data values
422
+ const arrayLiterals = new Map<string, string[]>();
423
+ const objectArrayLiterals = new Map<string, Record<string, string>[]>();
424
+ walkAST(sf, (node) => {
425
+ if (
426
+ ts.isVariableDeclaration(node) &&
427
+ ts.isIdentifier(node.name) &&
428
+ node.initializer &&
429
+ ts.isArrayLiteralExpression(node.initializer)
430
+ ) {
431
+ // Try string array first
432
+ const strValues: string[] = [];
433
+ let allStrings = true;
434
+ for (const el of node.initializer.elements) {
435
+ if (ts.isStringLiteral(el)) strValues.push(el.text);
436
+ else { allStrings = false; break; }
437
+ }
438
+ if (allStrings && strValues.length > 0) {
439
+ arrayLiterals.set(node.name.text, strValues);
440
+ return;
441
+ }
442
+
443
+ // Try object array
444
+ const objValues: Record<string, string>[] = [];
445
+ let allObjects = true;
446
+ for (const el of node.initializer.elements) {
447
+ if (ts.isObjectLiteralExpression(el)) {
448
+ const obj: Record<string, string> = {};
449
+ for (const prop of el.properties) {
450
+ if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name) && ts.isStringLiteral(prop.initializer)) {
451
+ obj[prop.name.text] = prop.initializer.text;
452
+ }
453
+ }
454
+ if (Object.keys(obj).length > 0) objValues.push(obj);
455
+ else { allObjects = false; break; }
456
+ } else {
457
+ allObjects = false;
458
+ break;
459
+ }
460
+ }
461
+ if (allObjects && objValues.length > 0) {
462
+ objectArrayLiterals.set(node.name.text, objValues);
463
+ }
464
+ }
465
+ });
466
+
467
+ // Second pass: find .map() and .slice()
468
+ walkAST(sf, (node) => {
469
+ // Find .map() call
470
+ if (
471
+ ts.isCallExpression(node) &&
472
+ ts.isPropertyAccessExpression(node.expression) &&
473
+ node.expression.name.text === 'map'
474
+ ) {
475
+ mapCallFound = true;
476
+ const obj = node.expression.expression;
477
+ if (ts.isIdentifier(obj) && !dataArrayName) {
478
+ // Only set if .slice() hasn't already identified the real source
479
+ dataArrayName = obj.text;
480
+ dataArrayValues = arrayLiterals.get(obj.text) ?? null;
481
+ dataArrayObjects = objectArrayLiterals.get(obj.text) ?? null;
482
+ }
483
+
484
+ // Count Text elements in callback
485
+ if (node.arguments.length > 0) {
486
+ let textCount = 0;
487
+ walkAST(node.arguments[0]!, (n) => {
488
+ if (ts.isJsxSelfClosingElement(n) && ts.isIdentifier(n.tagName) && n.tagName.text === 'Text') textCount++;
489
+ if (ts.isJsxElement(n) && ts.isIdentifier(n.openingElement.tagName) && n.openingElement.tagName.text === 'Text') textCount++;
490
+ });
491
+ if (textCount > 0) labelsPerItem = textCount;
492
+ }
493
+ }
494
+
495
+ // Find .slice(index, index + N)
496
+ if (
497
+ ts.isVariableDeclaration(node) &&
498
+ node.initializer &&
499
+ ts.isCallExpression(node.initializer) &&
500
+ ts.isPropertyAccessExpression(node.initializer.expression) &&
501
+ node.initializer.expression.name.text === 'slice'
502
+ ) {
503
+ const sliceArgs = node.initializer.arguments;
504
+ if (sliceArgs.length >= 2) {
505
+ const secondArg = sliceArgs[1]!;
506
+ if (
507
+ ts.isBinaryExpression(secondArg) &&
508
+ secondArg.operatorToken.kind === ts.SyntaxKind.PlusToken &&
509
+ ts.isNumericLiteral(secondArg.right)
510
+ ) {
511
+ visibleCount = Number(secondArg.right.text);
512
+ }
513
+ // Find scroll setter from the index variable
514
+ const firstArg = sliceArgs[0]!;
515
+ if (ts.isIdentifier(firstArg)) {
516
+ const indexVarName = firstArg.text;
517
+ walkAST(sf, (n) => {
518
+ if (
519
+ ts.isVariableDeclaration(n) &&
520
+ ts.isArrayBindingPattern(n.name) &&
521
+ n.name.elements.length >= 2
522
+ ) {
523
+ const first = n.name.elements[0]!;
524
+ if (
525
+ !ts.isOmittedExpression(first) &&
526
+ ts.isIdentifier(first.name) &&
527
+ first.name.text === indexVarName
528
+ ) {
529
+ const setter = n.name.elements[1]!;
530
+ if (!ts.isOmittedExpression(setter) && ts.isIdentifier(setter.name)) {
531
+ scrollSetterName = setter.name.text;
532
+ }
533
+ }
534
+ }
535
+ });
536
+ }
537
+ }
538
+
539
+ // Trace the source array from: visible = SOURCE.slice(...)
540
+ // This overrides .map()'s target since the slice source is the real data.
541
+ const sliceObj = node.initializer.expression.expression;
542
+ if (ts.isIdentifier(sliceObj)) {
543
+ dataArrayName = sliceObj.text;
544
+ dataArrayValues = arrayLiterals.get(sliceObj.text) ?? null;
545
+ dataArrayObjects = objectArrayLiterals.get(sliceObj.text) ?? null;
546
+ }
547
+ }
548
+ });
549
+
550
+ if (!mapCallFound || !dataArrayName) return null;
551
+
552
+ // Determine property order by matching baseline label texts to first item's properties
553
+ let propertyOrder: string[] | null = null;
554
+ if (dataArrayObjects && dataArrayObjects.length > 0 && labelsPerItem > 1) {
555
+ const firstItem = dataArrayObjects[0]!;
556
+ // We'll match during the main pipeline after rendering
557
+ propertyOrder = Object.keys(firstItem).slice(0, labelsPerItem);
558
+ }
559
+
560
+ return { dataArrayName, dataArrayValues, dataArrayObjects, propertyOrder, visibleCount, scrollSetterName, labelsPerItem };
561
+ }
562
+
563
+ // ---------------------------------------------------------------------------
564
+ // Emit context
565
+ // ---------------------------------------------------------------------------
566
+
567
+ interface EmitContext {
568
+ skins: Map<string, string>;
569
+ styles: Map<string, string>;
570
+ declarations: string[];
571
+ skinIdx: number;
572
+ styleIdx: number;
573
+ labelIdx: number;
574
+ rectIdx: number;
575
+ /** Map from label sequential index → its text */
576
+ labelTexts: Map<number, string>;
577
+ /** Map from rect sequential index → its fill color name */
578
+ rectFills: Map<number, string>;
579
+ }
580
+
581
+ function ensureSkin(ctx: EmitContext, fill: string): string {
582
+ const hex = colorToHex(fill);
583
+ const existing = ctx.skins.get(hex);
584
+ if (existing) return existing;
585
+ const name = `sk${ctx.skinIdx++}`;
586
+ ctx.skins.set(hex, name);
587
+ ctx.declarations.push(`const ${name} = new Skin({ fill: "${hex}" });`);
588
+ return name;
589
+ }
590
+
591
+ function ensureStyle(ctx: EmitContext, font: string, color: string): string {
592
+ const key = `${font}|${color}`;
593
+ const existing = ctx.styles.get(key);
594
+ if (existing) return existing;
595
+ const name = `st${ctx.styleIdx++}`;
596
+ ctx.styles.set(key, name);
597
+ const hex = colorToHex(color);
598
+ ctx.declarations.push(
599
+ `const ${name} = new Style({ font: "${fontToPiu(font)}", color: "${hex}" });`,
600
+ );
601
+ return name;
602
+ }
603
+
604
+ // ---------------------------------------------------------------------------
605
+ // Per-subtree conditional tracking (hoisted for emitNode access)
606
+ interface ConditionalChild {
607
+ stateSlot: number;
608
+ childIndex: number;
609
+ type: 'removed' | 'added';
610
+ }
611
+ const conditionalChildren: ConditionalChild[] = [];
612
+ let emitConditionals = false; // Only wrap conditionals during final emit
613
+ let conditionalDepth = 0; // Track nesting — only wrap at depth 1 (root Group's children)
614
+
615
+ // Emit piu tree
616
+ // ---------------------------------------------------------------------------
617
+
618
+ function emitNode(
619
+ node: AnyNode,
620
+ ctx: EmitContext,
621
+ indent: string,
622
+ dynamicLabels: Set<number> | null,
623
+ stateDeps: Map<number, { slotIndex: number; formatExpr: string }> | null,
624
+ skinDeps?: Map<number, { slotIndex: number; skins: string[] }> | null,
625
+ ): string | null {
626
+ if (node.type === '#text') return null;
627
+
628
+ const el = node as DOMElement;
629
+ const p = el.props;
630
+
631
+ switch (el.type) {
632
+ case 'pbl-root':
633
+ case 'pbl-group': {
634
+ if (el.type === 'pbl-group') conditionalDepth++;
635
+ const kids = el.children
636
+ .map((c, childIdx) => {
637
+ const emitted = emitNode(c, ctx, indent + ' ', dynamicLabels, stateDeps, skinDeps);
638
+ if (!emitted) return null;
639
+
640
+ // Check if this child is a per-subtree conditional
641
+ // (only applies to children of the root Group, which is child 0 of pbl-root)
642
+ // Only wrap at the root Group level (depth 1), not nested Groups
643
+ if (el.type === 'pbl-group' && emitConditionals && conditionalDepth === 1 && conditionalChildren.length > 0) {
644
+ const cond = conditionalChildren.find(
645
+ cc => cc.childIndex === childIdx && cc.type === 'removed'
646
+ );
647
+ if (cond) {
648
+ const name = `cv_s${cond.stateSlot}_${childIdx}`;
649
+ return `${indent} new Container(null, { name: "${name}", visible: true, left: 0, right: 0, top: 0, bottom: 0, contents: [\n${emitted}\n${indent} ] })`;
650
+ }
651
+ }
652
+
653
+ return emitted;
654
+ })
655
+ .filter(Boolean);
656
+ if (el.type === 'pbl-root') return kids.join(',\n');
657
+ const x = num(p, 'x');
658
+ const y = num(p, 'y');
659
+ // Groups without explicit position need full-size layout constraints
660
+ // so piu actually sizes them (otherwise they get zero size).
661
+ const layout = (x === 0 && y === 0)
662
+ ? 'left: 0, right: 0, top: 0, bottom: 0, '
663
+ : `left: ${x}, right: 0, top: ${y}, `;
664
+
665
+ // If this Group is a DIRECT parent of list slot labels (not a grandparent),
666
+ // give it a name so the Behavior can find it.
667
+ let groupNameProp = '';
668
+ if (listInfo && listInfo.labelsPerItem > 1 && listSlotLabels.size > 0) {
669
+ // Check if a DIRECT child (not nested) is a named list label
670
+ const directListLabels = kids.filter(k => k?.includes('name: "ls') && !k?.includes('contents:'));
671
+ if (directListLabels.length > 0) {
672
+ const m = directListLabels[0]!.match(/name: "ls(\d+)_/);
673
+ if (m) {
674
+ groupNameProp = `name: "lg${m[1]}", `;
675
+ }
676
+ }
677
+ }
678
+
679
+ if (el.type === 'pbl-group') conditionalDepth--;
680
+ return `${indent}new Container(null, { ${groupNameProp}${layout}contents: [\n${kids.join(',\n')}\n${indent}] })`;
681
+ }
682
+
683
+ case 'pbl-rect': {
684
+ const fill = str(p, 'fill');
685
+ if (!fill) return null;
686
+ const w = num(p, 'w') || num(p, 'width');
687
+ const h = num(p, 'h') || num(p, 'height');
688
+ const x = num(p, 'x');
689
+ const y = num(p, 'y');
690
+ const skinVar = ensureSkin(ctx, fill);
691
+
692
+ // Track rect fill for skin reactivity detection
693
+ const rectIdx = ctx.rectIdx++;
694
+ ctx.rectFills.set(rectIdx, fill);
695
+
696
+ // If this rect has a dynamic skin, give it a name
697
+ const isSkinDynamic = skinDeps?.has(rectIdx) ?? false;
698
+ const nameProp = isSkinDynamic ? `, name: "sr${rectIdx}"` : '';
699
+
700
+ // Use constraint-based layout when dimensions match screen size
701
+ // (so the output adapts to any screen at runtime)
702
+ const sizeProps = buildSizeProps(x, y, w, h);
703
+
704
+ const kids = el.children
705
+ .map((c) => emitNode(c, ctx, indent + ' ', dynamicLabels, stateDeps, skinDeps))
706
+ .filter(Boolean);
707
+ if (kids.length > 0) {
708
+ return `${indent}new Container(null, { ${sizeProps}, skin: ${skinVar}${nameProp}, contents: [\n${kids.join(',\n')}\n${indent}] })`;
709
+ }
710
+ return `${indent}new Content(null, { ${sizeProps}, skin: ${skinVar}${nameProp} })`;
711
+ }
712
+
713
+ case 'pbl-text': {
714
+ const text = getTextContent(el);
715
+ if (!text) return null;
716
+ const font = str(p, 'font');
717
+ const color = str(p, 'color') ?? 'white';
718
+ const align = str(p, 'align') ?? 'left';
719
+ const w = num(p, 'w') || num(p, 'width');
720
+ const x = num(p, 'x');
721
+ const y = num(p, 'y');
722
+
723
+ const idx = ctx.labelIdx++;
724
+ ctx.labelTexts.set(idx, text);
725
+
726
+ const styleVar = ensureStyle(ctx, font ?? 'gothic18', color);
727
+ const posProps: string[] = [`top: ${y}`];
728
+ posProps.push(`left: ${x}`);
729
+ if (w > 0) posProps.push(`width: ${w}`);
730
+ const horizProp = align !== 'left' ? `, horizontal: "${align}"` : '';
731
+ const escaped = text.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
732
+
733
+ // Determine label naming: state-dep → "sl{idx}", time-dep → "tl{idx}"
734
+ const isTimeDynamic = dynamicLabels?.has(idx) ?? false;
735
+ const isStateDynamic = stateDeps?.has(idx) ?? false;
736
+ let nameProp = '';
737
+ if (listSlotLabels.has(idx)) {
738
+ const flatIdx = [...listSlotLabels].indexOf(idx);
739
+ const lpi = listInfo?.labelsPerItem ?? 1;
740
+ const itemIdx = Math.floor(flatIdx / lpi);
741
+ const labelIdx = flatIdx % lpi;
742
+ const lsName = lpi > 1 ? `ls${itemIdx}_${labelIdx}` : `ls${flatIdx}`;
743
+ nameProp = `, name: "${lsName}"`;
744
+ } else if (isStateDynamic) {
745
+ nameProp = `, name: "sl${idx}"`;
746
+ } else if (isTimeDynamic) {
747
+ nameProp = `, name: "tl${idx}"`;
748
+ }
749
+
750
+ return `${indent}new Label(null, { ${posProps.join(', ')}, style: ${styleVar}${horizProp}${nameProp}, string: "${escaped}" })`;
751
+ }
752
+
753
+ case 'pbl-line': {
754
+ const x1 = num(p, 'x');
755
+ const y1 = num(p, 'y');
756
+ const x2 = num(p, 'x2');
757
+ const y2 = num(p, 'y2');
758
+ const color = str(p, 'color') ?? str(p, 'stroke') ?? 'white';
759
+ const skinVar = ensureSkin(ctx, color);
760
+ const sw = num(p, 'strokeWidth') || 1;
761
+ if (y1 === y2) {
762
+ const left = Math.min(x1, x2);
763
+ const w = Math.abs(x2 - x1) || 1;
764
+ return `${indent}new Content(null, { left: ${left}, top: ${y1}, width: ${w}, height: ${sw}, skin: ${skinVar} })`;
765
+ } else if (x1 === x2) {
766
+ const top = Math.min(y1, y2);
767
+ const h = Math.abs(y2 - y1) || 1;
768
+ return `${indent}new Content(null, { left: ${x1}, top: ${top}, width: ${sw}, height: ${h}, skin: ${skinVar} })`;
769
+ }
770
+ return null;
771
+ }
772
+
773
+ case 'pbl-circle': {
774
+ const r = num(p, 'r') || num(p, 'radius');
775
+ const cx = num(p, 'x');
776
+ const cy = num(p, 'y');
777
+ const fill = str(p, 'fill');
778
+ if (!fill || r <= 0) return null;
779
+ const skinVar = ensureSkin(ctx, fill);
780
+ // piu RoundRect with radius = r draws a circle when width = height = 2*r
781
+ const size = r * 2;
782
+ return `${indent}new RoundRect(null, { left: ${cx}, top: ${cy}, width: ${size}, height: ${size}, radius: ${r}, skin: ${skinVar} })`;
783
+ }
784
+
785
+ default:
786
+ return null;
787
+ }
788
+ }
789
+
790
+ function buildPos(p: Record<string, unknown>): string {
791
+ const parts: string[] = [];
792
+ const x = num(p, 'x');
793
+ const y = num(p, 'y');
794
+ if (x) parts.push(`left: ${x}`);
795
+ if (y) parts.push(`top: ${y}`);
796
+ return parts.length > 0 ? parts.join(', ') + ', ' : '';
797
+ }
798
+
799
+ /**
800
+ * Build size props using constraint layout when dimensions match screen.
801
+ * full-width (w === SCREEN.width) → left: 0, right: 0
802
+ * full-height (h === SCREEN.height) → top: 0, bottom: 0
803
+ * Otherwise: absolute left/top/width/height
804
+ */
805
+ function buildSizeProps(x: number, y: number, w: number, h: number): string {
806
+ const parts: string[] = [];
807
+
808
+ if (w >= SCREEN.width && x === 0) {
809
+ parts.push('left: 0', 'right: 0');
810
+ } else {
811
+ if (x !== 0) parts.push(`left: ${x}`);
812
+ else parts.push('left: 0');
813
+ parts.push(`width: ${w}`);
814
+ }
815
+
816
+ if (h >= SCREEN.height && y === 0) {
817
+ parts.push('top: 0', 'bottom: 0');
818
+ } else {
819
+ parts.push(`top: ${y}`);
820
+ parts.push(`height: ${h}`);
821
+ }
822
+
823
+ return parts.join(', ');
824
+ }
825
+
826
+ // ---------------------------------------------------------------------------
827
+ // Time format inference
828
+ // ---------------------------------------------------------------------------
829
+
830
+ type TimeFormat = 'HHMM' | 'SS' | 'DATE';
831
+
832
+ function inferTimeFormat(textAtT1: string, t1: Date): TimeFormat | null {
833
+ const hh = pad2(t1.getHours());
834
+ const mm = pad2(t1.getMinutes());
835
+ const ss = pad2(t1.getSeconds());
836
+ const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
837
+ const months = [
838
+ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
839
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
840
+ ];
841
+
842
+ if (textAtT1 === `${hh}:${mm}`) return 'HHMM';
843
+ if (textAtT1 === ss) return 'SS';
844
+ if (
845
+ textAtT1.includes(days[t1.getDay()]!) &&
846
+ textAtT1.includes(months[t1.getMonth()]!)
847
+ ) {
848
+ return 'DATE';
849
+ }
850
+ return null;
851
+ }
852
+
853
+ function emitTimeExpr(fmt: TimeFormat): string {
854
+ switch (fmt) {
855
+ case 'HHMM':
856
+ return 'pad(d.getHours()) + ":" + pad(d.getMinutes())';
857
+ case 'SS':
858
+ return 'pad(d.getSeconds())';
859
+ case 'DATE':
860
+ return 'days[d.getDay()] + " " + months[d.getMonth()] + " " + d.getDate()';
861
+ }
862
+ }
863
+
864
+ // ---------------------------------------------------------------------------
865
+ // Main
866
+ // ---------------------------------------------------------------------------
867
+
868
+ const origLog = console.log;
869
+ const silence = () => {
870
+ console.log = () => {};
871
+ };
872
+ const restore = () => {
873
+ console.log = origLog;
874
+ };
875
+
876
+ // Hoisted declarations (populated during detection phases below)
877
+ const listSlotLabels = new Set<number>();
878
+ let listScrollSlotIndex = -1;
879
+
880
+ // Install interceptors BEFORE any rendering
881
+ installUseStateInterceptor();
882
+ extractButtonBindingsFromSource(exampleName);
883
+
884
+ // Create BOTH test dates with the REAL Date before any mocking.
885
+ const OrigDate = globalThis.Date;
886
+ const T1 = new OrigDate(2026, 0, 15, 9, 7, 3);
887
+ const T2 = new OrigDate(2026, 5, 20, 14, 52, 48);
888
+ (globalThis as unknown as { Date: unknown }).Date = class MockDate extends OrigDate {
889
+ constructor() {
890
+ super();
891
+ return T1;
892
+ }
893
+ static now() {
894
+ return T1.getTime();
895
+ }
896
+ };
897
+
898
+ // --- Render at T1 (baseline) ---
899
+ resetStateTracking();
900
+ silence();
901
+ const app1 = exampleMain();
902
+ restore();
903
+ await settle(); // Let async effects (useEffect + setTimeout) fire
904
+
905
+ if (!app1) {
906
+ process.stderr.write('Failed to render at T1\n');
907
+ process.exit(1);
908
+ }
909
+
910
+ // Collect label texts from T1 render
911
+ const ctx1: EmitContext = {
912
+ skins: new Map(),
913
+ styles: new Map(),
914
+ declarations: [],
915
+ skinIdx: 0,
916
+ styleIdx: 0,
917
+ labelIdx: 0,
918
+ labelTexts: new Map(),
919
+ rectIdx: 0,
920
+ rectFills: new Map(),
921
+ };
922
+ emitNode(app1._root, ctx1, '', null, null);
923
+ const t1Texts = new Map(ctx1.labelTexts);
924
+ // Don't unmount app1 yet — we need its _root for per-subtree tree diff
925
+
926
+ process.stderr.write(`State slots discovered: ${stateSlots.length}\n`);
927
+ process.stderr.write(`Button bindings discovered: ${buttonBindings.length}\n`);
928
+ for (const b of buttonBindings) {
929
+ process.stderr.write(` button="${b.button}" handler=${b.handlerSource}\n`);
930
+ }
931
+
932
+ // ---------------------------------------------------------------------------
933
+ // Perturbation pipeline — discover state-dependent labels
934
+ // ---------------------------------------------------------------------------
935
+
936
+ const stateDeps = new Map<number, { slotIndex: number; formatExpr: string }>();
937
+ const skinDeps = new Map<number, { slotIndex: number; skins: [string, string] }>();
938
+
939
+ // Branch info: when tree structure changes between baseline and perturbed,
940
+ // we record both renders' full label sets so we can emit both as branches
941
+ // wrapped in named Containers with .visible toggling.
942
+ interface BranchInfo {
943
+ stateSlot: number;
944
+ perturbedValue: unknown;
945
+ baselineLabels: Map<number, string>;
946
+ perturbedLabels: Map<number, string>;
947
+ }
948
+ const branchInfos: BranchInfo[] = [];
949
+
950
+ // Per-subtree conditionals use the hoisted `conditionalChildren` array
951
+ // (declared before emitNode so it's accessible during rendering).
952
+
953
+ /**
954
+ * Compare children of two pebble-dom Group nodes to find which children
955
+ * were added or removed when a state was perturbed.
956
+ *
957
+ * Uses a simple fingerprint match: each child is identified by its type
958
+ * + first text content. Children present in baseline but not perturbed
959
+ * are 'removed' (conditional on the truthy state).
960
+ */
961
+ function diffTreeChildren(
962
+ baselineRoot: DOMElement,
963
+ perturbedRoot: DOMElement,
964
+ stateSlot: number,
965
+ ): ConditionalChild[] {
966
+ const result: ConditionalChild[] = [];
967
+
968
+ // Get the actual Group container (root → first child which is the Group)
969
+ const baseGroup = baselineRoot.children[0];
970
+ const pertGroup = perturbedRoot.children[0];
971
+ if (!baseGroup || baseGroup.type === '#text' || !pertGroup || pertGroup.type === '#text') {
972
+ return result;
973
+ }
974
+
975
+ const baseChildren = (baseGroup as DOMElement).children.filter(c => c.type !== '#text');
976
+ const pertChildren = (pertGroup as DOMElement).children.filter(c => c.type !== '#text');
977
+
978
+ // Fingerprint each child: type + first text descendant
979
+ function fingerprint(node: AnyNode): string {
980
+ if (node.type === '#text') return `#text:${node.value}`;
981
+ const el = node as DOMElement;
982
+ const firstText = el.children.find(c => c.type === '#text' || (c.type !== '#text' && getTextContent(c)));
983
+ const text = firstText ? getTextContent(firstText) : '';
984
+ return `${el.type}:${text.slice(0, 30)}`;
985
+ }
986
+
987
+ const baseFPs = baseChildren.map(fingerprint);
988
+ const pertFPs = pertChildren.map(fingerprint);
989
+
990
+ // Find children in baseline but not in perturbed (removed when state perturbed)
991
+ for (let i = 0; i < baseFPs.length; i++) {
992
+ if (!pertFPs.includes(baseFPs[i]!)) {
993
+ result.push({ stateSlot, childIndex: i, type: 'removed' });
994
+ }
995
+ }
996
+
997
+ // Find children in perturbed but not in baseline (added when state perturbed)
998
+ for (let i = 0; i < pertFPs.length; i++) {
999
+ if (!baseFPs.includes(pertFPs[i]!)) {
1000
+ result.push({ stateSlot, childIndex: i, type: 'added' });
1001
+ }
1002
+ }
1003
+
1004
+ return result;
1005
+ }
1006
+
1007
+ /**
1008
+ * For string enum states, return the set of possible values from button
1009
+ * handler sources. Values are collected during analyzeButtonHandler into
1010
+ * the module-level stringEnumValues map; this function triggers analysis
1011
+ * if not yet done and returns the map.
1012
+ */
1013
+ function extractStringValuesFromHandlers(): Map<number, Set<string>> {
1014
+ // Ensure handler analysis has run so stringEnumValues is populated.
1015
+ // Analyze all bindings if not yet analyzed.
1016
+ for (const binding of buttonBindings) {
1017
+ analyzeButtonHandler(binding.handlerSource);
1018
+ }
1019
+ return stringEnumValues;
1020
+ }
1021
+
1022
+ function computePerturbedValues(slot: StateSlot): unknown[] {
1023
+ const v = slot.initialValue;
1024
+ if (typeof v === 'number') return [v + 42];
1025
+ if (typeof v === 'boolean') return [!v];
1026
+ if (typeof v === 'string') {
1027
+ // Use extracted enum values (excluding the initial value itself)
1028
+ const enumValues = extractStringValuesFromHandlers().get(slot.index);
1029
+ if (enumValues && enumValues.size > 0) {
1030
+ return [...enumValues].filter((ev) => ev !== v);
1031
+ }
1032
+ return [v + '__PROBE__']; // fallback
1033
+ }
1034
+ return []; // unsupported type — skip
1035
+ }
1036
+
1037
+ for (const slot of stateSlots) {
1038
+ const perturbedValues = computePerturbedValues(slot);
1039
+ if (perturbedValues.length === 0) continue;
1040
+
1041
+ for (const perturbedValue of perturbedValues) {
1042
+
1043
+ forcedStateValues.set(slot.index, perturbedValue);
1044
+
1045
+ // Re-render with forced state
1046
+ resetStateTracking();
1047
+ silence();
1048
+ const appP = exampleMain();
1049
+ restore();
1050
+
1051
+ if (appP) {
1052
+ const ctxP: EmitContext = {
1053
+ skins: new Map(),
1054
+ styles: new Map(),
1055
+ declarations: [],
1056
+ skinIdx: 0,
1057
+ styleIdx: 0,
1058
+ labelIdx: 0,
1059
+ labelTexts: new Map(),
1060
+ rectIdx: 0,
1061
+ rectFills: new Map(),
1062
+ };
1063
+ emitNode(appP._root, ctxP, '', null, null);
1064
+
1065
+ // Check if tree shape changed (different label count or structure)
1066
+ const baseKeys = [...t1Texts.keys()].sort((a, b) => a - b);
1067
+ const pertKeys = [...ctxP.labelTexts.keys()].sort((a, b) => a - b);
1068
+ const sameShape = baseKeys.length === pertKeys.length &&
1069
+ baseKeys.every((k, i) => k === pertKeys[i]);
1070
+
1071
+ if (sameShape) {
1072
+ // Same structure — check for text changes (existing v3 path)
1073
+ for (const [idx, baseText] of t1Texts) {
1074
+ const pertText = ctxP.labelTexts.get(idx);
1075
+ if (pertText !== undefined && pertText !== baseText) {
1076
+ let formatExpr: string;
1077
+ if (String(perturbedValue) === pertText) {
1078
+ formatExpr = `"" + this.s${slot.index}`;
1079
+ } else if (typeof slot.initialValue === 'boolean') {
1080
+ // Boolean state produced different text — emit a ternary
1081
+ formatExpr = `this.s${slot.index} ? "${pertText.replace(/"/g, '\\"')}" : "${baseText.replace(/"/g, '\\"')}"`;
1082
+ } else {
1083
+ formatExpr = `"" + this.s${slot.index}`;
1084
+ }
1085
+ stateDeps.set(idx, { slotIndex: slot.index, formatExpr });
1086
+ process.stderr.write(
1087
+ ` Label ${idx} depends on state slot ${slot.index} (base="${baseText}", perturbed="${pertText}")\n`,
1088
+ );
1089
+ }
1090
+ }
1091
+
1092
+ // Also check for rect fill changes
1093
+ const baseRectFills = ctx1.rectFills;
1094
+ for (const [rIdx, baseFill] of baseRectFills) {
1095
+ const pertFill = ctxP.rectFills.get(rIdx);
1096
+ if (pertFill !== undefined && pertFill !== baseFill) {
1097
+ // Ensure both skins exist in declarations
1098
+ ensureSkin(ctx1, baseFill);
1099
+ ensureSkin(ctx1, pertFill);
1100
+ skinDeps.set(rIdx, {
1101
+ slotIndex: slot.index,
1102
+ skins: [baseFill, pertFill],
1103
+ });
1104
+ process.stderr.write(
1105
+ ` Rect ${rIdx} skin depends on state slot ${slot.index} (base="${baseFill}", perturbed="${pertFill}")\n`,
1106
+ );
1107
+ }
1108
+ }
1109
+ } else {
1110
+ // Different structure — this is a conditional branch!
1111
+ // EXCEPT: skip if this slot is the list scroll index — list items
1112
+ // change count when scrolled past the end, which is handled by
1113
+ // the list slot pool, not structural branching.
1114
+ if (listInfo && listInfo.scrollSetterName && setterSlotMap.get(listInfo.scrollSetterName) === slot.index) {
1115
+ process.stderr.write(
1116
+ ` State slot ${slot.index} causes structural change (skipped — list scroll): ${baseKeys.length} → ${pertKeys.length} labels\n`,
1117
+ );
1118
+ // Still check labels that exist in BOTH renders for text changes
1119
+ // (e.g., a "1/5" counter label that updates on scroll).
1120
+ for (const [idx, baseText] of t1Texts) {
1121
+ const pertText = ctxP.labelTexts.get(idx);
1122
+ if (pertText !== undefined && pertText !== baseText) {
1123
+ // Skip labels that will be handled as list slots
1124
+ if (listSlotLabels.has(idx)) continue;
1125
+ // Infer format by substitution: find where the perturbed value appears in the text
1126
+ let formatExpr: string;
1127
+ const pv = String(Number(perturbedValue) + 1); // try value+1 (common: sel+1)
1128
+ if (pertText.includes(pv)) {
1129
+ // Pattern like "{sel+1}/{total}" → "(this.s{N} + 1) + suffix"
1130
+ const before = pertText.substring(0, pertText.indexOf(pv));
1131
+ const after = pertText.substring(pertText.indexOf(pv) + pv.length);
1132
+ formatExpr = `${before ? `"${before}" + ` : ''}(this.s${slot.index} + 1)${after ? ` + "${after}"` : ''}`;
1133
+ } else if (pertText.includes(String(perturbedValue))) {
1134
+ const pStr = String(perturbedValue);
1135
+ const before = pertText.substring(0, pertText.indexOf(pStr));
1136
+ const after = pertText.substring(pertText.indexOf(pStr) + pStr.length);
1137
+ formatExpr = `${before ? `"${before}" + ` : ''}this.s${slot.index}${after ? ` + "${after}"` : ''}`;
1138
+ } else {
1139
+ formatExpr = `"" + this.s${slot.index}`;
1140
+ }
1141
+ stateDeps.set(idx, { slotIndex: slot.index, formatExpr });
1142
+ process.stderr.write(
1143
+ ` Label ${idx} depends on state slot ${slot.index} (base="${baseText}", perturbed="${pertText}")\n`,
1144
+ );
1145
+ }
1146
+ }
1147
+ } else if (typeof slot.initialValue === 'boolean') {
1148
+ // Boolean state with structural change → per-subtree conditional
1149
+ // Diff the tree to find which specific children appeared/disappeared
1150
+ const diffs = diffTreeChildren(app1._root, appP._root, slot.index);
1151
+ if (diffs.length > 0) {
1152
+ conditionalChildren.push(...diffs);
1153
+ process.stderr.write(
1154
+ ` State slot ${slot.index}: ${diffs.length} conditional child(ren) detected\n`,
1155
+ );
1156
+ } else {
1157
+ // Fallback to whole-tree branch if diff couldn't identify subtrees
1158
+ process.stderr.write(
1159
+ ` State slot ${slot.index} causes structural change: ${baseKeys.length} labels → ${pertKeys.length} labels\n`,
1160
+ );
1161
+ branchInfos.push({
1162
+ stateSlot: slot.index,
1163
+ perturbedValue,
1164
+ baselineLabels: new Map(t1Texts),
1165
+ perturbedLabels: new Map(ctxP.labelTexts),
1166
+ });
1167
+ }
1168
+ } else {
1169
+ // Non-boolean (string enum etc.) → whole-tree branching as before
1170
+ process.stderr.write(
1171
+ ` State slot ${slot.index} causes structural change: ${baseKeys.length} labels → ${pertKeys.length} labels\n`,
1172
+ );
1173
+ branchInfos.push({
1174
+ stateSlot: slot.index,
1175
+ perturbedValue,
1176
+ baselineLabels: new Map(t1Texts),
1177
+ perturbedLabels: new Map(ctxP.labelTexts),
1178
+ });
1179
+ }
1180
+ }
1181
+
1182
+ appP.unmount();
1183
+ }
1184
+
1185
+ forcedStateValues.delete(slot.index);
1186
+ } // end for perturbedValues
1187
+ }
1188
+
1189
+ // Now safe to unmount baseline
1190
+ app1.unmount();
1191
+
1192
+ process.stderr.write(`State-dependent labels: ${stateDeps.size}\n`);
1193
+ process.stderr.write(`Structural branches: ${branchInfos.length}\n`);
1194
+ if (conditionalChildren.length > 0) {
1195
+ process.stderr.write(`Conditional subtrees: ${conditionalChildren.length}\n`);
1196
+ }
1197
+
1198
+ // ---------------------------------------------------------------------------
1199
+ // List slot detection: identify which labels are .map() items
1200
+ // ---------------------------------------------------------------------------
1201
+
1202
+ // (listSlotLabels and listScrollSlotIndex hoisted before first emitNode call)
1203
+ // Populated in the list slot detection section below.
1204
+
1205
+ if (listInfo) {
1206
+ if (listInfo.scrollSetterName && setterSlotMap.has(listInfo.scrollSetterName)) {
1207
+ listScrollSlotIndex = setterSlotMap.get(listInfo.scrollSetterName)!;
1208
+ }
1209
+
1210
+ // Perturb scroll index to 1 and diff — labels that change are list slots
1211
+ if (listScrollSlotIndex >= 0) {
1212
+ forcedStateValues.set(listScrollSlotIndex, 1);
1213
+ resetStateTracking();
1214
+ silence();
1215
+ const appScroll = exampleMain();
1216
+ restore();
1217
+
1218
+ if (appScroll) {
1219
+ const ctxScroll: EmitContext = {
1220
+ skins: new Map(), styles: new Map(), declarations: [],
1221
+ skinIdx: 0, styleIdx: 0, labelIdx: 0, labelTexts: new Map(),
1222
+ rectIdx: 0, rectFills: new Map(),
1223
+ };
1224
+ emitNode(appScroll._root, ctxScroll, '', null, null);
1225
+
1226
+ for (const [idx, baseText] of t1Texts) {
1227
+ const scrollText = ctxScroll.labelTexts.get(idx);
1228
+ if (scrollText !== undefined && scrollText !== baseText) {
1229
+ listSlotLabels.add(idx);
1230
+ }
1231
+ }
1232
+ appScroll.unmount();
1233
+ }
1234
+ forcedStateValues.delete(listScrollSlotIndex);
1235
+ }
1236
+
1237
+ // Trim to expected count: visibleCount × labelsPerItem.
1238
+ // Extra labels (e.g., a "1/5" counter) that change on scroll aren't list slots.
1239
+ const expectedSlots = listInfo.visibleCount * listInfo.labelsPerItem;
1240
+ if (listSlotLabels.size > expectedSlots) {
1241
+ const all = [...listSlotLabels];
1242
+ // Keep the LAST expectedSlots (list items appear after header labels)
1243
+ const keep = new Set(all.slice(all.length - expectedSlots));
1244
+ listSlotLabels.clear();
1245
+ for (const idx of keep) listSlotLabels.add(idx);
1246
+ }
1247
+
1248
+ if (listSlotLabels.size > 0) {
1249
+ process.stderr.write(`List slot labels: [${[...listSlotLabels].join(', ')}]\n`);
1250
+ }
1251
+ }
1252
+
1253
+ // --- Render at T2 (for time diff) ---
1254
+ (globalThis as unknown as { Date: unknown }).Date = class MockDate2 extends OrigDate {
1255
+ constructor() {
1256
+ super();
1257
+ return T2;
1258
+ }
1259
+ static now() {
1260
+ return T2.getTime();
1261
+ }
1262
+ };
1263
+
1264
+ process.stderr.write('T2 Date.now before render: ' + Date.now() + ' vs expected ' + T2.getTime() + '\n');
1265
+ process.stderr.write('T2 new Date(): ' + new Date() + '\n');
1266
+ resetStateTracking();
1267
+ silence();
1268
+ const app2 = exampleMain();
1269
+ restore();
1270
+
1271
+ if (!app2) {
1272
+ process.stderr.write('Failed to render at T2\n');
1273
+ process.exit(1);
1274
+ }
1275
+
1276
+ const ctx2: EmitContext = {
1277
+ skins: new Map(),
1278
+ styles: new Map(),
1279
+ declarations: [],
1280
+ skinIdx: 0,
1281
+ styleIdx: 0,
1282
+ labelIdx: 0,
1283
+ labelTexts: new Map(),
1284
+ rectIdx: 0,
1285
+ rectFills: new Map(),
1286
+ };
1287
+ emitNode(app2._root, ctx2, '', null, null);
1288
+ const t2Texts = new Map(ctx2.labelTexts);
1289
+ app2.unmount();
1290
+
1291
+ // Restore real Date
1292
+ (globalThis as unknown as { Date: typeof Date }).Date = OrigDate;
1293
+
1294
+ // --- Diff texts to find time-dynamic labels ---
1295
+ const dynamicLabels = new Set<number>();
1296
+ const labelFormats = new Map<number, TimeFormat>();
1297
+
1298
+ for (const [idx, text1] of t1Texts) {
1299
+ // Skip labels that are state-dependent (already handled)
1300
+ if (stateDeps.has(idx)) continue;
1301
+ const text2 = t2Texts.get(idx);
1302
+ if (text2 !== undefined && text1 !== text2) {
1303
+ const fmt = inferTimeFormat(text1, T1);
1304
+ if (fmt) {
1305
+ dynamicLabels.add(idx);
1306
+ labelFormats.set(idx, fmt);
1307
+ }
1308
+ }
1309
+ }
1310
+
1311
+ process.stderr.write('T1 texts: ' + JSON.stringify([...t1Texts]) + '\n');
1312
+ process.stderr.write('T2 texts: ' + JSON.stringify([...t2Texts]) + '\n');
1313
+ process.stderr.write(
1314
+ `Found ${dynamicLabels.size} time-dependent label(s): ${[...labelFormats.entries()]
1315
+ .map(([idx, fmt]) => `tl${idx}=${fmt}`)
1316
+ .join(', ')}\n`,
1317
+ );
1318
+
1319
+ // --- Final render at T1 for the emitted static snapshot ---
1320
+ (globalThis as unknown as { Date: unknown }).Date = class MockDate3 extends OrigDate {
1321
+ constructor() {
1322
+ super();
1323
+ return T1;
1324
+ }
1325
+ static now() {
1326
+ return T1.getTime();
1327
+ }
1328
+ };
1329
+
1330
+ // Clear forced values for final render
1331
+ forcedStateValues.clear();
1332
+ resetStateTracking();
1333
+ silence();
1334
+ const appFinal = exampleMain();
1335
+ restore();
1336
+ await settle(); // Let async effects fire for final snapshot
1337
+ (globalThis as unknown as { Date: typeof Date }).Date = OrigDate;
1338
+
1339
+ if (!appFinal) {
1340
+ process.stderr.write('Failed to render final snapshot\n');
1341
+ process.exit(1);
1342
+ }
1343
+
1344
+ const ctx: EmitContext = {
1345
+ skins: new Map(),
1346
+ styles: new Map(),
1347
+ declarations: [],
1348
+ skinIdx: 0,
1349
+ styleIdx: 0,
1350
+ labelIdx: 0,
1351
+ labelTexts: new Map(),
1352
+ rectIdx: 0,
1353
+ rectFills: new Map(),
1354
+ };
1355
+
1356
+ let contents: string | null;
1357
+
1358
+ interface BranchOutput {
1359
+ value: unknown;
1360
+ tree: string;
1361
+ isBaseline: boolean;
1362
+ }
1363
+ const branchesBySlot = new Map<number, BranchOutput[]>();
1364
+
1365
+ if (branchInfos.length > 0) {
1366
+
1367
+ // Baseline tree (for each slot that has branches)
1368
+ emitConditionals = true;
1369
+ const baselineTree = emitNode(appFinal._root, ctx, ' ', dynamicLabels, stateDeps, skinDeps);
1370
+
1371
+ const affectedSlots = new Set(branchInfos.map((b) => b.stateSlot));
1372
+ for (const si of affectedSlots) {
1373
+ const slot = stateSlots[si];
1374
+ branchesBySlot.set(si, [
1375
+ { value: slot?.initialValue, tree: baselineTree ?? ' /* empty */', isBaseline: true },
1376
+ ]);
1377
+ }
1378
+
1379
+ // Perturbed trees
1380
+ for (const branch of branchInfos) {
1381
+ forcedStateValues.set(branch.stateSlot, branch.perturbedValue);
1382
+ resetStateTracking();
1383
+ (globalThis as unknown as { Date: unknown }).Date = class extends OrigDate {
1384
+ constructor() { super(); return T1; }
1385
+ static now() { return T1.getTime(); }
1386
+ };
1387
+ silence();
1388
+ const appBranch = exampleMain();
1389
+ restore();
1390
+ (globalThis as unknown as { Date: typeof Date }).Date = OrigDate;
1391
+ forcedStateValues.clear();
1392
+
1393
+ if (appBranch) {
1394
+ const tree = emitNode(appBranch._root, ctx, ' ', dynamicLabels, stateDeps, skinDeps);
1395
+ appBranch.unmount();
1396
+ branchesBySlot.get(branch.stateSlot)!.push({
1397
+ value: branch.perturbedValue,
1398
+ tree: tree ?? ' /* empty */',
1399
+ isBaseline: false,
1400
+ });
1401
+ }
1402
+ }
1403
+
1404
+ // Build the contents with named Containers per branch
1405
+ const branchLines: string[] = [];
1406
+ for (const [si, branches] of branchesBySlot) {
1407
+ for (let bi = 0; bi < branches.length; bi++) {
1408
+ const branch = branches[bi]!;
1409
+ const name = `br_s${si}_v${bi}`;
1410
+ // For message-driven apps, start with loading visible (non-baseline)
1411
+ // since data arrives at runtime. Otherwise, baseline is visible.
1412
+ const visible = messageInfo ? !branch.isBaseline : branch.isBaseline;
1413
+ branchLines.push(
1414
+ ` new Container(null, { name: "${name}", visible: ${visible}, left: 0, right: 0, top: 0, bottom: 0, contents: [\n${branch.tree}\n ] })`,
1415
+ );
1416
+ }
1417
+ }
1418
+ contents = branchLines.join(',\n');
1419
+ } else {
1420
+ // No structural branches — emit the single tree as before
1421
+ emitConditionals = true;
1422
+ contents = emitNode(appFinal._root, ctx, ' ', dynamicLabels, stateDeps, skinDeps);
1423
+ }
1424
+ appFinal.unmount();
1425
+
1426
+ // --- Analyze button handlers ---
1427
+ const buttonActions: { button: string; action: HandlerAction }[] = [];
1428
+ for (const binding of buttonBindings) {
1429
+ const action = analyzeButtonHandler(binding.handlerSource);
1430
+ if (action) {
1431
+ buttonActions.push({ button: binding.button, action });
1432
+ process.stderr.write(
1433
+ ` Button "${binding.button}": ${action.type} s${action.slotIndex} by ${action.value}\n`,
1434
+ );
1435
+ } else if (listInfo && listSlotLabels.size > 0 && listScrollSlotIndex >= 0) {
1436
+ // Unrecognized handler but we have a list — emit scroll fallback for up/down
1437
+ if (binding.button === 'up') {
1438
+ buttonActions.push({ button: 'up', action: { type: 'decrement', slotIndex: listScrollSlotIndex, value: 1 } });
1439
+ process.stderr.write(` Button "up": list scroll up (fallback)\n`);
1440
+ } else if (binding.button === 'down') {
1441
+ buttonActions.push({ button: 'down', action: { type: 'increment', slotIndex: listScrollSlotIndex, value: 1 } });
1442
+ process.stderr.write(` Button "down": list scroll down (fallback)\n`);
1443
+ }
1444
+ }
1445
+ }
1446
+
1447
+ // --- Emit piu output ---
1448
+ const hasTimeDeps = dynamicLabels.size > 0;
1449
+ const hasStateDeps = stateDeps.size > 0;
1450
+ const hasButtons = buttonActions.length > 0;
1451
+ const hasBranches = branchInfos.length > 0;
1452
+ const hasConditionals = conditionalChildren.length > 0;
1453
+ const hasSkinDeps = skinDeps.size > 0;
1454
+ const hasList = listInfo !== null && listSlotLabels.size > 0;
1455
+ const hasBehavior = hasTimeDeps || hasStateDeps || hasButtons || hasBranches || hasSkinDeps || hasList || hasConditionals;
1456
+
1457
+ const lines: string[] = [
1458
+ '// Auto-generated by react-pebble compile-to-piu (v3 with state reactivity)',
1459
+ `// Source: examples/${exampleName}.tsx rendered in Node mock mode.`,
1460
+ '//',
1461
+ '// Regenerate: npx tsx scripts/compile-to-piu.ts > pebble-spike/src/embeddedjs/main.js',
1462
+ '',
1463
+ 'import {} from "piu/MC";',
1464
+ hasButtons ? 'import PebbleButton from "pebble/button";' : '',
1465
+ messageInfo ? 'import Message from "pebble/message";' : '',
1466
+ '',
1467
+ ];
1468
+
1469
+ // Pre-register all skin deps so declarations are complete before output
1470
+ for (const [, dep] of skinDeps) {
1471
+ ensureSkin(ctx, dep.skins[0]!);
1472
+ ensureSkin(ctx, dep.skins[1]!);
1473
+ }
1474
+
1475
+ lines.push(
1476
+ ...ctx.declarations,
1477
+ '',
1478
+ `const bgSkin = new Skin({ fill: "${colorToHex('black')}" });`,
1479
+ '',
1480
+ );
1481
+
1482
+ if (hasTimeDeps) {
1483
+ lines.push('function pad(n) { return n < 10 ? "0" + n : "" + n; }');
1484
+ lines.push(
1485
+ 'const days = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];',
1486
+ 'const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];',
1487
+ );
1488
+ lines.push('');
1489
+ }
1490
+
1491
+ if (messageInfo) {
1492
+ // Runtime data: always declare _data for Message population
1493
+ lines.push('let _data = [];');
1494
+ lines.push('');
1495
+ }
1496
+ if (hasList && !messageInfo) {
1497
+ if (listInfo!.dataArrayObjects) {
1498
+ lines.push(`const _data = ${JSON.stringify(listInfo!.dataArrayObjects)};`);
1499
+ } else if (listInfo!.dataArrayValues) {
1500
+ lines.push(`const _data = ${JSON.stringify(listInfo!.dataArrayValues)};`);
1501
+ }
1502
+ lines.push('');
1503
+ }
1504
+
1505
+ if (hasBehavior) {
1506
+ lines.push('class AppBehavior extends Behavior {');
1507
+ lines.push(' onCreate(app) {');
1508
+ // `c` is the container to search for named content nodes (sl*, sr*, ls*).
1509
+ // Without branches: c = app.first (the root Group container).
1510
+ // With branches: c = the baseline branch's inner Group container.
1511
+ // app → br_s0_v0 (branch) → Container (Group) → named nodes
1512
+ if (hasBranches) {
1513
+ // Find the baseline (initially visible) branch and navigate into its Group
1514
+ const baselineBranchIdx = branchesBySlot.values().next().value?.[0]?.isBaseline ? 0 : 1;
1515
+ const si = branchInfos[0]?.stateSlot ?? 0;
1516
+ lines.push(` const c = app.content("br_s${si}_v${baselineBranchIdx}").first;`);
1517
+ } else {
1518
+ lines.push(' const c = app.first;');
1519
+ }
1520
+
1521
+ // State fields (skip non-serializable values like Date objects)
1522
+ for (const slot of stateSlots) {
1523
+ const v = slot.initialValue;
1524
+ if (v instanceof Date || (typeof v === 'object' && v !== null && !(Array.isArray(v)))) {
1525
+ // Skip — internal hook state (e.g., useTime's Date) shouldn't be baked
1526
+ continue;
1527
+ }
1528
+ lines.push(` this.s${slot.index} = ${JSON.stringify(v)};`);
1529
+ }
1530
+
1531
+ // Time-dependent label refs
1532
+ for (const [idx] of labelFormats) {
1533
+ lines.push(` this.tl${idx} = c.content("tl${idx}");`);
1534
+ }
1535
+
1536
+ // State-dependent label refs
1537
+ for (const [idx] of stateDeps) {
1538
+ lines.push(` this.sl${idx} = c.content("sl${idx}");`);
1539
+ }
1540
+
1541
+ // Skin-reactive rect refs
1542
+ for (const [rIdx] of skinDeps) {
1543
+ lines.push(` this.sr${rIdx} = c.content("sr${rIdx}");`);
1544
+ }
1545
+
1546
+ // Branch container refs (for structural conditional rendering)
1547
+ for (const [si, branches] of branchesBySlot ?? []) {
1548
+ for (let bi = 0; bi < branches.length; bi++) {
1549
+ lines.push(` this.br_s${si}_v${bi} = app.content("br_s${si}_v${bi}");`);
1550
+ }
1551
+ }
1552
+
1553
+ // Per-subtree conditional refs
1554
+ for (const cc of conditionalChildren) {
1555
+ if (cc.type === 'removed') {
1556
+ const name = `cv_s${cc.stateSlot}_${cc.childIndex}`;
1557
+ lines.push(` this.${name} = c.content("${name}");`);
1558
+ }
1559
+ }
1560
+
1561
+ // List slot refs
1562
+ if (hasList) {
1563
+ const lpi = listInfo!.labelsPerItem;
1564
+ lines.push(` this._ls = [];`);
1565
+ for (let i = 0; i < listInfo!.visibleCount; i++) {
1566
+ if (lpi > 1) {
1567
+ // Multi-label: find the item Group, then its labels within
1568
+ lines.push(` const _g${i} = c.content("lg${i}");`);
1569
+ const refs = [];
1570
+ for (let j = 0; j < lpi; j++) {
1571
+ refs.push(`_g${i}.content("ls${i}_${j}")`);
1572
+ }
1573
+ lines.push(` this._ls.push([${refs.join(', ')}]);`);
1574
+ } else {
1575
+ lines.push(` this._ls.push(c.content("ls${i}"));`);
1576
+ }
1577
+ }
1578
+ }
1579
+
1580
+ lines.push(' }');
1581
+
1582
+ // onDisplaying
1583
+ lines.push(' onDisplaying(app) {');
1584
+ if (!messageInfo) {
1585
+ lines.push(' this.refresh();');
1586
+ }
1587
+ if (hasTimeDeps) {
1588
+ lines.push(' app.interval = 1000;');
1589
+ lines.push(' app.start();');
1590
+ }
1591
+ if (hasButtons) {
1592
+ // Collect unique button names used by bindings
1593
+ const usedButtons = [...new Set(buttonBindings.map(b => b.button))];
1594
+ for (const btn of usedButtons) {
1595
+ lines.push(` new PebbleButton({ type: "${btn}", onPush: (pushed, name) => { if (pushed) this.onButton({ button: name }); } });`);
1596
+ }
1597
+ }
1598
+ if (messageInfo) {
1599
+ // Subscribe to phone→watch messages
1600
+ lines.push(` const self = this;`);
1601
+ lines.push(` new Message({`);
1602
+ lines.push(` keys: ["${messageInfo.key}"],`);
1603
+ lines.push(` onReadable() {`);
1604
+ lines.push(` const map = this.read();`);
1605
+ lines.push(` const json = map.get("${messageInfo.key}");`);
1606
+ lines.push(` if (json) {`);
1607
+ lines.push(` try {`);
1608
+ lines.push(` _data = JSON.parse(json);`);
1609
+ // Toggle: show loaded branches (v0 = baseline = loaded), hide loading (v1)
1610
+ for (const [si, branches] of branchesBySlot ?? []) {
1611
+ for (let bi = 0; bi < branches.length; bi++) {
1612
+ lines.push(` self.br_s${si}_v${bi}.visible = ${bi === 0 ? 'true' : 'false'};`);
1613
+ }
1614
+ }
1615
+ // Update list labels from parsed data
1616
+ if (listInfo && listInfo.labelsPerItem > 1 && listInfo.propertyOrder) {
1617
+ const lpi = listInfo.labelsPerItem;
1618
+ const vc = listInfo.visibleCount;
1619
+ lines.push(` const c = self.br_s${[...branchesBySlot.keys()][0]}_v0.first;`);
1620
+ for (let i = 0; i < vc; i++) {
1621
+ lines.push(` const g${i} = c.content("lg${i}");`);
1622
+ for (let j = 0; j < lpi; j++) {
1623
+ const prop = listInfo.propertyOrder[j]!;
1624
+ lines.push(` if (g${i}) { const l = g${i}.content("ls${i}_${j}"); if (l) l.string = _data[${i}] ? _data[${i}].${prop} : ""; }`);
1625
+ }
1626
+ }
1627
+ } else if (listInfo) {
1628
+ const vc = listInfo.visibleCount;
1629
+ lines.push(` const c = self.br_s${[...branchesBySlot.keys()][0]}_v0.first;`);
1630
+ for (let i = 0; i < vc; i++) {
1631
+ lines.push(` const l${i} = c.content("ls${i}"); if (l${i}) l${i}.string = _data[${i}] || "";`);
1632
+ }
1633
+ }
1634
+ lines.push(` } catch (e) { console.log("Parse error: " + e.message); }`);
1635
+ lines.push(` }`);
1636
+ lines.push(` }`);
1637
+ lines.push(` });`);
1638
+ }
1639
+ lines.push(' }');
1640
+
1641
+ // onTimeChanged (only if time-dependent)
1642
+ if (hasTimeDeps) {
1643
+ lines.push(' onTimeChanged() {');
1644
+ lines.push(' this.refresh();');
1645
+ lines.push(' }');
1646
+ }
1647
+
1648
+ // onButton (only if buttons)
1649
+ if (hasButtons) {
1650
+ lines.push(' onButton(e) {');
1651
+ lines.push(' const name = e && e.button;');
1652
+ for (const { button, action } of buttonActions) {
1653
+ const cond = `name === "${button}"`;
1654
+ let stmt: string;
1655
+ // Override for list scroll: add clamping
1656
+ const isListScroll = hasList && action.slotIndex === listScrollSlotIndex;
1657
+
1658
+ switch (action.type) {
1659
+ case 'increment':
1660
+ if (isListScroll) {
1661
+ stmt = `this.s${action.slotIndex} = Math.min(_data.length - ${listInfo!.visibleCount}, this.s${action.slotIndex} + ${action.value}); this.refresh();`;
1662
+ } else {
1663
+ stmt = `this.s${action.slotIndex} += ${action.value}; this.refresh();`;
1664
+ }
1665
+ break;
1666
+ case 'decrement':
1667
+ if (isListScroll) {
1668
+ stmt = `this.s${action.slotIndex} = Math.max(0, this.s${action.slotIndex} - ${action.value}); this.refresh();`;
1669
+ } else {
1670
+ stmt = `this.s${action.slotIndex} -= ${action.value}; this.refresh();`;
1671
+ }
1672
+ break;
1673
+ case 'reset':
1674
+ stmt = `this.s${action.slotIndex} = ${action.value}; this.refresh();`;
1675
+ break;
1676
+ case 'toggle':
1677
+ stmt = `this.s${action.slotIndex} = !this.s${action.slotIndex}; this.refresh();`;
1678
+ break;
1679
+ case 'set_string':
1680
+ stmt = `this.s${action.slotIndex} = "${action.stringValue}"; this.refresh();`;
1681
+ break;
1682
+ }
1683
+ lines.push(` if (${cond}) { ${stmt} }`);
1684
+ }
1685
+ lines.push(' }');
1686
+ }
1687
+
1688
+ // refresh
1689
+ lines.push(' refresh() {');
1690
+ if (hasTimeDeps) {
1691
+ lines.push(' const d = new Date();');
1692
+ }
1693
+ for (const [idx, fmt] of labelFormats) {
1694
+ lines.push(` this.tl${idx}.string = ${emitTimeExpr(fmt)};`);
1695
+ }
1696
+ for (const [idx, dep] of stateDeps) {
1697
+ lines.push(` this.sl${idx}.string = ${dep.formatExpr};`);
1698
+ }
1699
+ // Skin reactivity — swap skins on state change
1700
+ for (const [rIdx, dep] of skinDeps) {
1701
+ const baseSkinVar = ensureSkin(ctx, dep.skins[0]!);
1702
+ const pertSkinVar = ensureSkin(ctx, dep.skins[1]!);
1703
+ const slot = stateSlots[dep.slotIndex];
1704
+ if (typeof slot?.initialValue === 'boolean') {
1705
+ lines.push(` this.sr${rIdx}.skin = this.s${dep.slotIndex} ? ${pertSkinVar} : ${baseSkinVar};`);
1706
+ } else {
1707
+ lines.push(` this.sr${rIdx}.skin = (this.s${dep.slotIndex} !== ${JSON.stringify(slot?.initialValue)}) ? ${pertSkinVar} : ${baseSkinVar};`);
1708
+ }
1709
+ }
1710
+ // Per-subtree conditional visibility
1711
+ for (const cc of conditionalChildren) {
1712
+ if (cc.type === 'removed') {
1713
+ const name = `cv_s${cc.stateSlot}_${cc.childIndex}`;
1714
+ lines.push(` this.${name}.visible = !!this.s${cc.stateSlot};`);
1715
+ }
1716
+ }
1717
+ // Branch visibility toggles
1718
+ for (const [si, branches] of branchesBySlot ?? []) {
1719
+ for (let bi = 0; bi < branches.length; bi++) {
1720
+ const branch = branches[bi]!;
1721
+ const cond = `this.s${si} === ${JSON.stringify(branch.value)}`;
1722
+ lines.push(` this.br_s${si}_v${bi}.visible = (${cond});`);
1723
+ }
1724
+ }
1725
+ // List slot scroll updates
1726
+ if (hasList && listScrollSlotIndex >= 0) {
1727
+ const lpi = listInfo!.labelsPerItem;
1728
+ lines.push(` const _start = this.s${listScrollSlotIndex};`);
1729
+ lines.push(` for (let _i = 0; _i < ${listInfo!.visibleCount}; _i++) {`);
1730
+ lines.push(` const _item = _data[_start + _i];`);
1731
+ if (lpi > 1 && listInfo!.propertyOrder) {
1732
+ // Multi-label: update each label from the corresponding property
1733
+ lines.push(` const _slot = this._ls[_i];`);
1734
+ lines.push(` if (_slot) {`);
1735
+ for (let j = 0; j < lpi; j++) {
1736
+ const prop = listInfo!.propertyOrder[j]!;
1737
+ lines.push(` _slot[${j}].string = _item ? _item.${prop} : "";`);
1738
+ lines.push(` _slot[${j}].visible = !!_item;`);
1739
+ }
1740
+ lines.push(` }`);
1741
+ } else {
1742
+ // Single-label: simple string update
1743
+ lines.push(` if (this._ls[_i]) {`);
1744
+ lines.push(` this._ls[_i].string = _item !== undefined ? "" + _item : "";`);
1745
+ lines.push(` this._ls[_i].visible = (_item !== undefined);`);
1746
+ lines.push(` }`);
1747
+ }
1748
+ lines.push(` }`);
1749
+ }
1750
+ lines.push(' }');
1751
+
1752
+ // refreshList — separate from refresh() for Message-driven data updates
1753
+ if (messageInfo && listInfo) {
1754
+ const lpi = listInfo!.labelsPerItem;
1755
+ lines.push(' refreshList() {');
1756
+ lines.push(` for (let i = 0; i < ${listInfo!.visibleCount}; i++) {`);
1757
+ lines.push(` const item = _data[i];`);
1758
+ if (lpi > 1 && listInfo!.propertyOrder) {
1759
+ lines.push(` const slot = this._ls[i];`);
1760
+ lines.push(` if (slot) {`);
1761
+ for (let j = 0; j < lpi; j++) {
1762
+ const prop = listInfo!.propertyOrder[j]!;
1763
+ lines.push(` slot[${j}].string = item ? item.${prop} : "";`);
1764
+ lines.push(` slot[${j}].visible = !!item;`);
1765
+ }
1766
+ lines.push(` }`);
1767
+ } else {
1768
+ lines.push(` if (this._ls[i]) {`);
1769
+ lines.push(` this._ls[i].string = item !== undefined ? "" + item : "";`);
1770
+ lines.push(` this._ls[i].visible = (item !== undefined);`);
1771
+ lines.push(` }`);
1772
+ }
1773
+ lines.push(` }`);
1774
+ lines.push(' }');
1775
+ }
1776
+
1777
+ lines.push('}');
1778
+ lines.push('');
1779
+ }
1780
+
1781
+ lines.push(
1782
+ 'const WatchApp = Application.template(() => ({',
1783
+ ' skin: bgSkin,',
1784
+ hasBehavior ? ' Behavior: AppBehavior,' : '',
1785
+ ' contents: [',
1786
+ contents ?? ' /* empty */',
1787
+ ' ],',
1788
+ '}));',
1789
+ '',
1790
+ 'export default new WatchApp(null, { touchCount: 0, pixels: screen.width * 4 });',
1791
+ '',
1792
+ );
1793
+
1794
+ process.stdout.write(lines.join('\n'));