Skip to content
  •  
  •  
  •  
2 changes: 2 additions & 0 deletions .bumper/RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,5 @@ is needed.
ViewDocumentCore uses Foundation only. ViewDocumentUI depends on Core and system UI frameworks.
Both reject Broadway, Where, and SnapshotKit imports. The `ViewDocument rejects consumer dependencies` mutation test covers these boundaries.
Asset providers supply prepared images. The import rules do not inspect image contents or enforce resource limits; document validation owns those checks.

WhereUI may consume ViewDocumentCore and ViewDocumentUI for validated theme layers. The domain and adapter targets cannot import those modules directly. The downward-dependency fixture covers this presentation boundary.
2 changes: 1 addition & 1 deletion .bumper/Tests/WhereArchitectureTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ func `Where architecture accepts downward dependencies`() throws {
SourceInput(
path: "Where/WhereUI/Sources/Screen.swift",
component: ComponentID(WhereComponent.whereUI.rawValue),
source: "import WhereCore\nimport SwiftUI\nstruct Screen {}",
source: "import WhereCore\nimport SwiftUI\nimport ViewDocumentCore\nimport ViewDocumentUI\nstruct Screen {}",
),
],
),
Expand Down
12 changes: 11 additions & 1 deletion BumperBowling.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ let bumper = BumperProject {
Owns("Where/WhereIntents/Tests")
Owns("Where/Where/Tests")
MayDependOn(
.viewDocumentCore,
.viewDocumentUI,
.periscope,
.installerKit,
.installerKitUI,
Expand Down Expand Up @@ -136,7 +138,15 @@ let bumper = BumperProject {
Component(.whereUI) {
Owns("Where/WhereUI/Sources")
Modules("WhereUI")
MayDependOn(.periscope, .installerKit, .installerKitUI, .regionKit, .whereCore)
MayDependOn(
.periscope,
.installerKit,
.installerKitUI,
.regionKit,
.whereCore,
.viewDocumentCore,
.viewDocumentUI,
)
Applies(.wherePresentationLayer)
}

Expand Down
2 changes: 2 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ let package = Package(
.target(
name: "WhereUI",
dependencies: [
.target(name: "ViewDocumentCore"),
.target(name: "ViewDocumentUI"),
.target(name: "InstallerKitUI"),
.target(name: "InstallerKit"),
.target(name: "WhereCore"),
Expand Down
1 change: 1 addition & 0 deletions Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,7 @@ let project = Project(
bundleIdSuffix: "whereui",
productDependency: "WhereUI",
sources: ["Where/WhereUI/Tests/**"],
resources: ["Where/WhereUI/Tests/Fixtures/Themes/*.json"],
// The parser initializes SwiftUI accessibility for hosted interaction tests,
// including on fresh CI simulators with no accessibility tooling attached.
extraPackageProducts: ["AccessibilitySnapshotParser", "LifecycleKit", "SFSafeSymbols"],
Expand Down
1 change: 1 addition & 0 deletions Tools/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ These rules also apply when changing a root command or installer that calls this

## Snapshot shard plans

- Exclude helper-only Swift files from suite discovery. Files named `*Tests.swift` or containing test declarations must declare one matching top-level suite. Guard: `test_snapshot_shards.py`.
- Keep measured timings in balance reports, not the persisted execution plan.
- Preserve assignments unless intake, the improvement threshold, or an explicit shard-count change requires an update.
Guard: [test_snapshot_shards.py](Tests/test_snapshot_shards.py).
Expand Down
2 changes: 2 additions & 0 deletions Tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ The [Astro project](InstallerKit/Site/README.md) owns routes, components, and st

## Snapshot shard policy

Discovery accepts shared Swift helpers without test declarations. Each test file must contain one matching top-level suite; helper names cannot hide tests.

`snapshot_shards.py` keeps weekly assignments stable. Its bounded greedy search
places intake first, then stops existing-suite moves at a 5% reduction in the
slowest worker. If single-suite moves cannot reach that target, it discards them.
Expand Down
47 changes: 47 additions & 0 deletions Tools/Tests/test_snapshot_shards.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,53 @@ def test_rejects_a_test_file_with_a_different_suite_name(self):
with self.assertRaisesRegex(ValueError, "must declare one top-level suite"):
MODULE.source_inventory(repo)

def test_shared_snapshot_helpers_are_not_suites(self):
with tempfile.TemporaryDirectory() as directory:
repo = self.make_repo(directory)
helper = repo / "Module" / "SnapshotTests" / "WhereThemeSnapshotGate.swift"
helper.write_text(
"protocol WhereThemeSnapshotGate {}\n"
"enum LightDarkSnapshots<Source: WhereThemeSnapshotGate> {}\n"
)
plan = repo / "plan.json"
plan.write_text(json.dumps(self.make_plan()))

self.assertEqual(len(MODULE.source_inventory(repo)), 3)
result = subprocess.run(
[sys.executable, SCRIPT, "--plan", plan, "check", "--repo", repo],
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stderr, "")
self.assertIn("3 suites", result.stdout)

def test_helper_names_cannot_hide_test_declarations(self):
for source in (
"struct HiddenTests { @Test func example() {} }\n",
"enum Helpers {\n struct Nested { @Test func example() {} }\n}\n",
"enum Helpers {\n struct Nested { @Testing.Test func example() {} }\n}\n",
):
with (
self.subTest(source=source),
tempfile.TemporaryDirectory() as directory,
):
repo = self.make_repo(directory)
helper = repo / "Module" / "SnapshotTests" / "Helpers.swift"
helper.write_text(source)
with self.assertRaisesRegex(
ValueError, "must declare one top-level suite"
):
MODULE.source_inventory(repo)

def test_empty_test_files_are_rejected(self):
with tempfile.TemporaryDirectory() as directory:
repo = self.make_repo(directory)
source = repo / "Module" / "SnapshotTests" / "EmptyTests.swift"
source.write_text("import Testing\n")
with self.assertRaisesRegex(ValueError, "must declare one top-level suite"):
MODULE.source_inventory(repo)

def test_compares_the_plan_with_xcode_enumeration(self):
with tempfile.TemporaryDirectory() as directory:
path = pathlib.Path(directory) / "suites.txt"
Expand Down
6 changes: 6 additions & 0 deletions Tools/snapshot_shards.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ def source_inventory(repo):
text,
re.MULTILINE,
)
if (
not suite.endswith("Tests")
and not declarations
and not re.search(r"@(?:Testing\.)?Test\b", text)
):
continue
if declarations != [suite]:
relative = path.relative_to(repo)
found = ", ".join(declarations) if declarations else "none"
Expand Down
2 changes: 2 additions & 0 deletions Where/Where/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,5 @@ Before shipping a schema change:
On a fresh install, onboarding recommends automatic recording On for an iPhone only when no other device recently reported recording, and Off for an iPad/other device or explicit rejoin, then requires the user to confirm.
Existing installations created before that choice was introduced revisit only the final recording page once.
Enabling is the only path that asks for location access.

Standard, Flat, and Halloween share the saved theme preference and root rendering path in every audience. Appearance Settings saves the selection. Onboarding previews it through `WhereModel` and commits it on completion. The normal theme handoff supplies widgets and App Intents.
7 changes: 4 additions & 3 deletions Where/Where/Sources/RegularApplicationRuntime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -164,15 +164,16 @@ final class RegularApplicationRuntime: WhereApplicationRuntime {

func makeRootView() -> AnyView {
#if DEBUG
AnyView(RootView(
let root = RootView(
model: model,
launcher: launcher,
primaryAppIconName: buildEnvironment.primaryAppIconName,
updates: updates,
developerLaunchController: developerLaunchController,
))
)
return AnyView(root)
#else
AnyView(RootView(
return AnyView(RootView(
model: model,
launcher: launcher,
primaryAppIconName: buildEnvironment.primaryAppIconName,
Expand Down
1 change: 1 addition & 0 deletions Where/WhereCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ one it belongs to rather than to a god-object:
read, with a freshness policy for the independently aggregated data.
- **`WidgetPresentationPublisher`** — atomically writes the device-local `WhereTheme`
to its own App Group file and reloads WidgetKit without reading or rebuilding widget data.
Bundled seasonal themes use this same preference and publication path.
- **`BackupCoordinator`** — ZIP export/import via `ZIPFoundation`. Export pins
tables, planned-stay and home-region revisions, sample-attribution revisions, and evidence blobs
to one generation-consistent snapshot. Merge preserves queued locations
Expand Down
3 changes: 3 additions & 0 deletions Where/WhereCore/Sources/Preferences/WhereTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,7 @@ public enum WhereTheme: String, CaseIterable, Codable, Hashable, Sendable {

/// The Flat presentation theme. Its original raw value remains permanent.
case alternate

/// Illustrated pumpkins and plum paper surfaces.
case halloween
}
7 changes: 4 additions & 3 deletions Where/WhereCore/Tests/WherePreferencesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,12 +128,13 @@ struct WherePreferencesTests {
}
}

@Test func themeRoundTripsAndUnknownValuesFallBackToStandard() {
@Test(arguments: WhereTheme.allCases)
func themeRoundTripsAndUnknownValuesFallBackToStandard(theme: WhereTheme) {
let store = InMemoryKeyValueStore()
let preferences = WherePreferences(store: store)

preferences.theme = .alternate
#expect(preferences.theme == .alternate)
preferences.theme = theme
#expect(WherePreferences(store: store).theme == theme)

store.set("future-theme", forKey: "where.theme")
#expect(preferences.theme == .standard)
Expand Down
1 change: 1 addition & 0 deletions Where/WhereCore/Tests/WhereThemeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ struct WhereThemeTests {
@Test func rawValuesAreStable() {
#expect(WhereTheme.standard.rawValue == "standard")
#expect(WhereTheme.alternate.rawValue == "alternate")
#expect(WhereTheme.halloween.rawValue == "halloween")
}

@Test(arguments: WhereTheme.allCases)
Expand Down
7 changes: 4 additions & 3 deletions Where/WhereCore/Tests/WidgetPresentationPublisherTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ struct WidgetPresentationPublisherTests {
}
}

@Test func publishesWithoutTouchingWidgetData() async throws {
@Test(arguments: WhereTheme.allCases)
func publishesWithoutTouchingWidgetData(theme: WhereTheme) async throws {
let directory = FileManager.default.temporaryDirectory
.appending(path: "WidgetPresentationPublisherTests-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
Expand All @@ -25,9 +26,9 @@ struct WidgetPresentationPublisherTests {
let reloads = ReloadSpy()
let publisher = WidgetPresentationPublisher(store: store, reloadTimelines: reloads.reload)

await publisher.publish(.alternate)
await publisher.publish(theme)

#expect(store.readTheme() == .alternate)
#expect(store.readTheme() == theme)
#expect(reloads.reloadCount == 1)
#expect(!FileManager.default.fileExists(
atPath: directory.appending(path: "widget-snapshot.json").path,
Expand Down
7 changes: 4 additions & 3 deletions Where/WhereCore/Tests/WidgetPresentationStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ struct WidgetPresentationStoreTests {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
}

@Test func writeThenReadRoundTrips() throws {
@Test(arguments: WhereTheme.allCases)
func writeThenReadRoundTrips(theme: WhereTheme) throws {
defer { try? FileManager.default.removeItem(at: directory) }
let store = WidgetPresentationStore(directory: directory)

try store.write(theme: .alternate)
try store.write(theme: theme)

#expect(store.readTheme() == .alternate)
#expect(store.readTheme() == theme)
}

@Test func missingOrUnknownPresentationDefaultsToStandard() throws {
Expand Down
7 changes: 4 additions & 3 deletions Where/WhereIntents/Tests/IntentServicesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,15 +82,16 @@ struct IntentServicesTests {
#expect(resolved.journal === replacement.journal)
}

@Test func themeUpdatesAtomicallyWithoutReplacingTheStack() async throws {
@Test(arguments: WhereTheme.allCases)
func themeUpdatesAtomicallyWithoutReplacingTheStack(theme: WhereTheme) async throws {
let handoff = IntentServices(appGroupIdentifier: appGroupIdentifier)
let stack = try makeStack()
await handoff.install(stack, theme: .standard)

await handoff.updateTheme(.alternate)
await handoff.updateTheme(theme)

let context = try await handoff.currentContext()
#expect(context.services.journal === stack.journal)
#expect(context.theme == .alternate)
#expect(context.theme == theme)
}
}
16 changes: 10 additions & 6 deletions Where/WhereUI/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,12 +245,14 @@ worked examples.
Guard: `RegionAppearanceCatalogTests.idDerivedDefaultsHaveStableAssignments`.
- **Seed `WhereTheme` through `whereBroadwayRoot(theme:regionStyles:)`.** Standard
and Flat use the stable `standard` and `alternate` identities.
- Keep Standard as the base stylesheet. Resolve shared traits before applying `WhereThemeOverrides`.
Put theme values and theme-specific trait decisions in their definition, such as `WhereFlatTheme`.
Keep override application independent of theme selection and trait resolution.
Omitted overrides inherit the adjusted base. Explicit zero values disable effects.
Use `BModeVarying` for paired light/dark values. Keep runtime region colors outside theme definitions.
Guards: `WhereThemeOverridesTests`, `WhereFlatThemeTests`.
- Bundled JSON is authoritative. Standard is a complete root; Flat inherits Standard; Halloween inherits Flat. Resolve documents through `WhereThemeCatalog` before rendering. Do not add a native theme override path.
- Keep portable appearance in `Sources/Themes/WhereThemeAppearance*`. Add each authored stylesheet field to its typed patch, inheritance, canonical Standard document, and mapping tests. Keep runtime geometry, region identity, data reduction, and lifecycle scheduling native.
- Omitted fields inherit. Explicit values replace. Artwork arrays replace as a unit, including empty arrays. Optional visuals use `WhereThemeOptional.clear`. A literal value replaces an inherited named reference; do not supply both in one document.
- Validate complete roots, parents, named references, complexity, and placement before root injection. Prepare assets once. Cache identity includes complete resolved content, not only its ID. Failed imports retain the last accepted theme.
- Apply native accessibility constraints after document resolution. Keep ornament separate from essential surfaces. Inject static-motion policy and actual host activity into Broadway's renderer.
- Use component surfaces for interior and exterior artwork. Keep paint bounds finite, reserve gaps and gutters in parent layouts, and preserve viewport clipping. Exterior artwork must not expand interaction or accessibility bounds. Keep the inner card as the zoom source.
- Validate host capabilities before injection. Keep List rows and joined timeline segments interior-only. Apply artwork spacing outside buttons and accessibility groups in other hosts. Guard: `WhereThemeComponentsCapabilitiesTests`.
- Route every bundled theme through the saved `WhereTheme` selection and `whereBroadwayRoot`. Use the same selection flow in every app audience, including onboarding and relaunch. Guards: `WhereModelTests`, `WhereStylesheetEnvironmentTests`.
- **Design new and changed custom components for every supported theme.**
Resolve theme and accessibility choices in the owning `WhereStylesheet`
component style. Views consume that style without checking the selected theme.
Expand Down Expand Up @@ -334,3 +336,5 @@ lightweight widget/snippet surfaces do not need duplicate theme cases.
Other themes use stable consumer-owned groups (`flat/` for `.alternate`). Mixed-theme
sheets use `themes/`. Special trait images use `other/` inside the selected group.
Never derive the folder from a localized label or rename a persisted theme identity.

- Keep composite theme appearance groups boxed with `CopyOnWrite`. Background JSON decoding must fit a worker thread stack; preserve the unboxed JSON shape. Guard: `WhereThemeAppearanceTests`.
11 changes: 11 additions & 0 deletions Where/WhereUI/Artwork.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Halloween artwork

The Halloween theme uses three original illustrations generated for Where with OpenAI ImageGen on 2026-10-29.

- `HalloweenPumpkins.png`: apricot and cream pumpkins, sage vines, and lavender mushrooms.
- `HalloweenMoon.png`: a crescent moon, bat, and sparse stars.
- `HalloweenVine.png`: a sage vine with an apricot flower and lavender star.

Each PNG has a transparent background. The images contain no text or controls. Their shared palette uses cream, lavender, sage, apricot, and plum outlines.

`Sources/Resources/Themes/HalloweenTheme.json` owns placement, opacity, paint bounds, and motion. `HalloweenThemeAssets` supplies only these approved bundled resources. Native SwiftUI owns text, interaction, borders, and layout.
Loading
Loading