jui-tools-mcp-server 2.5.1 → 2.7.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.
@@ -181,7 +181,7 @@
181
181
  "string",
182
182
  "binding"
183
183
  ],
184
- "description": "Background color - hex string (#RRGGBB or #RRGGBBAA) or color name from colors.json (can be data binding)"
184
+ "description": "Background color - hex string (#RRGGBB or #RRGGBBAA) or color name from colors.json (can be data binding). `backgroundColor` folds here: the genuine layout reads of that spelling all chain with `background` (kjui blurview_component.rb:42 `background || backgroundColor`, kjui segment_component.rb:55 `backgroundColor || background`, rjui blur_converter.rb:58 and circle_view_converter.rb:55). Note the two kjui sites read the pair in OPPOSITE order, so a layout setting both drew two different colours until the normalizer began folding them — the same defect shape as CheckBox's accent chain (plan 51-E). When a `gradient` is also declared on the same view, the GRADIENT wins and this is the fallback fill — not a layer underneath it. Full ruling in attribute_semantics.json -> backgroundFill; do not restate it in toolchain comments."
185
185
  },
186
186
  "tapBackground": {
187
187
  "type": [
@@ -663,7 +663,8 @@
663
663
  "boolean",
664
664
  "binding"
665
665
  ],
666
- "description": "Whether to clip content to bounds (binding supported)"
666
+ "description": "Whether to clip content to bounds (binding supported). Defaults to FALSE: content is not clipped unless the layout asks. A clipping default would silently destroy a child's `shadow` and would make `clipToBounds: true` inert on the platform that already clips. Full ruling in attribute_semantics.json -> clipToBounds; do not restate it in toolchain comments.",
667
+ "default": false
667
668
  },
668
669
  "onclick": {
669
670
  "type": [
@@ -856,7 +857,7 @@
856
857
  "systemChromeMaterial": "Chrome"
857
858
  },
858
859
  "default": "Regular",
859
- "description": "Visual effect (blur) style. Measured across the three converters 2026-08-05: the UIKit trio Light/Dark/ExtraLight is what all three map (sjui blur_converter.rb:71, kjui blurview_component.rb:38, rjui blur_converter.rb:98), and web additionally maps the SwiftUI material names. Every converter downcases before matching, so the casing here is presentational. `Regular` is the fallback all three already use for an unrecognised or absent value, hence the declared default. Enumerated because it was declared as a bare string while Blur.effectStyle — the same concept on the component that owns it — carried the enum: the split let the common spelling reach the render stage unvalidated, which is what the codegen-effect gate reported once the mode audit put these fixtures back in scope."
860
+ "description": "Visual effect (blur) style. Measured across the three converters 2026-08-05: the UIKit trio Light/Dark/ExtraLight is what all three map (sjui blur_converter.rb:71, kjui blurview_component.rb:38, rjui blur_converter.rb:98), and web additionally maps the SwiftUI material names. Every converter downcases before matching, so the casing here is presentational. `Regular` is the fallback all three already use for an unrecognised or absent value, hence the declared default. Enumerated because it was declared as a bare string while Blur.effectStyle — the same concept on the component that owns it — carried the enum: the split let the common spelling reach the render stage unvalidated, which is what the codegen-effect gate reported once the mode audit put these fixtures back in scope. Declared on `common`, so it applies on all three platforms and not only on Blur; the ios material/tint table is pinned in attribute_semantics.json -> effectStyle, which is also where the system* valueAliases and the tint layer order are ruled."
860
861
  },
861
862
  "tag": {
862
863
  "type": [
@@ -950,7 +951,7 @@
950
951
  "centerHorizontal",
951
952
  "center"
952
953
  ],
953
- "description": "Content gravity/alignment"
954
+ "description": "Content gravity/alignment. A single value names ONE axis; the axis it does not name falls to the container default (top vertically, start horizontally), so in LTR `left` and `top` both resolve to (start, top) and render identically. Use the array form to name both axes. Full ruling in attribute_semantics.json -> gravityDefaults; do not restate it in toolchain comments."
954
955
  },
955
956
  "onAppear": {
956
957
  "type": "string",
@@ -1110,6 +1111,20 @@
1110
1111
  "description": "Debug: original height string before parsing. Dynamic-only; not emitted to static code.",
1111
1112
  "platform": "swift",
1112
1113
  "mode": "dynamic-only"
1114
+ },
1115
+ "offsetX": {
1116
+ "type": [
1117
+ "number",
1118
+ "binding"
1119
+ ],
1120
+ "description": "Horizontal offset applied after layout, in pt / dp / px. Measurement and sibling placement are unchanged — the layout is computed first and the offset moves only this view. Its INTERACTIVE region moves with it: an offset control is tappable where it is drawn, which is what rules out a draw-only translation. Absolute, not RTL-mirroring, like leftMargin/rightMargin rather than startMargin/endMargin. Pairs with offsetY; either one alone implies 0 for the other. Full ruling (including the android primitive) in attribute_semantics.json -> offset."
1121
+ },
1122
+ "offsetY": {
1123
+ "type": [
1124
+ "number",
1125
+ "binding"
1126
+ ],
1127
+ "description": "Vertical offset applied after layout, in pt / dp / px. Measurement and sibling placement are unchanged — the layout is computed first and the offset moves only this view. Its INTERACTIVE region moves with it: an offset control is tappable where it is drawn, which is what rules out a draw-only translation. Absolute, not RTL-mirroring, like leftMargin/rightMargin rather than startMargin/endMargin. Pairs with offsetX; either one alone implies 0 for the other. Full ruling (including the android primitive) in attribute_semantics.json -> offset."
1113
1128
  }
1114
1129
  },
1115
1130
  "Label": {
@@ -1242,7 +1257,7 @@
1242
1257
  "object",
1243
1258
  "array"
1244
1259
  ],
1245
- "description": "Underline styling (boolean for simple, object for styled)",
1260
+ "description": "Underline styling: boolean for a plain line, object to style it. The object face must never draw LESS than `true` does — a platform that cannot honour lineStyle or color still draws the plain Single line in the text colour. `lineStyle: \"None\"` is the one object value that draws nothing, exactly like `false`. Full ruling in attribute_semantics.json -> textDecoration; do not restate it in toolchain comments.",
1246
1261
  "properties": {
1247
1262
  "lineStyle": {
1248
1263
  "type": "string",
@@ -1269,7 +1284,7 @@
1269
1284
  "boolean",
1270
1285
  "object"
1271
1286
  ],
1272
- "description": "Strikethrough styling (boolean for simple, object for styled)",
1287
+ "description": "Strikethrough styling: boolean for a plain line, object to style it. Same contract as Label.underline, minus lineOffset, which strikethrough does not declare and must not invent. `lineStyle: \"None\"` draws nothing, exactly like `false`. Full ruling in attribute_semantics.json -> textDecoration; do not restate it in toolchain comments.",
1273
1288
  "properties": {
1274
1289
  "lineStyle": {
1275
1290
  "type": "string",
@@ -1289,7 +1304,7 @@
1289
1304
  },
1290
1305
  "autoShrink": {
1291
1306
  "type": "boolean",
1292
- "description": "Enable auto-shrink"
1307
+ "description": "Enable auto-shrink. Active only on a bounded axis (explicit size, matchParent, or a max bound) — a no-op under wrapContent, which grows to the content and leaves nothing to shrink into. Full ruling in attribute_semantics.json -> autoShrink."
1293
1308
  },
1294
1309
  "minimumScaleFactor": {
1295
1310
  "type": [
@@ -1479,6 +1494,13 @@
1479
1494
  "number"
1480
1495
  ],
1481
1496
  "description": "Font weight (e.g., 'bold', 'semibold', '500', 600)"
1497
+ },
1498
+ "disabledFontColor": {
1499
+ "type": [
1500
+ "string",
1501
+ "binding"
1502
+ ],
1503
+ "description": "Font color when disabled - hex string or color name from colors.json (binding supported). Declared from the implementation, which already read it: sjui label_converter.rb:179-180 (plan 51-E)."
1482
1504
  }
1483
1505
  },
1484
1506
  "TextField": {
@@ -1487,8 +1509,11 @@
1487
1509
  "string",
1488
1510
  "binding"
1489
1511
  ],
1490
- "description": "Text content (binding for two-way)",
1491
- "binding_direction": "two-way"
1512
+ "description": "Text content (binding for two-way). `value` folds here (sjui textfield_converter.rb:527 reads `text || value || bind`).",
1513
+ "binding_direction": "two-way",
1514
+ "aliases": [
1515
+ "value"
1516
+ ]
1492
1517
  },
1493
1518
  "hint": {
1494
1519
  "type": "string",
@@ -1519,6 +1544,24 @@
1519
1544
  "hintAttributes": {
1520
1545
  "type": "object",
1521
1546
  "description": "Hint text attributes (font, fontSize, fontColor). The nested keys win over the flat spellings (hintColor / hintFont / hintFontSize): a bag scoped to the hint is the more specific statement. That is the ordinary cascade rule and what all four readers do (rjui label_converter, kjui text_component, sjui Label and SelectBox) - it was unwritten until 2026-08-05, when the one converter that had it backwards was found by contradicting its own comment.",
1547
+ "properties": {
1548
+ "font": {
1549
+ "type": "string",
1550
+ "description": "Font name"
1551
+ },
1552
+ "fontSize": {
1553
+ "type": "number",
1554
+ "description": "Font size"
1555
+ },
1556
+ "fontColor": {
1557
+ "type": "string",
1558
+ "description": "Font color - hex string or color name from colors.json"
1559
+ },
1560
+ "lineHeightMultiple": {
1561
+ "type": "number",
1562
+ "description": "Line height multiplier"
1563
+ }
1564
+ },
1522
1565
  "platform": "swift"
1523
1566
  },
1524
1567
  "font": {
@@ -1902,6 +1945,10 @@
1902
1945
  "description": "Hide other elements while the field is focused.",
1903
1946
  "platform": "swift",
1904
1947
  "mode": "swiftui"
1948
+ },
1949
+ "hintLineHeightMultiple": {
1950
+ "type": "number",
1951
+ "description": "Placeholder line height multiplier. Declared from the implementation, which already read it: sjui textfield_converter.rb:153 and textview_converter.rb:93,117 (EditText/Input follow via _alias_of) (plan 51-E)."
1905
1952
  }
1906
1953
  },
1907
1954
  "TextView": {
@@ -1942,7 +1989,25 @@
1942
1989
  },
1943
1990
  "hintAttributes": {
1944
1991
  "type": "object",
1945
- "description": "Hint styling attributes. The nested keys win over the flat spellings (hintColor / hintFont / hintFontSize): a bag scoped to the hint is the more specific statement. That is the ordinary cascade rule and what all four readers do (rjui label_converter, kjui text_component, sjui Label and SelectBox) - it was unwritten until 2026-08-05, when the one converter that had it backwards was found by contradicting its own comment."
1992
+ "description": "Hint styling attributes. The nested keys win over the flat spellings (hintColor / hintFont / hintFontSize): a bag scoped to the hint is the more specific statement. That is the ordinary cascade rule and what all four readers do (rjui label_converter, kjui text_component, sjui Label and SelectBox) - it was unwritten until 2026-08-05, when the one converter that had it backwards was found by contradicting its own comment.",
1993
+ "properties": {
1994
+ "font": {
1995
+ "type": "string",
1996
+ "description": "Font name"
1997
+ },
1998
+ "fontSize": {
1999
+ "type": "number",
2000
+ "description": "Font size"
2001
+ },
2002
+ "fontColor": {
2003
+ "type": "string",
2004
+ "description": "Font color - hex string or color name from colors.json"
2005
+ },
2006
+ "lineHeightMultiple": {
2007
+ "type": "number",
2008
+ "description": "Line height multiplier"
2009
+ }
2010
+ }
1946
2011
  },
1947
2012
  "hideOnFocused": {
1948
2013
  "type": "boolean",
@@ -2291,6 +2356,105 @@
2291
2356
  "binding"
2292
2357
  ],
2293
2358
  "description": "Font weight (e.g., 'bold', 'semibold', '500', 600, or binding)"
2359
+ },
2360
+ "fontFamily": {
2361
+ "type": [
2362
+ "string",
2363
+ "binding"
2364
+ ],
2365
+ "description": "Font family name (e.g., 'Noto Sans JP'). Passed as the `family` field of `FontSpec` to `Configuration.Font.fontProvider`. Coexists with `font` (weight) and `fontSize` which are passed in the same FontSpec. Declared from the implementation, which already read it: sjui button_converter.rb:173-178 (plan 51-E)."
2366
+ },
2367
+ "partialAttributes": {
2368
+ "type": "array",
2369
+ "description": "Partial text styling: apply font/size/color/underline/shadow to a substring selected by 'range' (a [start, end] pair, a text pattern, or a binding), and optionally make it tappable with 'onclick'. This is the supported way to get emphasis or a link inside a Label, since 'text' is plain text and markdown is not interpreted, and it is the portable way to express a cross-reference: the handler navigates (and scrolls to the target) in host code, so the same layout works everywhere, where a URL-based link would only work on web. All three platforms resolve partials at RUNTIME against the resolved string, so a pattern range and a localized or bound 'text' both work. Semantics, identical across platforms and verified against each runtime: an array range is [start, end) with the end exclusive; a string range is the FIRST occurrence and the partial is skipped (not an error) when the pattern is absent; a range that is out of bounds or inverted is skipped; partials apply in declaration order and MERGE where they overlap, later declarations winning per property. Declared from the implementation, which already read it: sjui button_converter.rb:68-129 (plan 51-E).",
2370
+ "items": {
2371
+ "type": "object",
2372
+ "properties": {
2373
+ "font": {
2374
+ "type": "string",
2375
+ "description": "Font name"
2376
+ },
2377
+ "fontSize": {
2378
+ "type": "number",
2379
+ "description": "Font size"
2380
+ },
2381
+ "fontColor": {
2382
+ "type": "string",
2383
+ "description": "Font color - hex string or color name from colors.json"
2384
+ },
2385
+ "lineSpacing": {
2386
+ "type": "number",
2387
+ "description": "Line spacing"
2388
+ },
2389
+ "lineHeightMultiple": {
2390
+ "type": "number",
2391
+ "description": "Line height multiplier"
2392
+ },
2393
+ "lineBreakMode": {
2394
+ "type": "string",
2395
+ "enum": [
2396
+ "Char",
2397
+ "Clip",
2398
+ "Word",
2399
+ "Head",
2400
+ "Middle",
2401
+ "Tail"
2402
+ ],
2403
+ "description": "Line break mode"
2404
+ },
2405
+ "textAlign": {
2406
+ "type": "string",
2407
+ "enum": [
2408
+ "Left",
2409
+ "Right",
2410
+ "Center"
2411
+ ],
2412
+ "description": "Text alignment"
2413
+ },
2414
+ "underline": {
2415
+ "type": "object",
2416
+ "properties": {
2417
+ "lineStyle": {
2418
+ "type": "string",
2419
+ "enum": [
2420
+ "Single",
2421
+ "Double",
2422
+ "Thick",
2423
+ "None"
2424
+ ],
2425
+ "description": "Underline style"
2426
+ },
2427
+ "color": {
2428
+ "type": "string",
2429
+ "description": "Underline color - hex string or color name from colors.json"
2430
+ },
2431
+ "lineOffset": {
2432
+ "type": "number",
2433
+ "description": "Baseline offset"
2434
+ }
2435
+ }
2436
+ },
2437
+ "textShadow": {
2438
+ "type": "string",
2439
+ "description": "Text shadow"
2440
+ },
2441
+ "range": {
2442
+ "type": [
2443
+ "array",
2444
+ "string"
2445
+ ],
2446
+ "description": "Range [start, end], text pattern string, or binding expression"
2447
+ },
2448
+ "onclick": {
2449
+ "type": "string",
2450
+ "description": "Click handler for this range (selector-based, lowercase)"
2451
+ },
2452
+ "onClick": {
2453
+ "type": "binding",
2454
+ "description": "Click handler for this range (camelCase) - binding only (@{functionName})"
2455
+ }
2456
+ }
2457
+ }
2294
2458
  }
2295
2459
  },
2296
2460
  "Image": {
@@ -2418,6 +2582,10 @@
2418
2582
  "description": "Pinch-zoom lower bound.",
2419
2583
  "platform": "swift",
2420
2584
  "mode": "swiftui"
2585
+ },
2586
+ "defaultImage": {
2587
+ "type": "string",
2588
+ "description": "Default image name. Declared from the implementation, which already read it: sjui image_converter.rb:36,42 (defaultImage || errorImage || loadingImage) (plan 51-E)."
2421
2589
  }
2422
2590
  },
2423
2591
  "NetworkImage": {
@@ -2433,7 +2601,10 @@
2433
2601
  "string",
2434
2602
  "binding"
2435
2603
  ],
2436
- "description": "Image URL (alias, can be data binding)"
2604
+ "description": "Image URL (alias, can be data binding). `source` folds here (sjui network_image_converter.rb:14 reads `url || source || src`).",
2605
+ "aliases": [
2606
+ "source"
2607
+ ]
2437
2608
  },
2438
2609
  "hint": {
2439
2610
  "type": "string",
@@ -2513,6 +2684,18 @@
2513
2684
  "description": "HTTP headers for the image request (Coil 3: httpHeaders(NetworkHeaders) on ImageRequest.Builder).",
2514
2685
  "platform": "kotlin",
2515
2686
  "mode": "compose"
2687
+ },
2688
+ "renderingMode": {
2689
+ "type": "string",
2690
+ "enum": [
2691
+ "original",
2692
+ "template"
2693
+ ],
2694
+ "description": "Rendering mode. Declared from the implementation, which already read it: sjui network_image_converter.rb:67-68 (plan 51-E).",
2695
+ "platform": [
2696
+ "swift",
2697
+ "kotlin"
2698
+ ]
2516
2699
  }
2517
2700
  },
2518
2701
  "SelectBox": {
@@ -2575,7 +2758,7 @@
2575
2758
  "string",
2576
2759
  "binding"
2577
2760
  ],
2578
- "description": "Initial selected date (yyyy-MM-dd format, two-way binding)"
2761
+ "description": "Initial selected date (yyyy-MM-dd format, two-way binding). No `default`: when it is absent no initial selection has been stated, and the closed face shows the prompt / hint / placeholder rather than today's date. Full ruling in attribute_semantics.json -> selectBoxDate."
2579
2762
  },
2580
2763
  "datePickerMode": {
2581
2764
  "type": "string",
@@ -2605,7 +2788,10 @@
2605
2788
  },
2606
2789
  "dateStringFormat": {
2607
2790
  "type": "string",
2608
- "description": "Date string format"
2791
+ "description": "Date string format. `dateFormat` folds here (sjui selectbox_converter.rb:132 reads `dateFormat || dateStringFormat`).",
2792
+ "aliases": [
2793
+ "dateFormat"
2794
+ ]
2609
2795
  },
2610
2796
  "minimumDate": {
2611
2797
  "type": [
@@ -2940,6 +3126,42 @@
2940
3126
  "trackTintColor": {
2941
3127
  "type": "string",
2942
3128
  "description": "Colour of the switch track - hex string or color name from colors.json. Deprecation retracted with the Slider pair; a SwiftUI Toggle is not limited to a unified tint any more than Progress is."
3129
+ },
3130
+ "fontColor": {
3131
+ "type": [
3132
+ "string",
3133
+ "binding"
3134
+ ],
3135
+ "description": "Text color - hex string or color name from colors.json (binding supported). Declared from the implementation, which already read it: sjui toggle_converter.rb:62-63,71-72 (plan 51-E)."
3136
+ },
3137
+ "toggleStyle": {
3138
+ "type": "string",
3139
+ "enum": [
3140
+ "switch",
3141
+ "button",
3142
+ "checkbox",
3143
+ "default"
3144
+ ],
3145
+ "description": "Which control shape the Switch is drawn as. A SwiftUI-only concept (the ToggleStyle protocol); an unrecognised value falls back to the platform default rather than erroring. Declared from the implementation, which already read it: sjui toggle_converter.rb:92-104 (plan 51-E).",
3146
+ "platform": "swift",
3147
+ "mode": "swiftui"
3148
+ },
3149
+ "label": {
3150
+ "type": [
3151
+ "string",
3152
+ "binding"
3153
+ ],
3154
+ "description": "Switch label (can be data binding). Same shape CheckBox and Radio already declare: `label` is the canonical row and `text` folds into it. Declared from the implementation, which already read it: sjui toggle_converter.rb:21 (text || label) (plan 51-E).",
3155
+ "aliases": [
3156
+ "text"
3157
+ ]
3158
+ },
3159
+ "fontSize": {
3160
+ "type": [
3161
+ "number",
3162
+ "binding"
3163
+ ],
3164
+ "description": "Font size (binding supported). Declared from the implementation, which already read it: sjui toggle_converter.rb hands the whole component to the shared apply_font_modifiers helper, so the size was honoured with no declaration behind it (plan 51-E)."
2943
3165
  }
2944
3166
  },
2945
3167
  "Toggle": {
@@ -2957,12 +3179,18 @@
2957
3179
  "number",
2958
3180
  "binding"
2959
3181
  ],
2960
- "description": "Initially selected index (binding for two-way)",
2961
- "binding_direction": "two-way"
3182
+ "description": "Initially selected index (binding for two-way). `selectedTabIndex` folds here (sjui segment_converter.rb:16), matching the alias TabView already declares.",
3183
+ "binding_direction": "two-way",
3184
+ "aliases": [
3185
+ "selectedTabIndex"
3186
+ ]
2962
3187
  },
2963
3188
  "tintColor": {
2964
3189
  "type": "string",
2965
- "description": "Accent color of the SELECTED segment - a background fill on ios/web, the indicator on Compose. Hex string or color name from colors.json"
3190
+ "description": "Accent color of the SELECTED segment - a background fill on ios/web, the indicator on Compose. Hex string or color name from colors.json. `selectedSegmentTintColor` folds here (sjui segment_converter.rb:128 reads `selectedSegmentTintColor || tintColor`).",
3191
+ "aliases": [
3192
+ "selectedSegmentTintColor"
3193
+ ]
2966
3194
  },
2967
3195
  "fontColor": {
2968
3196
  "type": "string",
@@ -3013,22 +3241,24 @@
3013
3241
  "number",
3014
3242
  "binding"
3015
3243
  ],
3016
- "description": "Minimum value (binding supported). `minValue` / `minimumValue` are accepted alias spellings.",
3244
+ "description": "Minimum value (binding supported). `minValue` / `minimumValue` are accepted alias spellings. Defaults to 0: with no bounds declared the range is 0 .. 1, this SSoT's convention for a unitless value on a track (compare Progress.progress and opacity, both declared min 0 / max 1). Full ruling in attribute_semantics.json -> sliderRange.",
3017
3245
  "aliases": [
3018
3246
  "minimumValue",
3019
3247
  "minValue"
3020
- ]
3248
+ ],
3249
+ "default": 0
3021
3250
  },
3022
3251
  "maximum": {
3023
3252
  "type": [
3024
3253
  "number",
3025
3254
  "binding"
3026
3255
  ],
3027
- "description": "Maximum value (binding supported). `maxValue` / `maximumValue` are accepted alias spellings.",
3256
+ "description": "Maximum value (binding supported). `maxValue` / `maximumValue` are accepted alias spellings. Defaults to 1: with no bounds declared the range is 0 .. 1, this SSoT's convention for a unitless value on a track (compare Progress.progress and opacity, both declared min 0 / max 1). Full ruling in attribute_semantics.json -> sliderRange.",
3028
3257
  "aliases": [
3029
3258
  "maximumValue",
3030
3259
  "maxValue"
3031
- ]
3260
+ ],
3261
+ "default": 1
3032
3262
  },
3033
3263
  "tintColor": {
3034
3264
  "type": "string",
@@ -3065,6 +3295,10 @@
3065
3295
  "trackTintColor": {
3066
3296
  "type": "string",
3067
3297
  "description": "Colour of the unfilled track - hex string or color name from colors.json. Deprecation retracted with progressTintColor: SwiftUI Progress reaches the same result through .background()."
3298
+ },
3299
+ "range": {
3300
+ "type": "array",
3301
+ "description": "Both bounds at once, as [minimum, maximum] — a shorthand for declaring `minimum` and `maximum` separately. It is NOT an alias: one spelling carries two values, so the normalizer cannot fold it. Read only when it is a two-element array; anything else leaves `minimum` / `maximum` to speak for themselves. Declared from the implementation, which already read it: sjui slider_converter.rb:16-19 (plan 51-E)."
3068
3302
  }
3069
3303
  },
3070
3304
  "Progress": {
@@ -3197,7 +3431,7 @@
3197
3431
  },
3198
3432
  "gradient": {
3199
3433
  "type": "array",
3200
- "description": "Gradient colors",
3434
+ "description": "Gradient colors Wins over `background` when both are declared on the same view; see attribute_semantics.json -> backgroundFill.",
3201
3435
  "platform": "swift"
3202
3436
  },
3203
3437
  "gradientDirection": {
@@ -3459,6 +3693,19 @@
3459
3693
  ],
3460
3694
  "description": "Scroll behavior animation",
3461
3695
  "platform": "react"
3696
+ },
3697
+ "horizontalScroll": {
3698
+ "type": "boolean",
3699
+ "description": "Enable horizontal scroll. Declared from the implementation, which already read it: sjui scrollview_converter.rb:39 (plan 51-E)."
3700
+ },
3701
+ "defaultScrollAnchor": {
3702
+ "type": "string",
3703
+ "enum": [
3704
+ "top",
3705
+ "center",
3706
+ "bottom"
3707
+ ],
3708
+ "description": "Initial scroll position anchor. Sets where the scroll view starts. Declared from the implementation, which already read it: sjui scrollview_converter.rb:207-215. Scope is deliberately all three platforms: the anchor is a portable concept and the declaration says an attribute EXISTS, not which face has caught up — the coverage ledger carries the android and web gaps. The \"(iOS 17+)\" note this description was copied with belonged to Collection's ios implementation, not to the contract, and has been dropped here (plan 51-E, raised by C)."
3462
3709
  }
3463
3710
  },
3464
3711
  "Collection": {
@@ -3505,7 +3752,10 @@
3505
3752
  },
3506
3753
  "lineSpacing": {
3507
3754
  "type": "number",
3508
- "description": "Spacing between rows"
3755
+ "description": "Spacing between rows. `sectionSpacing` folds here (sjui collection_converter.rb:799,960 read `sectionSpacing || lineSpacing || 8`).",
3756
+ "aliases": [
3757
+ "sectionSpacing"
3758
+ ]
3509
3759
  },
3510
3760
  "horizontalScroll": {
3511
3761
  "type": "boolean",
@@ -3728,6 +3978,29 @@
3728
3978
  "onValueChanged",
3729
3979
  "onPageChanged"
3730
3980
  ]
3981
+ },
3982
+ "cellWidth": {
3983
+ "type": "number",
3984
+ "description": "Fixed width for every cell, in pt / dp / px. Applied to the cell view AFTER it is built, so it overrides whatever width the cell layout asked for; leave it out to let each cell size itself. Declared from the implementation, which already read it: sjui collection_converter.rb:256,282,315,650 (plan 51-E)."
3985
+ },
3986
+ "cellHeight": {
3987
+ "type": "number",
3988
+ "description": "Fixed height for every cell, in pt / dp / px. Applied to the cell view AFTER it is built, so it overrides whatever height the cell layout asked for; leave it out to let each cell size itself. Declared from the implementation, which already read it: sjui collection_converter.rb:260,286,318,402 (plan 51-E)."
3989
+ },
3990
+ "hideSeparator": {
3991
+ "type": "boolean",
3992
+ "description": "Hide the row separators a list draws between cells. A NO-OP where the container draws no separators (a grid-shaped Collection has none) — that is the contract, not an unimplemented gap, and it does not license switching the container to a List. No CODEGEN face reads it, so the coverage row is runtime-only. Full ruling in attribute_semantics.json -> collectionSeparators."
3993
+ },
3994
+ "listStyle": {
3995
+ "type": "string",
3996
+ "enum": [
3997
+ "plain",
3998
+ "grouped",
3999
+ "insetGrouped",
4000
+ "sidebar"
4001
+ ],
4002
+ "default": "plain",
4003
+ "description": "Which list chrome the collection is drawn with. Enumerated from the only implementation that reads it (SwiftJsonUI TableConverter.applyListStyle); an unrecognised value falls back to plain. ORTHOGONAL to hideSeparator — that one hides the separators, this one picks the chrome, and neither overrides the other. No CODEGEN face reads it, so the coverage row is runtime-only: sjui collection_converter.rb:213 hardcodes PlainListStyle instead. Full ruling in attribute_semantics.json -> collectionSeparators."
3731
4004
  }
3732
4005
  },
3733
4006
  "Radio": {
@@ -3774,7 +4047,10 @@
3774
4047
  "boolean",
3775
4048
  "binding"
3776
4049
  ],
3777
- "description": "Seeds the group's INITIAL selection — it is not the live state, and it is not two-way. The state a radio group carries is `selectedValue`; `checked` only says which option starts selected when nothing else has said. Precedence, and all three platforms implement it in this order: a BOUND `selectedValue` wins outright (the group's own two-way state), then a LITERAL `selectedValue` (a group-level statement naming the starting option), then this option's `checked`. So a radio group that binds selectedValue ignores `checked` entirely, which is what stops a seed from freezing a group that the user can no longer change. The bound form of `checked` seeds the glyph rather than the state, since a property initialiser cannot read the data map: it selects only while the group has made no choice yet (sjui radio_converter.rb:88-103, and the same rule in RadioConverter.swift). CONTRAST with CheckBox.checked and Switch.checked, which look identical and are not: those ARE the control's state and carry binding_direction two-way. A radio has no state of its own — the group does. Declared 2026-08-05 (plan 49-E) from the implementations; the previous description said only \"Initial checked state\", which was true and told nobody what happens when both are declared."
4050
+ "description": "Seeds the group's INITIAL selection — it is not the live state, and it is not two-way. The state a radio group carries is `selectedValue`; `checked` only says which option starts selected when nothing else has said. Precedence, and all three platforms implement it in this order: a BOUND `selectedValue` wins outright (the group's own two-way state), then a LITERAL `selectedValue` (a group-level statement naming the starting option), then this option's `checked`. So a radio group that binds selectedValue ignores `checked` entirely, which is what stops a seed from freezing a group that the user can no longer change. The bound form of `checked` seeds the glyph rather than the state, since a property initialiser cannot read the data map: it selects only while the group has made no choice yet (sjui radio_converter.rb:88-103, and the same rule in RadioConverter.swift). CONTRAST with CheckBox.checked and Switch.checked, which look identical and are not: those ARE the control's state and carry binding_direction two-way. A radio has no state of its own — the group does. Declared 2026-08-05 (plan 49-E) from the implementations; the previous description said only \"Initial checked state\", which was true and told nobody what happens when both are declared. `isOn` folds here (sjui radio_converter.rb:88,102 read both the literal and the bound form as `checked || isOn`).",
4051
+ "aliases": [
4052
+ "isOn"
4053
+ ]
3778
4054
  },
3779
4055
  "value": {
3780
4056
  "type": "any",
@@ -3850,6 +4126,13 @@
3850
4126
  "onValueChange": {
3851
4127
  "type": "binding",
3852
4128
  "description": "Value change handler - binding only (@{functionName})"
4129
+ },
4130
+ "items": {
4131
+ "type": [
4132
+ "array",
4133
+ "binding"
4134
+ ],
4135
+ "description": "List of items for normal picker (can be template variable). Declared from the implementation, which already read it: sjui radio_converter.rb:13 — the option list a radio GROUP renders; with it the converter emits a group, without it a single radio (plan 51-E)."
3853
4136
  }
3854
4137
  },
3855
4138
  "CheckBox": {
@@ -3949,14 +4232,22 @@
3949
4232
  },
3950
4233
  "onValueChange": {
3951
4234
  "type": "binding",
3952
- "description": "Value change handler - binding only (@{functionName})"
4235
+ "description": "Value change handler - binding only (@{functionName}). Legacy handler spellings fold here (sjui checkbox_converter.rb:122 reads `onValueChange || onClick || action || onValueChanged`; `onClick` stays common).",
4236
+ "aliases": [
4237
+ "action",
4238
+ "onValueChanged"
4239
+ ]
3953
4240
  },
3954
4241
  "checkedColor": {
3955
4242
  "type": "color",
3956
- "description": "Color when checked.",
4243
+ "description": "Color when checked. Legacy accent spellings fold here (sjui checkbox_converter.rb:98 and kjui checkbox_component.rb:211 both read the same four spellings for this one colour). `tintColor` is deliberately NOT in the alias list: it is already declared on `common`, and an alias that is also a declared row is a silent no-op (test_no_alias_is_cancelled_by_a_declaration_of_its_own_name). On a CheckBox the common tint IS the checked colour - that is what both converters implement - so it needs no alias row. Declaring the other two also settles a live divergence: ios reads `checkedColor` first, android reads `checkColor` first, so a layout setting both drew two different colours until the normalizer began folding them (plan 51-E).",
3957
4244
  "platform": [
3958
4245
  "swift",
3959
4246
  "kotlin"
4247
+ ],
4248
+ "aliases": [
4249
+ "checkColor",
4250
+ "onTintColor"
3960
4251
  ]
3961
4252
  },
3962
4253
  "uncheckedColor": {
@@ -3982,6 +4273,16 @@
3982
4273
  "swift",
3983
4274
  "kotlin"
3984
4275
  ]
4276
+ },
4277
+ "fontWeight": {
4278
+ "type": [
4279
+ "string",
4280
+ "number"
4281
+ ],
4282
+ "description": "Font weight (e.g., 'bold', 'semibold', '500', 600). Canonical name matches Label and Button; the legacy `fontStyle` spelling folds here. Declared from the implementation, which already read it: sjui checkbox_converter.rb:74-76, which reads `fontStyle` and emits `fontWeight:` (plan 51-E).",
4283
+ "aliases": [
4284
+ "fontStyle"
4285
+ ]
3985
4286
  }
3986
4287
  },
3987
4288
  "Check": {
@@ -4016,13 +4317,16 @@
4016
4317
  "string",
4017
4318
  "binding"
4018
4319
  ],
4019
- "description": "Indicator color - hex string or color name from colors.json (binding supported)",
4320
+ "description": "Indicator color - hex string or color name from colors.json (binding supported). `tint` folds here (sjui indicator_converter.rb:77 reads `color || tintColor || tint`).",
4020
4321
  "platform": "swift",
4021
- "mode": "swiftui"
4322
+ "mode": "swiftui",
4323
+ "aliases": [
4324
+ "tint"
4325
+ ]
4022
4326
  },
4023
4327
  "hidesWhenStopped": {
4024
4328
  "type": "boolean",
4025
- "description": "Hide when stopped",
4329
+ "description": "Hide when stopped. Decides SPACE: `true` collapses the stopped indicator out of the layout, `false` keeps it drawn AND laid out. Full ruling in attribute_semantics.json -> indicatorStopped.",
4026
4330
  "platform": "swift",
4027
4331
  "mode": "swiftui"
4028
4332
  }
@@ -4030,7 +4334,10 @@
4030
4334
  "GradientView": {
4031
4335
  "gradient": {
4032
4336
  "type": "array",
4033
- "description": "Gradient colors"
4337
+ "description": "Gradient colors. `colors` folds here (sjui gradient_view_converter.rb:52,66 read `colors || gradient`). On GradientView the gradient is the component's own fill, so the backgroundFill precedence question does not arise.",
4338
+ "aliases": [
4339
+ "colors"
4340
+ ]
4034
4341
  },
4035
4342
  "gradientDirection": {
4036
4343
  "type": "string",
@@ -4044,6 +4351,54 @@
4044
4351
  "locations": {
4045
4352
  "type": "array",
4046
4353
  "description": "Color stop locations (0.0-1.0)"
4354
+ },
4355
+ "child": {
4356
+ "type": "array",
4357
+ "description": "Child component(s). Declared from the implementation, which already read it: sjui gradient_view_converter.rb:16 (plan 51-E)."
4358
+ },
4359
+ "children": {
4360
+ "type": "array",
4361
+ "description": "Child components (alias for child). Declared from the implementation, which already read it: sjui gradient_view_converter.rb:16 (child || children) (plan 51-E)."
4362
+ },
4363
+ "startPoint": {
4364
+ "type": [
4365
+ "array",
4366
+ "object"
4367
+ ],
4368
+ "description": "Unit coordinates in the view's own box for where the gradient starts: [x, y] or {\"x\": x, \"y\": y}, with 0 = leading/top, 0.5 = centre/middle, 1 = trailing/bottom. THREE faces read this key three incompatible ways today: sjui gradient_view_converter.rb:107-109 wants an OBJECT ({x, y}), rjui gradient_view_converter.rb:72-73 destructures an ARRAY ([x, y]), and kjui gradientview_component.rb:58-59 expects a STRING edge name ('top'). The two coordinate forms are declared because both are real coordinate spellings of the same thing; the android string form is NOT declared - it is `gradientDirection` semantics written under the wrong key, and is filed as a defect rather than blessed here. Declaring BOTH startPoint and endPoint replaces gradientDirection entirely; declaring only one is ignored and gradientDirection still decides (plan 51-E).",
4369
+ "items": {
4370
+ "type": "number"
4371
+ },
4372
+ "properties": {
4373
+ "x": {
4374
+ "type": "number",
4375
+ "description": "0 = leading, 0.5 = centre, 1 = trailing"
4376
+ },
4377
+ "y": {
4378
+ "type": "number",
4379
+ "description": "0 = top, 0.5 = middle, 1 = bottom"
4380
+ }
4381
+ }
4382
+ },
4383
+ "endPoint": {
4384
+ "type": [
4385
+ "array",
4386
+ "object"
4387
+ ],
4388
+ "description": "Unit coordinates in the view's own box for where the gradient ends: [x, y] or {\"x\": x, \"y\": y}, with 0 = leading/top, 0.5 = centre/middle, 1 = trailing/bottom. THREE faces read this key three incompatible ways today: sjui gradient_view_converter.rb:107-109 wants an OBJECT ({x, y}), rjui gradient_view_converter.rb:72-73 destructures an ARRAY ([x, y]), and kjui gradientview_component.rb:58-59 expects a STRING edge name ('top'). The two coordinate forms are declared because both are real coordinate spellings of the same thing; the android string form is NOT declared - it is `gradientDirection` semantics written under the wrong key, and is filed as a defect rather than blessed here. Declaring BOTH startPoint and endPoint replaces gradientDirection entirely; declaring only one is ignored and gradientDirection still decides (plan 51-E).",
4389
+ "items": {
4390
+ "type": "number"
4391
+ },
4392
+ "properties": {
4393
+ "x": {
4394
+ "type": "number",
4395
+ "description": "0 = leading, 0.5 = centre, 1 = trailing"
4396
+ },
4397
+ "y": {
4398
+ "type": "number",
4399
+ "description": "0 = top, 0.5 = middle, 1 = bottom"
4400
+ }
4401
+ }
4047
4402
  }
4048
4403
  },
4049
4404
  "Blur": {
@@ -4063,6 +4418,14 @@
4063
4418
  "kotlin",
4064
4419
  "react"
4065
4420
  ]
4421
+ },
4422
+ "child": {
4423
+ "type": "array",
4424
+ "description": "Child component(s). Declared from the implementation, which already read it: sjui blur_converter.rb:16 (plan 51-E)."
4425
+ },
4426
+ "children": {
4427
+ "type": "array",
4428
+ "description": "Child components (alias for child). Declared from the implementation, which already read it: sjui blur_converter.rb:16 (child || children) (plan 51-E)."
4066
4429
  }
4067
4430
  },
4068
4431
  "IconLabel": {
@@ -4078,7 +4441,10 @@
4078
4441
  "boolean",
4079
4442
  "binding"
4080
4443
  ],
4081
- "description": "Selected state (binding supported). Chooses icon_on over icon_off and selectedFontColor over fontColor, so every platform declaring those needs this too."
4444
+ "description": "Selected state (binding supported). Chooses icon_on over icon_off and selectedFontColor over fontColor, so every platform declaring those needs this too. `isOn` folds here — the legacy spelling for the same state. IconLabelConverter.swift:145-151 says so in as many words (\"Pre-SSoT layouts that spelled it `isOn` keep working\") and reads it out of rawData because no typed slot existed; sjui icon_label_converter.rb:130 reads the canonical `selected` (plan 51-E).",
4445
+ "aliases": [
4446
+ "isOn"
4447
+ ]
4082
4448
  },
4083
4449
  "font": {
4084
4450
  "type": "string",
@@ -4124,6 +4490,13 @@
4124
4490
  "iconMargin": {
4125
4491
  "type": "number",
4126
4492
  "description": "Icon-text spacing"
4493
+ },
4494
+ "iconSize": {
4495
+ "type": [
4496
+ "number",
4497
+ "array"
4498
+ ],
4499
+ "description": "Icon size. A number sizes both edges; a two-element [width, height] sizes them separately (rjui icon_label_converter.rb:130-138 reads both, and its spec covers both). NOT the same shape as CheckBox.iconSize, which is a square glyph and takes a number only. sjui icon_label_converter.rb:50-51 interpolates the value straight into `iconSize:`, so the array form is web-only today — declared here because the attribute's value space is a property of the attribute, not of whichever face lags (plan 51-E)."
4127
4500
  }
4128
4501
  },
4129
4502
  "Web": {
@@ -4193,6 +4566,34 @@
4193
4566
  "children": {
4194
4567
  "type": "array",
4195
4568
  "description": "Child components (alias for child)"
4569
+ },
4570
+ "direction": {
4571
+ "type": "string",
4572
+ "enum": [
4573
+ "topToBottom",
4574
+ "bottomToTop",
4575
+ "leftToRight",
4576
+ "rightToLeft",
4577
+ "none"
4578
+ ],
4579
+ "description": "Layout direction. Declared from the implementation, which already read it: sjui view_converter.rb:140 (SafeAreaView routes through the View converter) (plan 51-E)."
4580
+ },
4581
+ "gradient": {
4582
+ "type": "array",
4583
+ "description": "Gradient colors. Declared from the implementation, which already read it: sjui view_converter.rb:456 (plan 51-E).",
4584
+ "platform": "swift"
4585
+ },
4586
+ "spacing": {
4587
+ "type": [
4588
+ "number",
4589
+ "binding"
4590
+ ],
4591
+ "description": "Space between children (binding supported). Declared from the implementation, which already read it: sjui view_converter.rb:181 (plan 51-E).",
4592
+ "mode": [
4593
+ "swiftui",
4594
+ "compose",
4595
+ "react"
4596
+ ]
4196
4597
  }
4197
4598
  },
4198
4599
  "TabView": {
@@ -8,28 +8,26 @@
8
8
  "styleAlone": "no-draw",
9
9
  "ruling": "A border is drawn only when BOTH borderWidth and borderColor are declared; borderWidth alone renders nothing on every platform — there is no default border color (2026-08-03 user ruling; supersedes the transient gray-default direction of d2c8628, unified across toolchains in 3e87b96). borderColor alone renders nothing for the same reason — the pair is what requests a border, and neither half requests one on its own (measured 2026-08-04, plan 34 re-measure: 0 px against the control on all three platforms; the android codegen does emit on the attribute's PRESENCE, which is a codegen-stage fact plan 41 tracks, not a rendered difference). borderStyle alone likewise renders nothing: it styles a border the width+color pair requests, it never summons one (2026-08-03 user ruling #2). NOTE: TextField.borderStyle is a DIFFERENT attribute (UIKit text-field chrome: roundedRect/line/bezel/none) and is outside this entry. The `__*_with_border` fixtures are the paired variants (borderStyle declared ALONGSIDE borderWidth + borderColor) that plan 49 added once three lanes had implemented the attribute with nothing measuring it — the lone-style fixtures above stay inert and keep testing the AND rule. `solid_with_border` is inert for a different reason from the lone ones: solid is the declared default of borderStyle, so stating it explicitly draws the same picture as omitting it (codegen_effect calls this class `value-is-default`). dashed/dotted are the two that must differ from the control. If the 3PF measurement comes back split rather than uniform, the honest record is a per-platform map like skinIdioms uses, not a forced uniform verdict: plan 34 caught ios emitting identical text for solid and dashed (C2/presence-only), and B/C/A implemented the three platforms separately in this wave. MEASURED 2026-08-05 (conformance-mobile run 30950551601 @ 5aada98), and the split the paragraph above anticipated is what happened: dashed and dotted are active on android and web and INERT on ios — the three borderStyle values render pixel-identical to each other and to the control on ios, which is the same symptom plan 34 recorded at the codegen stage (sjui emitting the same text for solid and dashed, C2/presence-only). They are therefore recorded as per-platform maps, not forced to a uniform verdict. This records today's reality; it does not bless it. If ios is supposed to draw a dashed border, the entry moves to uniformly-active once it does. solid_with_border stays uniformly-inert on all three because solid is borderStyle's declared default. RESOLVED 2026-08-05 on run 30990560599: ios draws both dashed and dotted, so the per-platform split is folded back to uniformly-active. The gap was never in sjui's codegen — that emitted StrokeStyle from dc35e82 — but in the SwiftUI dynamic renderer's applyBorder, which stroked with lineWidth only and so drew every style as solid. Recorded because the shape recurs: a codegen and a dynamic path disagreeing looks exactly like a platform not implementing an attribute.",
10
10
  "observable": {
11
- "common/borderWidth__static": "uniformly-inert",
12
- "common/borderColor__static": "uniformly-inert",
11
+ "common/borderWidth__static": "uniformly-active",
12
+ "common/borderColor__static": "uniformly-active",
13
13
  "common/borderStyle__solid": "uniformly-inert",
14
14
  "common/borderStyle__dashed": "uniformly-inert",
15
15
  "common/borderStyle__dotted": "uniformly-inert",
16
16
  "common/borderStyle__solid_with_border": "uniformly-inert",
17
17
  "common/borderStyle__dashed_with_border": "uniformly-active",
18
18
  "common/borderStyle__dotted_with_border": "uniformly-active"
19
- }
19
+ },
20
+ "observableNote": "MEASURED 2026-08-08 (run 31243724782 + local-env): uniformly active — the divergence this entry recorded was fixed in-wave (ios Blur bridge / ios highlighted swap / ios distribution fill / ios loadingImage via pending.invalid / border emit on all faces)."
20
21
  },
21
22
  "highlight": {
22
23
  "highlightedAlone": "no-draw",
23
24
  "highlightBackgroundAlone": "no-draw",
24
25
  "ruling": "The highlighted appearance is drawn only when BOTH `highlighted` and a highlight colour are declared — the same pair-required shape as `border`, and ruled the same way. `highlighted: true` alone swaps the background to a colour that was never given, so nothing changes; `highlightBackground` alone names a colour for a state the view is never in. Every codegen already treats them as a pair (kjui modifier_builder.rb:490 combines highlight_cond with highlight_bg; sjui apply_highlighted_to_bag does the same), so a single-attribute fixture measures the absence of the companion rather than the converter. Established by measurement, not assumption: plan 41's paired probe carried View.highlighted as its one PROVISIONAL entry, and giving it `highlightBackground` made the ios/C0 and android/C0 findings disappear — the converters were correct and the fixture was missing the other half. This entry retires that PROVISIONAL: the pairing is now derived from the ledger like every other family (2026-08-05, plan 49-E, on plan 41's evidence). MEASURED 2026-08-05 (same run): web is active — A routed the highlight background through an inline style instead of a losing Tailwind class — while ios and android are still inert, so the verdict is recorded per platform. NOTE for anyone tidying this block: `common/highlightBackground__static` carries no control in the generated fixture set, so it is never compared and its verdict here is not checked against anything. It must NOT be deleted regardless — companions.py derives the pairing from this topic's `observable` KEYS, and a topic with fewer than two members stops producing companions at all. Same permanent-unverifiable note as networkImage's loadingImage applies to `common/highlightBackground__static`: rules.py scopes it to the paired form, so the bare fixture has no control and the entry can never be checked at the render stage. It stays because companions.py derives the View.highlighted pairing from these keys — deleting it would stop the pairing, not just the check. MEASURED 2026-08-06 on run 31068716186: android is now active (152,960 px) — the highlighted+highlightBackground pair landed on android; ios remains inert.",
25
26
  "observable": {
26
- "View/highlighted__true": {
27
- "ios": "inert",
28
- "android": "active",
29
- "web": "active"
30
- },
27
+ "View/highlighted__true": "uniformly-active",
31
28
  "common/highlightBackground__static": "uniformly-inert"
32
- }
29
+ },
30
+ "observableNote": "MEASURED 2026-08-08 (run 31243724782 + local-env): uniformly active — the divergence this entry recorded was fixed in-wave (ios Blur bridge / ios highlighted swap / ios distribution fill / ios loadingImage via pending.invalid / border emit on all faces)."
33
31
  },
34
32
  "image": {
35
33
  "defaultContentMode": "fit",
@@ -50,12 +48,14 @@
50
48
  },
51
49
  "size": {
52
50
  "maxBoundsClampFill": "matchParent is clamped by maxWidth/maxHeight",
53
- "ruling": "2026-08-03 user ruling (31 handoff): matchParent + maxWidth/maxHeight resolves to min(parent extent, bound) — the bound clamps the fill. Generated code is canonical (kjui emits the constraint before the fill since 847fb56); KJUI dynamic ordered fillMaxWidth before widthIn, ignoring the bound entirely — reordered in the 33 wave (bounds precede a fill, stay after an explicit size). The composite clamp fixtures below are the observable: a clamping platform renders visibly narrower/shorter than its filled control (active); ignoring the bound renders identically (inert). Companion precedence (2026-08-03 campaign adjudication): an EXPLICIT numeric dimension pins the constraints and its bound stays inert (kjui codegen 847fb56, ios 35efc2b, rjui skips max-* under an explicit size); bounds in turn clamp a FILL. Order: explicit > bounds > fill.",
51
+ "ruling": "2026-08-03 user ruling (31 handoff): matchParent + maxWidth/maxHeight resolves to min(parent extent, bound) — the bound clamps the fill. Generated code is canonical (kjui emits the constraint before the fill since 847fb56); KJUI dynamic ordered fillMaxWidth before widthIn, ignoring the bound entirely — reordered in the 33 wave (bounds precede a fill, stay after an explicit size). The composite clamp fixtures below are the observable: a clamping platform renders visibly narrower/shorter than its filled control (active); ignoring the bound renders identically (inert). Companion precedence (2026-08-03 campaign adjudication): an EXPLICIT numeric dimension pins the constraints and its bound stays inert (kjui codegen 847fb56, ios 35efc2b, rjui skips max-* under an explicit size); bounds in turn clamp a FILL. Order: explicit > bounds > fill. 2026-08-07 (plan 51-E), the wrap-bound family D asked for as the companion to maxBoundsClampFill. `width: wrapContent` says size to the content and `maxWidth` declares a MAXIMUM, not a target, so the two compose in only one way: the content sizes itself, the bound caps it, and content that would exceed the cap REFLOWS inside it rather than overflowing or stretching the box out to the bound. A short string under `maxWidth: 150` therefore stays narrow — the bound is not an ideal size, which is the half that only existed as a comment in sjui frame_helper.rb until now. ios already emits the canonical shape (`.frame(maxWidth: 150).fixedSize(horizontal: false, vertical: true)`), android `wrapContentWidth().widthIn(max=150)` and web `w-fit max-w-[150px]` agree. Note for fixture authors, from D's measurement: the generic maxWidth sweep cannot see this axis at all — a 40pt child in a 200pt box does not move by one pixel — so the observable needs REFLOWABLE text, which is why common/maxWidth__wrap is bespoke in bounds_fixtures.py.",
54
52
  "observable": {
55
53
  "common/maxWidth__fill_clamp": "uniformly-active",
56
- "common/maxHeight__fill_clamp": "uniformly-active"
54
+ "common/maxHeight__fill_clamp": "uniformly-active",
55
+ "common/maxWidth__wrap": "uniformly-active"
57
56
  },
58
- "explicitOverBounds": "an explicit numeric dimension wins over its min/max bound"
57
+ "explicitOverBounds": "an explicit numeric dimension wins over its min/max bound",
58
+ "wrapBoundReflow": "a wrapContent axis with a max bound reflows the content INSIDE the bound; the bound never becomes the ideal size"
59
59
  },
60
60
  "networkImage": {
61
61
  "noSrc": "defaultImage",
@@ -68,11 +68,9 @@
68
68
  },
69
69
  "NetworkImage/hint__static": "uniformly-inert",
70
70
  "NetworkImage/errorImage__static": "uniformly-active",
71
- "NetworkImage/loadingImage__static": {
72
- "ios": "inert",
73
- "android": "active"
74
- }
75
- }
71
+ "NetworkImage/loadingImage__static": "uniformly-active"
72
+ },
73
+ "observableNote": "MEASURED 2026-08-08 (run 31243724782 + local-env): uniformly active — the divergence this entry recorded was fixed in-wave (ios Blur bridge / ios highlighted swap / ios distribution fill / ios loadingImage via pending.invalid / border emit on all faces)."
76
74
  },
77
75
  "margins": {
78
76
  "offsetModel": "ZStack/overlay child margins are full-margin offsets; an explicit center declaration disables that axis margin; margins arrays and start/endMargin apply as padding",
@@ -81,8 +79,14 @@
81
79
  },
82
80
  "gravityDefaults": {
83
81
  "defaultGravity": "top|start",
84
- "ruling": "2026-08-03 campaign adjudication: the default content gravity is top|start on every container (CollectionStack pins Top; SafeAreaView spacer layouts anchor start). A platform centering by default is the deviant. No activeness observable — default-vs-default renders identically; the geometry probe closes it mechanically.",
85
- "observable": {}
82
+ "ruling": "2026-08-03 campaign adjudication: the default content gravity is top|start on every container (CollectionStack pins Top; SafeAreaView spacer layouts anchor start). A platform centering by default is the deviant. No activeness observable — default-vs-default renders identically; the geometry probe closes it mechanically. 2026-08-07 (plan 51-E), Wave 2 carry-over: what happens to the axis a single-value gravity does NOT name. Ruled from the declaration alone, as the semantics rule requires, not from any platform's behaviour. common.gravity declares an enum of SINGLE-AXIS values (top, bottom, left, right, centerVertical, centerHorizontal) alongside an array type whose whole purpose is naming both axes at once. If a single-axis value left the other axis undefined, then `gravity: \"left\"` would have no defined rendering at all, and a declaration cannot leave its own enum values meaningless — so each axis resolves independently, and the axis that is not named takes the default already ruled above (top | start). It does NOT inherit the parent's gravity and it is not \"unset\". CONSEQUENCE, stated because it looks like a bug when measured: in LTR, `gravity: \"left\"` and `gravity: \"top\"` both resolve to (start, top) and are therefore observationally IDENTICAL. The Wave 2 note that ios draws `left` and `top` the same picture is the contract being honoured, not a defect; a fixture that expects those two to differ is testing something the SSoT never promised. Whether a given fixture can see the horizontal axis at all is a separate, real question — a child that fills the container's width makes it unobservable (D's lane). 2026-08-07 (plan 51-E), B's follow-up measurement: sjui emits `alignment: .topLeading` for every NAMED form (`left|top`, `left`, `top`) but drops the `alignment:` argument entirely when gravity is omitted, so SwiftUI falls to `.center` (51-B-progress). That is the deviation this ruling already named — \"a platform centering by default is the deviant\" — and the argument is the same one that settled the unspecified axis: if a partially specified gravity fills the unnamed axis with the default, then an omitted gravity fills BOTH axes with it. Any other reading makes omitting gravity mean something different from naming the default explicitly, which no declaration says. So the omitted form must also emit topLeading. NOTE FOR ROLLOUT, not for the canon: every View in every project that does not declare gravity goes through this line, so the fix wants a 3PF measurement and a fixture that can actually see it (a fixed-size container whose child does not stretch — with a stretching child the Spacer construction is identical in all four forms and the difference is unobservable). Sequencing is the orchestrator's; the canon is not in doubt.",
83
+ "observable": {},
84
+ "unspecifiedAxis": "falls to the container default for that axis (top vertically, start horizontally) — never \"unset\", never inherited from the parent",
85
+ "omittedEntirely": "same as the default: top | start. Omitting gravity must render identically to naming the default explicitly",
86
+ "leafOwnFrameChannel": {
87
+ "default": "center",
88
+ "ruling": "2026-08-08 adjudication (gravity leaf-scope hotfix, v1.5.2/10.15.1): the container default above scopes to CONTAINERS — nodes whose content the framework itself lays out. A childless node's own-frame content (a fit image's bitmap inside its declared box, a custom component's internals, a text block smaller than its fixed box) is a SEPARATE channel whose default is Center: Compose's Image/extension alignment default, which kjui never overrides, and the position the android face measurably draws a shrunken autoShrink line in its fixed box. ios converged in SwiftJsonUI 245d059+4d41bf9 (codegen gravity_to_frame_alignment leaf scope; dynamic fixed-frame text drop). A DECLARED gravity or textAlign still applies to any node — this entry names only the omitted-spelling default. Implementations were aligned BEFORE this text landed; the entry records the ruling the code already obeys."
89
+ }
86
90
  },
87
91
  "shadow": {
88
92
  "stringPipe": "five-field strict",
@@ -100,16 +104,8 @@
100
104
  "demarcation": "control chrome may differ per platform; the DIFFERENCE ITSELF is contract-pinned",
101
105
  "ruling": "2026-08-03 campaign adjudication: platform-owned control chrome is a legitimate difference, but only in its adjudicated SHAPE — the observable pins each accepted divergence as an expected-difference pattern, so both disappearing and mutating fail the gate. Members: Blur effectStyle (android material variants differ visibly, ios materials render alike on flat surfaces per the 31 saturation analysis, web CSS blur is single-variant), TextField.borderStyle (UIKit field chrome — each platform draws its own; web renders none for the UIKit-only bezel variants), TabView.showLabels WAS a member on the strength of a `__true` measurement — the DEFAULT value — and left the topic on 2026-08-05 when run 30990560599 measured both values on all three: `true` is inert everywhere (it is the default, so declaring it draws what omitting it draws) and `false` is active everywhere (ios 1491 px, android 8427, web 3019), with the two values distinguishable on every platform. There is no ios-only divergence to pin, so the entry moves to `__false` as a plain uniformly-active fact and the attribute is no longer a skin idiom. The original reading came from comparing the default value against its control, which cannot show what a non-default value does. The tabview glyph-source family is the codegen-side counterpart, pinned permanently in codegen_parity.json (2026-08-03 user ruling).",
102
106
  "observable": {
103
- "Blur/effectStyle__extralight": {
104
- "ios": "inert",
105
- "android": "active",
106
- "web": "inert"
107
- },
108
- "Blur/effectStyle__light": {
109
- "ios": "inert",
110
- "android": "active",
111
- "web": "inert"
112
- },
107
+ "Blur/effectStyle__extralight": "uniformly-active",
108
+ "Blur/effectStyle__light": "uniformly-active",
113
109
  "TextField/borderStyle__bezel": {
114
110
  "ios": "inert",
115
111
  "android": "active",
@@ -141,7 +137,8 @@
141
137
  "web": "inert"
142
138
  },
143
139
  "TabView/showLabels__false": "uniformly-active"
144
- }
140
+ },
141
+ "observableNote": "MEASURED 2026-08-08 (run 31243724782 + local-env): uniformly active — the divergence this entry recorded was fixed in-wave (ios Blur bridge / ios highlighted swap / ios distribution fill / ios loadingImage via pending.invalid / border emit on all faces)."
145
142
  },
146
143
  "progressValue": {
147
144
  "canonical": "progress",
@@ -175,11 +172,7 @@
175
172
  "spacingWins": "an explicit spacing pins the gap; distribution still governs size",
176
173
  "ruling": "2026-08-05 (plan 49-E), raised by F/G and measured across all three toolchains. The vocabulary is UIStackView's, and its four values split into TWO KINDS that the current implementations conflate: `fill` and `fillEqually` distribute SIZE among the children, while `equalSpacing` and `equalCentering` distribute the FREE SPACE between them. fill = children grow to consume the axis, leaving no free space (proportional to their content size); fillEqually = every child gets the SAME size regardless of content; equalSpacing = equal gaps between adjacent children, with no leading or trailing gap; equalCentering = equal centre-to-centre distances. CONSEQUENCE, and this is the part every platform gets wrong today: the two size values are NOT an arrangement. They are `Modifier.weight(1f)` on each child in Compose, `.frame(maxWidth: .infinity)` (or equal weights) in SwiftUI, and `flex-1` in CSS — not Arrangement.* and not justify-content. Mapping `fill` to SpaceBetween/justify-between is precisely backwards: fill means there is no free space left to distribute. MEASURED STATE 2026-08-05 — all three disagree with the canon AND with each other, and each collapses a DIFFERENT pair of values into one output, so no fixture comparing two declared values can tell them apart: android (kjui container_component.rb:129-140) sends fillEqually and equalCentering both to Arrangement.SpaceEvenly; web (rjui view_converter.rb:184-195) sends the same pair both to justify-evenly; ios (sjui view_converter.rb:217,367,383) synthesises identical Spacer sets for equalSpacing and equalCentering, and gives fillEqually between-only spacers, which is space-between rather than equal sizes. `fill` emits nothing on ios (correct by accident) and a space-between arrangement on the other two (wrong). WHY NO GATE CAUGHT IT: --inert-complete asks whether a fixture differs from its CONTROL. Two declared values that both differ from the control but are identical to EACH OTHER pass that check, and android parity passes too because codegen and dynamic collapse them the same way. A value-vs-value comparison is the missing instrument — this is the 'both wrong in the same direction reads as green' shape. SPACING PRECEDENCE: `spacing` and `distribution` are not mutually exclusive and the current Compose behaviour (spacing != null wins outright, distribution never read) is wrong for the size values. An explicit `spacing` pins the GAP, so it overrides the gap that equalSpacing/equalCentering would compute; it says nothing about size, so fill/fillEqually still apply underneath it. Same shape as the size topic's explicit > bounds > fill ordering: the more specific declaration wins the axis it speaks about, and only that axis. No observable entries yet: the fixtures that would prove this need to compare two declared VALUES against each other rather than each against the control, which the current generator does not emit. That instrument is the follow-up this ruling asks for. PER-VALUE MAPPING added 2026-08-05 after A implemented the ruling on web (jsonui-cli 651403e) — recorded so B and C match that choice instead of re-deriving it, which is how the three drifted apart in the first place. The `perValueMapping` block above is the table; the two decisions worth calling out are that the SIZE values are carried to the CHILDREN (a per-child flex/weight instruction, not a container arrangement) and that equalCentering maps to SpaceAround / justify-around rather than SpaceEvenly — SpaceEvenly equalises the GAPS, which is equalSpacing's job, and collapsing the two is precisely the conflation this ruling exists to undo. web landed first and is the reference implementation; ios (sjui view_converter.rb, still synthesising Spacers for both gap values) and android (kjui container_component.rb:129, still mapping four values onto two Arrangements) are outstanding. CHILD SIZE PRECEDENCE, ruled 2026-08-05 after G measured android and found `fill` leaving 60dp unconsumed: an EXPLICIT width/height on the child wins over the size half of distribution, so `fill` and `fillEqually` do not override a declared child size. This is not a new principle — the `size` topic's 2026-08-03 campaign adjudication already ordered explicit > bounds > fill, and distribution's size half IS a fill instruction on the child's axis, so it sits at the bottom of that order like any other. It is also what `spacingWins` says one line up: the more specific declaration wins the axis it speaks about. CONSEQUENCE for the measurement, and the reason this had to be ruled rather than implemented: a fixture whose children declare `width: 40` cannot demonstrate `fill` at all — the 60dp G measured is the correct outcome, not a defect. The fixture has to omit the child dimension on the distribution axis for the attribute to have anything to do. That makes it the fourth case of a fixture shape hiding the attribute it is supposed to measure (after SafeAreaView's matchParent, alignTop/LeftOfView off-screen, and SelectBox with nothing to decorate). android's current implementation is correct as it stands; no three-platform change follows from this. FIRST VALUE-VS-VALUE MEASUREMENT 2026-08-05, on run 30972192150's artifacts once D's fixtures (0035321) and H's --value-discrimination gate (f54d3d5) existed, with control_diff.ignore_bands applied. It confirms the collapse this ruling was written from, platform by platform and by measurement rather than by reading converters: ios draws equalSpacing and equalCentering pixel-identically, android and web draw fillEqually and equalCentering pixel-identically. Three platforms, two different collapsed pairs, nobody agreeing — recorded in `collapsedPairs` so the fix can be checked off per platform. `fill` is ALSO inert against its control on ios while active on android and web, which is not a contradiction of explicitChildSizeWins: the fixture's children carry an explicit width, so no platform should be moving them, and the two that do are moving the TRACK rather than the child. The entry is per-platform for that reason and must not be flattened to uniformly-inert until the fixture drops the child width — at which point fill becomes measurable and this entry gets re-measured, not edited. RE-MEASURED on run 30990560599 after D removed the child width from the size-half fixtures (661bfba). web now distinguishes all four values — A's implementation is the first to satisfy the ruling end to end, and `collapsedPairs` loses its web row. ios still collapses equalSpacing with equalCentering and android now collapses fill with fillEqually (it had been fillEqually with equalCentering): the pair moved because the fixture changed, so android's remaining defect is in the SIZE half, where fill and fillEqually must differ — fill grows children from their content, fillEqually makes them equal regardless of it. `fill` stays inert on ios: with the child width gone the fixture can finally demonstrate it, and ios does not. Run 31068716186 (2026-08-06) measured android fill != fillEqually with both active against the control — C's 27b9b13 (codegen) and G's c7d2dfb (DistributionFillRow) closed the SIZE-half collapse, so the android collapsedPairs row is deleted BY MEASUREMENT, not by edit. The ios pair (equalSpacing == equalCentering) persists: run-5 geometry shows identical child positions and a 32 px gap for both values. Run 31080217407 (2026-08-06) measured ios distinguishing equalSpacing from equalCentering — B (b57e327/3dc3795) and F (e8f99c7) implemented the pinned iosGapConstruction, and the geometry is exactly the canon's: equalCentering shifts the first child right by the half-gap edge spacer (box_a 154 -> 168 px) while every child keeps its declared size (118 px). collapsedPairs is now EMPTY — all three platforms distinguish all four values, which is the state this ruling was written to reach. The empty map stays as the healthy-state marker, same convention as companions.py's emptied PROVISIONAL.",
177
174
  "observable": {
178
- "View/distribution__fill": {
179
- "ios": "inert",
180
- "android": "active",
181
- "web": "active"
182
- },
175
+ "View/distribution__fill": "uniformly-active",
183
176
  "View/distribution__fillequally": "uniformly-active",
184
177
  "View/distribution__equalspacing": "uniformly-active",
185
178
  "View/distribution__equalcentering": "uniformly-active"
@@ -216,7 +209,8 @@
216
209
  "note": "Pinned 2026-08-06 (E's proposal, orchestrator-approved: implement this release, B codegen + F dynamic) so the two ios paths emit ONE counting instead of deriving Spacer arithmetic twice — two paths growing children separately is exactly what collapsed distribution's SIZE half before c7d2dfb. Spacer counting suffices because HStack/VStack split free space equally among unconstrained Spacers: the 1:2 end:between count ratio IS space-around's half-gap. No custom Layout, no measurement pass. Acceptance is already wired: when this lands, run 6 must delete collapsedPairs' ios row and turn the two value_discrimination receiving rows stale. Regression-test discipline from F's landing: a partial reversion (between 2->1 only) builds an equal-three-spacer structure the first sample points slipped past — pin BOTH sides of the computed boundary (55 in the 300pt fixture) so partial and full reversion both fail."
217
210
  },
218
211
  "explicitChildSizeWins": "an explicit child width/height beats distribution's size half — size topic's explicit > bounds > fill",
219
- "collapsedPairs": {}
212
+ "collapsedPairs": {},
213
+ "observableNote": "MEASURED 2026-08-08 (run 31243724782 + local-env): uniformly active — the divergence this entry recorded was fixed in-wave (ios Blur bridge / ios highlighted swap / ios distribution fill / ios loadingImage via pending.invalid / border emit on all faces)."
220
214
  },
221
215
  "trackColors": {
222
216
  "sliderTrack": "trackTintColor is the UNFILLED track; progressTintColor is the filled one",
@@ -234,8 +228,154 @@
234
228
  "iosSwitchConstraint": "SwiftUI's Toggle has no paintable OFF-track surface; both ios paths degrade to .tint(), visible only while ON"
235
229
  },
236
230
  "fontProvider": {
237
- "ruling": "Configuration.Font.fontProvider is the app's typeface-policy injection point, and the presence of `fontFamily` must NOT change whether it is consulted: every font resolution routes through the provider as FontSpec(family:, weight:, size:), family nil included. A path that bypasses the provider when family is absent makes the same attribute on the same screen pass or skip the app's policy depending on mode — the checked/unchecked shape this wave kept finding. The codegen path is the conforming side; SwiftJsonUI's dynamic path (DynamicDecodingHelper.fontFromComponent) is being aligned to it with a regression test (F, plan 49). A provider that ignores family nil sees no change; one that returns a custom face was silently falling back to the system font on dynamic — either way the contract converges on: the typeface does not depend on mode. Preserving a consumer's current look is not a reason to keep the bypass (canonical semantics win). 2026-08-06 ruling.",
238
- "contract": "family presence must not gate provider consultation — every implementer, current or future, routes every FontSpec through fontProvider"
231
+ "ruling": "Configuration.Font.fontProvider is the app's typeface-policy injection point, and the presence of `fontFamily` must NOT change whether it is consulted: every font resolution routes through the provider as FontSpec(family:, weight:, size:), family nil included. A path that bypasses the provider when family is absent makes the same attribute on the same screen pass or skip the app's policy depending on mode — the checked/unchecked shape this wave kept finding. The codegen path is the conforming side; SwiftJsonUI's dynamic path (DynamicDecodingHelper.fontFromComponent) is being aligned to it with a regression test (F, plan 49). A provider that ignores family nil sees no change; one that returns a custom face was silently falling back to the system font on dynamic — either way the contract converges on: the typeface does not depend on mode. Preserving a consumer's current look is not a reason to keep the bypass (canonical semantics win). 2026-08-06 ruling. 2026-08-07 (plan 51-E), raised by G: android's codegen default provider (Configuration.defaultResolved) declares that it deliberately DISCARDS generic family names such as serif, while ios and web resolve them. The default provider must resolve them. `fontFamily` is declared as a family NAME with no enumeration and no carve-out, so a generic name is an ordinary value of the attribute, and every platform has real typefaces behind those names. The ruling above already fixed that a provider must always be CONSULTED; this fixes the other half — the DEFAULT provider must not silently narrow the attribute's value space. An app that wants to ignore generic families can still do it by injecting its own provider, which is what the injection point is for; the default is not the place to encode that policy, because it makes the same layout render a different typeface on android with nothing in the layout saying so. Owner: C (android codegen) and G (dynamic, in step).",
232
+ "contract": "family presence must not gate provider consultation — every implementer, current or future, routes every FontSpec through fontProvider",
233
+ "genericFamilies": "the default provider MUST resolve generic family names (serif, sans-serif, monospace, ...) — dropping them is not a policy decision the default gets to make"
234
+ },
235
+ "textDecoration": {
236
+ "attributes": [
237
+ "Label.underline",
238
+ "Label.strikethrough",
239
+ "partialAttributes[].underline"
240
+ ],
241
+ "faces": {
242
+ "boolean": "true draws the line; false draws nothing",
243
+ "object": "draws the line DESCRIBED by the object — never less than the boolean face draws",
244
+ "lineStyleNone": "lineStyle: \"None\" is the one object value that draws NOTHING; it is exactly equivalent to false"
245
+ },
246
+ "unstyledDefaults": {
247
+ "color": "the text's own colour — `color` declares no `default`, so it MODIFIES the line rather than bringing it into existence, and an absent colour means 'do not modify'",
248
+ "lineStyle": "Single",
249
+ "lineOffset": "no baseline adjustment; declared on underline only — strikethrough has no lineOffset and must not invent one"
250
+ },
251
+ "ruling": "2026-08-07 (plan 51-E §3), written so the implementation lanes have an expected value to match; presence was repaired in 10.14.1 and the STYLE content is what remains. The contract is derived from the declaration: underline/strikethrough declare boolean|object, and the object face's properties (lineStyle, color, lineOffset) carry no `default`, which by the SSoT's own convention makes them MODIFIERS of a line that the attribute's presence already brought into existence. Two consequences are load-bearing. FIRST, the object face must never render LESS than the boolean face: `underline: {...}` is a more specific request than `underline: true`, so a platform that cannot yet honour lineStyle or color must still draw the plain Single line in the text colour. Rendering nothing is always wrong. SECOND, `lineStyle: \"None\"` is the sole exception and means no line at all — identical output to `false`. MEASURED STATE, 2026-08-07, so nobody re-derives it: sjui text_style_helper.rb:130-135 (line_decoration?) and kjui text_component.rb:455 (decoration_on?) both implement exactly this None rule and then draw a plain line, ignoring colour and lineOffset. rjui label_converter.rb:371 is a bare truthiness test (`classes << 'underline' if attributes['underline']`), so on web a Hash is always truthy and `{lineStyle: \"None\"}` DRAWS A LINE — probed directly through the converter, not read: true / {Single} / {None} / {color:#FF0000} all emit the same `underline` class. That is a defect owned by A. kjui's DYNAMIC renderer drops the object face entirely (cross_effect Label/underline__styled, Label/strikethrough__styled: android inert, ios and web active), which violates the never-less-than-boolean rule and is owned by G. Double and Thick are declared but no face distinguishes them from Single today; they stay declared because the enum is the contract, and the gap belongs in the coverage ledger rather than being papered over by deleting the values. 2026-08-07 addendum (plan 51-E), F reporting after the SwiftUI halves were aligned: SJUILabel.swift:448 (the UIKit face) still falls `lineStyle: \"None\"` through to .single, so one library now answers the same spelling two ways. THE CONTRACT BINDS UIKIT. `Label.underline` and `Label.strikethrough` declare no `mode` key, so they apply to every mode, and UIKit is not frozen — nothing in SwiftJsonUI declares it so, and the SSoT carries 93 uikit-mode attribute declarations against swiftui's 30. Narrowing the ruling to swiftui would state something the declaration does not. So it is a defect owned by F, correctly filed as not urgent: conformance never renders it.",
252
+ "observable": {},
253
+ "modeIndependent": "the contract binds every mode, uikit included — Label.underline/strikethrough declare no `mode` key"
254
+ },
255
+ "backgroundFill": {
256
+ "attributes": [
257
+ "common.background",
258
+ "View.gradient",
259
+ "GradientView.gradient"
260
+ ],
261
+ "precedence": "gradient wins; background is the fallback fill, not a layer underneath it",
262
+ "ruling": "2026-08-07 (plan 51-E), raised by B with both ios paths measured. `background` and `gradient` fill the SAME surface, and the SSoT declares no precedence between them. Ruled: a declared gradient WINS, and background is what fills the surface when no gradient is declared or the face cannot produce one. Two grounds, both internal. FIRST, `gradient` is the more specific statement about the fill — an array of stops against a single colour — and this SSoT has already ruled that a more specific request must never render less than the general one (see textDecoration, where the object face may not draw less than the boolean). SECOND, the alternative readings do not survive their own consequences: if background won, then declaring a gradient on a view that also has a background — the ordinary case — would make the gradient silently inert, which is precisely the ios defect B is holding open; and a LAYERED reading (background painted, gradient composited over it) would require an alpha/compositing model that neither declaration mentions, and would make `background` visible-but-covered, i.e. unobservable and untestable. One surface, one fill, most specific wins. MEASURED STATE 2026-08-07: ios currently renders a THIRD answer — the declared gradient is always invisible, on both the codegen and dynamic paths (B: MODIFIER_ORDER puts :background adjacent to :gradient for views with children, and a childless View becomes `Rectangle().fill(background)` and never reaches the order table at all). Both paths are B's and F's to close, together. The other two faces do not contradict this ruling because they do not implement this attribute pair: `View.gradient` is declared `platform: swift`, kjui reads `gradient` only in GradientView and in the FROZEN xml drawable path, and rjui expresses a gradient through the `background` string itself (CSS gradient syntax, base_converter.rb:180). GradientView is unaffected — there the gradient is the component's whole purpose.",
263
+ "observable": {}
264
+ },
265
+ "selectBoxDate": {
266
+ "attributes": [
267
+ "SelectBox.selectedDate",
268
+ "SelectBox.selectItemType",
269
+ "SelectBox.prompt"
270
+ ],
271
+ "closedFaceWithNoSelection": "the prompt / hint / placeholder — never a fabricated date",
272
+ "ruling": "2026-08-07 (plan 51-E), raised by F with B's measurement agreeing: with `selectItemType: Date` and no `selectedDate`, ios shows an empty closed face while android and web show the current date/time. IOS IS CANON. `selectedDate` declares NO `default`, and by this SSoT's convention that makes it a modifier rather than an attribute that brings a value into existence — its absence means no initial selection has been stated, not 'today'. SelectBox already declares prompt / hint / placeholder, which is the mechanism for \"nothing chosen yet\", so the closed face has something correct to show and does not need to invent one. Two consequences make the android/web behaviour actively wrong rather than merely different: it is unobservable (a caller cannot distinguish 'no selection' from 'the user picked today'), and `selectedDate` is declared two-way, so seeding it writes today's date back into the viewmodel on first render without the user ever opening the picker — a default that silently manufactures data. Owners: C (android) and A (web).",
273
+ "observable": {}
274
+ },
275
+ "clipToBounds": {
276
+ "default": false,
277
+ "ruling": "2026-08-07 (plan 51-E), from E2's measurement: web does not clip by default, android does. Declared `default: false`. Two internal grounds, neither a head count. FIRST, `shadow` is a common attribute and a clipping parent destroys a child's shadow; a default that silently disables another common attribute contradicts the declaration that offers it. SECOND, the SSoT gives this concept exactly one spelling, the opt-in `clipToBounds`, and on a platform that already clips, declaring `clipToBounds: true` is INERT — an attribute you cannot observe declaring. Making false the default is what gives the attribute something to say on every platform: absent means no clip, `true` means clip. android is the deviant and the change is C's and G's; as with gravity, the canon is not in doubt but the rollout wants a measurement, since every container in every android project goes through this default.",
278
+ "observable": {},
279
+ "governsHitTesting": "yes — clipping removes the overflow from hit testing as well as from drawing; see attribute_semantics.json -> offset.overflowHitTesting"
280
+ },
281
+ "indicatorStopped": {
282
+ "attributes": [
283
+ "Indicator.animating",
284
+ "Indicator.hidesWhenStopped"
285
+ ],
286
+ "falseFace": "the stopped indicator is BOTH drawn and laid out",
287
+ "trueFace": "the stopped indicator collapses out of the layout entirely",
288
+ "ruling": "2026-08-07 (plan 51-E), raised by F before implementing: on the false face ios codegen keeps an `opacity(0)` spinner while dynamic emits EmptyView. BOTH ARE WRONG, in different directions, and the declaration already says so — Indicator.animating states that hidesWhenStopped 'decides whether the stopped indicator keeps its space or collapses out of the layout'. So `true` collapses it and `false` keeps it, which makes dynamic's EmptyView an implementation of the TRUE face on the false branch. codegen gets the space right and the visibility wrong: an attribute named hidesWhenStopped set to false cannot hide anything, and opacity(0) is hiding. The false face is a stopped indicator that is drawn AND occupies its space. CONSISTENT WITH the Wave 2 effect ledger, which removed hidesWhenStopped as spinner phase noise: that note is about MEASURABILITY, not about the contract. Where a platform cannot render a genuinely stopped spinner the fixture stays phase-noisy and effect-excluded; the ledger entry stands and does not license drawing nothing.",
289
+ "observable": {}
290
+ },
291
+ "effectStyle": {
292
+ "attributes": [
293
+ "common.effectStyle"
294
+ ],
295
+ "scope": "all three platforms, and not only on Blur — the declaration is on `common`",
296
+ "spellingCanon": "the nine bare names are canonical; the five system* spellings are declared valueAliases and the normalizer folds them",
297
+ "iosMaterialTable": {
298
+ "ultraThin": {
299
+ "material": ".ultraThinMaterial"
300
+ },
301
+ "thin": {
302
+ "material": ".thinMaterial"
303
+ },
304
+ "regular": {
305
+ "material": ".regularMaterial"
306
+ },
307
+ "thick": {
308
+ "material": ".thickMaterial"
309
+ },
310
+ "chrome": {
311
+ "material": ".bar"
312
+ },
313
+ "light": {
314
+ "material": ".thinMaterial",
315
+ "tint": "white 0.25",
316
+ "colorScheme": ".light"
317
+ },
318
+ "extraLight": {
319
+ "material": ".ultraThinMaterial",
320
+ "tint": "white 0.45",
321
+ "colorScheme": ".light"
322
+ },
323
+ "dark": {
324
+ "material": ".regularMaterial",
325
+ "tint": "black 0.40",
326
+ "colorScheme": ".dark"
327
+ },
328
+ "prominent": {
329
+ "material": ".thickMaterial",
330
+ "tint": "white 0.20"
331
+ }
332
+ },
333
+ "tintLayer": "above the material, BELOW the content — a Blur's children must not be washed by the tint",
334
+ "ruling": "2026-08-07 (plan 51-E). Three questions at once, from B (scope), F (the ios table) and E2 (spelling). SCOPE: three platforms, and not only on Blur. The attribute is declared on `common`, android and web apply it there, and only ios restricts it — so ios is the deviant, not the declaration. This intersects the inert_audit single-platform-scope family: those rows are ios gaps, not evidence for narrowing the scope. SPELLING: the nine bare names are canonical and the five system* spellings are already declared as `valueAliases`, so the normalizer folds them and no implementation needs to know both sets. E2's seven connected rows follow that fold. IOS TABLE: pinned above, from F's implementation in VisualEffectStyle.swift, which is adopted as canon. SwiftUI offers only five Material steps against nine spellings, so a naive mapping would collapse prominent/thick, light/thin, extraLight/ultraThin and dark/regular into four identical pictures and MANUFACTURE value_discrimination collapsedPairs — the table separates the colliding spellings with a tint instead. The tint sits above the material and below the content, which is the layer order `.background(.ultraThinMaterial)` already meant. The table lives in the LIBRARY once and both ios paths call it (F's VisualEffectStyle, B replacing the hardcoded `.ultraThinMaterial` at blur_converter.rb:65 with an emit that calls it) — the same single-table shape kjui took for DistributionFillRow, because two paths growing their own copy always desync. NOT ASSERTED HERE: that the nine render as nine distinct pixel sets. F declined to claim distinctness unmeasured and that is right; `chrome` (.bar) and `thick` are the closest pair. If the next ios run reports a collapsedPair, the fix is a tint on chrome, not a retreat from the table.",
335
+ "observable": {}
336
+ },
337
+ "autoShrink": {
338
+ "attributes": [
339
+ "Label.autoShrink",
340
+ "Label.minimumScaleFactor"
341
+ ],
342
+ "requiresBoundedAxis": "autoShrink is a no-op on an unbounded axis — there is nothing to shrink into",
343
+ "ruling": "2026-08-07 (plan 51-E), raised by A: web shrinks only until the text fits its box, while ios applies the shrink even under wrapContent (an adjustsFontSizeToFitWidth-shaped reading). WEB IS CANON. Shrinking is defined relative to a constraint, and `width: wrapContent` is declared to mean size-to-content — an axis that grows to whatever the text needs and therefore presents nothing to shrink into. Under ios's reading `autoShrink` changes the picture on a box that was never too small, which makes minimumScaleFactor a font-size multiplier rather than a floor, and that is not what either attribute declares (`minimumScaleFactor` declares min 0 / max 1 as a FLOOR for the shrink). So: autoShrink is active only on a bounded axis (an explicit size, matchParent, or a max bound), and is a no-op under wrapContent. Owner: B and F (both ios halves). Not urgent — no lane is blocked on it — but it is a canon question with an answer, not a next-Wave ledger row.",
344
+ "observable": {}
345
+ },
346
+ "collectionSeparators": {
347
+ "attributes": [
348
+ "Collection.hideSeparator",
349
+ "Collection.listStyle"
350
+ ],
351
+ "noOpWhereNoSeparators": "a container that draws no separators satisfies hideSeparator vacuously — that is a no-op by contract, not an unimplemented gap",
352
+ "orthogonality": "listStyle picks the chrome, hideSeparator hides the separators; neither overrides the other",
353
+ "ruling": "2026-08-07 (plan 51-E), raised by B: the ios probe path is CollectionStackView, not SwiftUI's List, and a container that is not a list has no separators to hide — so what should `hideSeparator` draw there? NOTHING, and that is the contract, not a gap. 'Hide the separators' is vacuously satisfied where there are none. The scope is deliberately NOT narrowed: the axis here is the container's rendered SHAPE (Collection's own `layout`, `columns` and `orientation` decide whether it is list-shaped), and `platform` / `mode` cannot express that — narrowing by platform would say something false. More importantly, 'implement it' must NOT mean 'switch the container to a List': an attribute that reshapes the thing it modifies is the defect family this wave exists to remove. hideSeparator modifies a list; it does not create one. MEASUREMENT CONSEQUENCE, so the ledgers do not mis-file it: the fixture must use a list-shaped Collection to observe this at all. Inert on a non-list container is the CONTRACT being honoured, and the coverage rows for both attributes are `runtime-only` (ios dynamic implements them; no codegen face does) rather than `unimplemented`. listStyle: enumerated here from the only implementation that reads it (SwiftJsonUI TableConverter.applyListStyle) as plain / grouped / insetGrouped / sidebar with plain the default — B was right that a bare string could not be validated or discriminated, and B should not replace the hardcoded PlainListStyle() until the vocabulary exists, which it now does. THE TWO ARE ORTHOGONAL. TableConverter.swift:45-49 currently lets `hideSeparator: true` win and forces .plain, which makes a declared `listStyle: \"grouped\"` silently inert whenever separators are hidden — the same silently-inert-declaration shape again. `.listRowSeparator(.hidden)` applies to any list style, so there is no need to force plain. Owner: F.",
354
+ "observable": {}
355
+ },
356
+ "offset": {
357
+ "attributes": [
358
+ "common.offsetX",
359
+ "common.offsetY"
360
+ ],
361
+ "layoutInvariance": "measurement and sibling placement are unchanged — the layout is computed first",
362
+ "interactiveRegion": "moves WITH the view: an offset control is tappable where it is drawn",
363
+ "androidPrimitive": "Modifier.absoluteOffset — NOT graphicsLayer { translationX/Y }, and not the RTL-mirroring Modifier.offset",
364
+ "ruling": "2026-08-07 (plan 51-E), asked by C and G as a one-line confirmation. It is not one line, because the declaration as I wrote it does not decide the question and I should say so rather than rubber-stamp it. C and G read 'paint-time translation' in the offsetX description as naming the Compose DRAW phase. That phrase was my shorthand for layout invariance, not a phase specification, and BOTH candidate primitives satisfy what the description actually says: Modifier.offset changes placement without changing measurement, so siblings do not move and the occupied space is unchanged, exactly as graphicsLayer does. The description has been reworded so the next reader does not have to re-derive that. THE DISCRIMINATOR the declaration was silent on is the INTERACTIVE REGION. A draw-phase translation leaves the touch target at the un-offset position, so the view is drawn in one place and tappable in another. Ruled: the interactive region moves with the view. The ground is internal — `onClick`, `onLongPress` and `canTap` are COMMON attributes that apply to every view, so an offset that decouples them from the drawn position silently breaks another common declaration, which is the same reason clipToBounds does not default to clipping (it would silently break `shadow`). A secondary reason: graphicsLayer forces a render layer and pulls in shadow/alpha/clip compositing side effects that a purely positional attribute has no business requesting. So the android primitive is Modifier.absoluteOffset, not graphicsLayer. ABSOLUTE and not plain Modifier.offset because this SSoT already separates the absolute axis vocabulary from the RTL-aware one — leftMargin/rightMargin against startMargin/endMargin — and `offsetX` is spelled on the X axis, so it must not mirror under RTL. NOT YET MEASURED, and stated as a request rather than a claim: that SwiftUI's .offset carries hit testing with it. The ruling is a design decision either way, but if ios turns out to strand the touch target then ios is the deviant and B/F own it — I am not asserting compliance I have not seen. B / F: please measure. 2026-08-07 addendum, F asking about the part of an offset view that escapes its parent, and rightly separating it from the primitive question above — SwiftUI hit-tests only inside the parent frame, which is a different mechanism from which modifier does the offsetting. Ruled, and it needs no new principle because two declarations already decide it: `clipToBounds` defaults to FALSE, so the overflow is DRAWN, and the ruling above says an offset control is tappable where it is drawn. Therefore the overflow is tappable. Hit testing follows CLIPPING, not the parent's frame, which leaves `clipToBounds` as the single control over both: declared true, the overflow is clipped and is then neither drawn nor tappable; left at its default, it is both. The alternative — drawn but not tappable — is precisely the draw/tap mismatch the offset ruling exists to forbid, so accepting it for the overflow would contradict the same contract one level out. SwiftUI's frame-bounded hit testing is therefore the deviation. It is real work rather than a flag (contentShape / an enlarged hit region), so if ios turns out not to be able to honour it, the honest outcome is a ledger row saying ios cannot — NOT a quiet divergence, and not a weakening of the contract for the other two. NOT MEASURED: android and web overflow hit testing. F supplied the ios half only. C / G / A: please measure yours before anyone implements, so the fix is aimed at the faces that actually deviate.",
365
+ "observable": {},
366
+ "overflowHitTesting": "hit testing follows CLIPPING, not the parent frame: an unclipped overflow is drawn and therefore tappable; a clipped one is neither"
367
+ },
368
+ "sliderRange": {
369
+ "attributes": [
370
+ "Slider.minimum",
371
+ "Slider.maximum",
372
+ "Slider.range",
373
+ "Slider.value",
374
+ "Slider.step"
375
+ ],
376
+ "defaultRange": "0 .. 1",
377
+ "ruling": "2026-08-07 (plan 51-E), raised by E2 with all three converters measured: none of them had a declared default to read, so each invented one — sjui `max || 1`, kjui and rjui `max || 100`. A layout writing `value: 0.5` therefore sits mid-track on ios and at half a percent on android and web. IOS IS CANON: the default range is 0 .. 1. The ground is this SSoT's own convention for a unitless value on a track, not a head count. `Progress.progress` declares `min: 0, max: 1` and `common.opacity` declares `min: 0, max: 1`; Progress is the Slider's closest sibling — a number along a track — and 100 would make Slider the only such attribute in the SSoT that is not normalised. It also keeps the round-trip coherent: `Slider.value` is declared two-way, so under a 0..100 default the control writes 0..100 back into a viewmodel whose author was writing 0..1. Declared as `default` on minimum and maximum so no converter has to invent it again; `range` is the [min, max] shorthand, so its absence means [0, 1] too. CONSEQUENCE FOR WEB, so that fixing the range does not break the control: `Slider.step` is declared platform:react with no default, and E2 measured web rounding 0.5 up to 1 under the old 0..100 range. With a 0..1 range an implicit step of 1 collapses the slider to two positions, so the web default step must be continuous (HTML `step=\"any\"`) unless the layout declares one. Owner: A for the step, A and C for the range.",
378
+ "observable": {}
239
379
  }
240
380
  }
241
381
  }
@@ -11,6 +11,14 @@
11
11
  "reason": "unimplemented",
12
12
  "note": "codegen 未対応。ランタイム側に実装あり (ios: UIKit) — この検査は Ruby codegen しか走査しないので、実装の有無ではなく codegen 対応の有無を表す"
13
13
  },
14
+ {
15
+ "component": "Slider",
16
+ "attribute": "range",
17
+ "platforms": [
18
+ "android"
19
+ ],
20
+ "reason": "unimplemented"
21
+ },
14
22
  {
15
23
  "component": "common",
16
24
  "attribute": "binding",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jui-tools-mcp-server",
3
- "version": "2.5.1",
3
+ "version": "2.7.0",
4
4
  "description": "MCP server for JsonUI project management - wraps jui and jsonui-doc CLIs plus component spec lookup",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
@@ -44,7 +44,7 @@ const DATA_DIR = join(PROJECT_ROOT, "data");
44
44
  // 3. `npm run fetch-definitions` and commit the data/ diff together with
45
45
  // this pin in one commit
46
46
  // JSONUI_CLI_RAW_BASE still overrides the whole base URL for manual testing.
47
- const JSONUI_CLI_TAG = "v1.4.1";
47
+ const JSONUI_CLI_TAG = "v1.6.0";
48
48
  const BASE_URL =
49
49
  process.env.JSONUI_CLI_RAW_BASE ||
50
50
  `https://raw.githubusercontent.com/Tai-Kimura/jsonui-cli/${JSONUI_CLI_TAG}`;