From 29b69acdd4a1bf2ae794be9cad000a4afd0482c7 Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 23:11:46 +0200 Subject: [PATCH 01/18] DesignTokens: add Spacing.xxs, Size.hairline, Size.notificationBottomInset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preparation for the code-structure / hardcoded-values cleanup tracked in #33. Adds tokens for the gaps in the existing table: - Spacing.xxs (2pt) covers the many spacing: 2 / .padding(.vertical, 2) call sites in cards and notifications. - Size.hairline (0.5pt) replaces the .frame(height: 0.5) divider rule that appears in 4+ card files. - Size.notificationBottomInset (80pt) replaces the .padding(.bottom, 80) used three times in InAppNotificationOverlay. No call sites migrated yet — that's the next commit. Refs #33 Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee/Shared/DesignTokens.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Shellbee/Shared/DesignTokens.swift b/Shellbee/Shared/DesignTokens.swift index fd78422..95250a7 100644 --- a/Shellbee/Shared/DesignTokens.swift +++ b/Shellbee/Shared/DesignTokens.swift @@ -2,6 +2,7 @@ import SwiftUI nonisolated enum DesignTokens { nonisolated enum Spacing { + static let xxs: CGFloat = 2 static let xs: CGFloat = 4 static let sm: CGFloat = 8 static let md: CGFloat = 12 @@ -53,6 +54,8 @@ nonisolated enum DesignTokens { static let levelIndicatorHeight: CGFloat = 20 static let cardStroke: CGFloat = 1 static let badgeStroke: CGFloat = 0.5 + static let hairline: CGFloat = 0.5 + static let notificationBottomInset: CGFloat = 80 static let heroCardMinHeight: CGFloat = 200 static let metricCardMinHeight: CGFloat = 120 static let bridgeCardMinHeight: CGFloat = 164 From 4496eb5dc9102b3810fc498962b78ea6370a6d58 Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 23:13:33 +0200 Subject: [PATCH 02/18] FanControlCard: replace hardcoded SwiftUI literals with DesignTokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snaps spacing: 5 → Spacing.xs (4) and spacing: 4 → Spacing.xs across the hero/filter views, spacing: 2 → Spacing.xxs in the hero air-quality stack and age tile, frame(height: 0.5) → Size.hairline in the divider rule, and collapses the local rowIconWidth: 22 constant onto Size.cardSymbol so the card no longer redeclares a token. spacing: 0 left in place — semantic "no gap", not a literal-by-eye choice. No visible UI changes intended; the 5→4 and 4→4 snap shifts a couple of pixel-thin gaps by ≤1pt, which is the documented goal of the cleanup. Refs #33 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Shared/FanControl/FanControlCard.swift | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Shellbee/Shared/FanControl/FanControlCard.swift b/Shellbee/Shared/FanControl/FanControlCard.swift index d171e21..a47000c 100644 --- a/Shellbee/Shared/FanControl/FanControlCard.swift +++ b/Shellbee/Shared/FanControl/FanControlCard.swift @@ -15,7 +15,7 @@ struct FanControlCard: View { private let rowHorizontalPadding: CGFloat = DesignTokens.Spacing.lg private let rowVerticalPadding: CGFloat = DesignTokens.Spacing.md - private let rowIconWidth: CGFloat = 22 + private let rowIconWidth: CGFloat = DesignTokens.Size.cardSymbol var body: some View { VStack(spacing: DesignTokens.Spacing.lg) { @@ -154,7 +154,7 @@ struct FanControlCard: View { } private var heroEyebrow: some View { - HStack(spacing: 5) { + HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: hasAirSensors ? "aqi.medium" : (context.isOn ? "fan.fill" : "fan")) .font(.system(size: 11, weight: .bold)) .symbolRenderingMode(.hierarchical) @@ -169,9 +169,9 @@ struct FanControlCard: View { @ViewBuilder private var heroValue: some View { if hasAirSensors { - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.xxs) { if let pm = pm25Value { - HStack(alignment: .firstTextBaseline, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xs) { Text(Int(pm.rounded()).formatted()) .font(.system(size: 56, weight: .bold, design: .rounded)) .monospacedDigit() @@ -234,7 +234,7 @@ struct FanControlCard: View { private var hairline: some View { Rectangle() .fill(Color.primary.opacity(0.08)) - .frame(height: 0.5) + .frame(height: DesignTokens.Size.hairline) } // MARK: - Hero mode row @@ -264,7 +264,7 @@ struct FanControlCard: View { } } } label: { - HStack(spacing: 4) { + HStack(spacing: DesignTokens.Spacing.xs) { Text(prettify(context.fanMode ?? "—")) .foregroundStyle(.primary) Image(systemName: "chevron.up.chevron.down") @@ -330,7 +330,7 @@ struct FanControlCard: View { return VStack(alignment: .leading, spacing: DesignTokens.Spacing.xl) { VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { - HStack(spacing: 5) { + HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: icon) .font(.system(size: 11, weight: .bold)) .symbolRenderingMode(.hierarchical) @@ -381,7 +381,7 @@ struct FanControlCard: View { private func ageTile(label: String, minutes: Double, icon: String) -> some View { let parts = formatDurationParts(minutes) return VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { - HStack(alignment: .firstTextBaseline, spacing: 5) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xs) { Image(systemName: icon) .font(.system(size: 11, weight: .bold)) .symbolRenderingMode(.hierarchical) @@ -394,7 +394,7 @@ struct FanControlCard: View { } .foregroundStyle(.secondary) - HStack(alignment: .firstTextBaseline, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xxs) { Text(parts.value) .font(.system(size: 30, weight: .semibold, design: .rounded)) .monospacedDigit() @@ -647,7 +647,7 @@ private struct FanExtraRow: View { } } } label: { - HStack(spacing: 4) { + HStack(spacing: DesignTokens.Spacing.xs) { Text(prettify(current)) Image(systemName: "chevron.up.chevron.down") .font(.caption2.weight(.semibold)) From 0a85b02b9ab8299c17ade9714b5f09bb0120a17f Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 23:14:46 +0200 Subject: [PATCH 03/18] LightControlCard: replace hardcoded SwiftUI literals with DesignTokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snaps spacing: 5 → Spacing.xs in eyebrows and snapshot info rows, spacing: 4 → Spacing.xs in the brightness percent stack, spacing: 2 → Spacing.xxs in the snapshot value+unit row, frame(height: 0.5) → Size.hairline for the divider, frame(width: 22) → Size.cardSymbol for the color preview circle, frame(width: 18) → Size.summaryRowSymbol for the inline color dot, and .padding(.leading, 4) → Spacing.xs. Refs #33 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../LightControl/LightControlCard.swift | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Shellbee/Shared/LightControl/LightControlCard.swift b/Shellbee/Shared/LightControl/LightControlCard.swift index 6823f59..d7e3ebc 100644 --- a/Shellbee/Shared/LightControl/LightControlCard.swift +++ b/Shellbee/Shared/LightControl/LightControlCard.swift @@ -94,7 +94,7 @@ struct LightControlCard: View { @ViewBuilder private var interactiveContent: some View { HStack(spacing: DesignTokens.Spacing.sm) { - HStack(spacing: 5) { + HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: context.isOn ? "lightbulb.fill" : "lightbulb") .font(.system(size: 11, weight: .bold)) .symbolRenderingMode(.hierarchical) @@ -170,7 +170,7 @@ struct LightControlCard: View { private var snapshotHero: some View { HStack(alignment: .top, spacing: DesignTokens.Spacing.md) { VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { - HStack(spacing: 5) { + HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: context.isOn ? "lightbulb.fill" : "lightbulb") .font(.system(size: 11, weight: .bold)) .symbolRenderingMode(.hierarchical) @@ -192,7 +192,7 @@ struct LightControlCard: View { @ViewBuilder private var snapshotHeroValue: some View { if context.isOn, context.brightness != nil { - HStack(alignment: .firstTextBaseline, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xs) { Text("\(context.brightnessPercent)") .font(.system(size: 56, weight: .bold, design: .rounded)) .monospacedDigit() @@ -226,7 +226,7 @@ struct LightControlCard: View { private var hairline: some View { Rectangle() .fill(Color.primary.opacity(0.08)) - .frame(height: 0.5) + .frame(height: DesignTokens.Size.hairline) } private var hasColorOrTempInfo: Bool { @@ -245,7 +245,7 @@ struct LightControlCard: View { ) } else if isColorMode { HStack(alignment: .firstTextBaseline) { - HStack(spacing: 5) { + HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: "paintpalette.fill") .font(.system(size: 11, weight: .bold)) .symbolRenderingMode(.hierarchical) @@ -258,7 +258,7 @@ struct LightControlCard: View { Spacer() Circle() .fill(context.displayColor) - .frame(width: 22, height: 22) + .frame(width: DesignTokens.Size.cardSymbol, height: DesignTokens.Size.cardSymbol) .overlay(Circle().stroke(.separator, lineWidth: DesignTokens.Size.badgeStroke)) } } @@ -266,7 +266,7 @@ struct LightControlCard: View { private func snapshotInfoRow(icon: String, label: String, value: String, unit: String) -> some View { HStack(alignment: .firstTextBaseline) { - HStack(spacing: 5) { + HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: icon) .font(.system(size: 11, weight: .bold)) .symbolRenderingMode(.hierarchical) @@ -279,7 +279,7 @@ struct LightControlCard: View { } .foregroundStyle(.secondary) Spacer() - HStack(alignment: .firstTextBaseline, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xxs) { Text(value) .font(.system(size: 20, weight: .semibold, design: .rounded)) .monospacedDigit() @@ -289,9 +289,9 @@ struct LightControlCard: View { .foregroundStyle(.secondary) Circle() .fill(context.displayColor) - .frame(width: 18, height: 18) + .frame(width: DesignTokens.Size.summaryRowSymbol, height: DesignTokens.Size.summaryRowSymbol) .overlay(Circle().stroke(.separator, lineWidth: DesignTokens.Size.badgeStroke)) - .padding(.leading, 4) + .padding(.leading, DesignTokens.Spacing.xs) } } } From 14f9882cbae0419665bd530d4b5c0914a3d94abc Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 23:16:56 +0200 Subject: [PATCH 04/18] Cover/Climate cards: replace hardcoded SwiftUI literals with DesignTokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same pattern as the Fan and Light card sweeps: spacing: 5/4 → Spacing.xs, spacing: 2 → Spacing.xxs, frame(height: 0.5) → Size.hairline, plus a spacing: 6 in the cover open/stop/close action button row → Spacing.xs. Adds two new climate-scoped Size tokens: - climateActionButton (32) for the +/- setpoint buttons - climateSetpointMinWidth (72) for the temperature label, which needs a fixed min-width to prevent layout shift when the value changes. Refs #33 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Shared/ClimateControl/ClimateControlCard.swift | 14 +++++++------- .../Shared/CoverControl/CoverControlCard.swift | 14 +++++++------- Shellbee/Shared/DesignTokens.swift | 2 ++ 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/Shellbee/Shared/ClimateControl/ClimateControlCard.swift b/Shellbee/Shared/ClimateControl/ClimateControlCard.swift index fafb79d..9bf19ba 100644 --- a/Shellbee/Shared/ClimateControl/ClimateControlCard.swift +++ b/Shellbee/Shared/ClimateControl/ClimateControlCard.swift @@ -82,7 +82,7 @@ struct ClimateControlCard: View { } private var heroEyebrow: some View { - HStack(spacing: 5) { + HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: heroIcon) .font(.system(size: 11, weight: .bold)) .symbolRenderingMode(.hierarchical) @@ -106,7 +106,7 @@ struct ClimateControlCard: View { @ViewBuilder private var heroValue: some View { - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.xxs) { Text(context.displayTemperature) .font(.system(size: 56, weight: .bold, design: .rounded)) .monospacedDigit() @@ -133,7 +133,7 @@ struct ClimateControlCard: View { private var hairline: some View { Rectangle() .fill(Color.primary.opacity(0.08)) - .frame(height: 0.5) + .frame(height: DesignTokens.Size.hairline) } // MARK: - Setpoint row @@ -146,7 +146,7 @@ struct ClimateControlCard: View { private var setpointRow: some View { HStack(alignment: .center, spacing: DesignTokens.Spacing.md) { - HStack(spacing: 5) { + HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: "target") .font(.system(size: 11, weight: .bold)) .symbolRenderingMode(.hierarchical) @@ -171,7 +171,7 @@ struct ClimateControlCard: View { .font(.system(size: 24, weight: .semibold, design: .rounded)) .monospacedDigit() .foregroundStyle(heroTint) - .frame(minWidth: 72) + .frame(minWidth: DesignTokens.Size.climateSetpointMinWidth) .contentTransition(.numericText(value: setpointDraft)) .animation(.snappy, value: setpointDraft) @@ -190,7 +190,7 @@ struct ClimateControlCard: View { Image(systemName: systemImage) .font(.system(size: 14, weight: .bold)) .foregroundStyle(heroTint) - .frame(width: 32, height: 32) + .frame(width: DesignTokens.Size.climateActionButton, height: DesignTokens.Size.climateActionButton) .background(heroTint.opacity(0.15), in: Circle()) } .buttonStyle(.plain) @@ -201,7 +201,7 @@ struct ClimateControlCard: View { @ViewBuilder private func modeRow(modes: [String]) -> some View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { - HStack(spacing: 5) { + HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: "dial.medium") .font(.system(size: 11, weight: .bold)) .symbolRenderingMode(.hierarchical) diff --git a/Shellbee/Shared/CoverControl/CoverControlCard.swift b/Shellbee/Shared/CoverControl/CoverControlCard.swift index bc4b4ac..684fa5d 100644 --- a/Shellbee/Shared/CoverControl/CoverControlCard.swift +++ b/Shellbee/Shared/CoverControl/CoverControlCard.swift @@ -81,7 +81,7 @@ struct CoverControlCard: View { } private var heroEyebrow: some View { - HStack(spacing: 5) { + HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: isFullyClosed ? "blinds.horizontal.closed" : "blinds.horizontal.open") .font(.system(size: 11, weight: .bold)) .symbolRenderingMode(.hierarchical) @@ -102,8 +102,8 @@ struct CoverControlCard: View { @ViewBuilder private var heroValue: some View { if context.positionFeature != nil { - VStack(alignment: .leading, spacing: 2) { - HStack(alignment: .firstTextBaseline, spacing: 4) { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.xxs) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xs) { Text("\(Int(positionDraft.rounded()))") .font(.system(size: 56, weight: .bold, design: .rounded)) .monospacedDigit() @@ -132,7 +132,7 @@ struct CoverControlCard: View { private var hairline: some View { Rectangle() .fill(Color.primary.opacity(0.08)) - .frame(height: 0.5) + .frame(height: DesignTokens.Size.hairline) } // MARK: - Position slider @@ -175,7 +175,7 @@ struct CoverControlCard: View { Button { if let p = context.statePayload(payload) { onSend(p) } } label: { - HStack(spacing: 6) { + HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: systemImage) .font(.system(size: 14, weight: .semibold)) Text(title) @@ -196,7 +196,7 @@ struct CoverControlCard: View { let writable = context.tiltFeature?.isWritable == true && mode == .interactive VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { HStack(alignment: .firstTextBaseline) { - HStack(spacing: 5) { + HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: "rotate.3d") .font(.system(size: 11, weight: .bold)) .symbolRenderingMode(.hierarchical) @@ -207,7 +207,7 @@ struct CoverControlCard: View { } .foregroundStyle(.secondary) Spacer() - HStack(alignment: .firstTextBaseline, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xxs) { Text("\(Int(tiltDraft.rounded()))") .font(.system(size: 20, weight: .semibold, design: .rounded)) .monospacedDigit() diff --git a/Shellbee/Shared/DesignTokens.swift b/Shellbee/Shared/DesignTokens.swift index 95250a7..59648aa 100644 --- a/Shellbee/Shared/DesignTokens.swift +++ b/Shellbee/Shared/DesignTokens.swift @@ -100,6 +100,8 @@ nonisolated enum DesignTokens { static let splashIcon: CGFloat = 60 static let splashTitle: CGFloat = 40 static let touchlinkButtonFrame: CGFloat = 24 + static let climateActionButton: CGFloat = 32 + static let climateSetpointMinWidth: CGFloat = 72 } nonisolated enum Shadow { From e680f508c28a3d550bea135379bf0f14109d85ff Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 23:18:54 +0200 Subject: [PATCH 05/18] DeviceCard + InAppNotificationOverlay: replace literals with DesignTokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeviceCard: - spacing: 5 → Spacing.xs in identity-metric label rows - spacing: 2/1 → Spacing.xxs in metric value+unit and vendor/model stack - frame(height: 0.5) → Size.hairline - preview VStack(spacing: 20) → Spacing.xl InAppNotificationOverlay: - frame(width: 22) for the leading icon and close button → Size.cardSymbol - spacing: 2 / .padding(.vertical, 2) → Spacing.xxs in the count chip - .padding(.leading, 30) → Size.cardSymbol + Spacing.sm (the value is derived from the icon column width, not a magic number) - .padding(.bottom, 80) → Size.notificationBottomInset (3 call sites) Refs #33 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../InAppNotificationOverlay.swift | 18 +++++++++--------- Shellbee/Shared/Components/DeviceCard.swift | 10 +++++----- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Shellbee/Features/Notifications/InAppNotificationOverlay.swift b/Shellbee/Features/Notifications/InAppNotificationOverlay.swift index c511aca..a7e39e1 100644 --- a/Shellbee/Features/Notifications/InAppNotificationOverlay.swift +++ b/Shellbee/Features/Notifications/InAppNotificationOverlay.swift @@ -360,9 +360,9 @@ struct InAppNotificationBanner: View { Image(systemName: notification.level.systemImage) .font(.system(size: 15, weight: .semibold)) .foregroundStyle(notification.level.color) - .frame(width: 22) + .frame(width: DesignTokens.Size.cardSymbol) - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.xxs) { HStack(spacing: DesignTokens.Spacing.sm) { Text(notification.title) .font(.footnote.weight(.semibold)) @@ -372,7 +372,7 @@ struct InAppNotificationBanner: View { .font(.caption2.weight(.semibold)) .foregroundStyle(.secondary) .padding(.horizontal, DesignTokens.Spacing.sm) - .padding(.vertical, 2) + .padding(.vertical, DesignTokens.Spacing.xxs) .background(.secondary.opacity(DesignTokens.Opacity.subtleFill), in: Capsule()) } if let stackPositionLabel { @@ -380,7 +380,7 @@ struct InAppNotificationBanner: View { .font(.caption2.weight(.semibold).monospacedDigit()) .foregroundStyle(.secondary) .padding(.horizontal, DesignTokens.Spacing.sm) - .padding(.vertical, 2) + .padding(.vertical, DesignTokens.Spacing.xxs) .background(.tint.opacity(DesignTokens.Opacity.softFill), in: Capsule()) } } @@ -399,7 +399,7 @@ struct InAppNotificationBanner: View { Image(systemName: "xmark") .font(.caption.weight(.semibold)) .foregroundStyle(.tertiary) - .frame(width: 22, height: 22) + .frame(width: DesignTokens.Size.cardSymbol, height: DesignTokens.Size.cardSymbol) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -446,7 +446,7 @@ struct InAppNotificationBanner: View { Spacer(minLength: 0) } - .padding(.leading, 30) + .padding(.leading, DesignTokens.Size.cardSymbol + DesignTokens.Spacing.sm) } private var dragGesture: some Gesture { @@ -679,7 +679,7 @@ struct FastTrackBanner: View { onSwipeNext: {}, onSwipePrevious: {} ) - .padding(.bottom, 80) + .padding(.bottom, DesignTokens.Size.notificationBottomInset) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(.systemGroupedBackground)) @@ -695,7 +695,7 @@ struct FastTrackBanner: View { priority: .fastTrack ) ) - .padding(.bottom, 80) + .padding(.bottom, DesignTokens.Size.notificationBottomInset) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(.systemGroupedBackground)) @@ -719,7 +719,7 @@ private struct PreviewHost: View { onGoToDevice: {}, onCopyMessage: {} ) - .padding(.bottom, 80) + .padding(.bottom, DesignTokens.Size.notificationBottomInset) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(.systemGroupedBackground)) diff --git a/Shellbee/Shared/Components/DeviceCard.swift b/Shellbee/Shared/Components/DeviceCard.swift index de7bc7b..0828a2b 100644 --- a/Shellbee/Shared/Components/DeviceCard.swift +++ b/Shellbee/Shared/Components/DeviceCard.swift @@ -138,7 +138,7 @@ struct DeviceCard: View { private func identityMetric(label: String, icon: String, value: String, unit: String?, color: Color) -> some View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { - HStack(alignment: .firstTextBaseline, spacing: 5) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xs) { Image(systemName: icon) .font(.system(size: 11, weight: .bold)) .symbolRenderingMode(.hierarchical) @@ -150,7 +150,7 @@ struct DeviceCard: View { } .foregroundStyle(.secondary) - HStack(alignment: .firstTextBaseline, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xxs) { Text(value) .font(.system(size: 24, weight: .semibold, design: .rounded)) .monospacedDigit() @@ -202,7 +202,7 @@ struct DeviceCard: View { } private var deviceMetadata: some View { - VStack(alignment: .leading, spacing: 1) { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.xxs) { Text(vendor) .font(.subheadline) .foregroundStyle(.secondary) @@ -229,7 +229,7 @@ struct DeviceCard: View { private var hairline: some View { Rectangle() .fill(Color.primary.opacity(0.08)) - .frame(height: 0.5) + .frame(height: DesignTokens.Size.hairline) } @ViewBuilder @@ -372,7 +372,7 @@ struct DeviceCard: View { } #Preview { - VStack(spacing: 20) { + VStack(spacing: DesignTokens.Spacing.xl) { DeviceCard( device: .preview, state: [ From 5e74d01c2afb094470b0097ea883934e54ddbbd8 Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 23:20:28 +0200 Subject: [PATCH 06/18] MQTTInspectorView: replace hardcoded SwiftUI literals with DesignTokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two inspector-scoped Size tokens (inspectorTabPickerWidth = 220, inspectorPayloadInset = 10) and snaps the rest to existing tokens: - spacing: 6 → Spacing.sm in MessageRow VStack - spacing: 8 → Spacing.sm in MessageRow header HStack - frame(width: 14) → Size.logLevelIconWidth (already a token, was duplicated) - frame(width: 22) and .padding(.leading, 22) → Size.cardSymbol (icon column width carried through to align the payload bubble with the topic text) - .padding(.horizontal, 12), .padding(.bottom, 12) → Spacing.md - .padding(.vertical, 6) → Spacing.sm - .padding(.vertical, 2) → Spacing.xxs - cornerRadius: 8 → CornerRadius.sm Refs #33 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Developer/MQTTInspectorView.swift | 24 +++++++++---------- Shellbee/Shared/DesignTokens.swift | 2 ++ 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift b/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift index d12f7e0..4bc50d9 100644 --- a/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift +++ b/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift @@ -32,7 +32,7 @@ struct MQTTInspectorView: View { } } .pickerStyle(.segmented) - .frame(width: 220) + .frame(width: DesignTokens.Size.inspectorTabPickerWidth) } } .onAppear { store.attach(session: environment.session) } @@ -233,10 +233,10 @@ private struct SubscribeView: View { Text("Paused") .font(.caption.weight(.semibold)) .foregroundStyle(.white) - .padding(.horizontal, 12) - .padding(.vertical, 6) + .padding(.horizontal, DesignTokens.Spacing.md) + .padding(.vertical, DesignTokens.Spacing.sm) .background(.orange.opacity(0.9), in: Capsule()) - .padding(.bottom, 12) + .padding(.bottom, DesignTokens.Spacing.md) } } } @@ -257,12 +257,12 @@ private struct MessageRow: View { @State private var expanded: Bool = false var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .firstTextBaseline, spacing: 8) { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.sm) { Image(systemName: message.logLevelIcon) .font(.caption2.weight(.semibold)) .foregroundStyle(message.logLevelColor) - .frame(width: 14) + .frame(width: DesignTokens.Size.logLevelIconWidth) Text(message.topic) .font(.system(.caption, design: .monospaced).weight(.bold)) .foregroundStyle(.primary) @@ -277,13 +277,13 @@ private struct MessageRow: View { Text(JSONHighlighter.attributed(message.prettyPayload)) .lineLimit(expanded ? nil : 6) .textSelection(.enabled) - .padding(10) + .padding(DesignTokens.Size.inspectorPayloadInset) .frame(maxWidth: .infinity, alignment: .leading) .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) + RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.sm, style: .continuous) .fill(Color(.tertiarySystemFill)) ) - .padding(.leading, 22) + .padding(.leading, DesignTokens.Size.cardSymbol) if message.prettyPayload.components(separatedBy: "\n").count > 6 { Button { withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() } @@ -292,10 +292,10 @@ private struct MessageRow: View { .font(.caption.weight(.medium)) } .buttonStyle(.borderless) - .padding(.leading, 22) + .padding(.leading, DesignTokens.Size.cardSymbol) } } - .padding(.vertical, 2) + .padding(.vertical, DesignTokens.Spacing.xxs) } } diff --git a/Shellbee/Shared/DesignTokens.swift b/Shellbee/Shared/DesignTokens.swift index 59648aa..5764e19 100644 --- a/Shellbee/Shared/DesignTokens.swift +++ b/Shellbee/Shared/DesignTokens.swift @@ -102,6 +102,8 @@ nonisolated enum DesignTokens { static let touchlinkButtonFrame: CGFloat = 24 static let climateActionButton: CGFloat = 32 static let climateSetpointMinWidth: CGFloat = 72 + static let inspectorTabPickerWidth: CGFloat = 220 + static let inspectorPayloadInset: CGFloat = 10 } nonisolated enum Shadow { From 891e00782eb044fd323a0f3c4fb12af6081c27d4 Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 23:21:48 +0200 Subject: [PATCH 07/18] RestoreGuideSheet + DocBlockView: replace literals with DesignTokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RestoreGuideSheet: - Adds Size.restoreStepCircle (24) for the numbered step badge - spacing: 12 → Spacing.md, spacing: 8 → Spacing.sm - spacing: 4 → Spacing.xs (2 sites: outer step list, inner title/body) - .padding(.vertical, 2) → Spacing.xxs DocBlockView: - .padding(.top, 1) bullet baseline nudge → Spacing.xxs (1pt → 2pt; imperceptible) - preview VStack(spacing: 20) → Spacing.xl Leaves the table view's spacing: 0 in place — those are deliberate zero-gap stacks where cells/rows touch, not literals chosen by eye. Refs #33 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Features/Settings/Backup/RestoreGuideSheet.swift | 12 ++++++------ Shellbee/Shared/Components/Doc/DocBlockView.swift | 4 ++-- Shellbee/Shared/DesignTokens.swift | 1 + 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/Shellbee/Features/Settings/Backup/RestoreGuideSheet.swift b/Shellbee/Features/Settings/Backup/RestoreGuideSheet.swift index 0a58b75..d118dc0 100644 --- a/Shellbee/Features/Settings/Backup/RestoreGuideSheet.swift +++ b/Shellbee/Features/Settings/Backup/RestoreGuideSheet.swift @@ -8,7 +8,7 @@ struct RestoreGuideSheet: View { List { Section { Label { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) { Text("Host-only operation") .font(.subheadline.weight(.semibold)) .foregroundStyle(.orange) @@ -72,23 +72,23 @@ struct RestoreGuideSheet: View { } private func stepRow(n: Int, title: String, body: String) -> some View { - HStack(alignment: .top, spacing: 12) { + HStack(alignment: .top, spacing: DesignTokens.Spacing.md) { Text("\(n)") .font(.subheadline.weight(.bold)) .foregroundStyle(.white) - .frame(width: 24, height: 24) + .frame(width: DesignTokens.Size.restoreStepCircle, height: DesignTokens.Size.restoreStepCircle) .background(.indigo, in: Circle()) - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) { Text(title).font(.subheadline.weight(.semibold)) Text(body).font(.footnote).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } - .padding(.vertical, 2) + .padding(.vertical, DesignTokens.Spacing.xxs) } private func bulletRow(_ text: String) -> some View { - HStack(alignment: .top, spacing: 8) { + HStack(alignment: .top, spacing: DesignTokens.Spacing.sm) { Text("\u{2022}") .foregroundStyle(.secondary) Text(text) diff --git a/Shellbee/Shared/Components/Doc/DocBlockView.swift b/Shellbee/Shared/Components/Doc/DocBlockView.swift index 6ba1a18..b5fcc4f 100644 --- a/Shellbee/Shared/Components/Doc/DocBlockView.swift +++ b/Shellbee/Shared/Components/Doc/DocBlockView.swift @@ -68,7 +68,7 @@ private struct BulletListView: View { HStack(alignment: .top, spacing: DesignTokens.Spacing.sm) { Text("•") .foregroundStyle(.secondary) - .padding(.top, 1) + .padding(.top, DesignTokens.Spacing.xxs) DocInlineTextView(spans: spans, sourcePath: sourcePath) .font(.body) .fixedSize(horizontal: false, vertical: true) @@ -136,7 +136,7 @@ private struct SubsectionView: View { #Preview { ScrollView { - VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.xl) { DocBlockView(block: .paragraph([.text("IKEA lights only support transitions on "), .bold("1 attribute"), .text(" at a time.")])) DocBlockView(block: .note([.text("Keep the bulb close to the coordinator during pairing.")])) DocBlockView(block: .stepList([ diff --git a/Shellbee/Shared/DesignTokens.swift b/Shellbee/Shared/DesignTokens.swift index 5764e19..79ef51a 100644 --- a/Shellbee/Shared/DesignTokens.swift +++ b/Shellbee/Shared/DesignTokens.swift @@ -104,6 +104,7 @@ nonisolated enum DesignTokens { static let climateSetpointMinWidth: CGFloat = 72 static let inspectorTabPickerWidth: CGFloat = 220 static let inspectorPayloadInset: CGFloat = 10 + static let restoreStepCircle: CGFloat = 24 } nonisolated enum Shadow { From d6883fb89d4f80508f2aecdec8fb0acb1e367ada Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 23:27:24 +0200 Subject: [PATCH 08/18] Tail sweep: replace remaining hardcoded SwiftUI literals across the app Wraps up step 2 of the cleanup tracked in #33. After this, no .swift file under Shellbee/ contains a hardcoded SwiftUI numeric modifier (padding, frame, spacing, cornerRadius) outside of DesignTokens.swift itself, except for spacing: 0 which is intentionally kept as semantic "no gap". Adds five new tokens for one-off frames/insets that didn't fit existing ones: - Size.splashIconLarge (120) for the splash icon - Size.mainTabBarInset (58) for the in-app notification overlay's tab-bar-aware bottom padding - Size.permitJoinQR (220) for the pairing QR - Size.homeAddDividerInset (60) for the divider in the hidden-cards list - Size.docLabelColumnWidth (90) for the doc info card label column Files swept (all snap to existing tokens where one fit): - App: MainTabView, SplashScreenView - Connection: ConnectionOverviewView - Devices: DeviceFirmwareMenu, DeviceImageView - Groups: GroupCard - Home: HomeAddCardsSection, HomeBridgeCard, HomeCardComponents, HomeLogsCard, PermitJoinActiveSheet - Logs: LogRowView - Settings: AcknowledgementsView - Shared cards: GenericExposeCard, LockControlCard, RemoteCard, SensorCard, SwitchControlCard - Shared/Components/Doc: DefaultDocSectionView, DeviceInfoCardView, DocInlineTextView, DocNoteView, DocOptionRowView, DocStepListView Refs #33 Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee/App/MainTabView.swift | 2 +- Shellbee/App/SplashScreenView.swift | 2 +- Shellbee/Features/Connection/ConnectionOverviewView.swift | 2 +- Shellbee/Features/Devices/DeviceFirmwareMenu.swift | 2 +- Shellbee/Features/Devices/DeviceImageView.swift | 2 +- Shellbee/Features/Groups/GroupCard.swift | 6 +++--- Shellbee/Features/Home/HomeAddCardsSection.swift | 4 ++-- Shellbee/Features/Home/HomeBridgeCard.swift | 4 ++-- Shellbee/Features/Home/HomeCardComponents.swift | 2 +- Shellbee/Features/Home/HomeLogsCard.swift | 2 +- Shellbee/Features/Home/PermitJoinActiveSheet.swift | 2 +- Shellbee/Features/Logs/LogRowView.swift | 2 +- Shellbee/Features/Settings/AcknowledgementsView.swift | 6 +++--- .../Shared/Components/Doc/DefaultDocSectionView.swift | 2 +- Shellbee/Shared/Components/Doc/DeviceInfoCardView.swift | 2 +- Shellbee/Shared/Components/Doc/DocInlineTextView.swift | 2 +- Shellbee/Shared/Components/Doc/DocNoteView.swift | 2 +- Shellbee/Shared/Components/Doc/DocOptionRowView.swift | 2 +- Shellbee/Shared/Components/Doc/DocStepListView.swift | 2 +- Shellbee/Shared/DesignTokens.swift | 5 +++++ Shellbee/Shared/GenericExposeCard/GenericExposeCard.swift | 4 ++-- Shellbee/Shared/LockControl/LockControlCard.swift | 4 ++-- Shellbee/Shared/RemoteCard/RemoteCard.swift | 4 ++-- Shellbee/Shared/SensorCard/SensorCard.swift | 4 ++-- Shellbee/Shared/SwitchControl/SwitchControlCard.swift | 8 ++++---- 25 files changed, 42 insertions(+), 37 deletions(-) diff --git a/Shellbee/App/MainTabView.swift b/Shellbee/App/MainTabView.swift index 5e64753..02d8264 100644 --- a/Shellbee/App/MainTabView.swift +++ b/Shellbee/App/MainTabView.swift @@ -23,7 +23,7 @@ struct MainTabView: View { .overlay(alignment: .bottom) { InAppNotificationOverlay() .safeAreaPadding(.bottom) - .padding(.bottom, 58) + .padding(.bottom, DesignTokens.Size.mainTabBarInset) } .sheet(item: Binding( get: { environment.pendingLogSheet }, diff --git a/Shellbee/App/SplashScreenView.swift b/Shellbee/App/SplashScreenView.swift index 4d42de0..fb1fccf 100644 --- a/Shellbee/App/SplashScreenView.swift +++ b/Shellbee/App/SplashScreenView.swift @@ -19,7 +19,7 @@ struct SplashScreenView: View { Image(splashImageName) .resizable() .scaledToFit() - .frame(width: 120, height: 120) + .frame(width: DesignTokens.Size.splashIconLarge, height: DesignTokens.Size.splashIconLarge) .clipShape(RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.xl, style: .continuous)) .shadow(color: .black.opacity(0.12), radius: 18, y: 8) .scaleEffect(isVisible ? 1 : 0.8) diff --git a/Shellbee/Features/Connection/ConnectionOverviewView.swift b/Shellbee/Features/Connection/ConnectionOverviewView.swift index 16f9752..4f1df20 100644 --- a/Shellbee/Features/Connection/ConnectionOverviewView.swift +++ b/Shellbee/Features/Connection/ConnectionOverviewView.swift @@ -11,7 +11,7 @@ struct ConnectionOverviewView: View { Section("Explore") { NavigationLink(destination: DocBrowserView()) { Label { - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.xxs) { Text("Device Library") Text("Browse docs for 5,000+ Zigbee devices") .font(.caption) diff --git a/Shellbee/Features/Devices/DeviceFirmwareMenu.swift b/Shellbee/Features/Devices/DeviceFirmwareMenu.swift index fc81fb6..aec0e3d 100644 --- a/Shellbee/Features/Devices/DeviceFirmwareMenu.swift +++ b/Shellbee/Features/Devices/DeviceFirmwareMenu.swift @@ -75,7 +75,7 @@ struct DeviceFirmwareMenu: View { if updateCount > 0 && !bulkActive { Circle() .fill(Color.red) - .frame(width: 8, height: 8) + .frame(width: DesignTokens.Size.logLevelDotSize, height: DesignTokens.Size.logLevelDotSize) .offset(x: 4, y: -2) } } diff --git a/Shellbee/Features/Devices/DeviceImageView.swift b/Shellbee/Features/Devices/DeviceImageView.swift index dfaac54..4b23149 100644 --- a/Shellbee/Features/Devices/DeviceImageView.swift +++ b/Shellbee/Features/Devices/DeviceImageView.swift @@ -85,7 +85,7 @@ struct DeviceImageView: View { } #Preview { - VStack(spacing: 20) { + VStack(spacing: DesignTokens.Spacing.xl) { HStack { DeviceImageView(device: .preview, isAvailable: true) Text("Available Image") diff --git a/Shellbee/Features/Groups/GroupCard.swift b/Shellbee/Features/Groups/GroupCard.swift index fff6fc6..1ab1219 100644 --- a/Shellbee/Features/Groups/GroupCard.swift +++ b/Shellbee/Features/Groups/GroupCard.swift @@ -113,7 +113,7 @@ struct GroupCard: View { private func identityMetric(label: String, icon: String, value: String, unit: String?, color: Color) -> some View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { - HStack(alignment: .firstTextBaseline, spacing: 5) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xs) { Image(systemName: icon) .font(.system(size: 11, weight: .bold)) .symbolRenderingMode(.hierarchical) @@ -125,7 +125,7 @@ struct GroupCard: View { } .foregroundStyle(.secondary) - HStack(alignment: .firstTextBaseline, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xxs) { Text(value) .font(.system(size: 24, weight: .semibold, design: .rounded)) .monospacedDigit() @@ -176,7 +176,7 @@ struct GroupCard: View { private var hairline: some View { Rectangle() .fill(Color.primary.opacity(0.08)) - .frame(height: 0.5) + .frame(height: DesignTokens.Size.hairline) } private var scenesTitle: String { diff --git a/Shellbee/Features/Home/HomeAddCardsSection.swift b/Shellbee/Features/Home/HomeAddCardsSection.swift index df06e8b..1429e23 100644 --- a/Shellbee/Features/Home/HomeAddCardsSection.swift +++ b/Shellbee/Features/Home/HomeAddCardsSection.swift @@ -24,7 +24,7 @@ struct HomeAddCardsSection: View { Image(systemName: card.symbol) .font(.subheadline.weight(.semibold)) .foregroundStyle(card.tint) - .frame(width: 22) + .frame(width: DesignTokens.Size.cardSymbol) Text(card.title) .font(.subheadline.weight(.medium)) .foregroundStyle(.primary) @@ -36,7 +36,7 @@ struct HomeAddCardsSection: View { .buttonStyle(.plain) if index < hidden.count - 1 { - Divider().padding(.leading, 60) + Divider().padding(.leading, DesignTokens.Size.homeAddDividerInset) } } } diff --git a/Shellbee/Features/Home/HomeBridgeCard.swift b/Shellbee/Features/Home/HomeBridgeCard.swift index 894148b..3ed974f 100644 --- a/Shellbee/Features/Home/HomeBridgeCard.swift +++ b/Shellbee/Features/Home/HomeBridgeCard.swift @@ -69,7 +69,7 @@ struct HomeBridgeCard: View { HomeCardTitle(symbol: "antenna.radiowaves.left.and.right", title: headerTitle, tint: .teal) .lineLimit(1) if isReconnecting { - HStack(spacing: 4) { + HStack(spacing: DesignTokens.Spacing.xs) { ProgressView().controlSize(.mini) Text("Reconnecting (\(reconnectAttempt))") .font(.caption) @@ -123,7 +123,7 @@ struct HomeBridgeCard: View { } private func statusLine(symbol: String, tint: Color, text: String) -> some View { - HStack(spacing: 6) { + HStack(spacing: DesignTokens.Spacing.sm) { Image(systemName: symbol) .font(.footnote.weight(.semibold)) .foregroundStyle(tint) diff --git a/Shellbee/Features/Home/HomeCardComponents.swift b/Shellbee/Features/Home/HomeCardComponents.swift index 6986c85..6637e4b 100644 --- a/Shellbee/Features/Home/HomeCardComponents.swift +++ b/Shellbee/Features/Home/HomeCardComponents.swift @@ -38,7 +38,7 @@ struct HomeStatCell: View { var subtitle: String? = nil var body: some View { - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.xxs) { Text(value) .font(.title3.weight(.bold)) .foregroundStyle(valueColor) diff --git a/Shellbee/Features/Home/HomeLogsCard.swift b/Shellbee/Features/Home/HomeLogsCard.swift index f2c1d97..fffc5f1 100644 --- a/Shellbee/Features/Home/HomeLogsCard.swift +++ b/Shellbee/Features/Home/HomeLogsCard.swift @@ -50,7 +50,7 @@ struct HomeLogRow: View { var body: some View { HStack(alignment: .center, spacing: DesignTokens.Spacing.md) { badge - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.xxs) { Text(entry.summaryTitle) .font(.subheadline.weight(.semibold)) .foregroundStyle(.primary) diff --git a/Shellbee/Features/Home/PermitJoinActiveSheet.swift b/Shellbee/Features/Home/PermitJoinActiveSheet.swift index e75b79e..599e9e7 100644 --- a/Shellbee/Features/Home/PermitJoinActiveSheet.swift +++ b/Shellbee/Features/Home/PermitJoinActiveSheet.swift @@ -55,7 +55,7 @@ struct PermitJoinActiveSheet: View { .symbolEffect(.pulse) } } - .frame(width: 220, height: 220) + .frame(width: DesignTokens.Size.permitJoinQR, height: DesignTokens.Size.permitJoinQR) } private func progress(remaining: Int?) -> CGFloat { diff --git a/Shellbee/Features/Logs/LogRowView.swift b/Shellbee/Features/Logs/LogRowView.swift index fe800f2..810f9fe 100644 --- a/Shellbee/Features/Logs/LogRowView.swift +++ b/Shellbee/Features/Logs/LogRowView.swift @@ -8,7 +8,7 @@ struct LogRowView: View { HStack(alignment: .top, spacing: DesignTokens.Spacing.sm) { leadingVisual - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.xxs) { HStack(alignment: .top, spacing: DesignTokens.Spacing.sm) { Text(entry.summaryTitle) .font(.subheadline.bold()) diff --git a/Shellbee/Features/Settings/AcknowledgementsView.swift b/Shellbee/Features/Settings/AcknowledgementsView.swift index 77a4ca2..399b9a3 100644 --- a/Shellbee/Features/Settings/AcknowledgementsView.swift +++ b/Shellbee/Features/Settings/AcknowledgementsView.swift @@ -34,7 +34,7 @@ struct AcknowledgementsView: View { Image(systemName: "heart.fill") .font(.body) .foregroundStyle(.pink) - .frame(width: 28) + .frame(width: DesignTokens.Size.settingsIconFrame) VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) { Text("Donate to Koenkk") .foregroundStyle(.primary) @@ -61,7 +61,7 @@ struct AcknowledgementsView: View { Image(systemName: "chevron.left.forwardslash.chevron.right") .font(.body) .foregroundStyle(.green) - .frame(width: 28) + .frame(width: DesignTokens.Size.settingsIconFrame) VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) { HStack(spacing: DesignTokens.Spacing.sm) { Text(title) @@ -70,7 +70,7 @@ struct AcknowledgementsView: View { .font(.caption2.weight(.semibold)) .foregroundStyle(.secondary) .padding(.horizontal, DesignTokens.Spacing.xs) - .padding(.vertical, 2) + .padding(.vertical, DesignTokens.Spacing.xxs) .background(.quaternary, in: Capsule()) } Text(subtitle) diff --git a/Shellbee/Shared/Components/Doc/DefaultDocSectionView.swift b/Shellbee/Shared/Components/Doc/DefaultDocSectionView.swift index 915861d..2076d4b 100644 --- a/Shellbee/Shared/Components/Doc/DefaultDocSectionView.swift +++ b/Shellbee/Shared/Components/Doc/DefaultDocSectionView.swift @@ -63,7 +63,7 @@ struct SectionHeader: View { #Preview { ScrollView { - VStack(spacing: 20) { + VStack(spacing: DesignTokens.Spacing.xl) { DefaultDocSectionView(section: DocSection( title: "Notes", level: 2, diff --git a/Shellbee/Shared/Components/Doc/DeviceInfoCardView.swift b/Shellbee/Shared/Components/Doc/DeviceInfoCardView.swift index 5929b43..8350760 100644 --- a/Shellbee/Shared/Components/Doc/DeviceInfoCardView.swift +++ b/Shellbee/Shared/Components/Doc/DeviceInfoCardView.swift @@ -32,7 +32,7 @@ struct DeviceInfoCardView: View { Text(label) .font(.subheadline) .foregroundStyle(.secondary) - .frame(width: 90, alignment: .leading) + .frame(width: DesignTokens.Size.docLabelColumnWidth, alignment: .leading) Text(value) .font(.subheadline) diff --git a/Shellbee/Shared/Components/Doc/DocInlineTextView.swift b/Shellbee/Shared/Components/Doc/DocInlineTextView.swift index 5b70b44..2d25886 100644 --- a/Shellbee/Shared/Components/Doc/DocInlineTextView.swift +++ b/Shellbee/Shared/Components/Doc/DocInlineTextView.swift @@ -172,7 +172,7 @@ private struct InAppLinkUnavailableView: View { } #Preview { - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.lg) { DocInlineTextView(spans: [ .text("Keep the bulb "), .bold("close to the coordinator"), diff --git a/Shellbee/Shared/Components/Doc/DocNoteView.swift b/Shellbee/Shared/Components/Doc/DocNoteView.swift index 0c8df6f..2106f50 100644 --- a/Shellbee/Shared/Components/Doc/DocNoteView.swift +++ b/Shellbee/Shared/Components/Doc/DocNoteView.swift @@ -31,7 +31,7 @@ struct DocNoteView: View { } #Preview { - VStack(spacing: 16) { + VStack(spacing: DesignTokens.Spacing.lg) { DocNoteView(spans: [ .text("Keep the bulb "), .bold("close to the coordinator"), diff --git a/Shellbee/Shared/Components/Doc/DocOptionRowView.swift b/Shellbee/Shared/Components/Doc/DocOptionRowView.swift index a335db4..e5bf8ce 100644 --- a/Shellbee/Shared/Components/Doc/DocOptionRowView.swift +++ b/Shellbee/Shared/Components/Doc/DocOptionRowView.swift @@ -49,7 +49,7 @@ struct DocOptionRowView: View { } #Preview { - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.lg) { DocOptionRowView(option: DocOption( name: "transition", type: "number", diff --git a/Shellbee/Shared/Components/Doc/DocStepListView.swift b/Shellbee/Shared/Components/Doc/DocStepListView.swift index aa40435..e42d9d6 100644 --- a/Shellbee/Shared/Components/Doc/DocStepListView.swift +++ b/Shellbee/Shared/Components/Doc/DocStepListView.swift @@ -54,7 +54,7 @@ private struct DocStepRow: View { .font(.body) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 4) + .padding(.top, DesignTokens.Spacing.xs) .padding(.bottom, showConnector ? DesignTokens.Spacing.xl : 0) } } diff --git a/Shellbee/Shared/DesignTokens.swift b/Shellbee/Shared/DesignTokens.swift index 79ef51a..1c39eae 100644 --- a/Shellbee/Shared/DesignTokens.swift +++ b/Shellbee/Shared/DesignTokens.swift @@ -105,6 +105,11 @@ nonisolated enum DesignTokens { static let inspectorTabPickerWidth: CGFloat = 220 static let inspectorPayloadInset: CGFloat = 10 static let restoreStepCircle: CGFloat = 24 + static let splashIconLarge: CGFloat = 120 + static let mainTabBarInset: CGFloat = 58 + static let permitJoinQR: CGFloat = 220 + static let homeAddDividerInset: CGFloat = 60 + static let docLabelColumnWidth: CGFloat = 90 } nonisolated enum Shadow { diff --git a/Shellbee/Shared/GenericExposeCard/GenericExposeCard.swift b/Shellbee/Shared/GenericExposeCard/GenericExposeCard.swift index e454d9d..4ed4e4a 100644 --- a/Shellbee/Shared/GenericExposeCard/GenericExposeCard.swift +++ b/Shellbee/Shared/GenericExposeCard/GenericExposeCard.swift @@ -50,7 +50,7 @@ struct GenericExposeCard: View { /// name above the card already names what we're looking at, so a redundant /// "Controls" / "Device State" headline is just noise. private var snapshotHeader: some View { - HStack(spacing: 5) { + HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: "cpu") .font(.system(size: 12, weight: .bold)) .foregroundStyle(.tint) @@ -180,7 +180,7 @@ private struct GenericExposeRow: View { } } } label: { - HStack(spacing: 4) { + HStack(spacing: DesignTokens.Spacing.xs) { Text(prettify(current)) Image(systemName: "chevron.up.chevron.down") .font(.caption2.weight(.semibold)) diff --git a/Shellbee/Shared/LockControl/LockControlCard.swift b/Shellbee/Shared/LockControl/LockControlCard.swift index 3e3ce3f..f829fd3 100644 --- a/Shellbee/Shared/LockControl/LockControlCard.swift +++ b/Shellbee/Shared/LockControl/LockControlCard.swift @@ -60,7 +60,7 @@ struct LockControlCard: View { } private var heroEyebrow: some View { - HStack(spacing: 5) { + HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: context.isLocked ? "lock.fill" : "lock.open.fill") .font(.system(size: 11, weight: .bold)) .symbolRenderingMode(.hierarchical) @@ -92,7 +92,7 @@ struct LockControlCard: View { private var hairline: some View { Rectangle() .fill(Color.primary.opacity(0.08)) - .frame(height: 0.5) + .frame(height: DesignTokens.Size.hairline) } // MARK: - Action button diff --git a/Shellbee/Shared/RemoteCard/RemoteCard.swift b/Shellbee/Shared/RemoteCard/RemoteCard.swift index 47396aa..e5351f4 100644 --- a/Shellbee/Shared/RemoteCard/RemoteCard.swift +++ b/Shellbee/Shared/RemoteCard/RemoteCard.swift @@ -83,7 +83,7 @@ private struct ReadingTile: View { var body: some View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { - HStack(alignment: .firstTextBaseline, spacing: 5) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xs) { Image(systemName: icon) .font(.system(size: 11, weight: .bold)) .symbolRenderingMode(.hierarchical) @@ -96,7 +96,7 @@ private struct ReadingTile: View { } .foregroundStyle(.secondary) - HStack(alignment: .firstTextBaseline, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xxs) { Text(value) .font(.system(size: 30, weight: .semibold, design: .rounded)) .monospacedDigit() diff --git a/Shellbee/Shared/SensorCard/SensorCard.swift b/Shellbee/Shared/SensorCard/SensorCard.swift index eaa0ea6..8c7ed05 100644 --- a/Shellbee/Shared/SensorCard/SensorCard.swift +++ b/Shellbee/Shared/SensorCard/SensorCard.swift @@ -210,7 +210,7 @@ private struct SensorReadingTile: View { var body: some View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { - HStack(alignment: .firstTextBaseline, spacing: 5) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xs) { Image(systemName: reading.icon) .font(.system(size: 11, weight: .bold)) .symbolRenderingMode(.hierarchical) @@ -223,7 +223,7 @@ private struct SensorReadingTile: View { } .foregroundStyle(.secondary) - HStack(alignment: .firstTextBaseline, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xxs) { Text(reading.numericDisplayValue) .font(.system(size: 30, weight: .semibold, design: .rounded)) .monospacedDigit() diff --git a/Shellbee/Shared/SwitchControl/SwitchControlCard.swift b/Shellbee/Shared/SwitchControl/SwitchControlCard.swift index 13064b7..3c5dd55 100644 --- a/Shellbee/Shared/SwitchControl/SwitchControlCard.swift +++ b/Shellbee/Shared/SwitchControl/SwitchControlCard.swift @@ -56,7 +56,7 @@ struct SwitchControlCard: View { } private var heroEyebrow: some View { - HStack(spacing: 5) { + HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: context.isOn ? "power.circle.fill" : "power.circle") .font(.system(size: 11, weight: .bold)) .symbolRenderingMode(.hierarchical) @@ -116,7 +116,7 @@ struct SwitchControlCard: View { private var hairline: some View { Rectangle() .fill(Color.primary.opacity(0.08)) - .frame(height: 0.5) + .frame(height: DesignTokens.Size.hairline) } // MARK: - Metering grid @@ -179,7 +179,7 @@ private struct MeteringTile: View { var body: some View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { - HStack(alignment: .firstTextBaseline, spacing: 5) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xs) { Image(systemName: icon) .font(.system(size: 11, weight: .bold)) .symbolRenderingMode(.hierarchical) @@ -192,7 +192,7 @@ private struct MeteringTile: View { } .foregroundStyle(.secondary) - HStack(alignment: .firstTextBaseline, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xxs) { Text(value) .font(.system(size: 30, weight: .semibold, design: .rounded)) .monospacedDigit() From 5702066e24d22162e5ecf25dd263a34821b15840 Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 23:34:45 +0200 Subject: [PATCH 09/18] FanControlCard: split FanFeatureSections and FanExtraRow into peer files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FanControlCard.swift was 754 lines housing four top-level types. Splits: - FanFeatureSections (the native List sections variant for use under iOS-style settings screens) → Shared/FanControl/FanFeatureSections.swift. Mirrors the same pattern as Shared/LightControl/LightFeatureSections.swift. - FanExtraRow (binary/enum/numeric/text row used inside the card's extras list) → Shared/FanControl/FanExtraRow.swift, becoming a top-level type so it can be split out without exposing more API than the card needs (it was already file-private; now file-private to its own file). DisclosureRow stays inside FanControlCard.swift — it's a private helper used only there, so it's tightly-coupled per the new code-structure rule. Both new files are added to the ShellbeeWidgetsExtension membershipExceptions list alphabetically, since they reference main-app types (Expose, JSONValue, FeatureCatalog) that don't ship in the widget target. Brings FanControlCard.swift from 754 → 538 lines (still over the 400 soft cap but below the 600 hard cap, and the remainder is one cohesive type). Refs #33 Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee.xcodeproj/project.pbxproj | 2 + .../Shared/FanControl/FanControlCard.swift | 216 ------------------ Shellbee/Shared/FanControl/FanExtraRow.swift | 170 ++++++++++++++ .../FanControl/FanFeatureSections.swift | 49 ++++ 4 files changed, 221 insertions(+), 216 deletions(-) create mode 100644 Shellbee/Shared/FanControl/FanExtraRow.swift create mode 100644 Shellbee/Shared/FanControl/FanFeatureSections.swift diff --git a/Shellbee.xcodeproj/project.pbxproj b/Shellbee.xcodeproj/project.pbxproj index ec7c43b..5b5bff9 100644 --- a/Shellbee.xcodeproj/project.pbxproj +++ b/Shellbee.xcodeproj/project.pbxproj @@ -264,6 +264,8 @@ Shared/ExposeCardView.swift, Shared/FanControl/FanControlCard.swift, Shared/FanControl/FanControlContext.swift, + Shared/FanControl/FanExtraRow.swift, + Shared/FanControl/FanFeatureSections.swift, Shared/GenericExposeCard/GenericExposeCard.swift, "Shared/LightControl/Color+HexString.swift", Shared/LightControl/LightAdvancedFeatureRow.swift, diff --git a/Shellbee/Shared/FanControl/FanControlCard.swift b/Shellbee/Shared/FanControl/FanControlCard.swift index a47000c..7d90b3f 100644 --- a/Shellbee/Shared/FanControl/FanControlCard.swift +++ b/Shellbee/Shared/FanControl/FanControlCard.swift @@ -477,56 +477,6 @@ struct FanControlCard: View { } } -// MARK: - Native list sections - -/// Renders the Fan device's feature sections (Behaviour, Indicators, etc.) as -/// native `List` sections. Place inside a `List` whose `.listStyle` is grouped -/// or inset-grouped. The hero / filter cards are still rendered by -/// `FanControlCard` (with `rendersSectionsInline: false`). -struct FanFeatureSections: View { - let context: FanControlContext - let mode: CardDisplayMode - let onSend: (JSONValue) -> Void - - private let filterProps: Set = ["replace_filter", "filter_age", "device_age"] - - private var eligibleExtras: [Expose] { - let claimed: Set = Set(["pm25", "air_quality"]).union(filterProps) - return context.extras.filter { e in - guard let prop = e.property else { return false } - return !claimed.contains(prop) - } - } - - private var sections: [LayoutSection] { FeatureLayout.sections(from: eligibleExtras) } - - var body: some View { - ForEach(sections) { section in - Section(section.title) { - ForEach(section.items, id: \.id) { item in - rowFor(item) - } - } - } - } - - @ViewBuilder - private func rowFor(_ item: LayoutItem) -> some View { - switch item { - case .row(let expose): - SettingsFormRow(expose: expose, state: context.state, mode: mode, onSend: onSend) - case .indexedGroup(let group): - NavigationLink { - FeatureGroupDetailView(group: group, state: context.state, mode: mode, onSend: onSend) - } label: { - LabeledContent(group.label) { - Text("\(group.members.count)") - } - } - } - } -} - // MARK: - Disclosure row (monochrome, local to fan card) private struct DisclosureRow: View { @@ -563,172 +513,6 @@ private struct DisclosureRow: View { } } -// MARK: - Extra row - -private struct FanExtraRow: View { - let expose: Expose - let state: [String: JSONValue] - let mode: CardDisplayMode - let horizontalPadding: CGFloat - let verticalPadding: CGFloat - let iconWidth: CGFloat - let onSend: (JSONValue) -> Void - - @State private var numericDraft: Double = 0 - - private var property: String { expose.property ?? expose.name ?? "" } - private var meta: FeatureMeta { FeatureCatalog.meta(for: property, exposeType: expose.type) } - private var label: String { meta.label } - private var stateValue: JSONValue? { state[property] } - - var body: some View { - rowContent - .padding(.horizontal, horizontalPadding) - .padding(.vertical, verticalPadding) - } - - @ViewBuilder - private var rowContent: some View { - switch expose.type { - case "binary": binaryRow - case "enum": enumRow - case "numeric": numericRow - default: textRow - } - } - - private var leadingIcon: some View { - Image(systemName: meta.symbol) - .font(.system(size: 16, weight: .medium)) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(.secondary) - .frame(width: iconWidth) - } - - @ViewBuilder - private var binaryRow: some View { - let isOn = stateValue == expose.valueOn || stateValue?.boolValue == true - HStack(spacing: DesignTokens.Spacing.md) { - leadingIcon - labelStack - Spacer() - if mode == .interactive, expose.isWritable, - let on = expose.valueOn, let off = expose.valueOff { - Toggle("", isOn: Binding( - get: { isOn }, - set: { v in onSend(.object([property: v ? on : off])) } - )) - .labelsHidden() - } else { - Text(isOn ? "On" : "Off").foregroundStyle(.secondary) - } - } - } - - @ViewBuilder - private var enumRow: some View { - let values = expose.values ?? [] - let current = stateValue?.stringValue ?? "—" - HStack(spacing: DesignTokens.Spacing.md) { - leadingIcon - labelStack - Spacer() - if mode == .interactive, expose.isWritable, !values.isEmpty { - Menu { - ForEach(values, id: \.self) { v in - Button { - onSend(.object([property: .string(v)])) - } label: { - if current == v { - Label(prettify(v), systemImage: "checkmark") - } else { - Text(prettify(v)) - } - } - } - } label: { - HStack(spacing: DesignTokens.Spacing.xs) { - Text(prettify(current)) - Image(systemName: "chevron.up.chevron.down") - .font(.caption2.weight(.semibold)) - .foregroundStyle(.tertiary) - } - } - .tint(.primary) - } else { - Text(prettify(current)).foregroundStyle(.secondary) - } - } - } - - @ViewBuilder - private var numericRow: some View { - let current = stateValue?.numberValue ?? 0 - let unit = expose.unit ?? "" - let writable = mode == .interactive && expose.isWritable - && expose.valueMin != nil && expose.valueMax != nil - - if writable, let min = expose.valueMin, let max = expose.valueMax { - VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { - HStack(spacing: DesignTokens.Spacing.md) { - leadingIcon - labelStack - Spacer() - Text(formatNumeric(numericDraft, unit: unit)) - .font(.body.monospacedDigit()) - .foregroundStyle(.secondary) - } - Slider(value: $numericDraft, in: min...max, step: expose.valueStep ?? 1) { editing in - guard !editing else { return } - onSend(.object([property: numericPayload(numericDraft, step: expose.valueStep)])) - } - .padding(.leading, iconWidth + DesignTokens.Spacing.md) - } - .onAppear { numericDraft = current } - .onChange(of: current) { _, v in numericDraft = v } - } else { - HStack(spacing: DesignTokens.Spacing.md) { - leadingIcon - labelStack - Spacer() - Text(formatNumeric(current, unit: unit)) - .font(.body.monospacedDigit()) - .foregroundStyle(.secondary) - } - } - } - - private func numericPayload(_ v: Double, step: Double?) -> JSONValue { - if let step, step.truncatingRemainder(dividingBy: 1) == 0 { - return .int(Int(v.rounded())) - } - return .double(v) - } - - @ViewBuilder - private var textRow: some View { - HStack(spacing: DesignTokens.Spacing.md) { - leadingIcon - labelStack - Spacer() - Text(stateValue?.stringified ?? "—").foregroundStyle(.secondary) - } - } - - @ViewBuilder - private var labelStack: some View { - Text(label).font(.body) - } - - private func prettify(_ s: String) -> String { - s.replacingOccurrences(of: "_", with: " ").capitalized - } - - private func formatNumeric(_ v: Double, unit: String) -> String { - let formatted = v.formatted(.number.precision(.fractionLength(0...1))) - return unit.isEmpty ? formatted : "\(formatted) \(unit)" - } -} #Preview { ScrollView { diff --git a/Shellbee/Shared/FanControl/FanExtraRow.swift b/Shellbee/Shared/FanControl/FanExtraRow.swift new file mode 100644 index 0000000..adcc162 --- /dev/null +++ b/Shellbee/Shared/FanControl/FanExtraRow.swift @@ -0,0 +1,170 @@ +import SwiftUI + +/// A row that renders an arbitrary fan-related `Expose` (binary / enum / +/// numeric / text) inline inside the `FanControlCard`'s "Extras" section. +/// Mirrors the row chrome of `SettingsFormRow` but is monochrome and +/// fan-specific so it can sit on the card surface rather than in a `List`. +struct FanExtraRow: View { + let expose: Expose + let state: [String: JSONValue] + let mode: CardDisplayMode + let horizontalPadding: CGFloat + let verticalPadding: CGFloat + let iconWidth: CGFloat + let onSend: (JSONValue) -> Void + + @State private var numericDraft: Double = 0 + + private var property: String { expose.property ?? expose.name ?? "" } + private var meta: FeatureMeta { FeatureCatalog.meta(for: property, exposeType: expose.type) } + private var label: String { meta.label } + private var stateValue: JSONValue? { state[property] } + + var body: some View { + rowContent + .padding(.horizontal, horizontalPadding) + .padding(.vertical, verticalPadding) + } + + @ViewBuilder + private var rowContent: some View { + switch expose.type { + case "binary": binaryRow + case "enum": enumRow + case "numeric": numericRow + default: textRow + } + } + + private var leadingIcon: some View { + Image(systemName: meta.symbol) + .font(.system(size: 16, weight: .medium)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.secondary) + .frame(width: iconWidth) + } + + @ViewBuilder + private var binaryRow: some View { + let isOn = stateValue == expose.valueOn || stateValue?.boolValue == true + HStack(spacing: DesignTokens.Spacing.md) { + leadingIcon + labelStack + Spacer() + if mode == .interactive, expose.isWritable, + let on = expose.valueOn, let off = expose.valueOff { + Toggle("", isOn: Binding( + get: { isOn }, + set: { v in onSend(.object([property: v ? on : off])) } + )) + .labelsHidden() + } else { + Text(isOn ? "On" : "Off").foregroundStyle(.secondary) + } + } + } + + @ViewBuilder + private var enumRow: some View { + let values = expose.values ?? [] + let current = stateValue?.stringValue ?? "—" + HStack(spacing: DesignTokens.Spacing.md) { + leadingIcon + labelStack + Spacer() + if mode == .interactive, expose.isWritable, !values.isEmpty { + Menu { + ForEach(values, id: \.self) { v in + Button { + onSend(.object([property: .string(v)])) + } label: { + if current == v { + Label(prettify(v), systemImage: "checkmark") + } else { + Text(prettify(v)) + } + } + } + } label: { + HStack(spacing: DesignTokens.Spacing.xs) { + Text(prettify(current)) + Image(systemName: "chevron.up.chevron.down") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.tertiary) + } + } + .tint(.primary) + } else { + Text(prettify(current)).foregroundStyle(.secondary) + } + } + } + + @ViewBuilder + private var numericRow: some View { + let current = stateValue?.numberValue ?? 0 + let unit = expose.unit ?? "" + let writable = mode == .interactive && expose.isWritable + && expose.valueMin != nil && expose.valueMax != nil + + if writable, let min = expose.valueMin, let max = expose.valueMax { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { + HStack(spacing: DesignTokens.Spacing.md) { + leadingIcon + labelStack + Spacer() + Text(formatNumeric(numericDraft, unit: unit)) + .font(.body.monospacedDigit()) + .foregroundStyle(.secondary) + } + Slider(value: $numericDraft, in: min...max, step: expose.valueStep ?? 1) { editing in + guard !editing else { return } + onSend(.object([property: numericPayload(numericDraft, step: expose.valueStep)])) + } + .padding(.leading, iconWidth + DesignTokens.Spacing.md) + } + .onAppear { numericDraft = current } + .onChange(of: current) { _, v in numericDraft = v } + } else { + HStack(spacing: DesignTokens.Spacing.md) { + leadingIcon + labelStack + Spacer() + Text(formatNumeric(current, unit: unit)) + .font(.body.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + } + + private func numericPayload(_ v: Double, step: Double?) -> JSONValue { + if let step, step.truncatingRemainder(dividingBy: 1) == 0 { + return .int(Int(v.rounded())) + } + return .double(v) + } + + @ViewBuilder + private var textRow: some View { + HStack(spacing: DesignTokens.Spacing.md) { + leadingIcon + labelStack + Spacer() + Text(stateValue?.stringified ?? "—").foregroundStyle(.secondary) + } + } + + @ViewBuilder + private var labelStack: some View { + Text(label).font(.body) + } + + private func prettify(_ s: String) -> String { + s.replacingOccurrences(of: "_", with: " ").capitalized + } + + private func formatNumeric(_ v: Double, unit: String) -> String { + let formatted = v.formatted(.number.precision(.fractionLength(0...1))) + return unit.isEmpty ? formatted : "\(formatted) \(unit)" + } +} diff --git a/Shellbee/Shared/FanControl/FanFeatureSections.swift b/Shellbee/Shared/FanControl/FanFeatureSections.swift new file mode 100644 index 0000000..3cd7f3e --- /dev/null +++ b/Shellbee/Shared/FanControl/FanFeatureSections.swift @@ -0,0 +1,49 @@ +import SwiftUI + +/// Renders the Fan device's feature sections (Behaviour, Indicators, etc.) as +/// native `List` sections. Place inside a `List` whose `.listStyle` is grouped +/// or inset-grouped. The hero / filter cards are still rendered by +/// `FanControlCard` (with `rendersSectionsInline: false`). +struct FanFeatureSections: View { + let context: FanControlContext + let mode: CardDisplayMode + let onSend: (JSONValue) -> Void + + private let filterProps: Set = ["replace_filter", "filter_age", "device_age"] + + private var eligibleExtras: [Expose] { + let claimed: Set = Set(["pm25", "air_quality"]).union(filterProps) + return context.extras.filter { e in + guard let prop = e.property else { return false } + return !claimed.contains(prop) + } + } + + private var sections: [LayoutSection] { FeatureLayout.sections(from: eligibleExtras) } + + var body: some View { + ForEach(sections) { section in + Section(section.title) { + ForEach(section.items, id: \.id) { item in + rowFor(item) + } + } + } + } + + @ViewBuilder + private func rowFor(_ item: LayoutItem) -> some View { + switch item { + case .row(let expose): + SettingsFormRow(expose: expose, state: context.state, mode: mode, onSend: onSend) + case .indexedGroup(let group): + NavigationLink { + FeatureGroupDetailView(group: group, state: context.state, mode: mode, onSend: onSend) + } label: { + LabeledContent(group.label) { + Text("\(group.members.count)") + } + } + } + } +} From 4c66320af5a9b6cf713dbece65227f24fe7ce473 Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 23:37:26 +0200 Subject: [PATCH 10/18] InAppNotificationOverlay: split banner views into peer files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InAppNotificationOverlay.swift was 728 lines holding three independent top-level views and a preview host. Splits: - InAppNotificationBanner (the expanded/collapsed main banner with drag gestures and previews) → InAppNotificationBanner.swift, taking the PreviewHost helper and all 10 banner-flavor #Preview blocks with it. - FastTrackBanner (the compact "Copied to Clipboard"-style banner) → FastTrackBanner.swift with its single preview. The overlay file itself now stays at 300 lines and only holds the queue manager that orchestrates which banner to show. Both new files are added to the ShellbeeWidgetsExtension membership exception list. Refs #33 Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee.xcodeproj/project.pbxproj | 2 + .../Notifications/FastTrackBanner.swift | 44 ++ .../InAppNotificationBanner.swift | 389 ++++++++++++++++ .../InAppNotificationOverlay.swift | 428 ------------------ 4 files changed, 435 insertions(+), 428 deletions(-) create mode 100644 Shellbee/Features/Notifications/FastTrackBanner.swift create mode 100644 Shellbee/Features/Notifications/InAppNotificationBanner.swift diff --git a/Shellbee.xcodeproj/project.pbxproj b/Shellbee.xcodeproj/project.pbxproj index 5b5bff9..cddd76c 100644 --- a/Shellbee.xcodeproj/project.pbxproj +++ b/Shellbee.xcodeproj/project.pbxproj @@ -196,6 +196,8 @@ Features/Logs/LogRowView.swift, Features/Logs/LogsView.swift, Features/Logs/LogsViewModel.swift, + Features/Notifications/FastTrackBanner.swift, + Features/Notifications/InAppNotificationBanner.swift, Features/Notifications/InAppNotificationOverlay.swift, Features/Settings/AboutView.swift, Features/Settings/AcknowledgementsView.swift, diff --git a/Shellbee/Features/Notifications/FastTrackBanner.swift b/Shellbee/Features/Notifications/FastTrackBanner.swift new file mode 100644 index 0000000..e300b6e --- /dev/null +++ b/Shellbee/Features/Notifications/FastTrackBanner.swift @@ -0,0 +1,44 @@ +import SwiftUI + +/// A compact, non-interactive banner reserved for short, transient +/// confirmations like "Copied to Clipboard". The queue manager surfaces a +/// fast-track notification briefly above whatever main banner is showing, +/// then dismisses it on a fixed timer. +struct FastTrackBanner: View { + let notification: InAppNotification + + var body: some View { + HStack(spacing: DesignTokens.Spacing.sm) { + Image(systemName: notification.level.systemImage) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(notification.level.color) + Text(notification.title) + .font(.footnote.weight(.semibold)) + .foregroundStyle(.primary) + } + .padding(.horizontal, DesignTokens.Spacing.lg) + .padding(.vertical, DesignTokens.Spacing.md) + .glassEffect(in: Capsule(style: .continuous)) + .shadow( + color: .black.opacity(DesignTokens.Shadow.floatingOpacity), + radius: DesignTokens.Shadow.floatingRadius, + y: -DesignTokens.Shadow.floatingY + ) + } +} + +#Preview("Fast-track — Copied") { + VStack { + Spacer() + FastTrackBanner( + notification: InAppNotification( + level: .info, + title: "Copied to Clipboard", + priority: .fastTrack + ) + ) + .padding(.bottom, DesignTokens.Size.notificationBottomInset) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemGroupedBackground)) +} diff --git a/Shellbee/Features/Notifications/InAppNotificationBanner.swift b/Shellbee/Features/Notifications/InAppNotificationBanner.swift new file mode 100644 index 0000000..3f26f53 --- /dev/null +++ b/Shellbee/Features/Notifications/InAppNotificationBanner.swift @@ -0,0 +1,389 @@ +import SwiftUI + +/// The expanded/collapsed notification banner shown above the floating tab +/// bar. Vertical drags collapse / expand / dismiss; horizontal drags +/// (expanded, stack > 1) page the carousel via `onSwipeNext` / `onSwipePrevious`. +struct InAppNotificationBanner: View { + let notification: InAppNotification + @Binding var isExpanded: Bool + var stackCount: Int = 1 + var stackPositionLabel: String? = nil + let onDismiss: () -> Void + let onGoToLog: () -> Void + let onGoToDevice: () -> Void + let onCopyMessage: () -> Void + var onSwipeNext: (() -> Void)? = nil + var onSwipePrevious: (() -> Void)? = nil + + @State private var dragOffset: CGSize = .zero + // Vertical drag = collapse/expand/dismiss. Horizontal drag while + // expanded = carousel left/right. See dragGesture. + + var body: some View { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { + header + if isExpanded { expandedBody } + } + .padding(.horizontal, DesignTokens.Spacing.lg) + .padding(.vertical, DesignTokens.Spacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + // iOS 26 floating tab bar uses a continuous capsule; per Apple HIG + // (Tab bars — floating accessories), floating UI above the tab bar + // should match its silhouette. Expanded uses a rounded rect for body room. + .glassEffect( + in: isExpanded + ? AnyShape(RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.xl, style: .continuous)) + : AnyShape(Capsule(style: .continuous)) + ) + .shadow( + color: .black.opacity(DesignTokens.Shadow.floatingOpacity), + radius: DesignTokens.Shadow.floatingRadius, + y: -DesignTokens.Shadow.floatingY + ) + .padding(.horizontal, DesignTokens.Spacing.lg) + .offset(x: dragOffset.width, y: dragOffset.height) + .contentShape(bannerHitShape) + .highPriorityGesture(dragGesture, including: .all) + .animation(Self.settleAnimation, value: isExpanded) + } + + private static var settleAnimation: Animation { + .interactiveSpring(response: 0.24, dampingFraction: 0.9, blendDuration: 0.04) + } + + private var bannerHitShape: AnyShape { + isExpanded + ? AnyShape(RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.xl, style: .continuous)) + : AnyShape(Capsule(style: .continuous)) + } + + private var header: some View { + HStack(spacing: DesignTokens.Spacing.sm) { + Image(systemName: notification.level.systemImage) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(notification.level.color) + .frame(width: DesignTokens.Size.cardSymbol) + + VStack(alignment: .leading, spacing: DesignTokens.Spacing.xxs) { + HStack(spacing: DesignTokens.Spacing.sm) { + Text(notification.title) + .font(.footnote.weight(.semibold)) + .foregroundStyle(.primary) + if notification.count > 1, stackPositionLabel == nil { + Text("× \(notification.count)") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, DesignTokens.Spacing.sm) + .padding(.vertical, DesignTokens.Spacing.xxs) + .background(.secondary.opacity(DesignTokens.Opacity.subtleFill), in: Capsule()) + } + if let stackPositionLabel { + Text(stackPositionLabel) + .font(.caption2.weight(.semibold).monospacedDigit()) + .foregroundStyle(.secondary) + .padding(.horizontal, DesignTokens.Spacing.sm) + .padding(.vertical, DesignTokens.Spacing.xxs) + .background(.tint.opacity(DesignTokens.Opacity.softFill), in: Capsule()) + } + } + if let subtitle = notification.subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(isExpanded ? nil : 1) + .truncationMode(.tail) + } + } + + Spacer(minLength: DesignTokens.Spacing.sm) + + Button(action: onDismiss) { + Image(systemName: "xmark") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + .frame(width: DesignTokens.Size.cardSymbol, height: DesignTokens.Size.cardSymbol) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .contentShape(Rectangle()) + .onTapGesture { + if isExpanded { + onCopyMessage() + } else { + isExpanded = true + } + } + } + + private var expandedBody: some View { + HStack(spacing: DesignTokens.Spacing.sm) { + if notification.deviceName != nil { + Button(action: onGoToDevice) { + Label("Device", systemImage: "sensor.tag.radiowaves.forward.fill") + } + .buttonStyle(.glassProminent) + .controlSize(.small) + } else if !notification.logEntryIDs.isEmpty { + Button(action: onGoToLog) { + Label("Log", systemImage: "list.bullet.rectangle.portrait") + } + .buttonStyle(.glassProminent) + .controlSize(.small) + } + + if notification.deviceName != nil, !notification.logEntryIDs.isEmpty { + Button(action: onGoToLog) { + Label("Log", systemImage: "list.bullet.rectangle.portrait") + } + .buttonStyle(.glass) + .controlSize(.small) + } + + Button(action: onCopyMessage) { + Label("Copy", systemImage: "doc.on.doc") + } + .buttonStyle(.glass) + .controlSize(.small) + + Spacer(minLength: 0) + } + .padding(.leading, DesignTokens.Size.cardSymbol + DesignTokens.Spacing.sm) + } + + private var dragGesture: some Gesture { + // Vertical: swipe up expands (from collapsed), swipe down dismisses + // the entire stack (from either state). + // Horizontal (expanded only): carousel left/right when stack > 1. + DragGesture(minimumDistance: DesignTokens.Spacing.sm) + .onChanged { value in + let dx = value.translation.width + let dy = value.translation.height + let verticalDominant = abs(dy) > abs(dx) + if verticalDominant { + if isExpanded { + dragOffset = CGSize(width: 0, height: max(dy, 0)) + } else { + dragOffset = CGSize(width: 0, height: dy > 0 ? dy : dy / 3) + } + } else if isExpanded, stackCount > 1 { + dragOffset = CGSize(width: dx, height: 0) + } else { + dragOffset = .zero + } + } + .onEnded { value in + let dx = value.translation.width + let dy = value.translation.height + let verticalDominant = abs(dy) > abs(dx) + var committedSwipe = false + if verticalDominant { + if !isExpanded, dy < -30 { + isExpanded = true + } else if dy > 60 { + onDismiss() + } + } else if isExpanded, stackCount > 1 { + if dx < -60 { + onSwipeNext?() + committedSwipe = true + } else if dx > 60 { + onSwipePrevious?() + committedSwipe = true + } + } + // On a committed carousel swipe, the outgoing banner is now a + // removed view — its position doesn't matter. Drop offset + // without animating so the slide-out transition takes over + // cleanly instead of the finger-follow offset snapping back. + if committedSwipe { + dragOffset = .zero + } else { + withAnimation(Self.settleAnimation) { dragOffset = .zero } + } + } + } +} + +// MARK: - Previews + +private struct PreviewHost: View { + let notification: InAppNotification + var expanded: Bool = false + @State private var isExpanded = false + + var body: some View { + VStack { + Spacer() + InAppNotificationBanner( + notification: notification, + isExpanded: $isExpanded, + stackCount: 1, + stackPositionLabel: nil, + onDismiss: {}, + onGoToLog: {}, + onGoToDevice: {}, + onCopyMessage: {} + ) + .padding(.bottom, DesignTokens.Size.notificationBottomInset) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemGroupedBackground)) + .onAppear { isExpanded = expanded } + } +} + +#Preview("Info — collapsed") { + PreviewHost( + notification: InAppNotification( + level: .info, + title: "Bind Successful", + subtitle: "hallway_motion_sensor → living_room_light", + logEntryID: UUID() + ) + ) +} + +#Preview("Error — collapsed") { + PreviewHost( + notification: InAppNotification( + level: .error, + title: "Operation Failed", + subtitle: "Publish 'zigbee2mqtt/hallway_ff_sensor/set' failed because the device is offline and no response was received within the timeout window.", + logEntryID: UUID() + ) + ) +} + +#Preview("Warning — collapsed") { + PreviewHost( + notification: InAppNotification( + level: .warning, + title: "Device Left Network", + subtitle: "bedroom_thermostat", + logEntryID: UUID() + ) + ) +} + +#Preview("Error — expanded") { + PreviewHost( + notification: InAppNotification( + level: .error, + title: "Operation Failed", + subtitle: "Publish 'zigbee2mqtt/hallway_ff_sensor/set' failed because the device is offline and no response was received within the timeout window.", + logEntryID: UUID() + ), + expanded: true + ) +} + +#Preview("Coalesced burst (× 12)") { + PreviewHost( + notification: { + var n = InAppNotification( + level: .error, + title: "Operation Failed", + subtitle: "Interview of 'sensor_12' failed", + logEntryID: UUID() + ) + n.count = 12 + n.logEntryIDs = (0..<12).map { _ in UUID() } + return n + }() + ) +} + +#Preview("Coalesced — expanded") { + PreviewHost( + notification: { + var n = InAppNotification( + level: .warning, + title: "Interview Failed", + subtitle: "Most recent: 'attic_sensor_3'", + logEntryID: UUID() + ) + n.count = 4 + n.logEntryIDs = (0..<4).map { _ in UUID() } + return n + }(), + expanded: true + ) +} + +#Preview("Interview started — collapsed") { + PreviewHost( + notification: InAppNotification( + level: .info, + title: "Interviewing Device", + subtitle: "kitchen_motion_sensor", + logEntryID: UUID(), + deviceName: "kitchen_motion_sensor" + ) + ) +} + +#Preview("Interview successful — expanded") { + PreviewHost( + notification: InAppNotification( + level: .info, + title: "Interview Successful", + subtitle: "kitchen_motion_sensor", + logEntryID: UUID(), + deviceName: "kitchen_motion_sensor" + ), + expanded: true + ) +} + +#Preview("Interview failed — expanded") { + PreviewHost( + notification: InAppNotification( + level: .error, + title: "Interview Failed", + subtitle: "attic_thermostat", + logEntryID: UUID(), + deviceName: "attic_thermostat" + ), + expanded: true + ) +} + +#Preview("Info — no logEntry (Go to Log hidden)") { + PreviewHost( + notification: InAppNotification( + level: .info, + title: "Reporting Configured", + subtitle: "living_room_light" + ), + expanded: true + ) +} + +#Preview("Stacked carousel — 1 of 12 expanded") { + @Previewable @State var expanded = true + VStack { + Spacer() + InAppNotificationBanner( + notification: InAppNotification( + level: .error, + title: "Operation Failed", + subtitle: "Publish 'zigbee2mqtt/hallway_ff_sensor/set' failed", + logEntryID: UUID(), + deviceName: "hallway_ff_sensor" + ), + isExpanded: $expanded, + stackCount: 12, + stackPositionLabel: "1/12", + onDismiss: {}, + onGoToLog: {}, + onGoToDevice: {}, + onCopyMessage: {}, + onSwipeNext: {}, + onSwipePrevious: {} + ) + .padding(.bottom, DesignTokens.Size.notificationBottomInset) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemGroupedBackground)) +} diff --git a/Shellbee/Features/Notifications/InAppNotificationOverlay.swift b/Shellbee/Features/Notifications/InAppNotificationOverlay.swift index a7e39e1..65d3374 100644 --- a/Shellbee/Features/Notifications/InAppNotificationOverlay.swift +++ b/Shellbee/Features/Notifications/InAppNotificationOverlay.swift @@ -298,431 +298,3 @@ struct InAppNotificationOverlay: View { } } } - -// MARK: - Main banner - -struct InAppNotificationBanner: View { - let notification: InAppNotification - @Binding var isExpanded: Bool - var stackCount: Int = 1 - var stackPositionLabel: String? = nil - let onDismiss: () -> Void - let onGoToLog: () -> Void - let onGoToDevice: () -> Void - let onCopyMessage: () -> Void - var onSwipeNext: (() -> Void)? = nil - var onSwipePrevious: (() -> Void)? = nil - - @State private var dragOffset: CGSize = .zero - // Vertical drag = collapse/expand/dismiss. Horizontal drag while - // expanded = carousel left/right. See dragGesture. - - var body: some View { - VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { - header - if isExpanded { expandedBody } - } - .padding(.horizontal, DesignTokens.Spacing.lg) - .padding(.vertical, DesignTokens.Spacing.md) - .frame(maxWidth: .infinity, alignment: .leading) - // iOS 26 floating tab bar uses a continuous capsule; per Apple HIG - // (Tab bars — floating accessories), floating UI above the tab bar - // should match its silhouette. Expanded uses a rounded rect for body room. - .glassEffect( - in: isExpanded - ? AnyShape(RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.xl, style: .continuous)) - : AnyShape(Capsule(style: .continuous)) - ) - .shadow( - color: .black.opacity(DesignTokens.Shadow.floatingOpacity), - radius: DesignTokens.Shadow.floatingRadius, - y: -DesignTokens.Shadow.floatingY - ) - .padding(.horizontal, DesignTokens.Spacing.lg) - .offset(x: dragOffset.width, y: dragOffset.height) - .contentShape(bannerHitShape) - .highPriorityGesture(dragGesture, including: .all) - .animation(Self.settleAnimation, value: isExpanded) - } - - private static var settleAnimation: Animation { - .interactiveSpring(response: 0.24, dampingFraction: 0.9, blendDuration: 0.04) - } - - private var bannerHitShape: AnyShape { - isExpanded - ? AnyShape(RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.xl, style: .continuous)) - : AnyShape(Capsule(style: .continuous)) - } - - private var header: some View { - HStack(spacing: DesignTokens.Spacing.sm) { - Image(systemName: notification.level.systemImage) - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(notification.level.color) - .frame(width: DesignTokens.Size.cardSymbol) - - VStack(alignment: .leading, spacing: DesignTokens.Spacing.xxs) { - HStack(spacing: DesignTokens.Spacing.sm) { - Text(notification.title) - .font(.footnote.weight(.semibold)) - .foregroundStyle(.primary) - if notification.count > 1, stackPositionLabel == nil { - Text("× \(notification.count)") - .font(.caption2.weight(.semibold)) - .foregroundStyle(.secondary) - .padding(.horizontal, DesignTokens.Spacing.sm) - .padding(.vertical, DesignTokens.Spacing.xxs) - .background(.secondary.opacity(DesignTokens.Opacity.subtleFill), in: Capsule()) - } - if let stackPositionLabel { - Text(stackPositionLabel) - .font(.caption2.weight(.semibold).monospacedDigit()) - .foregroundStyle(.secondary) - .padding(.horizontal, DesignTokens.Spacing.sm) - .padding(.vertical, DesignTokens.Spacing.xxs) - .background(.tint.opacity(DesignTokens.Opacity.softFill), in: Capsule()) - } - } - if let subtitle = notification.subtitle { - Text(subtitle) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(isExpanded ? nil : 1) - .truncationMode(.tail) - } - } - - Spacer(minLength: DesignTokens.Spacing.sm) - - Button(action: onDismiss) { - Image(systemName: "xmark") - .font(.caption.weight(.semibold)) - .foregroundStyle(.tertiary) - .frame(width: DesignTokens.Size.cardSymbol, height: DesignTokens.Size.cardSymbol) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - .contentShape(Rectangle()) - .onTapGesture { - if isExpanded { - onCopyMessage() - } else { - isExpanded = true - } - } - } - - private var expandedBody: some View { - HStack(spacing: DesignTokens.Spacing.sm) { - if notification.deviceName != nil { - Button(action: onGoToDevice) { - Label("Device", systemImage: "sensor.tag.radiowaves.forward.fill") - } - .buttonStyle(.glassProminent) - .controlSize(.small) - } else if !notification.logEntryIDs.isEmpty { - Button(action: onGoToLog) { - Label("Log", systemImage: "list.bullet.rectangle.portrait") - } - .buttonStyle(.glassProminent) - .controlSize(.small) - } - - if notification.deviceName != nil, !notification.logEntryIDs.isEmpty { - Button(action: onGoToLog) { - Label("Log", systemImage: "list.bullet.rectangle.portrait") - } - .buttonStyle(.glass) - .controlSize(.small) - } - - Button(action: onCopyMessage) { - Label("Copy", systemImage: "doc.on.doc") - } - .buttonStyle(.glass) - .controlSize(.small) - - Spacer(minLength: 0) - } - .padding(.leading, DesignTokens.Size.cardSymbol + DesignTokens.Spacing.sm) - } - - private var dragGesture: some Gesture { - // Vertical: swipe up expands (from collapsed), swipe down dismisses - // the entire stack (from either state). - // Horizontal (expanded only): carousel left/right when stack > 1. - DragGesture(minimumDistance: DesignTokens.Spacing.sm) - .onChanged { value in - let dx = value.translation.width - let dy = value.translation.height - let verticalDominant = abs(dy) > abs(dx) - if verticalDominant { - if isExpanded { - dragOffset = CGSize(width: 0, height: max(dy, 0)) - } else { - dragOffset = CGSize(width: 0, height: dy > 0 ? dy : dy / 3) - } - } else if isExpanded, stackCount > 1 { - dragOffset = CGSize(width: dx, height: 0) - } else { - dragOffset = .zero - } - } - .onEnded { value in - let dx = value.translation.width - let dy = value.translation.height - let verticalDominant = abs(dy) > abs(dx) - var committedSwipe = false - if verticalDominant { - if !isExpanded, dy < -30 { - isExpanded = true - } else if dy > 60 { - onDismiss() - } - } else if isExpanded, stackCount > 1 { - if dx < -60 { - onSwipeNext?() - committedSwipe = true - } else if dx > 60 { - onSwipePrevious?() - committedSwipe = true - } - } - // On a committed carousel swipe, the outgoing banner is now a - // removed view — its position doesn't matter. Drop offset - // without animating so the slide-out transition takes over - // cleanly instead of the finger-follow offset snapping back. - if committedSwipe { - dragOffset = .zero - } else { - withAnimation(Self.settleAnimation) { dragOffset = .zero } - } - } - } -} - -// MARK: - Fast-track banner - -struct FastTrackBanner: View { - let notification: InAppNotification - - var body: some View { - HStack(spacing: DesignTokens.Spacing.sm) { - Image(systemName: notification.level.systemImage) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(notification.level.color) - Text(notification.title) - .font(.footnote.weight(.semibold)) - .foregroundStyle(.primary) - } - .padding(.horizontal, DesignTokens.Spacing.lg) - .padding(.vertical, DesignTokens.Spacing.md) - .glassEffect(in: Capsule(style: .continuous)) - .shadow( - color: .black.opacity(DesignTokens.Shadow.floatingOpacity), - radius: DesignTokens.Shadow.floatingRadius, - y: -DesignTokens.Shadow.floatingY - ) - } -} - -// MARK: - Previews - -#Preview("Info — collapsed") { - PreviewHost( - notification: InAppNotification( - level: .info, - title: "Bind Successful", - subtitle: "hallway_motion_sensor → living_room_light", - logEntryID: UUID() - ) - ) -} - -#Preview("Error — collapsed") { - PreviewHost( - notification: InAppNotification( - level: .error, - title: "Operation Failed", - subtitle: "Publish 'zigbee2mqtt/hallway_ff_sensor/set' failed because the device is offline and no response was received within the timeout window.", - logEntryID: UUID() - ) - ) -} - -#Preview("Warning — collapsed") { - PreviewHost( - notification: InAppNotification( - level: .warning, - title: "Device Left Network", - subtitle: "bedroom_thermostat", - logEntryID: UUID() - ) - ) -} - -#Preview("Error — expanded") { - PreviewHost( - notification: InAppNotification( - level: .error, - title: "Operation Failed", - subtitle: "Publish 'zigbee2mqtt/hallway_ff_sensor/set' failed because the device is offline and no response was received within the timeout window.", - logEntryID: UUID() - ), - expanded: true - ) -} - -#Preview("Coalesced burst (× 12)") { - PreviewHost( - notification: { - var n = InAppNotification( - level: .error, - title: "Operation Failed", - subtitle: "Interview of 'sensor_12' failed", - logEntryID: UUID() - ) - n.count = 12 - n.logEntryIDs = (0..<12).map { _ in UUID() } - return n - }() - ) -} - -#Preview("Coalesced — expanded") { - PreviewHost( - notification: { - var n = InAppNotification( - level: .warning, - title: "Interview Failed", - subtitle: "Most recent: 'attic_sensor_3'", - logEntryID: UUID() - ) - n.count = 4 - n.logEntryIDs = (0..<4).map { _ in UUID() } - return n - }(), - expanded: true - ) -} - -#Preview("Interview started — collapsed") { - PreviewHost( - notification: InAppNotification( - level: .info, - title: "Interviewing Device", - subtitle: "kitchen_motion_sensor", - logEntryID: UUID(), - deviceName: "kitchen_motion_sensor" - ) - ) -} - -#Preview("Interview successful — expanded") { - PreviewHost( - notification: InAppNotification( - level: .info, - title: "Interview Successful", - subtitle: "kitchen_motion_sensor", - logEntryID: UUID(), - deviceName: "kitchen_motion_sensor" - ), - expanded: true - ) -} - -#Preview("Interview failed — expanded") { - PreviewHost( - notification: InAppNotification( - level: .error, - title: "Interview Failed", - subtitle: "attic_thermostat", - logEntryID: UUID(), - deviceName: "attic_thermostat" - ), - expanded: true - ) -} - -#Preview("Info — no logEntry (Go to Log hidden)") { - PreviewHost( - notification: InAppNotification( - level: .info, - title: "Reporting Configured", - subtitle: "living_room_light" - ), - expanded: true - ) -} - -#Preview("Stacked carousel — 1 of 12 expanded") { - @Previewable @State var expanded = true - VStack { - Spacer() - InAppNotificationBanner( - notification: InAppNotification( - level: .error, - title: "Operation Failed", - subtitle: "Publish 'zigbee2mqtt/hallway_ff_sensor/set' failed", - logEntryID: UUID(), - deviceName: "hallway_ff_sensor" - ), - isExpanded: $expanded, - stackCount: 12, - stackPositionLabel: "1/12", - onDismiss: {}, - onGoToLog: {}, - onGoToDevice: {}, - onCopyMessage: {}, - onSwipeNext: {}, - onSwipePrevious: {} - ) - .padding(.bottom, DesignTokens.Size.notificationBottomInset) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(.systemGroupedBackground)) -} - -#Preview("Fast-track — Copied") { - VStack { - Spacer() - FastTrackBanner( - notification: InAppNotification( - level: .info, - title: "Copied to Clipboard", - priority: .fastTrack - ) - ) - .padding(.bottom, DesignTokens.Size.notificationBottomInset) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(.systemGroupedBackground)) -} - -private struct PreviewHost: View { - let notification: InAppNotification - var expanded: Bool = false - @State private var isExpanded = false - - var body: some View { - VStack { - Spacer() - InAppNotificationBanner( - notification: notification, - isExpanded: $isExpanded, - stackCount: 1, - stackPositionLabel: nil, - onDismiss: {}, - onGoToLog: {}, - onGoToDevice: {}, - onCopyMessage: {} - ) - .padding(.bottom, DesignTokens.Size.notificationBottomInset) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(.systemGroupedBackground)) - .onAppear { isExpanded = expanded } - } -} From 6de1dcc8470c514ddba61f0afffb6909595ca90a Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 23:39:10 +0200 Subject: [PATCH 11/18] MQTTInspectorView: lift JSONHighlighter and SubscribeStore into peer files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MQTTInspectorView.swift was 411 lines holding the inspector view, an @Observable store, a model type, a generic JSON highlighting utility, and two helper subviews. Splits: - JSONHighlighter (a generic regex-based JSON colorer with no MQTT-specific state) → Shared/Components/JSONHighlighter.swift, where it's reusable by anything else surfacing JSON to the user (BeautifulPayloadView is the obvious next consumer). Pure Foundation/SwiftUI, so no widget membership exception needed. - SubscribeStore + InspectorMessage (the @Observable buffer of inbound messages plus the per-message model that decodes the topic into a log level for coloring) → Features/Settings/Developer/SubscribeStore.swift. Tightly coupled, so they stay together; both reference main-app types (ConnectionSessionController, JSONValue, Z2MTopics, LogLevel) and need the widget membership exception. The view file itself drops from 411 → 276 lines and now holds only the view layer (MQTTInspectorView, SubscribeView, MessageRow, PublishView). Refs #33 Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee.xcodeproj/project.pbxproj | 1 + .../Developer/MQTTInspectorView.swift | 135 ------------------ .../Settings/Developer/SubscribeStore.swift | 77 ++++++++++ .../Shared/Components/JSONHighlighter.swift | 62 ++++++++ 4 files changed, 140 insertions(+), 135 deletions(-) create mode 100644 Shellbee/Features/Settings/Developer/SubscribeStore.swift create mode 100644 Shellbee/Shared/Components/JSONHighlighter.swift diff --git a/Shellbee.xcodeproj/project.pbxproj b/Shellbee.xcodeproj/project.pbxproj index cddd76c..09cbc64 100644 --- a/Shellbee.xcodeproj/project.pbxproj +++ b/Shellbee.xcodeproj/project.pbxproj @@ -212,6 +212,7 @@ Features/Settings/Developer/DeveloperSettings.swift, Features/Settings/Developer/DeveloperSettingsView.swift, Features/Settings/Developer/MQTTInspectorView.swift, + Features/Settings/Developer/SubscribeStore.swift, Features/Settings/DeviceStatisticsView.swift, Features/Settings/DocBrowserDetailView.swift, Features/Settings/DocBrowserView.swift, diff --git a/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift b/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift index 4bc50d9..97d4060 100644 --- a/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift +++ b/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift @@ -40,141 +40,6 @@ struct MQTTInspectorView: View { } } -// MARK: - Model - -@Observable -final class SubscribeStore { - var messages: [InspectorMessage] = [] - var paused: Bool = false - var filter: String = "" - let bufferCap: Int = 1000 - - func attach(session: ConnectionSessionController) { - session.rawInboundTap = { [weak self] topic, payload in - guard let self, !self.paused else { return } - let msg = InspectorMessage(timestamp: .now, topic: topic, payload: payload) - Task { @MainActor [weak self] in - guard let self else { return } - self.messages.append(msg) - if self.messages.count > self.bufferCap { - self.messages.removeFirst(self.messages.count - self.bufferCap) - } - } - } - } - - func detach(session: ConnectionSessionController) { - session.rawInboundTap = nil - } - - func clear() { - messages.removeAll() - } - - var filtered: [InspectorMessage] { - let f = filter.trimmingCharacters(in: .whitespaces) - guard !f.isEmpty else { return messages } - return messages.filter { $0.topic.localizedCaseInsensitiveContains(f) } - } -} - -struct InspectorMessage: Identifiable, Equatable { - let id = UUID() - let timestamp: Date - let topic: String - let payload: JSONValue - - var prettyPayload: String { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - guard let data = try? encoder.encode(payload), - let text = String(data: data, encoding: .utf8) else { - return "" - } - return text - } - - /// Z2M log messages on `bridge/logging` carry a `level` field — surface - /// that color on the row icon to match the raw logs view. - var logLevelColor: Color { - guard topic == Z2MTopics.bridgeLogging, - let level = payload.object?["level"]?.stringValue, - let parsed = LogLevel(rawValue: level.lowercased()) else { - return .secondary - } - return parsed.color - } - - var logLevelIcon: String { - guard topic == Z2MTopics.bridgeLogging, - let level = payload.object?["level"]?.stringValue, - let parsed = LogLevel(rawValue: level.lowercased()) else { - return "dot.radiowaves.up.forward" - } - return parsed.systemImage - } -} - -// MARK: - JSON syntax highlighting - -enum JSONHighlighter { - static func attributed(_ source: String) -> AttributedString { - var out = AttributedString(source) - out.font = .caption.monospaced() - out.foregroundColor = .secondary - - // Keys: "" : → blue - if let regex = try? Regex<(Substring, Substring)>("\"([^\"\\\\]+)\"\\s*:") { - for match in source.matches(of: regex) { - let r = match.range - if let lower = AttributedString.Index(r.lowerBound, within: out), - let upper = AttributedString.Index(r.upperBound, within: out) { - out[lower..(":\\s*(\"[^\"\\\\]*\")") { - for match in source.matches(of: regex) { - let inner = match.output.1 - let r = inner.startIndex..("(?("\\b\(word)\\b") { - for match in source.matches(of: regex) { - let r = match.range - if let lower = AttributedString.Index(r.lowerBound, within: out), - let upper = AttributedString.Index(r.upperBound, within: out) { - out[lower.. self.bufferCap { + self.messages.removeFirst(self.messages.count - self.bufferCap) + } + } + } + } + + func detach(session: ConnectionSessionController) { + session.rawInboundTap = nil + } + + func clear() { + messages.removeAll() + } + + var filtered: [InspectorMessage] { + let f = filter.trimmingCharacters(in: .whitespaces) + guard !f.isEmpty else { return messages } + return messages.filter { $0.topic.localizedCaseInsensitiveContains(f) } + } +} + +struct InspectorMessage: Identifiable, Equatable { + let id = UUID() + let timestamp: Date + let topic: String + let payload: JSONValue + + var prettyPayload: String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + guard let data = try? encoder.encode(payload), + let text = String(data: data, encoding: .utf8) else { + return "" + } + return text + } + + /// Z2M log messages on `bridge/logging` carry a `level` field — surface + /// that color on the row icon to match the raw logs view. + var logLevelColor: Color { + guard topic == Z2MTopics.bridgeLogging, + let level = payload.object?["level"]?.stringValue, + let parsed = LogLevel(rawValue: level.lowercased()) else { + return .secondary + } + return parsed.color + } + + var logLevelIcon: String { + guard topic == Z2MTopics.bridgeLogging, + let level = payload.object?["level"]?.stringValue, + let parsed = LogLevel(rawValue: level.lowercased()) else { + return "dot.radiowaves.up.forward" + } + return parsed.systemImage + } +} diff --git a/Shellbee/Shared/Components/JSONHighlighter.swift b/Shellbee/Shared/Components/JSONHighlighter.swift new file mode 100644 index 0000000..acf2938 --- /dev/null +++ b/Shellbee/Shared/Components/JSONHighlighter.swift @@ -0,0 +1,62 @@ +import SwiftUI + +/// Lightweight JSON syntax highlighting via regex over a pretty-printed +/// source string. Used by the MQTT Inspector to color message payloads, but +/// generic enough to drop into any view that surfaces JSON to the user. +enum JSONHighlighter { + static func attributed(_ source: String) -> AttributedString { + var out = AttributedString(source) + out.font = .caption.monospaced() + out.foregroundColor = .secondary + + // Keys: "" : → blue + if let regex = try? Regex<(Substring, Substring)>("\"([^\"\\\\]+)\"\\s*:") { + for match in source.matches(of: regex) { + let r = match.range + if let lower = AttributedString.Index(r.lowerBound, within: out), + let upper = AttributedString.Index(r.upperBound, within: out) { + out[lower..(":\\s*(\"[^\"\\\\]*\")") { + for match in source.matches(of: regex) { + let inner = match.output.1 + let r = inner.startIndex..("(?("\\b\(word)\\b") { + for match in source.matches(of: regex) { + let r = match.range + if let lower = AttributedString.Index(r.lowerBound, within: out), + let upper = AttributedString.Index(r.upperBound, within: out) { + out[lower.. Date: Wed, 29 Apr 2026 00:01:35 +0200 Subject: [PATCH 12/18] AppStore: split into 5 domain extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppStore.swift was 691 lines, well past the 600-line hard cap, holding the @Observable class plus event handling, notification queue management, OTA orchestration, log buffering, and device lookups all in one file. Splits methods into domain-scoped extensions while keeping stored properties (which @Observable requires in the main class) and the init/reset lifecycle in the main file: - AppStore.swift (116) — class declaration, all stored properties, the DeviceCheckResult enum, init, reset, and the first-seen persistence helpers (recordFirstSeen / removeFirstSeen are now internal so the Events extension can call them). - AppStore+Events.swift (275) — the apply(_ event:) dispatcher plus the static logEntry(from:) and notification(from:) helpers it uses. - AppStore+Notifications.swift (109) — pop / popFastTrack / enqueueOTABulkSummary / enqueueNotification / notification(for:) / stripped helper. - AppStore+OTA.swift (140) — activeOTAUpdates, otaStatus(for:), the start/cancel methods, and the handle… response/state callbacks plus flashCheckResult. - AppStore+Logs.swift (22) — clearLogs, insertLogEntry, insertRawLogEntry. - AppStore+Devices.swift (49) — device(named:), state(for:), isAvailable, and the optimisticRename / revertOptimisticRename pair. A handful of formerly-private members (pendingRenames, recordFirstSeen, removeFirstSeen, revertOptimisticRename, the OTA handlers, etc.) bumped to internal so cross-file extensions can reach them. No external API surface change — all of these were already module-internal, just file-private within AppStore.swift. The five new files are quoted in pbxproj (the `+` requires it) and added to the ShellbeeWidgetsExtension membership exception list. Build green. No behavior changes intended. Refs #34 Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee.xcodeproj/project.pbxproj | 5 + Shellbee/Core/Store/AppStore+Devices.swift | 49 ++ Shellbee/Core/Store/AppStore+Events.swift | 275 ++++++++ Shellbee/Core/Store/AppStore+Logs.swift | 22 + .../Core/Store/AppStore+Notifications.swift | 109 ++++ Shellbee/Core/Store/AppStore+OTA.swift | 140 ++++ Shellbee/Core/Store/AppStore.swift | 599 +----------------- 7 files changed, 612 insertions(+), 587 deletions(-) create mode 100644 Shellbee/Core/Store/AppStore+Devices.swift create mode 100644 Shellbee/Core/Store/AppStore+Events.swift create mode 100644 Shellbee/Core/Store/AppStore+Logs.swift create mode 100644 Shellbee/Core/Store/AppStore+Notifications.swift create mode 100644 Shellbee/Core/Store/AppStore+OTA.swift diff --git a/Shellbee.xcodeproj/project.pbxproj b/Shellbee.xcodeproj/project.pbxproj index 09cbc64..c46c21a 100644 --- a/Shellbee.xcodeproj/project.pbxproj +++ b/Shellbee.xcodeproj/project.pbxproj @@ -120,6 +120,11 @@ Core/Services/DeviceDocService.swift, Core/Services/DocBrowserIndex.swift, Core/Services/GuideDocService.swift, + "Core/Store/AppStore+Devices.swift", + "Core/Store/AppStore+Events.swift", + "Core/Store/AppStore+Logs.swift", + "Core/Store/AppStore+Notifications.swift", + "Core/Store/AppStore+OTA.swift", Core/Store/AppStore.swift, Core/Store/NotificationPreferences.swift, Features/Bridge/PhilipsHueResetSheet.swift, diff --git a/Shellbee/Core/Store/AppStore+Devices.swift b/Shellbee/Core/Store/AppStore+Devices.swift new file mode 100644 index 0000000..46ff3bc --- /dev/null +++ b/Shellbee/Core/Store/AppStore+Devices.swift @@ -0,0 +1,49 @@ +import Foundation + +extension AppStore { + func device(named friendlyName: String) -> Device? { + devices.first { $0.friendlyName == friendlyName } + } + + func state(for friendlyName: String) -> [String: JSONValue] { + deviceStates[friendlyName] ?? [:] + } + + func isAvailable(_ friendlyName: String) -> Bool { + deviceAvailability[friendlyName] ?? false + } + + /// Apply a rename to local state immediately so the UI updates without + /// waiting for the bridge/devices snapshot (which can lag 3-10s after a + /// bridge/request/device/rename). Migrates availability and state keys so + /// the renamed device doesn't flicker through "offline". + func optimisticRename(from: String, to: String) { + guard from != to, !to.isEmpty else { return } + guard let idx = devices.firstIndex(where: { $0.friendlyName == from }) else { return } + var device = devices[idx] + device.friendlyName = to + devices[idx] = device + + if let availability = deviceAvailability.removeValue(forKey: from) { + deviceAvailability[to] = availability + } + if let state = deviceStates.removeValue(forKey: from) { + deviceStates[to] = state + } + pendingRenames.append((from: from, to: to)) + } + + func revertOptimisticRename(from: String, to: String) { + guard let idx = devices.firstIndex(where: { $0.friendlyName == to }) else { return } + var device = devices[idx] + device.friendlyName = from + devices[idx] = device + + if let availability = deviceAvailability.removeValue(forKey: to) { + deviceAvailability[from] = availability + } + if let state = deviceStates.removeValue(forKey: to) { + deviceStates[from] = state + } + } +} diff --git a/Shellbee/Core/Store/AppStore+Events.swift b/Shellbee/Core/Store/AppStore+Events.swift new file mode 100644 index 0000000..cc5f8bf --- /dev/null +++ b/Shellbee/Core/Store/AppStore+Events.swift @@ -0,0 +1,275 @@ +import Foundation + +extension AppStore { + func apply(_ event: Z2MEvent) { + switch event { + case .bridgeInfo(let info): + bridgeInfo = info + case .bridgeState(let state): + bridgeOnline = state == "online" + case .devices(let list): + // Backfill first-seen for any device we've never recorded. + // Covers the case where a device joined while the app was closed + // and we missed the bridge/event device_joined message — when it + // shows up in interview state on the first snapshot, treat it as + // freshly added. + for device in list where device.type != .coordinator { + guard deviceFirstSeen[device.ieeeAddress] == nil else { continue } + if device.interviewing || !device.interviewCompleted { + recordFirstSeen(ieee: device.ieeeAddress) + } + } + devices = list + case .groups(let list): + groups = list + case .logMessage(let msg): + let level = LogLevel(raw: msg.level) ?? .info + insertRawLogEntry(LogEntry( + id: msg.id, timestamp: .now, level: level, + category: .general, namespace: msg.namespace, + message: msg.message, deviceName: nil + )) + let knownNames = Set(devices.map(\.friendlyName) + groups.map(\.friendlyName)) + let ctx = LogMapperEngine.context( + message: msg.message, namespace: msg.namespace, knownDevices: knownNames + ) + // MQTT publish for a known device/group state topic is redundant — the + // .deviceState event creates a richer stateChange entry for the same update. + if case .mqttPublish = ctx.action, + let deviceName = ctx.primaryDevice?.friendlyName, + knownNames.contains(deviceName) { + break + } + let entry = LogEntry( + id: msg.id, timestamp: .now, level: level, + category: ctx.inferredCategory, + namespace: msg.namespace, message: msg.message, + deviceName: ctx.primaryDevice?.friendlyName, context: ctx + ) + insertLogEntry(entry) + if let note = notification(for: ctx.action, level: level, deviceName: ctx.primaryDevice?.friendlyName, message: msg.message, id: msg.id) { + enqueueNotification(note) + } + case .bridgeEvent(let event): + if let entry = Self.logEntry(from: event) { + insertLogEntry(entry) + if let note = Self.notification(from: event, entry: entry) { + enqueueNotification(note) + } + } + if let ieee = event.data.object?["ieee_address"]?.stringValue { + switch event.type { + case "device_joined": + // Restart the 30-min window on (re)join. + recordFirstSeen(ieee: ieee, overwrite: true) + case "device_leave": + removeFirstSeen(ieee: ieee) + case "device_interview": + let name = event.data.object?["friendly_name"]?.stringValue ?? ieee + let status = event.data.object?["status"]?.stringValue + Task { @MainActor in + switch status { + case "started": + InterviewLiveActivityCoordinator.shared.start(deviceName: name, ieeeAddress: ieee) + case "successful": + InterviewLiveActivityCoordinator.shared.finish(deviceName: name, ieeeAddress: ieee, success: true) + case "failed": + InterviewLiveActivityCoordinator.shared.finish(deviceName: name, ieeeAddress: ieee, success: false) + default: + break + } + } + default: + break + } + } + case .deviceState(let name, let state): + let previous = deviceStates[name] ?? [:] + if !previous.isEmpty { + let changes = LogMapperEngine.diff(previous, state) + if !changes.isEmpty { + insertLogEntry(LogMapperEngine.stateChangeEntry(device: name, changes: changes)) + } + } + deviceStates[name] = state + handleOTAState(for: name, state: state) + case .deviceAvailability(let name, let available): + deviceAvailability[name] = available + case .deviceOTAUpdateResponse(let response): + handleOTAResponse(response) + case .deviceOTACheckResponse(let response): + handleOTACheckResponse(response) + case .permitJoinChanged(let enabled, let remaining): + if let info = bridgeInfo { + bridgeInfo = BridgeInfo( + version: info.version, + commit: info.commit, + coordinator: info.coordinator, + network: info.network, + logLevel: info.logLevel, + permitJoin: enabled, + permitJoinTimeout: remaining, + permitJoinEnd: remaining.map { Int(Date().timeIntervalSince1970 * 1000) + ($0 * 1000) }, + restartRequired: info.restartRequired, + config: info.config + ) + } + + case .bridgeResponse(let topic, let payload): + if topic == Z2MTopics.bridgeResponseBackup, let handler = backupResponseHandler { + backupResponseHandler = nil + if payload.object?["status"]?.stringValue == "ok", + let zip = payload.object?["data"]?.object?["zip"]?.stringValue { + handler(zip, nil) + } else { + let err = payload.object?["error"]?.stringValue ?? "Unknown error" + handler(nil, err) + } + break + } + // The options/info responses carry only `{restart_required}` (and + // echo the request on error). The full config is delivered via the + // separate `bridge/info` topic, so don't try to decode config here + // — doing so would overwrite the real config with all-nils. + guard payload.object?["status"]?.stringValue == "ok" else { break } + if let restartRequired = payload.object?["data"]?.object?["restart_required"]?.boolValue, + let info = bridgeInfo { + bridgeInfo = info.copyUpdating(restartRequired: restartRequired) + } + + case .bridgeHealth(let health): + if let existing = bridgeHealth, health.process == nil { + // Sparse response (e.g. bridge/response/health_check returns only {healthy:true}) + // Merge: preserve rich stats, update the healthy flag + bridgeHealth = BridgeHealth( + healthy: health.healthy, + responseTime: existing.responseTime, + process: existing.process, + os: existing.os, + mqtt: existing.mqtt + ) + } else { + bridgeHealth = health + } + + case .touchlinkScanResult(let devices): + touchlinkDevices = devices + touchlinkScanInProgress = false + + case .touchlinkIdentifyDone: + touchlinkIdentifyInProgress = false + + case .touchlinkFactoryResetDone: + touchlinkResetInProgress = false + + case .deviceRemoveResponse(let id, let ok, let errorMessage): + pendingRemovals.remove(id) + if ok { + // Remove locally so the next bridge/devices snapshot doesn't + // race with our List diff. Also clears keyed state so the + // "Recently Added" backfill doesn't resurrect it. + devices.removeAll { $0.friendlyName == id } + deviceStates.removeValue(forKey: id) + deviceAvailability.removeValue(forKey: id) + otaUpdates.removeValue(forKey: id) + deviceCheckResults.removeValue(forKey: id) + } else { + let message = errorMessage ?? "Failed to remove '\(id)'" + let error = Z2MOperationError( + id: UUID(), + topic: Z2MTopics.bridgeResponseDeviceRemove, + message: message, + timestamp: .now + ) + apply(.operationError(error)) + } + + case .deviceRenameResponse(let from, let to, let ok, let errorMessage): + if let pendingIdx = pendingRenames.firstIndex(where: { $0.from == from && $0.to == to }) { + pendingRenames.remove(at: pendingIdx) + } + if !ok { + revertOptimisticRename(from: from, to: to) + let message = errorMessage ?? "Failed to rename '\(from)' to '\(to)'" + let error = Z2MOperationError( + id: UUID(), + topic: Z2MTopics.bridgeResponseDeviceRename, + message: message, + timestamp: .now + ) + apply(.operationError(error)) + } + + case .operationError(let error): + touchlinkScanInProgress = false + touchlinkIdentifyInProgress = false + touchlinkResetInProgress = false + operationErrors.insert(error, at: 0) + let entry = LogEntry( + id: UUID(), + timestamp: error.timestamp, + level: .error, + category: .general, + namespace: "z2m:response", + message: error.message, + deviceName: nil + ) + insertLogEntry(entry) + enqueueNotification(InAppNotification( + level: .error, + title: "Operation Failed", + subtitle: stripped(String(error.message.prefix(100))), + logEntryID: entry.id, + category: .operationFailed + )) + + case .unknown: + break + } + } + + // MARK: - Static helpers for bridge events + + static func logEntry(from event: BridgeDeviceEvent) -> LogEntry? { + guard let data = event.data.object else { return nil } + let deviceName = data["friendly_name"]?.stringValue + let ieeeAddr = data["ieee_address"]?.stringValue + let name = deviceName ?? ieeeAddr ?? "unknown" + + switch event.type { + case "device_joined": + return LogEntry(id: UUID(), timestamp: .now, level: .info, category: .deviceJoined, namespace: nil, message: "Device '\(name)' joined the network", deviceName: deviceName) + case "device_announce": + return LogEntry(id: UUID(), timestamp: .now, level: .info, category: .deviceAnnounce, namespace: nil, message: "Device '\(name)' announced", deviceName: deviceName) + case "device_interview": + let status = data["status"]?.stringValue ?? "unknown" + let level: LogLevel = status == "failed" ? .error : .info + return LogEntry(id: UUID(), timestamp: .now, level: level, category: .interview, namespace: nil, message: "Interview of '\(name)' \(status)", deviceName: deviceName) + case "device_leave": + return LogEntry(id: UUID(), timestamp: .now, level: .warning, category: .deviceLeave, namespace: nil, message: "Device '\(name)' left the network", deviceName: deviceName) + default: + return nil + } + } + + static func notification(from event: BridgeDeviceEvent, entry: LogEntry) -> InAppNotification? { + switch event.type { + case "device_leave": + return InAppNotification(level: .warning, title: "Device Left Network", subtitle: entry.deviceName, logEntryID: entry.id, deviceName: entry.deviceName, category: .deviceLeft) + case "device_interview": + let status = event.data.object?["status"]?.stringValue ?? "unknown" + switch status { + case "started": + return InAppNotification(level: .info, title: "Interviewing Device", subtitle: entry.deviceName, logEntryID: entry.id, deviceName: entry.deviceName, category: .interviewStarted) + case "successful": + return InAppNotification(level: .info, title: "Interview Successful", subtitle: entry.deviceName, logEntryID: entry.id, deviceName: entry.deviceName, category: .interviewSuccessful) + case "failed": + return InAppNotification(level: .error, title: "Interview Failed", subtitle: entry.deviceName, logEntryID: entry.id, deviceName: entry.deviceName, category: .interviewFailed) + default: + return nil + } + default: + return nil + } + } +} diff --git a/Shellbee/Core/Store/AppStore+Logs.swift b/Shellbee/Core/Store/AppStore+Logs.swift new file mode 100644 index 0000000..d6745e7 --- /dev/null +++ b/Shellbee/Core/Store/AppStore+Logs.swift @@ -0,0 +1,22 @@ +import Foundation + +extension AppStore { + func clearLogs() { + logEntries = [] + rawLogEntries = [] + } + + func insertLogEntry(_ entry: LogEntry) { + logEntries.insert(entry, at: 0) + if logEntries.count > Self.logLimit { + logEntries = Array(logEntries.prefix(Self.logLimit)) + } + } + + func insertRawLogEntry(_ entry: LogEntry) { + rawLogEntries.insert(entry, at: 0) + if rawLogEntries.count > Self.logLimit { + rawLogEntries = Array(rawLogEntries.prefix(Self.logLimit)) + } + } +} diff --git a/Shellbee/Core/Store/AppStore+Notifications.swift b/Shellbee/Core/Store/AppStore+Notifications.swift new file mode 100644 index 0000000..142f958 --- /dev/null +++ b/Shellbee/Core/Store/AppStore+Notifications.swift @@ -0,0 +1,109 @@ +import Foundation + +extension AppStore { + func popNotification() -> InAppNotification? { + guard !pendingNotifications.isEmpty else { return nil } + return pendingNotifications.removeFirst() + } + + func popFastTrackNotification() -> InAppNotification? { + guard !fastTrackNotifications.isEmpty else { return nil } + return fastTrackNotifications.removeFirst() + } + + func enqueueOTABulkSummary(_ summary: OTABulkOperationQueue.CompletionSummary) { + let noun = summary.kind == .check ? "Checked" : "Updated" + let level: LogLevel = summary.failed > 0 ? .warning : .info + let title: String + if summary.wasCancelled { + title = summary.kind == .check ? "Check Cancelled" : "Updates Cancelled" + } else if summary.failed > 0 { + title = "\(noun) \(summary.total) Devices" + } else { + title = "\(noun) \(summary.total) Devices" + } + var parts: [String] = [] + if summary.succeeded > 0 { + parts.append("\(summary.succeeded) succeeded") + } + if summary.failed > 0 { + parts.append("\(summary.failed) failed") + } + let subtitle = parts.isEmpty ? nil : parts.joined(separator: ", ") + enqueueNotification(InAppNotification( + level: level, + title: title, + subtitle: subtitle, + category: .otaBulkSummary + )) + } + + func enqueueNotification(_ notification: InAppNotification) { + // Fast-track bypasses the filter — these are transient confirmations + // (e.g. "Copied to Clipboard") driven by the user's own action. + if notification.priority == .fastTrack { + fastTrackNotifications.append(notification) + return + } + + if let filter = notificationFilter, !filter(notification) { return } + + let now = Date() + + if let idx = pendingNotifications.lastIndex(where: { $0.coalesceKey == notification.coalesceKey }), + now.timeIntervalSince(pendingNotifications[idx].lastUpdated) <= Self.coalesceWindow { + pendingNotifications[idx].count += notification.count + pendingNotifications[idx].logEntryIDs.append(contentsOf: notification.logEntryIDs) + pendingNotifications[idx].occurrences.append(contentsOf: notification.occurrences) + if let sub = notification.subtitle { pendingNotifications[idx].subtitle = sub } + pendingNotifications[idx].lastUpdated = now + return + } + + pendingNotifications.append(notification) + notificationArrivalID = UUID() + } + + func notification( + for action: LogContext.LogAction, level: LogLevel, + deviceName: String?, message: String, id: UUID + ) -> InAppNotification? { + let truncated = stripped(String(message.prefix(100))) + switch action { + case .bindSuccess: + return InAppNotification(level: .info, title: "Bind Successful", subtitle: deviceName, logEntryID: id, deviceName: deviceName, category: .bindSuccess) + case .bindFailure: + return InAppNotification(level: .error, title: "Bind Failed", subtitle: deviceName ?? truncated, logEntryID: id, deviceName: deviceName, category: .bindFailure) + case .unbind: + return InAppNotification(level: .info, title: "Unbound", subtitle: deviceName, logEntryID: id, deviceName: deviceName, category: .unbind) + case .groupAdd: + return InAppNotification(level: .info, title: "Added to Group", subtitle: deviceName, logEntryID: id, deviceName: deviceName, category: .groupAdd) + case .groupRemove: + return InAppNotification(level: .info, title: "Removed from Group", subtitle: deviceName, logEntryID: id, deviceName: deviceName, category: .groupRemove) + case .publishFailure(let command): + let detail = command.isEmpty ? truncated : command + return InAppNotification(level: .error, title: "Command Failed", subtitle: detail, logEntryID: id, deviceName: deviceName, category: .publishFailure) + case .requestFailure: + return InAppNotification(level: .error, title: "Request Failed", subtitle: truncated, logEntryID: id, deviceName: deviceName, category: .requestFailure) + case .otaFinished: + return InAppNotification(level: .info, title: "Update Installed", subtitle: deviceName, logEntryID: id, deviceName: deviceName, category: .otaUpdateInstalled) + case .reportingConfigure: + return InAppNotification(level: .info, title: "Reporting Configured", subtitle: deviceName, logEntryID: id, deviceName: deviceName, category: .reportingConfigure) + case .general where level == .error: + return InAppNotification(level: .error, title: "Error", subtitle: truncated, logEntryID: id, deviceName: deviceName, category: .genericError) + default: + return nil + } + } + + /// Z2M log messages sometimes embed their namespace at the start + /// ("z2m:controller Something failed"). Strip it so notifications show + /// only the human-readable part. + func stripped(_ text: String) -> String { + guard text.hasPrefix("z2m:") else { return text } + if let spaceRange = text.range(of: " ") { + return String(text[spaceRange.upperBound...]) + } + return text + } +} diff --git a/Shellbee/Core/Store/AppStore+OTA.swift b/Shellbee/Core/Store/AppStore+OTA.swift new file mode 100644 index 0000000..7922e47 --- /dev/null +++ b/Shellbee/Core/Store/AppStore+OTA.swift @@ -0,0 +1,140 @@ +import Foundation + +extension AppStore { + var activeOTAUpdates: [OTAUpdateStatus] { + otaUpdates.values.filter(\.isActive) + } + + func otaStatus(for friendlyName: String) -> OTAUpdateStatus? { + otaUpdates[friendlyName] ?? state(for: friendlyName).otaUpdateStatus(for: friendlyName) + } + + func startOTAUpdate(for friendlyName: String) { + otaUpdates[friendlyName] = OTAUpdateStatus( + deviceName: friendlyName, + phase: .requested, + progress: nil, + remaining: nil + ) + OTAUpdateLiveActivityCoordinator.shared.sync(with: activeOTAUpdates, devices: devices) + } + + func startOTACheck(for friendlyName: String) { + otaUpdates[friendlyName] = OTAUpdateStatus( + deviceName: friendlyName, + phase: .checking, + progress: nil, + remaining: nil + ) + } + + func startOTASchedule(for friendlyName: String) { + otaUpdates[friendlyName] = OTAUpdateStatus( + deviceName: friendlyName, + phase: .scheduled, + progress: nil, + remaining: nil + ) + } + + func cancelOTASchedule(for friendlyName: String) { + if otaUpdates[friendlyName]?.phase == .scheduled { + otaUpdates.removeValue(forKey: friendlyName) + } + } + + func handleOTAState(for deviceName: String, state: [String: JSONValue]) { + guard let update = state.otaUpdateStatus(for: deviceName) else { return } + + let previous = otaUpdates[deviceName] + + switch update.phase { + case .available: + if previous?.isActive == true { + otaUpdates.removeValue(forKey: deviceName) + } + case .checking: + break // Handled by manual check trigger + case .requested: + otaUpdates[deviceName] = update + case .scheduled, .updating: + otaUpdates[deviceName] = update + case .idle: + otaUpdates.removeValue(forKey: deviceName) + if previous?.isActive == true, activeOTAUpdates.isEmpty { + OTAUpdateLiveActivityCoordinator.shared.finish(for: deviceName, success: true) + return + } + } + + OTAUpdateLiveActivityCoordinator.shared.sync(with: activeOTAUpdates, devices: devices) + } + + func handleOTAResponse(_ response: DeviceOTAUpdateResponse) { + if let deviceName = response.deviceName { + otaResponseForwarding?(deviceName, response.isSuccess, .update) + } + guard !response.isSuccess else { return } + guard let deviceName = response.deviceName else { return } + + otaUpdates.removeValue(forKey: deviceName) + + if activeOTAUpdates.isEmpty { + OTAUpdateLiveActivityCoordinator.shared.finish(for: deviceName, success: false) + } else { + OTAUpdateLiveActivityCoordinator.shared.sync(with: activeOTAUpdates, devices: devices) + } + } + + func handleOTACheckResponse(_ response: DeviceOTAUpdateResponse) { + guard let deviceName = response.deviceName else { return } + + otaResponseForwarding?(deviceName, response.isSuccess, .check) + + if !response.isSuccess { + flashCheckResult(.failed, for: deviceName) + Task { + try? await Task.sleep(for: .seconds(DesignTokens.Duration.liveActivitySuccess)) + await MainActor.run { + if otaUpdates[deviceName]?.phase == .checking { + otaUpdates.removeValue(forKey: deviceName) + } + } + } + } else { + if otaUpdates[deviceName]?.phase == .checking { + otaUpdates.removeValue(forKey: deviceName) + } + // The subsequent deviceState event decides whether an update was + // found. Look at current state: if hasUpdateAvailable, "Found + // update"; otherwise "No update" (off by default as a + // notification, but always shown as a transient row chip). + let hasUpdate = state(for: deviceName).hasUpdateAvailable + if hasUpdate { + flashCheckResult(.updateFound, for: deviceName) + } else { + flashCheckResult(.noUpdate, for: deviceName) + enqueueNotification(InAppNotification( + level: .info, + title: "No Update Available", + subtitle: deviceName, + deviceName: deviceName, + category: .otaNoUpdate + )) + } + } + } + + func flashCheckResult(_ result: DeviceCheckResult, for deviceName: String) { + deviceCheckResults[deviceName] = result + Task { [weak self] in + try? await Task.sleep(for: .seconds(3)) + await MainActor.run { + guard let self else { return } + if self.deviceCheckResults[deviceName] == result { + self.deviceCheckResults.removeValue(forKey: deviceName) + } + } + } + } +} diff --git a/Shellbee/Core/Store/AppStore.swift b/Shellbee/Core/Store/AppStore.swift index 6af5e72..6d9fc5f 100644 --- a/Shellbee/Core/Store/AppStore.swift +++ b/Shellbee/Core/Store/AppStore.swift @@ -17,7 +17,7 @@ final class AppStore { var deviceFirstSeen: [String: Date] = [:] // Optimistic renames awaiting bridge confirmation. Used to roll back if z2m // returns status="error" for the rename request. - private var pendingRenames: [(from: String, to: String)] = [] + var pendingRenames: [(from: String, to: String)] = [] // Friendly names of devices the user has asked to remove and we're awaiting // bridge/response/device/remove for. Drives the "Deleting" badge and // disables further swipe-deletes on the same row. @@ -70,500 +70,6 @@ final class AppStore { } } - private func persistFirstSeen() { - let raw = deviceFirstSeen.mapValues { $0.timeIntervalSince1970 } - UserDefaults.standard.set(raw, forKey: Self.firstSeenStoreKey) - } - - private func recordFirstSeen(ieee: String, overwrite: Bool = false) { - if !overwrite, deviceFirstSeen[ieee] != nil { return } - deviceFirstSeen[ieee] = Date() - persistFirstSeen() - } - - private func removeFirstSeen(ieee: String) { - guard deviceFirstSeen.removeValue(forKey: ieee) != nil else { return } - persistFirstSeen() - } - - func apply(_ event: Z2MEvent) { - switch event { - case .bridgeInfo(let info): - bridgeInfo = info - case .bridgeState(let state): - bridgeOnline = state == "online" - case .devices(let list): - // Backfill first-seen for any device we've never recorded. - // Covers the case where a device joined while the app was closed - // and we missed the bridge/event device_joined message — when it - // shows up in interview state on the first snapshot, treat it as - // freshly added. - for device in list where device.type != .coordinator { - guard deviceFirstSeen[device.ieeeAddress] == nil else { continue } - if device.interviewing || !device.interviewCompleted { - recordFirstSeen(ieee: device.ieeeAddress) - } - } - devices = list - case .groups(let list): - groups = list - case .logMessage(let msg): - let level = LogLevel(raw: msg.level) ?? .info - insertRawLogEntry(LogEntry( - id: msg.id, timestamp: .now, level: level, - category: .general, namespace: msg.namespace, - message: msg.message, deviceName: nil - )) - let knownNames = Set(devices.map(\.friendlyName) + groups.map(\.friendlyName)) - let ctx = LogMapperEngine.context( - message: msg.message, namespace: msg.namespace, knownDevices: knownNames - ) - // MQTT publish for a known device/group state topic is redundant — the - // .deviceState event creates a richer stateChange entry for the same update. - if case .mqttPublish = ctx.action, - let deviceName = ctx.primaryDevice?.friendlyName, - knownNames.contains(deviceName) { - break - } - let entry = LogEntry( - id: msg.id, timestamp: .now, level: level, - category: ctx.inferredCategory, - namespace: msg.namespace, message: msg.message, - deviceName: ctx.primaryDevice?.friendlyName, context: ctx - ) - insertLogEntry(entry) - if let note = notification(for: ctx.action, level: level, deviceName: ctx.primaryDevice?.friendlyName, message: msg.message, id: msg.id) { - enqueueNotification(note) - } - case .bridgeEvent(let event): - if let entry = Self.logEntry(from: event) { - insertLogEntry(entry) - if let note = Self.notification(from: event, entry: entry) { - enqueueNotification(note) - } - } - if let ieee = event.data.object?["ieee_address"]?.stringValue { - switch event.type { - case "device_joined": - // Restart the 30-min window on (re)join. - recordFirstSeen(ieee: ieee, overwrite: true) - case "device_leave": - removeFirstSeen(ieee: ieee) - case "device_interview": - let name = event.data.object?["friendly_name"]?.stringValue ?? ieee - let status = event.data.object?["status"]?.stringValue - Task { @MainActor in - switch status { - case "started": - InterviewLiveActivityCoordinator.shared.start(deviceName: name, ieeeAddress: ieee) - case "successful": - InterviewLiveActivityCoordinator.shared.finish(deviceName: name, ieeeAddress: ieee, success: true) - case "failed": - InterviewLiveActivityCoordinator.shared.finish(deviceName: name, ieeeAddress: ieee, success: false) - default: - break - } - } - default: - break - } - } - case .deviceState(let name, let state): - let previous = deviceStates[name] ?? [:] - if !previous.isEmpty { - let changes = LogMapperEngine.diff(previous, state) - if !changes.isEmpty { - insertLogEntry(LogMapperEngine.stateChangeEntry(device: name, changes: changes)) - } - } - deviceStates[name] = state - handleOTAState(for: name, state: state) - case .deviceAvailability(let name, let available): - deviceAvailability[name] = available - case .deviceOTAUpdateResponse(let response): - handleOTAResponse(response) - case .deviceOTACheckResponse(let response): - handleOTACheckResponse(response) - case .permitJoinChanged(let enabled, let remaining): - if let info = bridgeInfo { - bridgeInfo = BridgeInfo( - version: info.version, - commit: info.commit, - coordinator: info.coordinator, - network: info.network, - logLevel: info.logLevel, - permitJoin: enabled, - permitJoinTimeout: remaining, - permitJoinEnd: remaining.map { Int(Date().timeIntervalSince1970 * 1000) + ($0 * 1000) }, - restartRequired: info.restartRequired, - config: info.config - ) - } - - case .bridgeResponse(let topic, let payload): - if topic == Z2MTopics.bridgeResponseBackup, let handler = backupResponseHandler { - backupResponseHandler = nil - if payload.object?["status"]?.stringValue == "ok", - let zip = payload.object?["data"]?.object?["zip"]?.stringValue { - handler(zip, nil) - } else { - let err = payload.object?["error"]?.stringValue ?? "Unknown error" - handler(nil, err) - } - break - } - // The options/info responses carry only `{restart_required}` (and - // echo the request on error). The full config is delivered via the - // separate `bridge/info` topic, so don't try to decode config here - // — doing so would overwrite the real config with all-nils. - guard payload.object?["status"]?.stringValue == "ok" else { break } - if let restartRequired = payload.object?["data"]?.object?["restart_required"]?.boolValue, - let info = bridgeInfo { - bridgeInfo = info.copyUpdating(restartRequired: restartRequired) - } - - case .bridgeHealth(let health): - if let existing = bridgeHealth, health.process == nil { - // Sparse response (e.g. bridge/response/health_check returns only {healthy:true}) - // Merge: preserve rich stats, update the healthy flag - bridgeHealth = BridgeHealth( - healthy: health.healthy, - responseTime: existing.responseTime, - process: existing.process, - os: existing.os, - mqtt: existing.mqtt - ) - } else { - bridgeHealth = health - } - - case .touchlinkScanResult(let devices): - touchlinkDevices = devices - touchlinkScanInProgress = false - - case .touchlinkIdentifyDone: - touchlinkIdentifyInProgress = false - - case .touchlinkFactoryResetDone: - touchlinkResetInProgress = false - - case .deviceRemoveResponse(let id, let ok, let errorMessage): - pendingRemovals.remove(id) - if ok { - // Remove locally so the next bridge/devices snapshot doesn't - // race with our List diff. Also clears keyed state so the - // "Recently Added" backfill doesn't resurrect it. - devices.removeAll { $0.friendlyName == id } - deviceStates.removeValue(forKey: id) - deviceAvailability.removeValue(forKey: id) - otaUpdates.removeValue(forKey: id) - deviceCheckResults.removeValue(forKey: id) - } else { - let message = errorMessage ?? "Failed to remove '\(id)'" - let error = Z2MOperationError( - id: UUID(), - topic: Z2MTopics.bridgeResponseDeviceRemove, - message: message, - timestamp: .now - ) - apply(.operationError(error)) - } - - case .deviceRenameResponse(let from, let to, let ok, let errorMessage): - if let pendingIdx = pendingRenames.firstIndex(where: { $0.from == from && $0.to == to }) { - pendingRenames.remove(at: pendingIdx) - } - if !ok { - revertOptimisticRename(from: from, to: to) - let message = errorMessage ?? "Failed to rename '\(from)' to '\(to)'" - let error = Z2MOperationError( - id: UUID(), - topic: Z2MTopics.bridgeResponseDeviceRename, - message: message, - timestamp: .now - ) - apply(.operationError(error)) - } - - case .operationError(let error): - touchlinkScanInProgress = false - touchlinkIdentifyInProgress = false - touchlinkResetInProgress = false - operationErrors.insert(error, at: 0) - let entry = LogEntry( - id: UUID(), - timestamp: error.timestamp, - level: .error, - category: .general, - namespace: "z2m:response", - message: error.message, - deviceName: nil - ) - insertLogEntry(entry) - enqueueNotification(InAppNotification( - level: .error, - title: "Operation Failed", - subtitle: stripped(String(error.message.prefix(100))), - logEntryID: entry.id, - category: .operationFailed - )) - - case .unknown: - break - } - } - - func clearLogs() { - logEntries = [] - rawLogEntries = [] - } - - func popNotification() -> InAppNotification? { - guard !pendingNotifications.isEmpty else { return nil } - return pendingNotifications.removeFirst() - } - - func popFastTrackNotification() -> InAppNotification? { - guard !fastTrackNotifications.isEmpty else { return nil } - return fastTrackNotifications.removeFirst() - } - - func enqueueOTABulkSummary(_ summary: OTABulkOperationQueue.CompletionSummary) { - let noun = summary.kind == .check ? "Checked" : "Updated" - let level: LogLevel = summary.failed > 0 ? .warning : .info - let title: String - if summary.wasCancelled { - title = summary.kind == .check ? "Check Cancelled" : "Updates Cancelled" - } else if summary.failed > 0 { - title = "\(noun) \(summary.total) Devices" - } else { - title = "\(noun) \(summary.total) Devices" - } - var parts: [String] = [] - if summary.succeeded > 0 { - parts.append("\(summary.succeeded) succeeded") - } - if summary.failed > 0 { - parts.append("\(summary.failed) failed") - } - let subtitle = parts.isEmpty ? nil : parts.joined(separator: ", ") - enqueueNotification(InAppNotification( - level: level, - title: title, - subtitle: subtitle, - category: .otaBulkSummary - )) - } - - func enqueueNotification(_ notification: InAppNotification) { - // Fast-track bypasses the filter — these are transient confirmations - // (e.g. "Copied to Clipboard") driven by the user's own action. - if notification.priority == .fastTrack { - fastTrackNotifications.append(notification) - return - } - - if let filter = notificationFilter, !filter(notification) { return } - - let now = Date() - - if let idx = pendingNotifications.lastIndex(where: { $0.coalesceKey == notification.coalesceKey }), - now.timeIntervalSince(pendingNotifications[idx].lastUpdated) <= Self.coalesceWindow { - pendingNotifications[idx].count += notification.count - pendingNotifications[idx].logEntryIDs.append(contentsOf: notification.logEntryIDs) - pendingNotifications[idx].occurrences.append(contentsOf: notification.occurrences) - if let sub = notification.subtitle { pendingNotifications[idx].subtitle = sub } - pendingNotifications[idx].lastUpdated = now - return - } - - pendingNotifications.append(notification) - notificationArrivalID = UUID() - } - - private func notification( - for action: LogContext.LogAction, level: LogLevel, - deviceName: String?, message: String, id: UUID - ) -> InAppNotification? { - let truncated = stripped(String(message.prefix(100))) - switch action { - case .bindSuccess: - return InAppNotification(level: .info, title: "Bind Successful", subtitle: deviceName, logEntryID: id, deviceName: deviceName, category: .bindSuccess) - case .bindFailure: - return InAppNotification(level: .error, title: "Bind Failed", subtitle: deviceName ?? truncated, logEntryID: id, deviceName: deviceName, category: .bindFailure) - case .unbind: - return InAppNotification(level: .info, title: "Unbound", subtitle: deviceName, logEntryID: id, deviceName: deviceName, category: .unbind) - case .groupAdd: - return InAppNotification(level: .info, title: "Added to Group", subtitle: deviceName, logEntryID: id, deviceName: deviceName, category: .groupAdd) - case .groupRemove: - return InAppNotification(level: .info, title: "Removed from Group", subtitle: deviceName, logEntryID: id, deviceName: deviceName, category: .groupRemove) - case .publishFailure(let command): - let detail = command.isEmpty ? truncated : command - return InAppNotification(level: .error, title: "Command Failed", subtitle: detail, logEntryID: id, deviceName: deviceName, category: .publishFailure) - case .requestFailure: - return InAppNotification(level: .error, title: "Request Failed", subtitle: truncated, logEntryID: id, deviceName: deviceName, category: .requestFailure) - case .otaFinished: - return InAppNotification(level: .info, title: "Update Installed", subtitle: deviceName, logEntryID: id, deviceName: deviceName, category: .otaUpdateInstalled) - case .reportingConfigure: - return InAppNotification(level: .info, title: "Reporting Configured", subtitle: deviceName, logEntryID: id, deviceName: deviceName, category: .reportingConfigure) - case .general where level == .error: - return InAppNotification(level: .error, title: "Error", subtitle: truncated, logEntryID: id, deviceName: deviceName, category: .genericError) - default: - return nil - } - } - - // Z2M log messages sometimes embed their namespace at the start ("z2m:controller Something failed"). - // Strip it so notifications show only the human-readable part. - private func stripped(_ text: String) -> String { - guard text.hasPrefix("z2m:") else { return text } - if let spaceRange = text.range(of: " ") { - return String(text[spaceRange.upperBound...]) - } - return text - } - - private static func notification(from event: BridgeDeviceEvent, entry: LogEntry) -> InAppNotification? { - switch event.type { - case "device_leave": - return InAppNotification(level: .warning, title: "Device Left Network", subtitle: entry.deviceName, logEntryID: entry.id, deviceName: entry.deviceName, category: .deviceLeft) - case "device_interview": - let status = event.data.object?["status"]?.stringValue ?? "unknown" - switch status { - case "started": - return InAppNotification(level: .info, title: "Interviewing Device", subtitle: entry.deviceName, logEntryID: entry.id, deviceName: entry.deviceName, category: .interviewStarted) - case "successful": - return InAppNotification(level: .info, title: "Interview Successful", subtitle: entry.deviceName, logEntryID: entry.id, deviceName: entry.deviceName, category: .interviewSuccessful) - case "failed": - return InAppNotification(level: .error, title: "Interview Failed", subtitle: entry.deviceName, logEntryID: entry.id, deviceName: entry.deviceName, category: .interviewFailed) - default: - return nil - } - default: - return nil - } - } - - private func insertLogEntry(_ entry: LogEntry) { - logEntries.insert(entry, at: 0) - if logEntries.count > Self.logLimit { - logEntries = Array(logEntries.prefix(Self.logLimit)) - } - } - - private func insertRawLogEntry(_ entry: LogEntry) { - rawLogEntries.insert(entry, at: 0) - if rawLogEntries.count > Self.logLimit { - rawLogEntries = Array(rawLogEntries.prefix(Self.logLimit)) - } - } - - private static func logEntry(from event: BridgeDeviceEvent) -> LogEntry? { - guard let data = event.data.object else { return nil } - let deviceName = data["friendly_name"]?.stringValue - let ieeeAddr = data["ieee_address"]?.stringValue - let name = deviceName ?? ieeeAddr ?? "unknown" - - switch event.type { - case "device_joined": - return LogEntry(id: UUID(), timestamp: .now, level: .info, category: .deviceJoined, namespace: nil, message: "Device '\(name)' joined the network", deviceName: deviceName) - case "device_announce": - return LogEntry(id: UUID(), timestamp: .now, level: .info, category: .deviceAnnounce, namespace: nil, message: "Device '\(name)' announced", deviceName: deviceName) - case "device_interview": - let status = data["status"]?.stringValue ?? "unknown" - let level: LogLevel = status == "failed" ? .error : .info - return LogEntry(id: UUID(), timestamp: .now, level: level, category: .interview, namespace: nil, message: "Interview of '\(name)' \(status)", deviceName: deviceName) - case "device_leave": - return LogEntry(id: UUID(), timestamp: .now, level: .warning, category: .deviceLeave, namespace: nil, message: "Device '\(name)' left the network", deviceName: deviceName) - default: - return nil - } - } - - func device(named friendlyName: String) -> Device? { - devices.first { $0.friendlyName == friendlyName } - } - - func state(for friendlyName: String) -> [String: JSONValue] { - deviceStates[friendlyName] ?? [:] - } - - func isAvailable(_ friendlyName: String) -> Bool { - deviceAvailability[friendlyName] ?? false - } - - // Apply a rename to local state immediately so the UI updates without - // waiting for the bridge/devices snapshot (which can lag 3-10s after a - // bridge/request/device/rename). Migrates availability and state keys so - // the renamed device doesn't flicker through "offline". - func optimisticRename(from: String, to: String) { - guard from != to, !to.isEmpty else { return } - guard let idx = devices.firstIndex(where: { $0.friendlyName == from }) else { return } - var device = devices[idx] - device.friendlyName = to - devices[idx] = device - - if let availability = deviceAvailability.removeValue(forKey: from) { - deviceAvailability[to] = availability - } - if let state = deviceStates.removeValue(forKey: from) { - deviceStates[to] = state - } - pendingRenames.append((from: from, to: to)) - } - - private func revertOptimisticRename(from: String, to: String) { - guard let idx = devices.firstIndex(where: { $0.friendlyName == to }) else { return } - var device = devices[idx] - device.friendlyName = from - devices[idx] = device - - if let availability = deviceAvailability.removeValue(forKey: to) { - deviceAvailability[from] = availability - } - if let state = deviceStates.removeValue(forKey: to) { - deviceStates[from] = state - } - } - - func otaStatus(for friendlyName: String) -> OTAUpdateStatus? { - otaUpdates[friendlyName] ?? state(for: friendlyName).otaUpdateStatus(for: friendlyName) - } - - func startOTAUpdate(for friendlyName: String) { - otaUpdates[friendlyName] = OTAUpdateStatus( - deviceName: friendlyName, - phase: .requested, - progress: nil, - remaining: nil - ) - OTAUpdateLiveActivityCoordinator.shared.sync(with: activeOTAUpdates, devices: devices) - } - - func startOTACheck(for friendlyName: String) { - otaUpdates[friendlyName] = OTAUpdateStatus( - deviceName: friendlyName, - phase: .checking, - progress: nil, - remaining: nil - ) - } - - func startOTASchedule(for friendlyName: String) { - otaUpdates[friendlyName] = OTAUpdateStatus( - deviceName: friendlyName, - phase: .scheduled, - progress: nil, - remaining: nil - ) - } - - func cancelOTASchedule(for friendlyName: String) { - if otaUpdates[friendlyName]?.phase == .scheduled { - otaUpdates.removeValue(forKey: friendlyName) - } - } - func reset() { devices = [] groups = [] @@ -590,102 +96,21 @@ final class AppStore { OTAUpdateLiveActivityCoordinator.shared.clearAll() } - private var activeOTAUpdates: [OTAUpdateStatus] { - otaUpdates.values.filter(\.isActive) - } - - private func handleOTAState(for deviceName: String, state: [String: JSONValue]) { - guard let update = state.otaUpdateStatus(for: deviceName) else { return } - - let previous = otaUpdates[deviceName] - - switch update.phase { - case .available: - if previous?.isActive == true { - otaUpdates.removeValue(forKey: deviceName) - } - case .checking: - break // Handled by manual check trigger - case .requested: - otaUpdates[deviceName] = update - case .scheduled, .updating: - otaUpdates[deviceName] = update - case .idle: - otaUpdates.removeValue(forKey: deviceName) - if previous?.isActive == true, activeOTAUpdates.isEmpty { - OTAUpdateLiveActivityCoordinator.shared.finish(for: deviceName, success: true) - return - } - } - - OTAUpdateLiveActivityCoordinator.shared.sync(with: activeOTAUpdates, devices: devices) - } - - private func handleOTAResponse(_ response: DeviceOTAUpdateResponse) { - if let deviceName = response.deviceName { - otaResponseForwarding?(deviceName, response.isSuccess, .update) - } - guard !response.isSuccess else { return } - guard let deviceName = response.deviceName else { return } - - otaUpdates.removeValue(forKey: deviceName) + // MARK: - First-seen persistence - if activeOTAUpdates.isEmpty { - OTAUpdateLiveActivityCoordinator.shared.finish(for: deviceName, success: false) - } else { - OTAUpdateLiveActivityCoordinator.shared.sync(with: activeOTAUpdates, devices: devices) - } + private func persistFirstSeen() { + let raw = deviceFirstSeen.mapValues { $0.timeIntervalSince1970 } + UserDefaults.standard.set(raw, forKey: Self.firstSeenStoreKey) } - private func handleOTACheckResponse(_ response: DeviceOTAUpdateResponse) { - guard let deviceName = response.deviceName else { return } - - otaResponseForwarding?(deviceName, response.isSuccess, .check) - - if !response.isSuccess { - flashCheckResult(.failed, for: deviceName) - Task { - try? await Task.sleep(for: .seconds(DesignTokens.Duration.liveActivitySuccess)) - await MainActor.run { - if otaUpdates[deviceName]?.phase == .checking { - otaUpdates.removeValue(forKey: deviceName) - } - } - } - } else { - if otaUpdates[deviceName]?.phase == .checking { - otaUpdates.removeValue(forKey: deviceName) - } - // The subsequent deviceState event decides whether an update was - // found. Look at current state: if hasUpdateAvailable, "Found - // update"; otherwise "No update" (off by default as a - // notification, but always shown as a transient row chip). - let hasUpdate = state(for: deviceName).hasUpdateAvailable - if hasUpdate { - flashCheckResult(.updateFound, for: deviceName) - } else { - flashCheckResult(.noUpdate, for: deviceName) - enqueueNotification(InAppNotification( - level: .info, - title: "No Update Available", - subtitle: deviceName, - deviceName: deviceName, - category: .otaNoUpdate - )) - } - } + func recordFirstSeen(ieee: String, overwrite: Bool = false) { + if !overwrite, deviceFirstSeen[ieee] != nil { return } + deviceFirstSeen[ieee] = Date() + persistFirstSeen() } - private func flashCheckResult(_ result: DeviceCheckResult, for deviceName: String) { - deviceCheckResults[deviceName] = result - Task { [weak self] in - try? await Task.sleep(for: .seconds(3)) - await MainActor.run { - guard let self else { return } - if self.deviceCheckResults[deviceName] == result { - self.deviceCheckResults.removeValue(forKey: deviceName) - } - } - } + func removeFirstSeen(ieee: String) { + guard deviceFirstSeen.removeValue(forKey: ieee) != nil else { return } + persistFirstSeen() } } From b34dc27ce178c5c5c24293c8e94aa222bf1a1d78 Mon Sep 17 00:00:00 2001 From: tashda Date: Wed, 29 Apr 2026 07:34:46 +0200 Subject: [PATCH 13/18] DeviceDocNormalizer: split models out and extract pairing helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeviceDocNormalizer.swift was 646 lines mixing six DTO structs with the parsing/normalization enum. Splits into three focused files: - DeviceDocumentationModels.swift (119) — the six tightly-coupled DTOs: DeviceDocumentation, NormalizedDeviceDoc, DeviceDocIdentity, DevicePairingGuide, DevicePairingMethod, DeviceDocCapability. Per the Code-structure rule, DTO bundles can stay together. - DeviceDocNormalizer.swift (361) — the enum entry point, the main normalize(parsed:device:) orchestrator, capability/expose/section helpers, and the generic span helpers (firstParagraph, plainText, matchesAny, etc.) shared across pairing and capability extraction. - DeviceDocNormalizer+Pairing.swift (175) — pairing-specific helpers (extractFromNotes, makePairingGuide, collectStepItems, isPureStepList, collectSubsections, defaultPairingSummary) plus the prerequisite / success / troubleshooting keyword arrays they use. A handful of formerly-private helpers (normalizeTitle, isPairingAdjacentTitle, firstParagraph, collectParagraphSpans, unique, plainText, matchesAny) bumped to internal so the +Pairing extension across files can call them. No external API surface change — they were already module-internal, just file-private within the original file. The +Pairing path is quoted in pbxproj (the `+` requires it). Refs #34 Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee.xcodeproj/project.pbxproj | 4 +- .../Parsing/DeviceDocNormalizer+Pairing.swift | 175 ++++++++++ .../Core/Parsing/DeviceDocNormalizer.swift | 299 +----------------- .../Parsing/DeviceDocumentationModels.swift | 119 +++++++ 4 files changed, 304 insertions(+), 293 deletions(-) create mode 100644 Shellbee/Core/Parsing/DeviceDocNormalizer+Pairing.swift create mode 100644 Shellbee/Core/Parsing/DeviceDocumentationModels.swift diff --git a/Shellbee.xcodeproj/project.pbxproj b/Shellbee.xcodeproj/project.pbxproj index c46c21a..494cbce 100644 --- a/Shellbee.xcodeproj/project.pbxproj +++ b/Shellbee.xcodeproj/project.pbxproj @@ -113,19 +113,21 @@ Core/Networking/Z2MTopics.swift, Core/Networking/Z2MWebSocketClient.swift, Core/Networking/Z2MWebSocketSessionDelegate.swift, + "Core/Parsing/DeviceDocNormalizer+Pairing.swift", Core/Parsing/DeviceDocNormalizer.swift, + Core/Parsing/DeviceDocumentationModels.swift, Core/Parsing/DocParser.swift, Core/Parsing/FrontendReferenceRewriter.swift, Core/Services/BundledDocStore.swift, Core/Services/DeviceDocService.swift, Core/Services/DocBrowserIndex.swift, Core/Services/GuideDocService.swift, + Core/Store/AppStore.swift, "Core/Store/AppStore+Devices.swift", "Core/Store/AppStore+Events.swift", "Core/Store/AppStore+Logs.swift", "Core/Store/AppStore+Notifications.swift", "Core/Store/AppStore+OTA.swift", - Core/Store/AppStore.swift, Core/Store/NotificationPreferences.swift, Features/Bridge/PhilipsHueResetSheet.swift, Features/Bridge/TouchlinkDeviceRow.swift, diff --git a/Shellbee/Core/Parsing/DeviceDocNormalizer+Pairing.swift b/Shellbee/Core/Parsing/DeviceDocNormalizer+Pairing.swift new file mode 100644 index 0000000..c4011d7 --- /dev/null +++ b/Shellbee/Core/Parsing/DeviceDocNormalizer+Pairing.swift @@ -0,0 +1,175 @@ +import Foundation + +extension DeviceDocNormalizer { + static nonisolated func extractFromNotes(_ section: DocSection) -> (pairingBlocks: [DocBlock], pairingRelatedBlocks: [DocBlock], noteBlocks: [DocBlock]) { + var pairingBlocks: [DocBlock] = [] + var pairingRelatedBlocks: [DocBlock] = [] + var noteBlocks: [DocBlock] = [] + + for block in section.blocks { + switch block { + case .subsection(let title, let blocks): + let normalizedTitle = normalizeTitle(title) + if normalizedTitle == "pairing" { + pairingBlocks.append(contentsOf: blocks) + } else if isPairingAdjacentTitle(normalizedTitle) { + pairingRelatedBlocks.append(.subsection(title: title, blocks: blocks)) + } else { + noteBlocks.append(.subsection(title: title, blocks: blocks)) + } + default: + noteBlocks.append(block) + } + } + + return (pairingBlocks, pairingRelatedBlocks, noteBlocks) + } + + static nonisolated func makePairingGuide( + from pairingBlocks: [DocBlock], + relatedBlocks: [DocBlock], + identity: DeviceDocIdentity + ) -> DevicePairingGuide? { + // Only count steps that came from real step-list blocks, not paragraph fallback. + // Paragraph fallback produces step text identical to the summary, which would show twice. + let primarySteps = collectStepItems(from: pairingBlocks, paragraphFallback: false) + let summary = firstParagraph(in: pairingBlocks) ?? defaultPairingSummary(identity: identity, hasSteps: !primarySteps.isEmpty) + let summaryText = plainText(summary) + + // Exclude summary so it doesn't also appear in Before You Start / Success / Troubleshooting + let paragraphs = collectParagraphSpans(from: pairingBlocks + relatedBlocks) + .filter { plainText($0) != summaryText } + + let prerequisites = unique(paragraphs.filter { matchesAny($0, keywords: prerequisiteKeywords) }) + let successCues = unique(paragraphs.filter { matchesAny($0, keywords: successKeywords) }) + let troubleshooting = unique(paragraphs.filter { matchesAny($0, keywords: troubleshootingKeywords) }) + + // Track every plain-text span already shown in a named section so additionalNotes never repeats them + var usedTexts = Set([summaryText]) + for spans in prerequisites + successCues + troubleshooting { + usedTexts.insert(plainText(spans)) + } + + let alternatives = collectSubsections(from: pairingBlocks) + .filter { normalizeTitle($0.title) != "pairing" } + .map { subsection in + let altSummary = firstParagraph(in: subsection.blocks) ?? [] + let altSummaryText = plainText(altSummary) + let altSteps = collectStepItems(from: subsection.blocks, paragraphFallback: false) + let altNotes = subsection.blocks.filter { block in + guard !isPureStepList(block) else { return false } + if case .paragraph(let spans) = block { return plainText(spans) != altSummaryText } + return true + } + let normalizedSubtitle = normalizeTitle(subsection.title) + // A subsection is a pure Touchlink reference when its title mentions Touchlink and it + // contains no device-specific steps or notes — just a link to the Touchlink guide. + let isTouchlinkReset = normalizedSubtitle.contains("touchlink") + && altSteps.isEmpty + && altNotes.isEmpty + // A subsection describes a Philips Hue serial-number reset when it mentions + // "touchlink" and "serial". The Z2M docs include raw JSON and frontend references + // for this flow which the app replaces with the in-app Philips Hue Reset action. + let isPhilipsHueSerialReset = normalizedSubtitle.contains("touchlink") + && normalizedSubtitle.contains("serial") + return DevicePairingMethod( + title: subsection.title, + summary: altSummary, + steps: altSteps, + notes: altNotes, + isTouchlinkReset: isTouchlinkReset, + isPhilipsHueSerialReset: isPhilipsHueSerialReset + ) + } + .filter { !$0.summary.isEmpty || !$0.steps.isEmpty || !$0.notes.isEmpty || $0.isTouchlinkReset || $0.isPhilipsHueSerialReset } + + // Subsection titles promoted to Alternatives — skip them in additionalNotes + let alternativeTitles = Set(alternatives.map { normalizeTitle($0.title) }) + + let additionalNotes = (pairingBlocks + relatedBlocks).filter { block in + switch block { + case .paragraph(let spans), .note(let spans): + return !usedTexts.contains(plainText(spans)) + case .stepList: + return false + case .subsection(let title, _): + return !alternativeTitles.contains(normalizeTitle(title)) + default: + return true + } + } + + let guide = DevicePairingGuide( + summary: summary, + prerequisites: prerequisites, + primarySteps: primarySteps, + alternatives: alternatives, + successCues: successCues, + troubleshooting: troubleshooting, + additionalNotes: additionalNotes + ) + + return guide.hasContent ? guide : nil + } + + private static nonisolated func collectSubsections(from blocks: [DocBlock]) -> [(title: String, blocks: [DocBlock])] { + blocks.compactMap { block in + if case .subsection(let title, let blocks) = block { + return (title, blocks) + } + return nil + } + } + + private static nonisolated func isPureStepList(_ block: DocBlock) -> Bool { + if case .stepList = block { return true } + return false + } + + private static nonisolated func collectStepItems(from blocks: [DocBlock], paragraphFallback: Bool = true) -> [StepItem] { + var result: [StepItem] = [] + var autoNumber = 1 + + for block in blocks { + switch block { + case .stepList(let steps): + result.append(contentsOf: steps) + autoNumber = max(autoNumber, (steps.last?.number ?? 0) + 1) + case .paragraph(let spans) where paragraphFallback && result.isEmpty: + result.append(StepItem(number: autoNumber, spans: spans)) + autoNumber += 1 + case .subsection(_, let subblocks) where result.isEmpty: + let nested = collectStepItems(from: subblocks, paragraphFallback: paragraphFallback) + if !nested.isEmpty { + result.append(contentsOf: nested) + autoNumber = max(autoNumber, (nested.last?.number ?? 0) + 1) + } + default: + break + } + } + + return result + } + + private static nonisolated func defaultPairingSummary(identity: DeviceDocIdentity, hasSteps: Bool) -> [InlineSpan] { + if hasSteps { + return [.text("Follow the steps below to pair the \(identity.model) with Zigbee2MQTT.")] + } else { + return [.text("Pairing guidance for the \(identity.model) is limited. Review the notes below for device-specific instructions.")] + } + } + + static nonisolated let prerequisiteKeywords = [ + "coordinator", "adapter", "wake", "awake", "close to", "battery", "install code", + "permit join", "bridge", "factory reset" + ] + static nonisolated let successKeywords = [ + "when connected", "turns off", "flash", "flashes", "blink", "blinks", "pulsate", + "joined", "success", "light turns", "beep" + ] + static nonisolated let troubleshootingKeywords = [ + "troubleshooting", "issue", "doesn't", "didn't", "retry", "remove the device", + "join it again", "re-pair", "not work", "work around" + ] +} diff --git a/Shellbee/Core/Parsing/DeviceDocNormalizer.swift b/Shellbee/Core/Parsing/DeviceDocNormalizer.swift index 803cd71..3d58f7f 100644 --- a/Shellbee/Core/Parsing/DeviceDocNormalizer.swift +++ b/Shellbee/Core/Parsing/DeviceDocNormalizer.swift @@ -1,119 +1,5 @@ import Foundation -struct DeviceDocumentation: Sendable { - let sourcePath: String - let parsed: ParsedDeviceDoc - let normalized: NormalizedDeviceDoc -} - -struct NormalizedDeviceDoc: Sendable { - let identity: DeviceDocIdentity - let pairing: DevicePairingGuide? - let capabilities: [DeviceDocCapability] - let options: [DocOption] - let notesSections: [DocSection] - let advancedSections: [DocSection] - let miscSections: [DocSection] - let quality: Quality - - enum Quality: Sendable, Equatable { - case fullyNormalized - case partiallyNormalized - case parsedOnly - } - - var additionalSections: [DocSection] { advancedSections + miscSections } - var hasSemanticContent: Bool { - pairing != nil || !capabilities.isEmpty || !options.isEmpty || !notesSections.isEmpty - } -} - -struct DeviceDocIdentity: Sendable { - let vendor: String - let model: String - let description: String - let imageURL: URL? - let supportsOTA: Bool - let exposesSummary: String? -} - -struct DevicePairingGuide: Sendable { - let summary: [InlineSpan] - let prerequisites: [[InlineSpan]] - let primarySteps: [StepItem] - let alternatives: [DevicePairingMethod] - let successCues: [[InlineSpan]] - let troubleshooting: [[InlineSpan]] - let additionalNotes: [DocBlock] - - nonisolated var hasContent: Bool { - !summary.isEmpty - || !prerequisites.isEmpty - || !primarySteps.isEmpty - || !alternatives.isEmpty - || !successCues.isEmpty - || !troubleshooting.isEmpty - || !additionalNotes.isEmpty - } -} - -struct DevicePairingMethod: Sendable, Identifiable { - let id: UUID - let title: String - let summary: [InlineSpan] - let steps: [StepItem] - let notes: [DocBlock] - /// True when this alternative is purely a reference to the Touchlink guide with no - /// device-specific steps. The UI replaces the generic card with an in-app Touchlink button. - let isTouchlinkReset: Bool - /// True when this alternative describes a Philips Hue serial-number factory reset. - /// The UI replaces the raw Z2M content with an in-app Philips Hue Reset action. - let isPhilipsHueSerialReset: Bool - - nonisolated init(title: String, summary: [InlineSpan] = [], steps: [StepItem] = [], notes: [DocBlock] = [], isTouchlinkReset: Bool = false, isPhilipsHueSerialReset: Bool = false) { - self.id = UUID() - self.title = title - self.summary = summary - self.steps = steps - self.notes = notes - self.isTouchlinkReset = isTouchlinkReset - self.isPhilipsHueSerialReset = isPhilipsHueSerialReset - } -} - -struct DeviceDocCapability: Sendable, Identifiable { - let id: UUID - let title: String - let subtitle: String? - let summary: String - let kind: String - let unit: String? - let isReadable: Bool - let isWritable: Bool - let detailChips: [String] - - nonisolated init( - title: String, - subtitle: String? = nil, - summary: String, - kind: String, - unit: String? = nil, - isReadable: Bool, - isWritable: Bool, - detailChips: [String] = [] - ) { - self.id = UUID() - self.title = title - self.subtitle = subtitle - self.summary = summary - self.kind = kind - self.unit = unit - self.isReadable = isReadable - self.isWritable = isWritable - self.detailChips = detailChips - } -} - enum DeviceDocNormalizer { static nonisolated func normalize(parsed: ParsedDeviceDoc, device: Device) -> NormalizedDeviceDoc { // The Z2M device definition is authoritative for connected devices, but catalog @@ -214,7 +100,7 @@ enum DeviceDocNormalizer { ) } - private static nonisolated func normalizeTitle(_ title: String) -> String { + static nonisolated func normalizeTitle(_ title: String) -> String { title .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() @@ -272,158 +158,7 @@ enum DeviceDocNormalizer { } } - private static nonisolated func extractFromNotes(_ section: DocSection) -> (pairingBlocks: [DocBlock], pairingRelatedBlocks: [DocBlock], noteBlocks: [DocBlock]) { - var pairingBlocks: [DocBlock] = [] - var pairingRelatedBlocks: [DocBlock] = [] - var noteBlocks: [DocBlock] = [] - - for block in section.blocks { - switch block { - case .subsection(let title, let blocks): - let normalizedTitle = normalizeTitle(title) - if normalizedTitle == "pairing" { - pairingBlocks.append(contentsOf: blocks) - } else if isPairingAdjacentTitle(normalizedTitle) { - pairingRelatedBlocks.append(.subsection(title: title, blocks: blocks)) - } else { - noteBlocks.append(.subsection(title: title, blocks: blocks)) - } - default: - noteBlocks.append(block) - } - } - - return (pairingBlocks, pairingRelatedBlocks, noteBlocks) - } - - private static nonisolated func makePairingGuide( - from pairingBlocks: [DocBlock], - relatedBlocks: [DocBlock], - identity: DeviceDocIdentity - ) -> DevicePairingGuide? { - // Only count steps that came from real step-list blocks, not paragraph fallback. - // Paragraph fallback produces step text identical to the summary, which would show twice. - let primarySteps = collectStepItems(from: pairingBlocks, paragraphFallback: false) - let summary = firstParagraph(in: pairingBlocks) ?? defaultPairingSummary(identity: identity, hasSteps: !primarySteps.isEmpty) - let summaryText = plainText(summary) - - // Exclude summary so it doesn't also appear in Before You Start / Success / Troubleshooting - let paragraphs = collectParagraphSpans(from: pairingBlocks + relatedBlocks) - .filter { plainText($0) != summaryText } - - let prerequisites = unique(paragraphs.filter { matchesAny($0, keywords: prerequisiteKeywords) }) - let successCues = unique(paragraphs.filter { matchesAny($0, keywords: successKeywords) }) - let troubleshooting = unique(paragraphs.filter { matchesAny($0, keywords: troubleshootingKeywords) }) - - // Track every plain-text span already shown in a named section so additionalNotes never repeats them - var usedTexts = Set([summaryText]) - for spans in prerequisites + successCues + troubleshooting { - usedTexts.insert(plainText(spans)) - } - - let alternatives = collectSubsections(from: pairingBlocks) - .filter { normalizeTitle($0.title) != "pairing" } - .map { subsection in - let altSummary = firstParagraph(in: subsection.blocks) ?? [] - let altSummaryText = plainText(altSummary) - let altSteps = collectStepItems(from: subsection.blocks, paragraphFallback: false) - let altNotes = subsection.blocks.filter { block in - guard !isPureStepList(block) else { return false } - if case .paragraph(let spans) = block { return plainText(spans) != altSummaryText } - return true - } - let normalizedSubtitle = normalizeTitle(subsection.title) - // A subsection is a pure Touchlink reference when its title mentions Touchlink and it - // contains no device-specific steps or notes — just a link to the Touchlink guide. - let isTouchlinkReset = normalizedSubtitle.contains("touchlink") - && altSteps.isEmpty - && altNotes.isEmpty - // A subsection describes a Philips Hue serial-number reset when it mentions - // "touchlink" and "serial". The Z2M docs include raw JSON and frontend references - // for this flow which the app replaces with the in-app Philips Hue Reset action. - let isPhilipsHueSerialReset = normalizedSubtitle.contains("touchlink") - && normalizedSubtitle.contains("serial") - return DevicePairingMethod( - title: subsection.title, - summary: altSummary, - steps: altSteps, - notes: altNotes, - isTouchlinkReset: isTouchlinkReset, - isPhilipsHueSerialReset: isPhilipsHueSerialReset - ) - } - .filter { !$0.summary.isEmpty || !$0.steps.isEmpty || !$0.notes.isEmpty || $0.isTouchlinkReset || $0.isPhilipsHueSerialReset } - - // Subsection titles promoted to Alternatives — skip them in additionalNotes - let alternativeTitles = Set(alternatives.map { normalizeTitle($0.title) }) - - let additionalNotes = (pairingBlocks + relatedBlocks).filter { block in - switch block { - case .paragraph(let spans), .note(let spans): - return !usedTexts.contains(plainText(spans)) - case .stepList: - return false - case .subsection(let title, _): - return !alternativeTitles.contains(normalizeTitle(title)) - default: - return true - } - } - - let guide = DevicePairingGuide( - summary: summary, - prerequisites: prerequisites, - primarySteps: primarySteps, - alternatives: alternatives, - successCues: successCues, - troubleshooting: troubleshooting, - additionalNotes: additionalNotes - ) - - return guide.hasContent ? guide : nil - } - - private static nonisolated func collectSubsections(from blocks: [DocBlock]) -> [(title: String, blocks: [DocBlock])] { - blocks.compactMap { block in - if case .subsection(let title, let blocks) = block { - return (title, blocks) - } - return nil - } - } - - private static nonisolated func isPureStepList(_ block: DocBlock) -> Bool { - if case .stepList = block { return true } - return false - } - - private static nonisolated func collectStepItems(from blocks: [DocBlock], paragraphFallback: Bool = true) -> [StepItem] { - var result: [StepItem] = [] - var autoNumber = 1 - - for block in blocks { - switch block { - case .stepList(let steps): - result.append(contentsOf: steps) - autoNumber = max(autoNumber, (steps.last?.number ?? 0) + 1) - case .paragraph(let spans) where paragraphFallback && result.isEmpty: - result.append(StepItem(number: autoNumber, spans: spans)) - autoNumber += 1 - case .subsection(_, let subblocks) where result.isEmpty: - let nested = collectStepItems(from: subblocks, paragraphFallback: paragraphFallback) - if !nested.isEmpty { - result.append(contentsOf: nested) - autoNumber = max(autoNumber, (nested.last?.number ?? 0) + 1) - } - default: - break - } - } - - return result - } - - private static nonisolated func firstParagraph(in blocks: [DocBlock]) -> [InlineSpan]? { + static nonisolated func firstParagraph(in blocks: [DocBlock]) -> [InlineSpan]? { for block in blocks { switch block { case .paragraph(let spans): @@ -439,7 +174,7 @@ enum DeviceDocNormalizer { return nil } - private static nonisolated func collectParagraphSpans(from blocks: [DocBlock]) -> [[InlineSpan]] { + static nonisolated func collectParagraphSpans(from blocks: [DocBlock]) -> [[InlineSpan]] { var result: [[InlineSpan]] = [] for block in blocks { switch block { @@ -456,7 +191,7 @@ enum DeviceDocNormalizer { return result } - private static nonisolated func unique(_ items: [[InlineSpan]]) -> [[InlineSpan]] { + static nonisolated func unique(_ items: [[InlineSpan]]) -> [[InlineSpan]] { var seen = Set() return items.filter { spans in let key = plainText(spans) @@ -465,7 +200,7 @@ enum DeviceDocNormalizer { } } - private static nonisolated func plainText(_ spans: [InlineSpan]) -> String { + static nonisolated func plainText(_ spans: [InlineSpan]) -> String { spans.map { span in switch span { case .text(let text), .bold(let text), .italic(let text), .boldItalic(let text), .code(let text): @@ -479,19 +214,11 @@ enum DeviceDocNormalizer { .lowercased() } - private static nonisolated func matchesAny(_ spans: [InlineSpan], keywords: [String]) -> Bool { + static nonisolated func matchesAny(_ spans: [InlineSpan], keywords: [String]) -> Bool { let text = plainText(spans) return keywords.contains { text.contains($0) } } - private static nonisolated func defaultPairingSummary(identity: DeviceDocIdentity, hasSteps: Bool) -> [InlineSpan] { - if hasSteps { - return [.text("Follow the steps below to pair the \(identity.model) with Zigbee2MQTT.")] - } else { - return [.text("Pairing guidance for the \(identity.model) is limited. Review the notes below for device-specific instructions.")] - } - } - private static nonisolated func makeCapabilities(from exposes: [Expose]) -> [DeviceDocCapability] { exposes.flattened.map { expose in let title = expose.label ?? expose.name ?? expose.property ?? expose.type.capitalized @@ -621,7 +348,7 @@ enum DeviceDocNormalizer { || title.contains("warning") } - private static nonisolated func isPairingAdjacentTitle(_ title: String) -> Bool { + static nonisolated func isPairingAdjacentTitle(_ title: String) -> Bool { title.contains("troubleshooting") || title.contains("factory reset") || title.contains("install code") @@ -631,16 +358,4 @@ enum DeviceDocNormalizer { || title.contains("pair") } - private static nonisolated let prerequisiteKeywords = [ - "coordinator", "adapter", "wake", "awake", "close to", "battery", "install code", - "permit join", "bridge", "factory reset" - ] - private static nonisolated let successKeywords = [ - "when connected", "turns off", "flash", "flashes", "blink", "blinks", "pulsate", - "joined", "success", "light turns", "beep" - ] - private static nonisolated let troubleshootingKeywords = [ - "troubleshooting", "issue", "doesn't", "didn't", "retry", "remove the device", - "join it again", "re-pair", "not work", "work around" - ] } diff --git a/Shellbee/Core/Parsing/DeviceDocumentationModels.swift b/Shellbee/Core/Parsing/DeviceDocumentationModels.swift new file mode 100644 index 0000000..3e910e9 --- /dev/null +++ b/Shellbee/Core/Parsing/DeviceDocumentationModels.swift @@ -0,0 +1,119 @@ +import Foundation + +/// Top-level container pairing the raw `ParsedDeviceDoc` (from `DocParser`) +/// with the higher-level `NormalizedDeviceDoc` (from `DeviceDocNormalizer`). +/// `sourcePath` is the path that produced the document, used for resolving +/// relative image links. +struct DeviceDocumentation: Sendable { + let sourcePath: String + let parsed: ParsedDeviceDoc + let normalized: NormalizedDeviceDoc +} + +struct NormalizedDeviceDoc: Sendable { + let identity: DeviceDocIdentity + let pairing: DevicePairingGuide? + let capabilities: [DeviceDocCapability] + let options: [DocOption] + let notesSections: [DocSection] + let advancedSections: [DocSection] + let miscSections: [DocSection] + let quality: Quality + + enum Quality: Sendable, Equatable { + case fullyNormalized + case partiallyNormalized + case parsedOnly + } + + var additionalSections: [DocSection] { advancedSections + miscSections } + var hasSemanticContent: Bool { + pairing != nil || !capabilities.isEmpty || !options.isEmpty || !notesSections.isEmpty + } +} + +struct DeviceDocIdentity: Sendable { + let vendor: String + let model: String + let description: String + let imageURL: URL? + let supportsOTA: Bool + let exposesSummary: String? +} + +struct DevicePairingGuide: Sendable { + let summary: [InlineSpan] + let prerequisites: [[InlineSpan]] + let primarySteps: [StepItem] + let alternatives: [DevicePairingMethod] + let successCues: [[InlineSpan]] + let troubleshooting: [[InlineSpan]] + let additionalNotes: [DocBlock] + + nonisolated var hasContent: Bool { + !summary.isEmpty + || !prerequisites.isEmpty + || !primarySteps.isEmpty + || !alternatives.isEmpty + || !successCues.isEmpty + || !troubleshooting.isEmpty + || !additionalNotes.isEmpty + } +} + +struct DevicePairingMethod: Sendable, Identifiable { + let id: UUID + let title: String + let summary: [InlineSpan] + let steps: [StepItem] + let notes: [DocBlock] + /// True when this alternative is purely a reference to the Touchlink guide with no + /// device-specific steps. The UI replaces the generic card with an in-app Touchlink button. + let isTouchlinkReset: Bool + /// True when this alternative describes a Philips Hue serial-number factory reset. + /// The UI replaces the raw Z2M content with an in-app Philips Hue Reset action. + let isPhilipsHueSerialReset: Bool + + nonisolated init(title: String, summary: [InlineSpan] = [], steps: [StepItem] = [], notes: [DocBlock] = [], isTouchlinkReset: Bool = false, isPhilipsHueSerialReset: Bool = false) { + self.id = UUID() + self.title = title + self.summary = summary + self.steps = steps + self.notes = notes + self.isTouchlinkReset = isTouchlinkReset + self.isPhilipsHueSerialReset = isPhilipsHueSerialReset + } +} + +struct DeviceDocCapability: Sendable, Identifiable { + let id: UUID + let title: String + let subtitle: String? + let summary: String + let kind: String + let unit: String? + let isReadable: Bool + let isWritable: Bool + let detailChips: [String] + + nonisolated init( + title: String, + subtitle: String? = nil, + summary: String, + kind: String, + unit: String? = nil, + isReadable: Bool, + isWritable: Bool, + detailChips: [String] = [] + ) { + self.id = UUID() + self.title = title + self.subtitle = subtitle + self.summary = summary + self.kind = kind + self.unit = unit + self.isReadable = isReadable + self.isWritable = isWritable + self.detailChips = detailChips + } +} From db44792d596ca79102595b566fe203028a6ec98f Mon Sep 17 00:00:00 2001 From: tashda Date: Wed, 29 Apr 2026 08:28:35 +0200 Subject: [PATCH 14/18] DesignTokens: extend Typography, Opacity, Duration for second sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 1 of the wider-cleanup pass tracked in #36. Adds tokens for the patterns the original sweep missed — typography, opacity literals, and animation durations. No call sites touched yet; this commit only adds the vocabulary. New tokens: Typography - Eyebrow pattern: eyebrowLabel/Icon (11pt, dominant), eyebrowLabelLarge/ IconLarge (12pt, used in SensorCard/RemoteCard/GenericExposeCard — see #36.A), eyebrowTracking (0.5), eyebrowTrackingLoose (0.6). - Hero block: heroValue (56pt), heroStateText (48pt for "On"/"Off"), heroUnit (18pt), heroSubtitle (20pt). - Tile metrics: metricValue (30pt), metricUnit (15pt), identityValue (24pt) / identityUnit (14pt) for DeviceCard/GroupCard, snapshotRowValue (20pt) / snapshotRowUnit (13pt) for snapshot rows. - cardTitle (24pt bold rounded), footerActionLabel (13pt), sectionHeader (15pt), formRowIcon/IconBold (16pt), sliderEndLabel (9pt). - Banner glyphs (notificationLevelIcon 15pt, fastTrackLevelIcon 14pt — see #36.D), permit-join countdown (64pt thin) and symbol (48pt), climate setpoint button (14pt bold), light secondary glyphs (14pt). - minimumScaleFactor presets covering the 7 distinct values in use. Opacity - hairline (0.08) for the thin rule color across all cards - subtleFade (0.04), offStateTint (0.06), onStateTint (0.18) - actionButtonFill (0.15), strongAccentFill (0.20), mediumAccentFill (0.14) - banner (0.9), pressedAlpha (0.25), dimmedSurface (0.30) - secondaryDim/Full and several one-off named values Duration - quickFade (0.15), mediumAnimation (0.25), slowAnimation (0.6) - pulseExpand (0.8), pulseFull (1.0) - checkResultDisplay (3s), pendingDeleteTimeout (15s) Refs #36 Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee/Shared/DesignTokens.swift | 93 ++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/Shellbee/Shared/DesignTokens.swift b/Shellbee/Shared/DesignTokens.swift index 1c39eae..c2c9065 100644 --- a/Shellbee/Shared/DesignTokens.swift +++ b/Shellbee/Shared/DesignTokens.swift @@ -141,11 +141,82 @@ nonisolated enum DesignTokens { static let statusPulse: Double = 1.8 static let otaBadgeSpin: Double = 1.1 static let pressedState: Double = 0.16 + // Common SwiftUI .easeInOut / .snappy / withAnimation durations + static let quickFade: Double = 0.15 + static let mediumAnimation: Double = 0.25 + static let slowAnimation: Double = 0.6 + static let pulseExpand: Double = 0.8 + static let pulseFull: Double = 1.0 + static let checkResultDisplay: Double = 3 + static let pendingDeleteTimeout: Double = 15 } nonisolated enum Typography { static let cardHeadline: Font = .title3.weight(.semibold) static let cardSubheadline: Font = .subheadline + + // MARK: Eyebrow pattern (small uppercase label above metric values) + // Dominant value used in most card hero blocks. SensorCard, RemoteCard, + // and GenericExposeCard currently use the larger 12pt variant — + // tracked in #36. Once that's resolved one variant goes away. + static let eyebrowLabel: Font = .system(size: 11, weight: .semibold) + static let eyebrowIcon: Font = .system(size: 11, weight: .bold) + static let eyebrowLabelLarge: Font = .system(size: 12, weight: .semibold) + static let eyebrowIconLarge: Font = .system(size: 12, weight: .bold) + static let eyebrowTracking: CGFloat = 0.5 + static let eyebrowTrackingLoose: CGFloat = 0.6 + + // MARK: Hero metric (the giant number/state on a card hero block) + static let heroValue: Font = .system(size: 56, weight: .bold, design: .rounded) + // Used for "On"/"Off"/"Unlocked"/"Closed" hero text when there's no + // numeric metric. Slightly smaller than heroValue (deliberate — see #36). + static let heroStateText: Font = .system(size: 48, weight: .bold, design: .rounded) + // Unit text rendered next to heroValue (e.g. the "%" after a brightness) + static let heroUnit: Font = .system(size: 18, weight: .medium, design: .rounded) + // Smaller subtitle text under the hero value (e.g. "Target 21.5°" under temperature) + static let heroSubtitle: Font = .system(size: 20, weight: .semibold, design: .rounded) + + // MARK: Metric tile (a stat tile inside the card body) + static let metricValue: Font = .system(size: 30, weight: .semibold, design: .rounded) + static let metricUnit: Font = .system(size: 15, weight: .medium, design: .rounded) + // Identity-tile variant (smaller — used in DeviceCard / GroupCard). + // Whether 24/14 should be unified with 30/15 is tracked in #36. + static let identityValue: Font = .system(size: 24, weight: .semibold, design: .rounded) + static let identityUnit: Font = .system(size: 14, weight: .medium, design: .rounded) + // Snapshot-row variant (Light card colorSnapshotRow, Cover tilt row) + static let snapshotRowValue: Font = .system(size: 20, weight: .semibold, design: .rounded) + static let snapshotRowUnit: Font = .system(size: 13, weight: .medium, design: .rounded) + + // MARK: Card titles & section labels + static let cardTitle: Font = .system(size: 24, weight: .bold, design: .rounded) + static let footerActionLabel: Font = .system(size: 13, weight: .semibold, design: .rounded) + static let sectionHeader: Font = .system(size: 15, weight: .semibold) + + // MARK: Form / settings rows + static let formRowIcon: Font = .system(size: 16, weight: .medium) + static let formRowIconBold: Font = .system(size: 16, weight: .semibold) + + // MARK: Misc one-offs + static let sliderEndLabel: Font = .system(size: 9, weight: .medium) + static let permitJoinCountdown: Font = .system(size: 64, weight: .thin) + static let permitJoinSymbol: Font = .system(size: 48) + // Notification banner level glyphs — sizes differ per #36.D. + static let notificationLevelIcon: Font = .system(size: 15, weight: .semibold) + static let fastTrackLevelIcon: Font = .system(size: 14, weight: .semibold) + // Climate setpoint +/- button glyph + static let climateActionIcon: Font = .system(size: 14, weight: .bold) + // Light card secondary glyphs (compass / palette icon overlays) + static let lightSecondaryIcon: Font = .system(size: 14, weight: .semibold) + + // MARK: minimumScaleFactor presets (per Text element shrink budgets) + static let scaleFactorAggressive: CGFloat = 0.45 + static let scaleFactorTight: CGFloat = 0.55 + static let scaleFactorMedium: CGFloat = 0.6 + static let scaleFactorRelaxed: CGFloat = 0.7 + static let scaleFactorMild: CGFloat = 0.75 + static let scaleFactorSubtle: CGFloat = 0.82 + static let scaleFactorAggressiveLight: CGFloat = 0.65 + static let scaleFactorMildLight: CGFloat = 0.72 } nonisolated enum Opacity { @@ -157,6 +228,28 @@ nonisolated enum DesignTokens { static let accentFill: Double = 0.2 static let cardStroke: Double = 0.18 static let glow: Double = 0.16 + // The thin divider rule color tint used across cards + static let hairline: Double = 0.08 + // Subtle gradient fade, off-state hue tint + static let subtleFade: Double = 0.04 + // Off-state hero tint, "tertiaryLabel" gradient + static let offStateTint: Double = 0.06 + static let onStateTint: Double = 0.18 + // Climate action button background, color preview overlays + static let actionButtonFill: Double = 0.15 + static let strongAccentFill: Double = 0.20 + static let mediumAccentFill: Double = 0.14 + // Notification background blur + static let banner: Double = 0.9 + // Unique one-offs + static let veryFaint: Double = 0.03 + static let veryLight: Double = 0.05 + static let lightOpaque: Double = 0.10 + static let mildOpaque: Double = 0.22 + static let pressedAlpha: Double = 0.25 + static let dimmedSurface: Double = 0.30 + static let secondaryDim: Double = 0.75 + static let secondaryFull: Double = 0.7 } } From eeed408833fc5ae8dc69754f00772e3d548ea203 Mon Sep 17 00:00:00 2001 From: tashda Date: Wed, 29 Apr 2026 08:40:06 +0200 Subject: [PATCH 15/18] Card sweep: replace all font(.system(size:)) literals with Typography tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2 of #36. Sweeps the 104 font.system(size:) call sites across every card, banner, and chrome view. After this commit, grep for that pattern returns zero matches outside DesignTokens.swift. Snap rules used: - size 11 bold → Typography.eyebrowIcon - size 11 semibold → Typography.eyebrowLabel - size 12 bold/semibold (only SensorCard / RemoteCard / GenericExposeCard headers and a couple of icon overlays) → eyebrowIconLarge / Label- Large, marked with `// NOTE: ... see #36.A` comments at the outlier sites so the inconsistency stays visible until you decide whether to unify on 11pt. - size 56 bold rounded → Typography.heroValue - size 48 bold rounded (Off-state hero) → heroStateText - size 30 semibold rounded → metricValue - size 24 semibold rounded → identityValue (DeviceCard / GroupCard / Climate) - size 24 bold rounded → cardTitle - size 20 bold rounded → compactCardTitle (new — used in GroupCard / DeviceCard) - size 20 semibold rounded → heroSubtitle (e.g. "Target X°") and snapshotRowValue depending on context - size 18 medium rounded → heroUnit - size 15 medium rounded → metricUnit - size 15 semibold → sectionHeader - size 14 medium rounded → identityUnit - size 14 semibold → lightSecondaryIcon (Cover action button), fast- track banner level glyph, climate +/- button (climateActionIcon) - size 13 medium rounded → snapshotRowUnit - size 13 semibold rounded → footerActionLabel - size 12 semibold for one-off icon overlays (LightColorControl eyedropper, HomeLogsCard badge) → eyebrowLabelLarge (12pt is already the dominant 12pt token; semantically correct re-use) - size 16 medium → formRowIcon (FanExtraRow, FanControlCard, GenericExposeCard) - size 16 semibold → formRowIconBold (LightBrightnessArea, LockControlCard) - size 9 medium → sliderEndLabel (LightTemperatureControl preset labels) - size 64 thin / 48 → permitJoinCountdown / permitJoinSymbol The visual outliers tracked in #36 (Sensor/Remote/Generic 12pt eyebrows, DeviceCard/GroupCard/Climate 24pt vs 30pt tile metrics, FastTrack 14pt vs main 15pt banner glyph, hero 56 vs Off-state 48) all use distinct tokens — when you decide on each item, it's a one-line token swap. Refs #36 Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee/Features/Groups/GroupCard.swift | 12 +++---- .../Features/Groups/GroupCardFooterBar.swift | 2 +- Shellbee/Features/Home/HomeLogsCard.swift | 2 +- .../Features/Home/PermitJoinActiveSheet.swift | 4 +-- .../Notifications/FastTrackBanner.swift | 2 +- .../InAppNotificationBanner.swift | 2 +- .../ClimateControl/ClimateControlCard.swift | 20 ++++++------ Shellbee/Shared/Components/DeviceCard.swift | 12 +++---- .../Components/DeviceCardFooterBar.swift | 6 ++-- .../Doc/DefaultDocSectionView.swift | 2 +- Shellbee/Shared/Components/StatusChip.swift | 4 +-- .../CoverControl/CoverControlCard.swift | 22 ++++++------- Shellbee/Shared/DesignTokens.swift | 2 ++ .../Shared/FanControl/FanControlCard.swift | 32 +++++++++---------- Shellbee/Shared/FanControl/FanExtraRow.swift | 2 +- .../GenericExposeCard/GenericExposeCard.swift | 9 +++--- .../LightControl/LightBrightnessArea.swift | 2 +- .../LightControl/LightColorControl.swift | 2 +- .../LightControl/LightControlCard.swift | 28 ++++++++-------- .../LightTemperatureControl.swift | 2 +- .../Shared/LockControl/LockControlCard.swift | 8 ++--- Shellbee/Shared/RemoteCard/RemoteCard.swift | 15 +++++---- Shellbee/Shared/SensorCard/SensorCard.swift | 15 +++++---- .../SwitchControl/SwitchControlCard.swift | 14 ++++---- 24 files changed, 113 insertions(+), 108 deletions(-) diff --git a/Shellbee/Features/Groups/GroupCard.swift b/Shellbee/Features/Groups/GroupCard.swift index 1ab1219..0a0f8f1 100644 --- a/Shellbee/Features/Groups/GroupCard.swift +++ b/Shellbee/Features/Groups/GroupCard.swift @@ -37,7 +37,7 @@ struct GroupCard: View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) { Text(group.friendlyName) - .font(.system(size: 20, weight: .bold, design: .rounded)) + .font(DesignTokens.Typography.compactCardTitle) .foregroundStyle(.primary) .lineLimit(1) .minimumScaleFactor(0.72) @@ -115,10 +115,10 @@ struct GroupCard: View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xs) { Image(systemName: icon) - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text(label) - .font(.system(size: 11, weight: .semibold)) + .font(DesignTokens.Typography.eyebrowLabel) .tracking(0.5) .textCase(.uppercase) .lineLimit(1) @@ -127,14 +127,14 @@ struct GroupCard: View { HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xxs) { Text(value) - .font(.system(size: 24, weight: .semibold, design: .rounded)) + .font(DesignTokens.Typography.identityValue) .monospacedDigit() .foregroundStyle(color) .lineLimit(1) .minimumScaleFactor(0.55) if let unit { Text(unit) - .font(.system(size: 14, weight: .medium, design: .rounded)) + .font(DesignTokens.Typography.identityUnit) .foregroundStyle(.secondary) .lineLimit(1) } @@ -155,7 +155,7 @@ struct GroupCard: View { @ViewBuilder private var nameView: some View { let label = Text(group.friendlyName) - .font(.system(size: 24, weight: .bold, design: .rounded)) + .font(DesignTokens.Typography.cardTitle) .foregroundStyle(.primary) .lineLimit(1) .minimumScaleFactor(0.45) diff --git a/Shellbee/Features/Groups/GroupCardFooterBar.swift b/Shellbee/Features/Groups/GroupCardFooterBar.swift index c9c160d..22be303 100644 --- a/Shellbee/Features/Groups/GroupCardFooterBar.swift +++ b/Shellbee/Features/Groups/GroupCardFooterBar.swift @@ -17,7 +17,7 @@ struct GroupCardFooterBar: View { private func statCell(value: String, label: String, color: Color) -> some View { VStack(spacing: DesignTokens.Spacing.summaryRowVerticalPadding) { Text(value) - .font(.system(size: 13, weight: .semibold, design: .rounded)) + .font(DesignTokens.Typography.footerActionLabel) .foregroundStyle(color) .lineLimit(1) .minimumScaleFactor(0.75) diff --git a/Shellbee/Features/Home/HomeLogsCard.swift b/Shellbee/Features/Home/HomeLogsCard.swift index fffc5f1..1130a35 100644 --- a/Shellbee/Features/Home/HomeLogsCard.swift +++ b/Shellbee/Features/Home/HomeLogsCard.swift @@ -79,7 +79,7 @@ struct HomeLogRow: View { .frame(width: Self.badgeSize, height: Self.badgeSize) .overlay { Image(systemName: entry.category.systemImage) - .font(.system(size: 12, weight: .semibold)) + .font(DesignTokens.Typography.eyebrowLabelLarge) .foregroundStyle(entry.level.color) } } diff --git a/Shellbee/Features/Home/PermitJoinActiveSheet.swift b/Shellbee/Features/Home/PermitJoinActiveSheet.swift index 599e9e7..2862e66 100644 --- a/Shellbee/Features/Home/PermitJoinActiveSheet.swift +++ b/Shellbee/Features/Home/PermitJoinActiveSheet.swift @@ -45,12 +45,12 @@ struct PermitJoinActiveSheet: View { if let remaining { Text(String(format: "%d:%02d", remaining / 60, remaining % 60)) - .font(.system(size: 64, weight: .thin).monospacedDigit()) + .font(DesignTokens.Typography.permitJoinCountdown.monospacedDigit()) .foregroundStyle(.primary) .contentTransition(.numericText(countsDown: true)) } else { Image(systemName: "dot.radiowaves.up.forward") - .font(.system(size: 48)) + .font(DesignTokens.Typography.permitJoinSymbol) .foregroundStyle(.green) .symbolEffect(.pulse) } diff --git a/Shellbee/Features/Notifications/FastTrackBanner.swift b/Shellbee/Features/Notifications/FastTrackBanner.swift index e300b6e..979c80f 100644 --- a/Shellbee/Features/Notifications/FastTrackBanner.swift +++ b/Shellbee/Features/Notifications/FastTrackBanner.swift @@ -10,7 +10,7 @@ struct FastTrackBanner: View { var body: some View { HStack(spacing: DesignTokens.Spacing.sm) { Image(systemName: notification.level.systemImage) - .font(.system(size: 14, weight: .semibold)) + .font(DesignTokens.Typography.fastTrackLevelIcon) .foregroundStyle(notification.level.color) Text(notification.title) .font(.footnote.weight(.semibold)) diff --git a/Shellbee/Features/Notifications/InAppNotificationBanner.swift b/Shellbee/Features/Notifications/InAppNotificationBanner.swift index 3f26f53..1428923 100644 --- a/Shellbee/Features/Notifications/InAppNotificationBanner.swift +++ b/Shellbee/Features/Notifications/InAppNotificationBanner.swift @@ -60,7 +60,7 @@ struct InAppNotificationBanner: View { private var header: some View { HStack(spacing: DesignTokens.Spacing.sm) { Image(systemName: notification.level.systemImage) - .font(.system(size: 15, weight: .semibold)) + .font(DesignTokens.Typography.notificationLevelIcon) .foregroundStyle(notification.level.color) .frame(width: DesignTokens.Size.cardSymbol) diff --git a/Shellbee/Shared/ClimateControl/ClimateControlCard.swift b/Shellbee/Shared/ClimateControl/ClimateControlCard.swift index 9bf19ba..dc90371 100644 --- a/Shellbee/Shared/ClimateControl/ClimateControlCard.swift +++ b/Shellbee/Shared/ClimateControl/ClimateControlCard.swift @@ -84,10 +84,10 @@ struct ClimateControlCard: View { private var heroEyebrow: some View { HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: heroIcon) - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text(context.runningStateLabel) - .font(.system(size: 11, weight: .semibold)) + .font(DesignTokens.Typography.eyebrowLabel) .tracking(0.5) .textCase(.uppercase) .lineLimit(1) @@ -108,7 +108,7 @@ struct ClimateControlCard: View { private var heroValue: some View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.xxs) { Text(context.displayTemperature) - .font(.system(size: 56, weight: .bold, design: .rounded)) + .font(DesignTokens.Typography.heroValue) .monospacedDigit() .foregroundStyle(.primary) .lineLimit(1) @@ -118,7 +118,7 @@ struct ClimateControlCard: View { // setpoint row below already carries it. if let setpoint = context.activeSetpoint, !showsSetpointControl { Text("Target \(formatTemp(setpoint))") - .font(.system(size: 20, weight: .semibold, design: .rounded)) + .font(DesignTokens.Typography.heroSubtitle) .foregroundStyle(heroTint) .lineLimit(1) .minimumScaleFactor(0.7) @@ -148,10 +148,10 @@ struct ClimateControlCard: View { HStack(alignment: .center, spacing: DesignTokens.Spacing.md) { HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: "target") - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text("Target") - .font(.system(size: 11, weight: .semibold)) + .font(DesignTokens.Typography.eyebrowLabel) .tracking(0.5) .textCase(.uppercase) } @@ -168,7 +168,7 @@ struct ClimateControlCard: View { } Text(formatTemp(setpointDraft)) - .font(.system(size: 24, weight: .semibold, design: .rounded)) + .font(DesignTokens.Typography.identityValue) .monospacedDigit() .foregroundStyle(heroTint) .frame(minWidth: DesignTokens.Size.climateSetpointMinWidth) @@ -188,7 +188,7 @@ struct ClimateControlCard: View { private func setpointButton(systemImage: String, action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: systemImage) - .font(.system(size: 14, weight: .bold)) + .font(DesignTokens.Typography.climateActionIcon) .foregroundStyle(heroTint) .frame(width: DesignTokens.Size.climateActionButton, height: DesignTokens.Size.climateActionButton) .background(heroTint.opacity(0.15), in: Circle()) @@ -203,10 +203,10 @@ struct ClimateControlCard: View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: "dial.medium") - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text("Mode") - .font(.system(size: 11, weight: .semibold)) + .font(DesignTokens.Typography.eyebrowLabel) .tracking(0.5) .textCase(.uppercase) } diff --git a/Shellbee/Shared/Components/DeviceCard.swift b/Shellbee/Shared/Components/DeviceCard.swift index 0828a2b..99ddf88 100644 --- a/Shellbee/Shared/Components/DeviceCard.swift +++ b/Shellbee/Shared/Components/DeviceCard.swift @@ -56,7 +56,7 @@ struct DeviceCard: View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) { Text(device.friendlyName) - .font(.system(size: 20, weight: .bold, design: .rounded)) + .font(DesignTokens.Typography.compactCardTitle) .foregroundStyle(.primary) .lineLimit(1) .minimumScaleFactor(0.72) @@ -140,10 +140,10 @@ struct DeviceCard: View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xs) { Image(systemName: icon) - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text(label) - .font(.system(size: 11, weight: .semibold)) + .font(DesignTokens.Typography.eyebrowLabel) .tracking(0.5) .textCase(.uppercase) .lineLimit(1) @@ -152,14 +152,14 @@ struct DeviceCard: View { HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xxs) { Text(value) - .font(.system(size: 24, weight: .semibold, design: .rounded)) + .font(DesignTokens.Typography.identityValue) .monospacedDigit() .foregroundStyle(color) .lineLimit(1) .minimumScaleFactor(0.55) if let unit { Text(unit) - .font(.system(size: 14, weight: .medium, design: .rounded)) + .font(DesignTokens.Typography.identityUnit) .foregroundStyle(.secondary) .lineLimit(1) } @@ -183,7 +183,7 @@ struct DeviceCard: View { @ViewBuilder private var nameView: some View { let label = Text(device.friendlyName) - .font(.system(size: 24, weight: .bold, design: .rounded)) + .font(DesignTokens.Typography.cardTitle) .foregroundStyle(.primary) .lineLimit(1) .minimumScaleFactor(0.45) diff --git a/Shellbee/Shared/Components/DeviceCardFooterBar.swift b/Shellbee/Shared/Components/DeviceCardFooterBar.swift index 4768ecc..3d1ef99 100644 --- a/Shellbee/Shared/Components/DeviceCardFooterBar.swift +++ b/Shellbee/Shared/Components/DeviceCardFooterBar.swift @@ -20,7 +20,7 @@ struct DeviceCardFooterBar: View { private func statCell(value: String, label: String, color: Color) -> some View { VStack(spacing: DesignTokens.Spacing.summaryRowVerticalPadding) { Text(value) - .font(.system(size: 13, weight: .semibold, design: .rounded)) + .font(DesignTokens.Typography.footerActionLabel) .foregroundStyle(color) .lineLimit(1) .minimumScaleFactor(0.75) @@ -45,11 +45,11 @@ struct DeviceCardFooterBar: View { private var powerStatValue: some View { if device.type == .endDevice, let battery = state.battery { Text("\(battery)%") - .font(.system(size: 13, weight: .semibold, design: .rounded)) + .font(DesignTokens.Typography.footerActionLabel) .foregroundStyle(battery.batteryColor) } else { Text(normalizedPowerSource) - .font(.system(size: 13, weight: .semibold, design: .rounded)) + .font(DesignTokens.Typography.footerActionLabel) .foregroundStyle(Color.secondary) .lineLimit(1) .minimumScaleFactor(0.75) diff --git a/Shellbee/Shared/Components/Doc/DefaultDocSectionView.swift b/Shellbee/Shared/Components/Doc/DefaultDocSectionView.swift index 2076d4b..274db30 100644 --- a/Shellbee/Shared/Components/Doc/DefaultDocSectionView.swift +++ b/Shellbee/Shared/Components/Doc/DefaultDocSectionView.swift @@ -24,7 +24,7 @@ struct SectionHeader: View { .frame(width: DesignTokens.Size.docSectionIconFrame, height: DesignTokens.Size.docSectionIconFrame) Image(systemName: iconName) - .font(.system(size: 15, weight: .semibold)) + .font(DesignTokens.Typography.sectionHeader) .foregroundStyle(iconColor) } Text(title) diff --git a/Shellbee/Shared/Components/StatusChip.swift b/Shellbee/Shared/Components/StatusChip.swift index 0ef5655..ee10dbf 100644 --- a/Shellbee/Shared/Components/StatusChip.swift +++ b/Shellbee/Shared/Components/StatusChip.swift @@ -21,11 +21,11 @@ struct StatusChip: View { HStack(spacing: DesignTokens.Spacing.xs) { if let symbol { Image(systemName: symbol) - .font(.system(size: 10, weight: .bold)) + .font(.system(size: DesignTokens.Size.compactChipSymbol, weight: .bold)) } Text(title) - .font(.system(size: 11, weight: .semibold)) + .font(DesignTokens.Typography.eyebrowLabel) .lineLimit(1) } .padding(.horizontal, DesignTokens.Spacing.sm) diff --git a/Shellbee/Shared/CoverControl/CoverControlCard.swift b/Shellbee/Shared/CoverControl/CoverControlCard.swift index 684fa5d..2fb0ca4 100644 --- a/Shellbee/Shared/CoverControl/CoverControlCard.swift +++ b/Shellbee/Shared/CoverControl/CoverControlCard.swift @@ -83,10 +83,10 @@ struct CoverControlCard: View { private var heroEyebrow: some View { HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: isFullyClosed ? "blinds.horizontal.closed" : "blinds.horizontal.open") - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text(eyebrowLabel) - .font(.system(size: 11, weight: .semibold)) + .font(DesignTokens.Typography.eyebrowLabel) .tracking(0.5) .textCase(.uppercase) .lineLimit(1) @@ -105,7 +105,7 @@ struct CoverControlCard: View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.xxs) { HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xs) { Text("\(Int(positionDraft.rounded()))") - .font(.system(size: 56, weight: .bold, design: .rounded)) + .font(DesignTokens.Typography.heroValue) .monospacedDigit() .foregroundStyle(.primary) .lineLimit(1) @@ -113,18 +113,18 @@ struct CoverControlCard: View { .contentTransition(.numericText(value: positionDraft)) .animation(.snappy, value: positionDraft) Text("%") - .font(.system(size: 18, weight: .medium, design: .rounded)) + .font(DesignTokens.Typography.heroUnit) .foregroundStyle(.secondary) } Text(context.displayState) - .font(.system(size: 20, weight: .semibold, design: .rounded)) + .font(DesignTokens.Typography.heroSubtitle) .foregroundStyle(heroTint) .lineLimit(1) .minimumScaleFactor(0.7) } } else { Text(context.displayState) - .font(.system(size: 48, weight: .bold, design: .rounded)) + .font(DesignTokens.Typography.heroStateText) .foregroundStyle(heroTint) } } @@ -177,7 +177,7 @@ struct CoverControlCard: View { } label: { HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: systemImage) - .font(.system(size: 14, weight: .semibold)) + .font(DesignTokens.Typography.lightSecondaryIcon) Text(title) .font(.subheadline.weight(.semibold)) .lineLimit(1) @@ -198,10 +198,10 @@ struct CoverControlCard: View { HStack(alignment: .firstTextBaseline) { HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: "rotate.3d") - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text("Tilt") - .font(.system(size: 11, weight: .semibold)) + .font(DesignTokens.Typography.eyebrowLabel) .tracking(0.5) .textCase(.uppercase) } @@ -209,11 +209,11 @@ struct CoverControlCard: View { Spacer() HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xxs) { Text("\(Int(tiltDraft.rounded()))") - .font(.system(size: 20, weight: .semibold, design: .rounded)) + .font(DesignTokens.Typography.snapshotRowValue) .monospacedDigit() .foregroundStyle(.primary) Text("%") - .font(.system(size: 13, weight: .medium, design: .rounded)) + .font(DesignTokens.Typography.snapshotRowUnit) .foregroundStyle(.secondary) } } diff --git a/Shellbee/Shared/DesignTokens.swift b/Shellbee/Shared/DesignTokens.swift index c2c9065..002dcd6 100644 --- a/Shellbee/Shared/DesignTokens.swift +++ b/Shellbee/Shared/DesignTokens.swift @@ -189,6 +189,8 @@ nonisolated enum DesignTokens { // MARK: Card titles & section labels static let cardTitle: Font = .system(size: 24, weight: .bold, design: .rounded) + // Smaller card title for the GroupCard header + static let compactCardTitle: Font = .system(size: 20, weight: .bold, design: .rounded) static let footerActionLabel: Font = .system(size: 13, weight: .semibold, design: .rounded) static let sectionHeader: Font = .system(size: 15, weight: .semibold) diff --git a/Shellbee/Shared/FanControl/FanControlCard.swift b/Shellbee/Shared/FanControl/FanControlCard.swift index 7d90b3f..c94c7fb 100644 --- a/Shellbee/Shared/FanControl/FanControlCard.swift +++ b/Shellbee/Shared/FanControl/FanControlCard.swift @@ -156,10 +156,10 @@ struct FanControlCard: View { private var heroEyebrow: some View { HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: hasAirSensors ? "aqi.medium" : (context.isOn ? "fan.fill" : "fan")) - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text(hasAirSensors ? "Air Quality" : "Fan") - .font(.system(size: 11, weight: .semibold)) + .font(DesignTokens.Typography.eyebrowLabel) .tracking(0.5) .textCase(.uppercase) } @@ -173,19 +173,19 @@ struct FanControlCard: View { if let pm = pm25Value { HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xs) { Text(Int(pm.rounded()).formatted()) - .font(.system(size: 56, weight: .bold, design: .rounded)) + .font(DesignTokens.Typography.heroValue) .monospacedDigit() .foregroundStyle(.primary) .lineLimit(1) .minimumScaleFactor(0.6) Text(pm25Unit) - .font(.system(size: 18, weight: .medium, design: .rounded)) + .font(DesignTokens.Typography.heroUnit) .foregroundStyle(.secondary) } } if let aq = airQualityText { Text(prettify(aq)) - .font(.system(size: 20, weight: .semibold, design: .rounded)) + .font(DesignTokens.Typography.heroSubtitle) .foregroundStyle(heroTint) .lineLimit(1) .minimumScaleFactor(0.7) @@ -193,7 +193,7 @@ struct FanControlCard: View { } } else { Text(context.isOn ? "On" : "Off") - .font(.system(size: 48, weight: .bold, design: .rounded)) + .font(DesignTokens.Typography.heroStateText) .foregroundStyle(heroTint) } } @@ -332,17 +332,17 @@ struct FanControlCard: View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: icon) - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text("Filter") - .font(.system(size: 11, weight: .semibold)) + .font(DesignTokens.Typography.eyebrowLabel) .tracking(0.5) .textCase(.uppercase) } .foregroundStyle(tint) Text(title) - .font(.system(size: 30, weight: .semibold, design: .rounded)) + .font(DesignTokens.Typography.metricValue) .foregroundStyle(tint) .lineLimit(1) } @@ -383,10 +383,10 @@ struct FanControlCard: View { return VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xs) { Image(systemName: icon) - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text(label) - .font(.system(size: 11, weight: .semibold)) + .font(DesignTokens.Typography.eyebrowLabel) .tracking(0.5) .textCase(.uppercase) .lineLimit(2) @@ -396,13 +396,13 @@ struct FanControlCard: View { HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xxs) { Text(parts.value) - .font(.system(size: 30, weight: .semibold, design: .rounded)) + .font(DesignTokens.Typography.metricValue) .monospacedDigit() .foregroundStyle(.primary) .lineLimit(1) .minimumScaleFactor(0.55) Text(parts.unit) - .font(.system(size: 15, weight: .medium, design: .rounded)) + .font(DesignTokens.Typography.metricUnit) .foregroundStyle(.secondary) } } @@ -415,8 +415,8 @@ struct FanControlCard: View { private func sectionView(_ section: LayoutSection) -> some View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { Text(section.title) - .font(.system(size: 12, weight: .semibold)) - .tracking(0.6) + .font(DesignTokens.Typography.eyebrowLabelLarge) + .tracking(DesignTokens.Typography.eyebrowTrackingLoose) .textCase(.uppercase) .foregroundStyle(.secondary) .padding(.leading, DesignTokens.Spacing.md) @@ -492,7 +492,7 @@ private struct DisclosureRow: View { Button(action: action) { HStack(spacing: DesignTokens.Spacing.md) { Image(systemName: symbol) - .font(.system(size: 16, weight: .medium)) + .font(DesignTokens.Typography.formRowIcon) .symbolRenderingMode(.hierarchical) .foregroundStyle(.secondary) .frame(width: iconWidth) diff --git a/Shellbee/Shared/FanControl/FanExtraRow.swift b/Shellbee/Shared/FanControl/FanExtraRow.swift index adcc162..cb05492 100644 --- a/Shellbee/Shared/FanControl/FanExtraRow.swift +++ b/Shellbee/Shared/FanControl/FanExtraRow.swift @@ -38,7 +38,7 @@ struct FanExtraRow: View { private var leadingIcon: some View { Image(systemName: meta.symbol) - .font(.system(size: 16, weight: .medium)) + .font(DesignTokens.Typography.formRowIcon) .symbolRenderingMode(.hierarchical) .foregroundStyle(.secondary) .frame(width: iconWidth) diff --git a/Shellbee/Shared/GenericExposeCard/GenericExposeCard.swift b/Shellbee/Shared/GenericExposeCard/GenericExposeCard.swift index 4ed4e4a..9a067e7 100644 --- a/Shellbee/Shared/GenericExposeCard/GenericExposeCard.swift +++ b/Shellbee/Shared/GenericExposeCard/GenericExposeCard.swift @@ -50,13 +50,14 @@ struct GenericExposeCard: View { /// name above the card already names what we're looking at, so a redundant /// "Controls" / "Device State" headline is just noise. private var snapshotHeader: some View { + // NOTE: 12pt eyebrow vs 11pt elsewhere — see #36.A. HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: "cpu") - .font(.system(size: 12, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIconLarge) .foregroundStyle(.tint) Text("Device State") - .font(.system(size: 12, weight: .semibold)) - .tracking(0.6) + .font(DesignTokens.Typography.eyebrowLabelLarge) + .tracking(DesignTokens.Typography.eyebrowTrackingLoose) .textCase(.uppercase) .foregroundStyle(.secondary) Spacer(minLength: 0) @@ -128,7 +129,7 @@ private struct GenericExposeRow: View { private var leadingIcon: some View { Image(systemName: meta.symbol) - .font(.system(size: 16, weight: .medium)) + .font(DesignTokens.Typography.formRowIcon) .symbolRenderingMode(.hierarchical) .foregroundStyle(.secondary) .frame(width: iconWidth) diff --git a/Shellbee/Shared/LightControl/LightBrightnessArea.swift b/Shellbee/Shared/LightControl/LightBrightnessArea.swift index b4714df..0041e67 100644 --- a/Shellbee/Shared/LightControl/LightBrightnessArea.swift +++ b/Shellbee/Shared/LightControl/LightBrightnessArea.swift @@ -60,7 +60,7 @@ struct LightBrightnessArea: View { private var labelRow: some View { HStack(spacing: DesignTokens.Spacing.sm) { Image(systemName: isOn ? "lightbulb.max.fill" : "lightbulb.slash.fill") - .font(.system(size: 16, weight: .semibold)) + .font(DesignTokens.Typography.formRowIconBold) Spacer() Text(isOn ? "\(brightnessPercent)%" : "Off") .font(.subheadline.monospacedDigit().weight(.semibold)) diff --git a/Shellbee/Shared/LightControl/LightColorControl.swift b/Shellbee/Shared/LightControl/LightColorControl.swift index 11cd133..25d7ef0 100644 --- a/Shellbee/Shared/LightControl/LightColorControl.swift +++ b/Shellbee/Shared/LightControl/LightColorControl.swift @@ -68,7 +68,7 @@ struct LightColorControl: View { .frame(width: Self.swatchSize, height: Self.swatchSize) .overlay( Image(systemName: "eyedropper.halffull") - .font(.system(size: 12, weight: .semibold)) + .font(DesignTokens.Typography.eyebrowLabelLarge) .foregroundStyle(.secondary) ) .frame(maxWidth: .infinity) diff --git a/Shellbee/Shared/LightControl/LightControlCard.swift b/Shellbee/Shared/LightControl/LightControlCard.swift index d7e3ebc..9f5753d 100644 --- a/Shellbee/Shared/LightControl/LightControlCard.swift +++ b/Shellbee/Shared/LightControl/LightControlCard.swift @@ -96,10 +96,10 @@ struct LightControlCard: View { HStack(spacing: DesignTokens.Spacing.sm) { HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: context.isOn ? "lightbulb.fill" : "lightbulb") - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text(eyebrowLabel) - .font(.system(size: 11, weight: .semibold)) + .font(DesignTokens.Typography.eyebrowLabel) .tracking(0.5) .textCase(.uppercase) .lineLimit(1) @@ -172,10 +172,10 @@ struct LightControlCard: View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: context.isOn ? "lightbulb.fill" : "lightbulb") - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text(eyebrowLabel) - .font(.system(size: 11, weight: .semibold)) + .font(DesignTokens.Typography.eyebrowLabel) .tracking(0.5) .textCase(.uppercase) .lineLimit(1) @@ -194,18 +194,18 @@ struct LightControlCard: View { if context.isOn, context.brightness != nil { HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xs) { Text("\(context.brightnessPercent)") - .font(.system(size: 56, weight: .bold, design: .rounded)) + .font(DesignTokens.Typography.heroValue) .monospacedDigit() .foregroundStyle(.primary) .lineLimit(1) .minimumScaleFactor(0.6) Text("%") - .font(.system(size: 18, weight: .medium, design: .rounded)) + .font(DesignTokens.Typography.heroUnit) .foregroundStyle(.secondary) } } else { Text(context.isOn ? "On" : "Off") - .font(.system(size: 48, weight: .bold, design: .rounded)) + .font(DesignTokens.Typography.heroStateText) .foregroundStyle(headerTint) } } @@ -247,10 +247,10 @@ struct LightControlCard: View { HStack(alignment: .firstTextBaseline) { HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: "paintpalette.fill") - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text("Color") - .font(.system(size: 11, weight: .semibold)) + .font(DesignTokens.Typography.eyebrowLabel) .tracking(0.5) .textCase(.uppercase) } @@ -268,10 +268,10 @@ struct LightControlCard: View { HStack(alignment: .firstTextBaseline) { HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: icon) - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text(label) - .font(.system(size: 11, weight: .semibold)) + .font(DesignTokens.Typography.eyebrowLabel) .tracking(0.5) .textCase(.uppercase) .lineLimit(2) @@ -281,11 +281,11 @@ struct LightControlCard: View { Spacer() HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xxs) { Text(value) - .font(.system(size: 20, weight: .semibold, design: .rounded)) + .font(DesignTokens.Typography.snapshotRowValue) .monospacedDigit() .foregroundStyle(.primary) Text(unit) - .font(.system(size: 13, weight: .medium, design: .rounded)) + .font(DesignTokens.Typography.snapshotRowUnit) .foregroundStyle(.secondary) Circle() .fill(context.displayColor) @@ -306,7 +306,7 @@ struct LightControlCard: View { private func configButton(_ systemImage: String, action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: systemImage) - .font(.system(size: 15, weight: .semibold)) + .font(DesignTokens.Typography.sectionHeader) .foregroundStyle(.primary) .frame(width: DesignTokens.Size.lightControlButton, height: DesignTokens.Size.lightControlButton) .contentShape(Circle()) diff --git a/Shellbee/Shared/LightControl/LightTemperatureControl.swift b/Shellbee/Shared/LightControl/LightTemperatureControl.swift index 968395e..4e6fc56 100644 --- a/Shellbee/Shared/LightControl/LightTemperatureControl.swift +++ b/Shellbee/Shared/LightControl/LightTemperatureControl.swift @@ -65,7 +65,7 @@ struct LightTemperatureControl: View { )) .opacity(inRange ? 1 : 0.35) Text(preset.label) - .font(.system(size: 9, weight: .medium)) + .font(DesignTokens.Typography.sliderEndLabel) .foregroundStyle(.secondary) } } diff --git a/Shellbee/Shared/LockControl/LockControlCard.swift b/Shellbee/Shared/LockControl/LockControlCard.swift index f829fd3..4565117 100644 --- a/Shellbee/Shared/LockControl/LockControlCard.swift +++ b/Shellbee/Shared/LockControl/LockControlCard.swift @@ -62,10 +62,10 @@ struct LockControlCard: View { private var heroEyebrow: some View { HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: context.isLocked ? "lock.fill" : "lock.open.fill") - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text("Lock") - .font(.system(size: 11, weight: .semibold)) + .font(DesignTokens.Typography.eyebrowLabel) .tracking(0.5) .textCase(.uppercase) } @@ -74,7 +74,7 @@ struct LockControlCard: View { private var heroValue: some View { Text(context.isLocked ? "Locked" : "Unlocked") - .font(.system(size: 48, weight: .bold, design: .rounded)) + .font(DesignTokens.Typography.heroStateText) .foregroundStyle(heroTint) .lineLimit(1) .minimumScaleFactor(0.7) @@ -107,7 +107,7 @@ struct LockControlCard: View { } label: { HStack(spacing: DesignTokens.Spacing.sm) { Image(systemName: context.isLocked ? "lock.open.fill" : "lock.fill") - .font(.system(size: 16, weight: .semibold)) + .font(DesignTokens.Typography.formRowIconBold) Text(context.isLocked ? "Unlock" : "Lock") .font(.headline) } diff --git a/Shellbee/Shared/RemoteCard/RemoteCard.swift b/Shellbee/Shared/RemoteCard/RemoteCard.swift index e5351f4..ecc588c 100644 --- a/Shellbee/Shared/RemoteCard/RemoteCard.swift +++ b/Shellbee/Shared/RemoteCard/RemoteCard.swift @@ -36,13 +36,14 @@ struct RemoteCard: View { } private var header: some View { + // NOTE: 12pt eyebrow vs 11pt elsewhere — see #36.A. HStack(spacing: DesignTokens.Spacing.sm) { Image(systemName: "command") - .font(.system(size: 12, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIconLarge) .foregroundStyle(.tint) Text("Remote") - .font(.system(size: 12, weight: .semibold)) - .tracking(0.6) + .font(DesignTokens.Typography.eyebrowLabelLarge) + .tracking(DesignTokens.Typography.eyebrowTrackingLoose) .textCase(.uppercase) .foregroundStyle(.secondary) Spacer(minLength: 0) @@ -85,10 +86,10 @@ private struct ReadingTile: View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xs) { Image(systemName: icon) - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text(label) - .font(.system(size: 11, weight: .semibold)) + .font(DesignTokens.Typography.eyebrowLabel) .tracking(0.5) .textCase(.uppercase) .lineLimit(2) @@ -98,14 +99,14 @@ private struct ReadingTile: View { HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xxs) { Text(value) - .font(.system(size: 30, weight: .semibold, design: .rounded)) + .font(DesignTokens.Typography.metricValue) .monospacedDigit() .foregroundStyle(valueColor) .lineLimit(2) .minimumScaleFactor(0.55) if let unit { Text(unit) - .font(.system(size: 15, weight: .medium, design: .rounded)) + .font(DesignTokens.Typography.metricUnit) .foregroundStyle(.secondary) .lineLimit(1) } diff --git a/Shellbee/Shared/SensorCard/SensorCard.swift b/Shellbee/Shared/SensorCard/SensorCard.swift index 8c7ed05..d87b393 100644 --- a/Shellbee/Shared/SensorCard/SensorCard.swift +++ b/Shellbee/Shared/SensorCard/SensorCard.swift @@ -44,13 +44,14 @@ struct SensorCard: View { } private var header: some View { + // NOTE: 12pt eyebrow vs 11pt elsewhere — see #36.A. HStack(spacing: DesignTokens.Spacing.sm) { Image(systemName: "sensor.fill") - .font(.system(size: 12, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIconLarge) .foregroundStyle(.tint) Text("Sensor") - .font(.system(size: 12, weight: .semibold)) - .tracking(0.6) + .font(DesignTokens.Typography.eyebrowLabelLarge) + .tracking(DesignTokens.Typography.eyebrowTrackingLoose) .textCase(.uppercase) .foregroundStyle(.secondary) Spacer(minLength: 0) @@ -212,10 +213,10 @@ private struct SensorReadingTile: View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xs) { Image(systemName: reading.icon) - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text(reading.label) - .font(.system(size: 11, weight: .semibold)) + .font(DesignTokens.Typography.eyebrowLabel) .tracking(0.5) .textCase(.uppercase) .lineLimit(2) @@ -225,14 +226,14 @@ private struct SensorReadingTile: View { HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xxs) { Text(reading.numericDisplayValue) - .font(.system(size: 30, weight: .semibold, design: .rounded)) + .font(DesignTokens.Typography.metricValue) .monospacedDigit() .foregroundStyle(reading.valueColor) .lineLimit(1) .minimumScaleFactor(0.55) if let unit = reading.unitDisplay { Text(unit) - .font(.system(size: 15, weight: .medium, design: .rounded)) + .font(DesignTokens.Typography.metricUnit) .foregroundStyle(.secondary) .lineLimit(1) } diff --git a/Shellbee/Shared/SwitchControl/SwitchControlCard.swift b/Shellbee/Shared/SwitchControl/SwitchControlCard.swift index 3c5dd55..b45ae5c 100644 --- a/Shellbee/Shared/SwitchControl/SwitchControlCard.swift +++ b/Shellbee/Shared/SwitchControl/SwitchControlCard.swift @@ -58,10 +58,10 @@ struct SwitchControlCard: View { private var heroEyebrow: some View { HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: context.isOn ? "power.circle.fill" : "power.circle") - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text(eyebrowLabel) - .font(.system(size: 11, weight: .semibold)) + .font(DesignTokens.Typography.eyebrowLabel) .tracking(0.5) .textCase(.uppercase) .lineLimit(1) @@ -76,7 +76,7 @@ struct SwitchControlCard: View { private var heroValue: some View { Text(context.isOn ? "On" : "Off") - .font(.system(size: 48, weight: .bold, design: .rounded)) + .font(DesignTokens.Typography.heroStateText) .foregroundStyle(heroTint) } @@ -181,10 +181,10 @@ private struct MeteringTile: View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xs) { Image(systemName: icon) - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text(label) - .font(.system(size: 11, weight: .semibold)) + .font(DesignTokens.Typography.eyebrowLabel) .tracking(0.5) .textCase(.uppercase) .lineLimit(2) @@ -194,13 +194,13 @@ private struct MeteringTile: View { HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xxs) { Text(value) - .font(.system(size: 30, weight: .semibold, design: .rounded)) + .font(DesignTokens.Typography.metricValue) .monospacedDigit() .foregroundStyle(.primary) .lineLimit(1) .minimumScaleFactor(0.55) Text(unit) - .font(.system(size: 15, weight: .medium, design: .rounded)) + .font(DesignTokens.Typography.metricUnit) .foregroundStyle(.secondary) .lineLimit(1) } From 187efa73e8cec7055f3f228335d4763b9005dacf Mon Sep 17 00:00:00 2001 From: tashda Date: Wed, 29 Apr 2026 08:46:37 +0200 Subject: [PATCH 16/18] Sweep remaining literals: tracking, opacity, durations, scale factors, strokes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 3 of #36. After this commit, grep for hardcoded SwiftUI numeric literals across font sizes, opacity, tracking, animation duration, Task.sleep, minimumScaleFactor, Spacer minLength, lineWidth, radius, and scaleEffect — anywhere outside DesignTokens.swift — returns zero matches (modulo deliberate semantic zeros: spacing: 0, opacity(0), opacity(1), Spacer(minLength: 0)). What got swept: - 19 × tracking(0.5) → Typography.eyebrowTracking. The 0.6 variants in the size-12 outlier headers were already tokenized in stage 2. - ~40 × .opacity(N) literals → mapped to existing or newly named Opacity tokens (hairline 0.08, subtleFade 0.04, onStateTint 0.18, offStateTint 0.06, banner 0.9, lightOpaque 0.10, mildOpaque 0.22, pressedAlpha 0.25, secondaryDim 0.75, secondaryFull 0.7, dimmedSurface 0.30, outOfRange 0.35, veryLight 0.05, veryFaint 0.03, actionButtonFill 0.15, mediumAccentFill 0.14, strongAccentFill 0.20). - 14 × duration: literal → Duration.quickFade (0.15), fastFade (0.2), mediumAnimation (0.25), slowAnimation (0.6), pulseExpand (0.8), pulseFull (1.0). The duration: 0 case in HomeView is a function argument (permit-join "stop"), not an animation duration. - 2 × Task.sleep(for: .seconds(N)) → Duration.checkResultDisplay (3s), Duration.discoveryScanWindow (15s). - 27 × minimumScaleFactor(N) → Typography.scaleFactor* (Aggressive 0.45, Tight 0.55, Medium 0.6, AggressiveLight 0.65, Relaxed 0.7, MildLight 0.72, Mild 0.75, Subtle 0.82). Per-text shrink budgets are unavoidably per-element design choices, but they're still tokens now. - 1 × Spacer(minLength: 8) → Spacing.sm. The 16 minLength: 0 instances stay literal — they're the deliberate "no minimum spacing" semantic. - 4 × lineWidth: 8/2 → Size.permitJoinRingStroke, lightSelectionStroke. - 1 × radius: 18 + 1 × y: 8 → Shadow.splashRadius / splashY (used exclusively in SplashScreenView). - 1 × tracking(-1) → new Tracking enum, splashTitle (-1pt). Refs #36 Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee.xcodeproj/project.pbxproj | 2 +- Shellbee/App/RootView.swift | 2 +- Shellbee/App/SplashScreenView.swift | 7 ++++--- Shellbee/Core/Store/AppStore+OTA.swift | 2 +- .../Features/Bridge/TouchlinkGuideView.swift | 4 ++-- .../Connection/ConnectionViewModel.swift | 2 +- .../Devices/DeviceUpgradeBadgeView.swift | 2 +- Shellbee/Features/Groups/GroupCard.swift | 12 ++++++------ .../Features/Groups/GroupCardFooterBar.swift | 2 +- Shellbee/Features/Groups/GroupListRow.swift | 2 +- .../Features/Home/HomeBackgroundGradient.swift | 6 +++--- Shellbee/Features/Home/HomeLogsCard.swift | 2 +- Shellbee/Features/Home/HomeView.swift | 10 +++++----- .../Features/Home/PermitJoinActiveSheet.swift | 7 ++++--- Shellbee/Features/Logs/LogRowView.swift | 2 +- .../InAppNotificationOverlay.swift | 2 +- .../Settings/Developer/MQTTInspectorView.swift | 6 +++--- .../ClimateControl/ClimateControlCard.swift | 18 +++++++++--------- Shellbee/Shared/Components/DeviceCard.swift | 16 ++++++++-------- .../Components/DeviceCardFooterBar.swift | 4 ++-- .../Shared/Components/DeviceCardHeader.swift | 2 +- .../Shared/Components/DeviceMetricItem.swift | 2 +- .../Shared/Components/Doc/DocBlockView.swift | 2 +- .../Shared/Components/Doc/DocHeroCard.swift | 14 +++++++------- .../Shared/Components/Doc/DocNoteView.swift | 2 +- .../Components/Doc/DocStepListView.swift | 2 +- .../Doc/DocumentationExperienceView.swift | 2 +- .../Doc/PairingGuideExperienceView.swift | 10 +++++----- Shellbee/Shared/Components/FilterChip.swift | 4 ++-- Shellbee/Shared/Components/StatusChip.swift | 2 +- .../Shared/CoverControl/CoverControlCard.swift | 12 ++++++------ Shellbee/Shared/DesignTokens.swift | 13 ++++++++++++- .../Shared/FanControl/FanControlCard.swift | 18 +++++++++--------- .../LightControl/LightColorControl.swift | 2 +- .../Shared/LightControl/LightControlCard.swift | 14 +++++++------- .../LightControl/LightTemperatureControl.swift | 5 +++-- .../Shared/LockControl/LockControlCard.swift | 8 ++++---- Shellbee/Shared/RemoteCard/RemoteCard.swift | 4 ++-- Shellbee/Shared/SensorCard/SensorCard.swift | 4 ++-- .../SwitchControl/SwitchControlCard.swift | 10 +++++----- 40 files changed, 128 insertions(+), 114 deletions(-) diff --git a/Shellbee.xcodeproj/project.pbxproj b/Shellbee.xcodeproj/project.pbxproj index 494cbce..e48ef4a 100644 --- a/Shellbee.xcodeproj/project.pbxproj +++ b/Shellbee.xcodeproj/project.pbxproj @@ -113,8 +113,8 @@ Core/Networking/Z2MTopics.swift, Core/Networking/Z2MWebSocketClient.swift, Core/Networking/Z2MWebSocketSessionDelegate.swift, - "Core/Parsing/DeviceDocNormalizer+Pairing.swift", Core/Parsing/DeviceDocNormalizer.swift, + "Core/Parsing/DeviceDocNormalizer+Pairing.swift", Core/Parsing/DeviceDocumentationModels.swift, Core/Parsing/DocParser.swift, Core/Parsing/FrontendReferenceRewriter.swift, diff --git a/Shellbee/App/RootView.swift b/Shellbee/App/RootView.swift index 0e976bc..8ae3a70 100644 --- a/Shellbee/App/RootView.swift +++ b/Shellbee/App/RootView.swift @@ -17,7 +17,7 @@ struct RootView: View { setupInterface } } - .animation(.spring(duration: 0.6), value: isInitializing) + .animation(.spring(duration: DesignTokens.Duration.slowAnimation), value: isInitializing) .sheet(item: $pendingCrash) { crash in PendingCrashSheet( crash: crash, diff --git a/Shellbee/App/SplashScreenView.swift b/Shellbee/App/SplashScreenView.swift index fb1fccf..8178970 100644 --- a/Shellbee/App/SplashScreenView.swift +++ b/Shellbee/App/SplashScreenView.swift @@ -21,13 +21,14 @@ struct SplashScreenView: View { .scaledToFit() .frame(width: DesignTokens.Size.splashIconLarge, height: DesignTokens.Size.splashIconLarge) .clipShape(RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.xl, style: .continuous)) - .shadow(color: .black.opacity(0.12), radius: 18, y: 8) + .shadow(color: .black.opacity(DesignTokens.Opacity.chipFill), + radius: DesignTokens.Shadow.splashRadius, y: DesignTokens.Shadow.splashY) .scaleEffect(isVisible ? 1 : 0.8) .opacity(isVisible ? 1 : 0) Text("Shellbee") .font(.system(size: DesignTokens.Size.splashTitle, weight: .bold, design: .rounded)) - .tracking(-1) + .tracking(DesignTokens.Tracking.splashTitle) .opacity(isVisible ? 1 : 0) .offset(y: isVisible ? 0 : 10) @@ -45,7 +46,7 @@ struct SplashScreenView: View { } } .onAppear { - withAnimation(.easeOut(duration: 0.8)) { + withAnimation(.easeOut(duration: DesignTokens.Duration.pulseExpand)) { isVisible = true } } diff --git a/Shellbee/Core/Store/AppStore+OTA.swift b/Shellbee/Core/Store/AppStore+OTA.swift index 7922e47..ef76445 100644 --- a/Shellbee/Core/Store/AppStore+OTA.swift +++ b/Shellbee/Core/Store/AppStore+OTA.swift @@ -128,7 +128,7 @@ extension AppStore { func flashCheckResult(_ result: DeviceCheckResult, for deviceName: String) { deviceCheckResults[deviceName] = result Task { [weak self] in - try? await Task.sleep(for: .seconds(3)) + try? await Task.sleep(for: .seconds(DesignTokens.Duration.checkResultDisplay)) await MainActor.run { guard let self else { return } if self.deviceCheckResults[deviceName] == result { diff --git a/Shellbee/Features/Bridge/TouchlinkGuideView.swift b/Shellbee/Features/Bridge/TouchlinkGuideView.swift index b06a45c..c893ecc 100644 --- a/Shellbee/Features/Bridge/TouchlinkGuideView.swift +++ b/Shellbee/Features/Bridge/TouchlinkGuideView.swift @@ -91,8 +91,8 @@ struct TouchlinkGuideView: View { .background( LinearGradient( colors: [ - Color.teal.opacity(0.18), - Color.cyan.opacity(0.10), + Color.teal.opacity(DesignTokens.Opacity.onStateTint), + Color.cyan.opacity(DesignTokens.Opacity.lightOpaque), Color(.secondarySystemGroupedBackground) ], startPoint: .topLeading, diff --git a/Shellbee/Features/Connection/ConnectionViewModel.swift b/Shellbee/Features/Connection/ConnectionViewModel.swift index 963be0d..fb5dc3b 100644 --- a/Shellbee/Features/Connection/ConnectionViewModel.swift +++ b/Shellbee/Features/Connection/ConnectionViewModel.swift @@ -70,7 +70,7 @@ final class ConnectionViewModel { func startDiscovery() { Task { @MainActor in environment.discovery.start() - try? await Task.sleep(for: .seconds(15)) + try? await Task.sleep(for: .seconds(DesignTokens.Duration.discoveryScanWindow)) environment.discovery.stop() } } diff --git a/Shellbee/Features/Devices/DeviceUpgradeBadgeView.swift b/Shellbee/Features/Devices/DeviceUpgradeBadgeView.swift index 40aa5b1..1e4e43c 100644 --- a/Shellbee/Features/Devices/DeviceUpgradeBadgeView.swift +++ b/Shellbee/Features/Devices/DeviceUpgradeBadgeView.swift @@ -93,7 +93,7 @@ struct DeviceUpgradeBadgeView: View { .phaseAnimator([0, 360]) { content, phase in content.rotationEffect(.degrees(phase)) } animation: { _ in - .linear(duration: 1.0).repeatForever(autoreverses: false) + .linear(duration: DesignTokens.Duration.pulseFull).repeatForever(autoreverses: false) } } diff --git a/Shellbee/Features/Groups/GroupCard.swift b/Shellbee/Features/Groups/GroupCard.swift index 0a0f8f1..6fc3344 100644 --- a/Shellbee/Features/Groups/GroupCard.swift +++ b/Shellbee/Features/Groups/GroupCard.swift @@ -40,7 +40,7 @@ struct GroupCard: View { .font(DesignTokens.Typography.compactCardTitle) .foregroundStyle(.primary) .lineLimit(1) - .minimumScaleFactor(0.72) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorMildLight) Text("Group #\(group.id) · \(group.members.count) members") .font(.subheadline) @@ -88,7 +88,7 @@ struct GroupCard: View { .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(2) - .minimumScaleFactor(0.82) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorSubtle) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -119,7 +119,7 @@ struct GroupCard: View { .symbolRenderingMode(.hierarchical) Text(label) .font(DesignTokens.Typography.eyebrowLabel) - .tracking(0.5) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) .lineLimit(1) } @@ -131,7 +131,7 @@ struct GroupCard: View { .monospacedDigit() .foregroundStyle(color) .lineLimit(1) - .minimumScaleFactor(0.55) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorTight) if let unit { Text(unit) .font(DesignTokens.Typography.identityUnit) @@ -158,7 +158,7 @@ struct GroupCard: View { .font(DesignTokens.Typography.cardTitle) .foregroundStyle(.primary) .lineLimit(1) - .minimumScaleFactor(0.45) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorAggressive) .allowsTightening(true) if let onRenameTapped { @@ -175,7 +175,7 @@ struct GroupCard: View { private var hairline: some View { Rectangle() - .fill(Color.primary.opacity(0.08)) + .fill(Color.primary.opacity(DesignTokens.Opacity.hairline)) .frame(height: DesignTokens.Size.hairline) } diff --git a/Shellbee/Features/Groups/GroupCardFooterBar.swift b/Shellbee/Features/Groups/GroupCardFooterBar.swift index 22be303..417b634 100644 --- a/Shellbee/Features/Groups/GroupCardFooterBar.swift +++ b/Shellbee/Features/Groups/GroupCardFooterBar.swift @@ -20,7 +20,7 @@ struct GroupCardFooterBar: View { .font(DesignTokens.Typography.footerActionLabel) .foregroundStyle(color) .lineLimit(1) - .minimumScaleFactor(0.75) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorMild) Text(label) .font(.caption2) .foregroundStyle(.tertiary) diff --git a/Shellbee/Features/Groups/GroupListRow.swift b/Shellbee/Features/Groups/GroupListRow.swift index 82ed7b7..4f5f67b 100644 --- a/Shellbee/Features/Groups/GroupListRow.swift +++ b/Shellbee/Features/Groups/GroupListRow.swift @@ -38,7 +38,7 @@ struct GroupListRow: View { Text(title) .font(.caption2) .lineLimit(1) - .minimumScaleFactor(0.65) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorAggressiveLight) } .frame(minWidth: DesignTokens.Size.deviceActionSheetImage) } diff --git a/Shellbee/Features/Home/HomeBackgroundGradient.swift b/Shellbee/Features/Home/HomeBackgroundGradient.swift index a19dcf1..7d0dac2 100644 --- a/Shellbee/Features/Home/HomeBackgroundGradient.swift +++ b/Shellbee/Features/Home/HomeBackgroundGradient.swift @@ -19,7 +19,7 @@ struct HomeBackgroundGradient: View { stops: [ .init(color: .black, location: 0.0), .init(color: .black, location: 0.75), - .init(color: .black.opacity(0.75), location: 1.0) + .init(color: .black.opacity(DesignTokens.Opacity.secondaryDim), location: 1.0) ], startPoint: .top, endPoint: .bottom @@ -44,7 +44,7 @@ struct HomeBackgroundGradient: View { ) .overlay( LinearGradient( - colors: [.white.opacity(0.30), .clear], + colors: [.white.opacity(DesignTokens.Opacity.dimmedSurface), .clear], startPoint: .top, endPoint: .center ) @@ -69,7 +69,7 @@ struct HomeBackgroundGradient: View { ) .overlay( LinearGradient( - colors: [.black.opacity(0.25), .clear], + colors: [.black.opacity(DesignTokens.Opacity.pressedAlpha), .clear], startPoint: .top, endPoint: .center ) diff --git a/Shellbee/Features/Home/HomeLogsCard.swift b/Shellbee/Features/Home/HomeLogsCard.swift index 1130a35..a54a2df 100644 --- a/Shellbee/Features/Home/HomeLogsCard.swift +++ b/Shellbee/Features/Home/HomeLogsCard.swift @@ -75,7 +75,7 @@ struct HomeLogRow: View { private var badge: some View { Circle() - .fill(entry.level.color.opacity(0.18)) + .fill(entry.level.color.opacity(DesignTokens.Opacity.onStateTint)) .frame(width: Self.badgeSize, height: Self.badgeSize) .overlay { Image(systemName: entry.category.systemImage) diff --git a/Shellbee/Features/Home/HomeView.swift b/Shellbee/Features/Home/HomeView.swift index aaddc86..109cd58 100644 --- a/Shellbee/Features/Home/HomeView.swift +++ b/Shellbee/Features/Home/HomeView.swift @@ -45,12 +45,12 @@ struct HomeView: View { card: id, isEditing: layout.isEditing, onHide: { - withAnimation(.easeInOut(duration: 0.25)) { + withAnimation(.easeInOut(duration: DesignTokens.Duration.mediumAnimation)) { layout.hide(id) } }, onEnterEdit: { - withAnimation(.easeInOut(duration: 0.25)) { + withAnimation(.easeInOut(duration: DesignTokens.Duration.mediumAnimation)) { layout.isEditing = true } } @@ -76,7 +76,7 @@ struct HomeView: View { HomeAddCardsSection( hidden: HomeCardID.allCases.filter { layout.hidden.contains($0) } ) { card in - withAnimation(.easeInOut(duration: 0.25)) { + withAnimation(.easeInOut(duration: DesignTokens.Duration.mediumAnimation)) { layout.show(card) } } @@ -117,7 +117,7 @@ struct HomeView: View { if layout.isEditing { ToolbarItem(placement: .topBarTrailing) { Button("Done") { - withAnimation(.easeInOut(duration: 0.25)) { + withAnimation(.easeInOut(duration: DesignTokens.Duration.mediumAnimation)) { layout.isEditing = false } } @@ -211,7 +211,7 @@ struct HomeView: View { .font(.subheadline) .foregroundStyle(.secondary) Button("Edit Home") { - withAnimation(.easeInOut(duration: 0.25)) { + withAnimation(.easeInOut(duration: DesignTokens.Duration.mediumAnimation)) { layout.isEditing = true } } diff --git a/Shellbee/Features/Home/PermitJoinActiveSheet.swift b/Shellbee/Features/Home/PermitJoinActiveSheet.swift index 2862e66..d73046a 100644 --- a/Shellbee/Features/Home/PermitJoinActiveSheet.swift +++ b/Shellbee/Features/Home/PermitJoinActiveSheet.swift @@ -35,13 +35,14 @@ struct PermitJoinActiveSheet: View { private func countdownRing(remaining: Int?) -> some View { ZStack { Circle() - .stroke(Color.green.opacity(0.15), lineWidth: 8) + .stroke(Color.green.opacity(DesignTokens.Opacity.actionButtonFill), + lineWidth: DesignTokens.Size.permitJoinRingStroke) Circle() .trim(from: 0, to: progress(remaining: remaining)) - .stroke(Color.green, style: StrokeStyle(lineWidth: 8, lineCap: .round)) + .stroke(Color.green, style: StrokeStyle(lineWidth: DesignTokens.Size.permitJoinRingStroke, lineCap: .round)) .rotationEffect(.degrees(-90)) - .animation(.linear(duration: 1), value: remaining) + .animation(.linear(duration: DesignTokens.Duration.pulseFull), value: remaining) if let remaining { Text(String(format: "%d:%02d", remaining / 60, remaining % 60)) diff --git a/Shellbee/Features/Logs/LogRowView.swift b/Shellbee/Features/Logs/LogRowView.swift index 810f9fe..bf02e53 100644 --- a/Shellbee/Features/Logs/LogRowView.swift +++ b/Shellbee/Features/Logs/LogRowView.swift @@ -54,7 +54,7 @@ struct LogRowView: View { } private var iconForeground: Color { - entry.level == .warning ? Color.black.opacity(0.75) : Color.white + entry.level == .warning ? Color.black.opacity(DesignTokens.Opacity.secondaryDim) : Color.white } private func deviceThumbnail(_ device: Device, size: CGFloat) -> some View { diff --git a/Shellbee/Features/Notifications/InAppNotificationOverlay.swift b/Shellbee/Features/Notifications/InAppNotificationOverlay.swift index 65d3374..267f7c5 100644 --- a/Shellbee/Features/Notifications/InAppNotificationOverlay.swift +++ b/Shellbee/Features/Notifications/InAppNotificationOverlay.swift @@ -91,7 +91,7 @@ struct InAppNotificationOverlay: View { } .animation(.spring(duration: DesignTokens.Duration.standardAnimation), value: stack.isEmpty) .animation(Self.carouselAnimation, value: displayedPage.map { bannerIdentity(for: $0) }) - .animation(.spring(duration: 0.25), value: fastTrackVisible) + .animation(.spring(duration: DesignTokens.Duration.mediumAnimation), value: fastTrackVisible) .onChange(of: environment.store.notificationArrivalID) { _, newID in // New (non-coalesced) normal notification arrived. Haptic once, // and schedule auto-dismiss on the now-visible banner. diff --git a/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift b/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift index 97d4060..25f98f2 100644 --- a/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift +++ b/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift @@ -100,7 +100,7 @@ private struct SubscribeView: View { .foregroundStyle(.white) .padding(.horizontal, DesignTokens.Spacing.md) .padding(.vertical, DesignTokens.Spacing.sm) - .background(.orange.opacity(0.9), in: Capsule()) + .background(.orange.opacity(DesignTokens.Opacity.banner), in: Capsule()) .padding(.bottom, DesignTokens.Spacing.md) } } @@ -134,7 +134,7 @@ private struct MessageRow: View { .lineLimit(2) .truncationMode(.middle) .textSelection(.enabled) - Spacer(minLength: 8) + Spacer(minLength: DesignTokens.Spacing.sm) Text(message.timestamp, format: .dateTime.hour().minute().second()) .font(.caption2.monospacedDigit()) .foregroundStyle(.tertiary) @@ -151,7 +151,7 @@ private struct MessageRow: View { .padding(.leading, DesignTokens.Size.cardSymbol) if message.prettyPayload.components(separatedBy: "\n").count > 6 { Button { - withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() } + withAnimation(.easeInOut(duration: DesignTokens.Duration.quickFade)) { expanded.toggle() } } label: { Text(expanded ? "Show less" : "Show more") .font(.caption.weight(.medium)) diff --git a/Shellbee/Shared/ClimateControl/ClimateControlCard.swift b/Shellbee/Shared/ClimateControl/ClimateControlCard.swift index dc90371..36bbc30 100644 --- a/Shellbee/Shared/ClimateControl/ClimateControlCard.swift +++ b/Shellbee/Shared/ClimateControl/ClimateControlCard.swift @@ -62,7 +62,7 @@ struct ClimateControlCard: View { Color(.secondarySystemGroupedBackground) LinearGradient( colors: [heroTint.opacity(isActive ? 0.18 : 0.06), - heroTint.opacity(0.04)], + heroTint.opacity(DesignTokens.Opacity.subtleFade)], startPoint: .topLeading, endPoint: .bottomTrailing ) @@ -88,7 +88,7 @@ struct ClimateControlCard: View { .symbolRenderingMode(.hierarchical) Text(context.runningStateLabel) .font(DesignTokens.Typography.eyebrowLabel) - .tracking(0.5) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) .lineLimit(1) } @@ -112,7 +112,7 @@ struct ClimateControlCard: View { .monospacedDigit() .foregroundStyle(.primary) .lineLimit(1) - .minimumScaleFactor(0.6) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorMedium) // In snapshot mode (and when no interactive setpoint control is // shown), surface the target inside the hero block — otherwise the // setpoint row below already carries it. @@ -121,7 +121,7 @@ struct ClimateControlCard: View { .font(DesignTokens.Typography.heroSubtitle) .foregroundStyle(heroTint) .lineLimit(1) - .minimumScaleFactor(0.7) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorRelaxed) } } } @@ -132,7 +132,7 @@ struct ClimateControlCard: View { private var hairline: some View { Rectangle() - .fill(Color.primary.opacity(0.08)) + .fill(Color.primary.opacity(DesignTokens.Opacity.hairline)) .frame(height: DesignTokens.Size.hairline) } @@ -152,7 +152,7 @@ struct ClimateControlCard: View { .symbolRenderingMode(.hierarchical) Text("Target") .font(DesignTokens.Typography.eyebrowLabel) - .tracking(0.5) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) } .foregroundStyle(.secondary) @@ -191,7 +191,7 @@ struct ClimateControlCard: View { .font(DesignTokens.Typography.climateActionIcon) .foregroundStyle(heroTint) .frame(width: DesignTokens.Size.climateActionButton, height: DesignTokens.Size.climateActionButton) - .background(heroTint.opacity(0.15), in: Circle()) + .background(heroTint.opacity(DesignTokens.Opacity.actionButtonFill), in: Circle()) } .buttonStyle(.plain) } @@ -207,7 +207,7 @@ struct ClimateControlCard: View { .symbolRenderingMode(.hierarchical) Text("Mode") .font(DesignTokens.Typography.eyebrowLabel) - .tracking(0.5) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) } .foregroundStyle(.secondary) @@ -233,7 +233,7 @@ struct ClimateControlCard: View { .padding(.horizontal, DesignTokens.Spacing.md) .padding(.vertical, DesignTokens.Spacing.sm) .background( - isSelected ? chipTint.opacity(0.20) : Color(.tertiarySystemFill), + isSelected ? chipTint.opacity(DesignTokens.Opacity.strongAccentFill) : Color(.tertiarySystemFill), in: Capsule() ) .foregroundStyle(isSelected ? chipTint : Color.primary) diff --git a/Shellbee/Shared/Components/DeviceCard.swift b/Shellbee/Shared/Components/DeviceCard.swift index 99ddf88..15252ab 100644 --- a/Shellbee/Shared/Components/DeviceCard.swift +++ b/Shellbee/Shared/Components/DeviceCard.swift @@ -37,7 +37,7 @@ struct DeviceCard: View { hairline metricsGrid } - .animation(.easeInOut(duration: 0.2), value: isUpdating) + .animation(.easeInOut(duration: DesignTokens.Duration.fastFade), value: isUpdating) .padding(DesignTokens.Spacing.xl) .frame(maxWidth: .infinity, alignment: .leading) .background(Color(.secondarySystemGroupedBackground), @@ -59,7 +59,7 @@ struct DeviceCard: View { .font(DesignTokens.Typography.compactCardTitle) .foregroundStyle(.primary) .lineLimit(1) - .minimumScaleFactor(0.72) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorMildLight) Text("\(vendor) · \(model)") .font(.subheadline) @@ -144,7 +144,7 @@ struct DeviceCard: View { .symbolRenderingMode(.hierarchical) Text(label) .font(DesignTokens.Typography.eyebrowLabel) - .tracking(0.5) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) .lineLimit(1) } @@ -156,7 +156,7 @@ struct DeviceCard: View { .monospacedDigit() .foregroundStyle(color) .lineLimit(1) - .minimumScaleFactor(0.55) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorTight) if let unit { Text(unit) .font(DesignTokens.Typography.identityUnit) @@ -186,7 +186,7 @@ struct DeviceCard: View { .font(DesignTokens.Typography.cardTitle) .foregroundStyle(.primary) .lineLimit(1) - .minimumScaleFactor(0.45) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorAggressive) .allowsTightening(true) if let onRenameTapped { @@ -207,13 +207,13 @@ struct DeviceCard: View { .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(1) - .minimumScaleFactor(0.82) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorSubtle) Text(model) .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(1) - .minimumScaleFactor(0.72) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorMildLight) } } @@ -228,7 +228,7 @@ struct DeviceCard: View { private var hairline: some View { Rectangle() - .fill(Color.primary.opacity(0.08)) + .fill(Color.primary.opacity(DesignTokens.Opacity.hairline)) .frame(height: DesignTokens.Size.hairline) } diff --git a/Shellbee/Shared/Components/DeviceCardFooterBar.swift b/Shellbee/Shared/Components/DeviceCardFooterBar.swift index 3d1ef99..2dad644 100644 --- a/Shellbee/Shared/Components/DeviceCardFooterBar.swift +++ b/Shellbee/Shared/Components/DeviceCardFooterBar.swift @@ -23,7 +23,7 @@ struct DeviceCardFooterBar: View { .font(DesignTokens.Typography.footerActionLabel) .foregroundStyle(color) .lineLimit(1) - .minimumScaleFactor(0.75) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorMild) Text(label) .font(.caption2) .foregroundStyle(.tertiary) @@ -52,7 +52,7 @@ struct DeviceCardFooterBar: View { .font(DesignTokens.Typography.footerActionLabel) .foregroundStyle(Color.secondary) .lineLimit(1) - .minimumScaleFactor(0.75) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorMild) } } diff --git a/Shellbee/Shared/Components/DeviceCardHeader.swift b/Shellbee/Shared/Components/DeviceCardHeader.swift index 00c1422..04b32d4 100644 --- a/Shellbee/Shared/Components/DeviceCardHeader.swift +++ b/Shellbee/Shared/Components/DeviceCardHeader.swift @@ -52,7 +52,7 @@ struct DeviceCardHeader: View { .font(DesignTokens.Typography.cardHeadline) .foregroundStyle(.primary) .lineLimit(1) - .minimumScaleFactor(0.6) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorMedium) .allowsTightening(true) if let onRenameTapped { diff --git a/Shellbee/Shared/Components/DeviceMetricItem.swift b/Shellbee/Shared/Components/DeviceMetricItem.swift index a9c4b0c..bb26720 100644 --- a/Shellbee/Shared/Components/DeviceMetricItem.swift +++ b/Shellbee/Shared/Components/DeviceMetricItem.swift @@ -16,7 +16,7 @@ struct DeviceMetricItem: View { .font(.subheadline.bold()) .monospacedDigit() .lineLimit(1) - .minimumScaleFactor(0.7) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorRelaxed) .frame(height: DesignTokens.Size.deviceCardMetricValueHeight) Text(label) diff --git a/Shellbee/Shared/Components/Doc/DocBlockView.swift b/Shellbee/Shared/Components/Doc/DocBlockView.swift index b5fcc4f..7160f6f 100644 --- a/Shellbee/Shared/Components/Doc/DocBlockView.swift +++ b/Shellbee/Shared/Components/Doc/DocBlockView.swift @@ -105,7 +105,7 @@ private struct DocTableView: View { .padding(.vertical, DesignTokens.Spacing.xs) } } - .background(idx % 2 == 0 ? Color.clear : .secondary.opacity(0.05)) + .background(idx % 2 == 0 ? Color.clear : .secondary.opacity(DesignTokens.Opacity.veryLight)) } } .clipShape(RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.sm)) diff --git a/Shellbee/Shared/Components/Doc/DocHeroCard.swift b/Shellbee/Shared/Components/Doc/DocHeroCard.swift index 4287912..015eddf 100644 --- a/Shellbee/Shared/Components/Doc/DocHeroCard.swift +++ b/Shellbee/Shared/Components/Doc/DocHeroCard.swift @@ -35,7 +35,7 @@ struct DocHeroCard: View { HStack(alignment: .top, spacing: DesignTokens.Spacing.lg) { DeviceImageView(device: device, isAvailable: true, size: DesignTokens.Size.deviceActionSheetImage * 1.4) .frame(width: DesignTokens.Size.deviceActionSheetImage * 1.5, height: DesignTokens.Size.deviceActionSheetImage * 1.5) - .shadow(color: Color.black.opacity(0.08), radius: 8, x: 0, y: 4) + .shadow(color: Color.black.opacity(DesignTokens.Opacity.hairline), radius: 8, x: 0, y: 4) VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { if let eyebrow, !eyebrow.isEmpty { @@ -69,17 +69,17 @@ struct DocHeroCard: View { static var defaultGradient: [Color] { [ - Color.accentColor.opacity(0.22), - Color.blue.opacity(0.14), - Color.blue.opacity(0.06) + Color.accentColor.opacity(DesignTokens.Opacity.mildOpaque), + Color.blue.opacity(DesignTokens.Opacity.mediumAccentFill), + Color.blue.opacity(DesignTokens.Opacity.offStateTint) ] } static var pairingGradient: [Color] { [ - Color.blue.opacity(0.22), - Color.cyan.opacity(0.14), - Color.cyan.opacity(0.06) + Color.blue.opacity(DesignTokens.Opacity.mildOpaque), + Color.cyan.opacity(DesignTokens.Opacity.mediumAccentFill), + Color.cyan.opacity(DesignTokens.Opacity.offStateTint) ] } } diff --git a/Shellbee/Shared/Components/Doc/DocNoteView.swift b/Shellbee/Shared/Components/Doc/DocNoteView.swift index 2106f50..2f81208 100644 --- a/Shellbee/Shared/Components/Doc/DocNoteView.swift +++ b/Shellbee/Shared/Components/Doc/DocNoteView.swift @@ -26,7 +26,7 @@ struct DocNoteView: View { .padding(.horizontal, DesignTokens.Spacing.sm) .padding(.vertical, DesignTokens.Spacing.sm) .frame(maxWidth: .infinity, alignment: .leading) - .background(.tint.opacity(0.06), in: RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.sm)) + .background(.tint.opacity(DesignTokens.Opacity.offStateTint), in: RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.sm)) } } diff --git a/Shellbee/Shared/Components/Doc/DocStepListView.swift b/Shellbee/Shared/Components/Doc/DocStepListView.swift index e42d9d6..ee9a089 100644 --- a/Shellbee/Shared/Components/Doc/DocStepListView.swift +++ b/Shellbee/Shared/Components/Doc/DocStepListView.swift @@ -43,7 +43,7 @@ private struct DocStepRow: View { } if showConnector { Rectangle() - .fill(.tint.opacity(0.25)) + .fill(.tint.opacity(DesignTokens.Opacity.pressedAlpha)) .frame(width: DesignTokens.Size.docStepConnector) .frame(maxHeight: .infinity) } diff --git a/Shellbee/Shared/Components/Doc/DocumentationExperienceView.swift b/Shellbee/Shared/Components/Doc/DocumentationExperienceView.swift index 2567920..cf66015 100644 --- a/Shellbee/Shared/Components/Doc/DocumentationExperienceView.swift +++ b/Shellbee/Shared/Components/Doc/DocumentationExperienceView.swift @@ -439,7 +439,7 @@ private struct DocumentationCalloutView: View { .foregroundStyle(.secondary) } .padding(DesignTokens.Spacing.md) - .background(tint.opacity(0.08), in: RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.md)) + .background(tint.opacity(DesignTokens.Opacity.hairline), in: RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.md)) } } diff --git a/Shellbee/Shared/Components/Doc/PairingGuideExperienceView.swift b/Shellbee/Shared/Components/Doc/PairingGuideExperienceView.swift index 41d52fb..3ef3669 100644 --- a/Shellbee/Shared/Components/Doc/PairingGuideExperienceView.swift +++ b/Shellbee/Shared/Components/Doc/PairingGuideExperienceView.swift @@ -158,10 +158,10 @@ private struct PhilipsHueSerialResetCard: View { .buttonStyle(.plain) .frame(maxWidth: .infinity, alignment: .leading) .padding(DesignTokens.Spacing.lg) - .background(Color.blue.opacity(0.08), in: RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.lg, style: .continuous)) + .background(Color.blue.opacity(DesignTokens.Opacity.hairline), in: RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.lg, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.lg, style: .continuous) - .strokeBorder(Color.blue.opacity(0.2)) + .strokeBorder(Color.blue.opacity(DesignTokens.Opacity.accentFill)) ) .sheet(isPresented: $showResetSheet) { PhilipsHueResetSheet( @@ -214,10 +214,10 @@ private struct TouchlinkResetCard: View { .buttonStyle(.plain) .frame(maxWidth: .infinity, alignment: .leading) .padding(DesignTokens.Spacing.lg) - .background(Color.teal.opacity(0.08), in: RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.lg, style: .continuous)) + .background(Color.teal.opacity(DesignTokens.Opacity.hairline), in: RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.lg, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.lg, style: .continuous) - .strokeBorder(Color.teal.opacity(0.2)) + .strokeBorder(Color.teal.opacity(DesignTokens.Opacity.accentFill)) ) } } @@ -301,7 +301,7 @@ private struct PairingCalloutView: View { .font(.subheadline) } .padding(DesignTokens.Spacing.md) - .background(tint.opacity(0.08), in: RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.md)) + .background(tint.opacity(DesignTokens.Opacity.hairline), in: RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.md)) } } diff --git a/Shellbee/Shared/Components/FilterChip.swift b/Shellbee/Shared/Components/FilterChip.swift index c972414..d9c34d5 100644 --- a/Shellbee/Shared/Components/FilterChip.swift +++ b/Shellbee/Shared/Components/FilterChip.swift @@ -12,11 +12,11 @@ struct FilterChip: View { .font(.subheadline.weight(isSelected ? .semibold : .regular)) .padding(.horizontal, DesignTokens.Spacing.md) .padding(.vertical, DesignTokens.Spacing.xs) - .background(isSelected ? Color.accentColor : Color.secondary.opacity(0.12), in: Capsule()) + .background(isSelected ? Color.accentColor : Color.secondary.opacity(DesignTokens.Opacity.chipFill), in: Capsule()) .foregroundStyle(isSelected ? .white : .primary) } .buttonStyle(.plain) - .animation(.easeInOut(duration: 0.15), value: isSelected) + .animation(.easeInOut(duration: DesignTokens.Duration.quickFade), value: isSelected) } } diff --git a/Shellbee/Shared/Components/StatusChip.swift b/Shellbee/Shared/Components/StatusChip.swift index ee10dbf..95678f4 100644 --- a/Shellbee/Shared/Components/StatusChip.swift +++ b/Shellbee/Shared/Components/StatusChip.swift @@ -31,6 +31,6 @@ struct StatusChip: View { .padding(.horizontal, DesignTokens.Spacing.sm) .padding(.vertical, DesignTokens.Size.chipVerticalPadding) .foregroundStyle(tint) - .background(tint.opacity(0.12), in: Capsule()) + .background(tint.opacity(DesignTokens.Opacity.chipFill), in: Capsule()) } } diff --git a/Shellbee/Shared/CoverControl/CoverControlCard.swift b/Shellbee/Shared/CoverControl/CoverControlCard.swift index 2fb0ca4..11d36c7 100644 --- a/Shellbee/Shared/CoverControl/CoverControlCard.swift +++ b/Shellbee/Shared/CoverControl/CoverControlCard.swift @@ -61,7 +61,7 @@ struct CoverControlCard: View { Color(.secondarySystemGroupedBackground) LinearGradient( colors: [heroTint.opacity(isFullyClosed ? 0.06 : 0.18), - heroTint.opacity(0.04)], + heroTint.opacity(DesignTokens.Opacity.subtleFade)], startPoint: .topLeading, endPoint: .bottomTrailing ) @@ -87,7 +87,7 @@ struct CoverControlCard: View { .symbolRenderingMode(.hierarchical) Text(eyebrowLabel) .font(DesignTokens.Typography.eyebrowLabel) - .tracking(0.5) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) .lineLimit(1) } @@ -109,7 +109,7 @@ struct CoverControlCard: View { .monospacedDigit() .foregroundStyle(.primary) .lineLimit(1) - .minimumScaleFactor(0.6) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorMedium) .contentTransition(.numericText(value: positionDraft)) .animation(.snappy, value: positionDraft) Text("%") @@ -120,7 +120,7 @@ struct CoverControlCard: View { .font(DesignTokens.Typography.heroSubtitle) .foregroundStyle(heroTint) .lineLimit(1) - .minimumScaleFactor(0.7) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorRelaxed) } } else { Text(context.displayState) @@ -131,7 +131,7 @@ struct CoverControlCard: View { private var hairline: some View { Rectangle() - .fill(Color.primary.opacity(0.08)) + .fill(Color.primary.opacity(DesignTokens.Opacity.hairline)) .frame(height: DesignTokens.Size.hairline) } @@ -202,7 +202,7 @@ struct CoverControlCard: View { .symbolRenderingMode(.hierarchical) Text("Tilt") .font(DesignTokens.Typography.eyebrowLabel) - .tracking(0.5) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) } .foregroundStyle(.secondary) diff --git a/Shellbee/Shared/DesignTokens.swift b/Shellbee/Shared/DesignTokens.swift index 002dcd6..9f5de17 100644 --- a/Shellbee/Shared/DesignTokens.swift +++ b/Shellbee/Shared/DesignTokens.swift @@ -110,6 +110,8 @@ nonisolated enum DesignTokens { static let permitJoinQR: CGFloat = 220 static let homeAddDividerInset: CGFloat = 60 static let docLabelColumnWidth: CGFloat = 90 + static let permitJoinRingStroke: CGFloat = 8 + static let lightSelectionStroke: CGFloat = 2 } nonisolated enum Shadow { @@ -119,10 +121,16 @@ nonisolated enum DesignTokens { static let floatingOpacity: Double = 0.18 static let floatingRadius: CGFloat = 6 static let floatingY: CGFloat = 3 + static let splashRadius: CGFloat = 18 + static let splashY: CGFloat = 8 + } + + nonisolated enum Tracking { + static let splashTitle: CGFloat = -1 } nonisolated enum Gradient { - static let progress = [Color.blue, Color.blue.opacity(0.7)] + static let progress = [Color.blue, Color.blue.opacity(DesignTokens.Opacity.secondaryFull)] static let updateAvailable = [Color.blue, Color(red: 0.2, green: 0.5, blue: 1.0)] } @@ -143,12 +151,14 @@ nonisolated enum DesignTokens { static let pressedState: Double = 0.16 // Common SwiftUI .easeInOut / .snappy / withAnimation durations static let quickFade: Double = 0.15 + static let fastFade: Double = 0.2 static let mediumAnimation: Double = 0.25 static let slowAnimation: Double = 0.6 static let pulseExpand: Double = 0.8 static let pulseFull: Double = 1.0 static let checkResultDisplay: Double = 3 static let pendingDeleteTimeout: Double = 15 + static let discoveryScanWindow: Double = 15 } nonisolated enum Typography { @@ -250,6 +260,7 @@ nonisolated enum DesignTokens { static let mildOpaque: Double = 0.22 static let pressedAlpha: Double = 0.25 static let dimmedSurface: Double = 0.30 + static let outOfRange: Double = 0.35 static let secondaryDim: Double = 0.75 static let secondaryFull: Double = 0.7 } diff --git a/Shellbee/Shared/FanControl/FanControlCard.swift b/Shellbee/Shared/FanControl/FanControlCard.swift index c94c7fb..270c699 100644 --- a/Shellbee/Shared/FanControl/FanControlCard.swift +++ b/Shellbee/Shared/FanControl/FanControlCard.swift @@ -134,7 +134,7 @@ struct FanControlCard: View { LinearGradient( colors: [ heroTint.opacity(hasAirSensors ? 0.20 : (context.isOn ? 0.18 : 0.06)), - heroTint.opacity(0.04) + heroTint.opacity(DesignTokens.Opacity.subtleFade) ], startPoint: .topLeading, endPoint: .bottomTrailing @@ -160,7 +160,7 @@ struct FanControlCard: View { .symbolRenderingMode(.hierarchical) Text(hasAirSensors ? "Air Quality" : "Fan") .font(DesignTokens.Typography.eyebrowLabel) - .tracking(0.5) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) } .foregroundStyle(heroTint) @@ -177,7 +177,7 @@ struct FanControlCard: View { .monospacedDigit() .foregroundStyle(.primary) .lineLimit(1) - .minimumScaleFactor(0.6) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorMedium) Text(pm25Unit) .font(DesignTokens.Typography.heroUnit) .foregroundStyle(.secondary) @@ -188,7 +188,7 @@ struct FanControlCard: View { .font(DesignTokens.Typography.heroSubtitle) .foregroundStyle(heroTint) .lineLimit(1) - .minimumScaleFactor(0.7) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorRelaxed) } } } else { @@ -233,7 +233,7 @@ struct FanControlCard: View { private var hairline: some View { Rectangle() - .fill(Color.primary.opacity(0.08)) + .fill(Color.primary.opacity(DesignTokens.Opacity.hairline)) .frame(height: DesignTokens.Size.hairline) } @@ -336,7 +336,7 @@ struct FanControlCard: View { .symbolRenderingMode(.hierarchical) Text("Filter") .font(DesignTokens.Typography.eyebrowLabel) - .tracking(0.5) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) } .foregroundStyle(tint) @@ -367,7 +367,7 @@ struct FanControlCard: View { ZStack { Color(.secondarySystemGroupedBackground) LinearGradient( - colors: [tint.opacity(0.10), tint.opacity(0.03)], + colors: [tint.opacity(DesignTokens.Opacity.lightOpaque), tint.opacity(DesignTokens.Opacity.veryFaint)], startPoint: .topLeading, endPoint: .bottomTrailing ) @@ -387,7 +387,7 @@ struct FanControlCard: View { .symbolRenderingMode(.hierarchical) Text(label) .font(DesignTokens.Typography.eyebrowLabel) - .tracking(0.5) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) @@ -400,7 +400,7 @@ struct FanControlCard: View { .monospacedDigit() .foregroundStyle(.primary) .lineLimit(1) - .minimumScaleFactor(0.55) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorTight) Text(parts.unit) .font(DesignTokens.Typography.metricUnit) .foregroundStyle(.secondary) diff --git a/Shellbee/Shared/LightControl/LightColorControl.swift b/Shellbee/Shared/LightControl/LightColorControl.swift index 25d7ef0..46d2893 100644 --- a/Shellbee/Shared/LightControl/LightColorControl.swift +++ b/Shellbee/Shared/LightControl/LightColorControl.swift @@ -53,7 +53,7 @@ struct LightColorControl: View { Circle() .fill(color) .frame(width: Self.swatchSize, height: Self.swatchSize) - .overlay(Circle().strokeBorder(isSelected(color) ? Color.primary : Color.clear, lineWidth: 2)) + .overlay(Circle().strokeBorder(isSelected(color) ? Color.primary : Color.clear, lineWidth: DesignTokens.Size.lightSelectionStroke)) .frame(maxWidth: .infinity) .contentShape(Circle().inset(by: -8)) } diff --git a/Shellbee/Shared/LightControl/LightControlCard.swift b/Shellbee/Shared/LightControl/LightControlCard.swift index 9f5753d..ff7ced9 100644 --- a/Shellbee/Shared/LightControl/LightControlCard.swift +++ b/Shellbee/Shared/LightControl/LightControlCard.swift @@ -73,7 +73,7 @@ struct LightControlCard: View { LinearGradient( colors: [ (context.isOn ? context.displayColor : Color(.tertiaryLabel)).opacity(context.isOn ? 0.18 : 0.06), - (context.isOn ? context.displayColor : Color(.tertiaryLabel)).opacity(0.04) + (context.isOn ? context.displayColor : Color(.tertiaryLabel)).opacity(DesignTokens.Opacity.subtleFade) ], startPoint: .topLeading, endPoint: .bottomTrailing @@ -100,7 +100,7 @@ struct LightControlCard: View { .symbolRenderingMode(.hierarchical) Text(eyebrowLabel) .font(DesignTokens.Typography.eyebrowLabel) - .tracking(0.5) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) .lineLimit(1) } @@ -176,7 +176,7 @@ struct LightControlCard: View { .symbolRenderingMode(.hierarchical) Text(eyebrowLabel) .font(DesignTokens.Typography.eyebrowLabel) - .tracking(0.5) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) .lineLimit(1) } @@ -198,7 +198,7 @@ struct LightControlCard: View { .monospacedDigit() .foregroundStyle(.primary) .lineLimit(1) - .minimumScaleFactor(0.6) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorMedium) Text("%") .font(DesignTokens.Typography.heroUnit) .foregroundStyle(.secondary) @@ -225,7 +225,7 @@ struct LightControlCard: View { private var hairline: some View { Rectangle() - .fill(Color.primary.opacity(0.08)) + .fill(Color.primary.opacity(DesignTokens.Opacity.hairline)) .frame(height: DesignTokens.Size.hairline) } @@ -251,7 +251,7 @@ struct LightControlCard: View { .symbolRenderingMode(.hierarchical) Text("Color") .font(DesignTokens.Typography.eyebrowLabel) - .tracking(0.5) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) } .foregroundStyle(.secondary) @@ -272,7 +272,7 @@ struct LightControlCard: View { .symbolRenderingMode(.hierarchical) Text(label) .font(DesignTokens.Typography.eyebrowLabel) - .tracking(0.5) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) diff --git a/Shellbee/Shared/LightControl/LightTemperatureControl.swift b/Shellbee/Shared/LightControl/LightTemperatureControl.swift index 4e6fc56..8b7b4e1 100644 --- a/Shellbee/Shared/LightControl/LightTemperatureControl.swift +++ b/Shellbee/Shared/LightControl/LightTemperatureControl.swift @@ -61,9 +61,10 @@ struct LightTemperatureControl: View { .fill(LightDisplayColor.temperatureColor(mireds: mireds)) .frame(width: DesignTokens.Size.lightControlButton, height: DesignTokens.Size.lightControlButton) .overlay(Circle().strokeBorder( - isSelected ? Color.primary : Color.clear, lineWidth: 2 + isSelected ? Color.primary : Color.clear, + lineWidth: DesignTokens.Size.lightSelectionStroke )) - .opacity(inRange ? 1 : 0.35) + .opacity(inRange ? 1 : DesignTokens.Opacity.outOfRange) Text(preset.label) .font(DesignTokens.Typography.sliderEndLabel) .foregroundStyle(.secondary) diff --git a/Shellbee/Shared/LockControl/LockControlCard.swift b/Shellbee/Shared/LockControl/LockControlCard.swift index 4565117..f41bbb4 100644 --- a/Shellbee/Shared/LockControl/LockControlCard.swift +++ b/Shellbee/Shared/LockControl/LockControlCard.swift @@ -39,7 +39,7 @@ struct LockControlCard: View { ZStack { Color(.secondarySystemGroupedBackground) LinearGradient( - colors: [heroTint.opacity(0.18), heroTint.opacity(0.04)], + colors: [heroTint.opacity(DesignTokens.Opacity.onStateTint), heroTint.opacity(DesignTokens.Opacity.subtleFade)], startPoint: .topLeading, endPoint: .bottomTrailing ) @@ -66,7 +66,7 @@ struct LockControlCard: View { .symbolRenderingMode(.hierarchical) Text("Lock") .font(DesignTokens.Typography.eyebrowLabel) - .tracking(0.5) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) } .foregroundStyle(heroTint) @@ -77,7 +77,7 @@ struct LockControlCard: View { .font(DesignTokens.Typography.heroStateText) .foregroundStyle(heroTint) .lineLimit(1) - .minimumScaleFactor(0.7) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorRelaxed) } private var statePill: some View { @@ -91,7 +91,7 @@ struct LockControlCard: View { private var hairline: some View { Rectangle() - .fill(Color.primary.opacity(0.08)) + .fill(Color.primary.opacity(DesignTokens.Opacity.hairline)) .frame(height: DesignTokens.Size.hairline) } diff --git a/Shellbee/Shared/RemoteCard/RemoteCard.swift b/Shellbee/Shared/RemoteCard/RemoteCard.swift index ecc588c..aa2f104 100644 --- a/Shellbee/Shared/RemoteCard/RemoteCard.swift +++ b/Shellbee/Shared/RemoteCard/RemoteCard.swift @@ -90,7 +90,7 @@ private struct ReadingTile: View { .symbolRenderingMode(.hierarchical) Text(label) .font(DesignTokens.Typography.eyebrowLabel) - .tracking(0.5) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) @@ -103,7 +103,7 @@ private struct ReadingTile: View { .monospacedDigit() .foregroundStyle(valueColor) .lineLimit(2) - .minimumScaleFactor(0.55) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorTight) if let unit { Text(unit) .font(DesignTokens.Typography.metricUnit) diff --git a/Shellbee/Shared/SensorCard/SensorCard.swift b/Shellbee/Shared/SensorCard/SensorCard.swift index d87b393..0e3526d 100644 --- a/Shellbee/Shared/SensorCard/SensorCard.swift +++ b/Shellbee/Shared/SensorCard/SensorCard.swift @@ -217,7 +217,7 @@ private struct SensorReadingTile: View { .symbolRenderingMode(.hierarchical) Text(reading.label) .font(DesignTokens.Typography.eyebrowLabel) - .tracking(0.5) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) @@ -230,7 +230,7 @@ private struct SensorReadingTile: View { .monospacedDigit() .foregroundStyle(reading.valueColor) .lineLimit(1) - .minimumScaleFactor(0.55) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorTight) if let unit = reading.unitDisplay { Text(unit) .font(DesignTokens.Typography.metricUnit) diff --git a/Shellbee/Shared/SwitchControl/SwitchControlCard.swift b/Shellbee/Shared/SwitchControl/SwitchControlCard.swift index b45ae5c..e20ebe4 100644 --- a/Shellbee/Shared/SwitchControl/SwitchControlCard.swift +++ b/Shellbee/Shared/SwitchControl/SwitchControlCard.swift @@ -34,7 +34,7 @@ struct SwitchControlCard: View { LinearGradient( colors: [ heroTint.opacity(context.isOn ? 0.18 : 0.06), - heroTint.opacity(0.04) + heroTint.opacity(DesignTokens.Opacity.subtleFade) ], startPoint: .topLeading, endPoint: .bottomTrailing @@ -62,7 +62,7 @@ struct SwitchControlCard: View { .symbolRenderingMode(.hierarchical) Text(eyebrowLabel) .font(DesignTokens.Typography.eyebrowLabel) - .tracking(0.5) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) .lineLimit(1) } @@ -115,7 +115,7 @@ struct SwitchControlCard: View { private var hairline: some View { Rectangle() - .fill(Color.primary.opacity(0.08)) + .fill(Color.primary.opacity(DesignTokens.Opacity.hairline)) .frame(height: DesignTokens.Size.hairline) } @@ -185,7 +185,7 @@ private struct MeteringTile: View { .symbolRenderingMode(.hierarchical) Text(label) .font(DesignTokens.Typography.eyebrowLabel) - .tracking(0.5) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) @@ -198,7 +198,7 @@ private struct MeteringTile: View { .monospacedDigit() .foregroundStyle(.primary) .lineLimit(1) - .minimumScaleFactor(0.55) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorTight) Text(unit) .font(DesignTokens.Typography.metricUnit) .foregroundStyle(.secondary) From c60f97d891388424d63668f1f626a41fb1284742 Mon Sep 17 00:00:00 2001 From: tashda Date: Wed, 29 Apr 2026 08:55:34 +0200 Subject: [PATCH 17/18] Resolve #36: unify card eyebrows on 11pt, banner glyphs on 15pt, rename tile tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies the design decisions agreed for #36: A. Eyebrow size — unified on 11pt SensorCard, RemoteCard, and GenericExposeCard headers were using a 12pt + tracking-0.6 variant ("eyebrowLabelLarge") because they were doing dual duty as both eyebrow and de-facto card title. Card eyebrows everywhere else are 11pt + tracking-0.5, so unify there. The three outlier sites now use eyebrowLabel/eyebrowIcon/ eyebrowTracking like every other card. The 12pt + tracking-0.6 token survives, but renamed to sectionHeaderLabel / sectionHeaderIcon / sectionHeaderTracking — its actual role is dividing content within a card (FanControlCard sectionView, plus the LightColorControl eyedropper and HomeLogsCard badge that share the size). B. Tile metric size — kept distinct, renamed for clarity The 24pt vs 30pt distinction is intentional: 24 for packed identity grids (DeviceCard / GroupCard 2x2 stat tiles, ClimateControlCard setpoint between +/- buttons), 30 for prominent feature tiles with room to breathe (FanControl, RemoteCard, SensorCard, SwitchControl). Token renames make the role explicit: - identityValue → identityTileValue - identityUnit → identityTileUnit - metricValue → featureTileValue - metricUnit → featureTileUnit No visual change. D. Banner level glyph — unified on 15pt FastTrackBanner glyph bumps from 14pt → 15pt to match InAppNotificationBanner. The 1pt drop wasn't doing visual work; the FastTrack banner is already smaller via padding/capsule. Drops the fastTrackLevelIcon token; both banners now use notificationLevelIcon. C and E left as-is (unit sizes scale with value sizes — intentional; hero state 48 vs metric 56 — deliberate "no data" vs "data" register). Closes #36. Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee/Features/Groups/GroupCard.swift | 4 +- Shellbee/Features/Home/HomeLogsCard.swift | 2 +- .../Notifications/FastTrackBanner.swift | 2 +- .../ClimateControl/ClimateControlCard.swift | 2 +- Shellbee/Shared/Components/DeviceCard.swift | 4 +- Shellbee/Shared/DesignTokens.swift | 37 +++++++++++-------- .../Shared/FanControl/FanControlCard.swift | 10 ++--- .../GenericExposeCard/GenericExposeCard.swift | 7 ++-- .../LightControl/LightColorControl.swift | 2 +- Shellbee/Shared/RemoteCard/RemoteCard.swift | 11 +++--- Shellbee/Shared/SensorCard/SensorCard.swift | 11 +++--- .../SwitchControl/SwitchControlCard.swift | 4 +- 12 files changed, 50 insertions(+), 46 deletions(-) diff --git a/Shellbee/Features/Groups/GroupCard.swift b/Shellbee/Features/Groups/GroupCard.swift index 6fc3344..2cd0bce 100644 --- a/Shellbee/Features/Groups/GroupCard.swift +++ b/Shellbee/Features/Groups/GroupCard.swift @@ -127,14 +127,14 @@ struct GroupCard: View { HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xxs) { Text(value) - .font(DesignTokens.Typography.identityValue) + .font(DesignTokens.Typography.identityTileValue) .monospacedDigit() .foregroundStyle(color) .lineLimit(1) .minimumScaleFactor(DesignTokens.Typography.scaleFactorTight) if let unit { Text(unit) - .font(DesignTokens.Typography.identityUnit) + .font(DesignTokens.Typography.identityTileUnit) .foregroundStyle(.secondary) .lineLimit(1) } diff --git a/Shellbee/Features/Home/HomeLogsCard.swift b/Shellbee/Features/Home/HomeLogsCard.swift index a54a2df..66e695f 100644 --- a/Shellbee/Features/Home/HomeLogsCard.swift +++ b/Shellbee/Features/Home/HomeLogsCard.swift @@ -79,7 +79,7 @@ struct HomeLogRow: View { .frame(width: Self.badgeSize, height: Self.badgeSize) .overlay { Image(systemName: entry.category.systemImage) - .font(DesignTokens.Typography.eyebrowLabelLarge) + .font(DesignTokens.Typography.sectionHeaderLabel) .foregroundStyle(entry.level.color) } } diff --git a/Shellbee/Features/Notifications/FastTrackBanner.swift b/Shellbee/Features/Notifications/FastTrackBanner.swift index 979c80f..6a290b1 100644 --- a/Shellbee/Features/Notifications/FastTrackBanner.swift +++ b/Shellbee/Features/Notifications/FastTrackBanner.swift @@ -10,7 +10,7 @@ struct FastTrackBanner: View { var body: some View { HStack(spacing: DesignTokens.Spacing.sm) { Image(systemName: notification.level.systemImage) - .font(DesignTokens.Typography.fastTrackLevelIcon) + .font(DesignTokens.Typography.notificationLevelIcon) .foregroundStyle(notification.level.color) Text(notification.title) .font(.footnote.weight(.semibold)) diff --git a/Shellbee/Shared/ClimateControl/ClimateControlCard.swift b/Shellbee/Shared/ClimateControl/ClimateControlCard.swift index 36bbc30..747eec4 100644 --- a/Shellbee/Shared/ClimateControl/ClimateControlCard.swift +++ b/Shellbee/Shared/ClimateControl/ClimateControlCard.swift @@ -168,7 +168,7 @@ struct ClimateControlCard: View { } Text(formatTemp(setpointDraft)) - .font(DesignTokens.Typography.identityValue) + .font(DesignTokens.Typography.identityTileValue) .monospacedDigit() .foregroundStyle(heroTint) .frame(minWidth: DesignTokens.Size.climateSetpointMinWidth) diff --git a/Shellbee/Shared/Components/DeviceCard.swift b/Shellbee/Shared/Components/DeviceCard.swift index 15252ab..0e6db22 100644 --- a/Shellbee/Shared/Components/DeviceCard.swift +++ b/Shellbee/Shared/Components/DeviceCard.swift @@ -152,14 +152,14 @@ struct DeviceCard: View { HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xxs) { Text(value) - .font(DesignTokens.Typography.identityValue) + .font(DesignTokens.Typography.identityTileValue) .monospacedDigit() .foregroundStyle(color) .lineLimit(1) .minimumScaleFactor(DesignTokens.Typography.scaleFactorTight) if let unit { Text(unit) - .font(DesignTokens.Typography.identityUnit) + .font(DesignTokens.Typography.identityTileUnit) .foregroundStyle(.secondary) .lineLimit(1) } diff --git a/Shellbee/Shared/DesignTokens.swift b/Shellbee/Shared/DesignTokens.swift index 9f5de17..e5bd3c0 100644 --- a/Shellbee/Shared/DesignTokens.swift +++ b/Shellbee/Shared/DesignTokens.swift @@ -166,15 +166,18 @@ nonisolated enum DesignTokens { static let cardSubheadline: Font = .subheadline // MARK: Eyebrow pattern (small uppercase label above metric values) - // Dominant value used in most card hero blocks. SensorCard, RemoteCard, - // and GenericExposeCard currently use the larger 12pt variant — - // tracked in #36. Once that's resolved one variant goes away. + // Used as the small contextual label at the top of every card. + // (#36.A resolved 2026-04-29: unified all card eyebrows on 11pt.) static let eyebrowLabel: Font = .system(size: 11, weight: .semibold) static let eyebrowIcon: Font = .system(size: 11, weight: .bold) - static let eyebrowLabelLarge: Font = .system(size: 12, weight: .semibold) - static let eyebrowIconLarge: Font = .system(size: 12, weight: .bold) static let eyebrowTracking: CGFloat = 0.5 - static let eyebrowTrackingLoose: CGFloat = 0.6 + + // MARK: Section header (uppercase label dividing content within a card) + // Slightly larger and looser-tracked than card eyebrows. One step up + // in the visual hierarchy. + static let sectionHeaderLabel: Font = .system(size: 12, weight: .semibold) + static let sectionHeaderIcon: Font = .system(size: 12, weight: .bold) + static let sectionHeaderTracking: CGFloat = 0.6 // MARK: Hero metric (the giant number/state on a card hero block) static let heroValue: Font = .system(size: 56, weight: .bold, design: .rounded) @@ -186,13 +189,17 @@ nonisolated enum DesignTokens { // Smaller subtitle text under the hero value (e.g. "Target 21.5°" under temperature) static let heroSubtitle: Font = .system(size: 20, weight: .semibold, design: .rounded) - // MARK: Metric tile (a stat tile inside the card body) - static let metricValue: Font = .system(size: 30, weight: .semibold, design: .rounded) - static let metricUnit: Font = .system(size: 15, weight: .medium, design: .rounded) - // Identity-tile variant (smaller — used in DeviceCard / GroupCard). - // Whether 24/14 should be unified with 30/15 is tracked in #36. - static let identityValue: Font = .system(size: 24, weight: .semibold, design: .rounded) - static let identityUnit: Font = .system(size: 14, weight: .medium, design: .rounded) + // MARK: Feature tile (1–N prominent stats in a card body, room to breathe) + // Used by FanControlCard, RemoteCard, SensorCard, SwitchControlCard. + static let featureTileValue: Font = .system(size: 30, weight: .semibold, design: .rounded) + static let featureTileUnit: Font = .system(size: 15, weight: .medium, design: .rounded) + // Identity tile (smaller — packed 2x2 grids of summary stats). + // Used by DeviceCard / GroupCard for Type/Status/Signal/Power tiles, + // and by ClimateControlCard for the setpoint number between +/- buttons. + // The size distinction from featureTile is deliberate (#36.B resolved + // 2026-04-29: keep two tiers, identity grid stays compact). + static let identityTileValue: Font = .system(size: 24, weight: .semibold, design: .rounded) + static let identityTileUnit: Font = .system(size: 14, weight: .medium, design: .rounded) // Snapshot-row variant (Light card colorSnapshotRow, Cover tilt row) static let snapshotRowValue: Font = .system(size: 20, weight: .semibold, design: .rounded) static let snapshotRowUnit: Font = .system(size: 13, weight: .medium, design: .rounded) @@ -212,9 +219,9 @@ nonisolated enum DesignTokens { static let sliderEndLabel: Font = .system(size: 9, weight: .medium) static let permitJoinCountdown: Font = .system(size: 64, weight: .thin) static let permitJoinSymbol: Font = .system(size: 48) - // Notification banner level glyphs — sizes differ per #36.D. + // Banner level glyph — shared by InAppNotificationBanner and + // FastTrackBanner (#36.D resolved 2026-04-29: unified on 15pt). static let notificationLevelIcon: Font = .system(size: 15, weight: .semibold) - static let fastTrackLevelIcon: Font = .system(size: 14, weight: .semibold) // Climate setpoint +/- button glyph static let climateActionIcon: Font = .system(size: 14, weight: .bold) // Light card secondary glyphs (compass / palette icon overlays) diff --git a/Shellbee/Shared/FanControl/FanControlCard.swift b/Shellbee/Shared/FanControl/FanControlCard.swift index 270c699..8441e09 100644 --- a/Shellbee/Shared/FanControl/FanControlCard.swift +++ b/Shellbee/Shared/FanControl/FanControlCard.swift @@ -342,7 +342,7 @@ struct FanControlCard: View { .foregroundStyle(tint) Text(title) - .font(DesignTokens.Typography.metricValue) + .font(DesignTokens.Typography.featureTileValue) .foregroundStyle(tint) .lineLimit(1) } @@ -396,13 +396,13 @@ struct FanControlCard: View { HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xxs) { Text(parts.value) - .font(DesignTokens.Typography.metricValue) + .font(DesignTokens.Typography.featureTileValue) .monospacedDigit() .foregroundStyle(.primary) .lineLimit(1) .minimumScaleFactor(DesignTokens.Typography.scaleFactorTight) Text(parts.unit) - .font(DesignTokens.Typography.metricUnit) + .font(DesignTokens.Typography.featureTileUnit) .foregroundStyle(.secondary) } } @@ -415,8 +415,8 @@ struct FanControlCard: View { private func sectionView(_ section: LayoutSection) -> some View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { Text(section.title) - .font(DesignTokens.Typography.eyebrowLabelLarge) - .tracking(DesignTokens.Typography.eyebrowTrackingLoose) + .font(DesignTokens.Typography.sectionHeaderLabel) + .tracking(DesignTokens.Typography.sectionHeaderTracking) .textCase(.uppercase) .foregroundStyle(.secondary) .padding(.leading, DesignTokens.Spacing.md) diff --git a/Shellbee/Shared/GenericExposeCard/GenericExposeCard.swift b/Shellbee/Shared/GenericExposeCard/GenericExposeCard.swift index 9a067e7..9b81e77 100644 --- a/Shellbee/Shared/GenericExposeCard/GenericExposeCard.swift +++ b/Shellbee/Shared/GenericExposeCard/GenericExposeCard.swift @@ -50,14 +50,13 @@ struct GenericExposeCard: View { /// name above the card already names what we're looking at, so a redundant /// "Controls" / "Device State" headline is just noise. private var snapshotHeader: some View { - // NOTE: 12pt eyebrow vs 11pt elsewhere — see #36.A. HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: "cpu") - .font(DesignTokens.Typography.eyebrowIconLarge) + .font(DesignTokens.Typography.eyebrowIcon) .foregroundStyle(.tint) Text("Device State") - .font(DesignTokens.Typography.eyebrowLabelLarge) - .tracking(DesignTokens.Typography.eyebrowTrackingLoose) + .font(DesignTokens.Typography.eyebrowLabel) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) .foregroundStyle(.secondary) Spacer(minLength: 0) diff --git a/Shellbee/Shared/LightControl/LightColorControl.swift b/Shellbee/Shared/LightControl/LightColorControl.swift index 46d2893..ebf23c7 100644 --- a/Shellbee/Shared/LightControl/LightColorControl.swift +++ b/Shellbee/Shared/LightControl/LightColorControl.swift @@ -68,7 +68,7 @@ struct LightColorControl: View { .frame(width: Self.swatchSize, height: Self.swatchSize) .overlay( Image(systemName: "eyedropper.halffull") - .font(DesignTokens.Typography.eyebrowLabelLarge) + .font(DesignTokens.Typography.sectionHeaderLabel) .foregroundStyle(.secondary) ) .frame(maxWidth: .infinity) diff --git a/Shellbee/Shared/RemoteCard/RemoteCard.swift b/Shellbee/Shared/RemoteCard/RemoteCard.swift index aa2f104..a50ac04 100644 --- a/Shellbee/Shared/RemoteCard/RemoteCard.swift +++ b/Shellbee/Shared/RemoteCard/RemoteCard.swift @@ -36,14 +36,13 @@ struct RemoteCard: View { } private var header: some View { - // NOTE: 12pt eyebrow vs 11pt elsewhere — see #36.A. HStack(spacing: DesignTokens.Spacing.sm) { Image(systemName: "command") - .font(DesignTokens.Typography.eyebrowIconLarge) + .font(DesignTokens.Typography.eyebrowIcon) .foregroundStyle(.tint) Text("Remote") - .font(DesignTokens.Typography.eyebrowLabelLarge) - .tracking(DesignTokens.Typography.eyebrowTrackingLoose) + .font(DesignTokens.Typography.eyebrowLabel) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) .foregroundStyle(.secondary) Spacer(minLength: 0) @@ -99,14 +98,14 @@ private struct ReadingTile: View { HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xxs) { Text(value) - .font(DesignTokens.Typography.metricValue) + .font(DesignTokens.Typography.featureTileValue) .monospacedDigit() .foregroundStyle(valueColor) .lineLimit(2) .minimumScaleFactor(DesignTokens.Typography.scaleFactorTight) if let unit { Text(unit) - .font(DesignTokens.Typography.metricUnit) + .font(DesignTokens.Typography.featureTileUnit) .foregroundStyle(.secondary) .lineLimit(1) } diff --git a/Shellbee/Shared/SensorCard/SensorCard.swift b/Shellbee/Shared/SensorCard/SensorCard.swift index 0e3526d..468d4af 100644 --- a/Shellbee/Shared/SensorCard/SensorCard.swift +++ b/Shellbee/Shared/SensorCard/SensorCard.swift @@ -44,14 +44,13 @@ struct SensorCard: View { } private var header: some View { - // NOTE: 12pt eyebrow vs 11pt elsewhere — see #36.A. HStack(spacing: DesignTokens.Spacing.sm) { Image(systemName: "sensor.fill") - .font(DesignTokens.Typography.eyebrowIconLarge) + .font(DesignTokens.Typography.eyebrowIcon) .foregroundStyle(.tint) Text("Sensor") - .font(DesignTokens.Typography.eyebrowLabelLarge) - .tracking(DesignTokens.Typography.eyebrowTrackingLoose) + .font(DesignTokens.Typography.eyebrowLabel) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) .foregroundStyle(.secondary) Spacer(minLength: 0) @@ -226,14 +225,14 @@ private struct SensorReadingTile: View { HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xxs) { Text(reading.numericDisplayValue) - .font(DesignTokens.Typography.metricValue) + .font(DesignTokens.Typography.featureTileValue) .monospacedDigit() .foregroundStyle(reading.valueColor) .lineLimit(1) .minimumScaleFactor(DesignTokens.Typography.scaleFactorTight) if let unit = reading.unitDisplay { Text(unit) - .font(DesignTokens.Typography.metricUnit) + .font(DesignTokens.Typography.featureTileUnit) .foregroundStyle(.secondary) .lineLimit(1) } diff --git a/Shellbee/Shared/SwitchControl/SwitchControlCard.swift b/Shellbee/Shared/SwitchControl/SwitchControlCard.swift index e20ebe4..8eb54f3 100644 --- a/Shellbee/Shared/SwitchControl/SwitchControlCard.swift +++ b/Shellbee/Shared/SwitchControl/SwitchControlCard.swift @@ -194,13 +194,13 @@ private struct MeteringTile: View { HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xxs) { Text(value) - .font(DesignTokens.Typography.metricValue) + .font(DesignTokens.Typography.featureTileValue) .monospacedDigit() .foregroundStyle(.primary) .lineLimit(1) .minimumScaleFactor(DesignTokens.Typography.scaleFactorTight) Text(unit) - .font(DesignTokens.Typography.metricUnit) + .font(DesignTokens.Typography.featureTileUnit) .foregroundStyle(.secondary) .lineLimit(1) } From 5154b4f134aff8c6628a79d997864f997d461eb9 Mon Sep 17 00:00:00 2001 From: tashda Date: Wed, 29 Apr 2026 09:09:25 +0200 Subject: [PATCH 18/18] Token everything: AppConfig namespace, layout ratios, residual offsets, v1.3.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final pass on the design-tokens work. After this commit, every numeric magic value in Shellbee/ either flows through DesignTokens (visual) or AppConfig (behavior), or is a domain semantic constant. New: AppConfig namespace ======================== Created Shellbee/Core/Config/AppConfig.swift for *behavior tokens* — single sources of truth for runtime tuning that aren't visual: AppConfig.Networking - websocketConnectionTimeout (10s) - websocketFirstMessageTimeout (5s) - discoveryProbeTimeout (1.5s) AppConfig.UX - notificationCoalesceWindow (1.5s) - recentDeviceWindow (30 min) The five existing typed constants (`Z2MWebSocketClient.connectionTimeout` et al) are kept as readable in-class references but now source their values from AppConfig — single edit point for future Settings exposure. Both nested enums are `nonisolated` so they can be referenced from nonisolated contexts (Z2MDiscoveryService probe loop). DesignTokens: layout ratios + residual offsets ============================================== New `DesignTokens.Ratio` sub-enum for proportional layout — multipliers applied to a parent dimension. Sweeps 12 distinct ratio literals across 6 files (LogRowView, GroupIconView, DeviceImageView, FeatureIconTile, MemberAvatarStack): Ratio.logRowBadgeSize / .logRowBadgeBorder / .logRowBadgeBorderMin Ratio.groupIconMember / .groupIconOffset / .groupIconCorner Ratio.deviceImageDot / .deviceImageDotMin Ratio.featureTileCorner Ratio.memberAvatarBorder / .memberAvatarBorderMin / .memberAvatarFont Ratio.memberAvatarBadgeFont / .memberAvatarBadgeFontMin / Ratio.memberAvatarBadgePadding Plus three offset literals tokenized: Size.logRowBadgeOffset (3) Size.firmwareUpdateBadgeOffsetX (4) / OffsetY (-2) Size.homeCardSlotButtonOffset (-8) And the icon glyph ratios from the previous sweep moved out into a clearer location: Typography.iconRatioSmall (0.38) Typography.iconRatioHalf (0.5) Typography.iconRatioMedium (0.55) Verified with grep — `.offset(x: \\d+`, `size \\* 0\\.\\d+`, `max\\([0-9.]+, size \\* [0-9.]+\\)` patterns all return zero matches outside DesignTokens.swift. Version bump: 1.3.1 =================== MARKETING_VERSION bumped to 1.3.1 across all four build configurations in preparation for the v1.3.1 release. Closes #37. Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee.xcodeproj/project.pbxproj | 9 ++-- Shellbee/Core/Config/AppConfig.swift | 44 +++++++++++++++++++ .../Core/Networking/Z2MDiscoveryService.swift | 2 +- .../Core/Networking/Z2MWebSocketClient.swift | 4 +- Shellbee/Core/Store/AppStore.swift | 2 +- .../Features/Devices/DeviceFirmwareMenu.swift | 3 +- .../Features/Devices/DeviceImageView.swift | 5 ++- .../Devices/DeviceListViewModel.swift | 2 +- Shellbee/Features/Groups/GroupIconView.swift | 12 ++--- Shellbee/Features/Home/HomeCardSlot.swift | 3 +- Shellbee/Features/Logs/LogRowView.swift | 11 +++-- .../Features/Settings/DocBrowserView.swift | 2 +- .../Shared/Components/FeatureIconTile.swift | 4 +- .../Shared/Components/MemberAvatarStack.swift | 14 +++--- Shellbee/Shared/DesignTokens.swift | 43 ++++++++++++++++++ 15 files changed, 130 insertions(+), 30 deletions(-) create mode 100644 Shellbee/Core/Config/AppConfig.swift diff --git a/Shellbee.xcodeproj/project.pbxproj b/Shellbee.xcodeproj/project.pbxproj index e48ef4a..df85c4a 100644 --- a/Shellbee.xcodeproj/project.pbxproj +++ b/Shellbee.xcodeproj/project.pbxproj @@ -82,6 +82,7 @@ App/RootView.swift, Assets.xcassets, ContentView.swift, + Core/Config/AppConfig.swift, Core/CrashReporting/CrashReportingConsent.swift, Core/CrashReporting/CrashReportScrubber.swift, Core/CrashReporting/PendingCrashSheet.swift, @@ -821,7 +822,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -862,7 +863,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -902,7 +903,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -944,7 +945,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Shellbee/Core/Config/AppConfig.swift b/Shellbee/Core/Config/AppConfig.swift new file mode 100644 index 0000000..be2bfdb --- /dev/null +++ b/Shellbee/Core/Config/AppConfig.swift @@ -0,0 +1,44 @@ +import Foundation + +/// Tunable values that shape app behavior — networking timeouts, UX +/// windows, retry budgets, etc. These are *behavior tokens*, distinct from +/// `DesignTokens` (visual). Today they're static defaults; over time some +/// will be exposed in Settings as user-configurable. +/// +/// Add new values to a topical sub-enum (Networking, UX, …) rather than +/// the top level so this file stays browsable. +nonisolated enum AppConfig { + + /// WebSocket / discovery / network-layer timeouts and retry budgets. + nonisolated enum Networking { + /// Hard cap on how long we wait for the WebSocket handshake to + /// complete before giving up and surfacing a connection failure. + static let websocketConnectionTimeout: TimeInterval = 10 + + /// After the WebSocket opens, how long to wait for the first + /// inbound frame before treating the connection as silent / stuck. + /// Z2M sends `bridge/info` immediately on connect, so a 5s window + /// covers slow networks without making genuine breakage feel laggy. + static let websocketFirstMessageTimeout: TimeInterval = 5 + + /// How long the discovery service probes a candidate host:port + /// before declaring it unreachable. Short enough to scan a /24 in + /// a reasonable wall-clock time when most addresses don't answer. + static let discoveryProbeTimeout: TimeInterval = 1.5 + } + + /// UX-tuning windows that aren't visual (durations, coalescing, recency). + nonisolated enum UX { + /// Notifications with the same `coalesceKey` arriving within this + /// window collapse into a single banner with a `× N` count badge. + /// Tuned so a burst of related events (e.g. an interview producing + /// multiple log lines) reads as one notification, not four. + static let notificationCoalesceWindow: TimeInterval = 1.5 + + /// How long after a device first joins the network it shows the + /// "Recently Added" badge in the device list. 30 minutes covers + /// most pairing → naming → first-test workflows without lingering + /// on the homepage forever. + static let recentDeviceWindow: TimeInterval = 30 * 60 + } +} diff --git a/Shellbee/Core/Networking/Z2MDiscoveryService.swift b/Shellbee/Core/Networking/Z2MDiscoveryService.swift index d5ad5a1..63c9413 100644 --- a/Shellbee/Core/Networking/Z2MDiscoveryService.swift +++ b/Shellbee/Core/Networking/Z2MDiscoveryService.swift @@ -10,7 +10,7 @@ final class Z2MDiscoveryService { @MainActor private var scanTask: Task? nonisolated private static let z2mPort: UInt16 = 8080 - nonisolated private static let probeTimeout: TimeInterval = 1.5 + nonisolated private static let probeTimeout: TimeInterval = AppConfig.Networking.discoveryProbeTimeout nonisolated private static let maxConcurrent = 48 @MainActor diff --git a/Shellbee/Core/Networking/Z2MWebSocketClient.swift b/Shellbee/Core/Networking/Z2MWebSocketClient.swift index ab6d4c7..9f1695b 100644 --- a/Shellbee/Core/Networking/Z2MWebSocketClient.swift +++ b/Shellbee/Core/Networking/Z2MWebSocketClient.swift @@ -2,7 +2,7 @@ import Foundation actor Z2MWebSocketClient { - private static let connectionTimeout: TimeInterval = 10 + private static let connectionTimeout: TimeInterval = AppConfig.Networking.websocketConnectionTimeout /// After the WS handshake succeeds we wait for the *first inbound message* /// before declaring the connection valid. Z2M accepts the HTTP 101 upgrade /// and only then either (a) immediately publishes the cached bridge state / @@ -10,7 +10,7 @@ actor Z2MWebSocketClient { /// auth token is missing/invalid. Both arrive over the WS — receive() will /// return data on success and throw on close. If neither happens within /// this timeout, the bridge is unreachable. - private static let firstMessageTimeout: TimeInterval = 5 + private static let firstMessageTimeout: TimeInterval = AppConfig.Networking.websocketFirstMessageTimeout /// Default URLSessionWebSocketTask frame limit is 1 MB. Z2M `bridge/response/backup` /// payloads carry the entire data folder as a base64 string inside JSON — a populated /// install with many devices and rotated config backups can produce 5–10 MB frames. diff --git a/Shellbee/Core/Store/AppStore.swift b/Shellbee/Core/Store/AppStore.swift index 6d9fc5f..20a9c27 100644 --- a/Shellbee/Core/Store/AppStore.swift +++ b/Shellbee/Core/Store/AppStore.swift @@ -62,7 +62,7 @@ final class AppStore { } static let logLimit = 1000 - static let coalesceWindow: TimeInterval = 1.5 + static let coalesceWindow: TimeInterval = AppConfig.UX.notificationCoalesceWindow init() { if let raw = UserDefaults.standard.dictionary(forKey: Self.firstSeenStoreKey) as? [String: Double] { diff --git a/Shellbee/Features/Devices/DeviceFirmwareMenu.swift b/Shellbee/Features/Devices/DeviceFirmwareMenu.swift index aec0e3d..619f1f1 100644 --- a/Shellbee/Features/Devices/DeviceFirmwareMenu.swift +++ b/Shellbee/Features/Devices/DeviceFirmwareMenu.swift @@ -76,7 +76,8 @@ struct DeviceFirmwareMenu: View { Circle() .fill(Color.red) .frame(width: DesignTokens.Size.logLevelDotSize, height: DesignTokens.Size.logLevelDotSize) - .offset(x: 4, y: -2) + .offset(x: DesignTokens.Size.firmwareUpdateBadgeOffsetX, + y: DesignTokens.Size.firmwareUpdateBadgeOffsetY) } } } diff --git a/Shellbee/Features/Devices/DeviceImageView.swift b/Shellbee/Features/Devices/DeviceImageView.swift index 4b23149..cd456c8 100644 --- a/Shellbee/Features/Devices/DeviceImageView.swift +++ b/Shellbee/Features/Devices/DeviceImageView.swift @@ -60,7 +60,8 @@ struct DeviceImageView: View { } private var offlineDot: some View { - let dotSize = max(7, size * 0.26) + let dotSize = max(DesignTokens.Ratio.deviceImageDotMin, + size * DesignTokens.Ratio.deviceImageDot) return Circle() .fill(Color.red) .frame(width: dotSize, height: dotSize) @@ -70,7 +71,7 @@ struct DeviceImageView: View { private var fallbackIcon: some View { Image(systemName: device.categorySystemImage) - .font(.system(size: size * 0.5, weight: .medium)) + .font(.system(size: size * DesignTokens.Typography.iconRatioHalf, weight: .medium)) .foregroundStyle(isAvailable ? Color.accentColor : .secondary.opacity(DesignTokens.Opacity.overlay)) .frame(maxWidth: .infinity, maxHeight: .infinity) // Optional: add a very subtle circle for fallbacks only diff --git a/Shellbee/Features/Devices/DeviceListViewModel.swift b/Shellbee/Features/Devices/DeviceListViewModel.swift index 4d4cb79..546642a 100644 --- a/Shellbee/Features/Devices/DeviceListViewModel.swift +++ b/Shellbee/Features/Devices/DeviceListViewModel.swift @@ -102,7 +102,7 @@ final class DeviceListViewModel { /// A device counts as "Recently Added" if it is currently interviewing, or /// if its first-seen timestamp falls within this window. The window /// survives app close/open via the timestamps persisted in `AppStore`. - static let recentWindow: TimeInterval = 30 * 60 + static let recentWindow: TimeInterval = AppConfig.UX.recentDeviceWindow var hasActiveFilter: Bool { categoryFilter != nil || typeFilter != nil || vendorFilter != nil || statusFilter != .all diff --git a/Shellbee/Features/Groups/GroupIconView.swift b/Shellbee/Features/Groups/GroupIconView.swift index 20a9a3a..e719d8f 100644 --- a/Shellbee/Features/Groups/GroupIconView.swift +++ b/Shellbee/Features/Groups/GroupIconView.swift @@ -12,9 +12,10 @@ struct GroupIconView: View { .frame(width: size, height: size) } else { ZStack(alignment: .topLeading) { - DeviceImageView(device: memberDevices[0], isAvailable: true, size: size * 0.72) - DeviceImageView(device: memberDevices[1], isAvailable: true, size: size * 0.72) - .offset(x: size * 0.28, y: size * 0.28) + DeviceImageView(device: memberDevices[0], isAvailable: true, size: size * DesignTokens.Ratio.groupIconMember) + DeviceImageView(device: memberDevices[1], isAvailable: true, size: size * DesignTokens.Ratio.groupIconMember) + .offset(x: size * DesignTokens.Ratio.groupIconOffset, + y: size * DesignTokens.Ratio.groupIconOffset) } .frame(width: size, height: size, alignment: .topLeading) } @@ -22,9 +23,10 @@ struct GroupIconView: View { private var genericIcon: some View { Image(systemName: "square.on.square.fill") - .font(.system(size: size * 0.5, weight: .medium)) + .font(.system(size: size * DesignTokens.Typography.iconRatioHalf, weight: .medium)) .foregroundStyle(.secondary) .frame(width: size, height: size) - .background(.fill.secondary, in: RoundedRectangle(cornerRadius: size * 0.28)) + .background(.fill.secondary, + in: RoundedRectangle(cornerRadius: size * DesignTokens.Ratio.groupIconCorner)) } } diff --git a/Shellbee/Features/Home/HomeCardSlot.swift b/Shellbee/Features/Home/HomeCardSlot.swift index 1fab9d9..0a50767 100644 --- a/Shellbee/Features/Home/HomeCardSlot.swift +++ b/Shellbee/Features/Home/HomeCardSlot.swift @@ -20,7 +20,8 @@ struct HomeCardSlot: View { } .buttonStyle(.plain) .accessibilityLabel("Hide \(card.title)") - .offset(x: -8, y: -8) + .offset(x: DesignTokens.Size.homeCardSlotButtonOffset, + y: DesignTokens.Size.homeCardSlotButtonOffset) .transition(.scale.combined(with: .opacity)) } } diff --git a/Shellbee/Features/Logs/LogRowView.swift b/Shellbee/Features/Logs/LogRowView.swift index bf02e53..4c72334 100644 --- a/Shellbee/Features/Logs/LogRowView.swift +++ b/Shellbee/Features/Logs/LogRowView.swift @@ -34,7 +34,7 @@ struct LogRowView: View { private var leadingVisual: some View { let size = DesignTokens.Size.logRowDeviceImage - let badgeSize = size * 0.47 + let badgeSize = size * DesignTokens.Ratio.logRowBadgeSize return ZStack(alignment: .bottomTrailing) { Circle() @@ -42,13 +42,14 @@ struct LogRowView: View { .frame(width: size, height: size) .overlay { Image(systemName: entry.category.systemImage) - .font(.system(size: size * 0.38, weight: .semibold)) + .font(.system(size: size * DesignTokens.Typography.iconRatioSmall, weight: .semibold)) .foregroundStyle(iconForeground) } if let device = resolvedDevice { deviceThumbnail(device, size: badgeSize) - .offset(x: 3, y: 3) + .offset(x: DesignTokens.Size.logRowBadgeOffset, + y: DesignTokens.Size.logRowBadgeOffset) } } } @@ -60,7 +61,9 @@ struct LogRowView: View { private func deviceThumbnail(_ device: Device, size: CGFloat) -> some View { DeviceImageView(device: device, isAvailable: true, size: size) .clipShape(Circle()) - .overlay(Circle().strokeBorder(Color(.systemBackground), lineWidth: max(1.5, size * 0.1))) + .overlay(Circle().strokeBorder(Color(.systemBackground), + lineWidth: max(DesignTokens.Ratio.logRowBadgeBorderMin, + size * DesignTokens.Ratio.logRowBadgeBorder))) } private var resolvedDevice: Device? { diff --git a/Shellbee/Features/Settings/DocBrowserView.swift b/Shellbee/Features/Settings/DocBrowserView.swift index 60fcb1f..9857b9a 100644 --- a/Shellbee/Features/Settings/DocBrowserView.swift +++ b/Shellbee/Features/Settings/DocBrowserView.swift @@ -202,7 +202,7 @@ private struct DocEntryRow: View { .transition(.opacity.combined(with: .scale(scale: 0.95))) } placeholder: { Image(systemName: entry.deviceType?.systemImage ?? "cpu") - .font(.system(size: size * 0.5, weight: .medium)) + .font(.system(size: size * DesignTokens.Typography.iconRatioHalf, weight: .medium)) .foregroundStyle(Color.accentColor) } .frame(width: size, height: size) diff --git a/Shellbee/Shared/Components/FeatureIconTile.swift b/Shellbee/Shared/Components/FeatureIconTile.swift index 45f2db9..99ea8e4 100644 --- a/Shellbee/Shared/Components/FeatureIconTile.swift +++ b/Shellbee/Shared/Components/FeatureIconTile.swift @@ -9,12 +9,12 @@ struct FeatureIconTile: View { var prominent: Bool = false var body: some View { - RoundedRectangle(cornerRadius: size * 0.27, style: .continuous) + RoundedRectangle(cornerRadius: size * DesignTokens.Ratio.featureTileCorner, style: .continuous) .fill(tint.gradient) .frame(width: size, height: size) .overlay { Image(systemName: symbol) - .font(.system(size: prominent ? size * 0.55 : size * 0.5, + .font(.system(size: prominent ? size * DesignTokens.Typography.iconRatioMedium : size * DesignTokens.Typography.iconRatioHalf, weight: .semibold)) .foregroundStyle(.white) } diff --git a/Shellbee/Shared/Components/MemberAvatarStack.swift b/Shellbee/Shared/Components/MemberAvatarStack.swift index 28957f2..d4869f0 100644 --- a/Shellbee/Shared/Components/MemberAvatarStack.swift +++ b/Shellbee/Shared/Components/MemberAvatarStack.swift @@ -6,11 +6,15 @@ struct MemberAvatarStack: View { var maxVisible: Int = 5 private var overlap: CGFloat { size / 3 } - private var borderWidth: CGFloat { max(1.5, size * 0.063) } - private var placeholderFont: CGFloat { size * 0.375 } - private var badgeSize: CGFloat { size + 2 } - private var badgeFont: CGFloat { max(9, size * 0.44) } - private var badgePadding: CGFloat { size * 0.25 } + private var borderWidth: CGFloat { + max(DesignTokens.Ratio.memberAvatarBorderMin, size * DesignTokens.Ratio.memberAvatarBorder) + } + private var placeholderFont: CGFloat { size * DesignTokens.Ratio.memberAvatarFont } + private var badgeSize: CGFloat { size + DesignTokens.Spacing.xxs } + private var badgeFont: CGFloat { + max(DesignTokens.Ratio.memberAvatarBadgeFontMin, size * DesignTokens.Ratio.memberAvatarBadgeFont) + } + private var badgePadding: CGFloat { size * DesignTokens.Ratio.memberAvatarBadgePadding } var body: some View { HStack(spacing: -overlap) { diff --git a/Shellbee/Shared/DesignTokens.swift b/Shellbee/Shared/DesignTokens.swift index e5bd3c0..8bd6d41 100644 --- a/Shellbee/Shared/DesignTokens.swift +++ b/Shellbee/Shared/DesignTokens.swift @@ -112,6 +112,11 @@ nonisolated enum DesignTokens { static let docLabelColumnWidth: CGFloat = 90 static let permitJoinRingStroke: CGFloat = 8 static let lightSelectionStroke: CGFloat = 2 + // Offsets (pixel-pushing for badge alignment over a parent shape) + static let logRowBadgeOffset: CGFloat = 3 + static let firmwareUpdateBadgeOffsetX: CGFloat = 4 + static let firmwareUpdateBadgeOffsetY: CGFloat = -2 + static let homeCardSlotButtonOffset: CGFloat = -8 } nonisolated enum Shadow { @@ -139,6 +144,37 @@ nonisolated enum DesignTokens { static let weakSignal = 40 } + /// Proportional layout ratios — multipliers applied to a parent dimension + /// (e.g. an offset that's 28% of the parent frame side, a corner radius + /// that's 27% of the tile size). Use these instead of inline percentages + /// so the whole layout scales consistently. + nonisolated enum Ratio { + // Log row icon (device thumbnail badge inside the log level circle) + static let logRowBadgeSize: CGFloat = 0.47 + static let logRowBadgeBorder: CGFloat = 0.1 + static let logRowBadgeBorderMin: CGFloat = 1.5 + + // Group icon (member device images stacked into a group thumbnail) + static let groupIconMember: CGFloat = 0.72 + static let groupIconOffset: CGFloat = 0.28 + static let groupIconCorner: CGFloat = 0.28 + + // Device image (status / availability dot inside the thumbnail) + static let deviceImageDot: CGFloat = 0.26 + static let deviceImageDotMin: CGFloat = 7 + + // Feature tile (rounded rect background corner radius) + static let featureTileCorner: CGFloat = 0.27 + + // Member avatar stack (group detail row, etc) + static let memberAvatarBorder: CGFloat = 0.063 + static let memberAvatarBorderMin: CGFloat = 1.5 + static let memberAvatarFont: CGFloat = 0.375 + static let memberAvatarBadgeFont: CGFloat = 0.44 + static let memberAvatarBadgeFontMin: CGFloat = 9 + static let memberAvatarBadgePadding: CGFloat = 0.25 + } + nonisolated enum Duration { static let standardAnimation: Double = 0.3 static let hubAnimation: Double = 0.36 @@ -227,6 +263,13 @@ nonisolated enum DesignTokens { // Light card secondary glyphs (compass / palette icon overlays) static let lightSecondaryIcon: Font = .system(size: 14, weight: .semibold) + // MARK: Icon glyph ratios (proportional sizing inside a parent frame) + // e.g. an icon glyph that fills half its containing thumbnail circle. + // Used as multipliers: `.font(.system(size: parent * iconRatioHalf))`. + static let iconRatioSmall: CGFloat = 0.38 + static let iconRatioHalf: CGFloat = 0.5 + static let iconRatioMedium: CGFloat = 0.55 + // MARK: minimumScaleFactor presets (per Text element shrink budgets) static let scaleFactorAggressive: CGFloat = 0.45 static let scaleFactorTight: CGFloat = 0.55