jui-tools-mcp-server 2.15.3 → 2.15.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/data/attribute_definitions.json +5 -5
- package/data/attribute_semantics.json +123 -2
- package/data/modifier_order.json +57 -0
- package/dist/data/derived.d.ts +0 -1
- package/dist/data/derived.js +7 -26
- package/dist/spec_loader.d.ts +12 -0
- package/dist/spec_loader.js +51 -2
- package/docs/design.md +5 -1
- package/package.json +1 -1
- package/scripts/fetch-definitions.js +1 -1
- package/scripts/fetch-manifest.js +6 -0
- package/src/data/derived.ts +7 -26
- package/src/spec_loader.ts +62 -2
- package/tests/__snapshots__/derived.test.ts.snap +3 -26
- package/tests/derived.test.ts +4 -1
- package/tests/helpers.ts +17 -0
- package/tests/snapshot_completeness.test.ts +1 -0
- package/tests/tools_spec.test.ts +37 -7
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ bash ~/.jsonui-mcp-server/uninstall.sh
|
|
|
55
55
|
| `lookup_component` | コンポーネント仕様を取得(属性、binding、プラットフォーム別詳細) |
|
|
56
56
|
| `lookup_attribute` | 属性定義を取得(型、binding対応、使用コンポーネント) |
|
|
57
57
|
| `search_components` | キーワードでコンポーネント/属性を検索 |
|
|
58
|
-
| `get_modifier_order` | プラットフォーム別 modifier
|
|
58
|
+
| `get_modifier_order` | プラットフォーム別 modifier 適用順序(Swift は jsonui-cli の `sjui_tools/lib/swiftui/views/modifier_order.json` から導出。Kotlin / React は手書きで、`source` にそう出る) |
|
|
59
59
|
| `get_binding_rules` | binding 構文ルール(two-way / read-only) |
|
|
60
60
|
| `get_platform_mapping` | プラットフォーム間の値変換マッピング |
|
|
61
61
|
| `get_screen_identity` | スクリーン正準定義(screen とは何か・id 導出・表示判定述語) |
|
|
@@ -284,7 +284,7 @@
|
|
|
284
284
|
},
|
|
285
285
|
"responsive": {
|
|
286
286
|
"type": "object",
|
|
287
|
-
"description": "Size class overrides. Keys: compact, medium, regular, landscape, or hyphenated combos (e.g. regular-landscape)
|
|
287
|
+
"description": "Size class or window-width overrides. Keys: compact, medium, regular, landscape, or hyphenated combos (e.g. regular-landscape); or window-width conditions minWidth:N (the window is at least N wide) and maxWidth:N (narrower than N; N is not included, unlike jsonui-test-runner's maxWidth test condition), N a whole number of pt / dp / CSS px. One block uses size classes or width conditions, never both; every maxWidth's N is at most every minWidth's N; no landscape combination for a width condition. The one key that holds and ranks highest wins alone (size classes: regular-landscape > medium-landscape > compact-landscape > landscape > regular > medium > compact; width: the smallest maxWidth, else the largest minWidth). Values: object of attribute overrides."
|
|
288
288
|
},
|
|
289
289
|
"visibility": {
|
|
290
290
|
"type": [
|
|
@@ -1169,7 +1169,7 @@
|
|
|
1169
1169
|
"equalSpacing",
|
|
1170
1170
|
"equalCentering"
|
|
1171
1171
|
],
|
|
1172
|
-
"description": "Child distribution for orientation-bearing containers. SwiftUI: Spacer/weight synthesis; Compose: Arrangement.",
|
|
1172
|
+
"description": "Child distribution for orientation-bearing containers. SwiftUI: Spacer/weight synthesis; Compose: Arrangement. `fill` and `fillEqually` distribute SIZE: a child whose width (in a vertical stack, its height) is `wrapContent` grows, and the growing children share the axis \u2014 `fill` grows each from its content, `fillEqually` makes them equal shares of what is left. Only a child that declares a numeric size keeps it. `equalSpacing` and `equalCentering` leave the children's sizes alone and distribute the free space between them (equal gaps / equal centre-to-centre tracks).",
|
|
1173
1173
|
"platform": [
|
|
1174
1174
|
"swift",
|
|
1175
1175
|
"kotlin"
|
|
@@ -3544,7 +3544,7 @@
|
|
|
3544
3544
|
"equalSpacing",
|
|
3545
3545
|
"equalCentering"
|
|
3546
3546
|
],
|
|
3547
|
-
"description": "Child distribution mode for stacks",
|
|
3547
|
+
"description": "Child distribution mode for stacks. `fill` and `fillEqually` distribute SIZE: a child whose width (in a vertical stack, its height) is `wrapContent` grows, and the growing children share the axis \u2014 `fill` grows each from its content, `fillEqually` makes them equal shares of what is left. Only a child that declares a numeric size keeps it. `equalSpacing` and `equalCentering` leave the children's sizes alone and distribute the free space between them (equal gaps / equal centre-to-centre tracks).",
|
|
3548
3548
|
"mode": [
|
|
3549
3549
|
"swiftui",
|
|
3550
3550
|
"compose",
|
|
@@ -4043,7 +4043,7 @@
|
|
|
4043
4043
|
},
|
|
4044
4044
|
"scrollTo": {
|
|
4045
4045
|
"type": "binding",
|
|
4046
|
-
"description": "Binding that requests a programmatic scroll. The declared DATA CLASS is a plain value, and it decides what the value names, on every path (4f ruling 2026-09-27, round 14; jsonui-cli 1.9.0): an `Int` is a cell's index, a `String` a cell's key. cellIdProperty decides what a cell's key is — its `cellId`, else its cellIdProperty value — and not the class: a Collection with cellIdProperty may declare an `Int`, and one without it a `String` (its cells' cellIds are the keys). What it names across sections (4f ruling 2026-09-27, jsonui-cli 1.9.0; one rule on every path — sjui, kjui and rjui codegen, SwiftJsonUI and KotlinJsonUI Dynamic): an `Int` is the index of a CELL counted across all the drawn sections in order, cells only — a section's header or footer, and a grid's filler, is not counted (the same n as the paging address `<id>_item_<n>`); a `String` is the FIRST cell, in section order, whose key (its `cellId`, else its cellIdProperty value) it is, when two cells share the key — in two sections or in one; a cell with no key has none, and no String names it. A value that names no cell scrolls nowhere. Kotlin only (legacy): a String that is no cell's key and reads `<digits>` or `<digits>#<anything>` scrolls to lazy item <digits>, with a DEBUG warning, on the lazy lists; a container that is not one (the flow, the EAGER one, the wrapContent Column) has no lazy item and does not read it, and iOS and web do not read it. (A consuming Android screen asks its keyed, reverse-laid-out list for its bottom-most item with `\"0#<time>\"`; read as a cell, 0 would be section 0's first cell, which is not the bottom-most item when a later section has cells.) The request is a CHANGE of the value (4f ruling 2026-09-27, jsonui-cli 1.9.0): the value a Collection is drawn with scrolls nowhere, a value arriving where there was none is a change like any other, and sending the same value again does not re-scroll. It is deliberately NOT `PassthroughSubject<Int, Never>`, which this description used to prescribe: PassthroughSubject is a Combine type, and naming a Swift transport in the cross-platform SSoT is what made consumers write it into their data section, where kjui's map_to_kotlin_type passes unknown classes through verbatim and the Kotlin build dies on it. How the request is delivered is each platform's own business — iOS keys `.onChange(of:)` on the value inside a ScrollViewReader, Compose a LaunchedEffect, web a useEffect that compares the value with the one it last saw. STATUS 2026-09-27 (jsonui-cli 1.9.0): every path reads the plain value — sjui and SwiftJsonUI Dynamic, kjui and KotlinJsonUI Dynamic, rjui. On iOS and web it reaches the cells of every route with a scroll container of its own: the lists (the class-list shape's included), the grids, the flows, the horizontal Collections and the pagers — a pager turns to the page the value names, its currentPage when one is bound. On Compose (kjui codegen and KotlinJsonUI Dynamic) it reaches the lazy lists and grids, the horizontal rows, the pagers and a flow that scrolls in its own bounds (4f ruling 2026-09-27, round 11), and the EAGER container (kjui's CollectionStack EAGER, KotlinJsonUI Dynamic's Column and Row) and the Column of a vertical Collection whose height wraps its content (see `lazy`), by the same rule (4f ruling 2026-09-27, round 12); until jsonui-cli 1.9.0 neither scrolled to a cell. Until 1.9.0 kjui codegen read every value as its text and scrolled to that LAZY ITEM index - headers, footers and grid fillers counted -, with cellIdProperty looked an Int up as a key first (so an Int never became a cell there) and read a String without cellIdProperty as the index, scrolled on the value the list was first drawn with, landed bottom and center with the target's top edge at the viewport's bottom edge and middle, drew no scroll on the flow and the pager, and a key two cells shared took the lazy list down (\"Key ... was already used\"); KotlinJsonUI Dynamic read only a SharedFlow<Int>, so the plain value never scrolled, landed center and bottom at the top, and drew no scroll on the flow and the pager. Until 1.9.0 web's grid was no scroll container (a grid of a declared height drew its rows past its box and a scrollTo moved nothing); on iOS the flow (sjui codegen and SwiftJsonUI Dynamic), SwiftJsonUI Dynamic's sectioned List, the class-list List and the pager drew no scroll at all; and a String on a Collection with no cellIdProperty never met a cellId — web read a string of digits there as an index. Until 1.9.0 sjui codegen took what the value names from cellIdProperty, not from the declared class: an `Int` with cellIdProperty did not compile on the class-list List and the pager and scrolled nowhere on the other routes; and its cell ids with cellIdProperty let a String reach a cell with no key (its id was \"\\(index)\"), and gave two cells of one section with one key one id, the cell it reached SwiftUI's choice. A Collection with `lazy: none` has no scroll container of its own and scrolls nowhere. Until 2026-09-27 this paragraph named the SwiftUI Dynamic renderer as the one that could not read a plain value; it was the KotlinJsonUI Dynamic renderer, which reads it today."
|
|
4046
|
+
"description": "Binding that requests a programmatic scroll. The declared DATA CLASS is a plain value, and it decides what the value names, on every path (4f ruling 2026-09-27, round 14; jsonui-cli 1.9.0): an `Int` is a cell's index, a `String` a cell's key. cellIdProperty decides what a cell's key is — its `cellId`, else its cellIdProperty value — and not the class: a Collection with cellIdProperty may declare an `Int`, and one without it a `String` (its cells' cellIds are the keys). What it names across sections (4f ruling 2026-09-27, jsonui-cli 1.9.0; one rule on every path — sjui, kjui and rjui codegen, SwiftJsonUI and KotlinJsonUI Dynamic): an `Int` is the index of a CELL counted across all the drawn sections in order, cells only — a section's header or footer, and a grid's filler, is not counted (the same n as the paging address `<id>_item_<n>`); a `String` is the FIRST cell, in section order, whose key (its `cellId`, else its cellIdProperty value) it is, when two cells share the key — in two sections or in one; a cell with no key has none, and no String names it. A value that names no cell scrolls nowhere. Kotlin only (legacy): a String that is no cell's key and reads `<digits>` or `<digits>#<anything>` scrolls to lazy item <digits>, with a DEBUG warning, on the lazy lists; a container that is not one (the flow, the EAGER one, the wrapContent Column) has no lazy item and does not read it, and iOS and web do not read it. (A consuming Android screen asks its keyed, reverse-laid-out list for its bottom-most item with `\"0#<time>\"`; read as a cell, 0 would be section 0's first cell, which is not the bottom-most item when a later section has cells.) The request is a CHANGE of the value (4f ruling 2026-09-27, jsonui-cli 1.9.0): the value a Collection is drawn with scrolls nowhere, a value arriving where there was none is a change like any other, and sending the same value again does not re-scroll. A landing in flight is left alone by a change to a value that names no cell — \"\", null, a key no cell has, an index out of range — and replaced by a change to a value that names a cell, which lands on its own (jsonui-cli 1.9.26, kjui-collection-scrollto-landing-cancelled-by-next-value: a consumer that names a cell and puts the value back to \"\" 200 ms later, to be able to name it again, cut every Compose landing longer than that; iOS's `.onChange(of:)` never did). It is deliberately NOT `PassthroughSubject<Int, Never>`, which this description used to prescribe: PassthroughSubject is a Combine type, and naming a Swift transport in the cross-platform SSoT is what made consumers write it into their data section, where kjui's map_to_kotlin_type passes unknown classes through verbatim and the Kotlin build dies on it. How the request is delivered is each platform's own business — iOS keys `.onChange(of:)` on the value inside a ScrollViewReader, Compose a LaunchedEffect, web a useEffect that compares the value with the one it last saw. STATUS 2026-09-27 (jsonui-cli 1.9.0): every path reads the plain value — sjui and SwiftJsonUI Dynamic, kjui and KotlinJsonUI Dynamic, rjui. On iOS and web it reaches the cells of every route with a scroll container of its own: the lists (the class-list shape's included), the grids, the flows, the horizontal Collections and the pagers — a pager turns to the page the value names, its currentPage when one is bound. On Compose (kjui codegen and KotlinJsonUI Dynamic) it reaches the lazy lists and grids, the horizontal rows, the pagers and a flow that scrolls in its own bounds (4f ruling 2026-09-27, round 11), and the EAGER container (kjui's CollectionStack EAGER, KotlinJsonUI Dynamic's Column and Row) and the Column of a vertical Collection whose height wraps its content (see `lazy`), by the same rule (4f ruling 2026-09-27, round 12); until jsonui-cli 1.9.0 neither scrolled to a cell. Until 1.9.0 kjui codegen read every value as its text and scrolled to that LAZY ITEM index - headers, footers and grid fillers counted -, with cellIdProperty looked an Int up as a key first (so an Int never became a cell there) and read a String without cellIdProperty as the index, scrolled on the value the list was first drawn with, landed bottom and center with the target's top edge at the viewport's bottom edge and middle, drew no scroll on the flow and the pager, and a key two cells shared took the lazy list down (\"Key ... was already used\"); KotlinJsonUI Dynamic read only a SharedFlow<Int>, so the plain value never scrolled, landed center and bottom at the top, and drew no scroll on the flow and the pager. Until 1.9.0 web's grid was no scroll container (a grid of a declared height drew its rows past its box and a scrollTo moved nothing); on iOS the flow (sjui codegen and SwiftJsonUI Dynamic), SwiftJsonUI Dynamic's sectioned List, the class-list List and the pager drew no scroll at all; and a String on a Collection with no cellIdProperty never met a cellId — web read a string of digits there as an index. Until 1.9.0 sjui codegen took what the value names from cellIdProperty, not from the declared class: an `Int` with cellIdProperty did not compile on the class-list List and the pager and scrolled nowhere on the other routes; and its cell ids with cellIdProperty let a String reach a cell with no key (its id was \"\\(index)\"), and gave two cells of one section with one key one id, the cell it reached SwiftUI's choice. A Collection with `lazy: none` has no scroll container of its own and scrolls nowhere. Until 2026-09-27 this paragraph named the SwiftUI Dynamic renderer as the one that could not read a plain value; it was the KotlinJsonUI Dynamic renderer, which reads it today."
|
|
4047
4047
|
},
|
|
4048
4048
|
"scrollAnchor": {
|
|
4049
4049
|
"type": "string",
|
|
@@ -4453,7 +4453,7 @@
|
|
|
4453
4453
|
"large",
|
|
4454
4454
|
"linear"
|
|
4455
4455
|
],
|
|
4456
|
-
"description": "Indicator style: `small` / `medium` / `large` are the spinner's size
|
|
4456
|
+
"description": "Indicator style: `small` / `medium` / `large` are the spinner's size and `linear` a bar instead of a spinner. Each size is the platform's own value, `small` and `large` as well as `medium` (2026-10-05 user ruling): the faces need not draw the same small or large spinner. A declared width / height wins over the style's size. The legacy spellings `style` (naming one of these values) and `size` (a length) are folded by the layout normalizer, with a warning: `style` into this attribute, `size` into width and height.",
|
|
4457
4457
|
"platform": [
|
|
4458
4458
|
"swift",
|
|
4459
4459
|
"kotlin"
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
},
|
|
182
182
|
"perValueMapping": {
|
|
183
183
|
"fill": {
|
|
184
|
-
"swiftui": ".frame(maxWidth/maxHeight: .infinity)
|
|
184
|
+
"swiftui": "grow from content: DistributionFillLayout in SwiftJsonUI (10.29.6), content size plus an equal share of the leftover, the same policy as Compose's DistributionFillRow/Column; each undeclared child still takes .frame(maxWidth/maxHeight: .infinity), which is how the layout knows it may grow. An HStack/VStack of those children split the axis equally, which is fillEqually (frame-parity common/distribution__fill, 2026-10-05: 60 / 120 / 120 against Android 60 / 97 / 143)",
|
|
185
185
|
"compose": "grow from content — NOT expressible with Modifier.weight (weight(1f) IS fillEqually, the run-4 0px collapse; weight(1f, fill=false) packs by content and is inert). Dedicated measurement pass DistributionFillRow/Column in the base KotlinJsonUI library (c7d2dfb): content size plus an equal split of the leftover, one policy shared by the kjui codegen emit and the dynamic renderer. Device-verified: all 6 value pairs pairwise distinct, explicit child size never grown, spacing gaps pinned",
|
|
186
186
|
"web": "grow on each child"
|
|
187
187
|
},
|
|
@@ -210,7 +210,7 @@
|
|
|
210
210
|
"edgeSpacerGate": "equalCentering's edge spacers are suppressed ONLY on a wrapContent axis (declared size nil): with no free space a Spacer would expand the container and swallow the trailing padding (the measured round-3 bug). A FIXED numeric size has free space for the ratio and KEEPS its edge spacers — the older widthExpands gate (matchParent/-1/.infinity) was too narrow for it (F, e8f99c7, decision 2)",
|
|
211
211
|
"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."
|
|
212
212
|
},
|
|
213
|
-
"explicitChildSizeWins": "an explicit child width/height beats distribution's size half — size topic's explicit > bounds > fill",
|
|
213
|
+
"explicitChildSizeWins": "an explicit child width/height beats distribution's size half — size topic's explicit > bounds > fill. Explicit = a numeric size. wrapContent (and an undeclared axis, which the validator refuses) is a child fill grows. User ruling 2026-10-07 (relayed by the orchestrator): distribution(fill / fillEqually)で `width: wrapContent` の子は伸ばす。「明示された大きさ」は数値だけ。理由: width は required なので、wrapContent まで明示に入れると、正しい layout で fill が伸ばせる子が無くなる。 Measured before the ruling: KotlinJsonUI 2.43.5 Dynamic drew wrapContent children of a fill row at their content width (View/ and common/ distribution__fill / __fillequally, 2026-10-07); sjui codegen and KotlinJsonUI Dynamic's own fill decision already read only a numeric size as explicit.",
|
|
214
214
|
"collapsedPairs": {},
|
|
215
215
|
"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)."
|
|
216
216
|
},
|
|
@@ -368,6 +368,19 @@
|
|
|
368
368
|
"observable": {},
|
|
369
369
|
"overflowHitTesting": "hit testing follows CLIPPING, not the parent frame: an unclipped overflow is drawn and therefore tappable; a clipped one is neither"
|
|
370
370
|
},
|
|
371
|
+
"idBox": {
|
|
372
|
+
"attributes": [
|
|
373
|
+
"common.id"
|
|
374
|
+
],
|
|
375
|
+
"box": "an id names its element's box: inside the margin, including the padding, moved by the offset",
|
|
376
|
+
"ruling": "2026-10-06, user: 「マージンはそのビューの要素じゃない」 (the margin is not the element), 「パディングはその要素だからね」 (the padding is the element), and the id box moves with the offset (Android's measured result, accepted as the target). This is the box a UI test driver taps and reads (XCUIElement.frame on iOS, accessibility bounds on Android). iOS measured it against the layout box in SwiftJsonUI ConformanceHost IdBoxProbeUITests and CellIdBoxProbeUITests; containers met it from SwiftJsonUI 10.29.7 / jsonui-cli 1.9.18, when the 0.5pt single-child merge anchor moved inside the offset and the margins.",
|
|
377
|
+
"platformConventions": {
|
|
378
|
+
"androidButtonTouchTarget": "Android: a Button's id box is at least 48dp high (Material's minimum touch target), centred on the declared box — (25,6,100,48) for a 100 x 40 Button. A platform convention, not a defect (2026-10-06). iOS keeps the declared 40.",
|
|
379
|
+
"iosTextFieldInputArea": "iOS: a TextField's id box is the input field itself, inside the padding — (33,19,84,22) for a 100 x 40 field with padding 8. SwiftUI's TextField cannot be made larger than its line, and every placement that gives the padded box either takes the id off the input (an Other container with no value, which a text assertion cannot read) or cannot be typed into (measured 2026-10-06, six placements). With a symmetric padding the box's centre, where a tap aims, is the same. User ruling 2026-10-06: recorded as an iOS convention, not fixed. The id stays on the input field."
|
|
380
|
+
},
|
|
381
|
+
"collectionCellRoot": "A cell's root is addressed as `{collectionId}_item_{N}`. An id declared on the root is not used under the item address: the item address and the root's id would name the same element, which carries one identifier, so the root's own id is not found (iOS Dynamic and codegen, Android, measured 2026-10-06). The item's box is the root's box by the rule above. User ruling 2026-10-06.",
|
|
382
|
+
"observable": {}
|
|
383
|
+
},
|
|
371
384
|
"sliderRange": {
|
|
372
385
|
"attributes": [
|
|
373
386
|
"Slider.minimum",
|
|
@@ -395,6 +408,114 @@
|
|
|
395
408
|
},
|
|
396
409
|
"ruling": "2026-09-26 (4f, round 5 of the Collection audit). Until jsonui-cli 1.9.0 no declaration said what an undeclared Collection spacing draws, and the paths had each picked one: 0 everywhere except the flow route on iOS — sjui codegen 8 between cells, lines and section blocks; SwiftJsonUI Dynamic 8 on the lazy flow (its section blocks the ScrollView's own stack spacing) and `x > 0 ? x : 8` on the `lazy: none` flow, so a declared 0 drew 8. Android and web drew 0. The ruling: 0, flow included, on every route and every path. Declared HERE and not as a `default` in attribute_definitions.json, on purpose: a `default` there changes the attribute's kind — the conformance fixture generator writes the declared default as the fixture's value, the inert audit lists it, and attr codegen carries it into the generated tables — and the rule is per route, which one scalar cannot say. `sectionSpacing` is lineSpacing's alias (definitions `aliases`). Toolchain comments point here.",
|
|
397
410
|
"observable": {}
|
|
411
|
+
},
|
|
412
|
+
"backgroundSafeArea": {
|
|
413
|
+
"attributes": [
|
|
414
|
+
"common.background"
|
|
415
|
+
],
|
|
416
|
+
"ruling": "2026-10-05 user ruling (relayed by the orchestrator, from the frame-parity inventory: docs/bugs/material/frame-parity-inventory-3face-2026-10-05/rulings.md). When a view with a background touches the canvas edge next to a safe-area inset (the status-bar band above it), iOS painting the background into that band is an accepted iOS idiom; Android and web stopping at the view's frame are equally correct. The view's layout frame is the same on every face; only the paint may reach the band. Measured: SwiftUI's default for a background touching a safe-area edge (iOS frame y -62, height +62 or more, 340 ids), Android and web y 0. Not a reason to move the layout box.",
|
|
417
|
+
"observable": {}
|
|
418
|
+
},
|
|
419
|
+
"borderContentBox": {
|
|
420
|
+
"attributes": [
|
|
421
|
+
"common.borderWidth",
|
|
422
|
+
"common.borderColor"
|
|
423
|
+
],
|
|
424
|
+
"ruling": "2026-10-05 user ruling (relayed by the orchestrator, from the frame-parity inventory: docs/bugs/material/frame-parity-inventory-3face-2026-10-05/rulings.md). A border does NOT push the content inward: the view's children keep the positions they have without the border, and the border is drawn over them. A border on a wrapContent Label does not add to its height. Measured deviation: web insets children by the border width (2, 2) and adds 2 x borderWidth to a Label's height; iOS and Android draw over the content. See also `border` (when a border is drawn at all).",
|
|
425
|
+
"observable": {},
|
|
426
|
+
"inputFieldException": "TextField, TextView and SelectBox are the exception, declared so it is not a silent one: an input field's border is the control's chrome, and where its text sits inside that border is the platform's idiom. Web keeps a CSS border on input fields (rjui border_draws_over_content? is false for them) so the text never runs under the frame and the outline stays free for the focus ring. Every other web border is drawn over the content with ::after (an outline only for replaced elements such as <img>, which cannot hold ::after): outline does not follow rounded corners before Safari 16.4, and an inset box-shadow is painted under a child touching the edge (triage branch triage/1916-rjui-geometry, commit 7cee4bdb; it replaced 5898c2cf on rebase). Not covered by any fixture yet: the conformance corpus has no input field that declares a border (triage, scanning the web-scoped manifest), so this exception is not measured by the gate until one is added."
|
|
427
|
+
},
|
|
428
|
+
"stackOverflow": {
|
|
429
|
+
"attributes": [
|
|
430
|
+
"View.orientation",
|
|
431
|
+
"View.spacing",
|
|
432
|
+
"common.paddings"
|
|
433
|
+
],
|
|
434
|
+
"ruling": "2026-10-05 user ruling (relayed by the orchestrator, from the frame-parity inventory: docs/bugs/material/frame-parity-inventory-3face-2026-10-05/rulings.md). When a fixed-size row's (or column's) children, with its padding and spacing, exceed its size, the children are still placed in sequence at their declared sizes and the overflow runs past the far edge; no child is moved back inside. Measured deviation: Android holds the last child at 180 in a 200 row of six 40s; iOS and web place it at 200 (208 with paddingLeft 8, 240 with spacing 8).",
|
|
435
|
+
"observable": {}
|
|
436
|
+
},
|
|
437
|
+
"distributionRemainder": {
|
|
438
|
+
"attributes": [
|
|
439
|
+
"View.distribution"
|
|
440
|
+
],
|
|
441
|
+
"ruling": "2026-10-05 user ruling (relayed by the orchestrator, from the frame-parity inventory: docs/bugs/material/frame-parity-inventory-3face-2026-10-05/rulings.md). In a `fillEqually` stack where some children declare their size on the distribution axis, those children keep their declared size (distribution.explicitChildSizeWins) and the REMAINING space is shared equally by the others: 300 wide, one child declaring 60 and two undeclared -> 60 / 120 / 120. Measured before the ruling: Android 60 / 100 / 100 (40 left empty), web 100 / 100 / 100.",
|
|
442
|
+
"observable": {}
|
|
443
|
+
},
|
|
444
|
+
"emptyLabelHeight": {
|
|
445
|
+
"attributes": [
|
|
446
|
+
"Label.text",
|
|
447
|
+
"common.height"
|
|
448
|
+
],
|
|
449
|
+
"ruling": "2026-10-05 user ruling (relayed by the orchestrator, from the frame-parity inventory: docs/bugs/material/frame-parity-inventory-3face-2026-10-05/rulings.md). A Label whose text is empty and whose height is wrapContent still has the height of one line of its font; wrapContent does not collapse it to 0. Measured before the ruling: Android 24, web 0.",
|
|
450
|
+
"observable": {}
|
|
451
|
+
},
|
|
452
|
+
"labelHint": {
|
|
453
|
+
"attributes": [
|
|
454
|
+
"Label.hint",
|
|
455
|
+
"Label.hintAttributes"
|
|
456
|
+
],
|
|
457
|
+
"ruling": "2026-10-05 user ruling (relayed by the orchestrator, from the frame-parity inventory: docs/bugs/material/frame-parity-inventory-3face-2026-10-05/rulings.md). `Label.hint` is shown when the text is empty whether or not `hintAttributes` is declared; without hintAttributes it is drawn in the platform's default subdued hint colour at the Label's font. Measured before the ruling: rjui and Android required hintAttributes to show the hint.",
|
|
458
|
+
"observable": {}
|
|
459
|
+
},
|
|
460
|
+
"lineBreakModeLines": {
|
|
461
|
+
"attributes": [
|
|
462
|
+
"Label.lineBreakMode",
|
|
463
|
+
"Label.lines"
|
|
464
|
+
],
|
|
465
|
+
"ruling": "2026-10-05 user ruling (relayed by the orchestrator, from the frame-parity inventory: docs/bugs/material/frame-parity-inventory-3face-2026-10-05/rulings.md). `lineBreakMode` (head / middle / tail / clip) chooses HOW the last allowed line is truncated; it does not mean one line. The number of lines is `lines` alone (0 or absent: unlimited), so with no `lines` the text wraps and nothing is truncated. Measured before the ruling: web drew one line (24), Android wrapped (120).",
|
|
466
|
+
"observable": {}
|
|
467
|
+
},
|
|
468
|
+
"lineHeightMultipleBase": {
|
|
469
|
+
"attributes": [
|
|
470
|
+
"Label.lineHeightMultiple"
|
|
471
|
+
],
|
|
472
|
+
"ruling": "2026-10-05 user ruling (relayed by the orchestrator, from the frame-parity inventory: docs/bugs/material/frame-parity-inventory-3face-2026-10-05/rulings.md). `lineHeightMultiple` multiplies the font's own (natural) line height, not the font size. Measured before the ruling: lineHeightMultiple 1.8 drew 127.5 on Android and 143.98 on web. The base line height L is defined by a second user ruling (2026-10-05, relayed by the orchestrator): L is the height of one line of a Label on that platform that declares no lineHeight, at the Label's fontSize (with no fontSize: iOS 20.33, Android 24, web 24, measured on the conformance hosts; with a fontSize, one line of an undeclared-lineHeight Label at that size). lineHeightMultiple makes every line m x L; lineSpacing is added between lines only. iOS draws no multiple on the first line: SwiftUI has no way to set each line's height, so the first line stays L and the lines after it are m x L apart (2026-10-05 user instruction, relayed by the orchestrator: no elaborate implementation for this). So n lines are L + (n - 1) x m x L on iOS, (m - 1) x L short of the n x m x L the other platforms draw; frame_parity_rules.json pins that amount. Expected for three lines at m = 1.8 with no fontSize: iOS 93.52 (20.33 + 2 x 36.59), Android 129.6, web 129.6. Measured before (three-line probe, pixels): iOS 88.33 (it adds (m - 1) x fontSize between lines), Android 76.5 (it multiplied a fallback fontSize 14), web 86.4 (it multiplied the font size 16).",
|
|
473
|
+
"observable": {}
|
|
474
|
+
},
|
|
475
|
+
"lineSpacingBetween": {
|
|
476
|
+
"attributes": [
|
|
477
|
+
"Label.lineSpacing"
|
|
478
|
+
],
|
|
479
|
+
"ruling": "2026-10-05 user ruling (relayed by the orchestrator, from the frame-parity inventory: docs/bugs/material/frame-parity-inventory-3face-2026-10-05/rulings.md). `lineSpacing` is added BETWEEN lines only: n lines get n - 1 spacings, and nothing is added after the last line. Measured before the ruling: lineSpacing 16 drew 150 on Android and 160 on web. The base line height L is defined by a second user ruling (2026-10-05, relayed by the orchestrator): L is the height of one line of a Label on that platform that declares no lineHeight, at the Label's fontSize (with no fontSize: iOS 20.33, Android 24, web 24, measured on the conformance hosts; with a fontSize, one line of an undeclared-lineHeight Label at that size). lineHeightMultiple makes every line m x L; lineSpacing is added between lines only. So n lines are n x L + (n - 1) x lineSpacing. Expected for three lines at 16 with no fontSize: iOS 93, Android 104, web 104. Measured before (three-line probe, pixels): iOS 93 (already so: SwiftUI's lineSpacing replaces its default 1.67 leading), Android 90 and web 96 (both added the spacing to every line).",
|
|
480
|
+
"observable": {}
|
|
481
|
+
},
|
|
482
|
+
"iconLabelIconSize": {
|
|
483
|
+
"attributes": [
|
|
484
|
+
"IconLabel.iconSize",
|
|
485
|
+
"IconLabel.icon_on",
|
|
486
|
+
"IconLabel.icon_off"
|
|
487
|
+
],
|
|
488
|
+
"ruling": "2026-10-05 user ruling (relayed by the orchestrator, from the frame-parity inventory: docs/bugs/material/frame-parity-inventory-3face-2026-10-05/rulings.md). When `iconSize` is not declared: an IconLabel with no icon reserves no space for one, and one with an icon draws it at the image's own size. Measured before the ruling: web reserved a 96 icon box with no icon declared, Android none (height 24 vs 96).",
|
|
489
|
+
"observable": {}
|
|
490
|
+
},
|
|
491
|
+
"stackDirection": {
|
|
492
|
+
"attributes": [
|
|
493
|
+
"View.direction",
|
|
494
|
+
"SafeAreaView.direction"
|
|
495
|
+
],
|
|
496
|
+
"ruling": "2026-10-05 user ruling (relayed by the orchestrator, from the frame-parity inventory: docs/bugs/material/frame-parity-inventory-3face-2026-10-05/rulings.md). `direction: bottomToTop` stacks from the BOTTOM edge: the first child is the lowest, and each next child sits above it. (The same reading applies to the other directions: the first child sits at the edge the direction starts from.) Measured before the ruling: in a 200-high column of 40s, iOS and Android put box_b at 160, web at 120; SafeAreaView bottomToTop: Android stacked at the top (0 / 40 / 80), web at the bottom (728 / 688 / 648). 2026-10-05 user ruling (bottomToTop) applied to the horizontal axis by the orchestrator: `direction: rightToLeft` stacks from the RIGHT edge: the first child is the rightmost, and each next child sits to its left (the usual meaning of right-to-left). Measured before (frame-parity inventory, View/direction__righttoleft, six 40-wide boxes in a 200-wide row): web put box_a at x 160 and box_f at -40 (from the right edge, as declared); iOS (box_a 200 ... box_f 0) and Android (box_a 180, then 160 ... 0) reverse the children and stack them from the left.",
|
|
497
|
+
"observable": {}
|
|
498
|
+
},
|
|
499
|
+
"wrapContentCap": {
|
|
500
|
+
"attributes": [
|
|
501
|
+
"common.width",
|
|
502
|
+
"common.height"
|
|
503
|
+
],
|
|
504
|
+
"ruling": "2026-10-05 user ruling (relayed by the orchestrator, from the three-face conformance run): \"Wrapcontent は Android が正しいね。親の大きさが固定や matchparent ならその大きさまで\". A wrapContent box (or one with no size declared on that axis) takes its content's size, but no more than its parent's size on that axis when the parent has one there: a number, or matchParent. A parent that scrolls along that axis (a ScrollView's content axis) has none, and neither does a wrapContent parent. Measured before the ruling: a flow Collection, lazy none, wrapContent in a 150x100 parent (Collection/flowOverflow__noneWrapInBox and its control) was 168 high on web, 100 on Android. Distinct from ruling S: a child with a FIXED size larger than its parent keeps that size and overflows; the cap applies to the wrapContent box itself only.",
|
|
505
|
+
"iosLimitation": "SwiftUI: not enforced; a wrapContent box may report its content's size past a sized parent (declared limitation, 2026-10-06 user ruling). SwiftUI offers a child its share of a stack, not its parent's size, and a vertical ScrollView frames its content at least the viewport high, so a cap read from the offer squeezed wrapContent rows inside scrolling content below their content (measured on a consumer screen, 14.3pt -> 9.2pt); it was dropped rather than built around."
|
|
506
|
+
},
|
|
507
|
+
"iconLabelTextLine": {
|
|
508
|
+
"attributes": [
|
|
509
|
+
"IconLabel.text",
|
|
510
|
+
"IconLabel.fontSize"
|
|
511
|
+
],
|
|
512
|
+
"ruling": "2026-10-05 user ruling (relayed by the orchestrator): the text line of an IconLabel is the same as one line of a Label on the same platform at the same font size (lineHeightMultipleBase's L). An IconLabel draws no taller or shorter text line than a Label would."
|
|
513
|
+
},
|
|
514
|
+
"labelLongWordBreak": {
|
|
515
|
+
"attributes": [
|
|
516
|
+
"Label.text"
|
|
517
|
+
],
|
|
518
|
+
"ruling": "2026-10-05 user ruling (relayed by the orchestrator, answered as a choice): a word longer than the Label's box breaks inside the box — only the word that does not fit is cut at the box's width and carried to the next line (CSS `overflow-wrap: anywhere`); where it breaks may differ per OS. Measured before the ruling: a 200-wide Label with \"See https://example.com/conformance for details\" (Label/linkable__* and __control/Label__text-5ed92def) wrapped the URL inside the box on iOS and Android and let it run past the box on web. A declared lineBreakMode Char / Word keeps its own rule."
|
|
398
519
|
}
|
|
399
520
|
}
|
|
400
521
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_about": [
|
|
3
|
+
"The order sjui codegen emits SwiftUI modifiers in, for SwiftJsonUI to hold its Dynamic runtime to (DynamicModifierHelper.standardOrder).",
|
|
4
|
+
"bag: ModifierBag::MODIFIER_ORDER (swiftui/views/modifier_bag.rb), the order emit_all writes the registered keys in.",
|
|
5
|
+
"after_bag: what BaseViewConverter writes after the bag, in order: the accessibilityIdentifier, then .disabled again outside it (apply_outer_disabled).",
|
|
6
|
+
"shared_slots: keys that carry more than one attribute family, in the order their entries are written: :padding gets padding first, then insets (SpacingHelper#apply_insets appends to :padding after apply_padding).",
|
|
7
|
+
"spec/swiftui/views/modifier_order_json_spec.rb holds this file to the constant and to generated code. Change them together; SwiftJsonUI copies this file byte for byte and its CI compares the copy with this file at its pinned jsonui-cli ref."
|
|
8
|
+
],
|
|
9
|
+
"bag": [
|
|
10
|
+
"component_specific",
|
|
11
|
+
"padding",
|
|
12
|
+
"frame_constraints",
|
|
13
|
+
"fixed_size",
|
|
14
|
+
"frame_size",
|
|
15
|
+
"background",
|
|
16
|
+
"gradient",
|
|
17
|
+
"glass",
|
|
18
|
+
"corner_radius",
|
|
19
|
+
"border",
|
|
20
|
+
"shadow",
|
|
21
|
+
"clip_to_bounds",
|
|
22
|
+
"foreground_color",
|
|
23
|
+
"opacity",
|
|
24
|
+
"hidden",
|
|
25
|
+
"accessibility_anchor",
|
|
26
|
+
"conformance_frame",
|
|
27
|
+
"on_click",
|
|
28
|
+
"on_long_press",
|
|
29
|
+
"on_pan",
|
|
30
|
+
"on_pinch",
|
|
31
|
+
"allows_hit_testing",
|
|
32
|
+
"offset",
|
|
33
|
+
"margin",
|
|
34
|
+
"tint_color",
|
|
35
|
+
"on_value_change",
|
|
36
|
+
"on_text_change",
|
|
37
|
+
"on_appear",
|
|
38
|
+
"on_disappear",
|
|
39
|
+
"confirmation_dialog",
|
|
40
|
+
"alert",
|
|
41
|
+
"safe_area_insets",
|
|
42
|
+
"tag",
|
|
43
|
+
"z_index",
|
|
44
|
+
"accessibility_id",
|
|
45
|
+
"disabled"
|
|
46
|
+
],
|
|
47
|
+
"after_bag": [
|
|
48
|
+
"accessibility_identifier",
|
|
49
|
+
"disabled"
|
|
50
|
+
],
|
|
51
|
+
"shared_slots": {
|
|
52
|
+
"padding": [
|
|
53
|
+
"padding",
|
|
54
|
+
"insets"
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
}
|
package/dist/data/derived.d.ts
CHANGED
package/dist/data/derived.js
CHANGED
|
@@ -94,32 +94,11 @@ export function categorizeCommonAttributes(commonAttrs) {
|
|
|
94
94
|
// ----- modifier order (platform rendering) -------------------------------
|
|
95
95
|
export const MODIFIER_ORDER = {
|
|
96
96
|
description: "Platform-specific modifier application order. Order affects rendering.",
|
|
97
|
+
// Swift's order is not restated here: SpecLoader.getModifierOrder reads it
|
|
98
|
+
// from jsonui-cli's sjui_tools/lib/swiftui/views/modifier_order.json. The
|
|
99
|
+
// list that stood here had drifted from it (margins before offset, onClick
|
|
100
|
+
// after the margins). Only the prose rules stay.
|
|
97
101
|
swift: {
|
|
98
|
-
order: [
|
|
99
|
-
"centerAlignment",
|
|
100
|
-
"edgeAlignment",
|
|
101
|
-
"padding",
|
|
102
|
-
"frameConstraints (min/max)",
|
|
103
|
-
"frameSize (width/height)",
|
|
104
|
-
"insets/insetHorizontal",
|
|
105
|
-
"background",
|
|
106
|
-
"cornerRadius",
|
|
107
|
-
"border",
|
|
108
|
-
"margins",
|
|
109
|
-
"alpha/opacity",
|
|
110
|
-
"shadow",
|
|
111
|
-
"clipping",
|
|
112
|
-
"offset",
|
|
113
|
-
"visibility (hidden/opacity ternary)",
|
|
114
|
-
"safeAreaInsets",
|
|
115
|
-
"disabled",
|
|
116
|
-
"tag (for TabView)",
|
|
117
|
-
"tintColor",
|
|
118
|
-
"onClick/onTapGesture",
|
|
119
|
-
"lifecycle (onAppear/onDisappear)",
|
|
120
|
-
"confirmationDialog",
|
|
121
|
-
"accessibilityIdentifier",
|
|
122
|
-
],
|
|
123
102
|
criticalRules: [
|
|
124
103
|
"background MUST come after padding (background includes padding area)",
|
|
125
104
|
"border MUST come after cornerRadius (for rounded border)",
|
|
@@ -130,10 +109,11 @@ export const MODIFIER_ORDER = {
|
|
|
130
109
|
},
|
|
131
110
|
kotlin: {
|
|
132
111
|
order: [
|
|
133
|
-
"testTag",
|
|
134
112
|
"margins",
|
|
135
113
|
"weight (caller applies)",
|
|
136
114
|
"size (width/height, matchParent/wrapContent, min/max/aspectRatio)",
|
|
115
|
+
"offset",
|
|
116
|
+
"testTag (with testTagsAsResourceId): after margins and offset, so the tagged node's bounds are the drawn box",
|
|
137
117
|
"alpha/opacity",
|
|
138
118
|
"shadow/elevation",
|
|
139
119
|
"background (cornerRadius -> clip -> border -> bgColor)",
|
|
@@ -144,6 +124,7 @@ export const MODIFIER_ORDER = {
|
|
|
144
124
|
criticalRules: [
|
|
145
125
|
"Modifier.then() chains left-to-right; order matters",
|
|
146
126
|
"margins before size (outside to inside)",
|
|
127
|
+
"testTag after margins and offset (from KotlinJsonUI 2.43.5 / jsonui-cli 1.9.16): a tag outside them reports a margin-inclusive box to accessibility",
|
|
147
128
|
"cornerRadius must be applied as clip before background",
|
|
148
129
|
],
|
|
149
130
|
},
|
package/dist/spec_loader.d.ts
CHANGED
|
@@ -74,6 +74,7 @@ export interface DataSourceInfo {
|
|
|
74
74
|
platformSemantics: FileInfo | null;
|
|
75
75
|
coverage: FileInfo | null;
|
|
76
76
|
typeSynonyms: FileInfo | null;
|
|
77
|
+
swiftModifierOrder: FileInfo | null;
|
|
77
78
|
componentCount: number;
|
|
78
79
|
commonAttributeCount: number;
|
|
79
80
|
}
|
|
@@ -98,6 +99,7 @@ export declare class SpecLoader {
|
|
|
98
99
|
private platformSemantics;
|
|
99
100
|
private coverageGaps;
|
|
100
101
|
private screenIdentity;
|
|
102
|
+
private swiftModifierOrder;
|
|
101
103
|
private commonAttributes;
|
|
102
104
|
private aliasMap;
|
|
103
105
|
private typeSynonyms;
|
|
@@ -136,7 +138,17 @@ export declare class SpecLoader {
|
|
|
136
138
|
searchComponents(query: string): any[];
|
|
137
139
|
/** Keyword-indexed topical guides, each backed by a dedicated MCP tool. */
|
|
138
140
|
private topicGuides;
|
|
141
|
+
/**
|
|
142
|
+
* The Swift order is read from jsonui-cli's modifier_order.json, the order
|
|
143
|
+
* sjui codegen emits and SwiftJsonUI's Dynamic chain is tested against. It
|
|
144
|
+
* was a hand-written list here (derived.ts), and it had drifted: margins
|
|
145
|
+
* before offset, onClick after the margins — while jsonui-cli 1.9.20 puts
|
|
146
|
+
* the tap inside the offset and the margins (ticket mcp-get-modifier-order-
|
|
147
|
+
* swift-is-a-hand-written-list-that-drifted). Kotlin and React have no such
|
|
148
|
+
* file in jsonui-cli; they stay hand-written and say so (`source`).
|
|
149
|
+
*/
|
|
139
150
|
getModifierOrder(platform?: string): any;
|
|
151
|
+
private swiftModifierOrderEntry;
|
|
140
152
|
/**
|
|
141
153
|
* The canonical screen-identity asset, or null when this checkout predates
|
|
142
154
|
* it. Served verbatim: it is a declaration, not something to restate.
|
package/dist/spec_loader.js
CHANGED
|
@@ -57,6 +57,7 @@ export class SpecLoader {
|
|
|
57
57
|
/// the note explains why (backlog, alias normalization, runtime-only, ...).
|
|
58
58
|
coverageGaps = null;
|
|
59
59
|
screenIdentity = null;
|
|
60
|
+
swiftModifierOrder = null;
|
|
60
61
|
commonAttributes = null;
|
|
61
62
|
aliasMap = new Map();
|
|
62
63
|
/// Lower-cased spelling -> its entry in type_synonyms.json, for the
|
|
@@ -274,10 +275,45 @@ export class SpecLoader {
|
|
|
274
275
|
}
|
|
275
276
|
return guides;
|
|
276
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* The Swift order is read from jsonui-cli's modifier_order.json, the order
|
|
280
|
+
* sjui codegen emits and SwiftJsonUI's Dynamic chain is tested against. It
|
|
281
|
+
* was a hand-written list here (derived.ts), and it had drifted: margins
|
|
282
|
+
* before offset, onClick after the margins — while jsonui-cli 1.9.20 puts
|
|
283
|
+
* the tap inside the offset and the margins (ticket mcp-get-modifier-order-
|
|
284
|
+
* swift-is-a-hand-written-list-that-drifted). Kotlin and React have no such
|
|
285
|
+
* file in jsonui-cli; they stay hand-written and say so (`source`).
|
|
286
|
+
*/
|
|
277
287
|
getModifierOrder(platform) {
|
|
288
|
+
const swift = this.swiftModifierOrderEntry();
|
|
289
|
+
const all = { ...MODIFIER_ORDER, swift };
|
|
290
|
+
for (const key of ["kotlin", "react"]) {
|
|
291
|
+
all[key] = { source: "hand-written in jsonui-mcp-server (src/data/derived.ts); jsonui-cli has no order file for it", ...MODIFIER_ORDER[key] };
|
|
292
|
+
}
|
|
278
293
|
if (platform)
|
|
279
|
-
return
|
|
280
|
-
return
|
|
294
|
+
return all[platform] || null;
|
|
295
|
+
return all;
|
|
296
|
+
}
|
|
297
|
+
swiftModifierOrderEntry() {
|
|
298
|
+
const doc = this.swiftModifierOrder;
|
|
299
|
+
const file = this.dataSource?.swiftModifierOrder;
|
|
300
|
+
if (!doc || !file || !Array.isArray(doc.bag)) {
|
|
301
|
+
return {
|
|
302
|
+
source: "unavailable: sjui_tools/lib/swiftui/views/modifier_order.json did not resolve",
|
|
303
|
+
order: null,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
const afterBag = Array.isArray(doc.after_bag) ? doc.after_bag : [];
|
|
307
|
+
return {
|
|
308
|
+
source: `jsonui-cli sjui_tools/lib/swiftui/views/modifier_order.json (${file.layer}: ${file.path})`,
|
|
309
|
+
// The bag in the order it is written, then what is written after it.
|
|
310
|
+
order: [...doc.bag, ...afterBag.map((k) => `${k} (after the bag)`)],
|
|
311
|
+
bag: doc.bag,
|
|
312
|
+
afterBag,
|
|
313
|
+
sharedSlots: doc.shared_slots ?? {},
|
|
314
|
+
about: doc._about ?? [],
|
|
315
|
+
criticalRules: MODIFIER_ORDER.swift?.criticalRules ?? [],
|
|
316
|
+
};
|
|
281
317
|
}
|
|
282
318
|
/**
|
|
283
319
|
* The canonical screen-identity asset, or null when this checkout predates
|
|
@@ -311,6 +347,7 @@ export class SpecLoader {
|
|
|
311
347
|
this.dataSource.platformSemantics,
|
|
312
348
|
this.dataSource.coverage,
|
|
313
349
|
this.dataSource.typeSynonyms,
|
|
350
|
+
this.dataSource.swiftModifierOrder,
|
|
314
351
|
].filter((f) => f != null);
|
|
315
352
|
const changed = [];
|
|
316
353
|
for (const file of tracked) {
|
|
@@ -480,6 +517,17 @@ export class SpecLoader {
|
|
|
480
517
|
componentCount++;
|
|
481
518
|
}
|
|
482
519
|
const typeSynonymsResolution = this.loadTypeSynonyms();
|
|
520
|
+
// sjui's SwiftUI modifier order (get_modifier_order's Swift answer).
|
|
521
|
+
// Optional for the same backward-compat reason.
|
|
522
|
+
let swiftModifierOrderResolution = null;
|
|
523
|
+
try {
|
|
524
|
+
const orderResolution = this.resolveFile("sjui_tools/lib/swiftui/views/modifier_order.json", "data/modifier_order.json");
|
|
525
|
+
this.swiftModifierOrder = JSON.parse(readFileSync(orderResolution.path, "utf-8"));
|
|
526
|
+
swiftModifierOrderResolution = orderResolution;
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
this.swiftModifierOrder = null;
|
|
530
|
+
}
|
|
483
531
|
this.dataSource = {
|
|
484
532
|
attributeDefinitions: attrResolution,
|
|
485
533
|
componentMetadata: metaResolution,
|
|
@@ -489,6 +537,7 @@ export class SpecLoader {
|
|
|
489
537
|
platformSemantics: platformSemanticsResolution,
|
|
490
538
|
coverage: coverageResolution,
|
|
491
539
|
typeSynonyms: typeSynonymsResolution,
|
|
540
|
+
swiftModifierOrder: swiftModifierOrderResolution,
|
|
492
541
|
componentCount,
|
|
493
542
|
commonAttributeCount: Object.keys(this.commonAttributesRaw).filter((k) => !k.startsWith("_")).length,
|
|
494
543
|
};
|
package/docs/design.md
CHANGED
|
@@ -74,7 +74,11 @@ jsonui-cli files — it encodes fixed cross-platform conventions:
|
|
|
74
74
|
lifecycle / accessibility / binding buckets for the lookup tools.
|
|
75
75
|
|
|
76
76
|
These are served directly by the `get_modifier_order`,
|
|
77
|
-
`get_binding_rules`, and `get_platform_mapping` tools
|
|
77
|
+
`get_binding_rules`, and `get_platform_mapping` tools — except Swift's
|
|
78
|
+
modifier order, which `get_modifier_order` reads from jsonui-cli's
|
|
79
|
+
`sjui_tools/lib/swiftui/views/modifier_order.json` (bundled as
|
|
80
|
+
`data/modifier_order.json`): the hand-written list that stood in
|
|
81
|
+
`MODIFIER_ORDER.swift` had drifted from what sjui emits.
|
|
78
82
|
|
|
79
83
|
---
|
|
80
84
|
|
package/package.json
CHANGED
|
@@ -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.9.
|
|
47
|
+
const JSONUI_CLI_TAG = "v1.9.26";
|
|
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}`;
|
|
@@ -65,6 +65,12 @@ export const FILES = [
|
|
|
65
65
|
remote: "shared/core/type_synonyms.json",
|
|
66
66
|
local: "type_synonyms.json",
|
|
67
67
|
},
|
|
68
|
+
// sjui's SwiftUI modifier order: get_modifier_order serves its Swift
|
|
69
|
+
// answer from it rather than from a hand-kept list.
|
|
70
|
+
{
|
|
71
|
+
remote: "sjui_tools/lib/swiftui/views/modifier_order.json",
|
|
72
|
+
local: "modifier_order.json",
|
|
73
|
+
},
|
|
68
74
|
{
|
|
69
75
|
remote: "conformance/coverage.json",
|
|
70
76
|
local: "coverage.json",
|
package/src/data/derived.ts
CHANGED
|
@@ -100,32 +100,11 @@ export function categorizeCommonAttributes(commonAttrs: Record<string, any>): an
|
|
|
100
100
|
export const MODIFIER_ORDER = {
|
|
101
101
|
description:
|
|
102
102
|
"Platform-specific modifier application order. Order affects rendering.",
|
|
103
|
+
// Swift's order is not restated here: SpecLoader.getModifierOrder reads it
|
|
104
|
+
// from jsonui-cli's sjui_tools/lib/swiftui/views/modifier_order.json. The
|
|
105
|
+
// list that stood here had drifted from it (margins before offset, onClick
|
|
106
|
+
// after the margins). Only the prose rules stay.
|
|
103
107
|
swift: {
|
|
104
|
-
order: [
|
|
105
|
-
"centerAlignment",
|
|
106
|
-
"edgeAlignment",
|
|
107
|
-
"padding",
|
|
108
|
-
"frameConstraints (min/max)",
|
|
109
|
-
"frameSize (width/height)",
|
|
110
|
-
"insets/insetHorizontal",
|
|
111
|
-
"background",
|
|
112
|
-
"cornerRadius",
|
|
113
|
-
"border",
|
|
114
|
-
"margins",
|
|
115
|
-
"alpha/opacity",
|
|
116
|
-
"shadow",
|
|
117
|
-
"clipping",
|
|
118
|
-
"offset",
|
|
119
|
-
"visibility (hidden/opacity ternary)",
|
|
120
|
-
"safeAreaInsets",
|
|
121
|
-
"disabled",
|
|
122
|
-
"tag (for TabView)",
|
|
123
|
-
"tintColor",
|
|
124
|
-
"onClick/onTapGesture",
|
|
125
|
-
"lifecycle (onAppear/onDisappear)",
|
|
126
|
-
"confirmationDialog",
|
|
127
|
-
"accessibilityIdentifier",
|
|
128
|
-
],
|
|
129
108
|
criticalRules: [
|
|
130
109
|
"background MUST come after padding (background includes padding area)",
|
|
131
110
|
"border MUST come after cornerRadius (for rounded border)",
|
|
@@ -136,10 +115,11 @@ export const MODIFIER_ORDER = {
|
|
|
136
115
|
},
|
|
137
116
|
kotlin: {
|
|
138
117
|
order: [
|
|
139
|
-
"testTag",
|
|
140
118
|
"margins",
|
|
141
119
|
"weight (caller applies)",
|
|
142
120
|
"size (width/height, matchParent/wrapContent, min/max/aspectRatio)",
|
|
121
|
+
"offset",
|
|
122
|
+
"testTag (with testTagsAsResourceId): after margins and offset, so the tagged node's bounds are the drawn box",
|
|
143
123
|
"alpha/opacity",
|
|
144
124
|
"shadow/elevation",
|
|
145
125
|
"background (cornerRadius -> clip -> border -> bgColor)",
|
|
@@ -150,6 +130,7 @@ export const MODIFIER_ORDER = {
|
|
|
150
130
|
criticalRules: [
|
|
151
131
|
"Modifier.then() chains left-to-right; order matters",
|
|
152
132
|
"margins before size (outside to inside)",
|
|
133
|
+
"testTag after margins and offset (from KotlinJsonUI 2.43.5 / jsonui-cli 1.9.16): a tag outside them reports a margin-inclusive box to accessibility",
|
|
153
134
|
"cornerRadius must be applied as clip before background",
|
|
154
135
|
],
|
|
155
136
|
},
|
package/src/spec_loader.ts
CHANGED
|
@@ -126,6 +126,11 @@ export interface DataSourceInfo {
|
|
|
126
126
|
/// section they are not (ProgressBar, HStack, Text …). Optional: absent on
|
|
127
127
|
/// jsonui-cli before 1.9.0.
|
|
128
128
|
typeSynonyms: FileInfo | null;
|
|
129
|
+
/// sjui_tools/lib/swiftui/views/modifier_order.json — the order sjui
|
|
130
|
+
/// codegen emits SwiftUI modifiers in, which SwiftJsonUI's Dynamic chain
|
|
131
|
+
/// is held to. get_modifier_order serves its Swift order from it. Optional:
|
|
132
|
+
/// absent on jsonui-cli before 1.9.x.
|
|
133
|
+
swiftModifierOrder: FileInfo | null;
|
|
129
134
|
componentCount: number;
|
|
130
135
|
commonAttributeCount: number;
|
|
131
136
|
}
|
|
@@ -170,6 +175,7 @@ export class SpecLoader {
|
|
|
170
175
|
/// the note explains why (backlog, alias normalization, runtime-only, ...).
|
|
171
176
|
private coverageGaps: Map<string, any[]> | null = null;
|
|
172
177
|
private screenIdentity: Record<string, any> | null = null;
|
|
178
|
+
private swiftModifierOrder: Record<string, any> | null = null;
|
|
173
179
|
private commonAttributes: any = null;
|
|
174
180
|
private aliasMap: Map<string, string> = new Map();
|
|
175
181
|
/// Lower-cased spelling -> its entry in type_synonyms.json, for the
|
|
@@ -420,9 +426,45 @@ export class SpecLoader {
|
|
|
420
426
|
return guides;
|
|
421
427
|
}
|
|
422
428
|
|
|
429
|
+
/**
|
|
430
|
+
* The Swift order is read from jsonui-cli's modifier_order.json, the order
|
|
431
|
+
* sjui codegen emits and SwiftJsonUI's Dynamic chain is tested against. It
|
|
432
|
+
* was a hand-written list here (derived.ts), and it had drifted: margins
|
|
433
|
+
* before offset, onClick after the margins — while jsonui-cli 1.9.20 puts
|
|
434
|
+
* the tap inside the offset and the margins (ticket mcp-get-modifier-order-
|
|
435
|
+
* swift-is-a-hand-written-list-that-drifted). Kotlin and React have no such
|
|
436
|
+
* file in jsonui-cli; they stay hand-written and say so (`source`).
|
|
437
|
+
*/
|
|
423
438
|
getModifierOrder(platform?: string): any {
|
|
424
|
-
|
|
425
|
-
|
|
439
|
+
const swift = this.swiftModifierOrderEntry();
|
|
440
|
+
const all: Record<string, any> = { ...MODIFIER_ORDER, swift };
|
|
441
|
+
for (const key of ["kotlin", "react"]) {
|
|
442
|
+
all[key] = { source: "hand-written in jsonui-mcp-server (src/data/derived.ts); jsonui-cli has no order file for it", ...(MODIFIER_ORDER as any)[key] };
|
|
443
|
+
}
|
|
444
|
+
if (platform) return all[platform] || null;
|
|
445
|
+
return all;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private swiftModifierOrderEntry(): Record<string, any> {
|
|
449
|
+
const doc = this.swiftModifierOrder;
|
|
450
|
+
const file = this.dataSource?.swiftModifierOrder;
|
|
451
|
+
if (!doc || !file || !Array.isArray(doc.bag)) {
|
|
452
|
+
return {
|
|
453
|
+
source: "unavailable: sjui_tools/lib/swiftui/views/modifier_order.json did not resolve",
|
|
454
|
+
order: null,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
const afterBag: string[] = Array.isArray(doc.after_bag) ? doc.after_bag : [];
|
|
458
|
+
return {
|
|
459
|
+
source: `jsonui-cli sjui_tools/lib/swiftui/views/modifier_order.json (${file.layer}: ${file.path})`,
|
|
460
|
+
// The bag in the order it is written, then what is written after it.
|
|
461
|
+
order: [...doc.bag, ...afterBag.map((k: string) => `${k} (after the bag)`)],
|
|
462
|
+
bag: doc.bag,
|
|
463
|
+
afterBag,
|
|
464
|
+
sharedSlots: doc.shared_slots ?? {},
|
|
465
|
+
about: doc._about ?? [],
|
|
466
|
+
criticalRules: (MODIFIER_ORDER as any).swift?.criticalRules ?? [],
|
|
467
|
+
};
|
|
426
468
|
}
|
|
427
469
|
|
|
428
470
|
/**
|
|
@@ -458,6 +500,7 @@ export class SpecLoader {
|
|
|
458
500
|
this.dataSource.platformSemantics,
|
|
459
501
|
this.dataSource.coverage,
|
|
460
502
|
this.dataSource.typeSynonyms,
|
|
503
|
+
this.dataSource.swiftModifierOrder,
|
|
461
504
|
].filter((f): f is FileInfo => f != null);
|
|
462
505
|
|
|
463
506
|
const changed: string[] = [];
|
|
@@ -673,6 +716,22 @@ export class SpecLoader {
|
|
|
673
716
|
|
|
674
717
|
const typeSynonymsResolution = this.loadTypeSynonyms();
|
|
675
718
|
|
|
719
|
+
// sjui's SwiftUI modifier order (get_modifier_order's Swift answer).
|
|
720
|
+
// Optional for the same backward-compat reason.
|
|
721
|
+
let swiftModifierOrderResolution: FileInfo | null = null;
|
|
722
|
+
try {
|
|
723
|
+
const orderResolution = this.resolveFile(
|
|
724
|
+
"sjui_tools/lib/swiftui/views/modifier_order.json",
|
|
725
|
+
"data/modifier_order.json"
|
|
726
|
+
);
|
|
727
|
+
this.swiftModifierOrder = JSON.parse(
|
|
728
|
+
readFileSync(orderResolution.path, "utf-8")
|
|
729
|
+
) as Record<string, any>;
|
|
730
|
+
swiftModifierOrderResolution = orderResolution;
|
|
731
|
+
} catch {
|
|
732
|
+
this.swiftModifierOrder = null;
|
|
733
|
+
}
|
|
734
|
+
|
|
676
735
|
this.dataSource = {
|
|
677
736
|
attributeDefinitions: attrResolution,
|
|
678
737
|
componentMetadata: metaResolution,
|
|
@@ -682,6 +741,7 @@ export class SpecLoader {
|
|
|
682
741
|
platformSemantics: platformSemanticsResolution,
|
|
683
742
|
coverage: coverageResolution,
|
|
684
743
|
typeSynonyms: typeSynonymsResolution,
|
|
744
|
+
swiftModifierOrder: swiftModifierOrderResolution,
|
|
685
745
|
componentCount,
|
|
686
746
|
commonAttributeCount: Object.keys(this.commonAttributesRaw).filter(
|
|
687
747
|
(k) => !k.startsWith("_")
|
|
@@ -62,13 +62,15 @@ exports[`derived constants (pinned) > MODIFIER_ORDER snapshot 1`] = `
|
|
|
62
62
|
"criticalRules": [
|
|
63
63
|
"Modifier.then() chains left-to-right; order matters",
|
|
64
64
|
"margins before size (outside to inside)",
|
|
65
|
+
"testTag after margins and offset (from KotlinJsonUI 2.43.5 / jsonui-cli 1.9.16): a tag outside them reports a margin-inclusive box to accessibility",
|
|
65
66
|
"cornerRadius must be applied as clip before background",
|
|
66
67
|
],
|
|
67
68
|
"order": [
|
|
68
|
-
"testTag",
|
|
69
69
|
"margins",
|
|
70
70
|
"weight (caller applies)",
|
|
71
71
|
"size (width/height, matchParent/wrapContent, min/max/aspectRatio)",
|
|
72
|
+
"offset",
|
|
73
|
+
"testTag (with testTagsAsResourceId): after margins and offset, so the tagged node's bounds are the drawn box",
|
|
72
74
|
"alpha/opacity",
|
|
73
75
|
"shadow/elevation",
|
|
74
76
|
"background (cornerRadius -> clip -> border -> bgColor)",
|
|
@@ -99,31 +101,6 @@ exports[`derived constants (pinned) > MODIFIER_ORDER snapshot 1`] = `
|
|
|
99
101
|
"Image: .resizable() must come first, then .aspectRatio",
|
|
100
102
|
"Label: .leading alignment added to frame for textAlign to work",
|
|
101
103
|
],
|
|
102
|
-
"order": [
|
|
103
|
-
"centerAlignment",
|
|
104
|
-
"edgeAlignment",
|
|
105
|
-
"padding",
|
|
106
|
-
"frameConstraints (min/max)",
|
|
107
|
-
"frameSize (width/height)",
|
|
108
|
-
"insets/insetHorizontal",
|
|
109
|
-
"background",
|
|
110
|
-
"cornerRadius",
|
|
111
|
-
"border",
|
|
112
|
-
"margins",
|
|
113
|
-
"alpha/opacity",
|
|
114
|
-
"shadow",
|
|
115
|
-
"clipping",
|
|
116
|
-
"offset",
|
|
117
|
-
"visibility (hidden/opacity ternary)",
|
|
118
|
-
"safeAreaInsets",
|
|
119
|
-
"disabled",
|
|
120
|
-
"tag (for TabView)",
|
|
121
|
-
"tintColor",
|
|
122
|
-
"onClick/onTapGesture",
|
|
123
|
-
"lifecycle (onAppear/onDisappear)",
|
|
124
|
-
"confirmationDialog",
|
|
125
|
-
"accessibilityIdentifier",
|
|
126
|
-
],
|
|
127
104
|
},
|
|
128
105
|
}
|
|
129
106
|
`;
|
package/tests/derived.test.ts
CHANGED
|
@@ -78,7 +78,10 @@ describe("derived constants (pinned)", () => {
|
|
|
78
78
|
"react",
|
|
79
79
|
"swift",
|
|
80
80
|
]);
|
|
81
|
-
|
|
81
|
+
// Swift's order is read from jsonui-cli's modifier_order.json
|
|
82
|
+
// (SpecLoader.getModifierOrder); a list here is the drift that ticket
|
|
83
|
+
// mcp-get-modifier-order-swift-is-a-hand-written-list-that-drifted removed.
|
|
84
|
+
expect((MODIFIER_ORDER.swift as Record<string, unknown>).order).toBeUndefined();
|
|
82
85
|
expect(MODIFIER_ORDER.swift.criticalRules.length).toBeGreaterThan(0);
|
|
83
86
|
expect(MODIFIER_ORDER.kotlin.order.length).toBeGreaterThan(0);
|
|
84
87
|
});
|
package/tests/helpers.ts
CHANGED
|
@@ -108,8 +108,19 @@ export interface CliDatasetOptions {
|
|
|
108
108
|
omitComponentMetadata?: boolean;
|
|
109
109
|
/** Optional conformance/coverage.json fixture (implementation-gap ledger). */
|
|
110
110
|
coverage?: Record<string, unknown>;
|
|
111
|
+
/** Optional sjui_tools/lib/swiftui/views/modifier_order.json fixture. */
|
|
112
|
+
modifierOrder?: Record<string, unknown>;
|
|
111
113
|
}
|
|
112
114
|
|
|
115
|
+
/** A modifier order whose slot names exist nowhere else: an answer that
|
|
116
|
+
* carries them came from the file, not from a list kept in the server. */
|
|
117
|
+
export const FIXTURE_MODIFIER_ORDER = {
|
|
118
|
+
_about: ["test fixture — not real data"],
|
|
119
|
+
bag: ["fixture_slot_alpha", "fixture_slot_beta", "fixture_slot_gamma"],
|
|
120
|
+
after_bag: ["fixture_after_one"],
|
|
121
|
+
shared_slots: { fixture_slot_alpha: ["fixture_slot_alpha", "fixture_extra"] },
|
|
122
|
+
};
|
|
123
|
+
|
|
113
124
|
/** Coverage-ledger fixture: one gap on Label.fontSize [web] + one on common.onClick [android]. */
|
|
114
125
|
export const FIXTURE_COVERAGE = {
|
|
115
126
|
schemaVersion: 1,
|
|
@@ -149,6 +160,9 @@ export function makeCliDataset(root: string, opts: CliDatasetOptions = {}): void
|
|
|
149
160
|
if (opts.coverage) {
|
|
150
161
|
writeJson(join(root, "conformance", "coverage.json"), opts.coverage);
|
|
151
162
|
}
|
|
163
|
+
if (opts.modifierOrder) {
|
|
164
|
+
writeJson(join(root, "sjui_tools", "lib", "swiftui", "views", "modifier_order.json"), opts.modifierOrder);
|
|
165
|
+
}
|
|
152
166
|
}
|
|
153
167
|
|
|
154
168
|
/** Write a fixture bundled snapshot layout: <mcpRoot>/data/*.json */
|
|
@@ -166,6 +180,9 @@ export function makeBundledDataset(root: string, opts: CliDatasetOptions = {}):
|
|
|
166
180
|
opts.componentMetadata ?? FIXTURE_COMPONENT_METADATA
|
|
167
181
|
);
|
|
168
182
|
}
|
|
183
|
+
if (opts.modifierOrder) {
|
|
184
|
+
writeJson(join(dataDir, "modifier_order.json"), opts.modifierOrder);
|
|
185
|
+
}
|
|
169
186
|
}
|
|
170
187
|
|
|
171
188
|
// ----- fixture JsonUI project ---------------------------------------------
|
package/tests/tools_spec.test.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import {
|
|
24
24
|
cleanupTempDirs,
|
|
25
25
|
createToolHarness,
|
|
26
|
+
FIXTURE_MODIFIER_ORDER,
|
|
26
27
|
makeBundledDataset,
|
|
27
28
|
makeTempDir,
|
|
28
29
|
type ToolHarness,
|
|
@@ -42,9 +43,11 @@ function buildHarness(loader: SpecLoader): ToolHarness {
|
|
|
42
43
|
return harness;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
function makeFixtureLoader(opts: { staleDays?: number } = {}): SpecLoader {
|
|
46
|
+
function makeFixtureLoader(opts: { staleDays?: number; modifierOrder?: Record<string, unknown> | null } = {}): SpecLoader {
|
|
46
47
|
const mcpRoot = makeTempDir("mcp-root");
|
|
47
|
-
makeBundledDataset(mcpRoot
|
|
48
|
+
makeBundledDataset(mcpRoot, {
|
|
49
|
+
modifierOrder: opts.modifierOrder === null ? undefined : (opts.modifierOrder ?? FIXTURE_MODIFIER_ORDER),
|
|
50
|
+
});
|
|
48
51
|
if (opts.staleDays) {
|
|
49
52
|
const when = new Date(Date.now() - opts.staleDays * 24 * 60 * 60 * 1000);
|
|
50
53
|
for (const f of ["attribute_definitions.json", "component_metadata.json"]) {
|
|
@@ -156,16 +159,43 @@ describe("search_components", () => {
|
|
|
156
159
|
});
|
|
157
160
|
|
|
158
161
|
describe("get_modifier_order", () => {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
+
// The Swift order is read from jsonui-cli's modifier_order.json. The
|
|
163
|
+
// fixture's slot names exist nowhere else, so an answer carrying them came
|
|
164
|
+
// from the file — a list kept in the server cannot pass (ticket mcp-get-
|
|
165
|
+
// modifier-order-swift-is-a-hand-written-list-that-drifted).
|
|
166
|
+
it("serves the Swift order from modifier_order.json: the bag, then what follows it", async () => {
|
|
167
|
+
const result = JSON.parse(
|
|
168
|
+
await harness.call("get_modifier_order", { platform: "swift" })
|
|
169
|
+
);
|
|
170
|
+
expect(result.bag).toEqual(FIXTURE_MODIFIER_ORDER.bag);
|
|
171
|
+
expect(result.afterBag).toEqual(FIXTURE_MODIFIER_ORDER.after_bag);
|
|
172
|
+
expect(result.order).toEqual([
|
|
173
|
+
...FIXTURE_MODIFIER_ORDER.bag,
|
|
174
|
+
"fixture_after_one (after the bag)",
|
|
175
|
+
]);
|
|
176
|
+
expect(result.sharedSlots).toEqual(FIXTURE_MODIFIER_ORDER.shared_slots);
|
|
177
|
+
expect(result.source).toContain("sjui_tools/lib/swiftui/views/modifier_order.json");
|
|
178
|
+
expect(result.criticalRules).toEqual(MODIFIER_ORDER.swift.criticalRules);
|
|
162
179
|
});
|
|
163
180
|
|
|
164
|
-
it("
|
|
181
|
+
it("says the Swift order is unavailable when the file does not resolve, and invents none", async () => {
|
|
182
|
+
harness = buildHarness(makeFixtureLoader({ modifierOrder: null }));
|
|
165
183
|
const result = JSON.parse(
|
|
166
184
|
await harness.call("get_modifier_order", { platform: "swift" })
|
|
167
185
|
);
|
|
168
|
-
expect(result).
|
|
186
|
+
expect(result.order).toBeNull();
|
|
187
|
+
expect(result.source).toMatch(/^unavailable/);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("returns the full table when platform is omitted, Kotlin and React marked hand-written", async () => {
|
|
191
|
+
const result = JSON.parse(await harness.call("get_modifier_order"));
|
|
192
|
+
expect(Object.keys(result).sort()).toEqual(["description", "kotlin", "react", "swift"]);
|
|
193
|
+
expect(result.swift.bag).toEqual(FIXTURE_MODIFIER_ORDER.bag);
|
|
194
|
+
for (const key of ["kotlin", "react"] as const) {
|
|
195
|
+
expect(result[key].source).toMatch(/^hand-written/);
|
|
196
|
+
const { source, ...rest } = result[key];
|
|
197
|
+
expect(rest).toEqual(JSON.parse(JSON.stringify(MODIFIER_ORDER[key])));
|
|
198
|
+
}
|
|
169
199
|
});
|
|
170
200
|
|
|
171
201
|
it("rejects unknown platforms at the schema layer", async () => {
|