@onekeyfe/react-native-native-list 6.7.0-alpha.375 → 6.7.0-alpha.382

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 (116) hide show
  1. package/android/src/main/java/com/onekey/nativelist/HybridNativeList.kt +14 -1
  2. package/android/src/main/java/com/onekey/nativelist/NativeListAccessoryStack.kt +240 -11
  3. package/android/src/main/java/com/onekey/nativelist/NativeListActivityDetailsView.kt +7 -4
  4. package/android/src/main/java/com/onekey/nativelist/NativeListActivityRenderer.kt +6 -3
  5. package/android/src/main/java/com/onekey/nativelist/NativeListButtonGroupRenderer.kt +102 -0
  6. package/android/src/main/java/com/onekey/nativelist/NativeListContainerSlotAdapter.kt +12 -0
  7. package/android/src/main/java/com/onekey/nativelist/NativeListDataRowRenderer.kt +87 -7
  8. package/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt +177 -1
  9. package/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt +43 -14
  10. package/android/src/main/java/com/onekey/nativelist/NativeListMessageRenderer.kt +25 -0
  11. package/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +33 -2
  12. package/android/src/main/java/com/onekey/nativelist/NativeListPrimitives.kt +22 -0
  13. package/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt +2 -0
  14. package/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt +2 -0
  15. package/android/src/main/java/com/onekey/nativelist/NativeListResolvedText.kt +3 -2
  16. package/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt +69 -0
  17. package/android/src/main/java/com/onekey/nativelist/NativeListTextView.kt +55 -1
  18. package/android/src/main/java/com/onekey/nativelist/NativeListView.kt +1912 -107
  19. package/android/src/test/java/com/margelo/nitro/nativelist/NativeListPressPolicyTest.kt +1 -0
  20. package/docs/ROW_TEMPLATES.md +60 -9
  21. package/docs/SPEC.md +450 -5
  22. package/docs/STYLE_SPEC.md +6 -0
  23. package/ios/HybridNativeList.swift +21 -2
  24. package/ios/NativeListAccessoryStack.swift +227 -39
  25. package/ios/NativeListActivityDetailsView.swift +18 -8
  26. package/ios/NativeListActivityRenderer.swift +8 -2
  27. package/ios/NativeListButtonGroupRenderer.swift +89 -0
  28. package/ios/NativeListDataRowRenderer.swift +155 -27
  29. package/ios/NativeListDesignAssets.swift +3 -0
  30. package/ios/NativeListIdentityRenderer.swift +166 -30
  31. package/ios/NativeListLeadingVisual.swift +49 -14
  32. package/ios/NativeListMessageRenderer.swift +34 -1
  33. package/ios/NativeListModels.swift +42 -2
  34. package/ios/NativeListRendererRegistry.swift +1 -0
  35. package/ios/NativeListResolvedText.swift +2 -1
  36. package/ios/NativeListRowHost.swift +14 -0
  37. package/ios/NativeListSectionHeaderRenderer.swift +88 -0
  38. package/ios/RNCNativeListView.swift +1013 -55
  39. package/ios/Resources/NativeListIcons.xcassets/onekey_crypto_coin_outline.imageset/Contents.json +15 -0
  40. package/ios/Resources/NativeListIcons.xcassets/onekey_crypto_coin_outline.imageset/icon.svg +4 -0
  41. package/ios/Resources/NativeListIcons.xcassets/onekey_gas_solid.imageset/Contents.json +15 -0
  42. package/ios/Resources/NativeListIcons.xcassets/onekey_gas_solid.imageset/icon.svg +3 -0
  43. package/ios/Resources/NativeListIcons.xcassets/onekey_refresh_ccw_outline.imageset/Contents.json +15 -0
  44. package/ios/Resources/NativeListIcons.xcassets/onekey_refresh_ccw_outline.imageset/icon.svg +3 -0
  45. package/ios/tests/NativeListActivityPresentationTests.swift +56 -0
  46. package/ios/tests/NativeListLifecycleTests.swift +2720 -3
  47. package/lib/module/NativeList.js +175 -28
  48. package/lib/module/NativeList.web.js +129 -30
  49. package/lib/module/index.js +1 -1
  50. package/lib/module/models.js +1 -0
  51. package/lib/module/selection.js +1 -1
  52. package/lib/module/validation.js +421 -8
  53. package/lib/module/web/NativeListWebEngine.js +507 -19
  54. package/lib/module/web/templates/ActivityRowRenderer.js +8 -7
  55. package/lib/module/web/templates/ButtonGroupRowRenderer.js +47 -0
  56. package/lib/module/web/templates/DataRowRenderer.js +40 -5
  57. package/lib/module/web/templates/IdentityRowRenderer.js +90 -4
  58. package/lib/module/web/templates/MessageRowRenderer.js +18 -2
  59. package/lib/module/web/templates/RowContainerStyle.js +1 -0
  60. package/lib/module/web/templates/RowRendererRegistry.js +3 -0
  61. package/lib/module/web/templates/SectionHeaderRowRenderer.js +36 -2
  62. package/lib/nitrogen/generated/android/c++/JHybridNativeListSpec.cpp +26 -0
  63. package/lib/nitrogen/generated/android/c++/JHybridNativeListSpec.hpp +4 -0
  64. package/lib/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.cpp +10 -0
  65. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/HybridNativeListSpec.kt +20 -0
  66. package/lib/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.hpp +14 -0
  67. package/lib/nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm +12 -0
  68. package/lib/nitrogen/generated/ios/swift/HybridNativeListSpec.swift +2 -0
  69. package/lib/nitrogen/generated/ios/swift/HybridNativeListSpec_cxx.swift +43 -0
  70. package/lib/nitrogen/generated/shared/c++/HybridNativeListSpec.cpp +4 -0
  71. package/lib/nitrogen/generated/shared/c++/HybridNativeListSpec.hpp +4 -0
  72. package/lib/nitrogen/generated/shared/c++/views/HybridNativeListComponent.cpp +4 -0
  73. package/lib/nitrogen/generated/shared/c++/views/HybridNativeListComponent.hpp +6 -0
  74. package/lib/nitrogen/generated/shared/json/NativeListConfig.json +2 -0
  75. package/lib/typescript/src/NativeList.d.ts +1 -1
  76. package/lib/typescript/src/NativeList.nitro.d.ts +2 -0
  77. package/lib/typescript/src/NativeList.types.d.ts +18 -0
  78. package/lib/typescript/src/index.d.ts +2 -2
  79. package/lib/typescript/src/models.d.ts +105 -9
  80. package/lib/typescript/src/validation.d.ts +12 -0
  81. package/lib/typescript/src/web/NativeListWebEngine.d.ts +32 -0
  82. package/lib/typescript/src/web/templates/ButtonGroupRowRenderer.d.ts +14 -0
  83. package/lib/typescript/src/web/templates/IdentityRowRenderer.d.ts +1 -1
  84. package/lib/typescript/src/web/templates/MessageRowRenderer.d.ts +9 -3
  85. package/nitrogen/generated/android/c++/JHybridNativeListSpec.cpp +26 -0
  86. package/nitrogen/generated/android/c++/JHybridNativeListSpec.hpp +4 -0
  87. package/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.cpp +10 -0
  88. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/HybridNativeListSpec.kt +20 -0
  89. package/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.hpp +14 -0
  90. package/nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm +12 -0
  91. package/nitrogen/generated/ios/swift/HybridNativeListSpec.swift +2 -0
  92. package/nitrogen/generated/ios/swift/HybridNativeListSpec_cxx.swift +43 -0
  93. package/nitrogen/generated/shared/c++/HybridNativeListSpec.cpp +4 -0
  94. package/nitrogen/generated/shared/c++/HybridNativeListSpec.hpp +4 -0
  95. package/nitrogen/generated/shared/c++/views/HybridNativeListComponent.cpp +4 -0
  96. package/nitrogen/generated/shared/c++/views/HybridNativeListComponent.hpp +6 -0
  97. package/nitrogen/generated/shared/json/NativeListConfig.json +2 -0
  98. package/package.json +3 -3
  99. package/react-native-native-list.podspec +1 -0
  100. package/src/NativeList.nitro.ts +2 -0
  101. package/src/NativeList.tsx +313 -44
  102. package/src/NativeList.types.ts +22 -0
  103. package/src/NativeList.web.tsx +204 -48
  104. package/src/index.ts +2 -0
  105. package/src/models.ts +117 -3
  106. package/src/selection.ts +1 -0
  107. package/src/validation.ts +852 -6
  108. package/src/web/NativeListWebEngine.ts +803 -20
  109. package/src/web/templates/ActivityRowRenderer.ts +14 -6
  110. package/src/web/templates/ButtonGroupRowRenderer.ts +58 -0
  111. package/src/web/templates/DataRowRenderer.ts +93 -20
  112. package/src/web/templates/IdentityRowRenderer.ts +150 -5
  113. package/src/web/templates/MessageRowRenderer.ts +23 -7
  114. package/src/web/templates/RowContainerStyle.ts +2 -0
  115. package/src/web/templates/RowRendererRegistry.ts +11 -1
  116. package/src/web/templates/SectionHeaderRowRenderer.ts +47 -4
package/src/validation.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { ROW_BOX_STYLE_KEYS_BY_TYPE } from './models';
2
2
  import type {
3
3
  ActivityRow,
4
+ ButtonGroupRow,
4
5
  IdentityRow,
5
6
  ImageSource,
6
7
  LeadingVisual,
@@ -13,6 +14,7 @@ import type {
13
14
  RowBoxStyle,
14
15
  RowModel,
15
16
  RowPatch,
17
+ RowTextHelp,
16
18
  WalletGroupRow,
17
19
  WebMarketTableCell,
18
20
  WebMarketTablePresentation,
@@ -220,12 +222,14 @@ const TEXT_STYLE_KEYS_BY_ROW_TYPE: Readonly<
220
222
  metricCard: ['title', 'value', 'subtitle', 'trend'],
221
223
  sectionHeader: ['title', 'subtitle', 'value'],
222
224
  action: ['title', 'value'],
225
+ buttonGroup: [],
223
226
  system: ['title', 'message', 'actionText'],
224
227
  };
225
228
 
226
229
  const EXTRA_STYLE_KEYS_BY_ROW_TYPE: Readonly<
227
230
  Partial<Record<RowModel['type'], readonly string[]>>
228
231
  > = {
232
+ identity: ['leadingContentMaxWidthRatio'],
229
233
  market: [
230
234
  'titleBadgeLayout',
231
235
  'contentTrailingGap',
@@ -234,6 +238,7 @@ const EXTRA_STYLE_KEYS_BY_ROW_TYPE: Readonly<
234
238
  'changeHeight',
235
239
  'changeCornerRadius',
236
240
  ],
241
+ sectionHeader: ['valuePlacement', 'titleDecoration'],
237
242
  };
238
243
 
239
244
  function assertTextStyle(
@@ -492,6 +497,33 @@ function assertRowStyle(row: RowModel, path: string): void {
492
497
  // Market keeps its own richer assertions, driven from assertMarketRow.
493
498
  if (row.type === 'market') return;
494
499
  assertBoxStyle(style as RowBoxStyle, `${path}.style`);
500
+ if (row.type === 'identity') {
501
+ const ratio = (style as IdentityRow['style'])?.leadingContentMaxWidthRatio;
502
+ if (
503
+ ratio !== undefined &&
504
+ (!Number.isFinite(ratio) || ratio <= 0 || ratio > 1)
505
+ )
506
+ fail(
507
+ `${path}.style.leadingContentMaxWidthRatio`,
508
+ 'must be within (0, 1]'
509
+ );
510
+ }
511
+ if (row.type === 'sectionHeader') {
512
+ const sectionStyle = style as Extract<
513
+ RowModel,
514
+ { type: 'sectionHeader' }
515
+ >['style'];
516
+ if (
517
+ sectionStyle?.valuePlacement !== undefined &&
518
+ sectionStyle.valuePlacement !== 'afterTitle'
519
+ )
520
+ fail(`${path}.style.valuePlacement`, 'must be afterTitle');
521
+ if (
522
+ sectionStyle?.titleDecoration !== undefined &&
523
+ !['none', 'dashed'].includes(sectionStyle.titleDecoration)
524
+ )
525
+ fail(`${path}.style.titleDecoration`, 'must be none or dashed');
526
+ }
495
527
  const slots = style as Record<string, NativeListTextStyle | undefined>;
496
528
  textKeys.forEach((key) =>
497
529
  assertTextStyle(slots[key], `${path}.style.${key}`)
@@ -1049,6 +1081,55 @@ function assertTrailingAccessories(
1049
1081
  ): void {
1050
1082
  accessories?.forEach((accessory, index) => {
1051
1083
  const accessoryPath = `${path}[${index}]`;
1084
+ if (accessory.kind === 'button') {
1085
+ assertUnknownKeys(
1086
+ accessory as Record<string, unknown>,
1087
+ [
1088
+ 'kind',
1089
+ 'label',
1090
+ 'actionKey',
1091
+ 'disabled',
1092
+ 'testID',
1093
+ 'accessibilityLabel',
1094
+ 'textColor',
1095
+ 'backgroundColor',
1096
+ 'pressedBackgroundColor',
1097
+ 'hoverBackgroundColor',
1098
+ 'focusRingColor',
1099
+ ],
1100
+ accessoryPath,
1101
+ 'button key'
1102
+ );
1103
+ if (typeof accessory.label !== 'string' || !accessory.label.trim())
1104
+ fail(`${accessoryPath}.label`, 'must be non-blank');
1105
+ assertKey(accessory.actionKey, `${accessoryPath}.actionKey`);
1106
+ if (
1107
+ accessory.disabled !== undefined &&
1108
+ typeof accessory.disabled !== 'boolean'
1109
+ )
1110
+ fail(`${accessoryPath}.disabled`, 'must be boolean');
1111
+ for (const field of [
1112
+ 'textColor',
1113
+ 'backgroundColor',
1114
+ 'pressedBackgroundColor',
1115
+ 'hoverBackgroundColor',
1116
+ 'focusRingColor',
1117
+ ] as const) {
1118
+ const value = accessory[field];
1119
+ if (
1120
+ value !== undefined &&
1121
+ (typeof value !== 'string' ||
1122
+ !/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(value))
1123
+ )
1124
+ fail(`${accessoryPath}.${field}`, 'must be #RRGGBB or #RRGGBBAA');
1125
+ }
1126
+ }
1127
+ if (
1128
+ accessory.kind === 'icon' &&
1129
+ accessory.buttonStyle !== undefined &&
1130
+ accessory.buttonStyle !== 'tertiary'
1131
+ )
1132
+ fail(`${accessoryPath}.buttonStyle`, 'must be tertiary');
1052
1133
  if (
1053
1134
  accessory.kind === 'progress' &&
1054
1135
  (accessory.value < 0 || accessory.value > 1)
@@ -1056,12 +1137,160 @@ function assertTrailingAccessories(
1056
1137
  fail(`${accessoryPath}.value`, 'must be within 0...1');
1057
1138
  }
1058
1139
  if (accessory.kind === 'valuePair') {
1140
+ if (
1141
+ accessory.primaryFontWeight !== undefined &&
1142
+ accessory.primaryFontWeight !== 'medium'
1143
+ )
1144
+ fail(
1145
+ `${accessoryPath}.primaryFontWeight`,
1146
+ 'must be medium when provided'
1147
+ );
1148
+ assertTextHelp(accessory.primaryHelp, `${accessoryPath}.primaryHelp`);
1059
1149
  assertTextTone(accessory.primaryTone, `${accessoryPath}.primaryTone`);
1060
1150
  assertTextTone(accessory.secondaryTone, `${accessoryPath}.secondaryTone`);
1151
+ for (const field of [
1152
+ 'primaryTextSegments',
1153
+ 'secondaryTextSegments',
1154
+ ] as const) {
1155
+ const segments = accessory[field];
1156
+ if (segments === undefined) continue;
1157
+ const segmentPath = `${accessoryPath}.${field}`;
1158
+ if (!Array.isArray(segments) || segments.length > 64)
1159
+ fail(segmentPath, 'must be an array of at most 64 segments');
1160
+ let textLength = 0;
1161
+ segments.forEach((segment, segmentIndex) => {
1162
+ const path = `${segmentPath}[${segmentIndex}]`;
1163
+ const entry = protocolObject(segment, path);
1164
+ assertUnknownKeys(entry, ['text', 'style'], path, 'segment key');
1165
+ if (
1166
+ typeof entry.text !== 'string' ||
1167
+ entry.text.length > MAX_TEXT_LENGTH
1168
+ )
1169
+ fail(
1170
+ `${path}.text`,
1171
+ `must be a string no longer than ${MAX_TEXT_LENGTH} characters`
1172
+ );
1173
+ if (entry.style !== undefined && entry.style !== 'subscript')
1174
+ fail(`${path}.style`, 'must be subscript when provided');
1175
+ textLength += entry.text.length;
1176
+ });
1177
+ if (textLength > MAX_TEXT_LENGTH)
1178
+ fail(
1179
+ segmentPath,
1180
+ `total text must be at most ${MAX_TEXT_LENGTH} characters`
1181
+ );
1182
+ }
1061
1183
  }
1062
1184
  });
1063
1185
  }
1064
1186
 
1187
+ function assertTextHelp(help: RowTextHelp | undefined, path: string): void {
1188
+ if (help === undefined) return;
1189
+ const object = protocolObject(help, path);
1190
+ assertUnknownKeys(
1191
+ object,
1192
+ ['actionKey', 'presentation', 'underline'],
1193
+ path,
1194
+ 'help key'
1195
+ );
1196
+ assertKey(help.actionKey, `${path}.actionKey`);
1197
+ if (!['tooltip', 'popover'].includes(help.presentation))
1198
+ fail(`${path}.presentation`, 'must be tooltip or popover');
1199
+ if (help.underline !== undefined && help.underline !== 'dashed')
1200
+ fail(`${path}.underline`, 'must be dashed');
1201
+ }
1202
+
1203
+ function assertButtonGroup(
1204
+ row: Pick<ButtonGroupRow, 'buttons'>,
1205
+ path: string
1206
+ ): void {
1207
+ if (!Array.isArray(row.buttons) || row.buttons.length === 0)
1208
+ fail(`${path}.buttons`, 'must contain at least one button');
1209
+ const keys = new Set<string>();
1210
+ row.buttons.forEach((button, index) => {
1211
+ const itemPath = `${path}.buttons[${index}]`;
1212
+ const object = protocolObject(button, itemPath);
1213
+ assertUnknownKeys(
1214
+ object,
1215
+ [
1216
+ 'key',
1217
+ 'label',
1218
+ 'actionKey',
1219
+ 'disabled',
1220
+ 'loading',
1221
+ 'testID',
1222
+ 'accessibilityLabel',
1223
+ ],
1224
+ itemPath,
1225
+ 'button key'
1226
+ );
1227
+ assertKey(button.key, `${itemPath}.key`);
1228
+ if (keys.has(button.key)) fail(`${itemPath}.key`, 'duplicate button key');
1229
+ keys.add(button.key);
1230
+ if (
1231
+ typeof button.label !== 'string' ||
1232
+ !button.label.trim() ||
1233
+ button.label.length > MAX_TEXT_LENGTH
1234
+ )
1235
+ fail(`${itemPath}.label`, 'must be non-blank bounded text');
1236
+ assertKey(button.actionKey, `${itemPath}.actionKey`);
1237
+ for (const field of ['disabled', 'loading'] as const) {
1238
+ if (button[field] !== undefined && typeof button[field] !== 'boolean')
1239
+ fail(`${itemPath}.${field}`, 'must be boolean');
1240
+ }
1241
+ for (const field of ['testID', 'accessibilityLabel'] as const) {
1242
+ if (button[field] !== undefined && typeof button[field] !== 'string')
1243
+ fail(`${itemPath}.${field}`, 'must be a string');
1244
+ }
1245
+ });
1246
+ }
1247
+
1248
+ function assertIdentityExtras(
1249
+ row: Pick<
1250
+ IdentityRow,
1251
+ 'titleIcon' | 'secondaryTitleIcon' | 'trailing' | 'style'
1252
+ >,
1253
+ path: string
1254
+ ): void {
1255
+ for (const field of ['titleIcon', 'secondaryTitleIcon'] as const) {
1256
+ const titleIcon = row[field];
1257
+ if (titleIcon === undefined) continue;
1258
+ const iconPath = `${path}.${field}`;
1259
+ const icon = protocolObject(titleIcon, iconPath);
1260
+ assertUnknownKeys(
1261
+ icon,
1262
+ ['name', 'tintColor', 'accessibilityLabel', 'hoverActionKey'],
1263
+ iconPath,
1264
+ 'title icon key'
1265
+ );
1266
+ if (typeof titleIcon.name !== 'string' || !titleIcon.name.trim())
1267
+ fail(`${iconPath}.name`, 'must be non-blank');
1268
+ if (
1269
+ titleIcon.tintColor !== undefined &&
1270
+ (typeof titleIcon.tintColor !== 'string' ||
1271
+ !/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(titleIcon.tintColor))
1272
+ )
1273
+ fail(`${iconPath}.tintColor`, 'must be #RRGGBB or #RRGGBBAA');
1274
+ if (
1275
+ titleIcon.accessibilityLabel !== undefined &&
1276
+ typeof titleIcon.accessibilityLabel !== 'string'
1277
+ )
1278
+ fail(`${iconPath}.accessibilityLabel`, 'must be a string');
1279
+ if (titleIcon.hoverActionKey !== undefined)
1280
+ assertKey(titleIcon.hoverActionKey, `${iconPath}.hoverActionKey`);
1281
+ }
1282
+ const ratio = row.style?.leadingContentMaxWidthRatio;
1283
+ if (
1284
+ ratio !== undefined &&
1285
+ (typeof ratio !== 'number' ||
1286
+ !Number.isFinite(ratio) ||
1287
+ ratio <= 0 ||
1288
+ ratio > 1)
1289
+ )
1290
+ fail(`${path}.style.leadingContentMaxWidthRatio`, 'must be within (0, 1]');
1291
+ assertTrailingAccessories(row.trailing, `${path}.trailing`);
1292
+ }
1293
+
1065
1294
  function assertImage(image: ImageSource | undefined, path: string): void {
1066
1295
  if (!image) return;
1067
1296
  if (!image.uri.trim()) fail(`${path}.uri`, 'must not be empty');
@@ -1132,6 +1361,24 @@ function assertLeadingVisual(
1132
1361
  assertText(visual.cornerIcon?.name, `${path}.cornerIcon.name`);
1133
1362
  // OneKey patch: validate optional selector overlays at the JSON boundary.
1134
1363
  assertText(visual.fallbackIcon?.name, `${path}.fallbackIcon.name`);
1364
+ if (
1365
+ visual.fallbackTextColor !== undefined &&
1366
+ (typeof visual.fallbackTextColor !== 'string' ||
1367
+ !/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(visual.fallbackTextColor))
1368
+ )
1369
+ fail(`${path}.fallbackTextColor`, 'must be a hex color');
1370
+ if (
1371
+ visual.fallbackIcon?.size !== undefined &&
1372
+ (!Number.isFinite(visual.fallbackIcon.size) ||
1373
+ visual.fallbackIcon.size <= 0 ||
1374
+ visual.fallbackIcon.size > 40)
1375
+ )
1376
+ fail(`${path}.fallbackIcon.size`, 'must be within (0, 40]');
1377
+ if (
1378
+ visual.fallbackIcon?.backgroundColor !== undefined &&
1379
+ !/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(visual.fallbackIcon.backgroundColor)
1380
+ )
1381
+ fail(`${path}.fallbackIcon.backgroundColor`, 'must be a hex color');
1135
1382
  if ((visual.overlays?.length ?? 0) > 2)
1136
1383
  fail(`${path}.overlays`, 'supports at most two overlays');
1137
1384
  visual.overlays?.forEach((overlay, index) => {
@@ -1191,7 +1438,34 @@ function assertLeadingVisual(
1191
1438
  'must fit inside the overlay'
1192
1439
  );
1193
1440
  assertImage(overlay.image, `${path}.overlays[${index}].image`);
1441
+ if (overlay.fallbackIcon !== undefined && !overlay.image)
1442
+ fail(`${path}.overlays[${index}].fallbackIcon`, 'requires image');
1443
+ if (
1444
+ overlay.fallbackIcon !== undefined &&
1445
+ (typeof overlay.fallbackIcon.name !== 'string' ||
1446
+ !overlay.fallbackIcon.name.trim())
1447
+ )
1448
+ fail(
1449
+ `${path}.overlays[${index}].fallbackIcon.name`,
1450
+ 'must be non-blank'
1451
+ );
1452
+ if (
1453
+ overlay.fallbackIcon?.tintColor !== undefined &&
1454
+ !/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(overlay.fallbackIcon.tintColor)
1455
+ )
1456
+ fail(
1457
+ `${path}.overlays[${index}].fallbackIcon.tintColor`,
1458
+ 'must be a hex color'
1459
+ );
1194
1460
  assertText(overlay.text, `${path}.overlays[${index}].text`);
1461
+ if (
1462
+ overlay.fontWeight !== undefined &&
1463
+ !['regular', 'medium', 'semibold', 'bold'].includes(overlay.fontWeight)
1464
+ )
1465
+ fail(
1466
+ `${path}.overlays[${index}].fontWeight`,
1467
+ 'must be regular, medium, semibold, or bold'
1468
+ );
1195
1469
  });
1196
1470
  if (visual.kind === 'token') {
1197
1471
  assertImage(visual.networkImage, `${path}.networkImage`);
@@ -1223,6 +1497,8 @@ function assertVisual(row: RowModel, path: string): void {
1223
1497
  }
1224
1498
 
1225
1499
  function assertActivity(row: Partial<ActivityRow>, path: string): void {
1500
+ if (row.hideLeading !== undefined && typeof row.hideLeading !== 'boolean')
1501
+ fail(`${path}.hideLeading`, 'must be boolean');
1226
1502
  assertText(row.title, `${path}.title`);
1227
1503
  assertText(row.description, `${path}.description`);
1228
1504
  if (
@@ -1387,6 +1663,18 @@ function assertRow(
1387
1663
  assertWalletGroup(row, path);
1388
1664
  break;
1389
1665
  case 'identity':
1666
+ if (row.contentHeight !== undefined && row.contentHeight !== 'auto') {
1667
+ fail(`${path}.contentHeight`, 'must be auto');
1668
+ }
1669
+ if (
1670
+ row.subtitleLines !== undefined &&
1671
+ ![0, 1, 2].includes(row.subtitleLines)
1672
+ ) {
1673
+ fail(`${path}.subtitleLines`, 'must be 0, 1, or 2');
1674
+ }
1675
+ if (row.longPressActionKey !== undefined) {
1676
+ assertKey(row.longPressActionKey, `${path}.longPressActionKey`);
1677
+ }
1390
1678
  if (
1391
1679
  row.presentation !== undefined &&
1392
1680
  row.presentation !== 'walletSidebar' &&
@@ -1399,6 +1687,27 @@ function assertRow(
1399
1687
  );
1400
1688
  }
1401
1689
  assertText(row.title, `${path}.title`);
1690
+ if ((row.titleSuffixes?.length ?? 0) > 3)
1691
+ fail(`${path}.titleSuffixes`, 'supports at most 3 suffixes');
1692
+ row.titleSuffixes?.forEach((suffix, index) => {
1693
+ assertText(suffix.text, `${path}.titleSuffixes[${index}].text`);
1694
+ if (!['secondary', 'disabled'].includes(suffix.tone))
1695
+ fail(
1696
+ `${path}.titleSuffixes[${index}].tone`,
1697
+ 'must be secondary or disabled'
1698
+ );
1699
+ if (
1700
+ suffix.maxWidth !== undefined &&
1701
+ (!Number.isFinite(suffix.maxWidth) ||
1702
+ suffix.maxWidth < 0 ||
1703
+ suffix.maxWidth > 4096)
1704
+ )
1705
+ fail(
1706
+ `${path}.titleSuffixes[${index}].maxWidth`,
1707
+ 'must be finite within 0...4096'
1708
+ );
1709
+ });
1710
+ assertIdentityExtras(row, path);
1402
1711
  assertText(row.subtitle, `${path}.subtitle`);
1403
1712
  // OneKey patch: selector text segments truncate independently.
1404
1713
  row.subtitleSegments?.forEach((segment, segmentIndex) => {
@@ -1466,10 +1775,16 @@ function assertRow(
1466
1775
  assertActivity(row, path);
1467
1776
  break;
1468
1777
  case 'message':
1778
+ if (
1779
+ row.presentation !== undefined &&
1780
+ row.presentation !== 'inlineWarning'
1781
+ ) {
1782
+ fail(`${path}.presentation`, 'must be inlineWarning');
1783
+ }
1469
1784
  assertText(row.title, `${path}.title`);
1470
1785
  assertText(row.body, `${path}.body`);
1471
- if (![1, 2, 3].includes(row.bodyLines ?? 3)) {
1472
- fail(`${path}.bodyLines`, 'must be 1, 2, or 3');
1786
+ if (![0, 1, 2, 3].includes(row.bodyLines ?? 3)) {
1787
+ fail(`${path}.bodyLines`, 'must be 0, 1, 2, or 3');
1473
1788
  }
1474
1789
  assertImage(row.thumbnail, `${path}.thumbnail`);
1475
1790
  break;
@@ -1480,6 +1795,23 @@ function assertRow(
1480
1795
  row.columns.forEach((column, columnIndex) => {
1481
1796
  assertKey(column.key, `${path}.columns[${columnIndex}].key`);
1482
1797
  assertText(column.text, `${path}.columns[${columnIndex}].text`);
1798
+ assertIdentityExtras(column, `${path}.columns[${columnIndex}]`);
1799
+ for (const field of [
1800
+ 'textSegments',
1801
+ 'secondaryTextSegments',
1802
+ ] as const) {
1803
+ column[field]?.forEach((segment, segmentIndex) => {
1804
+ assertText(
1805
+ segment.text,
1806
+ `${path}.columns[${columnIndex}].${field}[${segmentIndex}].text`
1807
+ );
1808
+ if (segment.style !== undefined && segment.style !== 'subscript')
1809
+ fail(
1810
+ `${path}.columns[${columnIndex}].${field}[${segmentIndex}].style`,
1811
+ 'must be subscript'
1812
+ );
1813
+ });
1814
+ }
1483
1815
  assertText(
1484
1816
  column.secondaryLeadingText,
1485
1817
  `${path}.columns[${columnIndex}].secondaryLeadingText`
@@ -1496,6 +1828,13 @@ function assertRow(
1496
1828
  if ((row.badges?.length ?? 0) > MAX_BADGES) {
1497
1829
  fail(`${path}.badges`, `supports at most ${MAX_BADGES} badges`);
1498
1830
  }
1831
+ if ((row.trailing?.length ?? 0) > MAX_TRAILING_ACCESSORIES) {
1832
+ fail(
1833
+ `${path}.trailing`,
1834
+ `supports at most ${MAX_TRAILING_ACCESSORIES} accessories`
1835
+ );
1836
+ }
1837
+ assertTrailingAccessories(row.trailing, `${path}.trailing`);
1499
1838
  break;
1500
1839
  case 'market':
1501
1840
  assertMarketRow(row, path);
@@ -1572,6 +1911,11 @@ function assertRow(
1572
1911
  assertText(row.title, `${path}.title`);
1573
1912
  assertText(row.subtitle, `${path}.subtitle`);
1574
1913
  assertText(row.value, `${path}.value`);
1914
+ if (row.leadingBadge) {
1915
+ assertKey(row.leadingBadge.key, `${path}.leadingBadge.key`);
1916
+ assertText(row.leadingBadge.text, `${path}.leadingBadge.text`);
1917
+ }
1918
+ assertTextHelp(row.valueHelp, `${path}.valueHelp`);
1575
1919
  if (row.valueActionKey !== undefined) {
1576
1920
  assertKey(row.valueActionKey, `${path}.valueActionKey`);
1577
1921
  }
@@ -1604,6 +1948,9 @@ function assertRow(
1604
1948
  }
1605
1949
  assertTrailingAccessories(row.trailing, `${path}.trailing`);
1606
1950
  break;
1951
+ case 'buttonGroup':
1952
+ assertButtonGroup(row, path);
1953
+ break;
1607
1954
  case 'system':
1608
1955
  const presentation = 'presentation' in row ? row.presentation : undefined;
1609
1956
  if (presentation !== undefined && presentation !== 'market') {
@@ -1712,6 +2059,36 @@ function assertMediaPreview(value: unknown, path: string) {
1712
2059
  function assertSnapshotContract(
1713
2060
  snapshot: NativeListSnapshot
1714
2061
  ): NativeListSnapshot {
2062
+ if (snapshot.disclosure !== undefined) {
2063
+ if (typeof snapshot.disclosure.expanded !== 'boolean')
2064
+ fail('snapshot.disclosure.expanded', 'must be a boolean');
2065
+ const inset = snapshot.disclosure.expandedTrailingInset;
2066
+ if (inset !== undefined && (!Number.isFinite(inset) || inset < 0))
2067
+ fail(
2068
+ 'snapshot.disclosure.expandedTrailingInset',
2069
+ 'must be finite and nonnegative'
2070
+ );
2071
+ }
2072
+ if (snapshot.structuralTransition) {
2073
+ assertKey(
2074
+ snapshot.structuralTransition.id,
2075
+ 'snapshot.structuralTransition.id'
2076
+ );
2077
+ if (snapshot.structuralTransition.anchorKey !== undefined)
2078
+ assertKey(
2079
+ snapshot.structuralTransition.anchorKey,
2080
+ 'snapshot.structuralTransition.anchorKey'
2081
+ );
2082
+ const duration = snapshot.structuralTransition.durationMs;
2083
+ if (
2084
+ duration !== undefined &&
2085
+ (!Number.isFinite(duration) || duration < 0 || duration > 2000)
2086
+ )
2087
+ fail(
2088
+ 'snapshot.structuralTransition.durationMs',
2089
+ 'must be finite within 0...2000'
2090
+ );
2091
+ }
1715
2092
  if (
1716
2093
  snapshot.capabilities?.refreshTriggerDistance !== undefined &&
1717
2094
  (!Number.isFinite(snapshot.capabilities.refreshTriggerDistance) ||
@@ -1727,6 +2104,18 @@ function assertSnapshotContract(
1727
2104
  snapshot.theme?.imageFallbackBackground,
1728
2105
  'theme.imageFallbackBackground'
1729
2106
  );
2107
+ if (
2108
+ snapshot.theme?.successBackground !== undefined &&
2109
+ (typeof snapshot.theme.successBackground !== 'string' ||
2110
+ !/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(snapshot.theme.successBackground))
2111
+ )
2112
+ fail('theme.successBackground', 'must be a hex color');
2113
+ if (
2114
+ snapshot.theme?.rowHoverBackground !== undefined &&
2115
+ (typeof snapshot.theme.rowHoverBackground !== 'string' ||
2116
+ !/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(snapshot.theme.rowHoverBackground))
2117
+ )
2118
+ fail('theme.rowHoverBackground', 'must be a hex color');
1730
2119
  if (snapshot.schemaVersion !== 1) fail('snapshot.schemaVersion', 'must be 1');
1731
2120
  if (!Number.isSafeInteger(snapshot.generation) || snapshot.generation < 0) {
1732
2121
  fail('snapshot.generation', 'must be a non-negative safe integer');
@@ -1874,6 +2263,7 @@ function protocolKey(value: unknown, path: string): void {
1874
2263
 
1875
2264
  const PROTOCOL_ARRAY_FIELDS = new Set([
1876
2265
  'columns',
2266
+ 'titleSuffixes',
1877
2267
  'cells',
1878
2268
  'points',
1879
2269
  'images',
@@ -1881,6 +2271,7 @@ const PROTOCOL_ARRAY_FIELDS = new Set([
1881
2271
  'badges',
1882
2272
  'trailing',
1883
2273
  'footerActions',
2274
+ 'buttons',
1884
2275
  'amounts',
1885
2276
  'metrics',
1886
2277
  'children',
@@ -1899,6 +2290,7 @@ const PROTOCOL_ARRAY_FIELDS = new Set([
1899
2290
  'primaryTextSegments',
1900
2291
  ]);
1901
2292
  const PROTOCOL_NUMBER_FIELDS = new Set([
2293
+ 'leadingContentMaxWidthRatio',
1902
2294
  'height',
1903
2295
  'width',
1904
2296
  'fontSize',
@@ -1961,6 +2353,32 @@ function assertProtocolFields(
1961
2353
  return;
1962
2354
  }
1963
2355
  const object = value as Record<string, unknown>;
2356
+ if (parentField === 'fallbackIcon') {
2357
+ protocolKey(object.name, `${path}.name`);
2358
+ if (
2359
+ object.size !== undefined &&
2360
+ (typeof object.size !== 'number' ||
2361
+ !Number.isFinite(object.size) ||
2362
+ object.size <= 0 ||
2363
+ object.size > 40)
2364
+ )
2365
+ fail(`${path}.size`, 'must be within (0, 40]');
2366
+ for (const field of ['tintColor', 'backgroundColor'] as const) {
2367
+ const color = object[field];
2368
+ if (
2369
+ color !== undefined &&
2370
+ (typeof color !== 'string' ||
2371
+ !/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(color))
2372
+ )
2373
+ fail(`${path}.${field}`, 'must be a hex color');
2374
+ }
2375
+ }
2376
+ if (
2377
+ parentField === 'overlays' &&
2378
+ object.fallbackIcon !== undefined &&
2379
+ !object.image
2380
+ )
2381
+ fail(`${path}.fallbackIcon`, 'requires image');
1964
2382
  if (
1965
2383
  !path.includes('.style.') &&
1966
2384
  ['image', 'networkImage', 'thumbnail', 'images', 'hoverImages'].includes(
@@ -1987,6 +2405,16 @@ function assertProtocolFields(
1987
2405
  !['offsetX', 'offsetY'].includes(field)
1988
2406
  )
1989
2407
  fail(fieldPath, 'must be non-negative');
2408
+ if (
2409
+ parentField === 'overlays' &&
2410
+ field === 'fontWeight' &&
2411
+ !['regular', 'medium', 'semibold', 'bold'].includes(item as string)
2412
+ )
2413
+ fail(fieldPath, 'must be regular, medium, semibold, or bold');
2414
+ if (field === 'buttonStyle' && item !== 'tertiary')
2415
+ fail(fieldPath, 'must be tertiary');
2416
+ if (field === 'hideLeading' && typeof item !== 'boolean')
2417
+ fail(fieldPath, 'must be boolean');
1990
2418
  if (
1991
2419
  field === 'key' ||
1992
2420
  field === 'sectionKey' ||
@@ -2067,6 +2495,27 @@ function assertRowProtocol(row: RowModel, path: string): void {
2067
2495
  if (row.height !== undefined && row.height < 0)
2068
2496
  fail(`${path}.height`, 'must be non-negative');
2069
2497
  if (row.type === 'identity') {
2498
+ assertIdentityExtras(row, path);
2499
+ if (row.titleSuffixes !== undefined && !Array.isArray(row.titleSuffixes))
2500
+ fail(`${path}.titleSuffixes`, 'must be an array');
2501
+ row.titleSuffixes?.forEach((suffix, index) => {
2502
+ protocolObject(suffix, `${path}.titleSuffixes[${index}]`);
2503
+ if (
2504
+ typeof suffix.text !== 'string' ||
2505
+ !['secondary', 'disabled'].includes(suffix.tone)
2506
+ )
2507
+ fail(`${path}.titleSuffixes[${index}]`, 'invalid title suffix');
2508
+ if (
2509
+ suffix.maxWidth !== undefined &&
2510
+ (!Number.isFinite(suffix.maxWidth) ||
2511
+ suffix.maxWidth < 0 ||
2512
+ suffix.maxWidth > 4096)
2513
+ )
2514
+ fail(
2515
+ `${path}.titleSuffixes[${index}].maxWidth`,
2516
+ 'must be finite within 0...4096'
2517
+ );
2518
+ });
2070
2519
  let end = 0;
2071
2520
  row.titleMatch?.forEach((match) => {
2072
2521
  if (
@@ -2080,6 +2529,9 @@ function assertRowProtocol(row: RowModel, path: string): void {
2080
2529
  end = match.end;
2081
2530
  });
2082
2531
  }
2532
+ if (row.type === 'buttonGroup') assertButtonGroup(row, path);
2533
+ if (row.type === 'sectionHeader')
2534
+ assertTextHelp(row.valueHelp, `${path}.valueHelp`);
2083
2535
  if (row.type === 'mediaTile' && row.media) {
2084
2536
  protocolObject(row.media.source, `${path}.media.source`);
2085
2537
  if (!Array.isArray(row.media.probeOrder))
@@ -2100,8 +2552,16 @@ function assertRowProtocol(row: RowModel, path: string): void {
2100
2552
  keys.add(member.key);
2101
2553
  });
2102
2554
  }
2103
- if (row.type === 'dataRow' && !Array.isArray(row.columns))
2104
- fail(`${path}.columns`, 'must be an array');
2555
+ if (row.type === 'dataRow') {
2556
+ if (!Array.isArray(row.columns))
2557
+ fail(`${path}.columns`, 'must be an array');
2558
+ row.columns.forEach((column, index) => {
2559
+ const columnPath = `${path}.columns[${index}]`;
2560
+ protocolObject(column, columnPath);
2561
+ assertIdentityExtras(column, columnPath);
2562
+ });
2563
+ assertTrailingAccessories(row.trailing, `${path}.trailing`);
2564
+ }
2105
2565
  if (row.type === 'market') {
2106
2566
  protocolObject(row.change, `${path}.change`);
2107
2567
  if (row.webTable) {
@@ -2150,6 +2610,53 @@ function assertRowProtocol(row: RowModel, path: string): void {
2150
2610
 
2151
2611
  function assertSnapshotProtocol(snapshot: NativeListSnapshot): void {
2152
2612
  protocolObject(snapshot, 'snapshot');
2613
+ if (snapshot.disclosure !== undefined) {
2614
+ protocolObject(snapshot.disclosure, 'snapshot.disclosure');
2615
+ if (typeof snapshot.disclosure.expanded !== 'boolean')
2616
+ fail('snapshot.disclosure.expanded', 'must be a boolean');
2617
+ const inset = snapshot.disclosure.expandedTrailingInset;
2618
+ if (inset !== undefined && (!Number.isFinite(inset) || inset < 0))
2619
+ fail(
2620
+ 'snapshot.disclosure.expandedTrailingInset',
2621
+ 'must be finite and nonnegative'
2622
+ );
2623
+ }
2624
+ if (snapshot.structuralTransition) {
2625
+ protocolObject(
2626
+ snapshot.structuralTransition,
2627
+ 'snapshot.structuralTransition'
2628
+ );
2629
+ protocolKey(
2630
+ snapshot.structuralTransition.id,
2631
+ 'snapshot.structuralTransition.id'
2632
+ );
2633
+ if (snapshot.structuralTransition.anchorKey !== undefined)
2634
+ protocolKey(
2635
+ snapshot.structuralTransition.anchorKey,
2636
+ 'snapshot.structuralTransition.anchorKey'
2637
+ );
2638
+ const duration = snapshot.structuralTransition.durationMs;
2639
+ if (
2640
+ duration !== undefined &&
2641
+ (!Number.isFinite(duration) || duration < 0 || duration > 2000)
2642
+ )
2643
+ fail(
2644
+ 'snapshot.structuralTransition.durationMs',
2645
+ 'must be finite within 0...2000'
2646
+ );
2647
+ }
2648
+ if (
2649
+ snapshot.theme?.successBackground !== undefined &&
2650
+ (typeof snapshot.theme.successBackground !== 'string' ||
2651
+ !/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(snapshot.theme.successBackground))
2652
+ )
2653
+ fail('theme.successBackground', 'must be a hex color');
2654
+ if (
2655
+ snapshot.theme?.rowHoverBackground !== undefined &&
2656
+ (typeof snapshot.theme.rowHoverBackground !== 'string' ||
2657
+ !/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(snapshot.theme.rowHoverBackground))
2658
+ )
2659
+ fail('theme.rowHoverBackground', 'must be a hex color');
2153
2660
  if (snapshot.schemaVersion !== 1) fail('snapshot.schemaVersion', 'must be 1');
2154
2661
  if (!Number.isSafeInteger(snapshot.generation) || snapshot.generation < 0)
2155
2662
  fail('snapshot.generation', 'must be a non-negative safe integer');
@@ -2238,6 +2745,73 @@ export function validateSnapshot(
2238
2745
  return normalizeSnapshotStyles(normalized);
2239
2746
  }
2240
2747
 
2748
+ const ownedNativeSnapshots = new WeakMap<
2749
+ NativeListSnapshot,
2750
+ ReadonlyMap<string, number>
2751
+ >();
2752
+
2753
+ function capturePlainData<T>(value: T, seen = new Map<object, object>()): T {
2754
+ if (value === null || typeof value !== 'object') return value;
2755
+ const previous = seen.get(value);
2756
+ if (previous) return previous as T;
2757
+ const copy: Record<string, unknown> | unknown[] = Array.isArray(value)
2758
+ ? new Array(value.length)
2759
+ : Object.create(Object.getPrototypeOf(value));
2760
+ seen.set(value, copy);
2761
+ const captureKey = (key: string) => {
2762
+ Object.defineProperty(copy, key, {
2763
+ value: capturePlainData((value as Record<string, unknown>)[key], seen),
2764
+ enumerable: true,
2765
+ writable: true,
2766
+ configurable: true,
2767
+ });
2768
+ };
2769
+ if (Array.isArray(value)) {
2770
+ for (let index = 0; index < value.length; index += 1) {
2771
+ if (index in value) captureKey(String(index));
2772
+ }
2773
+ Object.keys(value).forEach((key) => {
2774
+ const index = Number(key);
2775
+ if (
2776
+ Number.isInteger(index) &&
2777
+ index >= 0 &&
2778
+ index < value.length &&
2779
+ String(index) === key
2780
+ )
2781
+ return;
2782
+ captureKey(key);
2783
+ });
2784
+ } else {
2785
+ Object.keys(value).forEach(captureKey);
2786
+ }
2787
+ return copy as T;
2788
+ }
2789
+
2790
+ function deepFreeze<T>(value: T, seen = new WeakSet<object>()): T {
2791
+ if (value === null || typeof value !== 'object' || seen.has(value))
2792
+ return value;
2793
+ seen.add(value);
2794
+ Object.values(value).forEach((item) => deepFreeze(item, seen));
2795
+ return Object.freeze(value);
2796
+ }
2797
+
2798
+ /** Capture caller data once before validation, including accessor values. */
2799
+ export function captureNativeListInput<T>(value: T): T {
2800
+ return capturePlainData(value);
2801
+ }
2802
+
2803
+ /** Private native-wrapper ownership; exported validators keep their public identity. */
2804
+ export function ownValidatedNativeSnapshot(
2805
+ snapshot: NativeListSnapshot
2806
+ ): NativeListSnapshot {
2807
+ const owned = deepFreeze(snapshot);
2808
+ ownedNativeSnapshots.set(
2809
+ owned,
2810
+ new Map(owned.rows.map((row, index) => [row.key, index]))
2811
+ );
2812
+ return owned;
2813
+ }
2814
+
2241
2815
  /**
2242
2816
  * Resolves typography tokens to numbers. Returns the original snapshot when
2243
2817
  * nothing needs resolving, so the common no-style path keeps object identity.
@@ -2294,7 +2868,46 @@ function assertPatchChanges(patch: RowPatch, index: number): void {
2294
2868
 
2295
2869
  switch (patch.type) {
2296
2870
  case 'identity':
2871
+ if (
2872
+ patch.changes.contentHeight !== undefined &&
2873
+ patch.changes.contentHeight !== 'auto'
2874
+ ) {
2875
+ fail(`${path}.contentHeight`, 'must be auto');
2876
+ }
2877
+ if (
2878
+ patch.changes.subtitleLines !== undefined &&
2879
+ ![0, 1, 2].includes(patch.changes.subtitleLines)
2880
+ ) {
2881
+ fail(`${path}.subtitleLines`, 'must be 0, 1, or 2');
2882
+ }
2883
+ if (patch.changes.longPressActionKey !== undefined) {
2884
+ assertKey(
2885
+ patch.changes.longPressActionKey,
2886
+ `${path}.longPressActionKey`
2887
+ );
2888
+ }
2297
2889
  assertText(patch.changes.title, `${path}.title`);
2890
+ if ((patch.changes.titleSuffixes?.length ?? 0) > 3)
2891
+ fail(`${path}.titleSuffixes`, 'supports at most 3 suffixes');
2892
+ patch.changes.titleSuffixes?.forEach((suffix, index) => {
2893
+ assertText(suffix.text, `${path}.titleSuffixes[${index}].text`);
2894
+ if (!['secondary', 'disabled'].includes(suffix.tone))
2895
+ fail(
2896
+ `${path}.titleSuffixes[${index}].tone`,
2897
+ 'must be secondary or disabled'
2898
+ );
2899
+ if (
2900
+ suffix.maxWidth !== undefined &&
2901
+ (!Number.isFinite(suffix.maxWidth) ||
2902
+ suffix.maxWidth < 0 ||
2903
+ suffix.maxWidth > 4096)
2904
+ )
2905
+ fail(
2906
+ `${path}.titleSuffixes[${index}].maxWidth`,
2907
+ 'must be finite within 0...4096'
2908
+ );
2909
+ });
2910
+ assertIdentityExtras(patch.changes, path);
2298
2911
  assertText(patch.changes.subtitle, `${path}.subtitle`);
2299
2912
  assertText(patch.changes.tertiary, `${path}.tertiary`);
2300
2913
  if (
@@ -2343,14 +2956,20 @@ function assertPatchChanges(patch: RowPatch, index: number): void {
2343
2956
  }
2344
2957
  break;
2345
2958
  case 'message':
2959
+ if (
2960
+ patch.changes.presentation !== undefined &&
2961
+ patch.changes.presentation !== 'inlineWarning'
2962
+ ) {
2963
+ fail(`${path}.presentation`, 'must be inlineWarning');
2964
+ }
2346
2965
  assertText(patch.changes.title, `${path}.title`);
2347
2966
  assertText(patch.changes.body, `${path}.body`);
2348
2967
  assertText(patch.changes.time, `${path}.time`);
2349
2968
  if (
2350
2969
  patch.changes.bodyLines !== undefined &&
2351
- ![1, 2, 3].includes(patch.changes.bodyLines)
2970
+ ![0, 1, 2, 3].includes(patch.changes.bodyLines)
2352
2971
  ) {
2353
- fail(`${path}.bodyLines`, 'must be 1, 2, or 3');
2972
+ fail(`${path}.bodyLines`, 'must be 0, 1, 2, or 3');
2354
2973
  }
2355
2974
  assertImage(patch.changes.thumbnail, `${path}.thumbnail`);
2356
2975
  break;
@@ -2364,6 +2983,23 @@ function assertPatchChanges(patch: RowPatch, index: number): void {
2364
2983
  patch.changes.columns?.forEach((column, columnIndex) => {
2365
2984
  assertKey(column.key, `${path}.columns[${columnIndex}].key`);
2366
2985
  assertText(column.text, `${path}.columns[${columnIndex}].text`);
2986
+ assertIdentityExtras(column, `${path}.columns[${columnIndex}]`);
2987
+ for (const field of [
2988
+ 'textSegments',
2989
+ 'secondaryTextSegments',
2990
+ ] as const) {
2991
+ column[field]?.forEach((segment, segmentIndex) => {
2992
+ assertText(
2993
+ segment.text,
2994
+ `${path}.columns[${columnIndex}].${field}[${segmentIndex}].text`
2995
+ );
2996
+ if (segment.style !== undefined && segment.style !== 'subscript')
2997
+ fail(
2998
+ `${path}.columns[${columnIndex}].${field}[${segmentIndex}].style`,
2999
+ 'must be subscript'
3000
+ );
3001
+ });
3002
+ }
2367
3003
  assertText(
2368
3004
  column.secondaryText,
2369
3005
  `${path}.columns[${columnIndex}].secondaryText`
@@ -2376,6 +3012,13 @@ function assertPatchChanges(patch: RowPatch, index: number): void {
2376
3012
  if ((patch.changes.badges?.length ?? 0) > MAX_BADGES) {
2377
3013
  fail(`${path}.badges`, `supports at most ${MAX_BADGES} badges`);
2378
3014
  }
3015
+ if ((patch.changes.trailing?.length ?? 0) > MAX_TRAILING_ACCESSORIES) {
3016
+ fail(
3017
+ `${path}.trailing`,
3018
+ `supports at most ${MAX_TRAILING_ACCESSORIES} accessories`
3019
+ );
3020
+ }
3021
+ assertTrailingAccessories(patch.changes.trailing, `${path}.trailing`);
2379
3022
  break;
2380
3023
  case 'market':
2381
3024
  assertMarketRow(
@@ -2450,6 +3093,14 @@ function assertPatchChanges(patch: RowPatch, index: number): void {
2450
3093
  assertText(patch.changes.title, `${path}.title`);
2451
3094
  assertText(patch.changes.subtitle, `${path}.subtitle`);
2452
3095
  assertText(patch.changes.value, `${path}.value`);
3096
+ if (patch.changes.leadingBadge) {
3097
+ assertKey(patch.changes.leadingBadge.key, `${path}.leadingBadge.key`);
3098
+ assertText(
3099
+ patch.changes.leadingBadge.text,
3100
+ `${path}.leadingBadge.text`
3101
+ );
3102
+ }
3103
+ assertTextHelp(patch.changes.valueHelp, `${path}.valueHelp`);
2453
3104
  if (patch.changes.valueActionKey !== undefined) {
2454
3105
  assertKey(patch.changes.valueActionKey, `${path}.valueActionKey`);
2455
3106
  }
@@ -2472,6 +3123,10 @@ function assertPatchChanges(patch: RowPatch, index: number): void {
2472
3123
  }
2473
3124
  assertTrailingAccessories(patch.changes.trailing, `${path}.trailing`);
2474
3125
  break;
3126
+ case 'buttonGroup':
3127
+ if (patch.changes.buttons !== undefined)
3128
+ assertButtonGroup({ buttons: patch.changes.buttons }, path);
3129
+ break;
2475
3130
  case 'system':
2476
3131
  assertText(patch.changes.message, `${path}.message`);
2477
3132
  break;
@@ -2495,6 +3150,8 @@ export function validatePatches(
2495
3150
  protocolObject(patch.changes, `patches[${index}].changes`);
2496
3151
  assertRowType(patch.type, `patches[${index}]`);
2497
3152
  assertProtocolFields(patch.changes, `patches[${index}].changes`);
3153
+ if (patch.type === 'identity')
3154
+ assertIdentityExtras(patch.changes, `patches[${index}].changes`);
2498
3155
  if (!isDevelopment()) continue;
2499
3156
  assertKey(patch.key, `patches[${index}].key`);
2500
3157
  if (Object.keys(patch.changes).length === 0) {
@@ -2529,6 +3186,116 @@ function normalizePatchStyles(
2529
3186
  return changed ? next : patches;
2530
3187
  }
2531
3188
 
3189
+ const EMPTY_ROW_PATCHES: readonly RowPatch[] = Object.freeze([]);
3190
+
3191
+ function sameJsonValue(left: unknown, right: unknown): boolean {
3192
+ if (left === right) return true;
3193
+ if (left === null || right === null) return false;
3194
+ if (typeof left !== 'object' || typeof right !== 'object') return false;
3195
+ if (Array.isArray(left) || Array.isArray(right)) {
3196
+ return (
3197
+ Array.isArray(left) &&
3198
+ Array.isArray(right) &&
3199
+ left.length === right.length &&
3200
+ left.every((value, index) => sameJsonValue(value, right[index]))
3201
+ );
3202
+ }
3203
+ const leftObject = left as Record<string, unknown>;
3204
+ const rightObject = right as Record<string, unknown>;
3205
+ const leftKeys = Object.keys(leftObject).filter(
3206
+ (key) => leftObject[key] !== undefined
3207
+ );
3208
+ const rightKeys = Object.keys(rightObject).filter(
3209
+ (key) => rightObject[key] !== undefined
3210
+ );
3211
+ return (
3212
+ leftKeys.length === rightKeys.length &&
3213
+ leftKeys.every((key) => sameJsonValue(leftObject[key], rightObject[key]))
3214
+ );
3215
+ }
3216
+
3217
+ function sameOptionalPresence(left: unknown, right: unknown): boolean {
3218
+ return (left === undefined) === (right === undefined);
3219
+ }
3220
+
3221
+ /** Plan value-only patches; callers own valid immutable snapshots and commit state. */
3222
+ export function planNativeListPatches(
3223
+ previous: NativeListSnapshot,
3224
+ next: NativeListSnapshot
3225
+ ): readonly RowPatch[] | undefined {
3226
+ if (previous === next) return EMPTY_ROW_PATCHES;
3227
+ const { rows: previousRows, ...previousMetadata } = previous;
3228
+ const { rows: nextRows, ...nextMetadata } = next;
3229
+ if (
3230
+ previousRows.length !== nextRows.length ||
3231
+ !sameJsonValue(previousMetadata, nextMetadata)
3232
+ )
3233
+ return undefined;
3234
+ if (previousRows === nextRows) return EMPTY_ROW_PATCHES;
3235
+
3236
+ const patches: RowPatch[] = [];
3237
+ for (let index = 0; index < nextRows.length; index += 1) {
3238
+ const oldRow = previousRows[index];
3239
+ const row = nextRows[index];
3240
+ if (oldRow.key !== row.key || oldRow.type !== row.type) return undefined;
3241
+ if (oldRow === row) continue;
3242
+
3243
+ if (oldRow.type === 'identity' && row.type === 'identity') {
3244
+ const {
3245
+ subtitle: oldSubtitle,
3246
+ subtitleSegments: oldSubtitleSegments,
3247
+ trailing: oldTrailing,
3248
+ ...oldMetadata
3249
+ } = oldRow;
3250
+ const { subtitle, subtitleSegments, trailing, ...metadata } = row;
3251
+ if (
3252
+ !sameOptionalPresence(oldSubtitle, subtitle) ||
3253
+ !sameOptionalPresence(oldSubtitleSegments, subtitleSegments) ||
3254
+ !sameOptionalPresence(oldTrailing, trailing) ||
3255
+ !sameJsonValue(oldMetadata, metadata)
3256
+ )
3257
+ return undefined;
3258
+ if (
3259
+ !sameJsonValue(oldSubtitle, subtitle) ||
3260
+ !sameJsonValue(oldSubtitleSegments, subtitleSegments) ||
3261
+ !sameJsonValue(oldTrailing, trailing)
3262
+ ) {
3263
+ patches.push({
3264
+ type: 'identity',
3265
+ key: row.key,
3266
+ changes: { subtitle, subtitleSegments, trailing },
3267
+ });
3268
+ }
3269
+ } else if (oldRow.type === 'dataRow' && row.type === 'dataRow') {
3270
+ const {
3271
+ columns: oldColumns,
3272
+ trailing: oldTrailing,
3273
+ ...oldMetadata
3274
+ } = oldRow;
3275
+ const { columns, trailing, ...metadata } = row;
3276
+ if (
3277
+ !sameOptionalPresence(oldTrailing, trailing) ||
3278
+ !sameJsonValue(oldMetadata, metadata) ||
3279
+ !sameJsonValue(oldColumns[0], columns[0])
3280
+ )
3281
+ return undefined;
3282
+ if (
3283
+ !sameJsonValue(oldColumns.slice(1), columns.slice(1)) ||
3284
+ !sameJsonValue(oldTrailing, trailing)
3285
+ ) {
3286
+ patches.push({
3287
+ type: 'dataRow',
3288
+ key: row.key,
3289
+ changes: { columns, trailing },
3290
+ });
3291
+ }
3292
+ } else if (!sameJsonValue(oldRow, row)) {
3293
+ return undefined;
3294
+ }
3295
+ }
3296
+ return patches.length === 0 ? EMPTY_ROW_PATCHES : patches;
3297
+ }
3298
+
2532
3299
  export function applyRowPatches(
2533
3300
  snapshot: NativeListSnapshot,
2534
3301
  patches: readonly RowPatch[]
@@ -2571,6 +3338,85 @@ export function applyRowPatches(
2571
3338
  return validateSnapshot({ ...snapshot, rows });
2572
3339
  }
2573
3340
 
3341
+ /** Native wrapper only: preserve full public validation for unsupported patches. */
3342
+ export function applyOwnedNativePatches(
3343
+ snapshot: NativeListSnapshot,
3344
+ patches: readonly RowPatch[]
3345
+ ): {
3346
+ patches: readonly RowPatch[];
3347
+ snapshot: NativeListSnapshot;
3348
+ json: string;
3349
+ } {
3350
+ const acceptedPatches = deepFreeze(
3351
+ validatePatches(capturePlainData(patches))
3352
+ );
3353
+ const rowIndexes = ownedNativeSnapshots.get(snapshot);
3354
+ const canPatchRows =
3355
+ rowIndexes !== undefined &&
3356
+ acceptedPatches.every((patch) => {
3357
+ const index = rowIndexes.get(patch.key);
3358
+ if (index === undefined || snapshot.rows[index].type !== patch.type)
3359
+ return false;
3360
+ const allowed =
3361
+ patch.type === 'identity'
3362
+ ? ['subtitle', 'subtitleSegments', 'trailing']
3363
+ : patch.type === 'dataRow'
3364
+ ? ['columns', 'trailing']
3365
+ : undefined;
3366
+ return (
3367
+ allowed !== undefined &&
3368
+ Object.entries(patch.changes).every(
3369
+ ([key, value]) =>
3370
+ allowed.includes(key) &&
3371
+ (value !== undefined ||
3372
+ (snapshot.rows[index] as unknown as Record<string, unknown>)[
3373
+ key
3374
+ ] === undefined)
3375
+ )
3376
+ );
3377
+ });
3378
+ if (!canPatchRows) {
3379
+ const baseline = rowIndexes ? snapshot : capturePlainData(snapshot);
3380
+ return {
3381
+ patches: acceptedPatches,
3382
+ snapshot: ownValidatedNativeSnapshot(
3383
+ applyRowPatches(baseline, acceptedPatches)
3384
+ ),
3385
+ json: JSON.stringify(acceptedPatches),
3386
+ };
3387
+ }
3388
+
3389
+ const nextRows = snapshot.rows.slice();
3390
+ for (const patch of acceptedPatches) {
3391
+ const index = rowIndexes.get(patch.key)!;
3392
+ const previous = nextRows[index];
3393
+ const merged = {
3394
+ ...previous,
3395
+ ...patch.changes,
3396
+ key: previous.key,
3397
+ type: previous.type,
3398
+ } as RowModel;
3399
+ const path = `rows[${index}]`;
3400
+ assertPlainSerializable(merged, path, new Set<object>());
3401
+ if (isDevelopment()) assertRow(merged, index);
3402
+ const normalized = isDevelopment()
3403
+ ? merged
3404
+ : (normalizeEmptyImages(merged) as RowModel);
3405
+ assertRowProtocol(normalized, path);
3406
+ nextRows[index] = deepFreeze(normalizeRowStyles(normalized));
3407
+ }
3408
+ const nextSnapshot = Object.freeze({
3409
+ ...snapshot,
3410
+ rows: Object.freeze(nextRows),
3411
+ });
3412
+ ownedNativeSnapshots.set(nextSnapshot, rowIndexes);
3413
+ return {
3414
+ patches: acceptedPatches,
3415
+ snapshot: nextSnapshot,
3416
+ json: JSON.stringify(acceptedPatches),
3417
+ };
3418
+ }
3419
+
2574
3420
  export function serializeSnapshot(snapshot: NativeListSnapshot): string {
2575
3421
  return JSON.stringify(validateSnapshot(snapshot));
2576
3422
  }