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 }