diff --git a/CLAUDE.md b/CLAUDE.md
index 8d11e7c..abf14bb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -87,7 +87,7 @@ native, and smooth enough to impress.
- **Modern Swift.** Swift 6 strict concurrency, `@Observable`,
async/await and structured concurrency. No Combine, no GCD, no
`ObservableObject`, and no UIKit except the camera. Colours live in
- asset catalogs, not `UIColor`.
+ one asset catalog, not in code (see The look).
- **iOS 26's design.** System components (`List`, `Form`,
`NavigationStack`, system button styles), so Liquid Glass appears where
the system puts it. SF Symbols with symbol effects.
@@ -125,20 +125,101 @@ native, and smooth enough to impress.
- **Evidence for feel work:** a screen recording, and an Animation Hitches
measurement from a device.
+## The look: Phosphor
+
+Fredrik chose this look for the polish release on 13 Oct 2026. Read it
+before you change a colour, a font or a mark.
+
+His standing rules from that day outlive the release:
+
+1. The phone keeps its own cache of the whole home and opens on it. FTW
+ Cloud only speeds things up and holds state; the app works when the
+ cloud is gone. Robust, fast, smooth.
+2. The fastest live path wins. Which path does not matter, only that it is
+ fast. Push, never poll.
+3. Freshness has no ticking seconds. A quiet mark says live the moment you
+ lift the phone: light means live.
+4. "FTW in control" is a small mark: green when FTW controls, a warning
+ when it does not. One tap gives the full detail, as the box's own page
+ does. Never a big banner.
+5. Charts are never angular and never hairlines; they have volume. On/off
+ loads (0 ↔ 1 kW) draw as bucket means with a range, and the scrub still
+ gives exact values.
+6. The flow on Now is small and smooth: continuous light, not jumping dots.
+7. TUI and retro are welcome, never bloated: Swedish simplicity.
+
+The rules below put these into practice.
+
+- **Light means live.** Only a reading the box has confirmed live glows or
+ moves: a live carrier (relay, WebRTC or graded-live FTW Cloud) and
+ `srcState` live. The cache, the past, the plan and a quiet stream are
+ ink and hold still. Light mode has no glow; motion and full colour alone
+ say live. Every glow call takes `isLive`, and a test proves that a cache
+ never glows.
+- **Three tenses, one grammar.** A glowing line is now. A solid line with a
+ filled body is what the box measured. A ghost (low fill, thin cap, dashed
+ line) is what it plans or forecasts. A hatch means "on its way"; a dotted
+ baseline means the box confirms nothing was there. Bars only where a
+ value holds across its slot (price, plan). No other marks.
+- **Colour is energy; the interface is ink.** One hue per kind: `solar`;
+ the battery's `gives` (gold) and `stores` (cyan); `grid`, magenta both
+ ways, where the side of zero and the word say buying or selling; `car`
+ (violet). The home is ink. `live` and `alarm` are states, never levels.
+ A price is ink by its level, never red and green. No orange anywhere;
+ the system tint is ink.
+- **Space, not boxes.** No cards and no borders: 24 to 32 pt between
+ groups, and a hairline only where two lists meet. `Card` keeps its
+ spacing and draws nothing.
+- **Type.** Figures are SF Pro with even digits (`Theme.figure`), never
+ mono. `Theme.mono` (SF Mono) is the terminal layer alone: the status
+ line, kickers, axis labels, footers, receipts and the scrub tag. Sizes
+ follow Dynamic Type; mono caps stop growing at accessibility1.
+- **Six TUI details, no more.** A pull request that adds a seventh names
+ the reading it carries, or it goes. Out: scanlines, glitch, grain,
+ braille dots, typed-in text and any pulsing icon.
+
+ | Detail | Where | The reading it carries |
+ |---|---|---|
+ | Mono status line, `● LIVE · FTW IN CONTROL` | Under every title | Whether this is now, and who controls the home |
+ | 8-level block price bars (▁▂▃▄▅▆▇█) | Now's strip, dial ticks, lock-screen widget | Price per slot at a glance |
+ | Lock-on: corner brackets and an inverse-video tag | Under a finger, on a pressed node | What the finger is on, and its value |
+ | 10-cell meter | Battery and car level | Level in tenths, readable at 4 pt |
+ | Receipt log `SENT ▸ ACCEPTED ▸ MEASURED ▸ CONFIRMED` | Car commands | How far the box has confirmed a command |
+ | Phosphor ignite and decay | Beams, the trace's head, the status dot | When live starts and when it stops |
+
+- **One palette source.** The roles live in `appleApp/FTWCharts/Theme.xcassets`,
+ in Display P3, one colour set per role with its dark variant: `ground`,
+ `raised`, `hairline`; `ink`, `ink2`, `ink3`; `solar`, `gives`, `stores`,
+ `grid`, `car`; `live`, `alarm`. The app and the widgets both build that
+ folder, and `Theme` in `FTWCharts/Palette.swift` names them for both.
+ `ThemeTests` holds the contrast (text 4.5:1 on ground and raised; energy
+ 4.5:1 in light and 7:1 in dark) and fails on any colour written in code.
+ Increase Contrast turns ink3 into ink2 and hairlines into 2 px; Reduce
+ Transparency drops the glow.
+- The 0.3 names in `FTW/UI/Theme.swift` (`fg`, `surface`, `accent` and the
+ rest) point at these roles until each screen is rebuilt. New code reads
+ the roles.
+- The app's palette leaves the web tokens (`ftw-webapp/src/styles/tokens.css`
+ and the box's `theme.css`) for now. The role names stay, so the web and
+ the box can follow in paired PRs.
+
## Design references
-Borrow patterns from the best iOS apps, never their graphics or brands.
+Borrow patterns from the best iOS apps, and from terminals and instruments,
+never their graphics or brands.
| Pattern | From | Where in FTW |
|---|---|---|
| Scrub a chart: the header gives way to the value under the finger, and comes back on release | Stocks | Every chart: price, node history, ahead |
-| Hourly strip with a now marker and a soft band for a range | Weather | Price chart, plan lanes |
-| Bars and rings that fill with a spring; one colour per metric | Fitness | Battery and car levels, one colour pair per node |
-| Big state, three fixed controls, receipt under them | Tesla, Rivian | The car view's Plan · Charge now · Pause bar |
-| A plain "what happens next" timeline with times | Flighty | Plan card, plan view, plan widget |
-| Price as a line coloured by level, with the cheap window marked | Tibber, Octopus | Price chart |
+| Hourly strip with a now marker | Weather | Now's plan strip, the price chart |
+| A level that fills with a spring, one hue per kind | Fitness | Battery and car levels, as the 10-cell meter |
+| Big state, three fixed controls, receipt under them | Tesla, Rivian | The car view's Plan · Charge now · Pause bar and its receipt log |
+| A plain "what happens next" timeline with times | Flighty | Now's plan sentence, plan view, plan widget |
+| Price as bars by level, with the cheap window marked | Tibber, Octopus | Price chart: ink bars by thirds and a cheapest bracket, never red and green |
| Calm lists, one prominent action, a quiet second one | Things, Apple Home | Pairing, mode sheet |
| Liquid Glass only where the system puts it (bars, sheets, buttons) | iOS 26 | Toolbars, sheets, glass buttons; no custom glass panels |
+| A trace that burns at its head and fades when the signal stops | Oscilloscopes, CRT terminals | Beams on Now, the live trace, the status dot |
+| One mono status line, and an inverse-video tag for what is selected | Terminal tools | The status line under each title, the scrub tag |
## Non-negotiable invariants
diff --git a/appleApp/FTW/About/AboutView.swift b/appleApp/FTW/About/AboutView.swift
index 1c7f2b2..87678dd 100644
--- a/appleApp/FTW/About/AboutView.swift
+++ b/appleApp/FTW/About/AboutView.swift
@@ -115,7 +115,7 @@ struct AboutView: View {
Section(AboutWords.text("This phone")) {
NavigationLink(value: Route.remove) {
Text(AboutWords.text("Remove this phone"))
- .foregroundStyle(Theme.importing)
+ .foregroundStyle(Theme.alarm)
}
}
}
diff --git a/appleApp/FTW/About/LicenseView.swift b/appleApp/FTW/About/LicenseView.swift
index 716eb53..b9bbba4 100644
--- a/appleApp/FTW/About/LicenseView.swift
+++ b/appleApp/FTW/About/LicenseView.swift
@@ -22,7 +22,7 @@ struct LicenseView: View {
LazyVStack(alignment: .leading, spacing: 12) {
if raw == nil {
Text(AboutWords.text("This text is missing from this build."))
- .foregroundStyle(Theme.importing)
+ .foregroundStyle(Theme.alarm)
}
ForEach(blocks) { block in
row(block)
diff --git a/appleApp/FTW/AppIcon.icon/Assets/bolt.svg b/appleApp/FTW/AppIcon.icon/Assets/bolt.svg
index 54e730e..e2ef068 100644
--- a/appleApp/FTW/AppIcon.icon/Assets/bolt.svg
+++ b/appleApp/FTW/AppIcon.icon/Assets/bolt.svg
@@ -1,4 +1,4 @@
diff --git a/appleApp/FTW/AppIcon.icon/icon.json b/appleApp/FTW/AppIcon.icon/icon.json
index d018b86..d0f7142 100644
--- a/appleApp/FTW/AppIcon.icon/icon.json
+++ b/appleApp/FTW/AppIcon.icon/icon.json
@@ -2,13 +2,13 @@
"fill-specializations" : [
{
"value" : {
- "solid" : "srgb:0.05098,0.05098,0.05098,1.00000"
+ "solid" : "display-p3:0.01961,0.03137,0.05882,1.00000"
}
},
{
"appearance" : "dark",
"value" : {
- "solid" : "srgb:0.00000,0.00000,0.00000,1.00000"
+ "solid" : "display-p3:0.01961,0.03137,0.05882,1.00000"
}
}
],
diff --git a/appleApp/FTW/Assets.xcassets/AccentColor.colorset/Contents.json b/appleApp/FTW/Assets.xcassets/AccentColor.colorset/Contents.json
index fdca332..4135819 100644
--- a/appleApp/FTW/Assets.xcassets/AccentColor.colorset/Contents.json
+++ b/appleApp/FTW/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -1,19 +1,18 @@
{
"colors": [
{
- "idiom": "universal",
"color": {
- "color-space": "srgb",
+ "color-space": "display-p3",
"components": {
- "red": "0xDA",
- "green": "0x7F",
- "blue": "0x00",
- "alpha": "1.000"
+ "alpha": "1.000",
+ "red": "0x0A",
+ "green": "0x0F",
+ "blue": "0x1A"
}
- }
+ },
+ "idiom": "universal"
},
{
- "idiom": "universal",
"appearances": [
{
"appearance": "luminosity",
@@ -21,18 +20,19 @@
}
],
"color": {
- "color-space": "srgb",
+ "color-space": "display-p3",
"components": {
- "red": "0xFF",
- "green": "0xAC",
- "blue": "0x41",
- "alpha": "1.000"
+ "alpha": "1.000",
+ "red": "0xEE",
+ "green": "0xF3",
+ "blue": "0xFF"
}
- }
+ },
+ "idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
-}
\ No newline at end of file
+}
diff --git a/appleApp/FTW/Assets.xcassets/LaunchBackground.colorset/Contents.json b/appleApp/FTW/Assets.xcassets/LaunchBackground.colorset/Contents.json
index 4cd9661..7371bbd 100644
--- a/appleApp/FTW/Assets.xcassets/LaunchBackground.colorset/Contents.json
+++ b/appleApp/FTW/Assets.xcassets/LaunchBackground.colorset/Contents.json
@@ -1,38 +1,38 @@
{
- "colors" : [
+ "colors": [
{
- "color" : {
- "color-space" : "srgb",
- "components" : {
- "alpha" : "1.000",
- "blue" : "0xF2",
- "green" : "0xF4",
- "red" : "0xF4"
+ "color": {
+ "color-space": "display-p3",
+ "components": {
+ "alpha": "1.000",
+ "red": "0xF5",
+ "green": "0xF6",
+ "blue": "0xF8"
}
},
- "idiom" : "universal"
+ "idiom": "universal"
},
{
- "appearances" : [
+ "appearances": [
{
- "appearance" : "luminosity",
- "value" : "dark"
+ "appearance": "luminosity",
+ "value": "dark"
}
],
- "color" : {
- "color-space" : "srgb",
- "components" : {
- "alpha" : "1.000",
- "blue" : "0x0D",
- "green" : "0x0D",
- "red" : "0x0D"
+ "color": {
+ "color-space": "display-p3",
+ "components": {
+ "alpha": "1.000",
+ "red": "0x05",
+ "green": "0x08",
+ "blue": "0x0F"
}
},
- "idiom" : "universal"
+ "idiom": "universal"
}
],
- "info" : {
- "author" : "xcode",
- "version" : 1
+ "info": {
+ "author": "xcode",
+ "version": 1
}
}
diff --git a/appleApp/FTW/Car/CarView.swift b/appleApp/FTW/Car/CarView.swift
index 391d6c7..1a5b53c 100644
--- a/appleApp/FTW/Car/CarView.swift
+++ b/appleApp/FTW/Car/CarView.swift
@@ -85,7 +85,7 @@ private struct StatusSection: View {
Hint(session).contentTransition(.numericText())
}
if let note {
- Hint(note.text, tone: note.warn ? Theme.generation : Theme.fgMuted)
+ Hint(note.text, tone: note.warn ? Theme.alarm : Theme.fgMuted)
}
}
.padding(.vertical, 4)
@@ -175,7 +175,7 @@ private struct LevelSection: View {
.foregroundStyle(Theme.onAccent)
.padding(.horizontal, 8)
.padding(.vertical, 3)
- .background(Theme.generation, in: Capsule())
+ .background(Theme.ink2, in: Capsule())
.fixedSize()
}
}
@@ -296,7 +296,7 @@ private struct GoalSection: View {
}
}
if let save = model.goalSave[lp.id], !editing {
- Hint(LoadpointsModel.goalText(save), tone: { if case .failed = save { return Theme.importing }; return Theme.fgDim }())
+ Hint(LoadpointsModel.goalText(save), tone: { if case .failed = save { return Theme.alarm }; return Theme.fgDim }())
}
} header: {
Text(L("Goal"))
@@ -325,7 +325,7 @@ struct Readiness: View {
.fixedSize(horizontal: false, vertical: true)
} icon: {
Image(systemName: short ? "exclamationmark.triangle.fill" : "checkmark.circle.fill")
- .foregroundStyle(short ? Theme.generation : Theme.exporting)
+ .foregroundStyle(short ? Theme.alarm : Theme.live)
.contentTransition(.symbolEffect(.replace))
}
.accessibilityElement(children: .combine)
@@ -584,19 +584,19 @@ struct ReceiptView: View {
private func tint(_ state: EVText.Outcome.Mark) -> Color {
switch state {
- case .done: return Theme.exporting
+ case .done: return Theme.live
case .active: return Theme.accent
case .waiting: return Theme.fgMuted
- case .failed: return Theme.importing
+ case .failed: return Theme.alarm
}
}
private var color: Color {
switch outcome.tone {
case .working: return Theme.fgDim
- case .done: return Theme.exporting
- case .warning: return Theme.generation
- case .failed: return Theme.importing
+ case .done: return Theme.live
+ case .warning: return Theme.alarm
+ case .failed: return Theme.alarm
}
}
}
diff --git a/appleApp/FTW/Car/GoalEditor.swift b/appleApp/FTW/Car/GoalEditor.swift
index dc63242..d8c8795 100644
--- a/appleApp/FTW/Car/GoalEditor.swift
+++ b/appleApp/FTW/Car/GoalEditor.swift
@@ -115,7 +115,7 @@ struct GoalEditor: View {
Section {
Text(preview).font(.subheadline).foregroundStyle(Theme.fgDim)
if let failure {
- Text(failure).font(.subheadline).foregroundStyle(Theme.importing)
+ Text(failure).font(.subheadline).foregroundStyle(Theme.alarm)
}
}
if lp.schedule != nil {
diff --git a/appleApp/FTW/Cloud/CloudConsentView.swift b/appleApp/FTW/Cloud/CloudConsentView.swift
index 05475a1..36616ab 100644
--- a/appleApp/FTW/Cloud/CloudConsentView.swift
+++ b/appleApp/FTW/Cloud/CloudConsentView.swift
@@ -38,6 +38,7 @@ struct CloudConsentView: View {
case .owner, .ownerLater, .unknown:
Section {
Toggle(CloudWords.text("Contribute to statistics"), isOn: $consent.contributeStats)
+ .tint(Theme.live)
} footer: {
Text(CloudWords.text("Hourly energy totals, without your name or address, help make FTW better. Off unless you turn it on."))
}
diff --git a/appleApp/FTW/Cloud/CloudSettingsView.swift b/appleApp/FTW/Cloud/CloudSettingsView.swift
index 2c6ef90..83dbe1c 100644
--- a/appleApp/FTW/Cloud/CloudSettingsView.swift
+++ b/appleApp/FTW/Cloud/CloudSettingsView.swift
@@ -49,6 +49,7 @@ struct CloudSettingsView: View {
if want { await consent.turnOn(site) } else { await followErasure(site) }
}
}))
+ .tint(Theme.live)
.disabled(consent.working || box?.erasing == true)
if on {
Label(CloudWords.text(site.cloudStatus?.isOpen == true || box?.connected == true ? "Connected" : "Not connected"), systemImage: "dot.radiowaves.left.and.right")
@@ -63,7 +64,7 @@ struct CloudSettingsView: View {
} footer: {
VStack(alignment: .leading, spacing: 4) {
Text(CloudWords.text("Turning it off erases the copy in FTW Cloud. Your box shows when it is gone."))
- if let help = consent.help { Text(help).foregroundStyle(Theme.importing) }
+ if let help = consent.help { Text(help).foregroundStyle(Theme.alarm) }
}
}
if on {
@@ -71,6 +72,7 @@ struct CloudSettingsView: View {
Toggle(CloudWords.text("Contribute to statistics"), isOn: Binding(get: { box?.stats ?? false }, set: { want in
Task { await consent.setStats(want, site) }
}))
+ .tint(Theme.live)
.disabled(consent.working)
} footer: {
Text(CloudWords.text("Hourly energy totals, without your name or address, help make FTW better. Off unless you turn it on."))
diff --git a/appleApp/FTW/Intents/CarIntents.swift b/appleApp/FTW/Intents/CarIntents.swift
index 34c5ec5..4a84732 100644
--- a/appleApp/FTW/Intents/CarIntents.swift
+++ b/appleApp/FTW/Intents/CarIntents.swift
@@ -212,5 +212,5 @@ struct FTWShortcuts: AppShortcutsProvider {
)
}
- static let shortcutTileColor: ShortcutTileColor = .orange
+ static let shortcutTileColor: ShortcutTileColor = .navy
}
diff --git a/appleApp/FTW/UI/FlowDiagram.swift b/appleApp/FTW/UI/FlowDiagram.swift
index ef52cb5..d76c86d 100644
--- a/appleApp/FTW/UI/FlowDiagram.swift
+++ b/appleApp/FTW/UI/FlowDiagram.swift
@@ -303,13 +303,13 @@ struct NodeValue: View {
var body: some View {
if let watts {
let parts = PowerFormat.parts(watts)
- Text("\(Text(AppWords.decimal(parts.text)).font(Theme.number(size, weight: .bold)))\(Text(" \(parts.unit)").font(.caption))")
+ Text("\(Text(AppWords.decimal(parts.text)).font(Theme.figure(size)))\(Text(" \(parts.unit)").font(.caption))")
.contentTransition(.numericText(value: watts))
.animation(Theme.spring, value: watts)
.lineLimit(1)
.minimumScaleFactor(0.7)
} else {
- Text("—").font(Theme.number(size, weight: .bold))
+ Text("—").font(Theme.figure(size))
}
}
}
@@ -352,7 +352,7 @@ struct FreshnessTag: View {
Text(NodeLook.age(freshness))
}
.font(.caption2)
- .foregroundStyle(Theme.generation)
+ .foregroundStyle(Theme.freshStale)
.lineLimit(1)
}
}
diff --git a/appleApp/FTW/UI/ModeSheet.swift b/appleApp/FTW/UI/ModeSheet.swift
index 0e2a49a..3b7635f 100644
--- a/appleApp/FTW/UI/ModeSheet.swift
+++ b/appleApp/FTW/UI/ModeSheet.swift
@@ -99,7 +99,8 @@ struct ModeSheet: View {
.disabled(!canSet || mode.prefs == nil || !onPlan)
Toggle(L("Let the battery sell to the grid when it pays"), isOn: exportBinding)
.disabled(!canSet || mode.prefs == nil || !onPlan)
- .tint(Theme.accentFill)
+ // On is a state: lime, where the ink tint would hide the knob.
+ .tint(Theme.live)
} header: {
Text(L("How your home is run"))
} footer: {
diff --git a/appleApp/FTW/UI/NodeCharts.swift b/appleApp/FTW/UI/NodeCharts.swift
index 53f9d6e..a7c39f9 100644
--- a/appleApp/FTW/UI/NodeCharts.swift
+++ b/appleApp/FTW/UI/NodeCharts.swift
@@ -466,7 +466,7 @@ struct PriceStripChart: View, Equatable {
yStart: .value("Zero", 0),
yEnd: .value("Price", PriceUnits.display(p.minor, currency))
)
- .foregroundStyle(p.minor > mean * 1.05 ? Theme.importing.opacity(0.7) : p.minor < mean * 0.95 ? Theme.exporting.opacity(0.7) : Theme.fgMuted.opacity(0.5))
+ .foregroundStyle(Theme.price(p.minor > mean * 1.05 ? .dear : p.minor < mean * 0.95 ? .cheap : .flat))
.accessibilityLabel(AppWords.clock(p.startMs, zone: .current))
.accessibilityValue("\(AppWords.decimal(PriceStrip.text(p.minor, currency))) \(PriceUnits.unit(currency).perKwh)")
}
diff --git a/appleApp/FTW/UI/NodeView.swift b/appleApp/FTW/UI/NodeView.swift
index 03540ce..401cccf 100644
--- a/appleApp/FTW/UI/NodeView.swift
+++ b/appleApp/FTW/UI/NodeView.swift
@@ -212,7 +212,7 @@ private struct HeroLine: View, Equatable {
Circle().fill(d.live ? Theme.freshLive : Theme.freshStale).frame(width: 6, height: 6)
Text(d.name).font(.caption).foregroundStyle(Theme.fgDim)
Text(AppWords.power(d.watts))
- .font(Theme.number(13, weight: .semibold))
+ .font(Theme.figure(13))
.foregroundStyle(d.live ? color : Theme.fgMuted)
.contentTransition(.numericText(value: d.watts))
}
@@ -249,7 +249,7 @@ private struct HeroFreshness: View {
.frame(width: 7, height: 7)
Text(line)
.font(.footnote)
- .foregroundStyle(freshness.live ? Theme.fgDim : Theme.generation)
+ .foregroundStyle(freshness.live ? Theme.fgDim : Theme.freshStale)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
@@ -303,7 +303,7 @@ private struct DeviceRows: View {
Circle().fill(f.live ? Theme.freshLive : Theme.freshStale).frame(width: 6, height: 6)
Text(d.name).font(.footnote.weight(.medium)).foregroundStyle(Theme.fg)
Text(AppWords.power(d.watts))
- .font(Theme.number(13, weight: .semibold))
+ .font(Theme.figure(13))
.foregroundStyle(f.live ? tint : Theme.fgMuted)
.contentTransition(.numericText(value: d.watts))
if let level = d.levelPct {
@@ -588,7 +588,7 @@ private struct ModeSection: View {
Label {
Text(doubt).foregroundStyle(Theme.fg)
} icon: {
- Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(Theme.generation)
+ Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(Theme.alarm)
}
.font(.caption.weight(.medium))
.fixedSize(horizontal: false, vertical: true)
@@ -702,9 +702,9 @@ extension NodeLook {
}
switch node {
case .battery:
- return ChartLook(up: Theme.storage, down: Theme.storage.opacity(0.5), line: Theme.storage, upWord: words?.up, downWord: words?.down)
+ return ChartLook(up: Theme.stores, down: Theme.gives, line: Theme.ink2, upWord: words?.up, downWord: words?.down)
case .grid:
- return ChartLook(up: Theme.importing, down: Theme.exporting, line: Theme.fgDim, upWord: words?.up, downWord: words?.down)
+ return ChartLook(up: Theme.grid, down: Theme.grid, line: Theme.grid, upWord: words?.up, downWord: words?.down)
default:
let c = Theme.color(tone(node))
return ChartLook(up: c, down: c, line: c)
diff --git a/appleApp/FTW/UI/NowView.swift b/appleApp/FTW/UI/NowView.swift
index 3ae93ec..163252b 100644
--- a/appleApp/FTW/UI/NowView.swift
+++ b/appleApp/FTW/UI/NowView.swift
@@ -211,7 +211,7 @@ struct StatusRow: View, Equatable {
Label {
Text(doubt).foregroundStyle(Theme.fg)
} icon: {
- Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(Theme.generation)
+ Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(Theme.alarm)
}
.font(.caption.weight(.medium))
.fixedSize(horizontal: false, vertical: true)
@@ -307,8 +307,8 @@ struct StatusRow: View, Equatable {
private var color: Color {
switch answer.tone {
- case .ok: return Theme.exporting
- case .warning: return Theme.generation
+ case .ok: return Theme.live
+ case .warning: return Theme.alarm
case .neutral, .stale: return Theme.fgMuted
}
}
diff --git a/appleApp/FTW/UI/PairView.swift b/appleApp/FTW/UI/PairView.swift
index 75e11c9..560497c 100644
--- a/appleApp/FTW/UI/PairView.swift
+++ b/appleApp/FTW/UI/PairView.swift
@@ -143,10 +143,11 @@ struct PairView: View {
if let message = model.message { Problem(message) }
}
- /// A sentence with the box's fingerprint set in even figures.
+ /// A sentence with the box's fingerprint set in mono, so it reads
+ /// character by character against the box's own page.
private func mono(_ sentence: String, _ code: String) -> Text {
var text = AttributedString(sentence)
- if let range = text.range(of: code) { text[range].font = Theme.number(16) }
+ if let range = text.range(of: code) { text[range].font = Theme.mono(.callout, weight: .semibold) }
return Text(text)
}
@@ -251,7 +252,7 @@ struct Problem: View {
var body: some View {
Text(text)
.font(.callout)
- .foregroundStyle(Theme.importing)
+ .foregroundStyle(Theme.alarm)
.fixedSize(horizontal: false, vertical: true)
}
}
diff --git a/appleApp/FTW/UI/PlanCard.swift b/appleApp/FTW/UI/PlanCard.swift
index 2736add..b027969 100644
--- a/appleApp/FTW/UI/PlanCard.swift
+++ b/appleApp/FTW/UI/PlanCard.swift
@@ -27,7 +27,7 @@ struct PlanCard: View, Equatable {
}
Spacer(minLength: 8)
if let priceNow {
- Text("\(Text(L("Price now")).font(.caption2).foregroundStyle(Theme.fgMuted)) \(Text(AppWords.decimal(priceNow)).font(Theme.number(13, weight: .bold)).foregroundStyle(Theme.fg)) \(Text(unit).font(.caption2).foregroundStyle(Theme.fgMuted))")
+ Text("\(Text(L("Price now")).font(.caption2).foregroundStyle(Theme.fgMuted)) \(Text(AppWords.decimal(priceNow)).font(Theme.figure(13)).foregroundStyle(Theme.fg)) \(Text(unit).font(.caption2).foregroundStyle(Theme.fgMuted))")
.lineLimit(1)
.minimumScaleFactor(0.8)
.contentTransition(.numericText())
@@ -93,7 +93,7 @@ struct PlanLanes: View, Equatable {
yStart: .value("Bottom", 0),
yEnd: .value("Price", Self.priceFloor + Self.priceRise * max(0, bar.minor) / top)
)
- .foregroundStyle(grey ? Theme.fgMuted : PriceBars.color(bar.tone))
+ .foregroundStyle(grey ? Theme.fgMuted : Theme.price(bar.tone))
.opacity(0.4)
}
ForEach(moment.events) { e in
@@ -110,7 +110,7 @@ struct PlanLanes: View, Equatable {
if !compact || e.endMs - e.startMs >= 90 * 60_000, let pct = e.targetPct, e.kind == .car {
Text(AppWords.percent(pct))
.font(.system(size: 9, weight: .bold).monospacedDigit())
- .foregroundStyle(.white)
+ .foregroundStyle(Theme.ground)
}
}
}
@@ -139,10 +139,10 @@ struct PlanLanes: View, Equatable {
static func color(_ kind: PlanGlance.Kind) -> Color {
switch kind {
- case .car: return Theme.mobility
- case .charge: return Theme.storage
- case .discharge: return Theme.generation
- case .cheap: return Theme.exporting
+ case .car: return Theme.car
+ case .charge: return Theme.stores
+ case .discharge: return Theme.gives
+ case .cheap: return Theme.ink2
}
}
diff --git a/appleApp/FTW/UI/PlanView.swift b/appleApp/FTW/UI/PlanView.swift
index 162578d..cd3ee23 100644
--- a/appleApp/FTW/UI/PlanView.swift
+++ b/appleApp/FTW/UI/PlanView.swift
@@ -54,7 +54,7 @@ private struct PlanBody: View {
.redacted(reason: moment.placeholder ? .placeholder : [])
Text(stateLine(moment))
.font(.footnote)
- .foregroundStyle(moment.state == .boxStale ? Theme.generation : Theme.fgDim)
+ .foregroundStyle(moment.state == .boxStale ? Theme.alarm : Theme.fgDim)
}
VStack(alignment: .leading, spacing: 6) {
PlanLanes(moment: moment)
@@ -162,7 +162,7 @@ private struct Events: View {
}
Spacer(minLength: 8)
Text(range(e))
- .font(Theme.number(13, weight: .medium))
+ .font(Theme.figure(13, weight: .medium))
.foregroundStyle(Theme.fgDim)
.lineLimit(1)
}
@@ -212,7 +212,7 @@ private struct PriceRow: View {
Kicker(L("Electricity price"))
Spacer(minLength: 8)
if let current {
- Text("\(Text(AppWords.decimal(PriceStrip.text(current.totalMinor, window.currency))).font(Theme.number(15, weight: .bold)).foregroundStyle(Theme.fg)) \(Text(PriceUnits.unit(window.currency).perKwh).font(.caption2).foregroundStyle(Theme.fgMuted))")
+ Text("\(Text(AppWords.decimal(PriceStrip.text(current.totalMinor, window.currency))).font(Theme.figure(15)).foregroundStyle(Theme.fg)) \(Text(PriceUnits.unit(window.currency).perKwh).font(.caption2).foregroundStyle(Theme.fgMuted))")
.lineLimit(1)
.contentTransition(.numericText())
}
diff --git a/appleApp/FTW/UI/PriceChart.swift b/appleApp/FTW/UI/PriceChart.swift
deleted file mode 100644
index 1c0983b..0000000
--- a/appleApp/FTW/UI/PriceChart.swift
+++ /dev/null
@@ -1,15 +0,0 @@
-import FTWKit
-import SwiftUI
-
-/// The colour of a price against the hours around it: green below the
-/// average ahead, red above, grey near it, yellow below zero.
-enum PriceBars {
- static func color(_ tone: PriceStrip.Tone) -> Color {
- switch tone {
- case .dear: return Theme.importing
- case .cheap: return Theme.exporting
- case .flat: return Theme.fgMuted
- case .negative: return Theme.generation
- }
- }
-}
diff --git a/appleApp/FTW/UI/PriceView.swift b/appleApp/FTW/UI/PriceView.swift
index 18cdd32..52d75e2 100644
--- a/appleApp/FTW/UI/PriceView.swift
+++ b/appleApp/FTW/UI/PriceView.swift
@@ -140,7 +140,7 @@ struct PriceChart: View, Equatable {
cheapest: cheapest.map { $0.startMs...$0.endMs },
cheapestLabel: L("Cheapest"),
meanLabel: mean.map { NodeWords.format("avg %@", AppWords.decimal(PriceStrip.text($0, currency))) },
- palette: PriceChartPalette(cheap: Theme.exporting, flat: Theme.fgMuted, dear: Theme.importing, now: Theme.accentFill, quiet: Theme.fgMuted, labelBackground: Theme.surfaceRaised),
+ palette: .ink,
zone: .current,
selected: selected
)
diff --git a/appleApp/FTW/UI/RootView.swift b/appleApp/FTW/UI/RootView.swift
index 06bb47c..b646a7e 100644
--- a/appleApp/FTW/UI/RootView.swift
+++ b/appleApp/FTW/UI/RootView.swift
@@ -171,7 +171,7 @@ struct FreshnessLine: View {
private var line: Line {
if app.isDemo {
let streaming = site.session.phase == .streaming
- return Line(color: streaming ? Theme.accentFill : Theme.fgMuted, message: L(streaming ? "Live demo · simulated home" : "Starting the demo"), tail: nil, beats: streaming)
+ return Line(color: streaming ? Theme.live : Theme.fgMuted, message: L(streaming ? "Live demo · simulated home" : "Starting the demo"), tail: nil, beats: streaming)
}
let band = site.freshness(noCarrier: app.connectHelp != nil)
let age = band.age.flatMap { $0 == "—" ? nil : $0 }
diff --git a/appleApp/FTW/UI/Theme.swift b/appleApp/FTW/UI/Theme.swift
index bc247a8..c886f15 100644
--- a/appleApp/FTW/UI/Theme.swift
+++ b/appleApp/FTW/UI/Theme.swift
@@ -1,57 +1,51 @@
import FTWKit
import SwiftUI
-/// The web app's design roles (src/styles/tokens.css), light and dark, so the
-/// native app and the box's own page look like one product. Views read
-/// roles, never raw colours. The values live in `Theme.xcassets`, one colour
-/// set per role with its dark variant, so the system switches them.
-enum Theme {
- static let surface = Color("ftw.surface")
- static let surfaceSunken = Color("ftw.surfaceSunken")
- static let surfaceRaised = Color("ftw.surfaceRaised")
- static let surfaceElevated = Color("ftw.surfaceElevated")
- static let line = Color("ftw.line")
- static let fg = Color("ftw.fg")
- static let fgDim = Color("ftw.fgDim")
- static let fgMuted = Color("ftw.fgMuted")
- static let onAccent = Color("ftw.onAccent")
-
- /// Accent for text and small glyphs: dark enough to read on every
- /// light surface.
- static let accent = Color("ftw.accent")
- /// The brand orange, for fills, strokes, lines and prominent buttons.
- static let accentFill = Color("ftw.accentFill")
- static let importing = Color("ftw.importing")
- static let exporting = Color("ftw.exporting")
- static let generation = Color("ftw.generation")
- static let storage = Color("ftw.storage")
- static let mobility = Color("ftw.mobility")
-
- static let freshLive = exporting
- static let freshStale = generation
- static let freshLost = fgMuted
+/// The app's side of `Theme` (FTWCharts/Palette.swift holds the palette and
+/// type it shares with the widgets): the names the screens read, the energy
+/// colour of a flow, the spring and the shapes.
+extension Theme {
+ // The names from 0.3, pointed at the Phosphor roles so every screen
+ // changed at once. New code reads the roles; these go as each screen is
+ // rebuilt.
+ static let surface = ground
+ static let surfaceSunken = hairline
+ static let surfaceRaised = raised
+ static let surfaceElevated = raised
+ static let line = hairline
+ static let fg = ink
+ static let fgDim = ink2
+ static let fgMuted = ink3
+ /// Text on an ink fill: inverse video.
+ static let onAccent = ground
+ /// The interface accent is ink. No orange anywhere.
+ static let accent = ink
+ static let accentFill = ink
+ static let storage = stores
+ static let mobility = car
+
+ static let freshLive = live
+ /// Not live is ink and holds still; only live gives off colour.
+ static let freshStale = ink2
+ static let freshLost = ink3
static let radius: CGFloat = 14
static let radiusSmall: CGFloat = 10
+ /// One hue per energy kind. The grid is magenta both ways and the home
+ /// is ink.
static func color(_ tone: Flow.Tone) -> Color {
switch tone {
- case .muted: return fgMuted
- case .importing: return importing
- case .exporting: return exporting
- case .solar: return generation
- case .battery, .charging, .discharging: return storage
- case .ev: return mobility
- case .house: return accentFill
+ case .muted: return ink3
+ case .importing, .exporting: return grid
+ case .solar: return solar
+ case .battery, .charging: return stores
+ case .discharging: return gives
+ case .ev: return car
+ case .house: return ink
}
}
- /// Mono figures, as the web app sets every number, so a value that
- /// changes every second does not shift the line under the reader's eye.
- static func number(_ size: CGFloat, weight: Font.Weight = .semibold) -> Font {
- .system(size: size, weight: weight, design: .monospaced)
- }
-
/// A percentage: even digits, and a % that reads as a sign, not a
/// fixed-width glyph.
static func percent(_ style: Font.TextStyle, weight: Font.Weight = .semibold) -> Font {
@@ -79,7 +73,9 @@ final class PowerState {
}
}
-/// A raised card, the web app's `.card`.
+/// A group of lines on the ground. No fill and no border: space, not
+/// boxes, separates one group from the next. It keeps the old card's
+/// spacing, so nothing moves.
struct Card: View {
@ViewBuilder var content: Content
@@ -88,20 +84,18 @@ struct Card: View {
.padding(.vertical, 14)
.padding(.horizontal, 16)
.frame(maxWidth: .infinity, alignment: .leading)
- .background(Theme.surfaceRaised, in: RoundedRectangle(cornerRadius: Theme.radius))
- .overlay(RoundedRectangle(cornerRadius: Theme.radius).strokeBorder(Theme.line, lineWidth: 1))
}
}
-/// The small caps line over a card title.
+/// The small caps line over a group, in the terminal's mono.
struct Kicker: View {
let text: String
init(_ text: String) { self.text = text }
var body: some View {
Text(text.uppercased())
- .font(.caption2.weight(.semibold))
- .tracking(0.8)
+ .font(Theme.mono())
+ .tracking(0.5)
.foregroundStyle(Theme.fgMuted)
// A label over a card, never louder than what it labels.
.dynamicTypeSize(...DynamicTypeSize.accessibility1)
@@ -125,7 +119,8 @@ struct Hint: View {
}
}
-/// The web app's three button weights.
+/// The three button weights. The primary one is inverse video: ink with
+/// the ground for its words.
struct FTWButtonStyle: ButtonStyle {
enum Kind { case primary, quiet, outline, danger }
var kind: Kind
@@ -152,7 +147,7 @@ struct FTWButtonStyle: ButtonStyle {
private var foreground: Color {
switch kind {
case .primary: return Theme.onAccent
- case .danger: return .white
+ case .danger: return Theme.ground
case .quiet: return Theme.accent
case .outline: return Theme.fg
}
@@ -161,7 +156,7 @@ struct FTWButtonStyle: ButtonStyle {
private var background: Color {
switch kind {
case .primary: return Theme.accentFill
- case .danger: return Theme.importing
+ case .danger: return Theme.alarm
case .quiet, .outline: return .clear
}
}
diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.accent.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.accent.colorset/Contents.json
deleted file mode 100644
index f8b2f96..0000000
--- a/appleApp/FTW/UI/Theme.xcassets/ftw.accent.colorset/Contents.json
+++ /dev/null
@@ -1,78 +0,0 @@
-{
- "colors": [
- {
- "color": {
- "color-space": "srgb",
- "components": {
- "alpha": "1.000",
- "red": "0x9C",
- "green": "0x58",
- "blue": "0x00"
- }
- },
- "idiom": "universal"
- },
- {
- "color": {
- "color-space": "srgb",
- "components": {
- "alpha": "1.000",
- "red": "0xFF",
- "green": "0xAC",
- "blue": "0x41"
- }
- },
- "idiom": "universal",
- "appearances": [
- {
- "appearance": "luminosity",
- "value": "dark"
- }
- ]
- },
- {
- "color": {
- "color-space": "srgb",
- "components": {
- "alpha": "1.000",
- "red": "0x7E",
- "green": "0x47",
- "blue": "0x00"
- }
- },
- "idiom": "universal",
- "appearances": [
- {
- "appearance": "contrast",
- "value": "high"
- }
- ]
- },
- {
- "color": {
- "color-space": "srgb",
- "components": {
- "alpha": "1.000",
- "red": "0xFF",
- "green": "0xC4",
- "blue": "0x78"
- }
- },
- "idiom": "universal",
- "appearances": [
- {
- "appearance": "luminosity",
- "value": "dark"
- },
- {
- "appearance": "contrast",
- "value": "high"
- }
- ]
- }
- ],
- "info": {
- "author": "xcode",
- "version": 1
- }
-}
diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.accentFill.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.accentFill.colorset/Contents.json
deleted file mode 100644
index 5b2c6d9..0000000
--- a/appleApp/FTW/UI/Theme.xcassets/ftw.accentFill.colorset/Contents.json
+++ /dev/null
@@ -1,78 +0,0 @@
-{
- "colors": [
- {
- "color": {
- "color-space": "srgb",
- "components": {
- "alpha": "1.000",
- "red": "0xDA",
- "green": "0x7F",
- "blue": "0x00"
- }
- },
- "idiom": "universal"
- },
- {
- "color": {
- "color-space": "srgb",
- "components": {
- "alpha": "1.000",
- "red": "0xFF",
- "green": "0xAC",
- "blue": "0x41"
- }
- },
- "idiom": "universal",
- "appearances": [
- {
- "appearance": "luminosity",
- "value": "dark"
- }
- ]
- },
- {
- "color": {
- "color-space": "srgb",
- "components": {
- "alpha": "1.000",
- "red": "0xB8",
- "green": "0x6A",
- "blue": "0x00"
- }
- },
- "idiom": "universal",
- "appearances": [
- {
- "appearance": "contrast",
- "value": "high"
- }
- ]
- },
- {
- "color": {
- "color-space": "srgb",
- "components": {
- "alpha": "1.000",
- "red": "0xFF",
- "green": "0xC4",
- "blue": "0x78"
- }
- },
- "idiom": "universal",
- "appearances": [
- {
- "appearance": "luminosity",
- "value": "dark"
- },
- {
- "appearance": "contrast",
- "value": "high"
- }
- ]
- }
- ],
- "info": {
- "author": "xcode",
- "version": 1
- }
-}
diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.fgMuted.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.fgMuted.colorset/Contents.json
deleted file mode 100644
index 1e31b20..0000000
--- a/appleApp/FTW/UI/Theme.xcassets/ftw.fgMuted.colorset/Contents.json
+++ /dev/null
@@ -1,78 +0,0 @@
-{
- "colors": [
- {
- "color": {
- "color-space": "srgb",
- "components": {
- "alpha": "1.000",
- "red": "0x68",
- "green": "0x68",
- "blue": "0x62"
- }
- },
- "idiom": "universal"
- },
- {
- "color": {
- "color-space": "srgb",
- "components": {
- "alpha": "1.000",
- "red": "0x85",
- "green": "0x85",
- "blue": "0x85"
- }
- },
- "idiom": "universal",
- "appearances": [
- {
- "appearance": "luminosity",
- "value": "dark"
- }
- ]
- },
- {
- "color": {
- "color-space": "srgb",
- "components": {
- "alpha": "1.000",
- "red": "0x4F",
- "green": "0x4F",
- "blue": "0x4B"
- }
- },
- "idiom": "universal",
- "appearances": [
- {
- "appearance": "contrast",
- "value": "high"
- }
- ]
- },
- {
- "color": {
- "color-space": "srgb",
- "components": {
- "alpha": "1.000",
- "red": "0xA8",
- "green": "0xA8",
- "blue": "0xA8"
- }
- },
- "idiom": "universal",
- "appearances": [
- {
- "appearance": "luminosity",
- "value": "dark"
- },
- {
- "appearance": "contrast",
- "value": "high"
- }
- ]
- }
- ],
- "info": {
- "author": "xcode",
- "version": 1
- }
-}
diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.line.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.line.colorset/Contents.json
deleted file mode 100644
index 2776b7d..0000000
--- a/appleApp/FTW/UI/Theme.xcassets/ftw.line.colorset/Contents.json
+++ /dev/null
@@ -1,38 +0,0 @@
-{
- "colors": [
- {
- "color": {
- "color-space": "srgb",
- "components": {
- "alpha": "1.000",
- "red": "0xCE",
- "green": "0xCE",
- "blue": "0xC7"
- }
- },
- "idiom": "universal"
- },
- {
- "appearances": [
- {
- "appearance": "luminosity",
- "value": "dark"
- }
- ],
- "color": {
- "color-space": "srgb",
- "components": {
- "alpha": "1.000",
- "red": "0x2A",
- "green": "0x2A",
- "blue": "0x2A"
- }
- },
- "idiom": "universal"
- }
- ],
- "info": {
- "author": "xcode",
- "version": 1
- }
-}
\ No newline at end of file
diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.surface.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.surface.colorset/Contents.json
deleted file mode 100644
index b9bc37b..0000000
--- a/appleApp/FTW/UI/Theme.xcassets/ftw.surface.colorset/Contents.json
+++ /dev/null
@@ -1,38 +0,0 @@
-{
- "colors": [
- {
- "color": {
- "color-space": "srgb",
- "components": {
- "alpha": "1.000",
- "red": "0xF4",
- "green": "0xF4",
- "blue": "0xF2"
- }
- },
- "idiom": "universal"
- },
- {
- "appearances": [
- {
- "appearance": "luminosity",
- "value": "dark"
- }
- ],
- "color": {
- "color-space": "srgb",
- "components": {
- "alpha": "1.000",
- "red": "0x0D",
- "green": "0x0D",
- "blue": "0x0D"
- }
- },
- "idiom": "universal"
- }
- ],
- "info": {
- "author": "xcode",
- "version": 1
- }
-}
\ No newline at end of file
diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceSunken.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceSunken.colorset/Contents.json
deleted file mode 100644
index 3829a78..0000000
--- a/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceSunken.colorset/Contents.json
+++ /dev/null
@@ -1,38 +0,0 @@
-{
- "colors": [
- {
- "color": {
- "color-space": "srgb",
- "components": {
- "alpha": "1.000",
- "red": "0xEC",
- "green": "0xEC",
- "blue": "0xE8"
- }
- },
- "idiom": "universal"
- },
- {
- "appearances": [
- {
- "appearance": "luminosity",
- "value": "dark"
- }
- ],
- "color": {
- "color-space": "srgb",
- "components": {
- "alpha": "1.000",
- "red": "0x10",
- "green": "0x10",
- "blue": "0x10"
- }
- },
- "idiom": "universal"
- }
- ],
- "info": {
- "author": "xcode",
- "version": 1
- }
-}
\ No newline at end of file
diff --git a/appleApp/FTWCharts/Palette.swift b/appleApp/FTWCharts/Palette.swift
new file mode 100644
index 0000000..f1d9795
--- /dev/null
+++ b/appleApp/FTWCharts/Palette.swift
@@ -0,0 +1,82 @@
+import FTWShared
+import SwiftUI
+
+/// FTW's palette and type on the phone, shared by the app and the widgets.
+/// Every value lives in `Theme.xcassets` beside this file, one colour set
+/// per role with its dark variant, authored in Display P3, so the system
+/// switches them and both targets read the same source.
+///
+/// Light means live: only a reading the box has confirmed live may glow or
+/// move. Colour is energy and the interface is ink. Each energy kind has
+/// one hue, the battery two (gives and stores). Live and alarm are states,
+/// never levels.
+enum Theme {
+ /// Every screen, and the body of every sheet: a blue-black void in dark.
+ static let ground = Color("ftw.ground")
+ /// Sheets, menus and list rows.
+ static let raised = Color("ftw.raised")
+ /// Only where two lists meet.
+ static let hairline = Color("ftw.hairline")
+
+ /// Figures, chrome, the system tint and the home.
+ static let ink = Color("ftw.ink")
+ /// Units, words and second lines.
+ static let ink2 = Color("ftw.ink2")
+ /// Axes, footers and idle strokes. Increase Contrast makes it ink2.
+ static let ink3 = Color("ftw.ink3")
+
+ /// The sun.
+ static let solar = Color("ftw.solar")
+ /// The battery giving power.
+ static let gives = Color("ftw.gives")
+ /// The battery storing power, and its level.
+ static let stores = Color("ftw.stores")
+ /// The grid, both ways. The side of zero and the word say buying or
+ /// selling.
+ static let grid = Color("ftw.grid")
+ /// The car.
+ static let car = Color("ftw.car")
+
+ /// A state: live, or confirmed.
+ static let live = Color("ftw.live")
+ /// A state: dispatch blocked, refused, failed.
+ static let alarm = Color("ftw.alarm")
+
+ /// A figure: SF Pro with even digits, so a value that changes does not
+ /// shift the line, and with no slashed zero or wide decimal gap.
+ static func figure(_ size: CGFloat, weight: Font.Weight = .semibold) -> Font {
+ .system(size: size, weight: weight).monospacedDigit()
+ }
+
+ /// The terminal layer only: status line, kickers, axis labels, footers,
+ /// receipts and the scrub tag. Everything else is SF Pro.
+ static func mono(_ style: Font.TextStyle = .caption2, weight: Font.Weight = .medium) -> Font {
+ .system(style, design: .monospaced, weight: weight)
+ }
+
+ /// A price by its level against the hours around it, in ink: the
+ /// dearer, the stronger (0.32, 0.50, 0.80). No red and no green: a price
+ /// is a level, not a state.
+ static func price(_ tone: PriceStrip.Tone) -> Color {
+ switch tone {
+ case .cheap, .negative: return ink.opacity(0.32)
+ case .flat: return ink.opacity(0.5)
+ case .dear: return ink.opacity(0.8)
+ }
+ }
+
+ /// One colour as it looks in one scheme, for a place that draws in a
+ /// fixed one whatever the phone's: the Dynamic Island is always dark,
+ /// and Control Center draws an on switch's glyph on white.
+ static func fixed(_ color: Color, _ scheme: ColorScheme) -> Color {
+ var environment = EnvironmentValues()
+ environment.colorScheme = scheme
+ return Color(color.resolve(in: environment))
+ }
+}
+
+extension PriceChartPalette {
+ /// The price line in ink, stronger as it gets dearer, for the app and
+ /// the widget alike.
+ static let ink = PriceChartPalette(cheap: Theme.ink3, flat: Theme.ink2, dear: Theme.ink, now: Theme.ink, quiet: Theme.ink3, labelBackground: Theme.raised)
+}
diff --git a/appleApp/FTW/UI/Theme.xcassets/Contents.json b/appleApp/FTWCharts/Theme.xcassets/Contents.json
similarity index 100%
rename from appleApp/FTW/UI/Theme.xcassets/Contents.json
rename to appleApp/FTWCharts/Theme.xcassets/Contents.json
diff --git a/appleApp/FTWCharts/Theme.xcassets/ftw.alarm.colorset/Contents.json b/appleApp/FTWCharts/Theme.xcassets/ftw.alarm.colorset/Contents.json
new file mode 100644
index 0000000..8d2079d
--- /dev/null
+++ b/appleApp/FTWCharts/Theme.xcassets/ftw.alarm.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors": [
+ {
+ "color": {
+ "color-space": "display-p3",
+ "components": {
+ "alpha": "1.000",
+ "red": "0xD7",
+ "green": "0x00",
+ "blue": "0x15"
+ }
+ },
+ "idiom": "universal"
+ },
+ {
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "dark"
+ }
+ ],
+ "color": {
+ "color-space": "display-p3",
+ "components": {
+ "alpha": "1.000",
+ "red": "0xFF",
+ "green": "0x45",
+ "blue": "0x3A"
+ }
+ },
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.mobility.colorset/Contents.json b/appleApp/FTWCharts/Theme.xcassets/ftw.car.colorset/Contents.json
similarity index 69%
rename from appleApp/FTW/UI/Theme.xcassets/ftw.mobility.colorset/Contents.json
rename to appleApp/FTWCharts/Theme.xcassets/ftw.car.colorset/Contents.json
index 771e0e2..c368099 100644
--- a/appleApp/FTW/UI/Theme.xcassets/ftw.mobility.colorset/Contents.json
+++ b/appleApp/FTWCharts/Theme.xcassets/ftw.car.colorset/Contents.json
@@ -2,12 +2,12 @@
"colors": [
{
"color": {
- "color-space": "srgb",
+ "color-space": "display-p3",
"components": {
"alpha": "1.000",
- "red": "0x7F",
- "green": "0x5B",
- "blue": "0xB6"
+ "red": "0x6A",
+ "green": "0x48",
+ "blue": "0xD8"
}
},
"idiom": "universal"
@@ -20,11 +20,11 @@
}
],
"color": {
- "color-space": "srgb",
+ "color-space": "display-p3",
"components": {
"alpha": "1.000",
- "red": "0xCC",
- "green": "0xA8",
+ "red": "0xB6",
+ "green": "0x9C",
"blue": "0xFF"
}
},
@@ -35,4 +35,4 @@
"author": "xcode",
"version": 1
}
-}
\ No newline at end of file
+}
diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.generation.colorset/Contents.json b/appleApp/FTWCharts/Theme.xcassets/ftw.gives.colorset/Contents.json
similarity index 81%
rename from appleApp/FTW/UI/Theme.xcassets/ftw.generation.colorset/Contents.json
rename to appleApp/FTWCharts/Theme.xcassets/ftw.gives.colorset/Contents.json
index b37c49c..d6989d9 100644
--- a/appleApp/FTW/UI/Theme.xcassets/ftw.generation.colorset/Contents.json
+++ b/appleApp/FTWCharts/Theme.xcassets/ftw.gives.colorset/Contents.json
@@ -2,11 +2,11 @@
"colors": [
{
"color": {
- "color-space": "srgb",
+ "color-space": "display-p3",
"components": {
"alpha": "1.000",
- "red": "0xD4",
- "green": "0x85",
+ "red": "0xA3",
+ "green": "0x57",
"blue": "0x00"
}
},
@@ -20,7 +20,7 @@
}
],
"color": {
- "color-space": "srgb",
+ "color-space": "display-p3",
"components": {
"alpha": "1.000",
"red": "0xFF",
@@ -35,4 +35,4 @@
"author": "xcode",
"version": 1
}
-}
\ No newline at end of file
+}
diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.storage.colorset/Contents.json b/appleApp/FTWCharts/Theme.xcassets/ftw.grid.colorset/Contents.json
similarity index 69%
rename from appleApp/FTW/UI/Theme.xcassets/ftw.storage.colorset/Contents.json
rename to appleApp/FTWCharts/Theme.xcassets/ftw.grid.colorset/Contents.json
index 25f9aac..6ba5fd0 100644
--- a/appleApp/FTW/UI/Theme.xcassets/ftw.storage.colorset/Contents.json
+++ b/appleApp/FTWCharts/Theme.xcassets/ftw.grid.colorset/Contents.json
@@ -2,12 +2,12 @@
"colors": [
{
"color": {
- "color-space": "srgb",
+ "color-space": "display-p3",
"components": {
"alpha": "1.000",
- "red": "0x00",
- "green": "0x8F",
- "blue": "0xA8"
+ "red": "0xC2",
+ "green": "0x18",
+ "blue": "0x6F"
}
},
"idiom": "universal"
@@ -20,12 +20,12 @@
}
],
"color": {
- "color-space": "srgb",
+ "color-space": "display-p3",
"components": {
"alpha": "1.000",
- "red": "0x13",
- "green": "0xDC",
- "blue": "0xF6"
+ "red": "0xFF",
+ "green": "0x5F",
+ "blue": "0xA8"
}
},
"idiom": "universal"
@@ -35,4 +35,4 @@
"author": "xcode",
"version": 1
}
-}
\ No newline at end of file
+}
diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceRaised.colorset/Contents.json b/appleApp/FTWCharts/Theme.xcassets/ftw.ground.colorset/Contents.json
similarity index 69%
rename from appleApp/FTW/UI/Theme.xcassets/ftw.surfaceRaised.colorset/Contents.json
rename to appleApp/FTWCharts/Theme.xcassets/ftw.ground.colorset/Contents.json
index 13843e1..7371bbd 100644
--- a/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceRaised.colorset/Contents.json
+++ b/appleApp/FTWCharts/Theme.xcassets/ftw.ground.colorset/Contents.json
@@ -2,11 +2,11 @@
"colors": [
{
"color": {
- "color-space": "srgb",
+ "color-space": "display-p3",
"components": {
"alpha": "1.000",
- "red": "0xFA",
- "green": "0xFA",
+ "red": "0xF5",
+ "green": "0xF6",
"blue": "0xF8"
}
},
@@ -20,12 +20,12 @@
}
],
"color": {
- "color-space": "srgb",
+ "color-space": "display-p3",
"components": {
"alpha": "1.000",
- "red": "0x16",
- "green": "0x16",
- "blue": "0x16"
+ "red": "0x05",
+ "green": "0x08",
+ "blue": "0x0F"
}
},
"idiom": "universal"
@@ -35,4 +35,4 @@
"author": "xcode",
"version": 1
}
-}
\ No newline at end of file
+}
diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.importing.colorset/Contents.json b/appleApp/FTWCharts/Theme.xcassets/ftw.hairline.colorset/Contents.json
similarity index 70%
rename from appleApp/FTW/UI/Theme.xcassets/ftw.importing.colorset/Contents.json
rename to appleApp/FTWCharts/Theme.xcassets/ftw.hairline.colorset/Contents.json
index 866f7d5..b2d90f5 100644
--- a/appleApp/FTW/UI/Theme.xcassets/ftw.importing.colorset/Contents.json
+++ b/appleApp/FTWCharts/Theme.xcassets/ftw.hairline.colorset/Contents.json
@@ -2,12 +2,12 @@
"colors": [
{
"color": {
- "color-space": "srgb",
+ "color-space": "display-p3",
"components": {
"alpha": "1.000",
- "red": "0xCC",
- "green": "0x24",
- "blue": "0x3D"
+ "red": "0xDC",
+ "green": "0xE0",
+ "blue": "0xE7"
}
},
"idiom": "universal"
@@ -20,12 +20,12 @@
}
],
"color": {
- "color-space": "srgb",
+ "color-space": "display-p3",
"components": {
"alpha": "1.000",
- "red": "0xFF",
- "green": "0x6E",
- "blue": "0x74"
+ "red": "0x1B",
+ "green": "0x24",
+ "blue": "0x33"
}
},
"idiom": "universal"
@@ -35,4 +35,4 @@
"author": "xcode",
"version": 1
}
-}
\ No newline at end of file
+}
diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.onAccent.colorset/Contents.json b/appleApp/FTWCharts/Theme.xcassets/ftw.ink.colorset/Contents.json
similarity index 69%
rename from appleApp/FTW/UI/Theme.xcassets/ftw.onAccent.colorset/Contents.json
rename to appleApp/FTWCharts/Theme.xcassets/ftw.ink.colorset/Contents.json
index d67944e..4135819 100644
--- a/appleApp/FTW/UI/Theme.xcassets/ftw.onAccent.colorset/Contents.json
+++ b/appleApp/FTWCharts/Theme.xcassets/ftw.ink.colorset/Contents.json
@@ -2,12 +2,12 @@
"colors": [
{
"color": {
- "color-space": "srgb",
+ "color-space": "display-p3",
"components": {
"alpha": "1.000",
"red": "0x0A",
- "green": "0x0A",
- "blue": "0x0A"
+ "green": "0x0F",
+ "blue": "0x1A"
}
},
"idiom": "universal"
@@ -20,12 +20,12 @@
}
],
"color": {
- "color-space": "srgb",
+ "color-space": "display-p3",
"components": {
"alpha": "1.000",
- "red": "0x0A",
- "green": "0x0A",
- "blue": "0x0A"
+ "red": "0xEE",
+ "green": "0xF3",
+ "blue": "0xFF"
}
},
"idiom": "universal"
@@ -35,4 +35,4 @@
"author": "xcode",
"version": 1
}
-}
\ No newline at end of file
+}
diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.fg.colorset/Contents.json b/appleApp/FTWCharts/Theme.xcassets/ftw.ink2.colorset/Contents.json
similarity index 66%
rename from appleApp/FTW/UI/Theme.xcassets/ftw.fg.colorset/Contents.json
rename to appleApp/FTWCharts/Theme.xcassets/ftw.ink2.colorset/Contents.json
index 990e793..ec04e7b 100644
--- a/appleApp/FTW/UI/Theme.xcassets/ftw.fg.colorset/Contents.json
+++ b/appleApp/FTWCharts/Theme.xcassets/ftw.ink2.colorset/Contents.json
@@ -2,12 +2,12 @@
"colors": [
{
"color": {
- "color-space": "srgb",
+ "color-space": "display-p3",
"components": {
"alpha": "1.000",
- "red": "0x19",
- "green": "0x19",
- "blue": "0x19"
+ "red": "0x54",
+ "green": "0x5E",
+ "blue": "0x70"
}
},
"idiom": "universal"
@@ -20,12 +20,12 @@
}
],
"color": {
- "color-space": "srgb",
+ "color-space": "display-p3",
"components": {
"alpha": "1.000",
- "red": "0xE8",
- "green": "0xE8",
- "blue": "0xE8"
+ "red": "0x9A",
+ "green": "0xA5",
+ "blue": "0xB8"
}
},
"idiom": "universal"
@@ -35,4 +35,4 @@
"author": "xcode",
"version": 1
}
-}
\ No newline at end of file
+}
diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.fgDim.colorset/Contents.json b/appleApp/FTWCharts/Theme.xcassets/ftw.ink3.colorset/Contents.json
similarity index 61%
rename from appleApp/FTW/UI/Theme.xcassets/ftw.fgDim.colorset/Contents.json
rename to appleApp/FTWCharts/Theme.xcassets/ftw.ink3.colorset/Contents.json
index 7129f61..a6e0d8e 100644
--- a/appleApp/FTW/UI/Theme.xcassets/ftw.fgDim.colorset/Contents.json
+++ b/appleApp/FTWCharts/Theme.xcassets/ftw.ink3.colorset/Contents.json
@@ -2,63 +2,53 @@
"colors": [
{
"color": {
- "color-space": "srgb",
+ "color-space": "display-p3",
"components": {
"alpha": "1.000",
- "red": "0x4F",
- "green": "0x4F",
- "blue": "0x4B"
+ "red": "0x67",
+ "green": "0x71",
+ "blue": "0x84"
}
},
"idiom": "universal"
},
{
- "color": {
- "color-space": "srgb",
- "components": {
- "alpha": "1.000",
- "red": "0xA0",
- "green": "0xA0",
- "blue": "0xA0"
- }
- },
- "idiom": "universal",
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
- ]
- },
- {
+ ],
"color": {
- "color-space": "srgb",
+ "color-space": "display-p3",
"components": {
"alpha": "1.000",
- "red": "0x3A",
- "green": "0x3A",
- "blue": "0x37"
+ "red": "0x72",
+ "green": "0x7D",
+ "blue": "0x90"
}
},
- "idiom": "universal",
+ "idiom": "universal"
+ },
+ {
"appearances": [
{
"appearance": "contrast",
"value": "high"
}
- ]
- },
- {
+ ],
"color": {
- "color-space": "srgb",
+ "color-space": "display-p3",
"components": {
"alpha": "1.000",
- "red": "0xC2",
- "green": "0xC2",
- "blue": "0xC2"
+ "red": "0x54",
+ "green": "0x5E",
+ "blue": "0x70"
}
},
- "idiom": "universal",
+ "idiom": "universal"
+ },
+ {
"appearances": [
{
"appearance": "luminosity",
@@ -68,7 +58,17 @@
"appearance": "contrast",
"value": "high"
}
- ]
+ ],
+ "color": {
+ "color-space": "display-p3",
+ "components": {
+ "alpha": "1.000",
+ "red": "0x9A",
+ "green": "0xA5",
+ "blue": "0xB8"
+ }
+ },
+ "idiom": "universal"
}
],
"info": {
diff --git a/appleApp/FTWCharts/Theme.xcassets/ftw.live.colorset/Contents.json b/appleApp/FTWCharts/Theme.xcassets/ftw.live.colorset/Contents.json
new file mode 100644
index 0000000..72e970d
--- /dev/null
+++ b/appleApp/FTWCharts/Theme.xcassets/ftw.live.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors": [
+ {
+ "color": {
+ "color-space": "display-p3",
+ "components": {
+ "alpha": "1.000",
+ "red": "0x2F",
+ "green": "0x7A",
+ "blue": "0x00"
+ }
+ },
+ "idiom": "universal"
+ },
+ {
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "dark"
+ }
+ ],
+ "color": {
+ "color-space": "display-p3",
+ "components": {
+ "alpha": "1.000",
+ "red": "0x83",
+ "green": "0xF5",
+ "blue": "0x2C"
+ }
+ },
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceElevated.colorset/Contents.json b/appleApp/FTWCharts/Theme.xcassets/ftw.raised.colorset/Contents.json
similarity index 77%
rename from appleApp/FTW/UI/Theme.xcassets/ftw.surfaceElevated.colorset/Contents.json
rename to appleApp/FTWCharts/Theme.xcassets/ftw.raised.colorset/Contents.json
index 8da8c03..700bcd9 100644
--- a/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceElevated.colorset/Contents.json
+++ b/appleApp/FTWCharts/Theme.xcassets/ftw.raised.colorset/Contents.json
@@ -2,7 +2,7 @@
"colors": [
{
"color": {
- "color-space": "srgb",
+ "color-space": "display-p3",
"components": {
"alpha": "1.000",
"red": "0xFF",
@@ -20,12 +20,12 @@
}
],
"color": {
- "color-space": "srgb",
+ "color-space": "display-p3",
"components": {
"alpha": "1.000",
- "red": "0x1E",
- "green": "0x1E",
- "blue": "0x1E"
+ "red": "0x0B",
+ "green": "0x11",
+ "blue": "0x1C"
}
},
"idiom": "universal"
@@ -35,4 +35,4 @@
"author": "xcode",
"version": 1
}
-}
\ No newline at end of file
+}
diff --git a/appleApp/FTWCharts/Theme.xcassets/ftw.solar.colorset/Contents.json b/appleApp/FTWCharts/Theme.xcassets/ftw.solar.colorset/Contents.json
new file mode 100644
index 0000000..a305759
--- /dev/null
+++ b/appleApp/FTWCharts/Theme.xcassets/ftw.solar.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors": [
+ {
+ "color": {
+ "color-space": "display-p3",
+ "components": {
+ "alpha": "1.000",
+ "red": "0x8A",
+ "green": "0x6C",
+ "blue": "0x00"
+ }
+ },
+ "idiom": "universal"
+ },
+ {
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "dark"
+ }
+ ],
+ "color": {
+ "color-space": "display-p3",
+ "components": {
+ "alpha": "1.000",
+ "red": "0xFF",
+ "green": "0xE1",
+ "blue": "0x4D"
+ }
+ },
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.exporting.colorset/Contents.json b/appleApp/FTWCharts/Theme.xcassets/ftw.stores.colorset/Contents.json
similarity index 69%
rename from appleApp/FTW/UI/Theme.xcassets/ftw.exporting.colorset/Contents.json
rename to appleApp/FTWCharts/Theme.xcassets/ftw.stores.colorset/Contents.json
index cfe7820..6ef68a9 100644
--- a/appleApp/FTW/UI/Theme.xcassets/ftw.exporting.colorset/Contents.json
+++ b/appleApp/FTWCharts/Theme.xcassets/ftw.stores.colorset/Contents.json
@@ -2,12 +2,12 @@
"colors": [
{
"color": {
- "color-space": "srgb",
+ "color-space": "display-p3",
"components": {
"alpha": "1.000",
"red": "0x00",
- "green": "0x96",
- "blue": "0x39"
+ "green": "0x73",
+ "blue": "0x9A"
}
},
"idiom": "universal"
@@ -20,12 +20,12 @@
}
],
"color": {
- "color-space": "srgb",
+ "color-space": "display-p3",
"components": {
"alpha": "1.000",
- "red": "0x5F",
- "green": "0xD3",
- "blue": "0x7F"
+ "red": "0x18",
+ "green": "0xD9",
+ "blue": "0xFF"
}
},
"idiom": "universal"
@@ -35,4 +35,4 @@
"author": "xcode",
"version": 1
}
-}
\ No newline at end of file
+}
diff --git a/appleApp/FTWKit/Tests/FTWKitTests/GridTodayShareTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/GridTodayShareTests.swift
index 4fd12ab..617fff8 100644
--- a/appleApp/FTWKit/Tests/FTWKitTests/GridTodayShareTests.swift
+++ b/appleApp/FTWKit/Tests/FTWKitTests/GridTodayShareTests.swift
@@ -186,28 +186,15 @@ import FTWShared
#expect(GridTodayShare.totals(["import_wh": 1]) == nil)
}
- /// The widget's two colours are FTW's own roles, `ftw.importing` and
- /// `ftw.exporting`, not a copy that drifts from them.
- @Test func theWidgetsColoursAreFTWsImportAndExportRoles() throws {
- let source = try RepoFiles.read("appleApp/FTWWidgets/GridToday/GridColors.swift")
- func pair(_ name: String) throws -> [String] {
- let line = try #require(source.split(separator: "\n").first { $0.contains("static let \(name) = Pair(") })
- return line.matches(of: /0x([0-9A-Fa-f]{6})/).map { String($0.1).uppercased() }
+ /// The grid widget draws the grid in the app's own grid role, magenta
+ /// both ways, from the shared catalog: no colour of its own and no
+ /// other energy's hue.
+ @Test func theWidgetDrawsTheGridInTheGridRole() throws {
+ let source = try RepoFiles.read("appleApp/FTWWidgets/GridToday/GridTodayViews.swift")
+ #expect(source.contains("Theme.grid"))
+ for other in ["solar", "gives", "stores", "car", "live", "alarm"] {
+ #expect(!source.contains("Theme.\(other)"), "the grid widget uses \(other)")
}
- func role(_ name: String) throws -> [String] {
- let text = try RepoFiles.read("appleApp/FTW/UI/Theme.xcassets/\(name).colorset/Contents.json")
- let json = try #require(try JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any])
- let colors = try #require(json["colors"] as? [[String: Any]])
- // Light first, then dark, as `Pair` takes them.
- let sorted = colors.sorted { ($0["appearances"] == nil ? 0 : 1) < ($1["appearances"] == nil ? 0 : 1) }
- return try sorted.map { c in
- let comps = try #require((c["color"] as? [String: Any])?["components"] as? [String: String])
- return ["red", "green", "blue"].map { String(comps[$0]!.dropFirst(2)) }.joined().uppercased()
- }
- }
- let importing = try role("ftw.importing"), exporting = try role("ftw.exporting")
- #expect(try pair("importing") == importing)
- #expect(try pair("exporting") == exporting)
- #expect(importing.count == 2)
+ _ = try RepoFiles.read("appleApp/FTWCharts/Theme.xcassets/ftw.grid.colorset/Contents.json")
}
}
diff --git a/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift
index 554bf87..41299cf 100644
--- a/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift
+++ b/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift
@@ -790,7 +790,7 @@ import FTWShared
@Test func everySentenceHasSwedish() throws {
let files = [
"appleApp/FTW/UI/NowView.swift", "appleApp/FTW/UI/FlowDiagram.swift", "appleApp/FTW/UI/NodeView.swift",
- "appleApp/FTW/UI/NodeCharts.swift", "appleApp/FTW/UI/ModeSheet.swift", "appleApp/FTW/UI/PriceChart.swift",
+ "appleApp/FTW/UI/NodeCharts.swift", "appleApp/FTW/UI/ModeSheet.swift",
"appleApp/FTW/UI/PlanCard.swift", "appleApp/FTW/UI/PlanView.swift", "appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift",
"appleApp/FTWKit/Sources/FTWKit/State/EnergyNode.swift", "appleApp/FTWKit/Sources/FTWKit/State/NowFlow.swift",
"appleApp/FTWKit/Sources/FTWKit/State/ModeModel.swift", "appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift",
diff --git a/appleApp/FTWKit/Tests/FTWKitTests/ThemeTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/ThemeTests.swift
index e9f9acc..22a3c6b 100644
--- a/appleApp/FTWKit/Tests/FTWKitTests/ThemeTests.swift
+++ b/appleApp/FTWKit/Tests/FTWKitTests/ThemeTests.swift
@@ -1,29 +1,36 @@
import Foundation
import Testing
-/// The app's colour roles, read from `Theme.xcassets`, keep text readable:
-/// every role drawn as text reaches 4.5:1 on every surface it sits on, in
-/// light and dark, and Increase Contrast only ever raises it.
+/// The app's palette, read from `FTWCharts/Theme.xcassets`, the one source
+/// the app and the widgets draw with. Text reaches 4.5:1 on every surface
+/// it sits on, in light and dark; an energy colour reads as a word in light
+/// and as a stroke without its glow in dark; Increase Contrast only ever
+/// raises it; and no orange is left.
@Suite struct ThemeTests {
- struct RGB { let r, g, b: Double }
+ struct RGB: Equatable { let r, g, b: Double; let p3: Bool }
enum Look: CaseIterable { case light, dark, lightHigh, darkHigh }
+ static let catalog = "appleApp/FTWCharts/Theme.xcassets"
+ static let roles = ["ground", "raised", "hairline", "ink", "ink2", "ink3", "solar", "gives", "stores", "grid", "car", "live", "alarm"]
+ static let energy = ["solar", "gives", "stores", "grid", "car"]
+
/// One colour set, by look. A look the set does not name falls back the
/// way the asset catalogue does: high contrast to normal, dark to light.
- func color(_ role: String, _ look: Look) throws -> RGB {
- let text = try RepoFiles.read("appleApp/FTW/UI/Theme.xcassets/\(role).colorset/Contents.json")
+ func color(_ role: String, _ look: Look, in path: String? = nil) throws -> RGB {
+ let text = try RepoFiles.read(path ?? "\(Self.catalog)/ftw.\(role).colorset/Contents.json")
let json = try #require(try JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any])
let colors = try #require(json["colors"] as? [[String: Any]])
var byLook: [Set: RGB] = [:]
for c in colors {
let marks = Set(((c["appearances"] as? [[String: String]]) ?? []).compactMap { $0["value"] })
- let comps = try #require((c["color"] as? [String: Any])?["components"] as? [String: String])
+ let color = try #require(c["color"] as? [String: Any])
+ let comps = try #require(color["components"] as? [String: String])
func channel(_ k: String) throws -> Double {
let v = try #require(comps[k])
return v.hasPrefix("0x") ? Double(Int(v.dropFirst(2), radix: 16) ?? 0) / 255 : (Double(v) ?? 0)
}
- byLook[marks] = RGB(r: try channel("red"), g: try channel("green"), b: try channel("blue"))
+ byLook[marks] = RGB(r: try channel("red"), g: try channel("green"), b: try channel("blue"), p3: color["color-space"] as? String == "display-p3")
}
let wanted: [Set]
switch look {
@@ -35,9 +42,11 @@ import Testing
return try #require(wanted.lazy.compactMap { byLook[$0] }.first, "\(role) has no colour for \(look)")
}
+ /// Relative luminance, with the primaries of the colour's own space.
func luminance(_ c: RGB) -> Double {
- func lin(_ x: Double) -> Double { x <= 0.039_28 ? x / 12.92 : pow((x + 0.055) / 1.055, 2.4) }
- return 0.2126 * lin(c.r) + 0.7152 * lin(c.g) + 0.0722 * lin(c.b)
+ func lin(_ x: Double) -> Double { x <= 0.040_45 ? x / 12.92 : pow((x + 0.055) / 1.055, 2.4) }
+ let k = c.p3 ? (0.228_975, 0.691_739, 0.079_287) : (0.2126, 0.7152, 0.0722)
+ return k.0 * lin(c.r) + k.1 * lin(c.g) + k.2 * lin(c.b)
}
func contrast(_ a: RGB, _ b: RGB) -> Double {
@@ -45,36 +54,89 @@ import Testing
return (max(x, y) + 0.05) / (min(x, y) + 0.05)
}
- static let surfaces = ["ftw.surface", "ftw.surfaceRaised", "ftw.surfaceSunken", "ftw.surfaceElevated"]
+ func ratio(_ role: String, on surface: String, _ look: Look) throws -> Double {
+ contrast(try color(role, look), try color(surface, look))
+ }
+
+ /// Display P3, with a light and a dark colour, for every role.
+ @Test(arguments: ThemeTests.roles)
+ func everyRoleIsDisplayP3InLightAndDark(_ role: String) throws {
+ let light = try color(role, .light), dark = try color(role, .dark)
+ #expect(light.p3 && dark.p3, "\(role) is not authored in Display P3")
+ #expect(light != dark, "\(role) has no dark variant")
+ }
- @Test(arguments: ["ftw.fg", "ftw.fgDim", "ftw.fgMuted", "ftw.accent"])
+ /// Ink, ink2 and ink3 are text: 4.5:1 on the ground and on raised rows,
+ /// in every look.
+ @Test(arguments: ["ink", "ink2", "ink3"])
func textReadsOnEverySurface(_ role: String) throws {
for look in Look.allCases {
- for surface in Self.surfaces {
- let ratio = contrast(try color(role, look), try color(surface, look))
- #expect(ratio >= 4.5, "\(role) on \(surface), \(look): \(String(format: "%.2f", ratio)):1")
+ for surface in ["ground", "raised"] {
+ let r = try ratio(role, on: surface, look)
+ #expect(r >= 4.5, "\(role) on \(surface), \(look): \(String(format: "%.2f", r)):1")
}
}
}
- /// Increase Contrast makes the text roles stronger, never weaker.
- @Test(arguments: ["ftw.fgDim", "ftw.fgMuted", "ftw.accent", "ftw.accentFill"])
- func increaseContrastRaisesIt(_ role: String) throws {
+ /// An energy colour reads as a word on paper, and in dark its stroke
+ /// alone carries 7:1, so the glow is extra and sunlight cannot erase a
+ /// reading. Live and alarm read as words in both.
+ @Test(arguments: ThemeTests.energy + ["live", "alarm"])
+ func energyAndStatesRead(_ role: String) throws {
+ let light = try ratio(role, on: "ground", .light)
+ let dark = try ratio(role, on: "ground", .dark)
+ #expect(light >= 4.5, "\(role) in light: \(String(format: "%.2f", light)):1")
+ #expect(dark >= (Self.energy.contains(role) ? 7 : 4.5), "\(role) in dark: \(String(format: "%.2f", dark)):1")
+ }
+
+ /// Increase Contrast turns ink3 into ink2, and raises it on every
+ /// surface.
+ @Test func increaseContrastMakesInk3Ink2() throws {
for (normal, high) in [(Look.light, Look.lightHigh), (.dark, .darkHigh)] {
- for surface in Self.surfaces {
- let before = contrast(try color(role, normal), try color(surface, normal))
- let after = contrast(try color(role, high), try color(surface, high))
- #expect(after > before, "\(role) on \(surface): \(before) → \(after)")
+ #expect(try color("ink3", high) == color("ink2", normal))
+ for surface in ["ground", "raised"] {
+ #expect(try ratio("ink3", on: surface, high) > ratio("ink3", on: surface, normal))
}
}
}
- /// The fill keeps the brand orange; the text role is its darker sibling
- /// in light and the same colour in dark, where the orange already reads.
- @Test func theFillIsTheBrandOrange() throws {
- let fill = try color("ftw.accentFill", .light)
- #expect(Int((fill.r * 255).rounded()) == 0xDA && Int((fill.g * 255).rounded()) == 0x7F && fill.b == 0)
- let text = try color("ftw.accent", .light)
- #expect(luminance(text) < luminance(fill))
+ /// The system tint is ink and the launch screen is the ground, in the
+ /// app's own catalog, which the system reads before any view.
+ @Test func theTintIsInkAndTheLaunchIsTheGround() throws {
+ for look in [Look.light, .dark] {
+ #expect(try color("", look, in: "appleApp/FTW/Assets.xcassets/AccentColor.colorset/Contents.json") == color("ink", look))
+ #expect(try color("", look, in: "appleApp/FTW/Assets.xcassets/LaunchBackground.colorset/Contents.json") == color("ground", look))
+ }
+ }
+
+ /// The app, its icon, the widgets and the charts they share: no 0.3
+ /// orange in any colour set or file, and no colour written in code. Colours live in the
+ /// catalog, so a widget can never drift from the app.
+ @Test func noOrangeAndNoColourInCode() throws {
+ let orange: Set = ["DA7F00", "FFAC41"]
+ for folder in ["appleApp/FTW", "appleApp/FTWCharts", "appleApp/FTWWidgets", "appleApp/FTWNotify"] {
+ let root = RepoFiles.root.appendingPathComponent(folder)
+ let files = try #require(FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil))
+ for case let url as URL in files {
+ let name = url.lastPathComponent
+ guard ["swift", "json", "svg"].contains(url.pathExtension) else { continue }
+ let text = try String(contentsOf: url, encoding: .utf8)
+ let shown = url.path.replacingOccurrences(of: RepoFiles.root.path + "/", with: "")
+ for hex in orange {
+ #expect(!text.uppercased().contains(hex), "\(shown) holds #\(hex)")
+ }
+ if name == "Contents.json", text.contains("\"components\"") {
+ for look in [Look.light, .dark, .lightHigh, .darkHigh] {
+ let c = try color("", look, in: shown)
+ let hex = String(format: "%02X%02X%02X", Int((c.r * 255).rounded()), Int((c.g * 255).rounded()), Int((c.b * 255).rounded()))
+ #expect(!orange.contains(hex), "\(shown) is #\(hex) in \(look)")
+ }
+ }
+ if url.pathExtension == "swift" {
+ #expect(!text.contains("Color(red:"), "\(shown) writes a colour in code")
+ #expect(text.firstMatch(of: /Pair\(0x/) == nil, "\(shown) writes a colour in code")
+ }
+ }
+ }
}
}
diff --git a/appleApp/FTWWidgets/Control/ChargeNowControl.swift b/appleApp/FTWWidgets/Control/ChargeNowControl.swift
index 6e21b93..c9d2266 100644
--- a/appleApp/FTWWidgets/Control/ChargeNowControl.swift
+++ b/appleApp/FTWWidgets/Control/ChargeNowControl.swift
@@ -6,10 +6,10 @@ import WidgetKit
private let log = Logger(subsystem: "energy.ftw.app", category: "control")
-/// The app's filled-button orange (`ftw.accentFill`). Control Center draws
-/// an on switch's bolt in it on white, in light and dark alike, and the car
-/// around it lighter; without it both were a pale orange.
-private let onTint = Color(red: 0xDA / 255, green: 0x7F / 255, blue: 0)
+/// The car's colour as light mode draws it (`ftw.car`). Control Center
+/// draws an on switch's bolt in it on white, in light and dark alike, so it
+/// takes the variant that reads on white.
+private let onTint = Theme.fixed(Theme.car, .light)
/// Charge now, as a switch in Control Center, on the Lock Screen and on the
/// Action button. iPhone's controls also appear on a paired Apple Watch on
diff --git a/appleApp/FTWWidgets/GridToday/GridColors.swift b/appleApp/FTWWidgets/GridToday/GridColors.swift
deleted file mode 100644
index 61449be..0000000
--- a/appleApp/FTWWidgets/GridToday/GridColors.swift
+++ /dev/null
@@ -1,17 +0,0 @@
-import SwiftUI
-
-/// The grid widget's two colours, in one place. They are FTW's own roles:
-/// `importing` for what the home buys and `exporting` for what it sells, as
-/// `Theme.importing` and `Theme.exporting` (`ftw.importing` and
-/// `ftw.exporting` in Theme.xcassets). The request asked for purple bars,
-/// but purple is the car in FTW (`Theme.mobility`). A test holds these two
-/// to the asset catalog.
-enum GridColors {
- static let importing = Pair(0xCC243D, 0xFF6E74)
- static let exporting = Pair(0x009639, 0x5FD37F)
-
- /// The import bars, and the arrow on a day that bought more.
- static var bars: Pair { importing }
- /// The export curve, the arrow on a day that sold more, and money earned.
- static var curve: Pair { exporting }
-}
diff --git a/appleApp/FTWWidgets/GridToday/GridTodayViews.swift b/appleApp/FTWWidgets/GridToday/GridTodayViews.swift
index 05b53c9..f15161c 100644
--- a/appleApp/FTWWidgets/GridToday/GridTodayViews.swift
+++ b/appleApp/FTWWidgets/GridToday/GridTodayViews.swift
@@ -147,7 +147,7 @@ struct NetFigure: View {
if let symbol = moment.direction.symbol {
Image(systemName: symbol)
.font(.system(size: size * 0.62, weight: .bold))
- .foregroundStyle(moment.direction == .fromGrid ? GridColors.bars : GridColors.curve)
+ .foregroundStyle(Theme.grid)
.widgetAccentable()
}
Text(moment.netText ?? "")
@@ -163,7 +163,7 @@ struct NetFigure: View {
}
}
-/// "⚡ 12,40 kr", or "−4,20 kr" in the colour of money earned.
+/// "⚡ 12,40 kr", or "−4,20 kr" in the grid's colour when the grid paid it.
struct CostFigure: View {
let moment: GridGlance.Moment
let size: CGFloat
@@ -176,7 +176,7 @@ struct CostFigure: View {
.widgetAccentable()
Text(moment.costText ?? "–")
.font(.system(size: size, weight: .semibold, design: .rounded).monospacedDigit())
- .foregroundStyle(moment.earned ? AnyShapeStyle(GridColors.curve) : AnyShapeStyle(.primary))
+ .foregroundStyle(moment.earned ? AnyShapeStyle(Theme.grid) : AnyShapeStyle(.primary))
.contentTransition(.numericText())
if moment.costText != nil {
Text(moment.costUnit)
@@ -248,7 +248,8 @@ struct GridEmpty: View {
/// The day on a fixed 00–24 axis: a bar for each quarter hour that bought,
/// a curve with a soft fill below zero for what was sold, nothing for the
-/// hours still to come, and a break wherever the box has no record.
+/// hours still to come, and a break wherever the box has no record. Both
+/// are the grid's magenta; the side of zero says which way.
///
/// Tinted and clear home screens draw in one colour: there the bars are
/// solid and the curve and its fill fainter, and zero tells them apart.
@@ -272,16 +273,16 @@ struct GridDayChart: View {
yStart: .value("Zero", 0.0),
yEnd: .value("Bought", bar.watts)
)
- .foregroundStyle(full ? AnyShapeStyle(GridColors.bars) : AnyShapeStyle(.primary))
+ .foregroundStyle(full ? AnyShapeStyle(Theme.grid) : AnyShapeStyle(.primary))
.cornerRadius(1)
}
ForEach(moment.curve) { p in
AreaMark(x: .value("Time", date(p.t)), yStart: .value("Sold", p.watts), yEnd: .value("Zero", 0.0), series: .value("Run", "\(p.run)-"))
.interpolationMethod(.monotone)
- .foregroundStyle(full ? AnyShapeStyle(GridColors.curve.opacity(0.22)) : AnyShapeStyle(.primary.opacity(0.18)))
+ .foregroundStyle(full ? AnyShapeStyle(Theme.grid.opacity(0.22)) : AnyShapeStyle(.primary.opacity(0.18)))
LineMark(x: .value("Time", date(p.t)), y: .value("Sold", p.watts), series: .value("Run", "\(p.run)"))
.interpolationMethod(.monotone)
- .foregroundStyle(full ? AnyShapeStyle(GridColors.curve) : AnyShapeStyle(.primary.opacity(0.7)))
+ .foregroundStyle(full ? AnyShapeStyle(Theme.grid) : AnyShapeStyle(.primary.opacity(0.7)))
.lineStyle(StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round))
}
}
diff --git a/appleApp/FTWWidgets/LiveActivity/ChargeNowLiveActivity.swift b/appleApp/FTWWidgets/LiveActivity/ChargeNowLiveActivity.swift
index 90b1437..5cb24bd 100644
--- a/appleApp/FTWWidgets/LiveActivity/ChargeNowLiveActivity.swift
+++ b/appleApp/FTWWidgets/LiveActivity/ChargeNowLiveActivity.swift
@@ -53,7 +53,7 @@ struct ChargeNowLiveActivity: Widget {
Bolt(face: face)
}
// The island is always dark.
- .keylineTint(face.dimmed ? Color.secondary : Tone.accent.dark)
+ .keylineTint(face.dimmed ? Color.secondary : Theme.fixed(Theme.car, .dark))
}
.supplementalActivityFamilies([.small])
}
@@ -178,7 +178,7 @@ private struct Elapsed: View {
timer
.monospacedDigit()
.multilineTextAlignment(.trailing)
- .foregroundStyle(face.dimmed || face.timer == .waiting ? AnyShapeStyle(.secondary) : AnyShapeStyle(Tone.accent))
+ .foregroundStyle(face.dimmed || face.timer == .waiting ? AnyShapeStyle(.secondary) : AnyShapeStyle(Theme.car))
.lineLimit(1)
.contentTransition(.numericText(countsDown: false))
// Plain text has its own width; the stale headline beside it
@@ -224,7 +224,7 @@ private struct StepBand: View {
private func fill(_ step: Int) -> AnyShapeStyle {
guard step < face.steps else { return AnyShapeStyle(.quaternary) }
- return face.dimmed ? AnyShapeStyle(.secondary) : AnyShapeStyle(Tone.accent)
+ return face.dimmed ? AnyShapeStyle(.secondary) : AnyShapeStyle(Theme.car)
}
}
@@ -236,7 +236,7 @@ private struct Bolt: View {
var body: some View {
Image(systemName: face.dimmed ? "bolt.car" : "bolt.car.fill")
- .foregroundStyle(dim || face.dimmed ? AnyShapeStyle(.secondary) : AnyShapeStyle(Tone.accent))
+ .foregroundStyle(dim || face.dimmed ? AnyShapeStyle(.secondary) : AnyShapeStyle(Theme.car))
.contentTransition(.symbolEffect(.replace))
}
}
diff --git a/appleApp/FTWWidgets/Plan/PlanViews.swift b/appleApp/FTWWidgets/Plan/PlanViews.swift
index b7107e1..a5ebe95 100644
--- a/appleApp/FTWWidgets/Plan/PlanViews.swift
+++ b/appleApp/FTWWidgets/Plan/PlanViews.swift
@@ -271,16 +271,14 @@ struct WidgetLanes: View {
}
extension Tone {
- static let mobility = Pair(0x7F5BB6, 0xCCA8FF)
- static let storage = Pair(0x008FA8, 0x13DCF6)
- static let generation = Pair(0xD48500, 0xFFB000)
-
- static func of(_ kind: PlanGlance.Kind) -> Pair {
+ static func of(_ kind: PlanGlance.Kind) -> Color {
switch kind {
- case .car: return mobility
- case .charge: return storage
- case .discharge: return generation
- case .cheap: return cheap
+ case .car: return Theme.car
+ case .charge: return Theme.stores
+ case .discharge: return Theme.gives
+ case .cheap: return Theme.ink2
}
}
+
+ static func of(_ tone: PriceStrip.Tone) -> Color { Theme.price(tone) }
}
diff --git a/appleApp/FTWWidgets/PriceViews.swift b/appleApp/FTWWidgets/PriceViews.swift
index 47523fa..9e5a3b1 100644
--- a/appleApp/FTWWidgets/PriceViews.swift
+++ b/appleApp/FTWWidgets/PriceViews.swift
@@ -191,7 +191,6 @@ struct Empty: View {
struct Curve: View {
let moment: PriceGlance.Moment
@Environment(\.widgetRenderingMode) private var renderingMode
- @Environment(\.colorScheme) private var colorScheme
var body: some View {
PriceLineChart(
@@ -201,7 +200,7 @@ struct Curve: View {
cheapest: moment.cheapest.map { $0.startMs...$0.endMs },
cheapestLabel: nil,
meanLabel: nil,
- palette: Tone.palette(colorScheme),
+ palette: .ink,
zone: moment.timeZone,
labelEveryHours: 3,
monochrome: renderingMode != .fullColor
@@ -211,52 +210,15 @@ struct Curve: View {
}
}
-/// The app's colour roles (FTW/UI/Theme.swift, from the web app's tokens),
-/// the few a price widget needs.
+/// The roles a widget needs, from the app's own palette
+/// (FTWCharts/Theme.xcassets), so a widget never drifts from the app.
enum Tone {
- static let surface = Pair(0xFAFAF8, 0x161616)
- static let empty = Pair(0xDCDCD6, 0x2A2A2A)
- static let cheap = Pair(0x009639, 0x5FD37F)
- static let dear = Pair(0xCC243D, 0xFF6E74)
- static let negative = Pair(0xD48500, 0xFFB000)
- static let flat = Pair(0x686862, 0x858585)
- /// Text and glyphs: the app's darker accent in light, so it reads on
- /// the widget's surface.
- static let accent = Pair(0x9C5800, 0xFFAC41)
- /// Fills and lines: the brand orange.
- static let accentFill = Pair(0xDA7F00, 0xFFAC41)
-
- /// The price chart's colours, for the scheme the widget draws in.
- static func palette(_ scheme: ColorScheme) -> PriceChartPalette {
- func pick(_ p: Pair) -> Color { scheme == .dark ? p.dark : p.light }
- return PriceChartPalette(cheap: pick(cheap), flat: pick(flat), dear: pick(dear), now: pick(accentFill), quiet: pick(flat), labelBackground: pick(surface))
- }
-
- static func of(_ tone: PriceStrip.Tone) -> Pair {
- switch tone {
- case .cheap: return cheap
- case .dear: return dear
- case .flat: return flat
- case .negative: return negative
- }
- }
-}
-
-/// One colour for light and one for dark, picked by SwiftUI where it draws.
-struct Pair: ShapeStyle {
- let light: Color
- let dark: Color
-
- init(_ light: UInt32, _ dark: UInt32) {
- self.light = Self.color(light)
- self.dark = Self.color(dark)
- }
-
- func resolve(in environment: EnvironmentValues) -> Color {
- environment.colorScheme == .dark ? dark : light
- }
-
- private static func color(_ rgb: UInt32) -> Color {
- Color(red: Double((rgb >> 16) & 0xFF) / 255, green: Double((rgb >> 8) & 0xFF) / 255, blue: Double(rgb & 0xFF) / 255)
- }
+ static let surface = Theme.ground
+ /// Behind a widget with nothing to show.
+ static let empty = Theme.hairline
+ static let flat = Theme.ink3
+ /// Text and glyphs: the interface is ink.
+ static let accent = Theme.ink
+ /// "Cheapest 02–04": ink, as every price is.
+ static let cheap = Theme.ink
}