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.
- package/CHANGELOG.md +234 -0
- package/LICENSE +21 -0
- package/README.md +592 -2
- package/dist/cli-jdrbzqfa.js +2975 -0
- package/dist/cli-qchh0m4d.js +14041 -0
- package/dist/cli.js +637 -0
- package/dist/compiler/index.js +20 -0
- package/dist/index.js +22 -0
- package/dist/types/analysis/alignment.d.ts +12 -0
- package/dist/types/analysis/bands.d.ts +53 -0
- package/dist/types/analysis/breakpoints.d.ts +22 -0
- package/dist/types/analysis/class-hints.d.ts +21 -0
- package/dist/types/analysis/context.d.ts +49 -0
- package/dist/types/analysis/controls.d.ts +97 -0
- package/dist/types/analysis/flex-row.d.ts +14 -0
- package/dist/types/analysis/flex-sizing.d.ts +5 -0
- package/dist/types/analysis/flow-layout.d.ts +18 -0
- package/dist/types/analysis/generated.d.ts +16 -0
- package/dist/types/analysis/geometry.d.ts +50 -0
- package/dist/types/analysis/grid-tracks.d.ts +44 -0
- package/dist/types/analysis/grid.d.ts +5 -0
- package/dist/types/analysis/infer.d.ts +18 -0
- package/dist/types/analysis/invisible.d.ts +6 -0
- package/dist/types/analysis/layers.d.ts +15 -0
- package/dist/types/analysis/measured-layout.d.ts +6 -0
- package/dist/types/analysis/motion.d.ts +29 -0
- package/dist/types/analysis/naming.d.ts +6 -0
- package/dist/types/analysis/node-base.d.ts +13 -0
- package/dist/types/analysis/size-constraints.d.ts +5 -0
- package/dist/types/analysis/sizing.d.ts +24 -0
- package/dist/types/analysis/style-background.d.ts +24 -0
- package/dist/types/analysis/style-border.d.ts +7 -0
- package/dist/types/analysis/style-effects.d.ts +4 -0
- package/dist/types/analysis/style.d.ts +9 -0
- package/dist/types/analysis/svg/attributes.d.ts +10 -0
- package/dist/types/analysis/svg/build.d.ts +6 -0
- package/dist/types/analysis/svg/context.d.ts +31 -0
- package/dist/types/analysis/svg/geometry.d.ts +11 -0
- package/dist/types/analysis/svg/gradients.d.ts +6 -0
- package/dist/types/analysis/svg/markup.d.ts +23 -0
- package/dist/types/analysis/svg/text.d.ts +7 -0
- package/dist/types/analysis/svg/transforms.d.ts +19 -0
- package/dist/types/analysis/svg/traversal.d.ts +4 -0
- package/dist/types/analysis/table-borders.d.ts +19 -0
- package/dist/types/analysis/table.d.ts +36 -0
- package/dist/types/analysis/text-layout.d.ts +65 -0
- package/dist/types/analysis/text-runs.d.ts +30 -0
- package/dist/types/analysis/text.d.ts +9 -0
- package/dist/types/analysis/tooltips.d.ts +16 -0
- package/dist/types/analysis/typography.d.ts +21 -0
- package/dist/types/analysis/wrapping.d.ts +5 -0
- package/dist/types/api/bands.d.ts +58 -0
- package/dist/types/api/build.d.ts +51 -0
- package/dist/types/api/convert.d.ts +13 -0
- package/dist/types/api/types.d.ts +69 -0
- package/dist/types/browser/capture.d.ts +89 -0
- package/dist/types/browser/conditions.d.ts +25 -0
- package/dist/types/browser/fonts.d.ts +34 -0
- package/dist/types/browser/images.d.ts +3 -0
- package/dist/types/browser/layers.d.ts +132 -0
- package/dist/types/browser/motion.d.ts +44 -0
- package/dist/types/browser/settle.d.ts +27 -0
- package/dist/types/browser/sizing.d.ts +10 -0
- package/dist/types/browser/snapshot.d.ts +155 -0
- package/dist/types/browser/states.d.ts +36 -0
- package/dist/types/cli/args.d.ts +31 -0
- package/dist/types/cli/config.d.ts +78 -0
- package/dist/types/cli/css.d.ts +9 -0
- package/dist/types/cli/manifest.d.ts +20 -0
- package/dist/types/cli/output.d.ts +27 -0
- package/dist/types/cli/run.d.ts +4 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/compiler/index.d.ts +91 -0
- package/dist/types/compiler/naming.d.ts +1 -0
- package/dist/types/css/background.d.ts +16 -0
- package/dist/types/css/blend.d.ts +9 -0
- package/dist/types/css/color.d.ts +14 -0
- package/dist/types/css/counter.d.ts +10 -0
- package/dist/types/css/easing.d.ts +4 -0
- package/dist/types/css/effects.d.ts +18 -0
- package/dist/types/css/grid.d.ts +29 -0
- package/dist/types/css/index.d.ts +15 -0
- package/dist/types/css/media.d.ts +16 -0
- package/dist/types/css/paint.d.ts +33 -0
- package/dist/types/css/radius.d.ts +5 -0
- package/dist/types/css/raster.d.ts +10 -0
- package/dist/types/css/shadows.d.ts +3 -0
- package/dist/types/css/syntax.d.ts +12 -0
- package/dist/types/css/transforms.d.ts +45 -0
- package/dist/types/css/typography.d.ts +8 -0
- package/dist/types/fonts/converter.d.ts +16 -0
- package/dist/types/fonts/metadata.d.ts +6 -0
- package/dist/types/fonts/package.d.ts +12 -0
- package/dist/types/fonts/registration.d.ts +13 -0
- package/dist/types/fonts/source.d.ts +14 -0
- package/dist/types/fonts/types.d.ts +28 -0
- package/dist/types/html/elements.d.ts +8 -0
- package/dist/types/html/index.d.ts +4 -0
- package/dist/types/html/inputs.d.ts +4 -0
- package/dist/types/html/policy.d.ts +3 -0
- package/dist/types/html/types.d.ts +12 -0
- package/dist/types/index.d.ts +8 -0
- package/dist/types/input/identity.d.ts +3 -0
- package/dist/types/input/mime.d.ts +1 -0
- package/dist/types/input/root.d.ts +1 -0
- package/dist/types/input/server.d.ts +11 -0
- package/dist/types/input/source.d.ts +9 -0
- package/dist/types/input/types.d.ts +54 -0
- package/dist/types/model/fonts.d.ts +26 -0
- package/dist/types/model/interactive.d.ts +10 -0
- package/dist/types/model/layout.d.ts +528 -0
- package/dist/types/model/semantic.d.ts +984 -0
- package/dist/types/model/values.d.ts +125 -0
- package/dist/types/model/version.d.ts +4 -0
- package/dist/types/swift/borders.d.ts +13 -0
- package/dist/types/swift/components.d.ts +115 -0
- package/dist/types/swift/context.d.ts +184 -0
- package/dist/types/swift/control-state.d.ts +19 -0
- package/dist/types/swift/controls.d.ts +62 -0
- package/dist/types/swift/diagnostics.d.ts +3 -0
- package/dist/types/swift/effects.d.ts +23 -0
- package/dist/types/swift/emit.d.ts +6 -0
- package/dist/types/swift/format.d.ts +91 -0
- package/dist/types/swift/frames.d.ts +17 -0
- package/dist/types/swift/images.d.ts +25 -0
- package/dist/types/swift/layout.d.ts +5 -0
- package/dist/types/swift/measured-layout.d.ts +4 -0
- package/dist/types/swift/modifiers.d.ts +30 -0
- package/dist/types/swift/motion.d.ts +20 -0
- package/dist/types/swift/nodes.d.ts +4 -0
- package/dist/types/swift/paint.d.ts +22 -0
- package/dist/types/swift/runtime.d.ts +6 -0
- package/dist/types/swift/shadows.d.ts +7 -0
- package/dist/types/swift/states.d.ts +3 -0
- package/dist/types/swift/svg.d.ts +14 -0
- package/dist/types/swift/text-box.d.ts +12 -0
- package/dist/types/swift/text.d.ts +3 -0
- package/dist/types/swift/tokens/emit.d.ts +26 -0
- package/dist/types/swift/tokens/extract.d.ts +21 -0
- package/dist/types/swift/tokens/naming.d.ts +22 -0
- package/dist/types/swift/tokens/slots.d.ts +18 -0
- package/dist/types/swift/tokens/types.d.ts +77 -0
- package/dist/types/swift/tokens.d.ts +6 -0
- package/dist/types/swift/traversal.d.ts +2 -0
- package/dist/types/swift/wrapping.d.ts +4 -0
- package/package.json +66 -4
package/README.md
CHANGED
|
@@ -1,3 +1,593 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Markswift
|
|
2
2
|
|
|
3
|
-
|
|
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).
|