Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -27,7 +27,7 @@ React: install `@origin89/ui-react` from npm. Import `Reading` or `EquipmentIllu
<Reading label="Battery" reading={{ state: "current", value: 76, unit: "%" }} />
```

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.

Expand Down
8 changes: 4 additions & 4 deletions brand-provenance.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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,
Expand All @@ -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),
Expand All @@ -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),
Expand Down
6 changes: 5 additions & 1 deletion platforms/swiftui/Sources/Origin89UI/GeneratedTokens.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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),
Expand All @@ -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),
Expand All @@ -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),
Expand Down
49 changes: 49 additions & 0 deletions platforms/swiftui/Sources/Origin89UI/Loading.swift
Original file line number Diff line number Diff line change
@@ -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()
}
66 changes: 66 additions & 0 deletions platforms/swiftui/Sources/Origin89UI/Notice.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import SwiftUI

public enum Origin89NoticeTone: Sendable, CaseIterable {
case info, alarm

var color: KeyPath<Origin89Palette, Color> {
switch self {
case .info: \.info
case .alarm: \.alarm
}
}
var text: KeyPath<Origin89Palette, Color> {
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()
}
66 changes: 66 additions & 0 deletions platforms/swiftui/Sources/Origin89UI/Status.swift
Original file line number Diff line number Diff line change
@@ -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<Origin89Palette, Color> {
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()
}
22 changes: 22 additions & 0 deletions platforms/swiftui/Sources/Origin89UI/Theme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
49 changes: 49 additions & 0 deletions platforms/swiftui/Tests/Origin89UITests/ComponentTests.swift
Original file line number Diff line number Diff line change
@@ -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<Origin89Palette, Color>] = [
.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)
}
}
2 changes: 1 addition & 1 deletion platforms/swiftui/Tests/Origin89UITests/ThemeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
Loading
Loading