diff --git a/README.md b/README.md
index a54bb77..257f76a 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@ Shared interface foundations with native implementations for React, SwiftUI and
| Platform | Package | Available base |
| --- | --- | --- |
| Web | `@origin89/ui-react` | Reading, equipment illustrations, styles |
-| Apple | Swift package `Origin89UI` | Native Reading, dynamic type, fonts and light/dark tokens |
+| Apple | Swift package `Origin89UI` | Native Reading, Status, Notice, Loading, dynamic type, fonts and light/dark tokens |
| Android | `com.origin89:ui-compose` | Native Compose Reading, fonts and light/dark tokens |
All three distinguish current, stale and unknown values. Zero is a valid measurement; non-finite values display as unknown. Shared JSON examples exercise these states across platforms. This is a usable foundation, not a complete mobile application or a shared screen runtime.
@@ -27,7 +27,7 @@ React: install `@origin89/ui-react` from npm. Import `Reading` or `EquipmentIllu
```
-Swift: add `https://github.com/origin89hq/ui` as a Swift package dependency with an exact version such as `0.2.0`, link `Origin89UI`, then use `Origin89Reading`. Brand colours follow light and dark mode through `.foregroundStyle(.origin89.muted)` or `Color.origin89.surface`; fixed palettes stay available as `Origin89Tokens.light` and `.dark`. Text styles such as `.font(.origin89Label)` register the bundled fonts. Native previews include current, zero, stale and unknown examples.
+Swift: add `https://github.com/origin89hq/ui` as a Swift package dependency pinned to the latest plain `X.Y.Z` [tag](https://github.com/origin89hq/ui/tags) with `exact:`, link `Origin89UI`, then use `Origin89Reading`, `Origin89Status`, `Origin89Notice` or `Origin89Loading`. Brand colours follow light and dark mode through `.foregroundStyle(.origin89.muted)` or `Color.origin89.surface`; fixed palettes stay available as `Origin89Tokens.light` and `.dark`. Text styles such as `.font(.origin89Label)` register the bundled fonts. Native previews include current, zero, stale and unknown examples.
Android: the `platforms/compose` Gradle project builds an Android library. Run `./gradlew :ui:publishToMavenLocal` there, add `mavenLocal()` to a consuming development project and depend on `com.origin89:ui-compose:0.1.0`. Release AAR and source artifacts are also attached to GitHub releases; Maven Central publication is not configured yet.
diff --git a/brand-provenance.json b/brand-provenance.json
index f62a850..9588cee 100644
--- a/brand-provenance.json
+++ b/brand-provenance.json
@@ -1,10 +1,10 @@
{
"package": "@origin89/brand",
- "version": "0.3.1",
+ "version": "0.4.0",
"sources": [
{
"file": "brand.json",
- "sha256": "5838f019d20b187cdf344f5a49b46633056813006b9a38aeea2187a524e9cbe2"
+ "sha256": "64b268aa10e8c5da46bb1a10c5064b5178a01900afa00617c8dabfe838ce999f"
},
{
"file": "fonts/InterTight-400.ttf",
@@ -30,11 +30,11 @@
"outputs": [
{
"file": "platforms/swiftui/Sources/Origin89UI/GeneratedTokens.swift",
- "sha256": "6eada2bbe7fe83ed219d450d175457c7272d8b57068b59a9e45c882d820e1ed9"
+ "sha256": "80f37f82ee9d7702ae8ac6399da544710ad1e13c45a360fc1d5d1788b6b764d9"
},
{
"file": "platforms/compose/ui/src/main/kotlin/com/origin89/ui/GeneratedTokens.kt",
- "sha256": "4d589f53b7b4994327267add9198cc616dcf97a01e6ba42c58b883e13acb5501"
+ "sha256": "429a3727bb1a6974fbb124f403f57d5ef627569d6ab85f15558d53a2e977bac6"
},
{
"file": "platforms/swiftui/Sources/Origin89UI/Resources/InterTight-400.ttf",
diff --git a/package.json b/package.json
index 3abb8b3..194e7dd 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,7 @@
"release:publish": "changeset publish"
},
"devDependencies": {
- "@origin89/brand": "0.3.1",
+ "@origin89/brand": "0.4.0",
"@biomejs/biome": "2.5.13",
"typescript": "6.0.3",
"@changesets/cli": "3.0.2"
diff --git a/platforms/compose/ui/src/main/kotlin/com/origin89/ui/GeneratedTokens.kt b/platforms/compose/ui/src/main/kotlin/com/origin89/ui/GeneratedTokens.kt
index 8344743..fce628d 100644
--- a/platforms/compose/ui/src/main/kotlin/com/origin89/ui/GeneratedTokens.kt
+++ b/platforms/compose/ui/src/main/kotlin/com/origin89/ui/GeneratedTokens.kt
@@ -1,4 +1,4 @@
-// Generated from @origin89/brand 0.3.1; run pnpm brand:generate.
+// Generated from @origin89/brand 0.4.0; run pnpm brand:generate.
package com.origin89.ui
import androidx.compose.ui.graphics.Color
data class Origin89Palette(
@@ -15,6 +15,7 @@ data class Origin89Palette(
val actionLit: Color,
val link: Color,
val onFill: Color,
+ val signal: Color,
val nominal: Color,
val warning: Color,
val nominalDeep: Color,
@@ -38,6 +39,7 @@ object Origin89Tokens {
actionLit = Color(0xFF3F61B3),
link = Color(0xFF2B4A97),
onFill = Color(0xFFFFFFFF),
+ signal = Color(0xFF5771B2),
nominal = Color(0xFF4C7D5F),
warning = Color(0xFF9F6601),
nominalDeep = Color(0xFF4C7D5F),
@@ -60,6 +62,7 @@ object Origin89Tokens {
actionLit = Color(0xFF3F61B3),
link = Color(0xFF6279AD),
onFill = Color(0xFFFFFFFF),
+ signal = Color(0xFF7F9CE0),
nominal = Color(0xFF2F9D64),
warning = Color(0xFFE9A13C),
nominalDeep = Color(0xFF168C54),
diff --git a/platforms/swiftui/Sources/Origin89UI/GeneratedTokens.swift b/platforms/swiftui/Sources/Origin89UI/GeneratedTokens.swift
index ca715c3..88f7e1f 100644
--- a/platforms/swiftui/Sources/Origin89UI/GeneratedTokens.swift
+++ b/platforms/swiftui/Sources/Origin89UI/GeneratedTokens.swift
@@ -1,4 +1,4 @@
-// Generated from @origin89/brand 0.3.1; run pnpm brand:generate.
+// Generated from @origin89/brand 0.4.0; run pnpm brand:generate.
import SwiftUI
public struct Origin89Palette: Sendable {
public let page: Color
@@ -14,6 +14,7 @@ public struct Origin89Palette: Sendable {
public let actionLit: Color
public let link: Color
public let onFill: Color
+ public let signal: Color
public let nominal: Color
public let warning: Color
public let nominalDeep: Color
@@ -37,6 +38,7 @@ public enum Origin89Tokens {
actionLit: Color(red: 63/255.0, green: 97/255.0, blue: 179/255.0),
link: Color(red: 43/255.0, green: 74/255.0, blue: 151/255.0),
onFill: Color(red: 255/255.0, green: 255/255.0, blue: 255/255.0),
+ signal: Color(red: 87/255.0, green: 113/255.0, blue: 178/255.0),
nominal: Color(red: 76/255.0, green: 125/255.0, blue: 95/255.0),
warning: Color(red: 159/255.0, green: 102/255.0, blue: 1/255.0),
nominalDeep: Color(red: 76/255.0, green: 125/255.0, blue: 95/255.0),
@@ -59,6 +61,7 @@ public enum Origin89Tokens {
actionLit: Color(red: 63/255.0, green: 97/255.0, blue: 179/255.0),
link: Color(red: 98/255.0, green: 121/255.0, blue: 173/255.0),
onFill: Color(red: 255/255.0, green: 255/255.0, blue: 255/255.0),
+ signal: Color(red: 127/255.0, green: 156/255.0, blue: 224/255.0),
nominal: Color(red: 47/255.0, green: 157/255.0, blue: 100/255.0),
warning: Color(red: 233/255.0, green: 161/255.0, blue: 60/255.0),
nominalDeep: Color(red: 22/255.0, green: 140/255.0, blue: 84/255.0),
@@ -82,6 +85,7 @@ public enum Origin89Tokens {
actionLit: Color(light: light.actionLit, dark: dark.actionLit),
link: Color(light: light.link, dark: dark.link),
onFill: Color(light: light.onFill, dark: dark.onFill),
+ signal: Color(light: light.signal, dark: dark.signal),
nominal: Color(light: light.nominal, dark: dark.nominal),
warning: Color(light: light.warning, dark: dark.warning),
nominalDeep: Color(light: light.nominalDeep, dark: dark.nominalDeep),
diff --git a/platforms/swiftui/Sources/Origin89UI/Loading.swift b/platforms/swiftui/Sources/Origin89UI/Loading.swift
new file mode 100644
index 0000000..3477507
--- /dev/null
+++ b/platforms/swiftui/Sources/Origin89UI/Loading.swift
@@ -0,0 +1,49 @@
+import SwiftUI
+
+/// Work in progress. The bars are the shape of what is coming rather than a bare spinner, and
+/// both the spinner and the bars stop under Reduce Motion.
+public struct Origin89Loading: View {
+ public let label: String
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ @State private var pulse = false
+ @State private var spin = false
+ public init(_ label: String = "Loading\u{2026}") {
+ self.label = label
+ }
+ public var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ HStack(spacing: 10) {
+ ZStack {
+ Circle().strokeBorder(.origin89.lineStrong, lineWidth: 2)
+ Circle().inset(by: 1).trim(from: 0, to: 0.25).stroke(.origin89.signal, lineWidth: 2)
+ }
+ .frame(width: 13, height: 13)
+ .rotationEffect(.degrees(spin ? 360 : 0))
+ .accessibilityHidden(true)
+ Text(label).font(.origin89Label).foregroundStyle(.origin89.muted)
+ }
+ .accessibilityElement(children: .combine)
+ VStack(alignment: .leading, spacing: 16) {
+ ForEach(0..<4, id: \.self) { bar in
+ GeometryReader { proxy in
+ Rectangle().fill(.origin89.line)
+ .frame(width: bar.isMultiple(of: 2) ? proxy.size.width : proxy.size.width * 0.8)
+ }
+ .frame(height: 32)
+ }
+ }
+ .opacity(reduceMotion ? 0.5 : (pulse ? 0.7 : 0.35))
+ .accessibilityHidden(true)
+ }
+ .padding(.vertical, 24)
+ .onAppear {
+ guard !reduceMotion else { return }
+ withAnimation(.easeInOut(duration: 1.6).repeatForever(autoreverses: true)) { pulse = true }
+ withAnimation(.linear(duration: 0.9).repeatForever(autoreverses: false)) { spin = true }
+ }
+ }
+}
+
+#Preview("Loading") {
+ Origin89Loading("Reading the network section\u{2026}").padding()
+}
diff --git a/platforms/swiftui/Sources/Origin89UI/Notice.swift b/platforms/swiftui/Sources/Origin89UI/Notice.swift
new file mode 100644
index 0000000..f7c6889
--- /dev/null
+++ b/platforms/swiftui/Sources/Origin89UI/Notice.swift
@@ -0,0 +1,66 @@
+import SwiftUI
+
+public enum Origin89NoticeTone: Sendable, CaseIterable {
+ case info, alarm
+
+ var color: KeyPath {
+ switch self {
+ case .info: \.info
+ case .alarm: \.alarm
+ }
+ }
+ var text: KeyPath {
+ switch self {
+ case .info: \.muted
+ case .alarm: \.alarm
+ }
+ }
+ /// An alarm announces itself; an info notice waits to be read, so VoiceOver is not interrupted.
+ var announces: Bool {
+ switch self {
+ case .info: false
+ case .alarm: true
+ }
+ }
+}
+
+/// A line of consequence above the thing it concerns, such as a refusal from the controller.
+public struct Origin89Notice: View {
+ public let message: String
+ public let tone: Origin89NoticeTone
+ public init(_ message: String, tone: Origin89NoticeTone = .info) {
+ self.message = message
+ self.tone = tone
+ }
+ public var body: some View {
+ let shape = BevelRectangle(corner: 10)
+ Text(message)
+ .font(.origin89Label)
+ .lineSpacing(4)
+ .foregroundStyle(Origin89Tokens.adaptive[keyPath: tone.text])
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.vertical, 14)
+ .padding(.horizontal, 16)
+ .background(.origin89.surface)
+ .overlay(alignment: .leading) {
+ Rectangle().fill(Origin89Tokens.adaptive[keyPath: tone.color]).frame(width: 3)
+ }
+ .clipShape(shape)
+ .overlay { shape.strokeBorder(.origin89.line, lineWidth: 1) }
+ .accessibilityElement(children: .combine)
+ .onAppear(perform: announce)
+ .onChange(of: message) { announce() }
+ }
+ private func announce() {
+ guard tone.announces else { return }
+ AccessibilityNotification.Announcement(message).post()
+ }
+}
+
+#Preview("Tones") {
+ VStack(spacing: 12) {
+ Origin89Notice("Open the pairing window at the panel, then pair within 120 seconds.")
+ Origin89Notice("The controller refused the proof. Check the QR code and try again.", tone: .alarm)
+ }.padding()
+}
diff --git a/platforms/swiftui/Sources/Origin89UI/Status.swift b/platforms/swiftui/Sources/Origin89UI/Status.swift
new file mode 100644
index 0000000..8a44050
--- /dev/null
+++ b/platforms/swiftui/Sources/Origin89UI/Status.swift
@@ -0,0 +1,66 @@
+import SwiftUI
+
+/// How a status chip looks. The tone is the presentation, not the meaning: the application maps
+/// its own states onto these. Each tone pairs its hue with a border style and a mark, so the chip
+/// still reads without colour.
+public enum Origin89StatusTone: Sendable, CaseIterable {
+ case nominal, alarm, warning, info, faint
+
+ var mark: String {
+ switch self {
+ case .nominal: "\u{2713}"
+ case .alarm: "!"
+ case .warning, .info, .faint: "\u{2022}"
+ }
+ }
+ var color: KeyPath {
+ switch self {
+ case .nominal: \.nominal
+ case .alarm: \.alarm
+ case .warning: \.warning
+ case .info: \.info
+ case .faint: \.faint
+ }
+ }
+ var border: StrokeStyle {
+ switch self {
+ case .nominal, .info: StrokeStyle(lineWidth: 1)
+ case .alarm: StrokeStyle(lineWidth: 2)
+ case .warning: StrokeStyle(lineWidth: 1, dash: [4, 3])
+ case .faint: StrokeStyle(lineWidth: 1, lineCap: .round, dash: [0.5, 2.5])
+ }
+ }
+}
+
+/// A state chip, such as a setup step that is pending, running, done or refused.
+public struct Origin89Status: View {
+ public let title: String
+ public let tone: Origin89StatusTone
+ public init(_ title: String, tone: Origin89StatusTone = .faint) {
+ self.title = title
+ self.tone = tone
+ }
+ public var body: some View {
+ HStack(spacing: 6) {
+ Text(tone.mark).accessibilityHidden(true)
+ Text(title)
+ }
+ .font(.origin89Data)
+ .lineLimit(1)
+ .padding(.vertical, 3)
+ .padding(.horizontal, 8)
+ .overlay { Rectangle().strokeBorder(style: tone.border) }
+ .foregroundStyle(Origin89Tokens.adaptive[keyPath: tone.color])
+ .accessibilityElement(children: .combine)
+ }
+}
+
+#Preview("Tones") {
+ VStack(alignment: .leading, spacing: 12) {
+ Origin89Status("Paired", tone: .nominal)
+ Origin89Status("Wrong proof", tone: .alarm)
+ Origin89Status("Window closing", tone: .warning)
+ Origin89Status("Pairing", tone: .info)
+ Origin89Status("Not started")
+ }.padding()
+}
diff --git a/platforms/swiftui/Sources/Origin89UI/Theme.swift b/platforms/swiftui/Sources/Origin89UI/Theme.swift
index 1d70956..0d3d8d9 100644
--- a/platforms/swiftui/Sources/Origin89UI/Theme.swift
+++ b/platforms/swiftui/Sources/Origin89UI/Theme.swift
@@ -49,3 +49,25 @@ public extension Font {
return .custom(name, size: size, relativeTo: style)
}
}
+
+/// The brand's bevelled corner: a straight cut `corner` points along each edge.
+struct BevelRectangle: InsettableShape {
+ var corner: CGFloat
+ var inset: CGFloat = 0
+ func path(in rect: CGRect) -> Path {
+ let r = rect.insetBy(dx: inset, dy: inset)
+ let c = max(0, min(corner - inset, r.width / 2, r.height / 2))
+ var path = Path()
+ path.move(to: CGPoint(x: r.minX + c, y: r.minY))
+ path.addLine(to: CGPoint(x: r.maxX - c, y: r.minY))
+ path.addLine(to: CGPoint(x: r.maxX, y: r.minY + c))
+ path.addLine(to: CGPoint(x: r.maxX, y: r.maxY - c))
+ path.addLine(to: CGPoint(x: r.maxX - c, y: r.maxY))
+ path.addLine(to: CGPoint(x: r.minX + c, y: r.maxY))
+ path.addLine(to: CGPoint(x: r.minX, y: r.maxY - c))
+ path.addLine(to: CGPoint(x: r.minX, y: r.minY + c))
+ path.closeSubpath()
+ return path
+ }
+ func inset(by amount: CGFloat) -> BevelRectangle { BevelRectangle(corner: corner, inset: inset + amount) }
+}
diff --git a/platforms/swiftui/Tests/Origin89UITests/ComponentTests.swift b/platforms/swiftui/Tests/Origin89UITests/ComponentTests.swift
new file mode 100644
index 0000000..978e2e8
--- /dev/null
+++ b/platforms/swiftui/Tests/Origin89UITests/ComponentTests.swift
@@ -0,0 +1,49 @@
+import SwiftUI
+import Testing
+@testable import Origin89UI
+
+@Test func statusTonesStayDistinctWithoutColour() {
+ // Mark and border together must tell every tone apart, as they do on the web.
+ let looks = Origin89StatusTone.allCases.map { "\($0.mark)|\($0.border.lineWidth)|\($0.border.dash)" }
+ #expect(Set(looks).count == Origin89StatusTone.allCases.count)
+ #expect(Origin89StatusTone.nominal.mark == "\u{2713}")
+ #expect(Origin89StatusTone.alarm.mark == "!")
+ #expect(Origin89StatusTone.alarm.border.lineWidth == 2)
+}
+@Test func statusTonesUseTheirBrandColour() {
+ let expected: [Origin89StatusTone: KeyPath] = [
+ .nominal: \.nominal, .alarm: \.alarm, .warning: \.warning, .info: \.info, .faint: \.faint,
+ ]
+ for tone in Origin89StatusTone.allCases { #expect(tone.color == expected[tone], "\(tone)") }
+}
+@Test func onlyAlarmNoticesAnnounce() {
+ #expect(Origin89NoticeTone.alarm.announces)
+ #expect(!Origin89NoticeTone.info.announces)
+ #expect(Origin89NoticeTone.alarm.text == \Origin89Palette.alarm)
+ #expect(Origin89NoticeTone.info.text == \Origin89Palette.muted)
+}
+@MainActor @Test func defaultsMatchTheWeb() {
+ #expect(Origin89Status("Pending").tone == .faint)
+ #expect(Origin89Notice("Note").tone == .info)
+ #expect(Origin89Loading().label == "Loading\u{2026}")
+}
+@Test func bevelCutsEveryCornerAndClampsToSmallRects() {
+ let square = BevelRectangle(corner: 10).path(in: CGRect(x: 0, y: 0, width: 100, height: 40))
+ #expect(!square.contains(CGPoint(x: 1, y: 1)))
+ #expect(!square.contains(CGPoint(x: 99, y: 39)))
+ #expect(square.contains(CGPoint(x: 50, y: 20)))
+ let tiny = BevelRectangle(corner: 10).path(in: CGRect(x: 0, y: 0, width: 6, height: 6))
+ #expect(tiny.contains(CGPoint(x: 3, y: 3)))
+ #expect(BevelRectangle(corner: 10).inset(by: 12).path(in: CGRect(x: 0, y: 0, width: 100, height: 40)).contains(CGPoint(x: 50, y: 20)))
+}
+@MainActor @Test(arguments: [ColorScheme.light, .dark])
+func componentsRenderInBothSchemes(scheme: ColorScheme) throws {
+ let views: [AnyView] = Origin89StatusTone.allCases.map { AnyView(Origin89Status("Paired", tone: $0)) }
+ + Origin89NoticeTone.allCases.map { AnyView(Origin89Notice("", tone: $0)) }
+ + [AnyView(Origin89Notice(String(repeating: "Wrong proof. ", count: 40), tone: .alarm)), AnyView(Origin89Loading(""))]
+ for view in views {
+ let renderer = ImageRenderer(content: view.frame(width: 320).environment(\.colorScheme, scheme))
+ let image = try #require(renderer.cgImage)
+ #expect(image.width > 0 && image.height > 0)
+ }
+}
diff --git a/platforms/swiftui/Tests/Origin89UITests/ThemeTests.swift b/platforms/swiftui/Tests/Origin89UITests/ThemeTests.swift
index bd469ca..40d5190 100644
--- a/platforms/swiftui/Tests/Origin89UITests/ThemeTests.swift
+++ b/platforms/swiftui/Tests/Origin89UITests/ThemeTests.swift
@@ -24,7 +24,7 @@ private func same(_ a: Color.Resolved, _ b: Color.Resolved) -> Bool {
@MainActor func adaptiveColorsFollowTheScheme(scheme: ColorScheme) {
let expected = colors(scheme == .dark ? Origin89Tokens.dark : Origin89Tokens.light)
let adaptive = colors(Origin89Tokens.adaptive)
- #expect(adaptive.count == 20)
+ #expect(adaptive.count == 21)
#expect(adaptive.map(\.0) == expected.map(\.0))
for ((name, color), (_, fixed)) in zip(adaptive, expected) {
#expect(same(resolved(color, in: scheme), resolved(fixed, in: scheme)), "\(name) in \(scheme)")
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5bbeeb9..bcf7007 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -116,8 +116,8 @@ importers:
specifier: 3.0.2
version: 3.0.2
'@origin89/brand':
- specifier: 0.3.1
- version: 0.3.1
+ specifier: 0.4.0
+ version: 0.4.0
typescript:
specifier: 6.0.3
version: 6.0.3
@@ -281,10 +281,6 @@ packages:
resolution: {integrity: sha512-6QEf6yqFbETdwGITKq57aYoPfX/3K8XFNwsAlx0C1M7o8cb79sv1M3w+tWuWvIcSbNqrLF7OD7YpZMVVz335hQ==}
engines: {node: '>=20.0.0'}
- '@origin89/brand@0.3.1':
- resolution: {integrity: sha512-H0d49I0nWyYmq9CPr/4e8/lo1yRUYyaEMhqqNxxvM5xvk3ujnnvSZP02t1Zp7ncCjwRW4ihVzPmL5RXIwxP13Q==}
- engines: {node: '>=22.12.0'}
-
'@origin89/brand@0.4.0':
resolution: {integrity: sha512-vX9eehWsu+7J81k685wKlR+G+VtyfMnk0bk9JVM1YNfCWtD0hQLe8fC+xGzLTu2kFJUQWFW3qWh3uwNhS7sbBQ==}
engines: {node: '>=22.12.0'}
@@ -562,8 +558,6 @@ snapshots:
tinyglobby: 0.2.17
yaml: 2.9.1
- '@origin89/brand@0.3.1': {}
-
'@origin89/brand@0.4.0': {}
'@pnpm/deps.graph-sequencer@1100.0.1': {}
diff --git a/scripts/generate-brand.mjs b/scripts/generate-brand.mjs
index 335d3f4..7a3cf39 100644
--- a/scripts/generate-brand.mjs
+++ b/scripts/generate-brand.mjs
@@ -7,7 +7,7 @@ const brandRoot = new URL("node_modules/@origin89/brand/", root);
const brandBytes = await readFile(new URL("brand.json", brandRoot));
const brand = JSON.parse(brandBytes);
const version = JSON.parse(await readFile(new URL("package.json", brandRoot), "utf8")).version;
-assert.equal(version, "0.3.1");
+assert.equal(version, "0.4.0");
const outputs = new Map();
const camel = (value) => value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
const keys = Object.keys(brand.colour.themes.light);