Skip to content

Code structure & design-tokens cleanup: oversized files and hardcoded SwiftUI values #33

@tashda

Description

@tashda

Summary

A new Code structure section was added to CLAUDE.md / AGENTS.md (one-responsibility-per-file, soft 400 / hard 600 line caps, extract reusable components into Shared/, no dead code), and the Design tokens rule was tightened: any literal numeric value in SwiftUI must use a token unless the native styling carries the value implicitly.

This issue tracks bringing the existing codebase into compliance.

Findings

Files over the hard cap (600 lines)

File Lines Notes
Shellbee/Shared/FanControl/FanControlCard.swift 754 Holds FanControlCard + FanFeatureSections + DisclosureRow + FanExtraRow
Shellbee/Features/Notifications/InAppNotificationOverlay.swift 728 InAppNotificationOverlay + InAppNotificationBanner (~200 lines) + FastTrackBanner (~200 lines) + PreviewHost
Shellbee/Core/Store/AppStore.swift 691 Single big store, candidate for domain extensions
Shellbee/Core/Parsing/DeviceDocNormalizer.swift 646 7 parsing types in one file

Files over the soft cap (400–600 lines)

File Lines
Shellbee/Shared/Components/Doc/DocumentationExperienceView.swift 521
Shellbee/Shared/Components/DeviceCard.swift 425
Shellbee/Features/Settings/Developer/MQTTInspectorView.swift 411

Multiple unrelated top-level types in one file

  • MQTTInspectorView.swiftMQTTInspectorView, SubscribeStore, InspectorMessage, JSONHighlighter. JSONHighlighter is a generic utility that doesn't belong in a view file.
  • InAppNotificationOverlay.swift — overlay + two ~200-line peer banner views.
  • Features/Home/HomeCardComponents.swift (7 types) — borderline; StatCellButtonStyle and HomeCardAlertList could split.
  • Features/Devices/DeviceListViewModel.swift (4 types) — worth a look.

Model files with multiple tightly-coupled DTOs (DeviceDoc.swift, BridgeSettings.swift, BridgeInfo.swift, Device.swift) are intentionally left alone — Swift convention permits grouping related value types.

Hardcoded numbers where a token should be used

131 instances total. Worst offenders:

File Count
MQTTInspectorView.swift 11
LightControl/LightControlCard.swift 10
FanControl/FanControlCard.swift 10
InAppNotificationOverlay.swift 9
CoverControl/CoverControlCard.swift 7
ClimateControl/ClimateControlCard.swift 6
Backup/RestoreGuideSheet.swift 6
Components/Doc/DocBlockView.swift 5
Components/DeviceCard.swift 5

Two recurring patterns:

  1. Small spacings the token table doesn't cover. spacing: 2, 4, 5, 6; .padding(.vertical, 2). xs = 4, sm = 85 and 6 have no token.
  2. One-off frame sizes. frame(width: 22), frame(width: 14), frame(width: 220), .padding(.bottom, 80), frame(height: 0.5). The 0.5 divider height appears in ≥4 card files and clearly wants a Size.hairline token. FanControlCard even declares a local rowIconWidth: CGFloat = 22 that duplicates Size.cardSymbol = 22.

Plan

Sequenced so each step is a small, reviewable PR with low blast radius. Each step can land independently on dev and ship as part of v1.3.1.

Step 1 — Extend DesignTokens to cover the gaps

File: Shellbee/Shared/DesignTokens.swift.

Additions:

  • Spacing.xxs: CGFloat = 2 — for tight inline gaps (spacing: 2, .padding(.vertical, 2)).
  • Size.hairline: CGFloat = 0.5 — for divider heights / sub-pixel strokes.
  • Decide on 5 and 6: snap to xs (4) where it's a tight gap or sm (8) where it's a row spacing. Spot-check visually on LightControlCard and FanControlCard before committing — these values were likely chosen by eye, so collapsing them to existing tokens is the goal, not adding new ones.
  • Add Size.notificationBottomInset: CGFloat = 80 (used 3× in InAppNotificationOverlay).
  • Document any new token with a one-line comment if its purpose isn't obvious from the name.

No behavior changes in this step — tokens are added but not yet referenced.

Step 2 — Mechanical sweep: replace literals with tokens

Touch one file per commit so reviews stay scannable. Order by impact:

  1. Shared/FanControl/FanControlCard.swift (10 literals; also delete the duplicate local rowIconWidth and use Size.cardSymbol).
  2. Shared/LightControl/LightControlCard.swift (10).
  3. Shared/CoverControl/CoverControlCard.swift (7).
  4. Shared/ClimateControl/ClimateControlCard.swift (6).
  5. Shared/Components/DeviceCard.swift (5).
  6. Features/Notifications/InAppNotificationOverlay.swift (9).
  7. Features/Settings/Developer/MQTTInspectorView.swift (11).
  8. Features/Settings/Backup/RestoreGuideSheet.swift (6).
  9. Shared/Components/Doc/DocBlockView.swift (5).
  10. Tail of remaining files (1–4 literals each), bundled into one final commit.

After each commit, run xcodebuild build for the simulator and visually diff the touched screen via mcp__XcodeBuildMCP__screenshot against dev to confirm nothing shifted.

Step 3 — Split FanControlCard.swift (754 → ~300)

  • Extract FanFeatureSections → new file Shellbee/Shared/FanControl/FanFeatureSections.swift (mirrors the existing Shared/LightControl/LightFeatureSections.swift pattern).
  • Extract FanExtraRowShellbee/Shared/FanControl/FanExtraRow.swift.
  • Keep DisclosureRow private inside FanFeatureSections.swift if only used there; otherwise lift to Shared/Components/.
  • Remember the widget target's membershipExceptions list — both new files need to be added there if they (transitively) reference AppEnvironment.

Step 4 — Split InAppNotificationOverlay.swift (728 → ~300)

  • Extract InAppNotificationBannerShellbee/Features/Notifications/InAppNotificationBanner.swift.
  • Extract FastTrackBannerShellbee/Features/Notifications/FastTrackBanner.swift.
  • Keep PreviewHost adjacent to whichever file has the most preview surface — likely the banner itself.
  • Confirm the banners don't share private state with the overlay (a quick read suggests they don't — they take values via init).

Step 5 — Lift JSONHighlighter out of MQTTInspectorView.swift

  • Move JSONHighlighterShellbee/Shared/Components/JSONHighlighter.swift (it's reusable; BeautifulPayloadView would also benefit).
  • Consider whether InspectorMessage and SubscribeStore should also split. SubscribeStore is a substantial @Observable class (~35 lines) — split into Features/Settings/Developer/SubscribeStore.swift. InspectorMessage is small and tightly coupled — leave inline.

Step 6 — Reduce Features/Home/HomeCardComponents.swift (7 types)

Lower priority. Split if the file grows further; otherwise leave as a deliberate "primitives bag" (it's under 200 lines).

Step 7 — Re-evaluate AppStore.swift and DeviceDocNormalizer.swift

These need design thought, not mechanical extraction. Defer to follow-up issues if the work is non-trivial:

  • AppStore.swift (691): consider splitting via extension AppStore files per domain (AppStore+Devices.swift, AppStore+Groups.swift, AppStore+Logs.swift, AppStore+OTA.swift). Read the file end-to-end first to confirm that's the right cut.
  • DeviceDocNormalizer.swift (646): 7 parsing types — likely a candidate for a Core/Parsing/Normalizer/ subfolder with one type per file. Confirm by reading; some of the 7 may be small inline helpers that should stay together.

If either turns out to need substantial refactoring, file a separate issue and target a later milestone.

Acceptance criteria

  • No file in Shellbee/ exceeds 600 lines (hard cap), unless explicitly justified in a comment at the top of the file.
  • grep for hardcoded SwiftUI numeric modifiers (.padding\(\d+\), frame(width:\s*\d+, spacing:\s*\d+\), cornerRadius(\d+)) returns zero matches outside DesignTokens.swift, ignoring 0 and tokens-passed-as-args.
  • No file has 3+ unrelated top-level types (DTO bundles excepted).
  • Fast CI green on the merge PR.
  • No visible UI regressions on Home, Devices list, Device detail (light/fan/cover/climate cards), Notifications overlay, MQTT inspector, Backup restore guide.

Notes

  • This is mechanical cleanup — no new features, no behavior changes. Each step should be reviewable in under 5 minutes.
  • The CLAUDE.md / AGENTS.md rules already shipped on dev. Until this issue is resolved, the codebase will fail its own conventions in the audited files.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions