jui-tools-mcp-server 2.15.4 → 2.15.10
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 +17 -11
- package/data/attribute_semantics.json +14 -1
- package/data/component_metadata.json +1 -1
- package/data/modifier_order.json +57 -0
- package/dist/data/derived.d.ts +0 -1
- package/dist/data/derived.js +4 -25
- 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 +4 -25
- package/src/spec_loader.ts +62 -2
- package/tests/__snapshots__/derived.test.ts.snap +0 -25
- 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",
|
|
@@ -3685,7 +3685,7 @@
|
|
|
3685
3685
|
"horizontal",
|
|
3686
3686
|
"vertical"
|
|
3687
3687
|
],
|
|
3688
|
-
"description": "Scroll orientation"
|
|
3688
|
+
"description": "Scroll axis: \"horizontal\" scrolls sideways; \"vertical\" — and a ScrollView that writes neither this nor horizontalScroll: true — scrolls vertically. The axis is never taken from the children: a horizontal child View is laid out inside a vertical scroll. Written together with horizontalScroll, the two must agree — orientation \"horizontal\" with horizontalScroll false, or \"vertical\" with horizontalScroll true, is refused by jui build (ERROR [scrollview]), under any responsive key too."
|
|
3689
3689
|
},
|
|
3690
3690
|
"scrollMode": {
|
|
3691
3691
|
"type": "string",
|
|
@@ -3840,7 +3840,7 @@
|
|
|
3840
3840
|
},
|
|
3841
3841
|
"horizontalScroll": {
|
|
3842
3842
|
"type": "boolean",
|
|
3843
|
-
"description": "
|
|
3843
|
+
"description": "Scroll sideways: true is the same as orientation \"horizontal\"; false or absent leaves the axis to orientation, vertical when that is not written either. Never taken from the children. Written together with orientation, the two must agree (jui build ERROR [scrollview]). Declared from the implementation, which already read it: sjui scrollview_converter.rb (plan 51-E)."
|
|
3844
3844
|
},
|
|
3845
3845
|
"defaultScrollAnchor": {
|
|
3846
3846
|
"type": "string",
|
|
@@ -4043,17 +4043,20 @@
|
|
|
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. A Collection taken out by visibility gone and brought back (kjui-collection-scrollto-ignored-after-visibility-gone, 2026-10-14): the screen remembers the value each Collection, by its id, was last drawn with, and a value it comes back with that differs from that one is a request — a scrollTo written in the same update as the visibility that brings it back runs, as it would on a Collection that never left; the value a Collection is first drawn with on a screen still scrolls nowhere, so a Collection that is gone from the moment the screen opens and first appears with a value does not scroll to it; a Collection with no id is not remembered, and a value it comes back with is its first value again. The anchor of such a request is the anchor of that update (scrollAnchor). Until jsonui-cli 1.9.30 / SwiftJsonUI 10.29.16 / KotlinJsonUI 2.43.13 the iOS and Compose paths read the value a Collection came back with as its first value, and the request never ran; the web kept it (its effect lives on the screen's component)."
|
|
4047
4047
|
},
|
|
4048
4048
|
"scrollAnchor": {
|
|
4049
|
-
"type":
|
|
4049
|
+
"type": [
|
|
4050
|
+
"string",
|
|
4051
|
+
"binding"
|
|
4052
|
+
],
|
|
4050
4053
|
"enum": [
|
|
4051
4054
|
"top",
|
|
4052
4055
|
"center",
|
|
4053
4056
|
"bottom"
|
|
4054
4057
|
],
|
|
4055
4058
|
"default": "bottom",
|
|
4056
|
-
"description": "Anchor point for the scrollTo target: where the target lands along the scroll axis. top - its leading edge at the viewport's leading edge; center - its middle at the viewport's middle; bottom (the default) - its trailing edge at the viewport's trailing edge. Drawn so by SwiftUI's ScrollViewReader (vertical), the web (scrollCollectionToCell) and, since jsonui-cli 1.9.0, both Compose paths (4f ruling 2026-09-27, round 11): kjui codegen offset the target by the viewport (bottom) or half of it (center), which put the target's top edge at the viewport's bottom edge or middle, and KotlinJsonUI Dynamic landed center and bottom at the top. A reverseLayout list (Compose) lands top and bottom on the edges they name on screen. Measured 2026-08-05: ios (sjui collection_converter.rb:1138), web (rjui build_command.rb:768) and the Compose GRID path all fall back to bottom, so bottom is the declared default. On a horizontal Collection, top / center / bottom mean the start / centre / end along the scroll axis, in reading direction, on every platform (4f ruling 2026-09-27)."
|
|
4059
|
+
"description": "Anchor point for the scrollTo target: where the target lands along the scroll axis. top - its leading edge at the viewport's leading edge; center - its middle at the viewport's middle; bottom (the default) - its trailing edge at the viewport's trailing edge. Drawn so by SwiftUI's ScrollViewReader (vertical), the web (scrollCollectionToCell) and, since jsonui-cli 1.9.0, both Compose paths (4f ruling 2026-09-27, round 11): kjui codegen offset the target by the viewport (bottom) or half of it (center), which put the target's top edge at the viewport's bottom edge or middle, and KotlinJsonUI Dynamic landed center and bottom at the top. A reverseLayout list (Compose) lands top and bottom on the edges they name on screen. Measured 2026-08-05: ios (sjui collection_converter.rb:1138), web (rjui build_command.rb:768) and the Compose GRID path all fall back to bottom, so bottom is the declared default. On a horizontal Collection, top / center / bottom mean the start / centre / end along the scroll axis, in reading direction, on every platform (4f ruling 2026-09-27). A binding (jsonui-cli 1.9.29, jui-collection-scrollto-per-request-anchor) chooses the anchor per request: it is read when the scrollTo value CHANGES, so the anchor a request lands by is the bound value at that moment — change it in the same update as the scrollTo value, or before; a change of the anchor alone scrolls nothing, and a landing in flight keeps the anchor it started with. A bound value that is not top, center or bottom lands as bottom, with a DEBUG warning, on every path. Under reverseLayout a bound anchor trades top and bottom as the written one does. Until 1.9.29 a binding here was read as bottom (rjui, kjui) or did not compile (sjui). The target is the CELL as the Collection lays it out: with cellHeight, the cellHeight-high cell, not its content (rjui-scrollanchor-lands-on-cell-content-not-cell, 2026-10-14: the web landed the content, so a cell taller than its content landed short of where iOS and Compose land it)."
|
|
4057
4060
|
},
|
|
4058
4061
|
"defaultScrollAnchor": {
|
|
4059
4062
|
"type": "string",
|
|
@@ -4062,11 +4065,14 @@
|
|
|
4062
4065
|
"center",
|
|
4063
4066
|
"bottom"
|
|
4064
4067
|
],
|
|
4065
|
-
"description": "Initial scroll position anchor (iOS 17+). Sets where the scroll view starts. On Compose (kjui codegen and KotlinJsonUI Dynamic) it is applied once, when the list first has cells, by scrolling to the middle (center) or last (bottom) CELL counted as scrollTo counts them - across the drawn sections, headers, footers and grid fillers not counted (4f ruling 2026-09-27, round 11); until jsonui-cli 1.9.0 it counted the first data section only and scrolled to that lazy item index. Under reverseLayout a list rests at its visual bottom, so top and bottom trade places, as they do for scrollAnchor (4f ruling 2026-09-27, round 12 - where iOS lands, which draws the same order from content anchored at its bottom): bottom is where the list rests and moves nothing; top goes to the cell drawn at the visual top, the last one the content emits. Until jsonui-cli 1.9.0 bottom went to the last cell, which a reversed list draws at its visual top. A container that is not a lazy list and draws no reverseLayout (the wrapContent Column, the horizontal EAGER one) takes the middle or last cell to its top edge (round 12); the vertical EAGER one draws reverseLayout and follows the lazy list (round 13). It sets the initial position only: how a list keeps its position when its data changes with no scrollTo is each platform's own - iOS keeps the anchored edge while the list rests at it and, once a scrollTo has moved it, stays where it scrolled; Compose keeps the first visible item - so to follow new content, send scrollTo (4f ruling 2026-09-27, round 13; the iOS half measured round 14: a list resting at its bottom showed an appended cell there, and one a scrollTo had left above a cell kept it below the edge when more arrived). On Compose a list that is not reversed, with defaultScrollAnchor bottom and content shorter than its viewport, sits at its bottom, as iOS draws it (4f ruling 2026-09-27, round 14; it sat at the top). On a horizontal Collection, top / center / bottom mean the start / centre / end along the scroll axis, in reading direction, on every platform (4f ruling 2026-09-27); a horizontal list whose content is shorter than its row sits at the row's centre (center) or end (bottom) on Compose too, on the lazy and eager routes, as iOS draws it - measured on sjui codegen and SwiftJsonUI Dynamic - and a `lazy: none` row, which does not scroll, at its start, as sjui codegen draws it (4f ruling 2026-09-27, round 15; every Compose row sat at its start until jsonui-cli 1.9.0). A vertical list shorter than its viewport sits in its middle for center, on Compose too - the lazy and eager routes, reversed or not, as iOS draws them (measured 2026-09-27 on sjui codegen and SwiftJsonUI Dynamic) - and a `lazy: none` Column, which does not scroll, at its top (4f ruling 2026-09-27, round 16; it sat at the top, the bottom when reversed, until jsonui-cli 1.9.0)."
|
|
4068
|
+
"description": "Initial scroll position anchor (iOS 17+). Sets where the scroll view starts. On Compose (kjui codegen and KotlinJsonUI Dynamic) it is applied once, when the list first has cells, by scrolling to the middle (center) or last (bottom) CELL counted as scrollTo counts them - across the drawn sections, headers, footers and grid fillers not counted (4f ruling 2026-09-27, round 11); center lands that cell's middle at the viewport's middle, as iOS and the web draw a list longer than its viewport (kjui-defaultscrollanchor-center-differs-when-content-overflows, 2026-10-14: Compose put it at the top edge, and a list whose middle cell could not get there stopped at its end, drawing bottom's picture); until jsonui-cli 1.9.0 it counted the first data section only and scrolled to that lazy item index. Under reverseLayout a list rests at its visual bottom, so top and bottom trade places, as they do for scrollAnchor (4f ruling 2026-09-27, round 12 - where iOS lands, which draws the same order from content anchored at its bottom): bottom is where the list rests and moves nothing; top goes to the cell drawn at the visual top, the last one the content emits. Until jsonui-cli 1.9.0 bottom went to the last cell, which a reversed list draws at its visual top. A container that is not a lazy list and draws no reverseLayout (the wrapContent Column, the horizontal EAGER one) takes the middle or last cell to its top edge (round 12); the vertical EAGER one draws reverseLayout and follows the lazy list (round 13). It sets the initial position only: how a list keeps its position when its data changes with no scrollTo is each platform's own - iOS keeps the anchored edge while the list rests at it and, once a scrollTo has moved it, stays where it scrolled; Compose keeps the first visible item - so to follow new content, send scrollTo (4f ruling 2026-09-27, round 13; the iOS half measured round 14: a list resting at its bottom showed an appended cell there, and one a scrollTo had left above a cell kept it below the edge when more arrived). On Compose a list that is not reversed, with defaultScrollAnchor bottom and content shorter than its viewport, sits at its bottom, as iOS draws it (4f ruling 2026-09-27, round 14; it sat at the top). On a horizontal Collection, top / center / bottom mean the start / centre / end along the scroll axis, in reading direction, on every platform (4f ruling 2026-09-27); a horizontal list whose content is shorter than its row sits at the row's centre (center) or end (bottom) on Compose too, on the lazy and eager routes, as iOS draws it - measured on sjui codegen and SwiftJsonUI Dynamic - and a `lazy: none` row, which does not scroll, at its start, as sjui codegen draws it (4f ruling 2026-09-27, round 15; every Compose row sat at its start until jsonui-cli 1.9.0). A vertical list shorter than its viewport sits in its middle for center, on Compose too - the lazy and eager routes, reversed or not, as iOS draws them (measured 2026-09-27 on sjui codegen and SwiftJsonUI Dynamic) - and a `lazy: none` Column, which does not scroll, at its top (4f ruling 2026-09-27, round 16; it sat at the top, the bottom when reversed, until jsonui-cli 1.9.0)."
|
|
4066
4069
|
},
|
|
4067
4070
|
"scrollAnimated": {
|
|
4068
|
-
"type":
|
|
4069
|
-
|
|
4071
|
+
"type": [
|
|
4072
|
+
"boolean",
|
|
4073
|
+
"binding"
|
|
4074
|
+
],
|
|
4075
|
+
"description": "Whether scrollTo uses animation (default: true). A binding decides at run time: the scroll animates only when the bound value is true (an unset bound value jumps) — read so by sjui, kjui and rjui since jsonui-cli 1.9.0 and declared in 1.9.29 (jui-collection-scrollto-per-request-anchor)."
|
|
4070
4076
|
},
|
|
4071
4077
|
"cellIdProperty": {
|
|
4072
4078
|
"type": "string",
|
|
@@ -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",
|
|
@@ -239,7 +239,7 @@
|
|
|
239
239
|
}
|
|
240
240
|
},
|
|
241
241
|
"rules": [
|
|
242
|
-
"orientation
|
|
242
|
+
"the axis is horizontal when horizontalScroll is true or orientation is horizontal, and vertical otherwise — never taken from the children; an orientation and a horizontalScroll that disagree are refused by jui build (ERROR [scrollview])",
|
|
243
243
|
"showsIndicator controls scrollbar visibility"
|
|
244
244
|
]
|
|
245
245
|
},
|
|
@@ -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)",
|
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.30";
|
|
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)",
|
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("_")
|
|
@@ -101,31 +101,6 @@ exports[`derived constants (pinned) > MODIFIER_ORDER snapshot 1`] = `
|
|
|
101
101
|
"Image: .resizable() must come first, then .aspectRatio",
|
|
102
102
|
"Label: .leading alignment added to frame for textAlign to work",
|
|
103
103
|
],
|
|
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
104
|
},
|
|
130
105
|
}
|
|
131
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 () => {
|