markswift 0.0.0-stage → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/CHANGELOG.md +234 -0
  2. package/LICENSE +21 -0
  3. package/README.md +592 -2
  4. package/dist/cli-jdrbzqfa.js +2975 -0
  5. package/dist/cli-qchh0m4d.js +14041 -0
  6. package/dist/cli.js +637 -0
  7. package/dist/compiler/index.js +20 -0
  8. package/dist/index.js +22 -0
  9. package/dist/types/analysis/alignment.d.ts +12 -0
  10. package/dist/types/analysis/bands.d.ts +53 -0
  11. package/dist/types/analysis/breakpoints.d.ts +22 -0
  12. package/dist/types/analysis/class-hints.d.ts +21 -0
  13. package/dist/types/analysis/context.d.ts +49 -0
  14. package/dist/types/analysis/controls.d.ts +97 -0
  15. package/dist/types/analysis/flex-row.d.ts +14 -0
  16. package/dist/types/analysis/flex-sizing.d.ts +5 -0
  17. package/dist/types/analysis/flow-layout.d.ts +18 -0
  18. package/dist/types/analysis/generated.d.ts +16 -0
  19. package/dist/types/analysis/geometry.d.ts +50 -0
  20. package/dist/types/analysis/grid-tracks.d.ts +44 -0
  21. package/dist/types/analysis/grid.d.ts +5 -0
  22. package/dist/types/analysis/infer.d.ts +18 -0
  23. package/dist/types/analysis/invisible.d.ts +6 -0
  24. package/dist/types/analysis/layers.d.ts +15 -0
  25. package/dist/types/analysis/measured-layout.d.ts +6 -0
  26. package/dist/types/analysis/motion.d.ts +29 -0
  27. package/dist/types/analysis/naming.d.ts +6 -0
  28. package/dist/types/analysis/node-base.d.ts +13 -0
  29. package/dist/types/analysis/size-constraints.d.ts +5 -0
  30. package/dist/types/analysis/sizing.d.ts +24 -0
  31. package/dist/types/analysis/style-background.d.ts +24 -0
  32. package/dist/types/analysis/style-border.d.ts +7 -0
  33. package/dist/types/analysis/style-effects.d.ts +4 -0
  34. package/dist/types/analysis/style.d.ts +9 -0
  35. package/dist/types/analysis/svg/attributes.d.ts +10 -0
  36. package/dist/types/analysis/svg/build.d.ts +6 -0
  37. package/dist/types/analysis/svg/context.d.ts +31 -0
  38. package/dist/types/analysis/svg/geometry.d.ts +11 -0
  39. package/dist/types/analysis/svg/gradients.d.ts +6 -0
  40. package/dist/types/analysis/svg/markup.d.ts +23 -0
  41. package/dist/types/analysis/svg/text.d.ts +7 -0
  42. package/dist/types/analysis/svg/transforms.d.ts +19 -0
  43. package/dist/types/analysis/svg/traversal.d.ts +4 -0
  44. package/dist/types/analysis/table-borders.d.ts +19 -0
  45. package/dist/types/analysis/table.d.ts +36 -0
  46. package/dist/types/analysis/text-layout.d.ts +65 -0
  47. package/dist/types/analysis/text-runs.d.ts +30 -0
  48. package/dist/types/analysis/text.d.ts +9 -0
  49. package/dist/types/analysis/tooltips.d.ts +16 -0
  50. package/dist/types/analysis/typography.d.ts +21 -0
  51. package/dist/types/analysis/wrapping.d.ts +5 -0
  52. package/dist/types/api/bands.d.ts +58 -0
  53. package/dist/types/api/build.d.ts +51 -0
  54. package/dist/types/api/convert.d.ts +13 -0
  55. package/dist/types/api/types.d.ts +69 -0
  56. package/dist/types/browser/capture.d.ts +89 -0
  57. package/dist/types/browser/conditions.d.ts +25 -0
  58. package/dist/types/browser/fonts.d.ts +34 -0
  59. package/dist/types/browser/images.d.ts +3 -0
  60. package/dist/types/browser/layers.d.ts +132 -0
  61. package/dist/types/browser/motion.d.ts +44 -0
  62. package/dist/types/browser/settle.d.ts +27 -0
  63. package/dist/types/browser/sizing.d.ts +10 -0
  64. package/dist/types/browser/snapshot.d.ts +155 -0
  65. package/dist/types/browser/states.d.ts +36 -0
  66. package/dist/types/cli/args.d.ts +31 -0
  67. package/dist/types/cli/config.d.ts +78 -0
  68. package/dist/types/cli/css.d.ts +9 -0
  69. package/dist/types/cli/manifest.d.ts +20 -0
  70. package/dist/types/cli/output.d.ts +27 -0
  71. package/dist/types/cli/run.d.ts +4 -0
  72. package/dist/types/cli.d.ts +2 -0
  73. package/dist/types/compiler/index.d.ts +91 -0
  74. package/dist/types/compiler/naming.d.ts +1 -0
  75. package/dist/types/css/background.d.ts +16 -0
  76. package/dist/types/css/blend.d.ts +9 -0
  77. package/dist/types/css/color.d.ts +14 -0
  78. package/dist/types/css/counter.d.ts +10 -0
  79. package/dist/types/css/easing.d.ts +4 -0
  80. package/dist/types/css/effects.d.ts +18 -0
  81. package/dist/types/css/grid.d.ts +29 -0
  82. package/dist/types/css/index.d.ts +15 -0
  83. package/dist/types/css/media.d.ts +16 -0
  84. package/dist/types/css/paint.d.ts +33 -0
  85. package/dist/types/css/radius.d.ts +5 -0
  86. package/dist/types/css/raster.d.ts +10 -0
  87. package/dist/types/css/shadows.d.ts +3 -0
  88. package/dist/types/css/syntax.d.ts +12 -0
  89. package/dist/types/css/transforms.d.ts +45 -0
  90. package/dist/types/css/typography.d.ts +8 -0
  91. package/dist/types/fonts/converter.d.ts +16 -0
  92. package/dist/types/fonts/metadata.d.ts +6 -0
  93. package/dist/types/fonts/package.d.ts +12 -0
  94. package/dist/types/fonts/registration.d.ts +13 -0
  95. package/dist/types/fonts/source.d.ts +14 -0
  96. package/dist/types/fonts/types.d.ts +28 -0
  97. package/dist/types/html/elements.d.ts +8 -0
  98. package/dist/types/html/index.d.ts +4 -0
  99. package/dist/types/html/inputs.d.ts +4 -0
  100. package/dist/types/html/policy.d.ts +3 -0
  101. package/dist/types/html/types.d.ts +12 -0
  102. package/dist/types/index.d.ts +8 -0
  103. package/dist/types/input/identity.d.ts +3 -0
  104. package/dist/types/input/mime.d.ts +1 -0
  105. package/dist/types/input/root.d.ts +1 -0
  106. package/dist/types/input/server.d.ts +11 -0
  107. package/dist/types/input/source.d.ts +9 -0
  108. package/dist/types/input/types.d.ts +54 -0
  109. package/dist/types/model/fonts.d.ts +26 -0
  110. package/dist/types/model/interactive.d.ts +10 -0
  111. package/dist/types/model/layout.d.ts +528 -0
  112. package/dist/types/model/semantic.d.ts +984 -0
  113. package/dist/types/model/values.d.ts +125 -0
  114. package/dist/types/model/version.d.ts +4 -0
  115. package/dist/types/swift/borders.d.ts +13 -0
  116. package/dist/types/swift/components.d.ts +115 -0
  117. package/dist/types/swift/context.d.ts +184 -0
  118. package/dist/types/swift/control-state.d.ts +19 -0
  119. package/dist/types/swift/controls.d.ts +62 -0
  120. package/dist/types/swift/diagnostics.d.ts +3 -0
  121. package/dist/types/swift/effects.d.ts +23 -0
  122. package/dist/types/swift/emit.d.ts +6 -0
  123. package/dist/types/swift/format.d.ts +91 -0
  124. package/dist/types/swift/frames.d.ts +17 -0
  125. package/dist/types/swift/images.d.ts +25 -0
  126. package/dist/types/swift/layout.d.ts +5 -0
  127. package/dist/types/swift/measured-layout.d.ts +4 -0
  128. package/dist/types/swift/modifiers.d.ts +30 -0
  129. package/dist/types/swift/motion.d.ts +20 -0
  130. package/dist/types/swift/nodes.d.ts +4 -0
  131. package/dist/types/swift/paint.d.ts +22 -0
  132. package/dist/types/swift/runtime.d.ts +6 -0
  133. package/dist/types/swift/shadows.d.ts +7 -0
  134. package/dist/types/swift/states.d.ts +3 -0
  135. package/dist/types/swift/svg.d.ts +14 -0
  136. package/dist/types/swift/text-box.d.ts +12 -0
  137. package/dist/types/swift/text.d.ts +3 -0
  138. package/dist/types/swift/tokens/emit.d.ts +26 -0
  139. package/dist/types/swift/tokens/extract.d.ts +21 -0
  140. package/dist/types/swift/tokens/naming.d.ts +22 -0
  141. package/dist/types/swift/tokens/slots.d.ts +18 -0
  142. package/dist/types/swift/tokens/types.d.ts +77 -0
  143. package/dist/types/swift/tokens.d.ts +6 -0
  144. package/dist/types/swift/traversal.d.ts +2 -0
  145. package/dist/types/swift/wrapping.d.ts +4 -0
  146. package/package.json +66 -4
package/README.md CHANGED
@@ -1,3 +1,593 @@
1
- # Temporary Holding Version
1
+ # Markswift
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Convert rendered HTML and CSS into reusable SwiftUI components.
4
+
5
+ Markswift captures a page or component in Chrome and writes SwiftUI for iOS 18+ and Swift 6 that looks like the
6
+ browser's rendering and can be reused: repeated siblings become a `ForEach` over a data array with an extracted row
7
+ view, every button and link is an `on<Name>` closure parameter, and the screen has an explicit `init` whose parameters
8
+ default to the design. Names come from the markup (`id`, `data-slot`, `aria-label`, class, nearest heading) and can be
9
+ set with `data-markswift-component` / `data-markswift-field` ([Components](#components)). The output is tool-owned:
10
+ regenerate it with `--update` and customise through the initializer and your own files
11
+ ([Regenerating](#regenerating)); `markswift build` puts many components into one Swift package. `app` mode is the
12
+ product, `fidelity` exists for verification ([Modes](#modes)). What is measured on iOS is listed under
13
+ [Supported subset](#supported-subset-evidence); everything else the capture loses is reported (see
14
+ [Diagnostics](#diagnostics)).
15
+
16
+ New in 2.0:
17
+
18
+ - Breakpoints (opt-in `--breakpoints`): one conversion captures every CSS width band and emits one view that switches
19
+ on the width SwiftUI offers it; iPad portrait and landscape are width bands ([Library API](#library-api)).
20
+ - Dialogs, alert dialogs, sheets, popovers and hover cards opened by a trigger are found automatically and drawn in the
21
+ view hierarchy with their CSS look behind an `is<Name>Presented` state seeded through `init`; disclosures expand in
22
+ place (`is<Name>Expanded`) and menus open native popups ([Not translated](#not-translated) lists the limits).
23
+ - CSS animations and transitions play with their CSS timing and honour Reduce Motion ([Motion](#motion)).
24
+
25
+ Upgrading from 1.x: saved 1.x captures must be recaptured and some call sites change — see
26
+ [Migrating from 1.x](CHANGELOG.md#migrating-from-1x).
27
+
28
+ ## Requirements
29
+
30
+ - Node.js ≥ 20 or Bun ≥ 1.4.2 (tested with 1.4.3), and an installed Google Chrome.
31
+ - `python3` with `fontTools` and `brotli` to bundle webfonts. Without them the conversion still
32
+ completes and reports each font it could not bundle.
33
+ - Xcode, only to compile the generated Swift.
34
+
35
+ ## Install
36
+
37
+ ```sh
38
+ npm install --save-dev markswift
39
+ npx markswift --version # or: bunx markswift --version
40
+ ```
41
+
42
+ From a checkout (contributors):
43
+
44
+ ```sh
45
+ git clone git@github.com:raelsei/markswift.git && cd markswift
46
+ bun install --frozen-lockfile
47
+ bun src/cli.ts --version
48
+ ```
49
+
50
+ In a checkout write `bun src/cli.ts` for `npx markswift` and `from "./src/index.ts"` for `from "markswift"`.
51
+
52
+ ## Quick start (CLI)
53
+
54
+ ```sh
55
+ printf '%s\n' '<main data-markswift-root style="width:240px;height:80px;font:18px system-ui">Hello Markswift</main>' \
56
+ | npx markswift convert - --name HelloCard --out ./generated-hello
57
+ ```
58
+
59
+ prints `Wrote 3 files for HelloCard to ./generated-hello`. `--out` must not exist yet; regenerate later with `--update`; one
60
+ conversion writes several files. Other inputs: `npx markswift convert ./page.html --out ./out`
61
+ (relative assets resolve beside the file), `--css ./compiled.css` for a separate **compiled**
62
+ stylesheet, and `--url http://localhost:5173/card --root '#preview'` for a running page.
63
+
64
+ Options (`npx markswift convert --help`): `--out <dir>` (required), `--update`, `--force`, `--name <Name>` (default
65
+ `ConvertedView`), `--root <selector>`, `--width`/`--height` (default 390 × 844),
66
+ `--theme adaptive|light|dark` (default `adaptive`), `--dark-class <name>`, `--mode app|fidelity`
67
+ (default `app`), `--css <file>`, `--url <url>`, `--breakpoints <auto|W[xH],…>` (one capture per CSS width band — auto:
68
+ 390, 834×1210, 1210×834, 1376×1032 — and one view that switches at the CSS thresholds). Exit status: 0 converted,
69
+ 2 invalid options or hand-edited files refused by `--update`, 1 conversion or file error. Warnings and diagnostics go
70
+ to stderr.
71
+
72
+ ## What you get
73
+
74
+ | File | When |
75
+ | --- | --- |
76
+ | `<Name>.swift` | Always: the view with an explicit `init`, a light and a dark `#Preview`, plus `extension <Name> { struct <Type>; static let <types>; struct <Type>View }` with extracted row types, data, subviews and one `#Preview` per subview when the design repeats sibling elements, and one `#Preview` per width band for breakpoint conversions |
77
+ | `<Name>+Tokens.swift` | Always: colours in `<Name>.Palette` |
78
+ | `MarkswiftRuntime.swift` | Always: shared helpers; its header names the Markswift version, and every conversion by that version produces the identical file |
79
+ | `<Name>+Shapes.swift` | When SVG or clip shapes exist |
80
+ | `<Name>+Fonts.swift`, `<Name>+UIAppFonts.plist.fragment`, `Fonts/*.ttf` | When webfonts were bundled |
81
+ | `Images/<hash>.<ext>` | When the component shows raster images (one file per distinct image) |
82
+ | `.markswift.json` | Always: the manifest `--update` reads (`tool`, `version`, and the SHA-256 of every file this run wrote) |
83
+
84
+ Every generated `.swift` file starts with `// Generated by Markswift <version> — …`; `npx markswift --version` prints
85
+ the same version.
86
+
87
+ ## Components
88
+
89
+ Repeated sibling elements (app mode) become a row type `<Type>`, a subview `<Type>View` and a data array `<types>`
90
+ (`static let`, the design's rows) iterated with `ForEach`. The screen always has an explicit `init` whose parameters
91
+ all default to the design, in this order: data arrays, annotated fields, actions:
92
+
93
+ ```swift
94
+ // TeamCards.swift, generated from tests/conformance/fixtures/components/tw-cards.html with --name TeamCards:
95
+ init(inviteATeammateCards: [InviteATeammateCard] = Self.inviteATeammateCards, onMessage: @escaping (InviteATeammateCard) -> Void = { _ in }, onManage: @escaping (InviteATeammateCard) -> Void = { _ in })
96
+
97
+ // Your code:
98
+ TeamCards() // the design as captured
99
+ TeamCards(onMessage: { card in print("message", card.title) }) // card: TeamCards.InviteATeammateCard
100
+ ```
101
+
102
+ - A run can be single elements too (a text, an image, a button): adjacent equal leaves become a `ForEach` when their
103
+ colours agree and the stack spaces them evenly; otherwise they stay unrolled.
104
+ - Per-row buttons collapse into one `on<Name>: (<Type>) -> Void` that receives the tapped row (`on<Type>` when the
105
+ buttons were named after their own captions); other actions are `() -> Void`.
106
+ - State: rows with controls are owned by the screen as `@State` seeded through `init`; the subview takes `@Binding var <type>: <Type>`.
107
+ Grids and measured, wrapping or distributed stacks call the subview per element from `Self.<types>[i]` and are not parameters.
108
+ - Extracted ordered-list rows derive their marker from the row index: the marker string field is gone from the row
109
+ type and the row subview takes `ordinal: Int`.
110
+ - Names come from the markup, first match wins: `data-markswift-component`, `id`, `data-slot`, `aria-label`, the first
111
+ non-utility class, else the nearest heading plus a tag noun (`li` → `Row`, `article` → `Card`, else `Item`). Fields use
112
+ `data-markswift-field`, then `data-slot`/class, then their kind (`title`, `text`, `foregroundColor`, `isOn`, …).
113
+ Override poor names with the two attributes:
114
+
115
+ ```html
116
+ <h2 data-markswift-field="title">Team members</h2>
117
+ <ul>
118
+ <li data-markswift-component="Member">
119
+ <span data-markswift-field="name">Ada</span> <span data-markswift-field="role">Owner</span>
120
+ </li>
121
+ </ul>
122
+ ```
123
+
124
+ `data-markswift-field` outside a repeated row turns a single-run text into `var <field>: String` or seeds a form
125
+ control's state through `init`. Names are API: a rule change renames them on regeneration (noted in the changelog).
126
+ - `convert` emits internal types; the `visibility: "public"` compile option (for a generated library) makes the
127
+ view, its `init`, row structs (with an explicit `public init`), data arrays and subviews `public`.
128
+
129
+ ## Regenerating
130
+
131
+ Generated files are tool-owned. Every output directory carries `.markswift.json` (`tool`, `version`, and `files`:
132
+ each written path → its SHA-256). When the design changes, rerun the same command with `--update`: it rewrites the
133
+ listed files, deletes listed files the new output no longer produces, and never touches a file it did not write. If a
134
+ listed file was edited by hand it refuses with exit 2 and names the file (`--update --force` discards those edits;
135
+ `--force` alone is a usage error); an unlisted file in the way of a new generated file is always refused, and so is a
136
+ directory without `.markswift.json` (exit 1, even with `--force`). Customise through the component's initializer,
137
+ [`--palette`](#using-your-apps-colours---palette), `extension`s and wrappers in your own files, and commit the output
138
+ directory including `.markswift.json`.
139
+
140
+ ## Library API
141
+
142
+ ```ts
143
+ import { mkdir, writeFile } from "node:fs/promises";
144
+ import { dirname } from "node:path";
145
+ import { convertHtml } from "markswift";
146
+
147
+ const result = await convertHtml(
148
+ '<article data-markswift-root class="card"><h2>Hello SwiftUI</h2><p>Made from HTML.</p></article>',
149
+ {
150
+ name: "WelcomeCard",
151
+ css: `
152
+ body { margin: 0; }
153
+ .card { width: 320px; padding: 24px; background: #f4f4f5; border-radius: 16px; }
154
+ h2, p { font-family: system-ui; }
155
+ `,
156
+ },
157
+ );
158
+
159
+ console.log(Object.keys(result.files), result.diagnostics.length, result.warnings);
160
+ for (const [path, data] of Object.entries({ ...result.files, ...result.assets })) {
161
+ const file = `generated-welcome/${path}`;
162
+ await mkdir(dirname(file), { recursive: true });
163
+ await writeFile(file, data);
164
+ }
165
+ ```
166
+
167
+ - `convert(source, options)` takes `{ file }`, `{ url }` or `{ html, baseURL?, assetsDir? }`;
168
+ `convertHtml(html, options)` is the string shorthand. `compileLayout(layout, { name, mode, dark })`
169
+ recompiles a saved `LayoutDoc` (version 4) without Chrome; it does not package fonts. A saved `LayoutDoc` is a
170
+ cache, not an interchange format: Markswift compiles only the LayoutDoc version it captures (`LAYOUT_DOC_VERSION`)
171
+ and rejects any other with an error naming both versions — recapture the source. A change to the captured style
172
+ list or the schema bumps the LayoutDoc version and is a breaking release. `compileLayout(layout, { variants,
173
+ darkVariants, variantProbes })` compiles saved width-band captures (ascending, `layout` is band 0) into one view.
174
+ - Options: `name`, `mode` (`app` | `fidelity`), `theme` (`adaptive` | `light` | `dark`),
175
+ `darkClass`, `css` (compiled CSS), `root` (default `[data-markswift-root]`, otherwise `body`),
176
+ `viewport` (default 390 × 844), `dpr` (default 1), `waitFor`, `prepare(page)`,
177
+ `untrustedInput`, `timeoutMs` (default 120000 ms for the whole conversion), `breakpoints` (`true` or
178
+ `{ devices?, max? }`: captures one LayoutDoc per CSS width band, see `bands`, and emits one view whose differing
179
+ elements switch at the CSS thresholds through `MarkswiftBands`; a stack or grid query container switches its
180
+ content on width `@container` queries at its own width, from extra captures of each band: its top and, past the
181
+ first band, its lower bound). Open states
182
+ need no option: triggers are found automatically (ARIA `aria-haspopup`/`aria-expanded`, native
183
+ `popovertarget`/`commandfor`/`interestfor`/`<details>`, shadcn `data-slot`,
184
+ `data-markswift-opens="click|hover|contextmenu"`), at most 12, and each is opened
185
+ in its own page per theme (~0.3 s); what it opens is captured in `layout.layers`. App mode emits the in-flow ones (a
186
+ closed `<details>`, collapsible or accordion item): the content sits behind an `is<Name>Expanded` state seeded
187
+ through `init`, the trigger toggles it and announces expanded/collapsed, and a `#Preview` shows it open. Dialogs,
188
+ alert dialogs, sheets, popovers and hover cards are drawn presentations (`.markswiftPresentation`) behind an
189
+ init-seeded `is<Name>Presented`; menus, menubars and selects open native `Menu`/`.contextMenu` popups.
190
+ - Result: `code`, `files` (every Swift source), `assets` (fonts and images), `diagnostics`,
191
+ `warnings` (`{ source, level?, feature?, component?, message }[]`: page and resource errors, viewport-dependent CSS
192
+ the view does not follow, font packaging, light/dark pairing, width-band merging), `layout` and, for adaptive
193
+ conversions, `darkLayout`;
194
+ with `breakpoints`, `bands` (one `{ layout, darkLayout? }` per width band, ascending; `bands[0].layout` is `layout`);
195
+ in app mode with an in-flow layer or a presentation, `openStates` (layer id → its `init` label).
196
+
197
+ ## Modes
198
+
199
+ - `app` (default) is the product: native stacks, grids and `Text` with native line breaking.
200
+ System and custom fonts scale with Dynamic Type (`@ScaledMetric`, `relativeTo:`) and boxes grow
201
+ with their text: a measured or authored height on anything that lays out text (text, stacks,
202
+ grids, text-bearing controls) is a floor (content CSS lets overflow the box does not grow it),
203
+ measured layouts move what lies below a grown child,
204
+ hugging label columns fit their text instead of a pinned width, and text editors size to their
205
+ text. Pages taller than the viewport scroll.
206
+ CSS `line-height` uses `.lineHeight(.multiple(…))` on iOS 26+ (exact line boxes; tight leading
207
+ compresses text the browser broke into lines, while one-line text keeps its line box and lets the
208
+ glyphs overflow it) and `.lineSpacing` on iOS 18–25 (`MarkswiftRuntime.swift`, `markswiftLineHeight`).
209
+ ARIA names, descriptions, values and states reach VoiceOver; `autocomplete`, `inputmode` and
210
+ `enterkeyhint` choose a text field's iOS keyboard, autofill type and Return key.
211
+ - `fidelity` keeps the captured geometry and line division at fixed sizes. It exists for visual
212
+ verification.
213
+
214
+ ## Light and dark
215
+
216
+ `theme: "adaptive"` (default) captures the page with `prefers-color-scheme: light`, then `dark`, and
217
+ emits one view from the light capture. Every colour that differs becomes a
218
+ `Color.markswift(light:dark:)` token in `<Name>.Palette`. Class-based themes (for example `.dark`)
219
+ need `darkClass: "dark"` (`--dark-class dark`), added to `<html>` for the dark capture. Nodes or
220
+ colour slots present in one theme only, or whose geometry differs by more than 0.5 px, keep their
221
+ light values and are reported in one `warnings` entry. `light` or `dark` captures one theme.
222
+ `compileLayout(light, { dark })` pairs saved captures without a browser.
223
+
224
+ ### Using your app's colours (--palette)
225
+
226
+ `--palette map.json` (`palette` in `ConvertOptions`, `BuildOptions` and `markswift.json`) points generated `Palette`
227
+ tokens at colours your app already has:
228
+
229
+ ```json
230
+ {
231
+ "--primary": "Color.accentColor",
232
+ "#e2e8f0": "Color(\"Border\")",
233
+ "#ffffff/#0a0a0a": "Color(\"Card\")"
234
+ }
235
+ ```
236
+
237
+ A key is the design's CSS variable (`--primary`), a static token's hex colour (`#rrggbb` or `#rrggbbaa`), or an
238
+ adaptive token's `#light/#dark` pair; the CSS variable wins over the hex. Values are one-line Swift `Color`
239
+ expressions emitted verbatim (your app's typecheck validates them). Views keep referencing `Palette.x`; only the
240
+ token's value changes, and its doc comment keeps the captured colour. Tokens without an entry and entries that matched
241
+ no token are listed as `palette` warnings. Single-use colours stay inline and are not mapped. Mapped colours are not
242
+ part of the visual verification.
243
+
244
+ ## Adding the output to Xcode
245
+
246
+ Add **all** generated `.swift` files to one target. For webfonts add `Fonts/` as resources and
247
+ either merge `<Name>+UIAppFonts.plist.fragment` into the app's Info.plist or call
248
+ `<Name>.BundledFonts.register(in:)` with the resource bundle before rendering. Add `Images/` as
249
+ resources; inside a Swift package set `MarkswiftAssets.bundle = .module` once at startup.
250
+
251
+ Several conversions can share one target: every per-view declaration (palette, shapes, bundled
252
+ fonts) is nested in the view's type and its files are prefixed with the view name. Add
253
+ `MarkswiftRuntime.swift`, each `Fonts/` file and each `Images/` file once. When you upgrade
254
+ Markswift, replace the runtime and re-convert older screens. For many components with one shared runtime, palette and
255
+ font set, use [`markswift build`](#building-a-component-library-markswift-build).
256
+
257
+ ## Building a component library (markswift build)
258
+
259
+ `markswift build [markswift.json] [--force]` captures every component of a config in one browser session and writes
260
+ ONE local Swift package. Paths are relative to the config file; `serve` (default: the config's directory) is served
261
+ over `http://127.0.0.1` so relative URLs such as `../assets/geist.woff2` resolve inside it. `root`, `viewport`,
262
+ `theme`, `darkClass`, `mode`, `css`, `waitFor` and `breakpoints` apply to every component; a component may override any
263
+ of them. `breakpoints` is `true` or `{ devices, max }`: one view across CSS width bands, as `convert --breakpoints`
264
+ (`false` turns a shared value off).
265
+ `source` is a file inside `serve` or an `http(s)` URL. Names must be Swift type names that SwiftUI does not already
266
+ use (`ButtonView`, not `Button`).
267
+
268
+ ```json
269
+ {
270
+ "library": "ShadcnKit",
271
+ "out": "ShadcnKit",
272
+ "serve": "fixtures",
273
+ "root": "[data-markswift-root]",
274
+ "theme": "adaptive",
275
+ "components": [
276
+ { "name": "AccordionView", "source": "fixtures/components/shadcn-accordion.html" },
277
+ { "name": "CardView", "source": "fixtures/components/shadcn-card.html", "theme": "light" }
278
+ ]
279
+ }
280
+ ```
281
+
282
+ The package (`Package.swift`, swift-tools 6.2, iOS 18 / macOS 15, Xcode 26) holds `Sources/<Library>/` with one
283
+ public `<Component>.swift` per component, `MarkswiftPalette.swift` (one module-level `public enum Palette`: one name
284
+ per light/dark colour pair across all components), `MarkswiftShapes.swift`, `MarkswiftFonts.swift` and `Fonts/`
285
+ (each webfont once), `Images/`, and one `MarkswiftRuntime.swift` (reading resources from `Bundle.module`). Add it once
286
+ with **File ▸ Add Package Dependencies ▸ Add Local…**, call `BundledFonts.register()` once at app launch (previews
287
+ call it themselves), then `import ShadcnKit` and use `AccordionView()`. Libraries generated separately coexist in one
288
+ app: each keeps its runtime internal.
289
+
290
+ Regeneration works like `convert --update`: rerunning `markswift build` replaces only the files listed in
291
+ `.markswift.json` and deletes listed files it no longer produces (a component removed from the config loses its
292
+ view); a hand-edited generated file stops the build with exit 2 unless `--force`; your own files in the directory are
293
+ never touched; an existing `out` without the manifest is refused. Customise through parameters and your own files,
294
+ not by editing generated files. From code: `buildLibrary({ library, components, serveRoot })`, or
295
+ `compileLayouts(components, { library })` for saved `LayoutDoc`s without Chrome.
296
+
297
+ ## Supported subset (evidence)
298
+
299
+ Numbers come from the tracked iOS baseline `tests/conformance/baselines/visual-all-ios.json`:
300
+ 1304 captures (every conformance fixture at 320 and 390 px, light and dark where the fixture has
301
+ both, in both modes, plus 52 `app` captures at 390 px of open layers (`@open:`) and animation frames (`@t:`)),
302
+ captured 2026-10-16 from source revision `c64d6c8` with Playwright's pinned Chromium 153.0.8010.12 and rendered on
303
+ the iOS runtime (`iPhone 17 com.apple.CoreSimulator.SimRuntime.iOS-27-0`, Xcode 27.1,
304
+ `UIHostingController` + `drawHierarchy`, so text fields, editors and scroll views are drawn natively). The
305
+ `components` family is 40 shadcn/ui and Tailwind component pages, 13 of them overlays opened by a trigger
306
+ (`tests/conformance/fixtures/components/`), compiled
307
+ offline with Tailwind 4.1. The metric is colour SSIM (minimum over R, G and B, at 1×, both images decoded to sRGB)
308
+ plus pixel mismatch and element-region anchors. `components@app` is held at SSIM ≥ 0.95 per capture except the
309
+ exceptions listed below (at most 20 % of its captures, each with a reason and an owner); every other family has a
310
+ ratcheted regression floor that never goes down. The block below is generated by
311
+ `bun run quality --readme tests/conformance/baselines/visual-all-ios.json tests/conformance/visual-policy-ios.json`
312
+ (a test fails when it is stale); `bun run quality --summary <manifest.json>` also prints the per-fixture table.
313
+
314
+ <!-- quality-summary tests/conformance/baselines/visual-all-ios.json -->
315
+ | domain | mode | n | mean ssim | min ssim | mean mismatch | anchors ok |
316
+ |---|---|---|---|---|---|---|
317
+ | attribute | app | 18 | 0.9628 | 0.8576 (attr-alt-320-light-app) | 0.0173 | 0/0 |
318
+ | breakpoints | app | 26 | 0.9778 | 0.9158 (swap-320-light-app) | 0.0070 | 0/0 |
319
+ | components | app | 198 | 0.9807 | 0.9168 (shadcn-field-320-dark-app) | 0.0095 | 0/0 |
320
+ | css-layout | app | 74 | 0.9863 | 0.8424 (display--contents-320-light-app) | 0.0110 | 16/16 |
321
+ | css-misc | app | 30 | 0.9667 | 0.9338 (motion-unmapped-390-light-app) | 0.0185 | 0/0 |
322
+ | css-paint | app | 94 | 0.9485 | 0.7061 (content-320-dark-app) | 0.0231 | 12/12 |
323
+ | css-typography | app | 112 | 0.9281 | 0.5713 (word-break-320-dark-app) | 0.0311 | 0/0 |
324
+ | html | app | 78 | 0.9455 | 0.8182 (pre-320-dark-app) | 0.0264 | 4/4 |
325
+ | svg | app | 40 | 0.9761 | 0.9326 (svg-lineargradient-320-dark-app) | 0.0164 | 0/0 |
326
+ | tailwind-v4 | app | 8 | 0.9491 | 0.8750 (gradients-390-dark-app) | 0.0269 | 0/0 |
327
+ | attribute | fidelity | 18 | 0.9429 | 0.8417 (attr-alt-320-light-fidelity) | 0.0230 | 0/0 |
328
+ | breakpoints | fidelity | 26 | 0.9784 | 0.9290 (switch-390-light-fidelity) | 0.0074 | 0/0 |
329
+ | components | fidelity | 160 | 0.9758 | 0.9161 (shadcn-textarea-320-dark-fidelity) | 0.0106 | 0/0 |
330
+ | css-layout | fidelity | 74 | 0.9778 | 0.9370 (min-width-320-light-fidelity) | 0.0138 | 16/16 |
331
+ | css-misc | fidelity | 18 | 0.9519 | 0.9003 (motion-unmapped-320-light-fidelity) | 0.0258 | 0/0 |
332
+ | css-paint | fidelity | 94 | 0.9501 | 0.8923 (list-style-type-320-dark-fidelity) | 0.0256 | 12/12 |
333
+ | css-typography | fidelity | 112 | 0.9239 | 0.6544 (font-family-320-dark-fidelity) | 0.0347 | 0/0 |
334
+ | html | fidelity | 76 | 0.9325 | 0.7872 (text-level-320-light-fidelity) | 0.0304 | 4/4 |
335
+ | svg | fidelity | 40 | 0.9627 | 0.9112 (svg-lineargradient-320-dark-fidelity) | 0.0204 | 0/0 |
336
+ | tailwind-v4 | fidelity | 8 | 0.9484 | 0.8748 (gradients-390-dark-fidelity) | 0.0272 | 0/0 |
337
+
338
+ Exceptions (exempt from family floors and the 1.0 component target; the 0.005 drop rule still applies):
339
+
340
+ - `motion-unmapped-320-light-fidelity`: the unmapped background-position shimmer is the fixture's purpose: Chrome is captured with the gradient at its settled keyframe position, native draws the static authored gradient (unmapped motion, plan 064 D-MOTION) — owner: @raelsei
341
+ - `motion-unmapped-390-light-fidelity`: the unmapped background-position shimmer is the fixture's purpose: Chrome is captured with the gradient at its settled keyframe position, native draws the static authored gradient (unmapped motion, plan 064 D-MOTION) — owner: @raelsei
342
+ - `overlay-collapsible-320-dark-app`: the `<pre>` stack `ui-monospace, SFMono-Regular, Menlo` is Menlo in Chrome and SF Mono (wider) natively, so the `<pre>` lines are wider and wrap differently — generic ui-* faces are a support boundary (README, plan 059) — owner: @raelsei
343
+ - `overlay-collapsible-320-light-app`: the `<pre>` stack `ui-monospace, SFMono-Regular, Menlo` is Menlo in Chrome and SF Mono (wider) natively, so the `<pre>` lines are wider and wrap differently — generic ui-* faces are a support boundary (README, plan 059) — owner: @raelsei
344
+ - `overlay-collapsible-390-dark-app`: the `<pre>` stack `ui-monospace, SFMono-Regular, Menlo` is Menlo in Chrome and SF Mono (wider) natively, so the `<pre>` lines are wider and wrap differently — generic ui-* faces are a support boundary (README, plan 059) — owner: @raelsei
345
+ - `overlay-collapsible-390-dark-app@open:advanced-settings`: the `<pre>` stack `ui-monospace, SFMono-Regular, Menlo` is Menlo in Chrome and SF Mono (wider) natively, so the `<pre>` lines are wider and wrap differently — generic ui-* faces are a support boundary (README, plan 059) — owner: @raelsei
346
+ - `overlay-collapsible-390-light-app`: the `<pre>` stack `ui-monospace, SFMono-Regular, Menlo` is Menlo in Chrome and SF Mono (wider) natively, so the `<pre>` lines are wider and wrap differently — generic ui-* faces are a support boundary (README, plan 059) — owner: @raelsei
347
+ - `shadcn-field@app`: items below the first input drift 0.5–1 pt down and the text is lighter — SwiftUI rounds the fractional CSS line boxes (19.25 pt labels, 21 pt descriptions) and the 32 pt field to the pixel grid, and Chrome rasterises the text heavier (the pinned fidelity captures score 0.938–0.949 too) — owner: @raelsei
348
+ - `shadcn-item-320-dark-app`: the description wraps one word earlier ("Deploys, failures / and mentions.", "A summary every / Monday morning.") — iOS native line breaking avoids a one-word last line; app mode keeps native breaking (decisions 2026-10-10) — owner: @raelsei
349
+ - `shadcn-item-320-light-app`: the description wraps one word earlier ("Deploys, failures / and mentions.", "A summary every / Monday morning.") — iOS native line breaking avoids a one-word last line; app mode keeps native breaking (decisions 2026-10-10) — owner: @raelsei
350
+ - `tw-cards-320-dark-app`: card descriptions wrap one word earlier ("members / and deploys.", "open / pull requests.") and the light-on-dark text is lighter — iOS native line breaking avoids a one-word last line (app mode keeps native breaking, decisions 2026-10-10), and Chrome rasterises light-on-dark text heavier (the light captures pass) — owner: @raelsei
351
+ - `tw-cards-390-dark-app`: card descriptions wrap one word earlier and the light-on-dark text is lighter — iOS native line breaking avoids a one-word last line (app mode keeps native breaking, decisions 2026-10-10), and Chrome rasterises light-on-dark text heavier (the light captures pass) — owner: @raelsei
352
+ <!-- /quality-summary -->
353
+
354
+ Of 678 `app` and 626 `fidelity` captures, 528 and 423 reach SSIM ≥ 0.95; 626 and 582
355
+ reach ≥ 0.90 (`jq '[.[] | select(.mode == "app" and .ssim >= 0.95)] | length'` over the baseline).
356
+ Read the table with these limits:
357
+
358
+ - Every `<family>@<mode>` has a regression floor in
359
+ `tests/conformance/visual-policy-{ios,mac}.json` (`families`), raised by `bun run quality --ratchet`
360
+ on each reviewed rebaseline and never lowered. The gate also fails a capture that drops more than
361
+ 0.005 below its baseline or misses an anchor.
362
+ - `<case>-390-<theme>-app@open:<layer>` captures check each in-flow layer open: Chrome's page with the trigger
363
+ clicked against the view initialised with `is<Name>Expanded: true`; they count toward their family floor. Menus
364
+ have no `@open` capture: their popup is native, so only the closed trigger is compared.
365
+ - Chrome renders `ui-monospace`, `ui-serif` and `ui-rounded` with other faces than iOS (SF Mono,
366
+ New York, SF Rounded), which depresses the `font-family` scores.
367
+
368
+ Every pull request also runs the macOS gate (`tests/conformance/baselines/visual-{pilot,all}-mac.json`, rendered
369
+ with `ImageRenderer` on macOS): the same per-capture 0.005 drop rule, anchors and family floors, plus the same-code
370
+ resize check (390-compiled code rendered at 320 and 768 px against fresh Chrome captures, `reflowMaxLoss`). Over all
371
+ fixtures (`--all --reflow`), each `fixtures/breakpoints/` page's merged breakpoint view is also rendered at its band
372
+ widths and at T−1/T of every CSS width threshold (`<case>-<width>-<theme>-app@bp`, `breakpointMaxLoss` 0.01 at band
373
+ capture widths, `reflowMaxLoss` at the others). macOS draws text at different heights than iOS, so its absolute
374
+ numbers are a regression signal, not the support claim.
375
+
376
+ Features outside this table may work but are not claimed; every captured feature is reported as
377
+ mapped, approximated or unmapped in `diagnostics`.
378
+
379
+ ## Verified behaviour
380
+
381
+ - Static accessibility parity on every pull request: every node Chrome's accessibility tree names in the pilot
382
+ fixture set keeps its role and name in the converted view, with no exceptions (`tests/conformance/ax-parity.test.ts`).
383
+ - iOS behaviour suite (`bun run test:ios-behaviour`, 34 cases in the iOS simulator — 19 curated component fixtures,
384
+ the 13 overlay fixtures, a `pointer-events: none` tap-through case and a breakpoint page — on `v*` tags, weekly and
385
+ on dispatch): the accessibility tree, Apple's
386
+ accessibility audit (Dynamic Type, clipped text, element descriptions, traits; hit regions are reported, not
387
+ failing), Dynamic Type at xSmall, large and AX5 plus the root height at AX3 against Chrome scaled to AX3,
388
+ right-to-left mirroring, interaction (toggles, text fields, pickers and every action closure is called), and motion:
389
+ an animated view moves on its live clock, and under Reduce Motion it moves only when something should still move
390
+ (see Motion); a text field's transitioned focused look arrives, with and without Reduce Motion. 2.0 interactions:
391
+ every disclosure opens and closes from its trigger; every drawn presentation opens (long-press for hover cards),
392
+ sits in a modal container when modal, closes from each of its closers and from an outside tap where the design
393
+ allows one, and its buttons' actions
394
+ are called; every native menu item's action is called; the breakpoint view switches bands while a typed text and a
395
+ toggled switch keep their values.
396
+ - 2 known audit issues are accepted and listed with their reasons in `tests/conformance/ios-behaviour.json`, both
397
+ outside the converter's reach: a text field whose value the audit reports as clipped in any stock `TextField`, and one
398
+ e-mail address whose label is the address itself (the design gives no other description).
399
+
400
+ ## Pinned geometry
401
+
402
+ In `app` mode these patterns keep the captured pixel geometry instead of reflowing, and say so in
403
+ `diagnostics` (one quoted note each):
404
+
405
+ - Flex-wrap outside the native-wrap subset: `approximated flex-wrap: flex-wrap flex grow allocation → captured child positions; cannot reflow at another size`.
406
+ - Unequal flex growth outside the `MarkswiftFlexRow` subset (unequal grow, px/0/%/content bases and shrink with CSS's
407
+ freeze loop and px min/max-width clamps, admitted when its solver reproduces Chrome; a second capture at 320 px
408
+ confirms it): `approximated flex-grow: unequal measured widths [146,146,300] (basis, shrink or content constraints); measured width pinned`.
409
+ - Grids outside the `MarkswiftTracks` subset (`fr`, `minmax(px, fr)`, `auto`, px, `repeat(auto-fill|auto-fit, minmax(px, 1fr))`
410
+ or one implicit `auto` column; line, negative-line, `span <n>` and named-area placement inside the explicit
411
+ columns; `auto`/px rows and one `grid-auto-rows` value; `justify-self`/`justify-items`) whose items overlap, use
412
+ template line names or implicit columns, flow by column or densely, or run right-to-left: `approximated display: grid: captured grid placement preserves holes, spans and alignment; responsive track recalculation is not reconstructed`.
413
+ - Inline-block rows: `approximated display: inline-block: inline-block items → rows of the captured line division; they do not re-wrap at another width`.
414
+ - Tables outside the `MarkswiftTable` subset (right-to-left, `visibility: collapse`, anonymous rows or cells, a separated table whose rows paint, a row or row group with `onclick`, a `role`, `aria-*` or `tabindex`, cells off the column/row lattice): `approximated table: <table> → measured rows/cells; shared column resizing, spanning semantics and table accessibility are not translated`.
415
+ - Auto-layout tables (`MarkswiftTable`): `approximated table-layout: auto table layout → captured column widths scale with the offered width; CSS min-/max-content column sizing is not reproduced`.
416
+ - Percentage relative offsets: `approximated position: relative: left 10% → offset pinned at 34.3px`.
417
+
418
+ Absolutely and fixed-positioned boxes attach to the corner CSS anchored them to, with their
419
+ captured size and offset; fixed boxes stay outside the page's scroll view.
420
+
421
+ ## Motion
422
+
423
+ CSS `@keyframes` animations play in `app` output through `MarkswiftAnimation` (`MarkswiftRuntime.swift`): opacity,
424
+ translate (px and `%` of the box), scale and rotate, with CSS timing — delay (negative too), fractional and infinite
425
+ iterations, direction, fill mode and per-keyframe `cubic-bezier()`/`steps()`/`linear()` easing — on a `TimelineView`
426
+ clock that stops once every one-shot has ended. Under Reduce Motion the author's `prefers-reduced-motion` rules win;
427
+ without them decorative loops stop at their rest look and one-shot animations lose their movement while their opacity
428
+ keeps its timing; status indicators (`role="status"`/`progressbar`, `aria-busy="true"`) keep turning. `fidelity`
429
+ output renders the settled look. The visual gate also seeks both sides: `<case>-390-<theme>-app@t:<ms>` captures pause
430
+ Chrome's animations and the native view at the same time (`@reduced@t:<ms>` under Reduce Motion; times from the
431
+ fixture's `markswift-motion` metas) and score them in the `motion@app` family.
432
+
433
+ Pressed and focused looks animate with the browser's own per-direction transition (duration, delay, easing; only the
434
+ transitioned properties). Mid-flight colours use SwiftUI's interpolation (endpoints exact), and an
435
+ interrupted transition reverses the SwiftUI way; the gate checks parameters and endpoints, not mid-flight frames
436
+ (D-MOTION). Under Reduce Motion without an author rule, scale/translate changes jump and fades remain.
437
+
438
+ Drawn overlays (dialogs, sheets, popovers, hover cards) enter with their CSS keyframes — the same `MarkswiftAnimation`,
439
+ starting when the layer is inserted — and leave with their exit keyframes as a removal transition with the exact
440
+ curve; the gate scores the enter at ½ its duration (`<case>-390-<theme>-app@open:<layer>@t:<ms>`, times from the
441
+ fixture's `markswift-motion-open` meta) and checks the exit's parameters, not its frames. Accordion, collapsible and
442
+ `<details>` content grows and shrinks with its CSS `height` animation (`MarkswiftReveal`: following content moves
443
+ with it). Native menus and tooltips use the system animation. Under Reduce Motion enter and exit drop their movement
444
+ and keep their opacity timing (D-MOTION); author `prefers-reduced-motion` rules for overlay enter/exit are not
445
+ captured (a capture warning says so).
446
+
447
+ ## Not translated
448
+
449
+ - JavaScript behaviour and business logic; scripts only run in Chrome during capture.
450
+ - Height and orientation media queries (reported; landscape is a wider width band), and width breakpoints without
451
+ `--breakpoints` (one viewport and one layout per capture). With it, CSS width media queries switch the view at the
452
+ root's proposed width and container queries switch at the container's width (stack and grid containers; height,
453
+ style and scroll-state queries are reported); a fixed-width root gets no breakpoints, and script-driven DOM
454
+ differences are emitted once per band.
455
+ - States other than pressed, focused and open: paint-only pressed and focused deltas *are* captured, and so is the
456
+ open state of a `<details>`, collapsible, accordion item, dialog, sheet, popover, hover card or menu (app mode);
457
+ hover-only states other than tooltips (`.help`) and hover cards (long-press), and disabled, validation, checked and
458
+ `data-state` variants are not.
459
+ - Swipe-to-dismiss and system presentation chrome: dialogs and popovers are drawn inside the component's frame (app
460
+ mode), with no collision flip, scroll lock or focus trap beyond VoiceOver's modal containment, and placed as
461
+ captured at the primary viewport. Menus, menubars and selects open native popups with their captured items (app
462
+ mode): the CSS look of the open list, shortcut text, item icons and submenu items are not drawn (a submenu is a
463
+ disabled entry), and a menu trigger has no tap action of its own; tooltips are described below.
464
+ - Tooltips are never drawn: `title` and captured tooltips (Radix/shadcn tooltip, `interestfor`) become `.help(…)`
465
+ (shown with a pointer on iPad and Mac) and a VoiceOver hint; nothing visible on iPhone.
466
+ - Scroll-driven animations, keyframed colours/shadows/filters/layout properties, `@starting-style` and script
467
+ animations (reported).
468
+ - Sticky pinning while scrolling (`position: sticky` stays at its rest position, reported).
469
+ - `<select multiple>` and `<datalist>`.
470
+ - Inline handlers on non-link elements inside running text (`<span onclick>`): they stay styled text and are reported.
471
+ - `ui-monospace` maps to a different native face (an explicit support boundary).
472
+
473
+ ## Known gaps
474
+
475
+ - 13 of the 198 `components@app` iOS captures are listed exceptions (see the block above): fractional line boxes
476
+ rounded to the pixel grid, iOS native line breaking that avoids a one-word last line, and `ui-monospace` drawn in
477
+ the wider SF Mono.
478
+ - Text and paint riders: a paragraph's own `unicode-bidi: plaintext` and the bidi isolation of list and
479
+ `<summary>` markers are not mapped; `color-scheme` is read on the root only, when it names one scheme; on system
480
+ fonts `font-stretch` snaps to SF's nearest width and other variation axes are dropped (reported); the `@font-face`
481
+ `font-stretch` descriptor does not select faces; a `pointer-events: none` overlay with a `pointer-events: auto`
482
+ descendant stays hittable as a whole; an aria-hidden transparent control is dropped even when the pointer could
483
+ hit it.
484
+ - `position: relative` with a `%` top/bottom resolves only against a px-height containing block (a `%`-height block
485
+ or a stretched flex/grid item lands at 0, reported); offsets of inline text runs and of `::before`/`::after` are not
486
+ drawn as offsets.
487
+ - Generated images (`content: url()`, inside `list-style-image`) are drawn at their captured size and do not scale with
488
+ Dynamic Type; outside list markers whose captured widths differ (alphabetic, roman, decimal from 10) keep a per-row
489
+ offset, so such lists stay unrolled instead of a `ForEach`.
490
+ - Native tables (`MarkswiftTable`): cells read in DOM order and `<th>` announces as a header, but VoiceOver table navigation (row/column position, header association) is not translated (`approximated table` on every admitted table).
491
+ - Layout: bare text beside a float is not split around it; a `width: %` block inside a `dir=rtl` parent is drawn at
492
+ the left edge; at other widths a percentage `transform: translateX(…%)` stays pinned at its captured px offset and an
493
+ `object-fit: cover` image does not refill its box.
494
+ - Overlays and menus: a trigger inside an open layer (nested dialog, submenu) is not opened; a trigger inside a
495
+ repeated row keeps that row from becoming a `ForEach`; dialog content keeps its captured width; with
496
+ `--breakpoints`, layers come from the primary capture and apply in every band.
497
+ - Accessibility: the 2 known audit issues above; hit-region findings are reported, not failing.
498
+
499
+ ## Diagnostics
500
+
501
+ The CLI prints one `Warning: [<component>] <level> <feature>: <message>` line per warning (resources, fonts,
502
+ viewport-dependent CSS, light/dark pairing, palette; each bracketed or leading part only when set) and one `Note <level>
503
+ <feature>: <message> [<nodes>]` line per distinct loss, unmapped first. In the library each
504
+ `diagnostics` entry is a node (`id`, `kind`, `confidence`) with its losses
505
+ `{ level: "approximated" | "unmapped", feature, message }`, where `feature` is a row id of the
506
+ conformance inventory (`tests/conformance/inventory/`), for example `font-family` or
507
+ `display: grid`.
508
+
509
+ ## Security
510
+
511
+ Capture runs the input in Chrome, including its scripts: convert trusted local inputs only.
512
+ For pasted markup, `convertHtml(html, { untrustedInput: true })` blocks page scripts, outbound
513
+ requests and DNS resolution in the capture browser and rejects file/URL sources, `assetsDir` and
514
+ `baseURL`; inline CSS and data-URL assets still work. Host that browser in an isolated environment
515
+ too. Outside this mode `@font-face` bytes are fetched over `http(s)` only, up to 25 MB per file.
516
+
517
+ ## Playground
518
+
519
+ ```sh
520
+ bun run --cwd playground dev # http://127.0.0.1:4317
521
+ ```
522
+
523
+ A local HTML/CSS → SwiftUI workbench; pasted markup uses the untrusted-input policy. Deployment of
524
+ the static frontend and the conversion service: `playground/README.md`.
525
+
526
+ ## Development
527
+
528
+ ```sh
529
+ bun install --frozen-lockfile
530
+ ./node_modules/.bin/playwright-core install --no-shell chromium # pinned Chromium for the visual gate
531
+ bun run lint && bun run typecheck && bun run test
532
+ bun run test:browser # Chrome integration and conformance tests
533
+ bun run test:native # Xcode: compile and macOS-render generated Swift
534
+ bun test playground/tests
535
+ bun run test:ios-behaviour # iOS simulator: accessibility audit, Dynamic Type, RTL, interaction
536
+ bun run pilot --renderer mac --out tests/conformance/out/<dir> # add --all for every fixture, --reflow for 320/768
537
+ bun run quality tests/conformance/baselines/visual-pilot-mac.json tests/conformance/out/<dir> tests/conformance/visual-policy-mac.json
538
+ MARKSWIFT_SIMULATOR='iPhone 17' bun run pilot --renderer ios --all --out tests/conformance/out/<dir> # iOS renderer
539
+ # iOS breakpoint evidence: `@bp` captures render on an iPad simulator (create it once with
540
+ # `xcrun simctl create "iPad Pro 13-inch (M5)" com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13-inch-M5-12GB com.apple.CoreSimulator.SimRuntime.iOS-27-0`)
541
+ MARKSWIFT_SIMULATOR='iPhone 17' MARKSWIFT_SIMULATOR_IPAD='iPad Pro 13-inch (M5)' bun run pilot --renderer ios --all --reflow --group breakpoints --out tests/conformance/out/<dir>
542
+ ```
543
+
544
+ Baselines change only through `bun run quality --write-baseline <run-dir> <manifest.json>`,
545
+ reviewed and committed by hand. GitHub Actions workflows:
546
+
547
+ - `ci.yml` (pull requests, pushes to `main`, nightly): lint, typecheck, `test`, `test:browser`, `test:native`, the
548
+ package build with the npm smoke test, and the conformance pilot (capture, both modes, iOS typecheck).
549
+ - `visual.yml`: the macOS gate on pull requests and pushes to `main` (pilot scope, with the 320/768 resize check)
550
+ and nightly (all fixtures); the iOS renderer over all fixtures on `v*` tags and manual dispatch only. Dispatch it
551
+ with `renderer`, `scope` and `rebaseline` to produce a candidate baseline.
552
+ - `behaviour.yml` (`v*` tags, weekly, dispatch): `bun run test:ios-behaviour`.
553
+ - `release.yml` (`v*` tags): after approval in the `npm` environment, checks that the tag equals
554
+ `v<package version>`, runs lint, typecheck, `test`, the build and the npm smoke test, and publishes with provenance.
555
+
556
+ | Path | Responsibility |
557
+ | --- | --- |
558
+ | `src/cli.ts`, `src/cli/` | Argument parsing, bundle writing, exit codes |
559
+ | `src/api/` | Public conversion options and resource lifecycle |
560
+ | `src/input/` | HTML sources, root selection and node identity |
561
+ | `src/html/` | Element registry (`HTML_ELEMENTS`, `classifyElement`) and input-type routing |
562
+ | `src/browser/` | Chrome capture, computed styles, images and fonts |
563
+ | `src/model/` | Serializable layout, semantic and font contracts |
564
+ | `src/css/` | Pure CSS value parsers |
565
+ | `src/analysis/` | Geometry, sizing, flow, text, controls and SVG inference |
566
+ | `src/compiler/` | Synchronous measured layout → Swift compilation |
567
+ | `src/swift/` | Swift layout, paint, text, controls, tokens and runtime generation |
568
+ | `src/fonts/` | Font metadata, packaging and Python conversion |
569
+ | `tests/` | Unit, architecture, integration and conformance (fixtures, inventory, baselines) suites |
570
+ | `tools/verify/`, `tools/quality.ts` | Swift typecheck, native rendering, image comparison and the visual gate |
571
+ | `playground/` | Local workbench and the separately deployable conversion service |
572
+
573
+ ## Roadmap after 2.0
574
+
575
+ - Looser extraction: optional children, variants, `ForEach` over grid cells.
576
+ - Break characters (zero-width space, soft hyphen); `TextRenderer` justify and decorations.
577
+ - Validation and `data-state` variants; iPad hover and keyboard.
578
+ - Overlays: swipe-to-dismiss and an opt-in native `.sheet` for bottom drawers; nested triggers (submenus, dialogs
579
+ opened from a dialog); per-band layers with `--breakpoints`; menu item icons and shortcut text.
580
+ - Motion: scroll-driven animations; mid-flight frame gates for state transitions; author `prefers-reduced-motion`
581
+ rules for overlay enter/exit.
582
+ - Height and orientation media queries; sticky pinning while scrolling.
583
+ - The full flex algorithm (wrap with grow, `min-width: auto` as min-content).
584
+ - `<select multiple>`, `<datalist>`.
585
+ - Linux capture; a compiled binary and Homebrew; no Python dependency for fonts; a capture cache; a shared browser
586
+ for single conversions; browser-response font bytes; static variable-font instances (smaller font bundles).
587
+ - Remote image download, @2x images and asset catalogs.
588
+ - Sharded iOS visual runs; body re-evaluation and scroll-hitch benchmarks.
589
+ - A Compose target over the target-independent IR.
590
+
591
+ ## License
592
+
593
+ MIT — see [LICENSE](LICENSE).