react-pebble 0.1.0 → 0.2.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.
@@ -36,18 +36,31 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
36
36
  // Dynamic example import
37
37
  // ---------------------------------------------------------------------------
38
38
 
39
- const exampleName = process.env.EXAMPLE ?? 'watchface';
39
+ const exampleInput = process.env.EXAMPLE ?? 'watchface';
40
40
  const settleMs = Number(process.env.SETTLE_MS ?? '0');
41
41
  const platform = process.env.PEBBLE_PLATFORM ?? 'emery';
42
42
  const settle = () =>
43
43
  settleMs > 0 ? new Promise<void>((r) => setTimeout(r, settleMs)) : Promise.resolve();
44
44
 
45
45
  // Set platform screen dimensions before importing the example
46
- // (so SCREEN.width/height are correct when the component renders)
47
46
  import { _setPlatform, SCREEN } from '../src/platform.js';
48
47
  _setPlatform(platform);
49
48
 
50
- const exampleMod = await import(`../examples/${exampleName}.js`);
49
+ // Resolve the entry: could be a bare name (e.g., "watchface") for internal examples,
50
+ // or an absolute/relative path (e.g., "/tmp/my-app/src/App.tsx") for external projects.
51
+ let entryPath: string;
52
+ let exampleName: string;
53
+ if (exampleInput.includes('/') || exampleInput.includes('\\')) {
54
+ // Absolute or relative path — resolve from cwd
55
+ entryPath = resolve(exampleInput);
56
+ exampleName = entryPath.replace(/\.[jt]sx?$/, '').split('/').pop()!;
57
+ } else {
58
+ // Bare name — look in ../examples/
59
+ entryPath = resolve(__dirname, '..', 'examples', `${exampleInput}.tsx`);
60
+ exampleName = exampleInput;
61
+ }
62
+
63
+ const exampleMod = await import(entryPath);
51
64
  const exampleMain: (...args: unknown[]) => ReturnType<typeof render> =
52
65
  exampleMod.main ?? exampleMod.default;
53
66
 
@@ -65,6 +78,7 @@ function colorToHex(name: string): string {
65
78
  }
66
79
 
67
80
  const FONT_TO_PIU: Record<string, string> = {
81
+ // Gothic family
68
82
  gothic14: '14px Gothic',
69
83
  gothic14Bold: 'bold 14px Gothic',
70
84
  gothic18: '18px Gothic',
@@ -73,9 +87,25 @@ const FONT_TO_PIU: Record<string, string> = {
73
87
  gothic24Bold: 'bold 24px Gothic',
74
88
  gothic28: '28px Gothic',
75
89
  gothic28Bold: 'bold 28px Gothic',
90
+ // Bitham family
76
91
  bitham30Black: 'black 30px Bitham',
77
92
  bitham42Bold: 'bold 42px Bitham',
78
93
  bitham42Light: 'light 42px Bitham',
94
+ bitham34MediumNumbers: '34px Bitham',
95
+ bitham42MediumNumbers: '42px Bitham',
96
+ // Roboto family
97
+ robotoCondensed21: '21px Roboto Condensed',
98
+ roboto21: '21px Roboto',
99
+ // Droid Serif
100
+ droid28: '28px Droid Serif',
101
+ // LECO family
102
+ leco20: '20px LECO',
103
+ leco26: '26px LECO',
104
+ leco28: '28px LECO',
105
+ leco32: '32px LECO',
106
+ leco36: '36px LECO',
107
+ leco38: '38px LECO',
108
+ leco42: '42px LECO',
79
109
  };
80
110
 
81
111
  function fontToPiu(name: string | undefined): string {
@@ -165,8 +195,17 @@ const buttonBindings: ButtonBinding[] = [];
165
195
  * Parse an example source file into a TypeScript AST SourceFile.
166
196
  */
167
197
  function parseExampleSource(exName: string): ts.SourceFile | null {
168
- for (const ext of ['.tsx', '.ts', '.jsx']) {
169
- const srcPath = resolve(__dirname, '..', 'examples', `${exName}${ext}`);
198
+ // If exName is an absolute path, try it directly
199
+ if (exName.startsWith('/')) {
200
+ try {
201
+ const source = readFileSync(exName, 'utf-8');
202
+ return ts.createSourceFile(exName, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
203
+ } catch { /* fall through to extension search */ }
204
+ }
205
+ for (const ext of ['.tsx', '.ts', '.jsx', '']) {
206
+ const srcPath = exName.startsWith('/')
207
+ ? `${exName}${ext}`
208
+ : resolve(__dirname, '..', 'examples', `${exName}${ext}`);
170
209
  try {
171
210
  const source = readFileSync(srcPath, 'utf-8');
172
211
  return ts.createSourceFile(srcPath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
@@ -248,8 +287,8 @@ function buildSetterSlotMap(exName: string): Map<string, number> {
248
287
  return map;
249
288
  }
250
289
 
251
- const setterSlotMap = buildSetterSlotMap(exampleName);
252
- const listInfo = detectListPatterns(exampleName);
290
+ const setterSlotMap = buildSetterSlotMap(entryPath);
291
+ const listInfo = detectListPatterns(entryPath);
253
292
  if (listInfo) {
254
293
  process.stderr.write(`List detected: array="${listInfo.dataArrayName}" visible=${listInfo.visibleCount} labelsPerItem=${listInfo.labelsPerItem}\n`);
255
294
  if (listInfo.dataArrayValues) process.stderr.write(` values: ${JSON.stringify(listInfo.dataArrayValues)}\n`);
@@ -298,9 +337,27 @@ function detectUseMessage(exName: string): MessageInfo | null {
298
337
  return { key, mockDataArrayName };
299
338
  }
300
339
 
301
- const messageInfo = detectUseMessage(exampleName);
340
+ const messageInfo = detectUseMessage(entryPath);
302
341
  if (messageInfo) {
303
342
  process.stderr.write(`useMessage detected: key="${messageInfo.key}"${messageInfo.mockDataArrayName ? ` mockData=${messageInfo.mockDataArrayName}` : ''}\n`);
343
+
344
+ // Extract the mock data value from the source so the plugin can generate phone-side JS
345
+ if (messageInfo.mockDataArrayName) {
346
+ const sf = parseExampleSource(entryPath);
347
+ if (sf) {
348
+ walkAST(sf, (node) => {
349
+ if (
350
+ ts.isVariableDeclaration(node) &&
351
+ ts.isIdentifier(node.name) &&
352
+ node.name.text === messageInfo.mockDataArrayName &&
353
+ node.initializer
354
+ ) {
355
+ const mockDataSource = node.initializer.getText(sf);
356
+ process.stderr.write(`mockDataValue=${mockDataSource}\n`);
357
+ }
358
+ });
359
+ }
360
+ }
304
361
  }
305
362
 
306
363
  /** Module-level map collecting string enum values per slot from handler analysis */
@@ -385,6 +442,32 @@ function analyzeSetterCall(call: ts.CallExpression, sf: ts.SourceFile): HandlerA
385
442
  if (ts.isPrefixUnaryExpression(body) && body.operator === ts.SyntaxKind.ExclamationToken) {
386
443
  return { type: 'toggle', slotIndex, value: 0 };
387
444
  }
445
+ // param => Math.min(param + N, max) (clamped increment)
446
+ // param => Math.max(param - N, min) (clamped decrement)
447
+ if (ts.isCallExpression(body) && ts.isPropertyAccessExpression(body.expression)) {
448
+ const obj = body.expression.expression;
449
+ const method = body.expression.name.text;
450
+ if (ts.isIdentifier(obj) && obj.text === 'Math' && body.arguments.length === 2) {
451
+ const [a0, a1] = [body.arguments[0]!, body.arguments[1]!];
452
+ if (method === 'min' && ts.isBinaryExpression(a0)) {
453
+ if (a0.operatorToken.kind === ts.SyntaxKind.PlusToken && ts.isNumericLiteral(a0.right)) {
454
+ return { type: 'increment', slotIndex, value: Number(a0.right.text) };
455
+ }
456
+ }
457
+ if (method === 'max' && ts.isBinaryExpression(a0)) {
458
+ if (a0.operatorToken.kind === ts.SyntaxKind.MinusToken && ts.isNumericLiteral(a0.right)) {
459
+ return { type: 'decrement', slotIndex, value: Number(a0.right.text) };
460
+ }
461
+ }
462
+ }
463
+ }
464
+ // param => (param + N) % M (modular increment)
465
+ if (ts.isBinaryExpression(body) && body.operatorToken.kind === ts.SyntaxKind.PercentToken) {
466
+ const left = ts.isParenthesizedExpression(body.left) ? body.left.expression : body.left;
467
+ if (ts.isBinaryExpression(left) && left.operatorToken.kind === ts.SyntaxKind.PlusToken && ts.isNumericLiteral(left.right)) {
468
+ return { type: 'increment', slotIndex, value: Number(left.right.text) };
469
+ }
470
+ }
388
471
  }
389
472
 
390
473
  return null;
@@ -564,6 +647,15 @@ function detectListPatterns(exName: string): ListInfo | null {
564
647
  // Emit context
565
648
  // ---------------------------------------------------------------------------
566
649
 
650
+ interface ElementPos {
651
+ type: string;
652
+ left: number;
653
+ top: number;
654
+ width: number;
655
+ height: number;
656
+ radius?: number;
657
+ }
658
+
567
659
  interface EmitContext {
568
660
  skins: Map<string, string>;
569
661
  styles: Map<string, string>;
@@ -576,6 +668,10 @@ interface EmitContext {
576
668
  labelTexts: Map<number, string>;
577
669
  /** Map from rect sequential index → its fill color name */
578
670
  rectFills: Map<number, string>;
671
+ /** Sequential index for all positioned elements (circles, rects) */
672
+ elemIdx: number;
673
+ /** Map from element sequential index → its position at this render */
674
+ elementPositions: Map<number, ElementPos>;
579
675
  }
580
676
 
581
677
  function ensureSkin(ctx: EmitContext, fill: string): string {
@@ -610,6 +706,7 @@ interface ConditionalChild {
610
706
  }
611
707
  const conditionalChildren: ConditionalChild[] = [];
612
708
  let emitConditionals = false; // Only wrap conditionals during final emit
709
+ const animatedElemNames = new Set<number>(); // Element indices that need names for animation
613
710
  let conditionalDepth = 0; // Track nesting — only wrap at depth 1 (root Group's children)
614
711
 
615
712
  // Emit piu tree
@@ -692,10 +789,14 @@ function emitNode(
692
789
  // Track rect fill for skin reactivity detection
693
790
  const rectIdx = ctx.rectIdx++;
694
791
  ctx.rectFills.set(rectIdx, fill);
792
+ // Track rect position for animation keyframing
793
+ const rEIdx = ctx.elemIdx++;
794
+ ctx.elementPositions.set(rEIdx, { type: 'rect', left: x, top: y, width: w, height: h });
695
795
 
696
- // If this rect has a dynamic skin, give it a name
796
+ // If this rect has a dynamic skin or animated position, give it a name
697
797
  const isSkinDynamic = skinDeps?.has(rectIdx) ?? false;
698
- const nameProp = isSkinDynamic ? `, name: "sr${rectIdx}"` : '';
798
+ const isAnimated = animatedElemNames.has(rEIdx);
799
+ const nameProp = isSkinDynamic ? `, name: "sr${rectIdx}"` : isAnimated ? `, name: "ae${rEIdx}"` : '';
699
800
 
700
801
  // Use constraint-based layout when dimensions match screen size
701
802
  // (so the output adapts to any screen at runtime)
@@ -779,7 +880,10 @@ function emitNode(
779
880
  const skinVar = ensureSkin(ctx, fill);
780
881
  // piu RoundRect with radius = r draws a circle when width = height = 2*r
781
882
  const size = r * 2;
782
- return `${indent}new RoundRect(null, { left: ${cx}, top: ${cy}, width: ${size}, height: ${size}, radius: ${r}, skin: ${skinVar} })`;
883
+ const eIdx = ctx.elemIdx++;
884
+ ctx.elementPositions.set(eIdx, { type: 'circle', left: cx, top: cy, width: size, height: size, radius: r });
885
+ const animName = animatedElemNames.has(eIdx) ? `, name: "ae${eIdx}"` : '';
886
+ return `${indent}new RoundRect(null, { left: ${cx}, top: ${cy}, width: ${size}, height: ${size}, radius: ${r}, skin: ${skinVar}${animName} })`;
783
887
  }
784
888
 
785
889
  default:
@@ -827,7 +931,7 @@ function buildSizeProps(x: number, y: number, w: number, h: number): string {
827
931
  // Time format inference
828
932
  // ---------------------------------------------------------------------------
829
933
 
830
- type TimeFormat = 'HHMM' | 'SS' | 'DATE';
934
+ type TimeFormat = 'HHMM' | 'MMSS' | 'SS' | 'DATE';
831
935
 
832
936
  function inferTimeFormat(textAtT1: string, t1: Date): TimeFormat | null {
833
937
  const hh = pad2(t1.getHours());
@@ -840,6 +944,7 @@ function inferTimeFormat(textAtT1: string, t1: Date): TimeFormat | null {
840
944
  ];
841
945
 
842
946
  if (textAtT1 === `${hh}:${mm}`) return 'HHMM';
947
+ if (textAtT1 === `${mm}:${ss}`) return 'MMSS';
843
948
  if (textAtT1 === ss) return 'SS';
844
949
  if (
845
950
  textAtT1.includes(days[t1.getDay()]!) &&
@@ -854,6 +959,8 @@ function emitTimeExpr(fmt: TimeFormat): string {
854
959
  switch (fmt) {
855
960
  case 'HHMM':
856
961
  return 'pad(d.getHours()) + ":" + pad(d.getMinutes())';
962
+ case 'MMSS':
963
+ return 'pad(d.getMinutes()) + ":" + pad(d.getSeconds())';
857
964
  case 'SS':
858
965
  return 'pad(d.getSeconds())';
859
966
  case 'DATE':
@@ -879,7 +986,7 @@ let listScrollSlotIndex = -1;
879
986
 
880
987
  // Install interceptors BEFORE any rendering
881
988
  installUseStateInterceptor();
882
- extractButtonBindingsFromSource(exampleName);
989
+ extractButtonBindingsFromSource(entryPath);
883
990
 
884
991
  // Create BOTH test dates with the REAL Date before any mocking.
885
992
  const OrigDate = globalThis.Date;
@@ -918,6 +1025,8 @@ const ctx1: EmitContext = {
918
1025
  labelTexts: new Map(),
919
1026
  rectIdx: 0,
920
1027
  rectFills: new Map(),
1028
+ elemIdx: 0,
1029
+ elementPositions: new Map(),
921
1030
  };
922
1031
  emitNode(app1._root, ctx1, '', null, null);
923
1032
  const t1Texts = new Map(ctx1.labelTexts);
@@ -933,7 +1042,7 @@ for (const b of buttonBindings) {
933
1042
  // Perturbation pipeline — discover state-dependent labels
934
1043
  // ---------------------------------------------------------------------------
935
1044
 
936
- const stateDeps = new Map<number, { slotIndex: number; formatExpr: string }>();
1045
+ const stateDeps = new Map<number, { slotIndex: number; formatExpr: string; needsTime?: boolean }>();
937
1046
  const skinDeps = new Map<number, { slotIndex: number; skins: [string, string] }>();
938
1047
 
939
1048
  // Branch info: when tree structure changes between baseline and perturbed,
@@ -1059,6 +1168,8 @@ for (const slot of stateSlots) {
1059
1168
  labelTexts: new Map(),
1060
1169
  rectIdx: 0,
1061
1170
  rectFills: new Map(),
1171
+ elemIdx: 0,
1172
+ elementPositions: new Map(),
1062
1173
  };
1063
1174
  emitNode(appP._root, ctxP, '', null, null);
1064
1175
 
@@ -1077,8 +1188,23 @@ for (const slot of stateSlots) {
1077
1188
  if (String(perturbedValue) === pertText) {
1078
1189
  formatExpr = `"" + this.s${slot.index}`;
1079
1190
  } 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, '\\"')}"`;
1191
+ // Boolean state produced different text — emit a ternary.
1192
+ // If the perturbed text looks like a time format, emit a live
1193
+ // time expression instead of a frozen compile-time snapshot.
1194
+ const pertTimeFmt = inferTimeFormat(pertText, T1);
1195
+ if (pertTimeFmt && (pertTimeFmt === 'MMSS' || pertTimeFmt === 'HHMM')) {
1196
+ // State toggles between static text and live time.
1197
+ // Emit elapsed-time tracking: capture start time when toggled on,
1198
+ // compute elapsed seconds in refresh().
1199
+ formatExpr = `this.s${slot.index} ? (function(e) { return pad(Math.floor(e / 60)) + ":" + pad(e % 60); })(Math.floor((Date.now() - this._startTime_s${slot.index}) / 1000)) : "${baseText.replace(/"/g, '\\"')}"`;
1200
+ stateDeps.set(idx, { slotIndex: slot.index, formatExpr, needsTime: true });
1201
+ process.stderr.write(
1202
+ ` Label ${idx} depends on state slot ${slot.index} (base="${baseText}", perturbed=ELAPSED:${pertTimeFmt})\n`,
1203
+ );
1204
+ continue; // skip the default stateDeps.set below
1205
+ } else {
1206
+ formatExpr = `this.s${slot.index} ? "${pertText.replace(/"/g, '\\"')}" : "${baseText.replace(/"/g, '\\"')}"`;
1207
+ }
1082
1208
  } else {
1083
1209
  formatExpr = `"" + this.s${slot.index}`;
1084
1210
  }
@@ -1220,6 +1346,7 @@ if (listInfo) {
1220
1346
  skins: new Map(), styles: new Map(), declarations: [],
1221
1347
  skinIdx: 0, styleIdx: 0, labelIdx: 0, labelTexts: new Map(),
1222
1348
  rectIdx: 0, rectFills: new Map(),
1349
+ elemIdx: 0, elementPositions: new Map(),
1223
1350
  };
1224
1351
  emitNode(appScroll._root, ctxScroll, '', null, null);
1225
1352
 
@@ -1245,6 +1372,20 @@ if (listInfo) {
1245
1372
  for (const idx of keep) listSlotLabels.add(idx);
1246
1373
  }
1247
1374
 
1375
+ // For message-driven lists without scroll buttons, identify list labels
1376
+ // from the template by matching the expected label count. The last
1377
+ // (visibleCount × labelsPerItem) labels in the T1 render are the list items.
1378
+ if (listSlotLabels.size === 0 && messageInfo && listInfo) {
1379
+ const expectedSlots = listInfo.visibleCount * listInfo.labelsPerItem;
1380
+ const allLabels = [...t1Texts.keys()].sort((a, b) => a - b);
1381
+ // Take the last expectedSlots labels (list items come after headers)
1382
+ const listLabels = allLabels.slice(-expectedSlots);
1383
+ for (const idx of listLabels) {
1384
+ listSlotLabels.add(idx);
1385
+ }
1386
+ process.stderr.write(`Message-driven list labels (inferred): [${[...listSlotLabels].join(', ')}]\n`);
1387
+ }
1388
+
1248
1389
  if (listSlotLabels.size > 0) {
1249
1390
  process.stderr.write(`List slot labels: [${[...listSlotLabels].join(', ')}]\n`);
1250
1391
  }
@@ -1283,6 +1424,8 @@ const ctx2: EmitContext = {
1283
1424
  labelTexts: new Map(),
1284
1425
  rectIdx: 0,
1285
1426
  rectFills: new Map(),
1427
+ elemIdx: 0,
1428
+ elementPositions: new Map(),
1286
1429
  };
1287
1430
  emitNode(app2._root, ctx2, '', null, null);
1288
1431
  const t2Texts = new Map(ctx2.labelTexts);
@@ -1316,6 +1459,105 @@ process.stderr.write(
1316
1459
  .join(', ')}\n`,
1317
1460
  );
1318
1461
 
1462
+ // --- Keyframe pass: detect animated positions/sizes ---
1463
+ // Compare element positions between T1 and T2 to find elements that move.
1464
+ // For those elements, render at 60 time points (one per second over 1 minute)
1465
+ // and build keyframe arrays for position updates in onTimeChanged.
1466
+
1467
+ interface AnimatedElement {
1468
+ elemIdx: number;
1469
+ prop: 'top' | 'width' | 'height' | 'radius';
1470
+ keyframes: number[]; // 60 values (one per second)
1471
+ }
1472
+
1473
+ const animatedElements: AnimatedElement[] = [];
1474
+ const t1Positions = ctx1.elementPositions;
1475
+ const t2Positions = ctx2.elementPositions;
1476
+
1477
+ // Find elements with changed positions between T1 and T2
1478
+ const changedElems = new Set<number>();
1479
+ for (const [idx, pos1] of t1Positions) {
1480
+ const pos2 = t2Positions.get(idx);
1481
+ if (!pos2) continue;
1482
+ if (pos1.top !== pos2.top || pos1.width !== pos2.width ||
1483
+ pos1.height !== pos2.height || (pos1.radius !== undefined && pos1.radius !== pos2.radius)) {
1484
+ changedElems.add(idx);
1485
+ }
1486
+ }
1487
+
1488
+ if (changedElems.size > 0) {
1489
+ process.stderr.write(`Found ${changedElems.size} animated element(s), sampling keyframes...\n`);
1490
+
1491
+ // Sample 60 keyframes (one per second)
1492
+ const keyframeData = new Map<number, Map<string, number[]>>(); // elemIdx → prop → values[]
1493
+
1494
+ for (let s = 0; s < 60; s++) {
1495
+ const mockTime = new OrigDate(T1.getFullYear(), T1.getMonth(), T1.getDate(),
1496
+ T1.getHours(), s, s, 0); // vary both minutes and seconds
1497
+ // Actually set minute = T1.minute, second = s
1498
+ const kfTime = new OrigDate(T1.getFullYear(), T1.getMonth(), T1.getDate(),
1499
+ T1.getHours(), T1.getMinutes(), s, 0);
1500
+
1501
+ (globalThis as unknown as { Date: unknown }).Date = class MockDateKF extends OrigDate {
1502
+ constructor() { super(); return kfTime; }
1503
+ static now() { return kfTime.getTime(); }
1504
+ };
1505
+
1506
+ forcedStateValues.clear();
1507
+ resetStateTracking();
1508
+ silence();
1509
+ const appKF = exampleMain();
1510
+ restore();
1511
+
1512
+ if (appKF) {
1513
+ const ctxKF: EmitContext = {
1514
+ skins: new Map(), styles: new Map(), declarations: [],
1515
+ skinIdx: 0, styleIdx: 0, labelIdx: 0, labelTexts: new Map(),
1516
+ rectIdx: 0, rectFills: new Map(),
1517
+ elemIdx: 0, elementPositions: new Map(),
1518
+ };
1519
+ emitNode(appKF._root, ctxKF, '', null, null);
1520
+
1521
+ for (const eIdx of changedElems) {
1522
+ const pos = ctxKF.elementPositions.get(eIdx);
1523
+ if (!pos) continue;
1524
+ if (!keyframeData.has(eIdx)) keyframeData.set(eIdx, new Map());
1525
+ const props = keyframeData.get(eIdx)!;
1526
+ for (const prop of ['top', 'width', 'height', 'radius'] as const) {
1527
+ const val = pos[prop];
1528
+ if (val === undefined) continue;
1529
+ if (!props.has(prop)) props.set(prop, []);
1530
+ props.get(prop)!.push(val);
1531
+ }
1532
+ }
1533
+ appKF.unmount();
1534
+ }
1535
+ }
1536
+
1537
+ (globalThis as unknown as { Date: typeof Date }).Date = OrigDate;
1538
+
1539
+ // Build AnimatedElement entries for props that actually change
1540
+ for (const [eIdx, props] of keyframeData) {
1541
+ for (const [prop, values] of props) {
1542
+ const allSame = values.every(v => v === values[0]);
1543
+ if (!allSame) {
1544
+ animatedElements.push({
1545
+ elemIdx: eIdx,
1546
+ prop: prop as AnimatedElement['prop'],
1547
+ keyframes: values,
1548
+ });
1549
+ }
1550
+ }
1551
+ }
1552
+
1553
+ process.stderr.write(` Animated properties: ${animatedElements.map(a => `e${a.elemIdx}.${a.prop}`).join(', ')}\n`);
1554
+
1555
+ // Mark elements for naming in the final emit pass
1556
+ for (const ae of animatedElements) {
1557
+ animatedElemNames.add(ae.elemIdx);
1558
+ }
1559
+ }
1560
+
1319
1561
  // --- Final render at T1 for the emitted static snapshot ---
1320
1562
  (globalThis as unknown as { Date: unknown }).Date = class MockDate3 extends OrigDate {
1321
1563
  constructor() {
@@ -1351,6 +1593,8 @@ const ctx: EmitContext = {
1351
1593
  labelTexts: new Map(),
1352
1594
  rectIdx: 0,
1353
1595
  rectFills: new Map(),
1596
+ elemIdx: 0,
1597
+ elementPositions: new Map(),
1354
1598
  };
1355
1599
 
1356
1600
  let contents: string | null;
@@ -1417,8 +1661,10 @@ if (branchInfos.length > 0) {
1417
1661
  }
1418
1662
  contents = branchLines.join(',\n');
1419
1663
  } else {
1420
- // No structural branches — emit the single tree as before
1421
- emitConditionals = true;
1664
+ // No structural branches — emit the single tree as before.
1665
+ // For message-driven apps, skip conditionals since the UI starts with
1666
+ // mock data visible and labels get updated in-place by the Message handler.
1667
+ emitConditionals = !messageInfo;
1422
1668
  contents = emitNode(appFinal._root, ctx, ' ', dynamicLabels, stateDeps, skinDeps);
1423
1669
  }
1424
1670
  appFinal.unmount();
@@ -1445,11 +1691,13 @@ for (const binding of buttonBindings) {
1445
1691
  }
1446
1692
 
1447
1693
  // --- Emit piu output ---
1448
- const hasTimeDeps = dynamicLabels.size > 0;
1694
+ const stateNeedsTime = [...stateDeps.values()].some(d => d.needsTime);
1695
+ const hasAnimatedElements = animatedElements.length > 0;
1696
+ const hasTimeDeps = dynamicLabels.size > 0 || stateNeedsTime || hasAnimatedElements;
1449
1697
  const hasStateDeps = stateDeps.size > 0;
1450
1698
  const hasButtons = buttonActions.length > 0;
1451
1699
  const hasBranches = branchInfos.length > 0;
1452
- const hasConditionals = conditionalChildren.length > 0;
1700
+ const hasConditionals = conditionalChildren.length > 0 && !messageInfo;
1453
1701
  const hasSkinDeps = skinDeps.size > 0;
1454
1702
  const hasList = listInfo !== null && listSlotLabels.size > 0;
1455
1703
  const hasBehavior = hasTimeDeps || hasStateDeps || hasButtons || hasBranches || hasSkinDeps || hasList || hasConditionals;
@@ -1479,6 +1727,14 @@ lines.push(
1479
1727
  '',
1480
1728
  );
1481
1729
 
1730
+ // Emit keyframe arrays for animated elements
1731
+ if (hasAnimatedElements) {
1732
+ for (const ae of animatedElements) {
1733
+ lines.push(`const _kf_e${ae.elemIdx}_${ae.prop} = [${ae.keyframes.join(',')}];`);
1734
+ }
1735
+ lines.push('');
1736
+ }
1737
+
1482
1738
  if (hasTimeDeps) {
1483
1739
  lines.push('function pad(n) { return n < 10 ? "0" + n : "" + n; }');
1484
1740
  lines.push(
@@ -1528,6 +1784,13 @@ if (hasBehavior) {
1528
1784
  lines.push(` this.s${slot.index} = ${JSON.stringify(v)};`);
1529
1785
  }
1530
1786
 
1787
+ // Elapsed-time start markers for state+time hybrid labels
1788
+ for (const [, dep] of stateDeps) {
1789
+ if (dep.needsTime) {
1790
+ lines.push(` this._startTime_s${dep.slotIndex} = Date.now();`);
1791
+ }
1792
+ }
1793
+
1531
1794
  // Time-dependent label refs
1532
1795
  for (const [idx] of labelFormats) {
1533
1796
  lines.push(` this.tl${idx} = c.content("tl${idx}");`);
@@ -1543,6 +1806,12 @@ if (hasBehavior) {
1543
1806
  lines.push(` this.sr${rIdx} = c.content("sr${rIdx}");`);
1544
1807
  }
1545
1808
 
1809
+ // Animated element refs
1810
+ const animElemIndices = [...new Set(animatedElements.map(a => a.elemIdx))];
1811
+ for (const eIdx of animElemIndices) {
1812
+ lines.push(` this.ae${eIdx} = c.content("ae${eIdx}");`);
1813
+ }
1814
+
1546
1815
  // Branch container refs (for structural conditional rendering)
1547
1816
  for (const [si, branches] of branchesBySlot ?? []) {
1548
1817
  for (let bi = 0; bi < branches.length; bi++) {
@@ -1550,8 +1819,8 @@ if (hasBehavior) {
1550
1819
  }
1551
1820
  }
1552
1821
 
1553
- // Per-subtree conditional refs
1554
- for (const cc of conditionalChildren) {
1822
+ // Per-subtree conditional refs (skipped for message-driven apps)
1823
+ if (hasConditionals) for (const cc of conditionalChildren) {
1555
1824
  if (cc.type === 'removed') {
1556
1825
  const name = `cv_s${cc.stateSlot}_${cc.childIndex}`;
1557
1826
  lines.push(` this.${name} = c.content("${name}");`);
@@ -1606,30 +1875,52 @@ if (hasBehavior) {
1606
1875
  lines.push(` if (json) {`);
1607
1876
  lines.push(` try {`);
1608
1877
  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'};`);
1878
+ // Toggle visibility: show loaded content, hide loading state
1879
+ if (branchesBySlot && branchesBySlot.size > 0) {
1880
+ // Structural branches: toggle br_s*_v* containers
1881
+ for (const [si, branches] of branchesBySlot) {
1882
+ for (let bi = 0; bi < branches.length; bi++) {
1883
+ lines.push(` self.br_s${si}_v${bi}.visible = ${bi === 0 ? 'true' : 'false'};`);
1884
+ }
1613
1885
  }
1614
1886
  }
1887
+ // Determine the content root for finding list labels
1888
+ const contentRoot = (branchesBySlot && branchesBySlot.size > 0)
1889
+ ? `self.br_s${[...branchesBySlot.keys()][0]}_v0.first`
1890
+ : 'app.first';
1615
1891
  // Update list labels from parsed data
1616
- if (listInfo && listInfo.labelsPerItem > 1 && listInfo.propertyOrder) {
1892
+ if (listInfo && listInfo.labelsPerItem > 1) {
1617
1893
  const lpi = listInfo.labelsPerItem;
1618
1894
  const vc = listInfo.visibleCount;
1619
- lines.push(` const c = self.br_s${[...branchesBySlot.keys()][0]}_v0.first;`);
1895
+ const props = listInfo.propertyOrder;
1896
+ lines.push(` const c = ${contentRoot};`);
1620
1897
  for (let i = 0; i < vc; i++) {
1621
1898
  lines.push(` const g${i} = c.content("lg${i}");`);
1622
1899
  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} : ""; }`);
1900
+ // Use property name if known, otherwise access by Object.values
1901
+ const accessor = props ? `_data[${i}].${props[j]}` : `Object.values(_data[${i}] || {})[${j}]`;
1902
+ lines.push(` if (g${i}) { const l = g${i}.content("ls${i}_${j}"); if (l) l.string = _data[${i}] ? ${accessor} || "" : ""; }`);
1625
1903
  }
1626
1904
  }
1627
1905
  } else if (listInfo) {
1628
1906
  const vc = listInfo.visibleCount;
1629
- lines.push(` const c = self.br_s${[...branchesBySlot.keys()][0]}_v0.first;`);
1907
+ lines.push(` const c = ${contentRoot};`);
1630
1908
  for (let i = 0; i < vc; i++) {
1631
1909
  lines.push(` const l${i} = c.content("ls${i}"); if (l${i}) l${i}.string = _data[${i}] || "";`);
1632
1910
  }
1911
+ } else {
1912
+ // Non-list message data: update state-dependent labels
1913
+ // The received data replaces the loading state — toggle conditionals
1914
+ for (const cc of conditionalChildren) {
1915
+ if (cc.type === 'removed') {
1916
+ const name = `cv_s${cc.stateSlot}_${cc.childIndex}`;
1917
+ lines.push(` if (self.${name}) self.${name}.visible = true;`);
1918
+ }
1919
+ }
1920
+ // Update any state-dependent labels with data values
1921
+ for (const [idx, dep] of stateDeps) {
1922
+ lines.push(` if (self.sl${idx}) self.sl${idx}.string = JSON.stringify(_data);`);
1923
+ }
1633
1924
  }
1634
1925
  lines.push(` } catch (e) { console.log("Parse error: " + e.message); }`);
1635
1926
  lines.push(` }`);
@@ -1673,9 +1964,16 @@ if (hasBehavior) {
1673
1964
  case 'reset':
1674
1965
  stmt = `this.s${action.slotIndex} = ${action.value}; this.refresh();`;
1675
1966
  break;
1676
- case 'toggle':
1677
- stmt = `this.s${action.slotIndex} = !this.s${action.slotIndex}; this.refresh();`;
1967
+ case 'toggle': {
1968
+ // Check if any state-dependent label needs elapsed time tracking for this slot
1969
+ const needsElapsed = [...stateDeps.values()].some(d => d.slotIndex === action.slotIndex && d.needsTime);
1970
+ if (needsElapsed) {
1971
+ stmt = `this.s${action.slotIndex} = !this.s${action.slotIndex}; if (this.s${action.slotIndex}) this._startTime_s${action.slotIndex} = Date.now(); this.refresh();`;
1972
+ } else {
1973
+ stmt = `this.s${action.slotIndex} = !this.s${action.slotIndex}; this.refresh();`;
1974
+ }
1678
1975
  break;
1976
+ }
1679
1977
  case 'set_string':
1680
1978
  stmt = `this.s${action.slotIndex} = "${action.stringValue}"; this.refresh();`;
1681
1979
  break;
@@ -1707,8 +2005,8 @@ if (hasBehavior) {
1707
2005
  lines.push(` this.sr${rIdx}.skin = (this.s${dep.slotIndex} !== ${JSON.stringify(slot?.initialValue)}) ? ${pertSkinVar} : ${baseSkinVar};`);
1708
2006
  }
1709
2007
  }
1710
- // Per-subtree conditional visibility
1711
- for (const cc of conditionalChildren) {
2008
+ // Per-subtree conditional visibility (skipped for message-driven apps)
2009
+ if (hasConditionals) for (const cc of conditionalChildren) {
1712
2010
  if (cc.type === 'removed') {
1713
2011
  const name = `cv_s${cc.stateSlot}_${cc.childIndex}`;
1714
2012
  lines.push(` this.${name}.visible = !!this.s${cc.stateSlot};`);
@@ -1747,6 +2045,19 @@ if (hasBehavior) {
1747
2045
  }
1748
2046
  lines.push(` }`);
1749
2047
  }
2048
+ // Animated element position/size updates from keyframe tables
2049
+ if (hasAnimatedElements) {
2050
+ lines.push(' const _s = d.getSeconds();');
2051
+ for (const ae of animatedElements) {
2052
+ const propMap: Record<string, string> = {
2053
+ top: 'y', width: 'width', height: 'height', radius: 'radius',
2054
+ };
2055
+ // piu Content uses .moveBy() or direct property access
2056
+ // For top: use .y property; for width/height: .width/.height
2057
+ const piuProp = propMap[ae.prop] ?? ae.prop;
2058
+ lines.push(` this.ae${ae.elemIdx}.${piuProp} = _kf_e${ae.elemIdx}_${ae.prop}[_s];`);
2059
+ }
2060
+ }
1750
2061
  lines.push(' }');
1751
2062
 
1752
2063
  // refreshList — separate from refresh() for Message-driven data updates
package/scripts/deploy.sh CHANGED
File without changes