@astryxdesign/native 0.7.5-canary.0c00688 → 0.7.5-canary.10ca8ea

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +16 -12
  2. package/apple/Package.swift +21 -4
  3. package/apple/README.md +188 -0
  4. package/apple/Sources/Astryx/Banner/AstryxBanner.swift +80 -0
  5. package/apple/Sources/Astryx/Button/AstryxButton.swift +167 -0
  6. package/apple/Sources/Astryx/Card/AstryxCard.swift +33 -0
  7. package/apple/Sources/Astryx/Heading/AstryxHeading.swift +46 -0
  8. package/apple/Sources/Astryx/Localization/AstryxStrings.swift +22 -0
  9. package/apple/Sources/Astryx/Resources/af.lproj/Localizable.strings +4 -0
  10. package/apple/Sources/Astryx/Resources/ar.lproj/Localizable.strings +4 -0
  11. package/apple/Sources/Astryx/Resources/ca.lproj/Localizable.strings +4 -0
  12. package/apple/Sources/Astryx/Resources/cs.lproj/Localizable.strings +4 -0
  13. package/apple/Sources/Astryx/Resources/da.lproj/Localizable.strings +4 -0
  14. package/apple/Sources/Astryx/Resources/de.lproj/Localizable.strings +4 -0
  15. package/apple/Sources/Astryx/Resources/el.lproj/Localizable.strings +4 -0
  16. package/apple/Sources/Astryx/Resources/en.lproj/Localizable.strings +4 -0
  17. package/apple/Sources/Astryx/Resources/es.lproj/Localizable.strings +4 -0
  18. package/apple/Sources/Astryx/Resources/fi.lproj/Localizable.strings +4 -0
  19. package/apple/Sources/Astryx/Resources/fr.lproj/Localizable.strings +4 -0
  20. package/apple/Sources/Astryx/Resources/he.lproj/Localizable.strings +4 -0
  21. package/apple/Sources/Astryx/Resources/hu.lproj/Localizable.strings +4 -0
  22. package/apple/Sources/Astryx/Resources/it.lproj/Localizable.strings +4 -0
  23. package/apple/Sources/Astryx/Resources/ja.lproj/Localizable.strings +4 -0
  24. package/apple/Sources/Astryx/Resources/ko.lproj/Localizable.strings +4 -0
  25. package/apple/Sources/Astryx/Resources/nb.lproj/Localizable.strings +4 -0
  26. package/apple/Sources/Astryx/Resources/nl.lproj/Localizable.strings +4 -0
  27. package/apple/Sources/Astryx/Resources/pl.lproj/Localizable.strings +4 -0
  28. package/apple/Sources/Astryx/Resources/pt-BR.lproj/Localizable.strings +4 -0
  29. package/apple/Sources/Astryx/Resources/pt-PT.lproj/Localizable.strings +4 -0
  30. package/apple/Sources/Astryx/Resources/ro.lproj/Localizable.strings +4 -0
  31. package/apple/Sources/Astryx/Resources/ru.lproj/Localizable.strings +4 -0
  32. package/apple/Sources/Astryx/Resources/sr.lproj/Localizable.strings +4 -0
  33. package/apple/Sources/Astryx/Resources/sv.lproj/Localizable.strings +4 -0
  34. package/apple/Sources/Astryx/Resources/tr.lproj/Localizable.strings +4 -0
  35. package/apple/Sources/Astryx/Resources/uk.lproj/Localizable.strings +4 -0
  36. package/apple/Sources/Astryx/Resources/vi.lproj/Localizable.strings +4 -0
  37. package/apple/Sources/Astryx/Resources/zh-Hans.lproj/Localizable.strings +4 -0
  38. package/apple/Sources/Astryx/Resources/zh-Hant.lproj/Localizable.strings +4 -0
  39. package/apple/Sources/Astryx/Text/AstryxText.swift +83 -0
  40. package/apple/Sources/Astryx/TextInput/AstryxTextInput.swift +81 -0
  41. package/apple/Sources/Astryx/Theme/AstryxIcons.swift +37 -0
  42. package/apple/Sources/Astryx/Theme/Generated/ButterTheme.swift +493 -0
  43. package/apple/Sources/Astryx/Theme/Generated/ChocolateTheme.swift +493 -0
  44. package/apple/Sources/Astryx/Theme/Generated/DefaultTheme.swift +493 -0
  45. package/apple/Sources/Astryx/Theme/Generated/GothicTheme.swift +493 -0
  46. package/apple/Sources/Astryx/Theme/Generated/MatchaTheme.swift +493 -0
  47. package/apple/Sources/Astryx/Theme/Generated/NeutralTheme.swift +493 -0
  48. package/apple/Sources/Astryx/Theme/Generated/StoneTheme.swift +493 -0
  49. package/apple/Sources/Astryx/Theme/Generated/Tokens.swift +1260 -0
  50. package/apple/Sources/Astryx/Theme/Generated/Y2kTheme.swift +493 -0
  51. package/apple/Sources/Astryx/Theme/Theme.swift +270 -0
  52. package/apple/Tests/AstryxTests/BannerTests.swift +47 -0
  53. package/apple/Tests/AstryxTests/ButtonTests.swift +72 -0
  54. package/apple/Tests/AstryxTests/CardTests.swift +19 -0
  55. package/apple/Tests/AstryxTests/EnvironmentTests.swift +87 -0
  56. package/apple/Tests/AstryxTests/HeadingTests.swift +40 -0
  57. package/apple/Tests/AstryxTests/Pixels.swift +68 -0
  58. package/apple/Tests/AstryxTests/StringsTests.swift +23 -0
  59. package/apple/Tests/AstryxTests/TextInputTests.swift +29 -0
  60. package/apple/Tests/AstryxTests/TextTests.swift +25 -0
  61. package/apple/Tests/AstryxTests/ThemeTests.swift +45 -0
  62. package/apple/Tests/AstryxTests/TwoThemeTests.swift +65 -0
  63. package/package.json +5 -3
  64. package/src/icons/swiftIcons.test.ts +22 -0
  65. package/src/theme/swiftIOSStrings.test.ts +49 -0
  66. package/src/theme/swiftIOSTheme.test.ts +223 -0
package/README.md CHANGED
@@ -12,20 +12,23 @@ rests on evidence.
12
12
 
13
13
  ## What is here today
14
14
 
15
- | Part | Path | What it does |
16
- | -------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
17
- | Contracts | `src/contract/` | Reads a core component's documentation and splits its props into a portable **core contract** and a **web-only** part, with a reason for each web-only prop. Checks prop names against Swift and Kotlin keywords and platform toolkit names. |
18
- | Platform types | `@astryxdesign/native/components` | TypeScript props per platform, chosen by type-resolution condition: `ios`, `android`, `macos`, `windows`, or `shared` for the core contract only. |
19
- | Swift binding | `apple/` | A Swift package with props generated from the same contracts. iOS and macOS props sit behind `#if os(...)`. |
20
- | Native theme | `defineNativeTheme` | Durable theme intent (`material: 'opaque' \| 'translucent'`) plus platform entries scoped by OS version, such as Liquid Glass from iOS 26. Never sets tokens. |
21
- | Icons | `NATIVE_ICON_DEFAULTS` | Astryx's built-in icon names mapped to SF Symbols on Apple platforms and Material Symbols on Android. |
22
- | Wire | `AstryxNode`, `AstryxAction` | Each component as JSON a TypeScript core sends a native host (arm C), and the action a host sends back when a view calls a callback. Swift decodes `<Name>Node` and turns it into `<Name>Props` with `viewProps(send:)`. |
23
- | Logic core | `@astryxdesign/native/logic` | `createLogicCore` holds a screen's state, builds its node tree, and reduces actions; `connectBridge` carries it to a native host as JSON. ECMAScript built-ins only, so it runs in Hermes. Shaped after EJ Campbell's astryx-swift spike. |
24
- | Logic host | `AstryxLogicHost` (Swift) | Runs a logic core bundle in JavaScriptCore, decodes each tree into `AstryxNode`, and sends views' actions back. A Hermes host speaks the same bridge. |
15
+ | Part | Path | What it does |
16
+ | -------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
17
+ | Contracts | `src/contract/` | Reads a core component's documentation and splits its props into a portable **core contract** and a **web-only** part, with a reason for each web-only prop. Checks prop names against Swift and Kotlin keywords and platform toolkit names. |
18
+ | Platform types | `@astryxdesign/native/components` | TypeScript props per platform, chosen by type-resolution condition: `ios`, `android`, `macos`, `windows`, or `shared` for the core contract only. |
19
+ | Swift binding | `apple/` | A Swift package with two products: `AstryxNative`, props generated from the same contracts (iOS and macOS props sit behind `#if os(...)`) plus the logic host, and `Astryx`, SwiftUI views drawn with core's tokens. Clone-only page templates sit in `apple/Templates/`. See [its README](apple/README.md). |
20
+ | Native theme | `defineNativeTheme` | Durable theme intent (`material: 'opaque' \| 'translucent'`) plus platform entries scoped by OS version, such as Liquid Glass from iOS 26. Never sets tokens. |
21
+ | Icons | `NATIVE_ICON_DEFAULTS` | Astryx's built-in icon names mapped to SF Symbols on Apple platforms and Material Symbols on Android. |
22
+ | Wire | `AstryxNode`, `AstryxAction` | Each component as JSON a TypeScript core sends a native host (arm C), and the action a host sends back when a view calls a callback. Swift decodes `<Name>Node` and turns it into `<Name>Props` with `viewProps(send:)`. |
23
+ | Logic core | `@astryxdesign/native/logic` | `createLogicCore` holds a screen's state, builds its node tree, and reduces actions; `connectBridge` carries it to a native host as JSON. ECMAScript built-ins only, so it runs in Hermes. Shaped after EJ Campbell's astryx-swift spike. |
24
+ | Logic host | `AstryxLogicHost` (Swift) | Runs a logic core bundle in JavaScriptCore, decodes each tree into `AstryxNode`, and sends views' actions back. A Hermes host speaks the same bridge. |
25
25
 
26
26
  Contracts cover Badge, Button, Card, Divider, Heading, HStack, ProgressBar,
27
- Selector, Switch, Text, TextInput, and VStack. React Native and Android
28
- bindings are not built yet; see [the experiment matrix](#experiment-matrix).
27
+ Selector, Switch, Text, TextInput, and VStack. The `Astryx` Swift product draws
28
+ Text, Heading, Button, TextInput, Card, and Banner, and `apple/Templates/` the
29
+ `login` template, in SwiftUI.
30
+ React Native and Android bindings are not built yet; see
31
+ [the experiment matrix](#experiment-matrix).
29
32
 
30
33
  ```ts
31
34
  // tsconfig.json for an iOS app: "customConditions": ["ios"]
@@ -129,6 +132,7 @@ pnpm -F @astryxdesign/native typecheck # source and tests
129
132
  pnpm -F @astryxdesign/native build # dist/, including platform types
130
133
  pnpm -F @astryxdesign/native typecheck:types # every platform's types, after build
131
134
  pnpm -F @astryxdesign/native generate:apple # regenerate apple/Sources/.../Generated
135
+ pnpm -F @astryxdesign/native generate:swift-ios # regenerate the Astryx product's typed tokens, every published theme, and strings
132
136
  pnpm vitest run --project node packages/native
133
137
  ```
134
138
 
@@ -1,18 +1,35 @@
1
1
  // swift-tools-version: 6.0
2
2
  // Copyright (c) Meta Platforms, Inc. and affiliates.
3
3
 
4
- // Experimental Astryx for Apple platforms (AST-082). The contract types under
5
- // Sources/AstryxNative/Generated are generated by `pnpm generate:apple`; do not
6
- // edit them by hand.
4
+ // Experimental Astryx for Apple platforms (AST-082). Two products:
5
+ // - `Astryx`: SwiftUI views painted with the theme's tokens, the theme
6
+ // environment, and core's localized messages (Sources/Astryx).
7
+ // - `AstryxNative`: the contract types generated by `pnpm generate:apple`
8
+ // (Sources/AstryxNative/Generated; do not edit them by hand) and the logic
9
+ // host.
10
+ // Page templates are a separate package in Templates/, with no products,
11
+ // because they are clone-only source and are never published.
7
12
 
8
13
  import PackageDescription
9
14
 
10
15
  let package = Package(
11
16
  name: "AstryxNative",
17
+ defaultLocalization: "en",
12
18
  platforms: [.iOS(.v17), .macOS(.v14)],
13
- products: [.library(name: "AstryxNative", targets: ["AstryxNative"])],
19
+ products: [
20
+ .library(name: "Astryx", targets: ["Astryx"]),
21
+ .library(name: "AstryxNative", targets: ["AstryxNative"]),
22
+ ],
14
23
  targets: [
15
24
  .target(name: "AstryxNative", path: "Sources/AstryxNative"),
25
+ // Resources holds core's catalog messages per locale, generated.
26
+ .target(
27
+ name: "Astryx",
28
+ dependencies: ["AstryxNative"],
29
+ path: "Sources/Astryx",
30
+ resources: [.process("Resources")]
31
+ ),
16
32
  .testTarget(name: "AstryxNativeTests", dependencies: ["AstryxNative"], path: "Tests/AstryxNativeTests"),
33
+ .testTarget(name: "AstryxTests", dependencies: ["Astryx"], path: "Tests/AstryxTests"),
17
34
  ]
18
35
  )
@@ -0,0 +1,188 @@
1
+ # Astryx for Apple platforms
2
+
3
+ > **Experimental. Canary only.** Part of `@astryxdesign/native`. APIs change
4
+ > without notice.
5
+
6
+ One Swift package with two products:
7
+
8
+ - **`Astryx`**: SwiftUI views painted with the theme's tokens, the theme
9
+ environment, and core's localized messages. It is arm A of the AST-082
10
+ experiment (pure native: platform views painted with Astryx's tokens).
11
+ - **`AstryxNative`**: contract types generated from core's component
12
+ documentation, the wire a TypeScript logic core sends, and the logic host
13
+ that runs one (arm C).
14
+
15
+ Page templates built from the views are a separate package with no products,
16
+ because templates are clone-only and never published.
17
+
18
+ ## Layout
19
+
20
+ ```text
21
+ apple/
22
+ ├── Package.swift # products Astryx and AstryxNative
23
+ ├── Sources/
24
+ │ ├── Astryx/ # SwiftUI views (module Astryx)
25
+ │ │ ├── Theme/ # tokens, the theme environment, Dynamic Type
26
+ │ │ │ └── Generated/ # typed tokens and every published theme, generated; do not edit
27
+ │ │ ├── Resources/ # core's messages per locale, generated; do not edit
28
+ │ │ ├── Localization/
29
+ │ │ ├── Text/ # AstryxText
30
+ │ │ ├── Heading/ # AstryxHeading
31
+ │ │ ├── Button/ # AstryxButton
32
+ │ │ ├── TextInput/ # AstryxTextInput
33
+ │ │ ├── Card/ # AstryxCard
34
+ │ │ └── Banner/ # AstryxBanner
35
+ │ └── AstryxNative/ # contracts, wire, logic host (module AstryxNative)
36
+ │ └── Generated/ # generated; do not edit
37
+ ├── Tests/
38
+ │ ├── AstryxTests/
39
+ │ └── AstryxNativeTests/
40
+ └── Templates/ # page templates to copy; its own package, never published
41
+ ├── Package.swift # no products; compiles and tests the templates
42
+ ├── Login/ # LoginTemplate, from `astryx template login`
43
+ └── Tests/
44
+ ```
45
+
46
+ - **`Sources/Astryx/<Name>/`** holds one folder per component, named as in
47
+ `packages/core/src/<Name>/`. A component's parameters use core's prop names
48
+ and values (`variant: .primary`, `size: .lg`, `isLoading`), so code reads
49
+ the same across web and native, with six exceptions, the interim shape
50
+ AST-093 FR5 exempts until the components move onto the generated contracts:
51
+ - `AstryxButton` takes `action` where core has `onClick`.
52
+ - `AstryxButton` takes `fillsWidth` where core has `width="100%"`.
53
+ - `AstryxButton` takes `icon` as a shared icon name (`"search"`), which the
54
+ generated `ButtonProps` does not carry yet.
55
+ - `AstryxTextInput` takes a `Binding` where core has `value` and `onChange`.
56
+ - `AstryxHeading`'s `level` is optional where core requires it.
57
+ - `AstryxCard` takes `padding` as a whole spacing step, so it cannot spell
58
+ core's `0.5` and `1.5` steps.
59
+ - **`Templates/`** holds one folder per page template, named after the
60
+ `astryx template` id. Templates are clone-only, as on the web: you copy a
61
+ template's folder into your app and own it from then on. They are a
62
+ separate package with no products, so nothing imports them, and
63
+ `@astryxdesign/native` leaves them out of its npm package. A template is
64
+ built only from `Astryx` views and SwiftUI layout, and matches the web
65
+ template's content and states. Rules a template needs, such as validation,
66
+ live in a plain value type beside the view so tests can check them without a
67
+ screen.
68
+
69
+ ## Use it
70
+
71
+ Depend on the `Astryx` product. To start from a template, copy its folder,
72
+ such as `Templates/Login/`, into your app's sources; it then belongs to your
73
+ app.
74
+
75
+ ```swift
76
+ import Astryx
77
+ import SwiftUI
78
+
79
+ @main
80
+ struct DemoApp: App {
81
+ var body: some Scene {
82
+ WindowGroup {
83
+ LoginTemplate().astryxTheme() // copied from Templates/Login/
84
+ }
85
+ }
86
+ }
87
+ ```
88
+
89
+ `astryxTheme(_:)` paints a subtree with a theme; without it, components use
90
+ core's default theme. Either way they follow Dark Mode and the person's text
91
+ size.
92
+
93
+ ## Theme tokens
94
+
95
+ Components read every color, size, and font from the `astryx` environment
96
+ value, never from a constant of their own. `astryx.tokens` holds the theme's
97
+ tokens in the current mode as typed members (`tokens.color.textPrimary`,
98
+ `tokens.space.s4`, `tokens.font.bodySize`), so a misspelled token fails to
99
+ compile. Text sizes grow with Dynamic Type. The components still read some
100
+ tokens by core's name (`astryx.color("color-accent")`); moving them onto the
101
+ typed members comes next.
102
+
103
+ Font families come from the theme's `font-family-*` stacks: the first family
104
+ the device has wins, and a system family such as `-apple-system` or
105
+ `sans-serif` is the system font. Icons use core's shared icon names
106
+ (`success`, `warning`, …), drawn as an SF Symbol by default; a theme replaces
107
+ one with `Theme(…, icons: ["success": "checkmark.seal.fill"])`.
108
+
109
+ `Sources/Astryx/Theme/Generated/` is written by the Swift theme generator
110
+ (`../specs/swift/library/theme-generator.md`) from core's own resolver, light
111
+ and dark:
112
+
113
+ - `Tokens.swift`: the typed token surface, from core's token names.
114
+ - One `<Name>Theme.swift` per theme, declaring `Theme.<name>`: core's
115
+ defaults (`.default`) and every published theme package (`.butter`,
116
+ `.chocolate`, `.gothic`, `.matcha`, `.neutral`, `.stone`, `.y2k`).
117
+
118
+ ```swift
119
+ LoginTemplate().astryxTheme(.neutral)
120
+ ```
121
+
122
+ Regenerate after a theme or core token change:
123
+
124
+ ```sh
125
+ pnpm -F @astryxdesign/native generate:swift-ios
126
+ ```
127
+
128
+ A product generates its own theme the same way, from its theme's module and
129
+ export:
130
+
131
+ ```sh
132
+ node packages/native/scripts/swift-ios-theme.mjs --theme ./src/acmeTheme.ts#acmeTheme --out ./Sources/Theme
133
+ ```
134
+
135
+ A theme's local tokens are not generated yet; the generator names each one it
136
+ leaves out.
137
+
138
+ **Fonts.** A theme's font families are carried as data. Astryx never bundles
139
+ a font: a component walks the theme's family list in order and uses the first
140
+ family the app has registered; a system family in the list (such as
141
+ `-apple-system` or `sans-serif`) is the system font, and a list with no
142
+ registered family falls back to the system font. To draw a theme's brand face, bundle and register it
143
+ in the app. Faces the published themes name (body, heading, code):
144
+
145
+ | Theme | Faces to bundle |
146
+ | ------------ | ------------------------------------------ |
147
+ | `.default` | none (system fonts) |
148
+ | `.butter` | Outfit, JetBrains Mono |
149
+ | `.chocolate` | Albert Sans, Fraunces, JetBrains Mono |
150
+ | `.gothic` | Fustat, JetBrains Mono |
151
+ | `.matcha` | DM Sans, Playwrite US Trad, JetBrains Mono |
152
+ | `.neutral` | Figtree |
153
+ | `.stone` | Figtree, Montserrat, JetBrains Mono |
154
+ | `.y2k` | Poppins, JetBrains Mono |
155
+
156
+ The same command writes
157
+ `Sources/Astryx/Resources/<locale>.lproj/Localizable.strings` from core's
158
+ locale catalogs, so words the components speak to assistive technology, such
159
+ as a loading button's "Loading", follow the person's language. Tests in
160
+ `@astryxdesign/native` fail when either committed output drifts from core.
161
+
162
+ ## Working on it
163
+
164
+ On a Mac with Xcode or the Swift command-line tools:
165
+
166
+ ```sh
167
+ cd packages/native/apple
168
+ swift test # both products
169
+ cd Templates && swift test # templates, against the Astryx product
170
+ ```
171
+
172
+ Both packages also declare macOS 14 so they build and test on a Mac without a
173
+ simulator; iOS 17 is the target. Open `Templates/Package.swift` in Xcode to
174
+ run `LoginTemplate` in an iOS simulator through its preview.
175
+
176
+ To add a component, create `Sources/Astryx/<Name>/Astryx<Name>.swift` with
177
+ core's prop names, read its tokens from `astryx`, and add tests under
178
+ `Tests/AstryxTests/`. To add a template, create
179
+ `Templates/<Name>/<Name>Template.swift` from the output of
180
+ `astryx template <id>`, with tests under `Templates/Tests/`.
181
+
182
+ ## Not here yet
183
+
184
+ - The views do not use the generated contract types in `AstryxNative` yet.
185
+ Moving them onto those types is how a core prop change reaches Swift.
186
+ - Pixel and behavior parity with the web components has not been measured.
187
+ - Increase Contrast, theme-local tokens, and platform theme entries
188
+ (`defineNativeTheme`, reserved as `Theme.native`) are not wired in.
@@ -0,0 +1,80 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import SwiftUI
4
+
5
+ /// Core's `Banner` statuses.
6
+ public enum AstryxBannerStatus: String, CaseIterable, Sendable {
7
+ case info
8
+ case warning
9
+ case error
10
+ case success
11
+
12
+ /// The shared icon name for this status (AST-082 FR17), drawn as the
13
+ /// theme's or the platform's symbol for it.
14
+ var iconName: String { rawValue }
15
+
16
+ var groundToken: String {
17
+ switch self {
18
+ case .info: "color-accent-muted"
19
+ case .warning: "color-warning-muted"
20
+ case .error: "color-error-muted"
21
+ case .success: "color-success-muted"
22
+ }
23
+ }
24
+
25
+ var iconToken: String {
26
+ switch self {
27
+ case .info: "color-accent"
28
+ case .warning: "color-warning"
29
+ case .error: "color-error"
30
+ case .success: "color-success"
31
+ }
32
+ }
33
+ }
34
+
35
+ /// A message about the page or a section of it. The status sets the color
36
+ /// and icon only; the title must say what happened, because the icon is
37
+ /// decorative to VoiceOver.
38
+ public struct AstryxBanner: View {
39
+ let status: AstryxBannerStatus
40
+ let title: String
41
+ let description: String?
42
+ @Environment(\.astryx) private var astryx
43
+
44
+ public init(status: AstryxBannerStatus, title: String, description: String? = nil) {
45
+ self.status = status
46
+ self.title = title
47
+ self.description = description
48
+ }
49
+
50
+ public var body: some View {
51
+ HStack(alignment: .firstTextBaseline, spacing: astryx.space(2)) {
52
+ if let symbol = astryx.icon(status.iconName) {
53
+ // Sized with the title so it grows with Dynamic Type.
54
+ Image(systemName: symbol)
55
+ .font(astryx.font(size: "text-body-size", 14, weight: "text-body-weight"))
56
+ .foregroundStyle(astryx.color(status.iconToken))
57
+ .accessibilityHidden(true)
58
+ }
59
+ VStack(alignment: .leading, spacing: astryx.space(1)) {
60
+ AstryxText(title, weight: .semibold)
61
+ if let description {
62
+ AstryxText(description, color: .secondary)
63
+ }
64
+ }
65
+ .frame(maxWidth: .infinity, alignment: .leading)
66
+ }
67
+ .padding(astryx.space(3))
68
+ .background(
69
+ RoundedRectangle(cornerRadius: astryx.size("radius-container", 12), style: .continuous)
70
+ .fill(astryx.color(status.groundToken))
71
+ )
72
+ .accessibilityElement(children: .ignore)
73
+ .accessibilityLabel(Text(accessibilityText))
74
+ }
75
+
76
+ /// What VoiceOver reads: the title, then the description.
77
+ var accessibilityText: String {
78
+ description.map { "\(title), \($0)" } ?? title
79
+ }
80
+ }
@@ -0,0 +1,167 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ // Adapted from github.com/ejc3/astryx-swift (MIT).
3
+
4
+ import SwiftUI
5
+
6
+ /// Core's `Button` variants.
7
+ public enum AstryxButtonVariant: String, CaseIterable, Sendable {
8
+ case primary
9
+ case secondary
10
+ case ghost
11
+ case destructive
12
+ }
13
+
14
+ /// Core's `Button` sizes: `size-element-sm`, `-md`, and `-lg` high.
15
+ public enum AstryxButtonSize: String, CaseIterable, Sendable {
16
+ case sm
17
+ case md
18
+ case lg
19
+
20
+ var heightToken: String { "size-element-\(rawValue)" }
21
+ var fallbackHeight: CGFloat {
22
+ switch self {
23
+ case .sm: 28
24
+ case .md: 32
25
+ case .lg: 36
26
+ }
27
+ }
28
+ }
29
+
30
+ /// A labeled action, painted with the theme's tokens. The tap area reaches
31
+ /// the platform's 44-by-44-point minimum even when the drawn button is smaller.
32
+ public struct AstryxButton: View {
33
+ let label: String
34
+ let variant: AstryxButtonVariant
35
+ let size: AstryxButtonSize
36
+ let icon: String?
37
+ let isLoading: Bool
38
+ let isDisabled: Bool
39
+ let fillsWidth: Bool
40
+ let action: () -> Void
41
+ @Environment(\.astryx) private var astryx
42
+
43
+ /// - Parameters:
44
+ /// - icon: a shared icon name, such as `"search"`, shown before the
45
+ /// label; the theme's symbol for it, else the platform default.
46
+ /// - fillsWidth: the button takes the whole width it is offered, as
47
+ /// core's `width="100%"` does.
48
+ public init(
49
+ label: String,
50
+ variant: AstryxButtonVariant = .secondary,
51
+ size: AstryxButtonSize = .md,
52
+ icon: String? = nil,
53
+ isLoading: Bool = false,
54
+ isDisabled: Bool = false,
55
+ fillsWidth: Bool = false,
56
+ action: @escaping () -> Void
57
+ ) {
58
+ self.label = label
59
+ self.variant = variant
60
+ self.size = size
61
+ self.icon = icon
62
+ self.isLoading = isLoading
63
+ self.isDisabled = isDisabled
64
+ self.fillsWidth = fillsWidth
65
+ self.action = action
66
+ }
67
+
68
+ public var body: some View {
69
+ Button(action: activate) {
70
+ // The label keeps its place while loading so the width holds; the
71
+ // spinner draws over it.
72
+ HStack(spacing: astryx.space(2)) {
73
+ if let icon, let symbol = astryx.icon(icon) {
74
+ Image(systemName: symbol)
75
+ }
76
+ Text(label).lineLimit(1)
77
+ }
78
+ .opacity(isLoading ? 0 : 1)
79
+ .overlay {
80
+ if isLoading {
81
+ ProgressView().controlSize(.small).tint(foreground)
82
+ }
83
+ }
84
+ }
85
+ .buttonStyle(
86
+ AstryxButtonStyle(
87
+ astryx: astryx,
88
+ size: size,
89
+ fillsWidth: fillsWidth,
90
+ foreground: foreground,
91
+ background: background
92
+ )
93
+ )
94
+ // As core's Button: a loading button stays undimmed and focusable, keeps
95
+ // its label, and says Loading; taps do nothing until it finishes.
96
+ .disabled(isDisabled)
97
+ .accessibilityLabel(Text(accessibilityText))
98
+ .accessibilityAddTraits(Self.accessibilityTraits)
99
+ .accessibilityValue(Text(loadingValue ?? ""))
100
+ .onChange(of: isLoading) { _, loading in
101
+ if loading { AccessibilityNotification.Announcement(AstryxStrings.buttonLoading).post() }
102
+ }
103
+ }
104
+
105
+ /// The button's accessible name: its label, also while loading.
106
+ var accessibilityText: String { label }
107
+
108
+ /// The traits assistive technology gets: a button.
109
+ static var accessibilityTraits: AccessibilityTraits { .isButton }
110
+
111
+ /// What assistive technology reads after the label while loading: core's
112
+ /// catalog message, in the person's language.
113
+ var loadingValue: String? { isLoading ? AstryxStrings.buttonLoading : nil }
114
+
115
+ /// Runs the action unless the button is loading.
116
+ func activate() {
117
+ guard !isLoading else { return }
118
+ action()
119
+ }
120
+
121
+ private var foreground: Color {
122
+ switch variant {
123
+ case .primary: astryx.color("color-on-accent")
124
+ case .destructive: astryx.color("color-on-error")
125
+ case .secondary, .ghost: astryx.color("color-text-primary")
126
+ }
127
+ }
128
+
129
+ private var background: Color {
130
+ switch variant {
131
+ case .primary: astryx.color("color-accent")
132
+ case .destructive: astryx.color("color-error")
133
+ case .secondary: astryx.color("color-neutral")
134
+ case .ghost: .clear
135
+ }
136
+ }
137
+ }
138
+
139
+ struct AstryxButtonStyle: ButtonStyle {
140
+ let astryx: AstryxStyle
141
+ let size: AstryxButtonSize
142
+ let fillsWidth: Bool
143
+ let foreground: Color
144
+ let background: Color
145
+ @Environment(\.isEnabled) private var isEnabled
146
+
147
+ func makeBody(configuration: Configuration) -> some View {
148
+ let shape = RoundedRectangle(
149
+ cornerRadius: astryx.size("radius-element", 8),
150
+ style: .continuous
151
+ )
152
+ // Grows with text size, but not so far that one line of a large label
153
+ // becomes a tall slab.
154
+ let height = astryx.size(size.heightToken, size.fallbackHeight) * min(astryx.textScale, 1.4)
155
+ configuration.label
156
+ .font(astryx.font(size: "text-label-size", 14, weight: "text-label-weight"))
157
+ .foregroundStyle(foreground)
158
+ .padding(.horizontal, astryx.space(3))
159
+ .frame(maxWidth: fillsWidth ? .infinity : nil, minHeight: height)
160
+ .background(shape.fill(background))
161
+ .opacity(isEnabled ? (configuration.isPressed ? 0.75 : 1) : 0.5)
162
+ // Drawn at the token size; laid out at least 44 by 44 so the whole
163
+ // platform-minimum tap area counts.
164
+ .frame(minWidth: 44, minHeight: 44)
165
+ .contentShape(Rectangle())
166
+ }
167
+ }
@@ -0,0 +1,33 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import SwiftUI
4
+
5
+ /// A card: one independent item on the theme's card ground, with a hairline
6
+ /// border and the container radius.
7
+ public struct AstryxCard<Content: View>: View {
8
+ let padding: Int
9
+ let content: Content
10
+ @Environment(\.astryx) private var astryx
11
+
12
+ /// - Parameter padding: a step on the spacing ladder, as core's `padding`.
13
+ public init(padding: Int = 4, @ViewBuilder content: () -> Content) {
14
+ self.padding = padding
15
+ self.content = content()
16
+ }
17
+
18
+ public var body: some View {
19
+ let shape = RoundedRectangle(
20
+ cornerRadius: astryx.size("radius-container", 12),
21
+ style: .continuous
22
+ )
23
+ // Several child views stack in one card, top to bottom with no gap of
24
+ // their own, as children flow inside core's Card.
25
+ VStack(alignment: .leading, spacing: 0) { content }
26
+ .padding(astryx.space(padding))
27
+ .frame(maxWidth: .infinity, alignment: .leading)
28
+ .background(shape.fill(astryx.color("color-background-card")))
29
+ .overlay(
30
+ shape.strokeBorder(astryx.color("color-border"), lineWidth: astryx.size("border-width", 1))
31
+ )
32
+ }
33
+ }
@@ -0,0 +1,46 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ // Adapted from github.com/ejc3/astryx-swift (MIT).
3
+
4
+ import SwiftUI
5
+
6
+ /// A heading at levels 1 through 6, painted with the theme's heading tokens
7
+ /// and exposed to VoiceOver as a heading.
8
+ public struct AstryxHeading: View {
9
+ let content: String
10
+ let level: Int
11
+ @Environment(\.astryx) private var astryx
12
+
13
+ public init(_ content: String, level: Int = 2) {
14
+ self.content = content
15
+ self.level = min(max(level, 1), 6)
16
+ }
17
+
18
+ public var body: some View {
19
+ Text(content)
20
+ .font(
21
+ astryx.font(
22
+ size: "text-heading-\(level)-size",
23
+ 20,
24
+ weight: "text-heading-\(level)-weight",
25
+ family: "font-family-heading"
26
+ )
27
+ )
28
+ .foregroundStyle(astryx.color("color-text-primary"))
29
+ .accessibilityAddTraits(Self.accessibilityTraits)
30
+ .accessibilityHeading(headingLevel)
31
+ }
32
+
33
+ /// The traits assistive technology gets: a header.
34
+ static var accessibilityTraits: AccessibilityTraits { .isHeader }
35
+
36
+ var headingLevel: AccessibilityHeadingLevel {
37
+ switch level {
38
+ case 1: .h1
39
+ case 2: .h2
40
+ case 3: .h3
41
+ case 4: .h4
42
+ case 5: .h5
43
+ default: .h6
44
+ }
45
+ }
46
+ }
@@ -0,0 +1,22 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import Foundation
4
+
5
+ /// Core's catalog messages, read from `Resources/<locale>.lproj`, which
6
+ /// `generate:swift-ios` writes from core's locale catalogs.
7
+ enum AstryxStrings {
8
+ /// What a loading button says to assistive technology.
9
+ static var buttonLoading: String { localized("@astryx.button.loading", "Loading") }
10
+
11
+ static func localized(_ key: String, _ english: String) -> String {
12
+ resourceBundle.localizedString(forKey: key, value: english, table: nil)
13
+ }
14
+
15
+ static var resourceBundle: Bundle {
16
+ #if SWIFT_PACKAGE
17
+ Bundle.module
18
+ #else
19
+ Bundle.main
20
+ #endif
21
+ }
22
+ }
@@ -0,0 +1,4 @@
1
+ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */
2
+
3
+ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */
4
+ "@astryx.button.loading" = "Laai tans";
@@ -0,0 +1,4 @@
1
+ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */
2
+
3
+ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */
4
+ "@astryx.button.loading" = "جارٍ التحميل";
@@ -0,0 +1,4 @@
1
+ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */
2
+
3
+ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */
4
+ "@astryx.button.loading" = "S’està carregant";
@@ -0,0 +1,4 @@
1
+ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */
2
+
3
+ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */
4
+ "@astryx.button.loading" = "Načítání";
@@ -0,0 +1,4 @@
1
+ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */
2
+
3
+ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */
4
+ "@astryx.button.loading" = "Indlæser";
@@ -0,0 +1,4 @@
1
+ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */
2
+
3
+ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */
4
+ "@astryx.button.loading" = "Wird geladen";