@swiftbrowser/native 0.11.0 → 0.12.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/README.md CHANGED
@@ -138,7 +138,9 @@ this: the iOS app's own views, in a page. `--base /path/` when the site is
138
138
  served under a path of another site, `--module <file>` for a module built
139
139
  from a checkout (`swift build --swift-sdk <wasm sdk> -c release --product
140
140
  NativeRoundTrip`), `--name <App>` for the site's app name (the folder
141
- holding `js/` by default).
141
+ holding `js/` by default), `--catalog <dir.xcassets>` (repeatable) for the
142
+ app's named images and colors (the Xcode target's catalog, or one kept for
143
+ the site: a `.symbolset` holding any SVG draws as a symbol there).
142
144
 
143
145
  ## The escape hatch
144
146
 
@@ -6,23 +6,25 @@
6
6
  // rebuild on save for `NativeApp.watch(url:)`; `swiftbrowser-native site
7
7
  // <entry> --out <dir>` writes the app as a static site the web renderer runs
8
8
  // (the native views built for WebAssembly, `--module <file>` for one other
9
- // than the package's; `--base /path/` when the site is not a host's root).
9
+ // than the package's; `--base /path/` when the site is not a host's root;
10
+ // `--catalog <dir.xcassets>`, repeatable, for the app's named images).
10
11
 
11
12
  import { basename, resolve } from 'node:path';
12
13
  import { buildApp, serve } from '../dist/build.js';
13
14
 
14
15
  const [command, entry, ...rest] = process.argv.slice(2);
15
- const flags = {};
16
+ const flags = { catalog: [] };
16
17
  for (let i = 0; i < rest.length; i++) {
17
18
  const arg = rest[i];
18
- if (arg === '--out' || arg === '--port' || arg === '--module' || arg === '--base' || arg === '--name') flags[arg.slice(2)] = rest[++i];
19
+ if (arg === '--catalog') flags.catalog.push(rest[++i]);
20
+ else if (arg === '--out' || arg === '--port' || arg === '--module' || arg === '--base' || arg === '--name') flags[arg.slice(2)] = rest[++i];
19
21
  else if (arg.startsWith('--')) flags[arg.slice(2)] = true;
20
22
  }
21
23
 
22
24
  if (!command || !entry || !flags.out || !['build', 'dev', 'site'].includes(command)) {
23
25
  console.error('usage: swiftbrowser-native build <entry.tsx> --out <App/app.js> [--minify] [--sourcemap]');
24
26
  console.error(' swiftbrowser-native dev <entry.tsx> --out <App/app.js> [--port 5173] [--sourcemap]');
25
- console.error(' swiftbrowser-native site <entry.tsx> --out <dir> [--name <App>] [--module <file.wasm>] [--base /path/] [--minify]');
27
+ console.error(' swiftbrowser-native site <entry.tsx> --out <dir> [--name <App>] [--module <file.wasm>] [--base /path/] [--catalog <dir.xcassets>]... [--minify]');
26
28
  process.exit(2);
27
29
  }
28
30
 
@@ -48,6 +50,7 @@ if (command === 'build') {
48
50
  ...(flags.name ? { name: flags.name } : {}),
49
51
  ...(flags.module ? { module: resolve(flags.module) } : {}),
50
52
  ...(flags.base ? { base: flags.base } : {}),
53
+ ...(flags.catalog.length ? { catalogs: flags.catalog.map((dir) => resolve(dir)) } : {}),
51
54
  });
52
55
  for (const warning of result.warnings) console.warn(warning);
53
56
  console.log(`${result.name}: app.js ${kb(result.bundleBytes)}, ${basename(result.module)} ${kb(result.moduleBytes)}`);
package/dist/site.d.ts CHANGED
@@ -28,6 +28,12 @@ export interface SiteOptions extends Omit<BuildOptions, 'outfile'> {
28
28
  module?: string;
29
29
  /** The path the site is served under when it is not a host's root (`/native/`); `/` by default. */
30
30
  base?: string;
31
+ /**
32
+ * Asset catalogs (`*.xcassets` directories) the app's named images and
33
+ * colors come from, served as the site's catalog; the Xcode target's, or
34
+ * one kept for the site (a `.symbolset` holding any SVG draws as a symbol).
35
+ */
36
+ catalogs?: string[];
31
37
  /** Vite's log level while building the site; `warn` by default. */
32
38
  logLevel?: 'info' | 'warn' | 'error' | 'silent';
33
39
  }
@@ -1 +1 @@
1
- {"version":3,"file":"site.d.ts","sourceRoot":"","sources":["../src/site.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAOH,OAAO,EAAU,KAAK,YAAY,EAAE,MAAM,YAAY,CAAC;AAEvD,2GAA2G;AAC3G,eAAO,MAAM,eAAe,QAA6E,CAAC;AAE1G,MAAM,WAAW,WAAY,SAAQ,IAAI,CAAC,YAAY,EAAE,SAAS,CAAC;IAChE,4DAA4D;IAC5D,MAAM,EAAE,MAAM,CAAC;IACf;;;;;OAKG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,8EAA8E;IAC9E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,0FAA0F;IAC1F,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,mGAAmG;IACnG,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,mEAAmE;IACnE,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,CAAC;CACjD;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAID,yGAAyG;AACzG,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAOjD;AAED,6DAA6D;AAC7D,wBAAsB,eAAe,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,UAAU,CAAC,CAqC/E"}
1
+ {"version":3,"file":"site.d.ts","sourceRoot":"","sources":["../src/site.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAOH,OAAO,EAAU,KAAK,YAAY,EAAE,MAAM,YAAY,CAAC;AAEvD,2GAA2G;AAC3G,eAAO,MAAM,eAAe,QAA6E,CAAC;AAE1G,MAAM,WAAW,WAAY,SAAQ,IAAI,CAAC,YAAY,EAAE,SAAS,CAAC;IAChE,4DAA4D;IAC5D,MAAM,EAAE,MAAM,CAAC;IACf;;;;;OAKG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,8EAA8E;IAC9E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,0FAA0F;IAC1F,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,mGAAmG;IACnG,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,mEAAmE;IACnE,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,CAAC;CACjD;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAID,yGAAyG;AACzG,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAOjD;AAED,6DAA6D;AAC7D,wBAAsB,eAAe,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,UAAU,CAAC,CAwC/E"}
package/dist/site.js CHANGED
@@ -40,7 +40,11 @@ export async function buildNativeSite(options) {
40
40
  }
41
41
  if (options.base !== undefined && !/^\/([^/].*\/)?$/.test(options.base))
42
42
  throw new Error(`site: --base must start and end with "/": ${options.base}`);
43
- const { outDir, name: _name, displayName, module: _module, base, logLevel, ...bundleOptions } = options;
43
+ const { outDir, name: _name, displayName, module: _module, base, logLevel, catalogs, ...bundleOptions } = options;
44
+ for (const catalog of catalogs ?? []) {
45
+ if (!existsSync(catalog))
46
+ throw new Error(`site: no asset catalog at ${catalog}`);
47
+ }
44
48
  // The bundle is the app's one resource: published at `/bundle/<name>/app.js`
45
49
  // and mounted at `/bundle/app.js`, where the module's launch reads it.
46
50
  const resources = mkdtempSync(path.join(tmpdir(), 'swiftbrowser-native-site-'));
@@ -53,7 +57,7 @@ export async function buildNativeSite(options) {
53
57
  displayName: displayName ?? name,
54
58
  description: 'A SwiftBrowser Native app in the browser',
55
59
  wasm: module,
56
- catalogs: [],
60
+ catalogs: (catalogs ?? []).map((catalog) => path.resolve(catalog)),
57
61
  resources: [path.join(resources, 'app.js')],
58
62
  }],
59
63
  layout: 'app',
package/dist/site.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"site.js","sourceRoot":"","sources":["../src/site.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"site.js","sourceRoot":"","sources":["../src/site.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/native",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "description": "Run a @swiftbrowser/react app natively: the runtime a JavaScriptCore-hosted app needs (timers, console, fetch and the other web globals over the SwiftBrowser Native bridge), the `run` entry that renders into the native SwiftUI renderer, and the bundler that builds app.js for the Xcode target.",
6
6
  "repository": {
@@ -59,8 +59,8 @@
59
59
  },
60
60
  "dependencies": {
61
61
  "@babel/core": "^7.29.7",
62
- "@swiftbrowser/react": "0.11.0",
63
- "@swiftbrowser/web": "0.11.0",
62
+ "@swiftbrowser/react": "0.12.0",
63
+ "@swiftbrowser/web": "0.12.0",
64
64
  "esbuild": "^0.28.2",
65
65
  "react-refresh": "^0.17.0"
66
66
  },
package/src/site.ts CHANGED
@@ -36,6 +36,12 @@ export interface SiteOptions extends Omit<BuildOptions, 'outfile'> {
36
36
  module?: string;
37
37
  /** The path the site is served under when it is not a host's root (`/native/`); `/` by default. */
38
38
  base?: string;
39
+ /**
40
+ * Asset catalogs (`*.xcassets` directories) the app's named images and
41
+ * colors come from, served as the site's catalog; the Xcode target's, or
42
+ * one kept for the site (a `.symbolset` holding any SVG draws as a symbol).
43
+ */
44
+ catalogs?: string[];
39
45
  /** Vite's log level while building the site; `warn` by default. */
40
46
  logLevel?: 'info' | 'warn' | 'error' | 'silent';
41
47
  }
@@ -74,7 +80,10 @@ export async function buildNativeSite(options: SiteOptions): Promise<SiteResult>
74
80
  );
75
81
  }
76
82
  if (options.base !== undefined && !/^\/([^/].*\/)?$/.test(options.base)) throw new Error(`site: --base must start and end with "/": ${options.base}`);
77
- const { outDir, name: _name, displayName, module: _module, base, logLevel, ...bundleOptions } = options;
83
+ const { outDir, name: _name, displayName, module: _module, base, logLevel, catalogs, ...bundleOptions } = options;
84
+ for (const catalog of catalogs ?? []) {
85
+ if (!existsSync(catalog)) throw new Error(`site: no asset catalog at ${catalog}`);
86
+ }
78
87
  // The bundle is the app's one resource: published at `/bundle/<name>/app.js`
79
88
  // and mounted at `/bundle/app.js`, where the module's launch reads it.
80
89
  const resources = mkdtempSync(path.join(tmpdir(), 'swiftbrowser-native-site-'));
@@ -87,7 +96,7 @@ export async function buildNativeSite(options: SiteOptions): Promise<SiteResult>
87
96
  displayName: displayName ?? name,
88
97
  description: 'A SwiftBrowser Native app in the browser',
89
98
  wasm: module,
90
- catalogs: [],
99
+ catalogs: (catalogs ?? []).map((catalog) => path.resolve(catalog)),
91
100
  resources: [path.join(resources, 'app.js')],
92
101
  }],
93
102
  layout: 'app',
@@ -26,7 +26,7 @@ struct ElementView: View {
26
26
  case "zstack": return ZStackElement(node: node).reportingSize(of: node)
27
27
  case "spacer": return AnyView(Spacer(minLength: SpacerProps(props: node.props).minLength.map { CGFloat($0) }))
28
28
  case "divider": return AnyView(Divider())
29
- case "styled": return node.isSpinner ? AnyView(ProgressView()) : ChildrenView(node: node).styled(node.style)
29
+ case "styled": return node.isSpinner ? AnyView(ProgressView()) : ChildrenView(node: node).styled(node.style, id: node.id)
30
30
  case "decorated": return AnyView(DecoratedElement(node: node))
31
31
  case "geometry": return AnyView(GeometryElement(node: node))
32
32
  case "scrollview": return AnyView(ScrollViewElement(node: node))
@@ -58,6 +58,13 @@ struct ElementView: View {
58
58
  // Navigation and presentation
59
59
  case "navstack": return AnyView(NavigationStackElement(node: node))
60
60
  case "navsplit": return AnyView(NavigationSplitElement(node: node))
61
+ case "inspector": return AnyView(InspectorElement(node: node))
62
+ case "inspectorpanel": return AnyView(ChildrenView(node: node))
63
+ // Charts
64
+ case "chart": return AnyView(ChartElement(node: node))
65
+ case "chartitem":
66
+ // Placed by the chart; in place it draws nothing.
67
+ return AnyView(EmptyView())
61
68
  case "navscreen": return AnyView(NavigationScreenElement(node: node))
62
69
  case "navlink": return AnyView(NavigationLinkElement(node: node))
63
70
  case "tabview": return AnyView(TabViewElement(node: node))
@@ -0,0 +1,340 @@
1
+ #if canImport(SwiftUI)
2
+ import Foundation
3
+ import SwiftBrowserProtocol
4
+ import SwiftBrowserTree
5
+ import SwiftUI
6
+
7
+ /// `chart`: Swift Charts as the protocol carries it, already resolved (the
8
+ /// marks in unit coordinates of the plot, the axes' grid lines, ticks and
9
+ /// formatted labels, the legend), drawn with paths and texts, following the
10
+ /// web renderer's layout rules (docs/ops-protocol.md, "Layout rules: chart"):
11
+ /// the legend row, the axis bands as wide as their labels, and the plot in
12
+ /// what remains. `chartitem` children are annotations against their mark's
13
+ /// rect, or axis label views at their tick.
14
+ struct ChartElement: View {
15
+ let node: ElementNode
16
+
17
+ var body: some View {
18
+ let props = ChartProps(props: node.props)
19
+ let items = node.children.filter { $0.kind == "chartitem" }
20
+ GeometryReader { geometry in
21
+ ChartCanvas(props: props, items: items, size: geometry.size)
22
+ }
23
+ }
24
+ }
25
+
26
+ /// The chart's boxes in points.
27
+ struct ChartLayout {
28
+ var plot: CGRect
29
+ var xBand: CGRect?
30
+ var yBand: CGRect?
31
+ var legend: CGRect?
32
+
33
+ static let axisFontSize: CGFloat = 12
34
+ /// A caption glyph's average width, for the y band's width (the labels are not measured).
35
+ static let glyphWidth: CGFloat = 6.6
36
+ static let labelGap: CGFloat = 6
37
+ static let legendHeight: CGFloat = 16
38
+ static let legendGap: CGFloat = 8
39
+
40
+ init(props: ChartProps, size: CGSize) {
41
+ var plot = CGRect(origin: .zero, size: size)
42
+ if let legend = props.legend, !legend.items.isEmpty {
43
+ switch legend.position {
44
+ case "top":
45
+ self.legend = CGRect(x: 0, y: 0, width: size.width, height: Self.legendHeight)
46
+ plot.origin.y += Self.legendHeight + Self.legendGap
47
+ plot.size.height -= Self.legendHeight + Self.legendGap
48
+ case "leading", "trailing":
49
+ let width = legend.items.map { CGFloat($0.label.count) * Self.glyphWidth + 14 }.max() ?? 0
50
+ if legend.position == "leading" {
51
+ self.legend = CGRect(x: 0, y: 0, width: width, height: size.height)
52
+ plot.origin.x += width + Self.legendGap
53
+ } else {
54
+ self.legend = CGRect(x: size.width - width, y: 0, width: width, height: size.height)
55
+ }
56
+ plot.size.width -= width + Self.legendGap
57
+ default:
58
+ self.legend = CGRect(x: 0, y: size.height - Self.legendHeight, width: size.width, height: Self.legendHeight)
59
+ plot.size.height -= Self.legendHeight + Self.legendGap
60
+ }
61
+ }
62
+ if let axis = props.yAxis, !axis.labels.isEmpty {
63
+ let width = (axis.labels.map { CGFloat($0.text.count) }.max() ?? 0) * Self.glyphWidth + Self.labelGap
64
+ if axis.position == "leading" {
65
+ yBand = CGRect(x: plot.minX, y: plot.minY, width: width, height: plot.height)
66
+ plot.origin.x += width
67
+ } else {
68
+ yBand = CGRect(x: plot.maxX - width, y: plot.minY, width: width, height: plot.height)
69
+ }
70
+ plot.size.width -= width
71
+ }
72
+ if let axis = props.xAxis, !axis.labels.isEmpty {
73
+ let height = Self.axisFontSize * 4 / 3 + Self.labelGap
74
+ if axis.position == "top" {
75
+ xBand = CGRect(x: plot.minX, y: plot.minY, width: plot.width, height: height)
76
+ plot.origin.y += height
77
+ } else {
78
+ xBand = CGRect(x: plot.minX, y: plot.maxY - height, width: plot.width, height: height)
79
+ }
80
+ plot.size.height -= height
81
+ }
82
+ self.plot = CGRect(x: plot.minX, y: plot.minY, width: max(plot.width, 0), height: max(plot.height, 0))
83
+ }
84
+
85
+ func point(x: Double, y: Double) -> CGPoint {
86
+ CGPoint(x: plot.minX + plot.width * x, y: plot.minY + plot.height * y)
87
+ }
88
+
89
+ func rect(x0: Double, x1: Double, y0: Double, y1: Double) -> CGRect {
90
+ let a = point(x: min(x0, x1), y: min(y0, y1)), b = point(x: max(x0, x1), y: max(y0, y1))
91
+ return CGRect(x: a.x, y: a.y, width: b.x - a.x, height: b.y - a.y)
92
+ }
93
+ }
94
+
95
+ struct ChartCanvas: View {
96
+ let props: ChartProps
97
+ let items: [ElementNode]
98
+ let size: CGSize
99
+
100
+ var body: some View {
101
+ let layout = ChartLayout(props: props, size: size)
102
+ ZStack(alignment: .topLeading) {
103
+ axisLines(props.xAxis, layout: layout, horizontal: true)
104
+ axisLines(props.yAxis, layout: layout, horizontal: false)
105
+ ForEach(Array(props.marks.enumerated()), id: \.offset) { _, mark in
106
+ ChartMarkView(mark: mark, layout: layout)
107
+ }
108
+ axisLabels(props.xAxis, layout: layout, horizontal: true)
109
+ axisLabels(props.yAxis, layout: layout, horizontal: false)
110
+ if let legend = props.legend, let box = layout.legend {
111
+ ChartLegendView(legend: legend)
112
+ .frame(width: box.width, height: box.height)
113
+ .position(x: box.midX, y: box.midY)
114
+ }
115
+ ForEach(items) { item in
116
+ ChartItemView(item: item, layout: layout)
117
+ }
118
+ }
119
+ .frame(width: size.width, height: size.height, alignment: .topLeading)
120
+ .clipped()
121
+ }
122
+
123
+ /// The grid lines across the plot and the ticks outside it.
124
+ @ViewBuilder
125
+ private func axisLines(_ axis: ChartAxisSpec?, layout: ChartLayout, horizontal: Bool) -> some View {
126
+ if let axis {
127
+ let plot = layout.plot
128
+ let grid = Path { path in
129
+ for line in axis.gridLines {
130
+ if horizontal {
131
+ let x = plot.minX + plot.width * line
132
+ path.move(to: CGPoint(x: x, y: plot.minY))
133
+ path.addLine(to: CGPoint(x: x, y: plot.maxY))
134
+ } else {
135
+ let y = plot.minY + plot.height * line
136
+ path.move(to: CGPoint(x: plot.minX, y: y))
137
+ path.addLine(to: CGPoint(x: plot.maxX, y: y))
138
+ }
139
+ }
140
+ }
141
+ let ticks = Path { path in
142
+ for tick in axis.ticks {
143
+ if horizontal {
144
+ let x = plot.minX + plot.width * tick
145
+ let y = axis.position == "top" ? plot.minY : plot.maxY
146
+ path.move(to: CGPoint(x: x, y: y))
147
+ path.addLine(to: CGPoint(x: x, y: axis.position == "top" ? y - 4 : y + 4))
148
+ } else {
149
+ let y = plot.minY + plot.height * tick
150
+ let x = axis.position == "leading" ? plot.minX : plot.maxX
151
+ path.move(to: CGPoint(x: x, y: y))
152
+ path.addLine(to: CGPoint(x: axis.position == "leading" ? x - 4 : x + 4, y: y))
153
+ }
154
+ }
155
+ }
156
+ let separator = Color.secondary.opacity(0.3)
157
+ grid.stroke(separator, style: StrokeStyle(lineWidth: 1, dash: axis.dashed ? [2, 2] : []))
158
+ ticks.stroke(separator, lineWidth: 1)
159
+ }
160
+ }
161
+
162
+ /// The default text labels, centered on their ticks in the band.
163
+ @ViewBuilder
164
+ private func axisLabels(_ axis: ChartAxisSpec?, layout: ChartLayout, horizontal: Bool) -> some View {
165
+ if let axis {
166
+ ForEach(Array(axis.labels.enumerated()), id: \.offset) { _, label in
167
+ let text = Text(verbatim: label.text)
168
+ .font(.system(size: ChartLayout.axisFontSize))
169
+ .foregroundStyle(.secondary)
170
+ .lineLimit(1)
171
+ .fixedSize()
172
+ if horizontal, let band = layout.xBand {
173
+ let x = layout.plot.minX + layout.plot.width * label.at
174
+ if label.anchor == "leading" {
175
+ text.frame(width: max(band.width, 1), alignment: .leading).position(x: x + band.width / 2, y: band.midY)
176
+ } else {
177
+ text.position(x: min(max(x, layout.plot.minX), layout.plot.maxX), y: band.midY)
178
+ }
179
+ } else if !horizontal, let band = layout.yBand {
180
+ let y = layout.plot.minY + layout.plot.height * label.at
181
+ text.frame(width: max(band.width - ChartLayout.labelGap, 1), alignment: axis.position == "leading" ? .trailing : .leading)
182
+ .position(x: band.midX + (axis.position == "leading" ? -ChartLayout.labelGap / 2 : ChartLayout.labelGap / 2), y: min(max(y, layout.plot.minY), layout.plot.maxY))
183
+ }
184
+ }
185
+ }
186
+ }
187
+ }
188
+
189
+ /// One mark, in the plot's points.
190
+ struct ChartMarkView: View {
191
+ let mark: ChartMarkSpec
192
+ let layout: ChartLayout
193
+
194
+ var body: some View {
195
+ let style = mark.style.map(AnyShapeStyle.init(spec:)) ?? AnyShapeStyle(Color.accentColor)
196
+ Group {
197
+ switch mark.kind {
198
+ case "rect":
199
+ let unit = layout.rect(x0: mark.x0, x1: mark.x1, y0: mark.y0, y1: mark.y1)
200
+ let width = mark.fixedWidth.map { CGFloat($0) } ?? unit.width
201
+ RoundedRectangle(cornerRadius: mark.cornerRadius, style: .continuous)
202
+ .fill(style)
203
+ .frame(width: max(width, 0), height: max(unit.height, 0))
204
+ .position(x: unit.midX, y: unit.midY)
205
+ case "line":
206
+ let points = mark.points.map { layout.point(x: $0.x, y: $0.y) }
207
+ Path { path in
208
+ if let first = points.first {
209
+ path.move(to: first)
210
+ path.addLines(Array(points.dropFirst()))
211
+ }
212
+ }
213
+ .stroke(style, style: StrokeStyle(lineWidth: mark.lineWidth, lineCap: .round, lineJoin: .round))
214
+ if let symbol = mark.symbol {
215
+ ForEach(Array(points.enumerated()), id: \.offset) { _, point in
216
+ ChartSymbolView(symbol: symbol, size: mark.symbolSize, style: style).position(point)
217
+ }
218
+ }
219
+ case "area":
220
+ let upper = mark.points.map { layout.point(x: $0.x, y: $0.y0) }
221
+ let lower = mark.points.reversed().map { layout.point(x: $0.x, y: $0.y1) }
222
+ Path { path in
223
+ if let first = upper.first {
224
+ path.move(to: first)
225
+ path.addLines(Array(upper.dropFirst()))
226
+ path.addLines(lower)
227
+ path.closeSubpath()
228
+ }
229
+ }
230
+ .fill(style)
231
+ case "point":
232
+ ChartSymbolView(symbol: mark.symbol ?? "circle", size: mark.symbolSize, style: style)
233
+ .position(layout.point(x: mark.x0, y: mark.y0))
234
+ case "rule":
235
+ Path { path in
236
+ path.move(to: layout.point(x: mark.x0, y: mark.y0))
237
+ path.addLine(to: layout.point(x: mark.x1, y: mark.y1))
238
+ }
239
+ .stroke(style, lineWidth: mark.lineWidth)
240
+ default:
241
+ EmptyView()
242
+ }
243
+ }
244
+ .opacity(mark.opacity)
245
+ }
246
+ }
247
+
248
+ /// A mark's symbol glyph: a circle, a square, or a diamond for the rest.
249
+ struct ChartSymbolView: View {
250
+ let symbol: String
251
+ let size: Double
252
+ let style: AnyShapeStyle
253
+
254
+ var body: some View {
255
+ switch symbol {
256
+ case "square":
257
+ Rectangle().fill(style).frame(width: size, height: size)
258
+ case "circle":
259
+ Circle().fill(style).frame(width: size, height: size)
260
+ default:
261
+ Rectangle().fill(style).frame(width: size * 0.8, height: size * 0.8).rotationEffect(.degrees(45))
262
+ }
263
+ }
264
+ }
265
+
266
+ /// The legend's items: a symbol (or a short line) and the label, flowing in a row.
267
+ struct ChartLegendView: View {
268
+ let legend: ChartLegendSpec
269
+
270
+ var body: some View {
271
+ let items = Array(legend.items.enumerated())
272
+ if legend.position == "leading" || legend.position == "trailing" {
273
+ VStack(alignment: .leading, spacing: 4) {
274
+ ForEach(items, id: \.offset) { _, item in row(item) }
275
+ }
276
+ } else {
277
+ HStack(spacing: 12) {
278
+ ForEach(items, id: \.offset) { _, item in row(item) }
279
+ }
280
+ }
281
+ }
282
+
283
+ private func row(_ item: ChartLegendItemSpec) -> some View {
284
+ let style = item.style.map(AnyShapeStyle.init(spec:)) ?? AnyShapeStyle(Color.accentColor)
285
+ return HStack(spacing: 4) {
286
+ if let symbol = item.symbol {
287
+ ChartSymbolView(symbol: symbol, size: 10, style: style)
288
+ } else {
289
+ Rectangle().fill(style).frame(width: 18, height: 3)
290
+ }
291
+ Text(verbatim: item.label).font(.system(size: ChartLayout.axisFontSize)).foregroundStyle(.secondary).lineLimit(1)
292
+ }
293
+ }
294
+ }
295
+
296
+ /// A `chartitem`: its one child against its mark's rect (an annotation) or
297
+ /// at its tick in the band (an axis label view). An annotation beside the
298
+ /// rect sits in a frame the rect's size, aligned to the edge it leaves from,
299
+ /// so it overflows outward by its own size plus the spacing.
300
+ struct ChartItemView: View {
301
+ let item: ElementNode
302
+ let layout: ChartLayout
303
+
304
+ var body: some View {
305
+ let props = ChartItemProps(props: item.props)
306
+ let content = ZStack { ChildrenView(node: item) }
307
+ switch props.role {
308
+ case "xLabel":
309
+ let band = layout.xBand ?? CGRect(x: layout.plot.minX, y: layout.plot.maxY, width: layout.plot.width, height: 0)
310
+ content.position(x: layout.plot.minX + layout.plot.width * props.x0, y: band.midY)
311
+ case "yLabel":
312
+ let band = layout.yBand ?? CGRect(x: layout.plot.maxX, y: layout.plot.minY, width: 0, height: layout.plot.height)
313
+ content.position(x: band.midX, y: layout.plot.minY + layout.plot.height * props.y0)
314
+ default:
315
+ let rect = layout.rect(x0: props.x0, x1: props.x1, y0: props.y0, y1: props.y1)
316
+ let spacing = CGFloat(props.spacing)
317
+ let across = Alignment(name: props.alignment)
318
+ Group {
319
+ switch props.position {
320
+ case "top":
321
+ VStack(spacing: spacing) { content; Color.clear.frame(width: rect.width, height: rect.height) }
322
+ .frame(width: rect.width, height: rect.height, alignment: Alignment(horizontal: across.horizontal, vertical: .bottom))
323
+ case "bottom":
324
+ VStack(spacing: spacing) { Color.clear.frame(width: rect.width, height: rect.height); content }
325
+ .frame(width: rect.width, height: rect.height, alignment: Alignment(horizontal: across.horizontal, vertical: .top))
326
+ case "leading":
327
+ HStack(spacing: spacing) { content; Color.clear.frame(width: rect.width, height: rect.height) }
328
+ .frame(width: rect.width, height: rect.height, alignment: Alignment(horizontal: .trailing, vertical: across.vertical))
329
+ case "trailing":
330
+ HStack(spacing: spacing) { Color.clear.frame(width: rect.width, height: rect.height); content }
331
+ .frame(width: rect.width, height: rect.height, alignment: Alignment(horizontal: .leading, vertical: across.vertical))
332
+ default:
333
+ content.frame(width: rect.width, height: rect.height, alignment: across)
334
+ }
335
+ }
336
+ .position(x: rect.midX, y: rect.midY)
337
+ }
338
+ }
339
+ }
340
+ #endif
@@ -0,0 +1,29 @@
1
+ #if canImport(SwiftUI)
2
+ import Foundation
3
+ import SwiftBrowserProtocol
4
+ import SwiftBrowserTree
5
+ import SwiftUI
6
+
7
+ /// `inspector`: `.inspector(isPresented:)` in a regular width. The children
8
+ /// are the modified view's elements and, last, an `inspectorpanel` holding
9
+ /// the inspector's content (its `inspectorColumnWidth` hint rides the
10
+ /// panel content's style). The app owns the binding: `presented` is what it
11
+ /// holds, and the protocol has no dismiss from this side (in a compact width
12
+ /// the app presents a sheet instead and the element never appears).
13
+ struct InspectorElement: View {
14
+ let node: ElementNode
15
+
16
+ var body: some View {
17
+ let props = InspectorProps(props: node.props)
18
+ let panel = node.children.last { $0.kind == "inspectorpanel" }
19
+ ZStack {
20
+ ForEach(node.children.filter { $0.kind != "inspectorpanel" }) { ElementView(node: $0) }
21
+ }
22
+ .inspector(isPresented: Binding(get: { props.presented }, set: { _ in })) {
23
+ if let panel {
24
+ ZStack { ChildrenView(node: panel) }
25
+ }
26
+ }
27
+ }
28
+ }
29
+ #endif
@@ -155,7 +155,14 @@ struct NavigationScreenElement: View {
155
155
  case "principal": return .principal
156
156
  case "secondary": return .secondaryAction
157
157
  case "bottom": return bottomPlacement
158
- default: return trailingPlacement // `trailing`, and `largeTitle` beside it
158
+ case "largeTitle":
159
+ // Beside the large title: the shim's own placement; SwiftUI has none, so the bar's trailing edge.
160
+ #if canImport(FoundationBridge)
161
+ return .largeTitle
162
+ #else
163
+ return trailingPlacement
164
+ #endif
165
+ default: return trailingPlacement
159
166
  }
160
167
  }
161
168
  }
@@ -11,6 +11,7 @@ struct VStackElement: View {
11
11
  VStack(alignment: HorizontalAlignment(name: props.alignment), spacing: props.spacing.map { CGFloat($0) }) {
12
12
  ChildrenView(node: node)
13
13
  }
14
+ .scrollTargetLayout(isEnabled: props.scrollTarget)
14
15
  }
15
16
  }
16
17
 
@@ -32,6 +33,7 @@ struct HStackElement: View {
32
33
  HStack(alignment: VerticalAlignment(name: props.alignment), spacing: props.spacing.map { CGFloat($0) }) {
33
34
  ChildrenView(node: node)
34
35
  }
36
+ .scrollTargetLayout(isEnabled: props.scrollTarget)
35
37
  }
36
38
  }
37
39
  }
@@ -106,21 +108,71 @@ struct GeometryElement: View {
106
108
  }
107
109
  }
108
110
 
111
+ /// `scrollview`: its axes and indicators; `refreshable` sends `refresh`;
112
+ /// `snap` is its scroll target behavior (the stack inside marked
113
+ /// `scrollTarget` is the layout whose children snap); `reportsPosition`
114
+ /// is `.scrollPosition(id:)`, which sends `scrollposition` with the marked
115
+ /// element at the anchor once the view settles. A `ScrollViewReader`'s
116
+ /// `proxy.scrollTo(x)` arrives as a `scrollTo` hint, with a new serial each
117
+ /// time, on the marked element it names (`scrollID`, `.id(x)`): the view
118
+ /// finds the latest in its content and scrolls to it.
109
119
  struct ScrollViewElement: View {
110
120
  let node: ElementNode
111
121
  @Environment(\.nativeApp) private var app
122
+ @State private var position: Int?
123
+ @State private var served: Double = -1
112
124
 
113
125
  var body: some View {
114
126
  let props = ScrollViewProps(props: node.props)
115
127
  let axes: Axis.Set = props.axes == "horizontal" ? .horizontal : props.axes == "both" ? [.horizontal, .vertical] : .vertical
116
- let scroll = ScrollView(axes, showsIndicators: props.showsIndicators) {
117
- ChildrenView(node: node)
128
+ let request = Self.scrollRequest(in: node)
129
+ ScrollViewReader { proxy in
130
+ let scroll = ScrollView(axes, showsIndicators: props.showsIndicators) {
131
+ ChildrenView(node: node)
132
+ }
133
+ let refreshed: AnyView = props.refreshable ? AnyView(scroll.refreshable { app?.dispatch(.refresh(node.id)) }) : AnyView(scroll)
134
+ let snapped: AnyView = props.snap == "paging" ? AnyView(refreshed.scrollTargetBehavior(.paging))
135
+ : props.snap == "viewAligned" ? AnyView(refreshed.scrollTargetBehavior(.viewAligned)) : refreshed
136
+ let positioned: AnyView = props.reportsPosition
137
+ ? AnyView(snapped.scrollPosition(id: Binding(get: { position }, set: { next in
138
+ guard next != position else { return }
139
+ position = next
140
+ if let next { app?.dispatch(.scrollPosition(next)) }
141
+ }), anchor: props.positionAnchor.map(UnitPoint.init(spec:))))
142
+ : snapped
143
+ positioned
144
+ .onAppear { perform(request, with: proxy) }
145
+ .onChange(of: request?.serial) { _, _ in perform(request, with: proxy) }
118
146
  }
119
- if props.refreshable {
120
- scroll.refreshable { app?.dispatch(.refresh(node.id)) }
121
- } else {
122
- scroll
147
+ }
148
+
149
+ private func perform(_ request: ScrollRequest?, with proxy: ScrollViewProxy) {
150
+ guard let request, request.serial > served else { return }
151
+ served = request.serial
152
+ position = request.id
153
+ proxy.scrollTo(request.id, anchor: request.anchor)
154
+ }
155
+
156
+ struct ScrollRequest: Equatable {
157
+ var serial: Double
158
+ var id: Int
159
+ var anchor: UnitPoint?
160
+ }
161
+
162
+ /// The newest `scrollTo` among the content's marked elements (not those of a scroll view nested inside).
163
+ static func scrollRequest(in node: ElementNode) -> ScrollRequest? {
164
+ var best: ScrollRequest?
165
+ func visit(_ element: ElementNode) {
166
+ for child in element.children {
167
+ if child.kind == "scrollview" || child.kind == "list" { continue }
168
+ if child.kind == "styled", let request = child.style.scrollTo, best == nil || request.serial > best!.serial {
169
+ best = ScrollRequest(serial: request.serial, id: child.id, anchor: request.anchor.map(UnitPoint.init(spec:)))
170
+ }
171
+ visit(child)
172
+ }
123
173
  }
174
+ visit(node)
175
+ return best
124
176
  }
125
177
  }
126
178
 
@@ -11,7 +11,7 @@ import SwiftUI
11
11
  /// Type-erased step by step: a chain of sixty optional modifiers would
12
12
  /// otherwise be one type the compiler has to name.
13
13
  extension View {
14
- func styled(_ style: Style) -> AnyView {
14
+ func styled(_ style: Style, id: Int? = nil) -> AnyView {
15
15
  var view = AnyView(self)
16
16
 
17
17
  // Text attributes, which flow down the environment. A font with only a
@@ -71,16 +71,31 @@ extension View {
71
71
  }
72
72
  }
73
73
  if let frame = style.frame {
74
- if frame.isFlexible {
74
+ // Per axis, a fixed length wins over a min/max, as the web renderer
75
+ // lays it out: `{ maxWidth: "infinity", height: 1 }` is
76
+ // `.frame(height: 1).frame(maxWidth: .infinity)`, a rule across
77
+ // the width.
78
+ let alignment = Alignment(name: frame.alignment)
79
+ if frame.width != nil || frame.height != nil {
80
+ view = AnyView(view.frame(width: frame.width.map { CGFloat($0) }, height: frame.height.map { CGFloat($0) }, alignment: alignment))
81
+ }
82
+ let flexibleWidth = frame.width == nil && (frame.minWidth != nil || frame.maxWidth != nil)
83
+ let flexibleHeight = frame.height == nil && (frame.minHeight != nil || frame.maxHeight != nil)
84
+ if flexibleWidth || flexibleHeight {
75
85
  view = AnyView(view.frame(
76
- minWidth: CGFloat(dimension: frame.minWidth), maxWidth: CGFloat(dimension: frame.maxWidth),
77
- minHeight: CGFloat(dimension: frame.minHeight), maxHeight: CGFloat(dimension: frame.maxHeight),
78
- alignment: Alignment(name: frame.alignment)
86
+ minWidth: flexibleWidth ? CGFloat(dimension: frame.minWidth) : nil, maxWidth: flexibleWidth ? CGFloat(dimension: frame.maxWidth) : nil,
87
+ minHeight: flexibleHeight ? CGFloat(dimension: frame.minHeight) : nil, maxHeight: flexibleHeight ? CGFloat(dimension: frame.maxHeight) : nil,
88
+ alignment: alignment
79
89
  ))
80
- } else {
81
- view = AnyView(view.frame(width: frame.width.map { CGFloat($0) }, height: frame.height.map { CGFloat($0) }, alignment: Alignment(name: frame.alignment)))
82
90
  }
83
91
  }
92
+ if let relative = style.containerRelativeFrame {
93
+ var axes: Axis.Set = []
94
+ if relative.horizontal { axes.insert(.horizontal) }
95
+ if relative.vertical { axes.insert(.vertical) }
96
+ let a = relative.a, b = relative.b
97
+ view = AnyView(view.containerRelativeFrame(axes, alignment: Alignment(name: relative.alignment)) { length, _ in a * length + b })
98
+ }
84
99
  if let ratio = style.aspectRatio {
85
100
  let mode: ContentMode = style.aspectRatioContentMode == "fill" ? .fill : .fit
86
101
  view = AnyView(view.aspectRatio(ratio.map { CGFloat($0) }, contentMode: mode))
@@ -127,6 +142,9 @@ extension View {
127
142
  if style.hitTesting == false { view = AnyView(view.allowsHitTesting(false)) }
128
143
  if style.hidden { view = AnyView(view.hidden()) }
129
144
  if let help = style.help { view = AnyView(view.help(help)) }
145
+ // A `ScrollViewReader`'s `.id(x)`: the element's own id names it to a `scrollTo` and a scroll position.
146
+ if style.scrollID, let id { view = AnyView(view.id(id)) }
147
+ if let width = style.raw.double("inspectorColumnWidth") { view = AnyView(view.inspectorColumnWidth(width)) }
130
148
 
131
149
  // Lists.
132
150
  if let badge = style.badge { view = AnyView(view.badge(Text(badge))) }
@@ -54,9 +54,13 @@ public struct StackProps: Equatable, Sendable {
54
54
  /// A `VStack`'s horizontal alignment, an `HStack`'s vertical one, a `ZStack`'s frame alignment.
55
55
  public var alignment: String
56
56
 
57
+ /// `.scrollTargetLayout()`: the stack's children are the snap points of the scroll view around it.
58
+ public var scrollTarget: Bool
59
+
57
60
  public init(props: JSONObject) {
58
61
  spacing = props.double("spacing")
59
62
  alignment = props.string("alignment") ?? "center"
63
+ scrollTarget = props.bool("scrollTarget") ?? false
60
64
  }
61
65
  }
62
66
 
@@ -111,13 +115,27 @@ public struct ScrollViewProps: Equatable, Sendable {
111
115
  public var axes: String
112
116
  public var showsIndicators: Bool
113
117
  public var refreshable: Bool
118
+ /// `.scrollTargetBehavior`: `viewAligned` or `paging`.
119
+ public var snap: String?
120
+ /// `.scrollPosition(id:)`: the view reports the marked element at its anchor as it settles.
121
+ public var reportsPosition: Bool
122
+ public var positionAnchor: UnitPointSpec?
114
123
  public init(props: JSONObject) {
115
124
  axes = props.string("axes") ?? "vertical"
116
125
  showsIndicators = props.bool("showsIndicators") ?? true
117
126
  refreshable = props.bool("refreshable") ?? false
127
+ snap = props.string("snap")
128
+ reportsPosition = props.bool("reportsPosition") ?? false
129
+ positionAnchor = UnitPointSpec(json: props.value("positionAnchor"))
118
130
  }
119
131
  }
120
132
 
133
+ /// `inspector`: the modified view's elements, then an `inspectorpanel`.
134
+ public struct InspectorProps: Equatable, Sendable {
135
+ public var presented: Bool
136
+ public init(props: JSONObject) { presented = props.bool("presented") ?? false }
137
+ }
138
+
121
139
  public struct ListProps: Equatable, Sendable {
122
140
  /// `insetGrouped` (the default), `grouped`, `plain`, `sidebar`.
123
141
  public var style: String
@@ -473,3 +491,155 @@ public struct SizeReportProps: Equatable, Sendable {
473
491
  reader = props.int("reader")
474
492
  }
475
493
  }
494
+
495
+ // MARK: - Charts
496
+
497
+ /// `chart`: the marks in unit coordinates of the plot (x 0…1 left to right,
498
+ /// y 0…1 top to bottom), the axes with their grid lines, ticks and labels
499
+ /// already placed and formatted, and the legend (docs/ops-protocol.md,
500
+ /// "Phase 7"). A renderer measures the labels, lays the plot out and draws.
501
+ public struct ChartProps: Equatable, Sendable {
502
+ public var marks: [ChartMarkSpec]
503
+ public var xAxis: ChartAxisSpec?
504
+ public var yAxis: ChartAxisSpec?
505
+ public var legend: ChartLegendSpec?
506
+ public init(props: JSONObject) {
507
+ marks = (props.array("marks") ?? []).compactMap(ChartMarkSpec.init(json:))
508
+ xAxis = ChartAxisSpec(json: props.value("xAxis"))
509
+ yAxis = ChartAxisSpec(json: props.value("yAxis"))
510
+ legend = ChartLegendSpec(json: props.value("legend"))
511
+ }
512
+ }
513
+
514
+ /// A point of a line (`y`) or an area (`y0 ≤ y1`) mark.
515
+ public struct ChartPointSpec: Equatable, Sendable {
516
+ public var x: Double
517
+ public var y: Double
518
+ public var y0: Double
519
+ public var y1: Double
520
+ init?(json: JSONValue) {
521
+ guard let x = json["x"].double else { return nil }
522
+ self.x = x
523
+ y = json["y"].double ?? json["y0"].double ?? 0
524
+ y0 = json["y0"].double ?? y
525
+ y1 = json["y1"].double ?? y
526
+ }
527
+ }
528
+
529
+ /// One mark: `rect` (bars), `line`, `area`, `point` or `rule`.
530
+ public struct ChartMarkSpec: Equatable, Sendable {
531
+ public var kind: String
532
+ public var style: ShapeStyleSpec?
533
+ public var opacity: Double
534
+ /// `rect` and `rule`: the unit rect (a rule's two ends); `point`: `x0`/`y0`.
535
+ public var x0: Double
536
+ public var x1: Double
537
+ public var y0: Double
538
+ public var y1: Double
539
+ public var cornerRadius: Double
540
+ /// A rect this wide in points, centered on the unit rect, instead of the unit rect's width.
541
+ public var fixedWidth: Double?
542
+ /// `line` and `area`.
543
+ public var points: [ChartPointSpec]
544
+ public var lineWidth: Double
545
+ public var interpolation: String
546
+ /// `circle`, `square`, `triangle`, `diamond`, `cross`, `plus`, `asterisk`; nil for none.
547
+ public var symbol: String?
548
+ public var symbolSize: Double
549
+ public init?(json: JSONValue) {
550
+ guard let kind = json["kind"].string else { return nil }
551
+ self.kind = kind
552
+ style = ShapeStyleSpec(json: json["style"])
553
+ opacity = json["opacity"].double ?? 1
554
+ x0 = json["x0"].double ?? json["x"].double ?? 0
555
+ x1 = json["x1"].double ?? x0
556
+ y0 = json["y0"].double ?? json["y"].double ?? 0
557
+ y1 = json["y1"].double ?? y0
558
+ cornerRadius = json["cornerRadius"].double ?? 0
559
+ fixedWidth = json["fixedWidth"].double
560
+ points = (json["points"].array ?? []).compactMap(ChartPointSpec.init(json:))
561
+ lineWidth = json["lineWidth"].double ?? (kind == "rule" ? 1 : 2)
562
+ interpolation = json["interpolation"].string ?? "linear"
563
+ symbol = json["symbol"].string ?? (kind == "point" ? "circle" : nil)
564
+ symbolSize = json["symbolSize"].double ?? 8
565
+ }
566
+ }
567
+
568
+ public struct ChartAxisLabelSpec: Equatable, Sendable {
569
+ /// Unit position along the axis (y: 0 is the top).
570
+ public var at: Double
571
+ public var text: String
572
+ /// `leading`: an x label that starts at its tick instead of centering on it.
573
+ public var anchor: String?
574
+ init?(json: JSONValue) {
575
+ guard let at = json["at"].double else { return nil }
576
+ self.at = at
577
+ text = json["text"].string ?? ""
578
+ anchor = json["anchor"].string
579
+ }
580
+ }
581
+
582
+ public struct ChartAxisSpec: Equatable, Sendable {
583
+ /// x: `bottom` or `top`; y: `trailing` or `leading`.
584
+ public var position: String
585
+ public var gridLines: [Double]
586
+ public var dashed: Bool
587
+ public var ticks: [Double]
588
+ public var labels: [ChartAxisLabelSpec]
589
+ public init?(json: JSONValue) {
590
+ guard let fields = json.object else { return nil }
591
+ position = fields.string("position") ?? "bottom"
592
+ gridLines = (fields.array("gridLines") ?? []).compactMap(\.double)
593
+ dashed = fields.bool("dashed") ?? false
594
+ ticks = (fields.array("ticks") ?? []).compactMap(\.double)
595
+ labels = (fields.array("labels") ?? []).compactMap(ChartAxisLabelSpec.init(json:))
596
+ }
597
+ }
598
+
599
+ public struct ChartLegendItemSpec: Equatable, Sendable {
600
+ public var label: String
601
+ public var style: ShapeStyleSpec?
602
+ /// Nil: a short line instead of a symbol.
603
+ public var symbol: String?
604
+ init(json: JSONValue) {
605
+ label = json["label"].string ?? ""
606
+ style = ShapeStyleSpec(json: json["style"])
607
+ symbol = json["symbol"].string
608
+ }
609
+ }
610
+
611
+ public struct ChartLegendSpec: Equatable, Sendable {
612
+ /// `top`, `bottom`, `leading` or `trailing`.
613
+ public var position: String
614
+ public var items: [ChartLegendItemSpec]
615
+ public init?(json: JSONValue) {
616
+ guard let fields = json.object else { return nil }
617
+ position = fields.string("position") ?? "bottom"
618
+ items = (fields.array("items") ?? []).map(ChartLegendItemSpec.init(json:))
619
+ }
620
+ }
621
+
622
+ /// `chartitem`: an annotation against its mark's unit rect, or an axis label
623
+ /// view at its tick (`x0 == x1` for an x label, `y0 == y1` for a y label).
624
+ public struct ChartItemProps: Equatable, Sendable {
625
+ /// `annotation`, `xLabel` or `yLabel`.
626
+ public var role: String
627
+ public var x0: Double
628
+ public var x1: Double
629
+ public var y0: Double
630
+ public var y1: Double
631
+ /// `top`, `bottom`, `leading`, `trailing` or `overlay`.
632
+ public var position: String
633
+ public var alignment: String
634
+ public var spacing: Double
635
+ public init(props: JSONObject) {
636
+ role = props.string("role") ?? "annotation"
637
+ x0 = props.double("x0") ?? 0
638
+ x1 = props.double("x1") ?? x0
639
+ y0 = props.double("y0") ?? 0
640
+ y1 = props.double("y1") ?? y0
641
+ position = props.string("position") ?? "overlay"
642
+ alignment = props.string("alignment") ?? "center"
643
+ spacing = props.double("spacing") ?? 0
644
+ }
645
+ }
@@ -88,6 +88,8 @@ public struct RendererEvent: Equatable, Sendable {
88
88
  public static func longPress(_ id: Int) -> RendererEvent { with("longpress", id: id) }
89
89
  /// A `list` or `scrollview` with `refreshable` was pulled.
90
90
  public static func refresh(_ id: Int) -> RendererEvent { with("refresh", id: id) }
91
+ /// `.scrollPosition(id:)`: the marked element at the scroll view's anchor once it settled.
92
+ public static func scrollPosition(_ id: Int) -> RendererEvent { with("scrollposition", id: id) }
91
93
  /// A `menu` opened or closed (lazy menus build their items on `open`).
92
94
  public static func menu(_ id: Int, open: Bool) -> RendererEvent { with("menu", id: id, ["open": .bool(open)]) }
93
95
  /// The host's answer to a `native` element's own event (docs/native.md, the escape hatch).
@@ -67,6 +67,12 @@ public struct Style: Equatable, Sendable {
67
67
  public var help: String?
68
68
  public var matchedGeometry: String?
69
69
  public var contentTransition: String?
70
+ /// `.containerRelativeFrame`: the length along each named axis is `a × container + b`.
71
+ public var containerRelativeFrame: ContainerRelativeFrameSpec?
72
+ /// `.id(x)` inside a `ScrollViewReader`: the element a `scrollTo` or a scroll position names.
73
+ public var scrollID: Bool
74
+ /// `proxy.scrollTo(x)`: the request (its serial is new each time) on the element it names.
75
+ public var scrollTo: ScrollToSpec?
70
76
 
71
77
  public init(json: JSONValue) {
72
78
  let fields = json.object ?? [:]
@@ -137,6 +143,9 @@ public struct Style: Equatable, Sendable {
137
143
  help = fields.string("help")
138
144
  matchedGeometry = fields.string("matchedGeometry")
139
145
  contentTransition = fields.object("contentTransition")?.string("kind")
146
+ containerRelativeFrame = ContainerRelativeFrameSpec(json: fields.value("containerRelativeFrame"))
147
+ scrollID = fields.bool("scrollID") ?? false
148
+ scrollTo = ScrollToSpec(json: fields.value("scrollTo"))
140
149
  }
141
150
 
142
151
  public init() {
@@ -146,3 +155,35 @@ public struct Style: Equatable, Sendable {
146
155
  /// The style's field names, for the unsupported-field diagnostics and tests.
147
156
  public var fieldNames: [String] { raw.keys.sorted() }
148
157
  }
158
+
159
+ /// `.containerRelativeFrame(axes)` as the style hint carries it: `length = a × container + b`.
160
+ public struct ContainerRelativeFrameSpec: Equatable, Sendable {
161
+ public var horizontal: Bool
162
+ public var vertical: Bool
163
+ public var a: Double
164
+ public var b: Double
165
+ public var alignment: String
166
+ public init?(json: JSONValue) {
167
+ guard let fields = json.object else { return nil }
168
+ horizontal = fields.bool("horizontal") ?? false
169
+ vertical = fields.bool("vertical") ?? false
170
+ a = fields.double("a") ?? 1
171
+ b = fields.double("b") ?? 0
172
+ alignment = fields.string("alignment") ?? "center"
173
+ }
174
+ }
175
+
176
+ /// A `ScrollViewReader` proxy's `scrollTo` on the element it names.
177
+ public struct ScrollToSpec: Equatable, Sendable {
178
+ /// Increases with every request, across all readers.
179
+ public var serial: Double
180
+ public var anchor: UnitPointSpec?
181
+ /// `horizontal`, `vertical` or `both` (the default).
182
+ public var axes: String
183
+ public init?(json: JSONValue) {
184
+ guard let fields = json.object, let serial = fields.double("serial") else { return nil }
185
+ self.serial = serial
186
+ anchor = UnitPointSpec(json: fields.value("anchor"))
187
+ axes = fields.string("axes") ?? "both"
188
+ }
189
+ }
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: package/wasm/SwiftBrowserNative.wasm
3
- size: 49193851 bytes
4
- sha256: 314a751e10bb902322011666194057cb533dfcdc7f31df7d4c6a7e55f1b8181d
3
+ size: 49299172 bytes
4
+ sha256: 4b0ae4bc428d21234784a4c806f021482aa294045f94c5fc80eeea0ad40b5405