Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
29b69ac
DesignTokens: add Spacing.xxs, Size.hairline, Size.notificationBottom…
tashda Apr 28, 2026
4496eb5
FanControlCard: replace hardcoded SwiftUI literals with DesignTokens
tashda Apr 28, 2026
0a85b02
LightControlCard: replace hardcoded SwiftUI literals with DesignTokens
tashda Apr 28, 2026
14f9882
Cover/Climate cards: replace hardcoded SwiftUI literals with DesignTo…
tashda Apr 28, 2026
e680f50
DeviceCard + InAppNotificationOverlay: replace literals with DesignTo…
tashda Apr 28, 2026
5e74d01
MQTTInspectorView: replace hardcoded SwiftUI literals with DesignTokens
tashda Apr 28, 2026
891e007
RestoreGuideSheet + DocBlockView: replace literals with DesignTokens
tashda Apr 28, 2026
d6883fb
Tail sweep: replace remaining hardcoded SwiftUI literals across the app
tashda Apr 28, 2026
5702066
FanControlCard: split FanFeatureSections and FanExtraRow into peer files
tashda Apr 28, 2026
4c66320
InAppNotificationOverlay: split banner views into peer files
tashda Apr 28, 2026
6de1dcc
MQTTInspectorView: lift JSONHighlighter and SubscribeStore into peer …
tashda Apr 28, 2026
2088ebe
AppStore: split into 5 domain extensions
tashda Apr 28, 2026
b34dc27
DeviceDocNormalizer: split models out and extract pairing helpers
tashda Apr 29, 2026
db44792
DesignTokens: extend Typography, Opacity, Duration for second sweep
tashda Apr 29, 2026
eeed408
Card sweep: replace all font(.system(size:)) literals with Typography…
tashda Apr 29, 2026
187efa7
Sweep remaining literals: tracking, opacity, durations, scale factors…
tashda Apr 29, 2026
c60f97d
Resolve #36: unify card eyebrows on 11pt, banner glyphs on 15pt, rena…
tashda Apr 29, 2026
5154b4f
Token everything: AppConfig namespace, layout ratios, residual offset…
tashda Apr 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions Shellbee.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -114,13 +115,20 @@
Core/Networking/Z2MWebSocketClient.swift,
Core/Networking/Z2MWebSocketSessionDelegate.swift,
Core/Parsing/DeviceDocNormalizer.swift,
"Core/Parsing/DeviceDocNormalizer+Pairing.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/NotificationPreferences.swift,
Features/Bridge/PhilipsHueResetSheet.swift,
Features/Bridge/TouchlinkDeviceRow.swift,
Expand Down Expand Up @@ -196,6 +204,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,
Expand All @@ -210,6 +220,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,
Expand Down Expand Up @@ -264,6 +275,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,
Expand Down Expand Up @@ -809,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;
Expand Down Expand Up @@ -850,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 = "";
Expand Down Expand Up @@ -890,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;
Expand Down Expand Up @@ -932,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 = "";
Expand Down
2 changes: 1 addition & 1 deletion Shellbee/App/MainTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
2 changes: 1 addition & 1 deletion Shellbee/App/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 5 additions & 4 deletions Shellbee/App/SplashScreenView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,16 @@ 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)
.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)

Expand All @@ -45,7 +46,7 @@ struct SplashScreenView: View {
}
}
.onAppear {
withAnimation(.easeOut(duration: 0.8)) {
withAnimation(.easeOut(duration: DesignTokens.Duration.pulseExpand)) {
isVisible = true
}
}
Expand Down
44 changes: 44 additions & 0 deletions Shellbee/Core/Config/AppConfig.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
2 changes: 1 addition & 1 deletion Shellbee/Core/Networking/Z2MDiscoveryService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ final class Z2MDiscoveryService {
@MainActor private var scanTask: Task<Void, Never>?

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
Expand Down
4 changes: 2 additions & 2 deletions Shellbee/Core/Networking/Z2MWebSocketClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ 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 /
/// device list, or (b) closes the socket with a policy-violation if the
/// 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.
Expand Down
175 changes: 175 additions & 0 deletions Shellbee/Core/Parsing/DeviceDocNormalizer+Pairing.swift
Original file line number Diff line number Diff line change
@@ -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<String>([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"
]
}
Loading
Loading