@hegemonart/get-design-done 1.33.6 → 1.34.1
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/.claude-plugin/marketplace.json +6 -3
- package/.claude-plugin/plugin.json +5 -2
- package/CHANGELOG.md +24 -0
- package/README.md +10 -0
- package/agents/compose-executor.md +142 -0
- package/agents/design-context-builder.md +35 -1
- package/agents/design-verifier.md +14 -18
- package/agents/flutter-executor.md +147 -0
- package/agents/swift-executor.md +226 -0
- package/connections/android-emulator.md +107 -0
- package/connections/connections.md +4 -0
- package/connections/xcode-simulator.md +108 -0
- package/package.json +1 -1
- package/reference/native-platforms.md +273 -0
- package/reference/registry.json +7 -0
- package/scripts/lib/design-tokens/_native-shared.cjs +206 -0
- package/scripts/lib/design-tokens/compose.cjs +150 -0
- package/scripts/lib/design-tokens/flutter.cjs +128 -0
- package/scripts/lib/design-tokens/index.cjs +13 -0
- package/scripts/lib/design-tokens/swift.cjs +122 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# Native Platforms — Token-Bridge Spec
|
|
2
|
+
|
|
3
|
+
This reference is the **token→native-code bridge**: it specifies how the canonical
|
|
4
|
+
CSS-token form produced by the Phase-23 token engine
|
|
5
|
+
(`scripts/lib/design-tokens/`) maps onto the three native theme systems —
|
|
6
|
+
SwiftUI, Jetpack Compose, and Flutter — and pins down the **precision contract**
|
|
7
|
+
that defines what "token identity preserved" means for the deterministic
|
|
8
|
+
round-trip (`reference/native-platforms.md` is the authority that
|
|
9
|
+
`test/suite/native-token-bridge.test.cjs` asserts against).
|
|
10
|
+
|
|
11
|
+
It is the sibling of [`reference/platforms.md`](./platforms.md). Those two files
|
|
12
|
+
have distinct jobs and must not be confused:
|
|
13
|
+
|
|
14
|
+
| File | Job |
|
|
15
|
+
| --- | --- |
|
|
16
|
+
| `reference/platforms.md` (Phase 19) | Interaction **conventions** — navigation, safe areas, gestures, native typography, haptics. *Behavioral* knowledge the executors reference when laying out a screen. |
|
|
17
|
+
| `reference/native-platforms.md` (Phase 34.1, this file) | The token→theme **bridge** — how a design token (`#3B82F6`, `16px`, `Inter`) becomes a SwiftUI `Color` / Compose `Color(0x…)` / Flutter `Color(0x…)`, plus the precision contract for the round-trip. *Structural* knowledge the emitters implement. |
|
|
18
|
+
|
|
19
|
+
Per **D-02** the bridge **extends** the Phase-23 engine with three new emitters
|
|
20
|
+
(`scripts/lib/design-tokens/{swift,compose,flutter}.cjs`) rather than forking a
|
|
21
|
+
separate native IR. There is one canonical token form (below) and one set of
|
|
22
|
+
readers; the native emitters are additional *sinks* on the same facade. Per
|
|
23
|
+
**D-10** the round-trip operates at the **token level** — deterministic emit +
|
|
24
|
+
re-extract with documented precision — never full-view parsing and never a live
|
|
25
|
+
simulator, so the default `npm test` stays green on any machine.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## 1. Purpose
|
|
30
|
+
|
|
31
|
+
Phase 19 shipped platform *references* but zero generators; Phase 23 shipped a
|
|
32
|
+
multi-source token reader (`css-vars` / `js-const` / `tailwind` / `figma`) that
|
|
33
|
+
all normalise to a single flat `{ tokens: Record<string, string> }` map. Phase
|
|
34
|
+
34.1 crosses from platform-knowledge into platform-execution: instead of each
|
|
35
|
+
native executor re-deriving "how does `#3B82F6` become a SwiftUI `Color`", the
|
|
36
|
+
mapping lives once here (the spec) and once in the emitters (the
|
|
37
|
+
implementation), and the executors consume it. This amortizes the Phase-23 token
|
|
38
|
+
investment across SwiftUI / Compose / Flutter.
|
|
39
|
+
|
|
40
|
+
The canonical CSS-token form is the **single input** to all three native
|
|
41
|
+
emitters. This spec maps that one input to three native theme systems and states
|
|
42
|
+
the precision each mapping preserves.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## 2. Canonical input shape
|
|
47
|
+
|
|
48
|
+
The emitters consume the exact map shape the Phase-23 readers return: a **flat
|
|
49
|
+
`{ tokens: Record<string, string> }` object** whose keys are the design-token
|
|
50
|
+
names with the leading `--` stripped (exactly as `css-vars.cjs` returns) and
|
|
51
|
+
whose values are the raw token values as strings.
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
{ tokens: { "color-primary": "#3B82F6", "space-4": "16px", "font-family-body": "Inter, system-ui" } }
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Each emitter accepts either the full Phase-23 `TokenSet`
|
|
58
|
+
(`{ tokens, source?, format? }`) **or** a bare `{ tokens }` object — it reads
|
|
59
|
+
`tokenSet.tokens` and throws a `TypeError` only when no `.tokens` object is
|
|
60
|
+
present.
|
|
61
|
+
|
|
62
|
+
### Prefix → category inference
|
|
63
|
+
|
|
64
|
+
Token **category** is inferred from the key prefix. The emitters use this table
|
|
65
|
+
to decide whether a value is a color, a dimension, or a string:
|
|
66
|
+
|
|
67
|
+
| Key prefix | Category | Native treatment |
|
|
68
|
+
| --- | --- | --- |
|
|
69
|
+
| `color-` | color | hex → native channel form (§3–§5, §6 COLOR) |
|
|
70
|
+
| `space-`, `spacing-` | dimension | px → pt / dp / logical px (§6 DIMENSION) |
|
|
71
|
+
| `radius-` | dimension | px → pt / dp / logical px |
|
|
72
|
+
| `size-` | dimension | px → pt / dp / logical px |
|
|
73
|
+
| `font-`, `text-` | typography | string pass-through (family / weight / named size) |
|
|
74
|
+
| `shadow-` | other | string pass-through (composite values are non-mappable) |
|
|
75
|
+
| *(anything else)* | other | value-sniffed: a `#…` value is treated as color, an `Npx` value as dimension, otherwise string pass-through |
|
|
76
|
+
|
|
77
|
+
A value is **always** re-sniffed regardless of prefix, so a `#…` value under a
|
|
78
|
+
non-color prefix is still emitted as a color and a `Npx` value under a non-space
|
|
79
|
+
prefix is still emitted as a dimension. The prefix is the hint; the value is the
|
|
80
|
+
authority. This keeps the bridge robust against arbitrary token-naming schemes.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## 3. SwiftUI mapping
|
|
85
|
+
|
|
86
|
+
Target: a Swift source string exposing an `enum` of static theme constants
|
|
87
|
+
(`enum GDDTheme { … }`) — colors as `Color`, dimensions as `CGFloat` points,
|
|
88
|
+
typography families as `String` (and an optional `Font` helper).
|
|
89
|
+
|
|
90
|
+
| Token | SwiftUI form |
|
|
91
|
+
| --- | --- |
|
|
92
|
+
| color `#RRGGBB` | `Color(red: R/255.0, green: G/255.0, blue: B/255.0, opacity: A/255.0)` from the 8-bit channels |
|
|
93
|
+
| dimension `Npx` | `CGFloat` point literal — integer `N` (pt) |
|
|
94
|
+
| font-family | `String` literal (`"Inter, system-ui"`) |
|
|
95
|
+
|
|
96
|
+
Illustrative (2-line) snippet:
|
|
97
|
+
|
|
98
|
+
```swift
|
|
99
|
+
static let colorPrimary = Color(red: 59.0/255.0, green: 130.0/255.0, blue: 246.0/255.0, opacity: 255.0/255.0)
|
|
100
|
+
static let space4: CGFloat = 16
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
SwiftUI uses normalized `0.0…1.0` channel fractions; to keep the round-trip
|
|
104
|
+
**exact** the emitter writes each channel as the 8-bit numerator over `255.0`
|
|
105
|
+
(e.g. `59.0/255.0`) rather than a pre-divided decimal — the re-extractor reads
|
|
106
|
+
the numerator back as the integer channel, avoiding float drift. The `Color` /
|
|
107
|
+
`Font` / `ViewModifier` consumption pattern (applying the constants to views) is
|
|
108
|
+
the executor's job; this emitter produces the *constants*.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## 4. Jetpack Compose mapping
|
|
113
|
+
|
|
114
|
+
Target: a Kotlin source string with `Color` vals, a `Shapes` block (from
|
|
115
|
+
`radius-` tokens), a `Typography` block (from `font-`/`text-` tokens), and a
|
|
116
|
+
`MaterialTheme` wiring (`object GDDTheme { val Colors… ; val Shapes… ; val Typography… }`).
|
|
117
|
+
|
|
118
|
+
| Token | Compose form |
|
|
119
|
+
| --- | --- |
|
|
120
|
+
| color `#RRGGBB` | `Color(0xAARRGGBB)` long literal (alpha-first, 8 hex digits) |
|
|
121
|
+
| dimension `Npx` | `N.dp` (integer dp) |
|
|
122
|
+
| radius `Npx` | `RoundedCornerShape(N.dp)` inside `Shapes` |
|
|
123
|
+
| typography family | `String` (fed into a `TextStyle.fontFamily` slot / `Typography`) |
|
|
124
|
+
|
|
125
|
+
Illustrative (2-line) snippet:
|
|
126
|
+
|
|
127
|
+
```kotlin
|
|
128
|
+
val colorPrimary = Color(0xFF3B82F6)
|
|
129
|
+
val space4 = 16.dp
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Compose packs ARGB into a single `0xAARRGGBB` long; alpha is the high byte. The
|
|
133
|
+
re-extractor reads the 8 hex digits straight back to the channels.
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## 5. Flutter mapping
|
|
138
|
+
|
|
139
|
+
Target: a Dart source string building a `ThemeData` whose `colorScheme`
|
|
140
|
+
(`ColorScheme`) carries the color tokens and whose `textTheme` (`TextTheme`)
|
|
141
|
+
carries the typography tokens, plus a constants class
|
|
142
|
+
(`class GDDTheme { … }`).
|
|
143
|
+
|
|
144
|
+
| Token | Flutter form |
|
|
145
|
+
| --- | --- |
|
|
146
|
+
| color `#RRGGBB` | `Color(0xAARRGGBB)` (alpha-first, 8 hex digits) |
|
|
147
|
+
| dimension `Npx` | logical-px **double** — `N.0` |
|
|
148
|
+
| typography family | `String` (`fontFamily: 'Inter'`) |
|
|
149
|
+
|
|
150
|
+
Illustrative (2-line) snippet:
|
|
151
|
+
|
|
152
|
+
```dart
|
|
153
|
+
static const colorPrimary = Color(0xFF3B82F6);
|
|
154
|
+
static const space4 = 16.0;
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Flutter measures in logical pixels and keeps the value as a `double` (`16.0`),
|
|
158
|
+
so — unlike pt/dp — Flutter dimensions are **not** rounded to integers; the
|
|
159
|
+
fractional part survives.
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## 6. PRECISION CONTRACT
|
|
164
|
+
|
|
165
|
+
This section is the crux. It defines, per value category, exactly what
|
|
166
|
+
information the emit → re-extract round-trip preserves. The test asserts token
|
|
167
|
+
identity **within this precision** — not bit-exact floats, not lossy
|
|
168
|
+
approximation. An emitter is correct **iff** `reextract(emit({tokens}))`
|
|
169
|
+
reproduces every token in the identity set under these rules.
|
|
170
|
+
|
|
171
|
+
### COLOR — 8-bit-per-channel, EXACT
|
|
172
|
+
|
|
173
|
+
- Accepted input forms: `#RGB`, `#RRGGBB`, `#RGBA`, `#RRGGBBAA` (case-insensitive).
|
|
174
|
+
- `#RGB` / `#RGBA` shorthand **expands** to `#RRGGBB` / `#RRGGBBAA` by
|
|
175
|
+
duplicating each nibble (`#3af` → `#33aaff`). This expansion is part of the
|
|
176
|
+
contract: the re-extractor recovers the **expanded** `#RRGGBB(AA)` form, so
|
|
177
|
+
`#3af` round-trips to `#33aaff` (canonically equal, the documented identity).
|
|
178
|
+
- Each channel is an 8-bit integer (0–255) and is preserved **exactly** — no
|
|
179
|
+
channel may be off by one. SwiftUI stores channels as `N.0/255.0` numerators;
|
|
180
|
+
Compose/Flutter store them in a `0xAARRGGBB` literal. Both forms recover the
|
|
181
|
+
identical 8-bit channels.
|
|
182
|
+
- **Alpha:** when the input has no alpha (`#RGB`/`#RRGGBB`) the emitted color is
|
|
183
|
+
**opaque** — alpha byte `0xFF` (Compose/Flutter) / `opacity: 255.0/255.0`
|
|
184
|
+
(SwiftUI). The re-extractor emits an alpha channel **only when the original
|
|
185
|
+
had one**: a 6-digit input round-trips to a 6-digit `#RRGGBB` (the implied
|
|
186
|
+
opaque alpha is dropped on the way back); an 8-digit input round-trips to the
|
|
187
|
+
8-digit `#RRGGBBAA`. This keeps `#3B82F6 → #3B82F6` an exact identity.
|
|
188
|
+
|
|
189
|
+
### DIMENSION — integer pt/dp, logical-px double
|
|
190
|
+
|
|
191
|
+
- Accepted input: `Npx` or a bare unit-less number (`16px`, `16`). The unit is
|
|
192
|
+
normalised to `px` on the canonical side.
|
|
193
|
+
- **iOS (pt) / Android (dp):** rounded to the nearest integer, **round-half-up**
|
|
194
|
+
(`15.5px` → `16`). Because rounding is lossy for non-integers, the round-trip
|
|
195
|
+
**identity set** is restricted to integer-px dimensions (e.g. `16px`), which
|
|
196
|
+
recover exactly: `16px → 16 (pt/dp) → 16px`.
|
|
197
|
+
- **Flutter (logical px):** kept as a `double` (`16px` → `16.0`), so Flutter
|
|
198
|
+
does **not** round and a fractional dimension survives. The re-extractor
|
|
199
|
+
recovers `Npx` by stripping the trailing `.0` for whole numbers.
|
|
200
|
+
- The re-extractor always recovers the canonical `Npx` string form, so the
|
|
201
|
+
emit→re-extract identity for an integer dimension is `"16px" === "16px"`.
|
|
202
|
+
|
|
203
|
+
### `rem` / `em` — passed through verbatim (non-mappable)
|
|
204
|
+
|
|
205
|
+
`rem`/`em` values depend on a root/element font-size that the token map does not
|
|
206
|
+
carry, so they are **not** converted. They are treated as **non-mappable**
|
|
207
|
+
(below): emitted verbatim into a raw slot and **excluded** from the round-trip
|
|
208
|
+
identity set. (A future plan may add an explicit base-size option; until then,
|
|
209
|
+
verbatim pass-through is the stated, deterministic behavior.)
|
|
210
|
+
|
|
211
|
+
### TYPOGRAPHY / NAMED VALUES — string pass-through
|
|
212
|
+
|
|
213
|
+
`font-family`, `font-weight`, named sizes, and any other string token are
|
|
214
|
+
emitted **verbatim** as a string literal and recovered **string-equal**
|
|
215
|
+
(`"Inter, system-ui" → "Inter, system-ui"`). No normalisation, no quoting
|
|
216
|
+
changes that alter the recovered string.
|
|
217
|
+
|
|
218
|
+
### NON-MAPPABLE — verbatim, EXCLUDED from the identity set
|
|
219
|
+
|
|
220
|
+
Values the emitter cannot represent as a native primitive — CSS `var(--x)`
|
|
221
|
+
references, `calc(…)` expressions, gradients (`linear-gradient(…)`), and `rem`/
|
|
222
|
+
`em` dimensions — are **passed through verbatim** into a raw-string slot
|
|
223
|
+
(a trailing comment such as `// non-mappable: <name> = <value>` or the language
|
|
224
|
+
equivalent) so no information is lost, and are **explicitly excluded** from the
|
|
225
|
+
round-trip identity assertion. The contract documents them as
|
|
226
|
+
"verbatim / not round-tripped": the test asserts the verbatim value appears in
|
|
227
|
+
the emitted source, and does **not** assert it survives re-extraction as a typed
|
|
228
|
+
token.
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## 7. The round-trip (what the test locks)
|
|
233
|
+
|
|
234
|
+
For each emitter the bridge guarantees:
|
|
235
|
+
|
|
236
|
+
1. **Determinism.** `emit(x) === emit(x)` byte-for-byte. Keys are iterated in a
|
|
237
|
+
stable sorted order; no `Date`, no `process.env`, no filesystem, no network in
|
|
238
|
+
the emit path (D-10).
|
|
239
|
+
2. **Identity within precision.** For the identity set (color + integer
|
|
240
|
+
dimension + typography), `reextract(emit({tokens}))` deep-equals `{tokens}`
|
|
241
|
+
under the precision rules above (8-bit color channels exact with `#RGB`
|
|
242
|
+
expansion; integer pt/dp; logical-px double; family/weight string-equal).
|
|
243
|
+
3. **Verbatim exclusion.** Non-mappable values appear verbatim in the source and
|
|
244
|
+
are not part of the identity assertion.
|
|
245
|
+
|
|
246
|
+
Each emitter module exports a symmetric re-extractor
|
|
247
|
+
(`reextractSwift` / `reextractCompose` / `reextractFlutter`) that parses the
|
|
248
|
+
emitted native source back into a `{ tokens }` map, so the round-trip is
|
|
249
|
+
deterministic and bijective on the identity set and reusable by the Phase-34.1
|
|
250
|
+
regression baseline.
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## 8. Registration
|
|
255
|
+
|
|
256
|
+
This reference is registered in
|
|
257
|
+
[`reference/registry.json`](./registry.json) as the `native-platforms` entry
|
|
258
|
+
(type `heuristic`, phase `34.1`) so the registry round-trip test
|
|
259
|
+
(`test/suite/reference-registry.test.cjs`) stays green — every `reference/*.md`
|
|
260
|
+
must be registered and resolve (D-05, the 33.5-01 lesson).
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## 9. Cross-references
|
|
265
|
+
|
|
266
|
+
- [`reference/platforms.md`](./platforms.md) — the interaction-conventions
|
|
267
|
+
sibling (navigation, safe areas, gestures, native typography). Executors read
|
|
268
|
+
**both**: this file for the token→theme bridge, that file for layout/behavior.
|
|
269
|
+
- `scripts/lib/design-tokens/` — the Phase-23 token engine this bridge extends
|
|
270
|
+
(`index.cjs` facade + `css-vars` / `js-const` / `tailwind` / `figma` readers +
|
|
271
|
+
the new `swift` / `compose` / `flutter` emitters).
|
|
272
|
+
- `test/fixtures/mapper-outputs/tokens.json` — the canonical token fixture the
|
|
273
|
+
round-trip test derives its map from.
|
package/reference/registry.json
CHANGED
|
@@ -888,6 +888,13 @@
|
|
|
888
888
|
"type": "data",
|
|
889
889
|
"phase": 33.6,
|
|
890
890
|
"description": "Phase 33.6 catalog-derived OpenRouter price sub-table — per-model prompt/completion $/tok snapshot of .design/cache/openrouter-models.json; derived view, the dynamic catalog is the source of truth (D-11 registry round-trip)."
|
|
891
|
+
},
|
|
892
|
+
{
|
|
893
|
+
"name": "native-platforms",
|
|
894
|
+
"path": "reference/native-platforms.md",
|
|
895
|
+
"type": "heuristic",
|
|
896
|
+
"phase": 34.1,
|
|
897
|
+
"description": "Phase 34.1 token-bridge spec — maps the canonical CSS-token form (Phase 23) to SwiftUI Color/Font/ViewModifier, Jetpack Compose Color/Shapes/Typography/MaterialTheme, and Flutter ThemeData/ColorScheme/TextTheme, with the precision contract (color hex→8-bit channels exact, dimension px→pt/dp/logical px) defining token-identity for the round-trip."
|
|
891
898
|
}
|
|
892
899
|
]
|
|
893
900
|
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design-tokens/_native-shared.cjs — shared precision-contract helpers for the
|
|
3
|
+
* native emitters (swift.cjs / compose.cjs / flutter.cjs), Phase 34.1 Plan 01.
|
|
4
|
+
*
|
|
5
|
+
* The single implementation of the PRECISION CONTRACT documented in
|
|
6
|
+
* reference/native-platforms.md, so all three emitters convert color /
|
|
7
|
+
* dimension / typography / non-mappable values identically:
|
|
8
|
+
* COLOR hex #RGB/#RGBA/#RRGGBB/#RRGGBBAA -> 8-bit channels EXACT;
|
|
9
|
+
* #RGB/#RGBA expand by nibble duplication; alpha tracked.
|
|
10
|
+
* DIMENSION Npx | unit-less -> integer (round-half-up) + logical-px double.
|
|
11
|
+
* TYPOGRAPHY strings -> pass-through.
|
|
12
|
+
* NON-MAPPABLE var()/calc()/gradient/rem/em -> verbatim, excluded.
|
|
13
|
+
*
|
|
14
|
+
* Pure: no fs, no network, no Date, no process.env, no child_process (D-10).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
// Markers embedded in every emitted line so the symmetric re-extractors can
|
|
20
|
+
// recover the EXACT canonical token key (native identifiers are mangled, e.g.
|
|
21
|
+
// `color-primary` -> `colorPrimary`, so the key must travel alongside).
|
|
22
|
+
const TOKEN_MARKER = '// token: ';
|
|
23
|
+
const NONMAPPABLE_MARKER = '// non-mappable: ';
|
|
24
|
+
|
|
25
|
+
const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
|
26
|
+
const PX_RE = /^-?\d+(?:\.\d+)?px$/;
|
|
27
|
+
const UNITLESS_RE = /^-?\d+(?:\.\d+)?$/;
|
|
28
|
+
const NONMAPPABLE_RE = /(^var\(|^calc\(|gradient\(|\b(rem|em)$|\d(rem|em)$)/i;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read `.tokens` from a Phase-23 TokenSet or a bare {tokens} map.
|
|
32
|
+
* @param {*} tokenSet
|
|
33
|
+
* @returns {Record<string,string>}
|
|
34
|
+
*/
|
|
35
|
+
function readTokens(tokenSet) {
|
|
36
|
+
if (
|
|
37
|
+
!tokenSet ||
|
|
38
|
+
typeof tokenSet !== 'object' ||
|
|
39
|
+
!tokenSet.tokens ||
|
|
40
|
+
typeof tokenSet.tokens !== 'object'
|
|
41
|
+
) {
|
|
42
|
+
throw new TypeError(
|
|
43
|
+
'native emitter: expected { tokens: Record<string,string> } (or a Phase-23 TokenSet)',
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return tokenSet.tokens;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Stable, sorted [key, value] entries so emit(x) === emit(x) byte-for-byte.
|
|
51
|
+
* @param {Record<string,string>} tokens
|
|
52
|
+
* @returns {[string,string][]}
|
|
53
|
+
*/
|
|
54
|
+
function sortedEntries(tokens) {
|
|
55
|
+
return Object.keys(tokens)
|
|
56
|
+
.sort()
|
|
57
|
+
.map((k) => [k, String(tokens[k])]);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Classify a token value per the precision contract. The value is the
|
|
62
|
+
* authority; the key prefix is only a hint (see native-platforms.md §2).
|
|
63
|
+
* @param {string} _key
|
|
64
|
+
* @param {string} value
|
|
65
|
+
* @returns {'non-mappable'|'color'|'dimension'|'typography'}
|
|
66
|
+
*/
|
|
67
|
+
function classify(_key, value) {
|
|
68
|
+
const v = String(value).trim();
|
|
69
|
+
if (NONMAPPABLE_RE.test(v)) return 'non-mappable';
|
|
70
|
+
if (HEX_RE.test(v)) return 'color';
|
|
71
|
+
if (PX_RE.test(v) || UNITLESS_RE.test(v)) return 'dimension';
|
|
72
|
+
return 'typography';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Expand #RGB/#RGBA shorthand to #RRGGBB/#RRGGBBAA and split into 8-bit
|
|
77
|
+
* channels. Alpha defaults to opaque (255) when absent; `hadAlpha` records
|
|
78
|
+
* whether the source carried an alpha channel so the re-extractor can emit a
|
|
79
|
+
* 6- or 8-digit hex faithfully.
|
|
80
|
+
* @param {string} hex
|
|
81
|
+
* @returns {{r:number,g:number,b:number,a:number,hadAlpha:boolean}}
|
|
82
|
+
*/
|
|
83
|
+
function parseHexChannels(hex) {
|
|
84
|
+
let h = String(hex).trim().replace(/^#/, '');
|
|
85
|
+
let hadAlpha = false;
|
|
86
|
+
if (h.length === 3) {
|
|
87
|
+
h = h.split('').map((c) => c + c).join('');
|
|
88
|
+
} else if (h.length === 4) {
|
|
89
|
+
h = h.split('').map((c) => c + c).join('');
|
|
90
|
+
hadAlpha = true;
|
|
91
|
+
} else if (h.length === 8) {
|
|
92
|
+
hadAlpha = true;
|
|
93
|
+
}
|
|
94
|
+
const r = parseInt(h.slice(0, 2), 16);
|
|
95
|
+
const g = parseInt(h.slice(2, 4), 16);
|
|
96
|
+
const b = parseInt(h.slice(4, 6), 16);
|
|
97
|
+
const a = hadAlpha ? parseInt(h.slice(6, 8), 16) : 255;
|
|
98
|
+
return { r, g, b, a, hadAlpha };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Recover the canonical hex from 8-bit channels. Emits 6-digit #RRGGBB when
|
|
103
|
+
* the original had no alpha, 8-digit #RRGGBBAA when it did (contract: implied
|
|
104
|
+
* opaque alpha is dropped on the way back so #3B82F6 round-trips exactly).
|
|
105
|
+
* @param {number} r
|
|
106
|
+
* @param {number} g
|
|
107
|
+
* @param {number} b
|
|
108
|
+
* @param {number} a
|
|
109
|
+
* @param {boolean} hadAlpha
|
|
110
|
+
* @returns {string}
|
|
111
|
+
*/
|
|
112
|
+
function channelsToHex(r, g, b, a, hadAlpha) {
|
|
113
|
+
const h2 = (n) => (n & 0xff).toString(16).padStart(2, '0');
|
|
114
|
+
const base = `#${h2(r)}${h2(g)}${h2(b)}`;
|
|
115
|
+
return hadAlpha ? `${base}${h2(a)}` : base;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Pack channels into a Compose/Flutter 0xAARRGGBB literal (uppercase digits).
|
|
120
|
+
* @param {{r:number,g:number,b:number,a:number}} ch
|
|
121
|
+
* @returns {string} e.g. "0xFF3B82F6"
|
|
122
|
+
*/
|
|
123
|
+
function channelsToArgbLiteral({ r, g, b, a }) {
|
|
124
|
+
const h2 = (n) => (n & 0xff).toString(16).toUpperCase().padStart(2, '0');
|
|
125
|
+
return `0x${h2(a)}${h2(r)}${h2(g)}${h2(b)}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Strip a 0xAARRGGBB literal back into channels.
|
|
130
|
+
* @param {string} literal e.g. "0xFF3B82F6"
|
|
131
|
+
* @returns {{r:number,g:number,b:number,a:number}}
|
|
132
|
+
*/
|
|
133
|
+
function argbLiteralToChannels(literal) {
|
|
134
|
+
const h = String(literal).replace(/^0x/i, '').padStart(8, '0');
|
|
135
|
+
return {
|
|
136
|
+
a: parseInt(h.slice(0, 2), 16),
|
|
137
|
+
r: parseInt(h.slice(2, 4), 16),
|
|
138
|
+
g: parseInt(h.slice(4, 6), 16),
|
|
139
|
+
b: parseInt(h.slice(6, 8), 16),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* px (or unit-less) -> nearest integer, round-half-up (for pt/dp).
|
|
145
|
+
* @param {string} value
|
|
146
|
+
* @returns {number}
|
|
147
|
+
*/
|
|
148
|
+
function pxToInt(value) {
|
|
149
|
+
const n = parseFloat(String(value));
|
|
150
|
+
return Math.floor(n + 0.5);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* px (or unit-less) -> logical-px double form string for Dart (`16` -> `16.0`).
|
|
155
|
+
* @param {string} value
|
|
156
|
+
* @returns {string}
|
|
157
|
+
*/
|
|
158
|
+
function pxToDouble(value) {
|
|
159
|
+
const n = parseFloat(String(value));
|
|
160
|
+
return Number.isInteger(n) ? `${n}.0` : String(n);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Recover the canonical `Npx` string from an emitted numeric form.
|
|
165
|
+
* @param {string|number} num
|
|
166
|
+
* @returns {string}
|
|
167
|
+
*/
|
|
168
|
+
function numToPx(num) {
|
|
169
|
+
const n = parseFloat(String(num));
|
|
170
|
+
return Number.isInteger(n) ? `${n}px` : `${n}px`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* dash-case token -> camelCase Swift/Kotlin/Dart identifier.
|
|
175
|
+
* `color-primary` -> `colorPrimary`; leading digit gets a `t` prefix.
|
|
176
|
+
* @param {string} key
|
|
177
|
+
* @returns {string}
|
|
178
|
+
*/
|
|
179
|
+
function swiftIdent(key) {
|
|
180
|
+
let id = String(key).replace(/[^A-Za-z0-9]+(.)?/g, (_, c) =>
|
|
181
|
+
c ? c.toUpperCase() : '',
|
|
182
|
+
);
|
|
183
|
+
if (!id) id = 'token';
|
|
184
|
+
if (/^[0-9]/.test(id)) id = 't' + id;
|
|
185
|
+
return id;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = {
|
|
189
|
+
TOKEN_MARKER,
|
|
190
|
+
NONMAPPABLE_MARKER,
|
|
191
|
+
HEX_RE,
|
|
192
|
+
PX_RE,
|
|
193
|
+
UNITLESS_RE,
|
|
194
|
+
NONMAPPABLE_RE,
|
|
195
|
+
readTokens,
|
|
196
|
+
sortedEntries,
|
|
197
|
+
classify,
|
|
198
|
+
parseHexChannels,
|
|
199
|
+
channelsToHex,
|
|
200
|
+
channelsToArgbLiteral,
|
|
201
|
+
argbLiteralToChannels,
|
|
202
|
+
pxToInt,
|
|
203
|
+
pxToDouble,
|
|
204
|
+
numToPx,
|
|
205
|
+
swiftIdent,
|
|
206
|
+
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design-tokens/compose.cjs — Jetpack Compose (Kotlin) theme emitter +
|
|
3
|
+
* re-extractor (Phase 34.1 Plan 01).
|
|
4
|
+
*
|
|
5
|
+
* Consumes the canonical Phase-23 token map ({ tokens: Record<string,string> })
|
|
6
|
+
* and emits a deterministic Kotlin theme source string: `Color` vals, a
|
|
7
|
+
* `Shapes` block (radius tokens), a `Typography` block (font/text tokens), and
|
|
8
|
+
* a `MaterialTheme` wiring object — per the PRECISION CONTRACT in
|
|
9
|
+
* reference/native-platforms.md.
|
|
10
|
+
*
|
|
11
|
+
* Precision contract:
|
|
12
|
+
* COLOR hex -> Color(0xAARRGGBB) long. 8-bit channels EXACT; #RGB
|
|
13
|
+
* expands; alpha 0xFF when absent.
|
|
14
|
+
* DIMENSION Npx -> N.dp (integer dp, round-half-up).
|
|
15
|
+
* TYPOGRAPHY strings -> Kotlin string literal, pass-through.
|
|
16
|
+
* NON-MAPPABLE var()/calc()/gradient/rem/em -> verbatim comment, EXCLUDED.
|
|
17
|
+
*
|
|
18
|
+
* Each emitted token line carries a `// token: <original-key>` marker so the
|
|
19
|
+
* symmetric `reextractCompose(source)` recovers the exact canonical key+value.
|
|
20
|
+
* Pure: no fs, no network, no Date, no process.env, no child_process (D-10).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const {
|
|
26
|
+
TOKEN_MARKER,
|
|
27
|
+
NONMAPPABLE_MARKER,
|
|
28
|
+
readTokens,
|
|
29
|
+
sortedEntries,
|
|
30
|
+
classify,
|
|
31
|
+
parseHexChannels,
|
|
32
|
+
channelsToArgbLiteral,
|
|
33
|
+
argbLiteralToChannels,
|
|
34
|
+
channelsToHex,
|
|
35
|
+
pxToInt,
|
|
36
|
+
swiftIdent,
|
|
37
|
+
} = require('./_native-shared.cjs');
|
|
38
|
+
|
|
39
|
+
const RADIUS_RE = /(^|[^a-z])radius/i;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Emit a Jetpack Compose / Kotlin theme source string from a token set.
|
|
43
|
+
*
|
|
44
|
+
* @param {{tokens: Record<string,string>}|Record<string,string>} tokenSet
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
function emitCompose(tokenSet) {
|
|
48
|
+
const tokens = readTokens(tokenSet);
|
|
49
|
+
const entries = sortedEntries(tokens);
|
|
50
|
+
const lines = [];
|
|
51
|
+
lines.push('// Generated by get-design-done — Jetpack Compose theme tokens.');
|
|
52
|
+
lines.push('// See reference/native-platforms.md (token-bridge precision contract).');
|
|
53
|
+
lines.push('import androidx.compose.ui.graphics.Color');
|
|
54
|
+
lines.push('import androidx.compose.ui.unit.dp');
|
|
55
|
+
lines.push('import androidx.compose.material3.Shapes');
|
|
56
|
+
lines.push('import androidx.compose.material3.Typography');
|
|
57
|
+
lines.push('import androidx.compose.foundation.shape.RoundedCornerShape');
|
|
58
|
+
lines.push('');
|
|
59
|
+
lines.push('object GDDTheme {');
|
|
60
|
+
|
|
61
|
+
// Colors
|
|
62
|
+
for (const [key, value] of entries) {
|
|
63
|
+
if (classify(key, value) !== 'color') continue;
|
|
64
|
+
const { r, g, b, a, hadAlpha } = parseHexChannels(value);
|
|
65
|
+
lines.push(
|
|
66
|
+
` val ${swiftIdent(key)} = Color(${channelsToArgbLiteral({ r, g, b, a })}) ${TOKEN_MARKER}${key} hadAlpha=${hadAlpha ? 1 : 0}`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Dimensions (dp)
|
|
71
|
+
for (const [key, value] of entries) {
|
|
72
|
+
if (classify(key, value) !== 'dimension') continue;
|
|
73
|
+
lines.push(
|
|
74
|
+
` val ${swiftIdent(key)} = ${pxToInt(value)}.dp ${TOKEN_MARKER}${key}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Typography (strings)
|
|
79
|
+
for (const [key, value] of entries) {
|
|
80
|
+
if (classify(key, value) !== 'typography') continue;
|
|
81
|
+
lines.push(
|
|
82
|
+
` val ${swiftIdent(key)} = ${JSON.stringify(value)} ${TOKEN_MARKER}${key}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Non-mappable — verbatim, excluded from identity set
|
|
87
|
+
for (const [key, value] of entries) {
|
|
88
|
+
if (classify(key, value) !== 'non-mappable') continue;
|
|
89
|
+
lines.push(` ${NONMAPPABLE_MARKER}${key} = ${value}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Shapes block (from radius dimension tokens) — wires dp into RoundedCornerShape
|
|
93
|
+
lines.push('');
|
|
94
|
+
lines.push(' val Shapes = Shapes(');
|
|
95
|
+
for (const [key, value] of entries) {
|
|
96
|
+
if (classify(key, value) !== 'dimension') continue;
|
|
97
|
+
if (!RADIUS_RE.test(key)) continue;
|
|
98
|
+
lines.push(` // ${key}: RoundedCornerShape(${pxToInt(value)}.dp)`);
|
|
99
|
+
}
|
|
100
|
+
lines.push(' )');
|
|
101
|
+
|
|
102
|
+
// Typography block (MaterialTheme wiring) — references the font tokens above
|
|
103
|
+
lines.push(' val Typography = Typography()');
|
|
104
|
+
lines.push('}');
|
|
105
|
+
lines.push('');
|
|
106
|
+
return lines.join('\n');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const COMPOSE_COLOR_RE = new RegExp(
|
|
110
|
+
String.raw`val \w+ = Color\((0x[0-9a-fA-F]{8})\)\s*` +
|
|
111
|
+
TOKEN_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
|
|
112
|
+
String.raw`(\S+)\s+hadAlpha=([01])`,
|
|
113
|
+
);
|
|
114
|
+
const COMPOSE_DIM_RE = new RegExp(
|
|
115
|
+
String.raw`val \w+ = (\d+)\.dp\s*` +
|
|
116
|
+
TOKEN_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
|
|
117
|
+
String.raw`(\S+)`,
|
|
118
|
+
);
|
|
119
|
+
const COMPOSE_STR_RE = new RegExp(
|
|
120
|
+
String.raw`val \w+ = ("(?:[^"\\]|\\.)*")\s*` +
|
|
121
|
+
TOKEN_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
|
|
122
|
+
String.raw`(\S+)`,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Recover the canonical token map from an emitted Compose source string.
|
|
127
|
+
* Non-mappable lines and the Shapes-comment lines are NOT recovered.
|
|
128
|
+
*
|
|
129
|
+
* @param {string} source
|
|
130
|
+
* @returns {{tokens: Record<string,string>}}
|
|
131
|
+
*/
|
|
132
|
+
function reextractCompose(source) {
|
|
133
|
+
/** @type {Record<string,string>} */
|
|
134
|
+
const tokens = {};
|
|
135
|
+
for (const line of String(source).split(/\r?\n/)) {
|
|
136
|
+
if (line.includes(NONMAPPABLE_MARKER)) continue;
|
|
137
|
+
let m;
|
|
138
|
+
if ((m = COMPOSE_COLOR_RE.exec(line))) {
|
|
139
|
+
const ch = argbLiteralToChannels(m[1]);
|
|
140
|
+
tokens[m[2]] = channelsToHex(ch.r, ch.g, ch.b, ch.a, m[3] === '1');
|
|
141
|
+
} else if ((m = COMPOSE_DIM_RE.exec(line))) {
|
|
142
|
+
tokens[m[2]] = `${m[1]}px`;
|
|
143
|
+
} else if ((m = COMPOSE_STR_RE.exec(line))) {
|
|
144
|
+
tokens[m[2]] = JSON.parse(m[1]);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { tokens };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = { emitCompose, reextractCompose };
|