From aaef1da520b92f10c09298e10686cf2692924f34 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sat, 1 Aug 2026 19:23:12 -0700 Subject: [PATCH 01/28] Refine Where card and calendar styling --- .../calendarContent.FullContent_fullHeight.png | 4 ++-- .../calendarContent.MissingDays_iPhone.png | 4 ++-- .../calendarContent.MissingDays_iPhone_dark.png | 4 ++-- .../locations.Loaded_iPad.png | 4 ++-- .../locations.Loaded_iPad_accessibility.png | 4 ++-- .../locations.Loaded_iPad_ax5.png | 4 ++-- .../locations.Loaded_iPad_contrast.png | 4 ++-- .../locations.Loaded_iPad_dark.png | 4 ++-- .../locations.Loaded_iPhone.png | 4 ++-- .../locations.Loaded_iPhone_accessibility.png | 4 ++-- .../locations.Loaded_iPhone_ax5.png | 4 ++-- .../locations.Loaded_iPhone_contrast.png | 4 ++-- .../locations.Loaded_iPhone_dark.png | 4 ++-- .../locations.MissingDays_iPhone.png | 4 ++-- .../locations.MissingDays_iPhone_dark.png | 4 ++-- .../Sources/Primary/CalendarContentView.swift | 10 ++++++++-- Where/WhereUI/Sources/Shared/WhereStylesheet.swift | 14 ++++++++++++-- Where/WhereUI/Tests/WhereStylesheetTests.swift | 10 ++++++++-- 18 files changed, 58 insertions(+), 36 deletions(-) diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FullContent_fullHeight.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FullContent_fullHeight.png index 564d16fe..adb965d3 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FullContent_fullHeight.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FullContent_fullHeight.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a2c8d441e90743ac57d9055fa361aeba423fbb560efb0c5f215d0d4027af1493 -size 1016568 +oid sha256:73cf42497c984cf8969d5eee07ead9ec24ee23f5f40f576163b4bd8699955038 +size 935687 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone.png index e0e0df98..76bbc491 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b3d06689d02ac0a9c511df63eafc8f8cd7efa72f5e412075a4a1559675b106cc -size 239269 +oid sha256:6a7c1914c1ab6e21f81fdcb280c7ec2f830b05f5edb0b99a1d56c5a9fbb7fa00 +size 240143 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone_dark.png index 09bc46ed..6023c33e 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:30f8b24d088da2d8feb4fb29b3ef95ba5d901402aa157e8520b7ec10c93ac0f2 -size 228592 +oid sha256:49828d4a5570bf18aa652076ab008457701e61a837d1c87ac335077bc031892a +size 228571 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png index a0e86786..e10e1a23 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bb9aa09ee6ddb5ec20530eeada7d9becd2d33f81b7974e099ffd252d013015c6 -size 3323906 +oid sha256:4be4a0d5dec72edc75d637a1a20ed48ecc827e6753401db8482be338453458a8 +size 3296780 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png index cb70481c..a35420cd 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7b8cc655632c7d8e1be8b3760d879a1fe29058fa6af87bbe5cad4f3e03b60d33 -size 2265842 +oid sha256:b6a3c63039c967d5d5254a09a35ee3a4cb8097a093439167b0ab043f1f935885 +size 2229071 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png index 126cf0f9..6e5c8d9d 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:84ba760ccfb6f6b240245751435300dd525c3a0ceb2ac62402044d0bf9c12578 -size 3688736 +oid sha256:96adabbd37bc3c4cf0b9be016aca3a9d179f72206f158e6330b9ebe06afa1098 +size 3661435 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png index 54880710..03464bf3 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c4eaa176126332ba54d9153197d47ebd03d59efc08d90ba4a76181db625ae0d1 -size 3269793 +oid sha256:61cc689d0b5e6aa4c88990dc53a5956c21053e7669ba862adf3fedbb2b348116 +size 3243890 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png index 70b72b20..d904c3fc 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f412f543531c991fd31abf5c989ededb5531d640d3deb3427da47405a9b82284 -size 3456597 +oid sha256:73c27325bcb8bace47bdd00f4a49335b7ac5db2e21d65c5d77a5d9653be1e2c6 +size 3422723 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png index 93e7070a..32ca1953 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:59089d04f7b17bfcf322948942f5806be1d0c4225b9af930ba93b8f795287a23 -size 2225238 +oid sha256:7680460467e44884ef51b533214cee45a9a2e288a51a1dd9e32f4465788bca5a +size 2227568 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png index 4d921dd0..1bbc765e 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:056d75b639133f36cd3b2be3143b512d8db4f7deec9f9bf4eedd572bf1d2fac3 -size 1381994 +oid sha256:805e62ba8f82b12c626be7f92affc66fb0a872929c319543eafbb3e233fc48c7 +size 1373869 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png index f1686840..be7dfc9f 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e9fb579f1afdc26ae94f9c92b357f0820674d553ed79d341d71529eb1ac34af2 -size 2679259 +oid sha256:ae828c37d6d88e718c39b7fc8513ae5ec6b9329eb42997e0e28c60434c517525 +size 2680410 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png index 4027f331..83b6c6d9 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c074226034ff84c3436e5f5225b0b15d789b8e23aab6fdb3e8be7028886ce106 -size 2117829 +oid sha256:7aed7cadf72db15dad7d7dff81c28b8b6a4dd9e5367ed8cf4c56cb0a04519790 +size 2118701 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png index cf60e9ae..3123fc93 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:639c76c2c812870c1314ad6a788a476a3c8db8f5eb3685b41e8dca83cf566f83 -size 2116707 +oid sha256:aca97554fae6efd478d678d442cb68fe6be0ada08f2c9b37c0c7eabf32724a24 +size 2120718 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png index fb9f5eed..64800033 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c206921e577c60cc9c568e4531de40172c0f827efd7a247018d5c82fbf2b9e55 -size 1250815 +oid sha256:9340522f0ca9fe6a583fd2d2718902193fff1679f81fed4e215d9b5f99ba648d +size 1251363 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png index fa4b608e..a348b49e 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:58a9611378865f81cf2ca61e4c8d9516e7e7033fe028b66f4feb48f3567b6dfb -size 1170253 +oid sha256:a08e336d29d535ef279d5580065275113b96b83a6939fd2b1bd47a0a2d145d31 +size 1171767 diff --git a/Where/WhereUI/Sources/Primary/CalendarContentView.swift b/Where/WhereUI/Sources/Primary/CalendarContentView.swift index 6547131a..9da8d7e1 100644 --- a/Where/WhereUI/Sources/Primary/CalendarContentView.swift +++ b/Where/WhereUI/Sources/Primary/CalendarContentView.swift @@ -266,6 +266,12 @@ private struct MonthGridView: View { stylesheet.calendar } + /// Resolve the month variant once so its background and inherited text + /// treatment cannot drift apart. + private var card: WhereStylesheet.CalendarStyle.MonthStyle.Card { + month.isCurrentMonth ? calendar.month.current : calendar.month.plain + } + var body: some View { VStack(alignment: .leading, spacing: calendar.month.sectionSpacing) { Text(month.startOfMonth.formatted(.dateTime.month(.wide))) @@ -301,10 +307,10 @@ private struct MonthGridView: View { } } .padding(calendar.month.padding) + .foregroundStyle(card.foreground) .background { // Past and future months share the plain card; the current one - // gets the accent card (bluer wash, heavier border). - let card = month.isCurrentMonth ? calendar.month.current : calendar.month.plain + // gets the accent card (bluer wash, text, and heavier border). RoundedRectangle(cornerRadius: calendar.month.cornerRadius) .fill(card.fill) .overlay { diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index 7b5cf3bf..3eaf070b 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -409,7 +409,7 @@ extension WhereStylesheet { innerFrameInset: 16, rosette: CardStyle.Rosette( wobble: 3, - lineWidth: 3, + lineWidth: 2, primaryRingSpacing: 18, secondaryRingSpacing: 15, ), @@ -525,6 +525,10 @@ extension WhereStylesheet { /// Border around the card (a touch darker than `fill`). var border: Color var borderWidth: CGFloat + /// Base foreground inherited by the month's neutral primary and + /// secondary text. Explicit semantic colors (today, unresolved, + /// and region tints) still override it. + var foreground: Color } } @@ -605,11 +609,17 @@ extension WhereStylesheet { fill: Color.primary.opacity(0.03), border: Color.primary.opacity(0.12), borderWidth: 2, + foreground: .primary, ), current: MonthStyle.Card( fill: Color.accentColor.opacity(0.08), border: Color.accentColor.opacity(0.7), - borderWidth: 4, + borderWidth: 3, + foreground: Color.primary.mix( + with: .accentColor, + by: 0.25, + in: .perceptual, + ), ), futureOpacity: 0.55, futurePeekFraction: 0.5, diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index 8545bb95..a9edd799 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -43,7 +43,7 @@ struct WhereStylesheetTests { #expect(card.innerFrameInset == 16) #expect(card.rosette == .init( wobble: 3, - lineWidth: 3, + lineWidth: 2, primaryRingSpacing: 18, secondaryRingSpacing: 15, )) @@ -152,9 +152,15 @@ struct WhereStylesheetTests { #expect(month.plain.fill == Color.primary.opacity(0.03)) #expect(month.plain.border == Color.primary.opacity(0.12)) #expect(month.plain.borderWidth == 2) + #expect(month.plain.foreground == .primary) #expect(month.current.fill == Color.accentColor.opacity(0.08)) #expect(month.current.border == Color.accentColor.opacity(0.7)) - #expect(month.current.borderWidth == 4) + #expect(month.current.borderWidth == 3) + #expect(month.current.foreground == Color.primary.mix( + with: .accentColor, + by: 0.25, + in: .perceptual, + )) #expect(month.futureOpacity == 0.55) #expect(month.futurePeekFraction == 0.5) #expect(month.footerDividerSpacing == 8) From 2956d07256cc2a419b004c4fa8c8e680e9c8671d Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sat, 1 Aug 2026 19:44:57 -0700 Subject: [PATCH 02/28] Match region previews to location cards --- .../RegionCustomizeViewSnapshotTests.swift | 10 ++++ .../locations.Loaded_iPad.png | 4 +- .../locations.Loaded_iPad_accessibility.png | 4 +- .../locations.Loaded_iPad_ax5.png | 4 +- .../locations.Loaded_iPad_contrast.png | 4 +- .../locations.Loaded_iPad_dark.png | 4 +- .../locations.Loaded_iPhone.png | 4 +- .../locations.Loaded_iPhone_accessibility.png | 4 +- .../locations.Loaded_iPhone_ax5.png | 4 +- .../locations.Loaded_iPhone_contrast.png | 4 +- .../locations.Loaded_iPhone_dark.png | 4 +- .../locations.MissingDays_iPhone.png | 4 +- .../locations.MissingDays_iPhone_dark.png | 4 +- .../regionCustomize.Editor_iPhone.png | 3 ++ .../regionCustomize.Editor_iPhone_dark.png | 3 ++ .../Sources/Primary/RegionSummaryCard.swift | 27 ++++++++--- .../Sources/Regions/RegionCustomizeView.swift | 35 ++++++++++---- .../Sources/Shared/HolographicSheen.swift | 46 ++++++++++++++----- .../WhereUI/Sources/Shared/TiltProvider.swift | 9 +++- .../Sources/Shared/WhereStylesheet.swift | 34 ++++++++++++-- .../WhereUI/Tests/WhereStylesheetTests.swift | 12 ++++- 21 files changed, 170 insertions(+), 57 deletions(-) create mode 100644 Where/WhereUI/SnapshotTests/RegionCustomizeViewSnapshotTests.swift create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png diff --git a/Where/WhereUI/SnapshotTests/RegionCustomizeViewSnapshotTests.swift b/Where/WhereUI/SnapshotTests/RegionCustomizeViewSnapshotTests.swift new file mode 100644 index 00000000..175baf18 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/RegionCustomizeViewSnapshotTests.swift @@ -0,0 +1,10 @@ +import SnapshotKitTesting +import Testing +@testable import WhereUI + +@MainActor +struct RegionCustomizeViewSnapshotTests { + @Test func regionCustomize() async { + await assertSnapshots(of: RegionCustomizeView.self) + } +} diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png index e10e1a23..cc50e328 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4be4a0d5dec72edc75d637a1a20ed48ecc827e6753401db8482be338453458a8 -size 3296780 +oid sha256:2cd3d5a254a7cfcebf57c02d03164591a61f97e0e3876328f58953c2a8a4cf2d +size 3345081 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png index a35420cd..ff52621e 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b6a3c63039c967d5d5254a09a35ee3a4cb8097a093439167b0ab043f1f935885 -size 2229071 +oid sha256:ea1b0010ba7732cc36963d730a648b3c40f5281facc0bd1e785837ef818fb633 +size 2225128 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png index 6e5c8d9d..15895359 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:96adabbd37bc3c4cf0b9be016aca3a9d179f72206f158e6330b9ebe06afa1098 -size 3661435 +oid sha256:d141174d17f8397059a82b4de7e73093f2d271a2a0f522f7382084fe690ea493 +size 3738500 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png index 03464bf3..0162632e 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:61cc689d0b5e6aa4c88990dc53a5956c21053e7669ba862adf3fedbb2b348116 -size 3243890 +oid sha256:3ac920157a6e48c555aed86d9727ba7d7d5ccea443d75a1194f452d979befadb +size 3155840 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png index d904c3fc..9b6d270d 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:73c27325bcb8bace47bdd00f4a49335b7ac5db2e21d65c5d77a5d9653be1e2c6 -size 3422723 +oid sha256:b0eee8773864ce7f041793152a91c77d04f0711fa25707a5ca9755d269a8410d +size 3473132 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png index 32ca1953..f5004a0d 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7680460467e44884ef51b533214cee45a9a2e288a51a1dd9e32f4465788bca5a -size 2227568 +oid sha256:5ec2a37fb0c33d996a99815d045317800410caae7f3a5b0d2b3820691dca7dcd +size 2126748 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png index 1bbc765e..fb0ff290 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:805e62ba8f82b12c626be7f92affc66fb0a872929c319543eafbb3e233fc48c7 -size 1373869 +oid sha256:44201c529f70e11bf5264077594b81a289a8033d3574fb04a851c005080c5218 +size 1327492 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png index be7dfc9f..24ecbeaa 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ae828c37d6d88e718c39b7fc8513ae5ec6b9329eb42997e0e28c60434c517525 -size 2680410 +oid sha256:084bd240f60f3b02e04c41913b19c24a27e35b2321bdac0a6b2c8bb54b3e4e7c +size 2560474 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png index 83b6c6d9..d637bd80 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7aed7cadf72db15dad7d7dff81c28b8b6a4dd9e5367ed8cf4c56cb0a04519790 -size 2118701 +oid sha256:14bf27fb00a8185ce9da6a68cc395b38306acf843cd5fed2b60cdd823527c974 +size 1984077 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png index 3123fc93..c08f708e 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:aca97554fae6efd478d678d442cb68fe6be0ada08f2c9b37c0c7eabf32724a24 -size 2120718 +oid sha256:e06f8ba54337c7b8e99bf9f64b89a55fca59c877e94eb0031b180eb3a369ab2a +size 2177443 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png index 64800033..09fb0896 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9340522f0ca9fe6a583fd2d2718902193fff1679f81fed4e215d9b5f99ba648d -size 1251363 +oid sha256:edf92b1589b64155e9d6e44ad0b8550ec200d1197bfb26f233e61f1312e11fd2 +size 1206659 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png index a348b49e..f45f1c95 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a08e336d29d535ef279d5580065275113b96b83a6939fd2b1bd47a0a2d145d31 -size 1171767 +oid sha256:af7cdd56320d2a0538c55c8a61381e8d3a7b15beeab6c3a0122ffd8b66923c30 +size 1174594 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png new file mode 100644 index 00000000..0607a3f9 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ac85e28010adac8a4513b919ede12f44e1f1fb4752686fc7ada17914ba62507a +size 1438989 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png new file mode 100644 index 00000000..9279d449 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9fb453832a5a559690761a5b0d8228b52707f3b6cf0683d8f1edb2246552bc55 +size 1491912 diff --git a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift index 84e8ac9e..9bd33d17 100644 --- a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift @@ -32,9 +32,8 @@ struct RegionSummaryCard: View { /// pass `WhereSession.selectedYear`; the default is only for previews. var year = WhereModel.currentYear - /// Drives the holographic stamp sheen. The Primary tab passes its live - /// `TiltProvider`; Elsewhere (and previews) pass `nil`, leaving a gentle - /// static sheen. + /// Drives the holographic stamp sheen. Locations and the region editor pass + /// a live `TiltProvider`; callers without one use the card's static pose. var tilt: TiltProvider? /// An explicit style to render instead of resolving the region's look from @@ -75,6 +74,18 @@ struct RegionSummaryCard: View { stylesheet.card.dayCount } + /// Use live gravity only after Core Motion has delivered a real sample. The + /// pre-sample/unavailable state and callers without a provider render the + /// variant's deterministic pose instead of treating zero as live tilt. + private var sheenPose: WhereStylesheet.CardStyle.Sheen.Pose { + guard let tilt, tilt.hasLiveSample else { return card.sheen.staticPose } + return .init(roll: tilt.roll, pitch: tilt.pitch) + } + + private var isSheenStatic: Bool { + tilt?.hasLiveSample != true + } + /// A circular rubber-stamp "entry" impression: the region glyph and year /// ringed by the region name, tilted as if pressed onto the page. The arc /// lettering is dropped on the small compact cards where it can't be read. @@ -254,11 +265,15 @@ struct RegionSummaryCard: View { in: cardShape, ) .holographicSheen( - roll: tilt?.roll ?? 0, - pitch: tilt?.pitch ?? 0, + roll: sheenPose.roll, + pitch: sheenPose.pitch, + staticRoll: card.sheen.staticPose.roll, + staticPitch: card.sheen.staticPose.pitch, + isStatic: isSheenStatic, in: cardShape, tint: .white, - intensity: card.holographicIntensity, + intensity: card.sheen.intensity, + staticGlintIntensity: card.sheen.staticGlintIntensity, ) .overlay { stampFrame } .clipShape(cardShape) diff --git a/Where/WhereUI/Sources/Regions/RegionCustomizeView.swift b/Where/WhereUI/Sources/Regions/RegionCustomizeView.swift index f2f75b47..933d9544 100644 --- a/Where/WhereUI/Sources/Regions/RegionCustomizeView.swift +++ b/Where/WhereUI/Sources/Regions/RegionCustomizeView.swift @@ -1,4 +1,5 @@ import RegionKit +import SnapshotKit import SwiftUI import WhereCore @@ -11,6 +12,10 @@ struct RegionAppearanceEditor: View { @Bindable var model: PrimaryRegionSelectionModel let region: Region + /// Matches the Locations card's live holographic response while this editor + /// is visible; the card uses its static pose until the first sample arrives. + @State private var tilt = TiltProvider() + @Environment(\.stylesheet) private var stylesheet private var appearance: RegionAppearance { @@ -34,14 +39,19 @@ struct RegionAppearanceEditor: View { } .padding(stylesheet.spacing.large) } + .onAppear { tilt.start() } + .onDisappear { tilt.stop() } } private var preview: some View { - RegionSummaryCard( - regionDays: RegionDays(region: region, days: 128), - caption: WhereFormat.regionCustomizeSubtitle(region: region.localizedName), - styleOverride: RegionStyle(appearance), - ) + GlassEffectContainer(spacing: stylesheet.spacing.xxLarge) { + RegionSummaryCard( + regionDays: RegionDays(region: region, days: 128), + caption: WhereFormat.regionCustomizeSubtitle(region: region.localizedName), + tilt: tilt, + styleOverride: RegionStyle(appearance), + ) + } .animation(stylesheet.motion.captionFade, value: appearance) } @@ -254,11 +264,18 @@ struct RegionCustomizeView: View { .whereBroadwayRoot() } - #Preview("Stepping") { - NavigationStack { - RegionCustomizeView(model: PreviewSupport.primaryRegionSelectionModel()) + extension RegionCustomizeView: SnapshotProviding { + static var snapshots: [SnapshotCase] { + whereSnapshot(name: "Editor", configurations: .phoneLightDark) { + NavigationStack { + RegionCustomizeView(model: PreviewSupport.primaryRegionSelectionModel()) + } + } } - .whereBroadwayRoot() + } + + #Preview("Stepping") { + RegionCustomizeView.snapshotPreviews } #endif diff --git a/Where/WhereUI/Sources/Shared/HolographicSheen.swift b/Where/WhereUI/Sources/Shared/HolographicSheen.swift index aa9071ec..8c853957 100644 --- a/Where/WhereUI/Sources/Shared/HolographicSheen.swift +++ b/Where/WhereUI/Sources/Shared/HolographicSheen.swift @@ -6,19 +6,23 @@ import SwiftUI /// over whatever it modifies (typically a Liquid Glass card), clipped to the /// card's shape, and non-interactive. /// -/// Driven by normalized `roll`/`pitch` (see `TiltProvider`); at the neutral -/// zero it renders a gentle static sheen, so it degrades cleanly on hardware -/// without motion. Honors Reduce Motion by pinning the glint to a fixed offset -/// instead of tracking the device. +/// Driven by normalized `roll`/`pitch` (see `TiltProvider`). A caller also +/// supplies the deterministic pose used when motion must stay static, so Reduce +/// Motion and snapshot capture never depend on a live sensor reading. struct HolographicSheen: ViewModifier { var roll: Double var pitch: Double + var staticRoll: Double + var staticPitch: Double + var isStatic: Bool var shape: ClipShape var tint: Color = .white - /// Overall strength of the effect, `0...1`. + /// Rainbow-foil and live-glint strength, `0...1`. var intensity: Double = 1 + /// Additive white-glint strength when `usesStaticPose`, `0...1`. + var staticGlintIntensity: Double = 1 - @Environment(\.accessibilityReduceMotion) private var reduceMotion + @MotionIsStatic private var motionIsStatic func body(content: Content) -> some View { content.overlay { @@ -29,14 +33,22 @@ struct HolographicSheen: ViewModifier { } } - /// Tilt actually used to place the highlight. Reduce Motion replaces the - /// live values with a fixed, gentle diagonal so the sheen stays put. + /// Tilt actually used to place the highlight. Static-motion contexts use + /// the caller's deterministic pose so the sheen stays put. private var activeRoll: Double { - reduceMotion ? 0.18 : roll.clamped + usesStaticPose ? staticRoll.clamped : roll.clamped } private var activePitch: Double { - reduceMotion ? -0.12 : pitch.clamped + usesStaticPose ? staticPitch.clamped : pitch.clamped + } + + private var usesStaticPose: Bool { + isStatic || motionIsStatic + } + + private var glintIntensity: Double { + usesStaticPose ? staticGlintIntensity : intensity } private var sheen: some View { @@ -59,7 +71,7 @@ struct HolographicSheen: ViewModifier { // Specular glint that tracks the tilt like a moving light. RadialGradient( - colors: [tint.opacity(0.85 * intensity), tint.opacity(0)], + colors: [tint.opacity(0.85 * glintIntensity), tint.opacity(0)], center: glint, startRadius: 0, endRadius: diagonal * 0.75, @@ -81,16 +93,24 @@ extension View { func holographicSheen( roll: Double, pitch: Double, + staticRoll: Double, + staticPitch: Double, + isStatic: Bool, in shape: some Shape, tint: Color = .white, intensity: Double = 1, + staticGlintIntensity: Double = 1, ) -> some View { modifier(HolographicSheen( roll: roll, pitch: pitch, + staticRoll: staticRoll, + staticPitch: staticPitch, + isStatic: isStatic, shape: shape, tint: tint, intensity: intensity, + staticGlintIntensity: staticGlintIntensity, )) } } @@ -111,7 +131,11 @@ extension Double { .holographicSheen( roll: 0.4, pitch: -0.2, + staticRoll: 0.4, + staticPitch: -0.2, + isStatic: false, in: RoundedRectangle(cornerRadius: 28, style: .continuous), + staticGlintIntensity: 1, ) .padding() } diff --git a/Where/WhereUI/Sources/Shared/TiltProvider.swift b/Where/WhereUI/Sources/Shared/TiltProvider.swift index 49d04604..95affef9 100644 --- a/Where/WhereUI/Sources/Shared/TiltProvider.swift +++ b/Where/WhereUI/Sources/Shared/TiltProvider.swift @@ -10,7 +10,8 @@ import Observation /// `Info.plist` usage string and no authorization (unlike motion *activity*), /// so this is safe to start without prompting. On hardware that can't provide /// motion (Simulator, the unit-test host, Mac), `start()` is a no-op and the -/// values stay at the neutral zero, so the sheen simply renders statically. +/// values stay at zero with `hasLiveSample == false`, so a consumer can render +/// its deterministic static pose instead. /// /// `@MainActor` so the observable state is mutated on the main actor (where /// SwiftUI reads it); CoreMotion delivers updates on the `.main` queue, so the @@ -23,6 +24,10 @@ public final class TiltProvider { public private(set) var roll: Double = 0 /// Forward/back tilt, roughly `-1...1`. Zero when flat or unavailable. public private(set) var pitch: Double = 0 + /// Whether device motion has delivered a value since the latest `start()`. + /// Internal because only WhereUI's sheen renderer needs to distinguish a + /// real flat-device zero from the pre-sample/unavailable state. + private(set) var hasLiveSample = false @ObservationIgnored private let motionManager = CMMotionManager() @ObservationIgnored private var isRunning = false @@ -48,6 +53,7 @@ public final class TiltProvider { MainActor.assumeIsolated { self.roll = x self.pitch = y + self.hasLiveSample = true } } } @@ -62,5 +68,6 @@ public final class TiltProvider { motionManager.stopDeviceMotionUpdates() roll = 0 pitch = 0 + hasLiveSample = false } } diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index 3eaf070b..ba4ba78d 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -256,8 +256,9 @@ extension WhereStylesheet { var watermarkFontSize: CGFloat /// Offset of that watermark toward the bottom-trailing corner. var watermarkOffset: CGSize - /// Holographic sheen strength (the Primary cards catch more light). - var holographicIntensity: Double + /// Holographic sheen strength plus the deterministic pose used until a + /// live motion sample arrives (and whenever motion must stay static). + var sheen: Sheen /// Line width of the heavy outer frame stroke. var frameOuterLineWidth: CGFloat /// Whether the dashed perforation ring is drawn (Primary cards only). @@ -278,6 +279,20 @@ extension WhereStylesheet { var secondaryRingSpacing: CGFloat } + struct Sheen: Equatable { + var intensity: Double + /// Strength of only the additive white glint while the pose is + /// static; the rainbow foil keeps `intensity` so the card retains + /// its color instead of fading toward white. + var staticGlintIntensity: Double + var staticPose: Pose + + struct Pose: Equatable { + var roll: Double + var pitch: Double + } + } + /// A region-tinted drop shadow: the view supplies the region tint, this /// supplies the geometry and how strongly to tint it. struct Shadow: Equatable { @@ -403,7 +418,13 @@ extension WhereStylesheet { dayUnitFont: .title3.weight(.medium), watermarkFontSize: 150, watermarkOffset: CGSize(width: 20, height: 12), - holographicIntensity: 1, + sheen: CardStyle.Sheen( + intensity: 1, + staticGlintIntensity: 0.25, + // A phone held upright: the glint sits near the lower edge + // instead of washing out the card's central content. + staticPose: .init(roll: 0, pitch: -1), + ), frameOuterLineWidth: 3.5, showsPerforationRing: true, innerFrameInset: 16, @@ -429,7 +450,12 @@ extension WhereStylesheet { dayUnitFont: .subheadline.weight(.medium), watermarkFontSize: 96, watermarkOffset: CGSize(width: 12, height: 10), - holographicIntensity: 0.5, + sheen: CardStyle.Sheen( + intensity: 0.5, + staticGlintIntensity: 0.5, + // Preserve the compact card's existing neutral treatment. + staticPose: .init(roll: 0, pitch: 0), + ), frameOuterLineWidth: 2.5, showsPerforationRing: false, innerFrameInset: 12, diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index a9edd799..820b70cf 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -37,7 +37,11 @@ struct WhereStylesheetTests { #expect(card.regionNameTracking == -0.5) #expect(card.watermarkFontSize == 150) #expect(card.watermarkOffset == CGSize(width: 20, height: 12)) - #expect(card.holographicIntensity == 1) + #expect(card.sheen == .init( + intensity: 1, + staticGlintIntensity: 0.25, + staticPose: .init(roll: 0, pitch: -1), + )) #expect(card.frameOuterLineWidth == 3.5) #expect(card.showsPerforationRing) #expect(card.innerFrameInset == 16) @@ -62,7 +66,11 @@ struct WhereStylesheetTests { #expect(card.regionNameTracking == 0) #expect(card.watermarkFontSize == 96) #expect(card.watermarkOffset == CGSize(width: 12, height: 10)) - #expect(card.holographicIntensity == 0.5) + #expect(card.sheen == .init( + intensity: 0.5, + staticGlintIntensity: 0.5, + staticPose: .init(roll: 0, pitch: 0), + )) #expect(card.frameOuterLineWidth == 2.5) #expect(!card.showsPerforationRing) #expect(card.innerFrameInset == 12) From cff9ce2cfa5f84a1513e38f380575f6b016ec8ba Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sat, 1 Aug 2026 21:06:14 -0700 Subject: [PATCH 03/28] Expand the region color palette --- .../Sources/Regions/RegionAppearance.swift | 12 +++++- .../Tests/RegionAppearanceTests.swift | 7 ++++ .../regionCustomize.Editor_iPhone.png | 4 +- .../regionCustomize.Editor_iPhone_dark.png | 4 +- .../Model/RegionAppearanceCatalog.swift | 42 ++++++++++++++++--- .../Sources/Regions/RegionCustomizeView.swift | 2 +- .../Tests/RegionAppearanceCatalogTests.swift | 24 +++++++++++ 7 files changed, 83 insertions(+), 12 deletions(-) create mode 100644 Where/WhereUI/Tests/RegionAppearanceCatalogTests.swift diff --git a/Where/WhereCore/Sources/Regions/RegionAppearance.swift b/Where/WhereCore/Sources/Regions/RegionAppearance.swift index 9e1913c9..3d1f7132 100644 --- a/Where/WhereCore/Sources/Regions/RegionAppearance.swift +++ b/Where/WhereCore/Sources/Regions/RegionAppearance.swift @@ -5,8 +5,9 @@ import Foundation /// processes, backups, and the CloudKit mirror; the presentation layer /// (`WhereUI.RegionStyle`) maps each token to a concrete SwiftUI color. /// -/// The cases mirror the historical `RegionStyle` default palette so an -/// unpicked region and a picked-then-matched one render identically. +/// The first cases mirror the historical `RegionStyle` default palette so an +/// unpicked region and a picked-then-matched one render identically. Additional +/// picker colors append to that set so existing raw values stay stable. public enum RegionColorToken: String, CaseIterable, Sendable, Codable, Hashable { case orange case indigo @@ -19,6 +20,13 @@ public enum RegionColorToken: String, CaseIterable, Sendable, Codable, Hashable case purple case pink case brown + case gold + case lime + case coral + case magenta + case silver + case slate + case charcoal } /// The user-chosen look for a region: an accent color token, an emoji, and an diff --git a/Where/WhereCore/Tests/RegionAppearanceTests.swift b/Where/WhereCore/Tests/RegionAppearanceTests.swift index c57e4c19..ff29398f 100644 --- a/Where/WhereCore/Tests/RegionAppearanceTests.swift +++ b/Where/WhereCore/Tests/RegionAppearanceTests.swift @@ -18,6 +18,13 @@ struct RegionAppearanceTests { "purple", "pink", "brown", + "gold", + "lime", + "coral", + "magenta", + "silver", + "slate", + "charcoal", ]) } diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png index 0607a3f9..c76a14c5 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ac85e28010adac8a4513b919ede12f44e1f1fb4752686fc7ada17914ba62507a -size 1438989 +oid sha256:1df3c4d73e5186fc00063d4531e9a44e9e2dff3b30eb1822a833f6dae853d1a3 +size 1425061 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png index 9279d449..abb9e881 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9fb453832a5a559690761a5b0d8228b52707f3b6cf0683d8f1edb2246552bc55 -size 1491912 +oid sha256:7fe85fcaebfd64ea42beb9fd971d050e07b2f9520e1b60cc3b9fa7c882c1b45b +size 1479141 diff --git a/Where/WhereUI/Sources/Model/RegionAppearanceCatalog.swift b/Where/WhereUI/Sources/Model/RegionAppearanceCatalog.swift index 57a94d29..afb529e4 100644 --- a/Where/WhereUI/Sources/Model/RegionAppearanceCatalog.swift +++ b/Where/WhereUI/Sources/Model/RegionAppearanceCatalog.swift @@ -8,9 +8,9 @@ import WhereCore /// lives in `WhereUI` next to the pickers and `RegionStyle` (which shares the /// color mapping and default look). public enum RegionAppearanceCatalog { - /// Selectable accent colors, in the order the picker lays them out. The full - /// token set — matching `RegionStyle`'s historical default palette so a - /// picked color reads the same as an auto-assigned one. + /// Selectable accent colors, in the order the picker lays them out. Every + /// persisted token is pickable; additions append so familiar choices keep + /// their positions. public static let colors: [RegionColorToken] = RegionColorToken.allCases /// Selectable emoji, grouped loosely by theme (places, nature, transit, @@ -122,11 +122,27 @@ public enum RegionAppearanceCatalog { RegionAppearance(color: .teal, emoji: "🧭", symbolName: "location.magnifyingglass"), ] + /// Keep id-derived defaults stable as the user-selectable palette grows. + /// These are the eleven colors used by the original hash mapping. + private static let defaultColors: [RegionColorToken] = [ + .orange, + .indigo, + .red, + .blue, + .teal, + .green, + .mint, + .cyan, + .purple, + .pink, + .brown, + ] + /// A stable accent token derived from the region id, matching the order of - /// ``colors`` so an auto-assigned color is one the picker can also show. + /// the original palette so adding picker choices doesn't recolor a region. private static func defaultColor(for id: String) -> RegionColorToken { let sum = id.unicodeScalars.reduce(0) { $0 &+ Int($1.value) } - return colors[sum % colors.count] + return defaultColors[sum % defaultColors.count] } } @@ -146,6 +162,22 @@ extension RegionColorToken { case .purple: .purple case .pink: .pink case .brown: .brown + case .gold: Color(red: 0.65, green: 0.43, blue: 0) + case .lime: Color(red: 0.40, green: 0.62, blue: 0.08) + case .coral: Color(red: 0.88, green: 0.26, blue: 0.22) + case .magenta: Color(red: 0.72, green: 0.12, blue: 0.52) + case .silver: Color(red: 0.55, green: 0.55, blue: 0.55) + case .slate: Color(red: 0.45, green: 0.45, blue: 0.45) + case .charcoal: Color(red: 0.35, green: 0.35, blue: 0.35) + } + } + + /// Selection glyph color chosen for contrast against each swatch. + var selectionForeground: Color { + switch self { + case .gold, .lime, .silver: .black + case .orange, .indigo, .red, .blue, .teal, .green, .mint, .cyan, + .purple, .pink, .brown, .coral, .magenta, .slate, .charcoal: .white } } } diff --git a/Where/WhereUI/Sources/Regions/RegionCustomizeView.swift b/Where/WhereUI/Sources/Regions/RegionCustomizeView.swift index 933d9544..a1ce9312 100644 --- a/Where/WhereUI/Sources/Regions/RegionCustomizeView.swift +++ b/Where/WhereUI/Sources/Regions/RegionCustomizeView.swift @@ -96,7 +96,7 @@ struct RegionAppearanceEditor: View { if isSelected { Image(systemName: "checkmark") .font(.caption.weight(.bold)) - .foregroundStyle(.white) + .foregroundStyle(token.selectionForeground) } } } diff --git a/Where/WhereUI/Tests/RegionAppearanceCatalogTests.swift b/Where/WhereUI/Tests/RegionAppearanceCatalogTests.swift new file mode 100644 index 00000000..d8066f1b --- /dev/null +++ b/Where/WhereUI/Tests/RegionAppearanceCatalogTests.swift @@ -0,0 +1,24 @@ +import RegionKit +import Testing +@testable import WhereCore +@testable import WhereUI + +/// The picker exposes every stored color while id-derived fallback appearances +/// remain stable as new choices are added. +struct RegionAppearanceCatalogTests { + @Test func selectorIncludesEveryColorToken() { + #expect(RegionAppearanceCatalog.colors == RegionColorToken.allCases) + } + + @Test func expandedSelectorDoesNotRecolorExistingDefaults() throws { + let texas = try #require(Region(rawValue: "us-TX")) + #expect(RegionAppearanceCatalog.defaultAppearance(for: texas).color == .pink) + } + + @Test func lightSwatchesUseDarkSelectionGlyphs() { + #expect(RegionColorToken.gold.selectionForeground == .black) + #expect(RegionColorToken.lime.selectionForeground == .black) + #expect(RegionColorToken.silver.selectionForeground == .black) + #expect(RegionColorToken.coral.selectionForeground == .white) + } +} From e59d41f7c91b8d51f8d80fbab448c086a30be932 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sat, 1 Aug 2026 21:23:25 -0700 Subject: [PATCH 04/28] Round calendar month cards --- .../calendarContent.Empty_iPhone.png | 4 ++-- .../calendarContent.Empty_iPhone_dark.png | 4 ++-- .../calendarContent.Focused_iPhone.png | 4 ++-- .../calendarContent.Focused_iPhone_dark.png | 4 ++-- .../calendarContent.FullContent_fullHeight.png | 4 ++-- .../calendarContent.MissingDays_iPhone.png | 4 ++-- .../calendarContent.MissingDays_iPhone_dark.png | 4 ++-- .../calendarContent.WithData_iPad.png | 4 ++-- .../calendarContent.WithData_iPad_ax5.png | 4 ++-- .../calendarContent.WithData_iPad_contrast.png | 4 ++-- .../calendarContent.WithData_iPad_dark.png | 4 ++-- .../calendarContent.WithData_iPhone.png | 4 ++-- .../calendarContent.WithData_iPhone_ax5.png | 4 ++-- .../calendarContent.WithData_iPhone_contrast.png | 4 ++-- .../calendarContent.WithData_iPhone_dark.png | 4 ++-- .../YearViewSnapshotTests/year.Empty_iPhone.png | 4 ++-- .../YearViewSnapshotTests/year.Empty_iPhone_dark.png | 4 ++-- .../YearViewSnapshotTests/year.Loaded_iPad.png | 4 ++-- .../YearViewSnapshotTests/year.Loaded_iPad_ax5.png | 4 ++-- .../year.Loaded_iPad_contrast.png | 4 ++-- .../YearViewSnapshotTests/year.Loaded_iPad_dark.png | 4 ++-- .../YearViewSnapshotTests/year.Loaded_iPhone.png | 4 ++-- .../year.Loaded_iPhone_contrast.png | 4 ++-- .../YearViewSnapshotTests/year.Loaded_iPhone_dark.png | 4 ++-- .../WhereUI/Sources/Primary/CalendarContentView.swift | 11 ++++++++--- Where/WhereUI/Sources/Shared/WhereStylesheet.swift | 2 +- Where/WhereUI/Tests/WhereStylesheetTests.swift | 2 +- 27 files changed, 58 insertions(+), 53 deletions(-) diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Empty_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Empty_iPhone.png index f4d72ff3..865e986a 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Empty_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Empty_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5a7d204ae4d06a92f8eec6fed9149b9b05d83c17c8bf17e45d88401e27c3c497 -size 230769 +oid sha256:27363b32430b1e790678e86073d935582034051af2ecbaad4a038ae0853eb8d7 +size 233676 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Empty_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Empty_iPhone_dark.png index 176fc047..aa7c29cd 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Empty_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Empty_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bf91161f1abb200833b3c30c06a62060843711a97651fa22b613cc6e7bc845ea -size 215910 +oid sha256:21df3bcee49550abaff656debc831422383392998c9302a3cc3f46334d6414c5 +size 219802 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone.png index a7f02d23..62295173 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1c7b9816b498ed3774265ec55fef6867db63e860f07f8a170fc076e9e2b6c859 -size 229382 +oid sha256:7af20df893dd783f0202d928738781ebf80ad18905930d3f992e6df51c35dc15 +size 231836 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone_dark.png index 1b15d4af..0e7782ac 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.Focused_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7f712b7cba1118663a980261731c95660209227ec623636db4b77664eb6d8a09 -size 223733 +oid sha256:2daa771d69ea06e5b711bda555b90cd77dfcd5005822b647242c7f4d1b4f2ec9 +size 227403 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FullContent_fullHeight.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FullContent_fullHeight.png index adb965d3..a85a8c19 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FullContent_fullHeight.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.FullContent_fullHeight.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:73cf42497c984cf8969d5eee07ead9ec24ee23f5f40f576163b4bd8699955038 -size 935687 +oid sha256:d36936ffe1baba74574625dd9d76a5496bfffe67af542f03b1ea7907c23f73d4 +size 950118 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone.png index 76bbc491..78606053 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6a7c1914c1ab6e21f81fdcb280c7ec2f830b05f5edb0b99a1d56c5a9fbb7fa00 -size 240143 +oid sha256:011913e734d982f021e0a3e4dac58a5789cad4ad04d896932a16a79b4b945e2a +size 243382 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone_dark.png index 6023c33e..af95c04a 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.MissingDays_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:49828d4a5570bf18aa652076ab008457701e61a837d1c87ac335077bc031892a -size 228571 +oid sha256:598b44a3ab1cc4e6a5ec96546132be1ee192b4929eff5468461e034a7f3af86a +size 232603 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad.png index f3bcd2e6..92502950 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ebc42dede2a3163790b81f9104fd371f3e014e5dfed09eea8f8646d5f4445595 -size 438357 +oid sha256:0c4cae71c3e1d843b0c1b20cc014aaa7a0ecd07795adbf95f56b7e86bf69d08e +size 442184 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad_ax5.png index 7f458926..c6bc850f 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:01cdfc319a03b091f79ae6ed6cdb6e30fec88a220984c964585f7265bd5037c2 -size 470313 +oid sha256:2faf4d2846da94596fb5c05ff5a3ea244ab0cd4851ad3e68457cbb1c0186a47e +size 473401 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad_contrast.png index 287c0b04..5fe661ff 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b8fc7751003a6025ce760d4d1e5f2c06771300ce184071f99410bd0871f0c08a -size 437973 +oid sha256:f6482f3e92fb1fb932cd8b5cc2052ef65b52699f49397273398adfcc5e39ee62 +size 441843 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad_dark.png index 45b185fa..a4b6e60c 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3bb7618414c4e3f7249fd12ecd8b99971b3daf1617a8e39efa3b2b279b5b04dd -size 425876 +oid sha256:612a174143be1e1fdb6db632dfc5a876c9ddfbf87b50b723b2439d7afb1f825f +size 431190 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone.png index dfea28c4..a2c59c09 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c670ed95535ac8fa2d12e2b4db785419be7e3c719c31d77e7cf8be00cef868c1 -size 229699 +oid sha256:143be88ab5f924720e54147333ce49a2b977e0fc845e364202e67df1653587e7 +size 232147 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone_ax5.png index 08f3d7c9..c12755a5 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:965e45338356cf3aed60b131c8cfe495d7bf26d01f8c7c07f6d3c777ccac0f01 -size 225819 +oid sha256:f105d15ff57d202a34224e517ca22a7e0522b4bb0a201f1ee612dac8261f020e +size 227728 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone_contrast.png index e4287791..39878959 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7a5a65fa2831399c0c77a745ffc1de318b107a675d8f87cdb31775c6596ebdd8 -size 230249 +oid sha256:04471e8d4179595ebaf5fcd6618ddb90d389d4489ff831135c9b2f4b3a5af439 +size 232962 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone_dark.png index c097b2d0..afb8fa5a 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.WithData_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:30feff2ebb695d9df349ca233ca2fca4d6dfbb8513c7ea6cf7b85f595be52a3c -size 224160 +oid sha256:b5e890866cc5ab8723494e6fcd8c7a836f9c583e6fb72f58ff25d892c339dc09 +size 227854 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone.png index 38ab407d..70e85e3d 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7c36ff8383cc51f788115b9ae0aa7fe33662ae6389e079ef3e85269491f780e2 -size 266947 +oid sha256:945a0d94dc2f9e50f4b0345dec292e2278d347c2e84cb83785a6a6fb31ca9159 +size 269795 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone_dark.png index 1be8b55a..1309a6bb 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:54e9f3fca9658a369823756b32fab42927ff44529e81a19f9e2e7a133ffb8a89 -size 260456 +oid sha256:4fde62fde198e7ef8178834a2eccdd60a890fc3254214aed97e285a636ef4aee +size 264129 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad.png index 7a0e86b0..fb9a39c0 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c99bdee6dd2c23aa4c25792063d4114be45113089ece2e48ba29ffe791ab881d -size 483436 +oid sha256:6f00159a4afbcc7274dd0aee85e3fceb7be782476f4a6f9803abd8428aaa5e33 +size 486665 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_ax5.png index 86e34693..4c44ee2b 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0b964570700ba0a5ce9846fe2aba0345924135e8ad9d81e5b9cad1c03c48705e -size 601893 +oid sha256:65c2448e82900ffff7c0379c22b2118b7202403a6d19abf1d58b0e8b486ebc45 +size 603979 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_contrast.png index 4bb80560..28397300 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ef3286cdee7f78af8860ccfc7d594e351359f5a2fb0e43c8bb73785b573305a7 -size 476706 +oid sha256:684c39aca2e67374c35cbcc50a16ffbbbdc6f1b917decc2f9a91b07a76f25341 +size 479957 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_dark.png index fbc90744..18ea70ce 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d74f4fe06ec8e51f754bc26ac5c9e29e68bfe4eba7972ca6c4790489df28b09f -size 478250 +oid sha256:cd70fa1e89fc54e3710f82df93c3fa100db4b49ebd70382fd52be60854cf8ff7 +size 482600 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone.png index f0b1645c..fb21bfd9 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b64ba2fd768f0b7252d5c35f94c5f180a77e4d35bbea26658ec131a34628b580 -size 276870 +oid sha256:9cd69b6f85a3586a00d689297ad19cefe52d92df533566264ce2df0a755aef58 +size 278922 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_contrast.png index d006cd43..4e2929d7 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:55beddd007619b7500ecf1b24e62595805f7b4ebafd7da914f39b2d740761665 -size 269184 +oid sha256:5f0b206d54f27e7dc830a1b1da09d7be59a6f44c2865caa30fc6a4010fc6e7b7 +size 271304 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_dark.png index cfb8bf13..a564be3f 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b2105f5870233277326e52e2cb4150aeeac5547206a7c48c841404b543161fed -size 280470 +oid sha256:b534878f07d092da42e9ca2c636f2204c40dc7e103619f4cbe974f3dbe4812e8 +size 283530 diff --git a/Where/WhereUI/Sources/Primary/CalendarContentView.swift b/Where/WhereUI/Sources/Primary/CalendarContentView.swift index 9da8d7e1..2801e2f2 100644 --- a/Where/WhereUI/Sources/Primary/CalendarContentView.swift +++ b/Where/WhereUI/Sources/Primary/CalendarContentView.swift @@ -272,6 +272,12 @@ private struct MonthGridView: View { month.isCurrentMonth ? calendar.month.current : calendar.month.plain } + /// Shared by the fill and border so every month uses the same continuous + /// corner geometry. + private var cardShape: RoundedRectangle { + RoundedRectangle(cornerRadius: calendar.month.cornerRadius, style: .continuous) + } + var body: some View { VStack(alignment: .leading, spacing: calendar.month.sectionSpacing) { Text(month.startOfMonth.formatted(.dateTime.month(.wide))) @@ -311,11 +317,10 @@ private struct MonthGridView: View { .background { // Past and future months share the plain card; the current one // gets the accent card (bluer wash, text, and heavier border). - RoundedRectangle(cornerRadius: calendar.month.cornerRadius) + cardShape .fill(card.fill) .overlay { - RoundedRectangle(cornerRadius: calendar.month.cornerRadius) - .strokeBorder(card.border, lineWidth: card.borderWidth) + cardShape.strokeBorder(card.border, lineWidth: card.borderWidth) } } } diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index ba4ba78d..762180f6 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -630,7 +630,7 @@ extension WhereStylesheet { sectionSpacing: 8, gridSpacing: 6, padding: 16, - cornerRadius: 21, + cornerRadius: 28, plain: MonthStyle.Card( fill: Color.primary.opacity(0.03), border: Color.primary.opacity(0.12), diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index 820b70cf..403f1593 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -156,7 +156,7 @@ struct WhereStylesheetTests { #expect(month.sectionSpacing == 8) #expect(month.gridSpacing == 6) #expect(month.padding == 16) - #expect(month.cornerRadius == 21) + #expect(month.cornerRadius == 28) #expect(month.plain.fill == Color.primary.opacity(0.03)) #expect(month.plain.border == Color.primary.opacity(0.12)) #expect(month.plain.borderWidth == 2) From f52b9d632c2a5403d82ab51e858e296e30e4162b Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sat, 1 Aug 2026 21:50:16 -0700 Subject: [PATCH 05/28] Use a color-neutral card sheen --- .../locations.Loaded_iPad.png | 4 +- .../locations.Loaded_iPad_accessibility.png | 4 +- .../locations.Loaded_iPad_ax5.png | 4 +- .../locations.Loaded_iPad_contrast.png | 4 +- .../locations.Loaded_iPad_dark.png | 4 +- .../locations.Loaded_iPhone.png | 4 +- .../locations.Loaded_iPhone_accessibility.png | 4 +- .../locations.Loaded_iPhone_ax5.png | 4 +- .../locations.Loaded_iPhone_contrast.png | 4 +- .../locations.Loaded_iPhone_dark.png | 4 +- .../locations.MissingDays_iPhone.png | 4 +- .../locations.MissingDays_iPhone_dark.png | 4 +- .../regionCustomize.Editor_iPhone.png | 4 +- .../regionCustomize.Editor_iPhone_dark.png | 4 +- .../regionCustomize.NeutralEditor_iPhone.png | 3 ++ ...ionCustomize.NeutralEditor_iPhone_dark.png | 3 ++ .../Sources/Primary/LocationsView.swift | 2 +- .../Sources/Primary/RegionSummaryCard.swift | 7 ++- .../Sources/Regions/RegionCustomizeView.swift | 16 +++++- .../WhereUI/Sources/Shared/TiltProvider.swift | 4 +- ...HolographicSheen.swift => TiltSheen.swift} | 49 ++++++++++--------- .../Sources/Shared/WhereStylesheet.swift | 6 +-- 22 files changed, 82 insertions(+), 64 deletions(-) create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png rename Where/WhereUI/Sources/Shared/{HolographicSheen.swift => TiltSheen.swift} (70%) diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png index cc50e328..731ed68a 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2cd3d5a254a7cfcebf57c02d03164591a61f97e0e3876328f58953c2a8a4cf2d -size 3345081 +oid sha256:eb8708fb9b5a10c60b342fdf44608fcab24fb5e4597258838028bb0486d5403f +size 3107737 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png index ff52621e..678b7814 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ea1b0010ba7732cc36963d730a648b3c40f5281facc0bd1e785837ef818fb633 -size 2225128 +oid sha256:0548233d268cb670692a48cf088e1914a4dffca6c7d5c9856390a55490474a95 +size 2180703 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png index 15895359..6e4ec933 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d141174d17f8397059a82b4de7e73093f2d271a2a0f522f7382084fe690ea493 -size 3738500 +oid sha256:def1dbed028ceed82b49478088c3302466365503a31010ea279382a85db4054c +size 3433538 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png index 0162632e..12b70c5b 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3ac920157a6e48c555aed86d9727ba7d7d5ccea443d75a1194f452d979befadb -size 3155840 +oid sha256:76abbf12c51b6133ceda4fe17a1d77e1f5710666ad6f96f78748aa88f8ee25f8 +size 3079328 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png index 9b6d270d..ab9dc355 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b0eee8773864ce7f041793152a91c77d04f0711fa25707a5ca9755d269a8410d -size 3473132 +oid sha256:46cda98dbc08d9fd9dcf54b3f53026cde4527131baa75f5dc7b1236260c4fefd +size 3394017 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png index f5004a0d..e8bb90df 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5ec2a37fb0c33d996a99815d045317800410caae7f3a5b0d2b3820691dca7dcd -size 2126748 +oid sha256:e5f19e44438514e33c4760a5c540a8160152274908bfba25d9f5be37178f40eb +size 1971548 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png index fb0ff290..e26fc39c 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:44201c529f70e11bf5264077594b81a289a8033d3574fb04a851c005080c5218 -size 1327492 +oid sha256:973de8d23fc800524a1f3d9fcb920f0e4c0fc670d1ab57efba0b6a17f06495f4 +size 1328226 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png index 24ecbeaa..b25f4cdb 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:084bd240f60f3b02e04c41913b19c24a27e35b2321bdac0a6b2c8bb54b3e4e7c -size 2560474 +oid sha256:7fe0dd5d7772525e4bdd0a96a82b93aedb6615d41b6456a3012c59b1023a91f8 +size 2385087 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png index d637bd80..3f858e3d 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:14bf27fb00a8185ce9da6a68cc395b38306acf843cd5fed2b60cdd823527c974 -size 1984077 +oid sha256:24e5969a7f385aa32f5f1fc654988a2483303b5995744443efcda53aede02491 +size 1900505 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png index c08f708e..21dbd702 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e06f8ba54337c7b8e99bf9f64b89a55fca59c877e94eb0031b180eb3a369ab2a -size 2177443 +oid sha256:1d0f584954419773c08b0962cdc63e27581a92140b16d68ffb1667473f513f2a +size 2049508 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png index 09fb0896..55753a7b 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:edf92b1589b64155e9d6e44ad0b8550ec200d1197bfb26f233e61f1312e11fd2 -size 1206659 +oid sha256:19342662b13e5a2ff71469addc806ff2e5ca90d1142d60c3bde18e18b7084f52 +size 1110477 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png index f45f1c95..197a281a 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:af7cdd56320d2a0538c55c8a61381e8d3a7b15beeab6c3a0122ffd8b66923c30 -size 1174594 +oid sha256:7750844b63f1a1eb440df62f1fbf89122ccbf78bce73c10311d5907c604a1129 +size 1113232 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png index c76a14c5..643fa736 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1df3c4d73e5186fc00063d4531e9a44e9e2dff3b30eb1822a833f6dae853d1a3 -size 1425061 +oid sha256:e6a602e331f0e97c694c6a0bc449f9694b677cf13bded0dd1197a929e88138a1 +size 1343926 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png index abb9e881..7010fc2c 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7fe85fcaebfd64ea42beb9fd971d050e07b2f9520e1b60cc3b9fa7c882c1b45b -size 1479141 +oid sha256:57f4b9b7679b1bd70b68a7c13db36071bb38008f9e47d02171cf84cc4967c16b +size 1419713 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone.png new file mode 100644 index 00000000..4825d1d2 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d4f0017b5980224bd730d990a07ee644341b3e4048c592608f2f332c7a6cafbd +size 1286023 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png new file mode 100644 index 00000000..151d521a --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c859768dd14bf2565c52ab8470880cec2a8628325d0f443816846c991b583ad4 +size 1221176 diff --git a/Where/WhereUI/Sources/Primary/LocationsView.swift b/Where/WhereUI/Sources/Primary/LocationsView.swift index 9457b21c..35a13b5a 100644 --- a/Where/WhereUI/Sources/Primary/LocationsView.swift +++ b/Where/WhereUI/Sources/Primary/LocationsView.swift @@ -13,7 +13,7 @@ struct LocationsView: View { @State private var showingResolution = false - /// Drives the region cards' tilt-reactive holographic sheen. Started/stopped + /// Drives the region cards' tilt-reactive light sheen. Started/stopped /// with the view's lifecycle; a no-op on hardware without device motion. @State private var tilt = TiltProvider() diff --git a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift index 9bd33d17..a829f71a 100644 --- a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift @@ -32,8 +32,8 @@ struct RegionSummaryCard: View { /// pass `WhereSession.selectedYear`; the default is only for previews. var year = WhereModel.currentYear - /// Drives the holographic stamp sheen. Locations and the region editor pass - /// a live `TiltProvider`; callers without one use the card's static pose. + /// Drives the card's light sheen. Locations and the region editor pass a + /// live `TiltProvider`; callers without one use the card's static pose. var tilt: TiltProvider? /// An explicit style to render instead of resolving the region's look from @@ -264,14 +264,13 @@ struct RegionSummaryCard: View { .interactive(interactive), in: cardShape, ) - .holographicSheen( + .tiltSheen( roll: sheenPose.roll, pitch: sheenPose.pitch, staticRoll: card.sheen.staticPose.roll, staticPitch: card.sheen.staticPose.pitch, isStatic: isSheenStatic, in: cardShape, - tint: .white, intensity: card.sheen.intensity, staticGlintIntensity: card.sheen.staticGlintIntensity, ) diff --git a/Where/WhereUI/Sources/Regions/RegionCustomizeView.swift b/Where/WhereUI/Sources/Regions/RegionCustomizeView.swift index a1ce9312..14b125ce 100644 --- a/Where/WhereUI/Sources/Regions/RegionCustomizeView.swift +++ b/Where/WhereUI/Sources/Regions/RegionCustomizeView.swift @@ -12,8 +12,8 @@ struct RegionAppearanceEditor: View { @Bindable var model: PrimaryRegionSelectionModel let region: Region - /// Matches the Locations card's live holographic response while this editor - /// is visible; the card uses its static pose until the first sample arrives. + /// Matches the Locations card's live sheen response while this editor is + /// visible; the card uses its static pose until the first sample arrives. @State private var tilt = TiltProvider() @Environment(\.stylesheet) private var stylesheet @@ -271,6 +271,18 @@ struct RegionCustomizeView: View { RegionCustomizeView(model: PreviewSupport.primaryRegionSelectionModel()) } } + + whereSnapshot(name: "NeutralEditor", configurations: .phoneLightDark) { + NavigationStack { + RegionCustomizeView(model: neutralPreviewModel()) + } + } + } + + private static func neutralPreviewModel() -> PrimaryRegionSelectionModel { + let model = PreviewSupport.primaryRegionSelectionModel() + model.setColor(.slate, for: .california) + return model } } diff --git a/Where/WhereUI/Sources/Shared/TiltProvider.swift b/Where/WhereUI/Sources/Shared/TiltProvider.swift index 95affef9..c7a9e68e 100644 --- a/Where/WhereUI/Sources/Shared/TiltProvider.swift +++ b/Where/WhereUI/Sources/Shared/TiltProvider.swift @@ -2,8 +2,8 @@ import CoreMotion import Observation /// Publishes the device's tilt as a normalized `roll`/`pitch` pair so the -/// passport's holographic sheen can react to how the phone is held — the same -/// trick a foil trading card or a real passport's hologram uses. +/// card's light sheen can react to how the phone is held, like a coated card +/// shifting beneath a light source. /// /// Backed by `CMMotionManager`'s device-motion gravity vector, whose `x`/`y` /// components already land in roughly `-1...1`. Device motion needs no diff --git a/Where/WhereUI/Sources/Shared/HolographicSheen.swift b/Where/WhereUI/Sources/Shared/TiltSheen.swift similarity index 70% rename from Where/WhereUI/Sources/Shared/HolographicSheen.swift rename to Where/WhereUI/Sources/Shared/TiltSheen.swift index 8c853957..02f0cd94 100644 --- a/Where/WhereUI/Sources/Shared/HolographicSheen.swift +++ b/Where/WhereUI/Sources/Shared/TiltSheen.swift @@ -1,25 +1,23 @@ import SwiftUI -/// A holographic "foil" sheen overlay — a translucent rainbow wash plus a -/// bright specular glint — that slides with the device's tilt, the way light -/// catches the hologram on a passport page or a foil trading card. Composited -/// over whatever it modifies (typically a Liquid Glass card), clipped to the -/// card's shape, and non-interactive. +/// A color-neutral sheen overlay — a grayscale luminance wash plus a soft +/// specular glint — that slides with the device's tilt, the way light catches a +/// coated card. Soft-light compositing preserves the card's underlying region +/// hue, then the result is clipped to the card's shape and made non-interactive. /// /// Driven by normalized `roll`/`pitch` (see `TiltProvider`). A caller also /// supplies the deterministic pose used when motion must stay static, so Reduce /// Motion and snapshot capture never depend on a live sensor reading. -struct HolographicSheen: ViewModifier { +struct TiltSheen: ViewModifier { var roll: Double var pitch: Double var staticRoll: Double var staticPitch: Double var isStatic: Bool var shape: ClipShape - var tint: Color = .white - /// Rainbow-foil and live-glint strength, `0...1`. + /// Grayscale-wash and live-glint strength, `0...1`. var intensity: Double = 1 - /// Additive white-glint strength when `usesStaticPose`, `0...1`. + /// White-glint strength when `usesStaticPose`, `0...1`. var staticGlintIntensity: Double = 1 @MotionIsStatic private var motionIsStatic @@ -60,55 +58,58 @@ struct HolographicSheen: ViewModifier { ) ZStack { - // Rainbow foil that slides laterally as the device rolls. + // A color-neutral luminance wash that slides as the device + // rolls, preserving the region tint beneath it. LinearGradient( - colors: Self.foilHues, + colors: Self.luminanceStops, startPoint: UnitPoint(x: activeRoll * 0.3, y: 0), endPoint: UnitPoint(x: 1 + activeRoll * 0.3, y: 1), ) .opacity(0.28 * intensity) - .blendMode(.plusLighter) + .blendMode(.softLight) // Specular glint that tracks the tilt like a moving light. RadialGradient( - colors: [tint.opacity(0.85 * glintIntensity), tint.opacity(0)], + colors: [ + Color.white.opacity(0.85 * glintIntensity), + Color.white.opacity(0), + ], center: glint, startRadius: 0, endRadius: diagonal * 0.75, ) - .blendMode(.plusLighter) + .blendMode(.softLight) } } } - /// Translucent rainbow used for the foil wash. - private static var foilHues: [Color] { - [.pink, .purple, .blue, .cyan, .green, .yellow, .orange] + /// Alternating grayscale tones create changing light without introducing a + /// second hue into the region's palette. + private static var luminanceStops: [Color] { + [.white, .gray, .black, .gray, .white] } } extension View { - /// Overlay a tilt-reactive holographic sheen clipped to `shape`. Pass the - /// same shape used for the card's `glassEffect` so the sheen lines up. - func holographicSheen( + /// Overlay a tilt-reactive grayscale sheen clipped to `shape`. Pass the same + /// shape used for the card's `glassEffect` so the sheen lines up. + func tiltSheen( roll: Double, pitch: Double, staticRoll: Double, staticPitch: Double, isStatic: Bool, in shape: some Shape, - tint: Color = .white, intensity: Double = 1, staticGlintIntensity: Double = 1, ) -> some View { - modifier(HolographicSheen( + modifier(TiltSheen( roll: roll, pitch: pitch, staticRoll: staticRoll, staticPitch: staticPitch, isStatic: isStatic, shape: shape, - tint: tint, intensity: intensity, staticGlintIntensity: staticGlintIntensity, )) @@ -128,7 +129,7 @@ extension Double { RoundedRectangle(cornerRadius: 28, style: .continuous) .fill(.indigo.gradient) .frame(width: 320, height: 180) - .holographicSheen( + .tiltSheen( roll: 0.4, pitch: -0.2, staticRoll: 0.4, diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index 762180f6..a1c239c9 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -281,9 +281,9 @@ extension WhereStylesheet { struct Sheen: Equatable { var intensity: Double - /// Strength of only the additive white glint while the pose is - /// static; the rainbow foil keeps `intensity` so the card retains - /// its color instead of fading toward white. + /// Strength of only the white glint while the pose is + /// static; the grayscale wash keeps `intensity` so the card retains + /// dimensional light without fading toward white. var staticGlintIntensity: Double var staticPose: Pose From df8fdf7b6cc45e7ff7f09717fda2bee034145c19 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sat, 1 Aug 2026 22:26:11 -0700 Subject: [PATCH 06/28] Repeat region silhouettes across cards --- Where/RegionKit/AGENTS.md | 12 +- Where/RegionKit/README.md | 7 +- .../Logging/RegionGeometryCatalogLog.swift | 41 +++++- .../RegionKit/Sources/Logging/RegionLog.swift | 4 +- .../Sources/RegionGeometryCatalog.swift | 71 ++++++++- .../Tests/RegionGeometryCatalogTests.swift | 17 +++ Where/WhereUI/README.md | 5 + .../locations.Loaded_iPad.png | 4 +- .../locations.Loaded_iPad_ax5.png | 4 +- .../locations.Loaded_iPad_contrast.png | 4 +- .../locations.Loaded_iPad_dark.png | 4 +- .../locations.Loaded_iPhone.png | 4 +- .../locations.Loaded_iPhone_ax5.png | 4 +- .../locations.Loaded_iPhone_contrast.png | 4 +- .../locations.Loaded_iPhone_dark.png | 4 +- .../locations.MissingDays_iPhone.png | 4 +- .../locations.MissingDays_iPhone_dark.png | 4 +- .../regionCustomize.Editor_iPhone.png | 4 +- .../regionCustomize.Editor_iPhone_dark.png | 4 +- .../regionCustomize.NeutralEditor_iPhone.png | 4 +- ...ionCustomize.NeutralEditor_iPhone_dark.png | 4 +- .../Primary/RegionOutlineArtwork.swift | 138 ++++++++++++++++++ .../Sources/Primary/RegionSummaryCard.swift | 51 ++++++- .../Sources/Shared/WhereStylesheet.swift | 33 ++++- .../Tests/RegionOutlineArtworkTests.swift | 29 ++++ .../WhereUI/Tests/WhereStylesheetTests.swift | 13 ++ 26 files changed, 419 insertions(+), 58 deletions(-) create mode 100644 Where/WhereUI/Sources/Primary/RegionOutlineArtwork.swift create mode 100644 Where/WhereUI/Tests/RegionOutlineArtworkTests.swift diff --git a/Where/RegionKit/AGENTS.md b/Where/RegionKit/AGENTS.md index b3e629cb..9824334d 100644 --- a/Where/RegionKit/AGENTS.md +++ b/Where/RegionKit/AGENTS.md @@ -33,12 +33,14 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature checks its regions in order and the first polygon match wins (regions are mutually exclusive at our resolution). (Day-count ranking lives in `WhereCore`'s `Region+Ordering`, not here.) -- **Attribution is per-region, on demand.** `RegionAttributor(for:)` loads only +- **Geometry access is per-region, on demand.** `RegionAttributor(for:)` loads only the passed regions' `regions/.geojson` files, so the app parses only the - tracked set — never the whole US at launch. `.all` loads the whole catalog - (dev viewer/tests); `.shared` the default four. It's UI-free: `BoundingBox` / - `LongitudeSpan` expose the min/max math, but MapKit conversion lives in the UI - layer. `RegionAttributing` lets `WhereCore` supply a live, swappable attributor. + tracked set, while `RegionGeometryCatalog.outlines(for: Region)` caches only + the drawable region requested by UI artwork — never load the whole US for one + card. `.all` loads the whole catalog (dev viewer/tests); `.shared` the default + four. It's UI-free: `BoundingBox` / `LongitudeSpan` expose the min/max math, + but drawing and MapKit conversion live in the UI layer. `RegionAttributing` + lets `WhereCore` supply a live, swappable attributor. - **Bundled geometry is credited in code, not only in prose.** `RegionDataSource` states each boundary set's origin, license, and fidelity, and derives its coverage from the catalog — the US sources by the `us-` id prefix the generator diff --git a/Where/RegionKit/README.md b/Where/RegionKit/README.md index c1575ba5..da7820ba 100644 --- a/Where/RegionKit/README.md +++ b/Where/RegionKit/README.md @@ -32,9 +32,10 @@ into it for lookup. RegionKit depends only on only those regions' files; `.all` covers the whole catalog and `.shared` the default four. `RegionAttributing` is the protocol the app's live, swappable attributor also conforms to. -- **`RegionGeometryCatalog`** — read-only drawable `RegionOutline`s for the - developer region-map viewer (`.attribution` for a given attributor vs `.source` - for the whole catalog). +- **`RegionGeometryCatalog`** — read-only drawable `RegionOutline`s: a cached, + per-region path for UI artwork, plus the developer region-map viewer's + `.attribution` view of a given attributor and `.source` view of the whole + catalog. - **`RegionDataSource`** — where the bundled geometry came from: the boundary set's name, its links, its `License`, its `Fidelity` (`.authoritative` vs the `.approximate` hand-drawn outlines), and the regions it covers. diff --git a/Where/RegionKit/Sources/Logging/RegionGeometryCatalogLog.swift b/Where/RegionKit/Sources/Logging/RegionGeometryCatalogLog.swift index 1fd377cb..4d9ae7f6 100644 --- a/Where/RegionKit/Sources/Logging/RegionGeometryCatalogLog.swift +++ b/Where/RegionKit/Sources/Logging/RegionGeometryCatalogLog.swift @@ -1,35 +1,64 @@ import PeriscopeCore -/// Structured events for the developer region-map viewer's geometry load. A -/// failed load is degraded-but-handled (the viewer shows an error state), so it -/// logs at `.warning`. Public because the viewer lives in WhereUI, above -/// RegionKit, and emits through ``RegionLog/geometryCatalog``. +/// Structured events for drawable geometry loads. A failed developer-viewer +/// load is degraded-but-handled and logs at `.warning`; a missing production +/// artwork resource is a bundled-data invariant and logs at `.fault`. Public +/// because the UI consumers live above RegionKit and emit through +/// ``RegionLog/geometryCatalog``. public enum RegionGeometryCatalogLog: LogEvent { /// Names the catalog's timed span. /// `Sendable` is spelled out because this is a `public` nested type — unlike /// the internal `SpanName`s elsewhere, it gets no inferred conformance, and /// `LogEvent.SpanName` requires one. - public enum SpanName: Hashable, Sendable { + public enum SpanName: Hashable, Sendable, CustomStringConvertible { /// The `.source` build: decoding *every* catalog region's GeoJSON at full /// authored fidelity, which is far heavier than attribution's tracked /// subset. Runs once per process behind the cache actor, so this span is /// what the viewer's first toggle to source actually costs. case buildSourceOutlines + /// The first request for one region's drawable outlines. Later requests + /// reuse the per-region cache. + case loadRegionOutlines(Region) + + public var description: String { + switch self { + case .buildSourceOutlines: "buildSourceOutlines" + case let .loadRegionOutlines(region): + "loadRegionOutlines(\(region.rawValue))" + } + } } /// Loading the outlines for a `RegionGeometryKind` failed. case loadFailed(kind: String, description: String) + /// Loading the bundled outlines used by region-specific artwork failed. + /// Bundled geometry is a programmer-owned invariant, so this is a fault. + case regionLoadFailed(region: Region, description: String) public static let eventName = "RegionGeometryCatalog" public var level: LogLevel { - .warning + switch self { + case .loadFailed: .warning + case .regionLoadFailed: .fault + } } public var message: String { switch self { case let .loadFailed(kind, description): "Region map viewer failed to load \(kind) geometry: \(description)" + case let .regionLoadFailed(region, description): + "Failed to load drawable outlines for \(region.rawValue): \(description)" + } + } + + public var externalID: String? { + switch self { + case .loadFailed: + nil + case let .regionLoadFailed(region, _): + region.regionURL.absoluteString } } } diff --git a/Where/RegionKit/Sources/Logging/RegionLog.swift b/Where/RegionKit/Sources/Logging/RegionLog.swift index d95e8d4b..8ead506a 100644 --- a/Where/RegionKit/Sources/Logging/RegionLog.swift +++ b/Where/RegionKit/Sources/Logging/RegionLog.swift @@ -31,7 +31,7 @@ public enum RegionLog { /// `RegionCatalog` — the bundled `regions.json` manifest load. static let catalog = root(RegionCatalogLog.self) - /// `RegionGeometryCatalog` — the developer region-map viewer's geometry - /// load. Public because the viewer lives in WhereUI, above RegionKit. + /// `RegionGeometryCatalog` — drawable geometry for region artwork and the + /// developer map viewer. Public because both consumers live above RegionKit. public static let geometryCatalog = root(RegionGeometryCatalogLog.self) } diff --git a/Where/RegionKit/Sources/RegionGeometryCatalog.swift b/Where/RegionKit/Sources/RegionGeometryCatalog.swift index 4578bb5d..9d4be0c5 100644 --- a/Where/RegionKit/Sources/RegionGeometryCatalog.swift +++ b/Where/RegionKit/Sources/RegionGeometryCatalog.swift @@ -47,10 +47,12 @@ public struct RegionOutline: Identifiable, Sendable, Hashable { } } -/// Failure decoding bundled region geometry. Surfaced (never swallowed) -/// so the viewer can show a real error state instead of an empty map. +/// Failure decoding bundled region geometry. Surfaced (never swallowed) so +/// developer tools can show a real error state and production artwork can log +/// a broken bundled-resource invariant instead of silently drawing nothing. public enum RegionGeometryError: Error { case missingResource(String) + case emptyResource(String) } extension RegionGeometryError: LocalizedError { @@ -62,15 +64,38 @@ extension RegionGeometryError: LocalizedError { switch self { case let .missingResource(resource): "Missing bundled region geometry resource “\(resource).geojson”." + case let .emptyResource(resource): + "Bundled region geometry resource “\(resource).geojson” contains no drawable outlines." } } } -/// Read-only catalog of region boundary geometry for the developer -/// region-map viewer. The single public entry point is -/// ``outlines(for:)``; UI never touches `RegionAttributor`'s internal +/// Read-only catalog of region boundary geometry for region artwork and the +/// developer region-map viewer. UI never touches `RegionAttributor`'s internal /// polygons or the `GeoJSON` decoder directly. public enum RegionGeometryCatalog { + /// Drawable outlines for one region, cached after the first request. + /// + /// This is the lightweight path for region-specific UI artwork: it decodes + /// only `region` rather than the full source catalog. `.other` returns an + /// empty array because it intentionally has no geometry. Missing, corrupt, + /// or empty bundled geometry logs a fault and asserts in debug; release + /// builds safely omit the decorative outline. + public static func outlines(for region: Region) async -> [RegionOutline] { + guard region != .other else { return [] } + do { + return try await RegionCache.shared.outlines(for: region) + } catch { + RegionLog.geometryCatalog(attachments: [.error(error, name: "geometry-error")]) { + .regionLoadFailed(region: region, description: error.localizedDescription) + } + assertionFailure( + "Failed to load drawable outlines for \(region.rawValue): \(error.localizedDescription)", + ) + return [] + } + } + /// Drawable outlines for `kind`. /// /// - `.attribution` reflects exactly what `attributor` loaded (the tracked @@ -147,6 +172,27 @@ public enum RegionGeometryCatalog { return try GeoJSON.namedPolygons(at: url) } + /// Decode one region for card/overlay artwork without loading unrelated + /// catalog entries. + private static func buildRegionOutlines(for region: Region) throws -> [RegionOutline] { + try RegionLog.geometryCatalog.measure(.loadRegionOutlines(region), budget: .seconds(1)) { + var builder = OutlineBuilder() + for feature in try namedPolygons(for: region) { + for polygon in feature.polygons { + builder.add( + title: region.localizedName, + region: region, + coordinates: polygon.vertices, + ) + } + } + guard !builder.outlines.isEmpty else { + throw RegionGeometryError.emptyResource(region.rawValue) + } + return builder.outlines + } + } + /// Caches the heavy `.source` decode (parsing every per-region file) so /// toggling back to source after the first load is instant. An `actor` both /// serializes the one-time build and runs it off the main thread. @@ -161,6 +207,21 @@ public enum RegionGeometryCatalog { return built } } + + /// Per-region cache for UI artwork. Actor isolation serializes simultaneous + /// requests for the same first-use decode without introducing a global + /// mutable registry in the UI layer. + private actor RegionCache { + static let shared = RegionCache() + private var cached: [Region: [RegionOutline]] = [:] + + func outlines(for region: Region) throws -> [RegionOutline] { + if let cached = cached[region] { return cached } + let built = try RegionGeometryCatalog.buildRegionOutlines(for: region) + cached[region] = built + return built + } + } } /// Accumulates `RegionOutline`s while assigning each a unique, stable diff --git a/Where/RegionKit/Tests/RegionGeometryCatalogTests.swift b/Where/RegionKit/Tests/RegionGeometryCatalogTests.swift index ebe8e587..5170bf96 100644 --- a/Where/RegionKit/Tests/RegionGeometryCatalogTests.swift +++ b/Where/RegionKit/Tests/RegionGeometryCatalogTests.swift @@ -8,6 +8,17 @@ struct RegionGeometryCatalogTests { // MARK: - Attribution + @Test func region_coversOnlyTheRequestedRegion() async { + let outlines = await RegionGeometryCatalog.outlines(for: .california) + #expect(!outlines.isEmpty) + #expect(outlines.allSatisfy { $0.region == .california }) + #expect(outlines.allSatisfy { $0.title == Region.california.localizedName }) + } + + @Test func otherRegion_hasNoDrawableOutlines() async { + #expect(await RegionGeometryCatalog.outlines(for: .other).isEmpty) + } + @Test func attribution_coversExactlyTheAttributorsRegions() async throws { let outlines = try await RegionGeometryCatalog.outlines( for: .attribution, @@ -136,4 +147,10 @@ struct RegionGeometryCatalogTests { #expect(error.errorDescription?.contains("us-states.geojson") == true) #expect(error.localizedDescription.contains("us-states.geojson")) } + + @Test func emptyResourceErrorNamesTheFile() { + let error = RegionGeometryError.emptyResource("us-CA") + #expect(error.errorDescription?.contains("us-CA.geojson") == true) + #expect(error.localizedDescription.contains("us-CA.geojson")) + } } diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 4e0daddd..ae3fb144 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -213,6 +213,11 @@ default empty resolver yields the fallback looks region-map viewer. The catalog also owns the selectable color/emoji/symbol option lists the picker shows. +Regular `RegionSummaryCard`s also request their cached `RegionOutline`s from +RegionKit and reuse the silhouette as both a large security-print watermark and +the seal inside the circular entry stamp. Compact cards retain the simpler +symbol treatment. + ## Previews Every previewable component ships a `#Preview` (wrapped in `#if DEBUG`) built diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png index 731ed68a..7962fce3 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eb8708fb9b5a10c60b342fdf44608fcab24fb5e4597258838028bb0486d5403f -size 3107737 +oid sha256:cca6ca98c010680bc6d82d051265bc5e5a28381e94a637bf0d236792c679e9ed +size 3119860 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png index 6e4ec933..2420a132 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:def1dbed028ceed82b49478088c3302466365503a31010ea279382a85db4054c -size 3433538 +oid sha256:d12277655d17c8aa405508e6adaed3c47acfaac92b17fb49d5224b7e9246ca12 +size 3452808 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png index 12b70c5b..d74c1a91 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:76abbf12c51b6133ceda4fe17a1d77e1f5710666ad6f96f78748aa88f8ee25f8 -size 3079328 +oid sha256:52633d05794aff7df1773b2e8cf084c48f5b783abf439ca1549ba467010f854b +size 3092863 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png index ab9dc355..6dd97d3a 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:46cda98dbc08d9fd9dcf54b3f53026cde4527131baa75f5dc7b1236260c4fefd -size 3394017 +oid sha256:f3942be76204fabf37253cdca4e84c75366196799bb78a339ef9d3ac7513722c +size 3407469 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png index e8bb90df..998967b3 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e5f19e44438514e33c4760a5c540a8160152274908bfba25d9f5be37178f40eb -size 1971548 +oid sha256:d95f2dc3c4fe1d33ee2e359fc796aaecf3f44ed0cc0b0c0b8ef8dc4536b251f1 +size 1974535 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png index b25f4cdb..f6eda84f 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7fe0dd5d7772525e4bdd0a96a82b93aedb6615d41b6456a3012c59b1023a91f8 -size 2385087 +oid sha256:c1b535a96dc2d8d9f9ac347fc0b07203d2b8a0c5eb3d3cec2202090d2d5d5d4d +size 2390456 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png index 3f858e3d..1483f4c6 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:24e5969a7f385aa32f5f1fc654988a2483303b5995744443efcda53aede02491 -size 1900505 +oid sha256:be8d71d61fc4cb564d78ef57c469a91941920647a6253651995a8187c0fc5e4f +size 1906143 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png index 21dbd702..74a86722 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1d0f584954419773c08b0962cdc63e27581a92140b16d68ffb1667473f513f2a -size 2049508 +oid sha256:483751b41266d48e05db5883588d8e1ae10bd6c8df6ea30d233de7f466594cb9 +size 2050351 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png index 55753a7b..1fc8a9ee 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:19342662b13e5a2ff71469addc806ff2e5ca90d1142d60c3bde18e18b7084f52 -size 1110477 +oid sha256:50107dea964c80d17287bafcee62faa43cefc93542718cf457de2ebe7fe7c6f6 +size 1113063 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png index 197a281a..d84d3190 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7750844b63f1a1eb440df62f1fbf89122ccbf78bce73c10311d5907c604a1129 -size 1113232 +oid sha256:49f2a69aacdbe8dc18fa84e39ed72ff6698081727d3f60bfb27144ddcaaace28 +size 1114588 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png index 643fa736..c36ac528 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e6a602e331f0e97c694c6a0bc449f9694b677cf13bded0dd1197a929e88138a1 -size 1343926 +oid sha256:a93fe569f81dbd60b8b740ddd784e35c37051b939813ad2033e58c14f390b13d +size 1347273 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png index 7010fc2c..9803514d 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:57f4b9b7679b1bd70b68a7c13db36071bb38008f9e47d02171cf84cc4967c16b -size 1419713 +oid sha256:2124bc4cf08defb2d84926b1d61648d7c779d4c1947bbe8217ec1f486ec0caf2 +size 1422893 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone.png index 4825d1d2..d25e8e08 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d4f0017b5980224bd730d990a07ee644341b3e4048c592608f2f332c7a6cafbd -size 1286023 +oid sha256:61ecf148e38501308af1464564461b483ea1bec3f54de38d44580d80170f50b3 +size 1287059 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png index 151d521a..04b0e619 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c859768dd14bf2565c52ab8470880cec2a8628325d0f443816846c991b583ad4 -size 1221176 +oid sha256:ea49dd2faf53d4b335e1b8155d56253ae63857e0f5eac09901b438014134b95b +size 1221232 diff --git a/Where/WhereUI/Sources/Primary/RegionOutlineArtwork.swift b/Where/WhereUI/Sources/Primary/RegionOutlineArtwork.swift new file mode 100644 index 00000000..a9339238 --- /dev/null +++ b/Where/WhereUI/Sources/Primary/RegionOutlineArtwork.swift @@ -0,0 +1,138 @@ +import RegionKit +import SwiftUI + +/// Projects RegionKit coordinates into the repeated silhouette artwork on a +/// regular region card, preserving geographic aspect and antimeridian-spanning +/// multipart regions. +struct RegionOutlineArtwork: View { + enum Placement { + case watermark + case stamp + } + + let outlines: [RegionOutline] + let tint: Color + let style: WhereStylesheet.CardStyle.RegionShape + let placement: Placement + + var body: some View { + Canvas { context, size in + guard + let box = BoundingBox.enclosing(outlines), + let longitudeSpan = LongitudeSpan.enclosing( + outlines.lazy.flatMap { outline in + outline.coordinates.lazy.map(\.longitude) + }, + ) + else { return } + + let latitudeSpan = max(box.maxLatitude - box.minLatitude, 0.0001) + let midLatitude = (box.minLatitude + box.maxLatitude) / 2 + let longitudeCorrection = max(cos(midLatitude * .pi / 180), 0.1) + let projectedLongitudeSpan = max( + longitudeSpan.degrees * longitudeCorrection, + 0.0001, + ) + let layout = layout(in: size) + let scale = min( + layout.extent.width / projectedLongitudeSpan, + layout.extent.height / latitudeSpan, + ) + + func point(for coordinate: Coordinate) -> CGPoint { + let longitudeDelta = (coordinate.longitude - longitudeSpan.center + 540) + .truncatingRemainder(dividingBy: 360) - 180 + return CGPoint( + x: layout.center.x + + longitudeDelta * longitudeCorrection * scale * layout.scale, + y: layout.center.y + + (midLatitude - coordinate.latitude) * scale * layout.scale, + ) + } + + for outline in outlines { + guard let first = outline.coordinates.first else { continue } + var path = Path() + path.move(to: point(for: first)) + for coordinate in outline.coordinates.dropFirst() { + path.addLine(to: point(for: coordinate)) + } + path.closeSubpath() + + switch placement { + case .watermark: + context.fill( + path, + with: .color(tint.opacity(style.watermarkFillOpacity)), + ) + context.stroke( + path, + with: .color(tint.opacity(style.watermarkStrokeOpacity)), + lineWidth: style.watermarkStrokeWidth, + ) + case .stamp: + context.fill( + path, + with: .color(tint.opacity(style.stampFillOpacity)), + ) + context.stroke(path, with: .color(tint), lineWidth: style.stampStrokeWidth) + } + } + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } + + private func layout(in size: CGSize) -> (center: CGPoint, extent: CGSize, scale: CGFloat) { + switch placement { + case .watermark: + ( + CGPoint( + x: size.width * style.watermarkCenter.x, + y: size.height * style.watermarkCenter.y, + ), + CGSize( + width: size.width * style.watermarkExtent.width, + height: size.height * style.watermarkExtent.height, + ), + style.watermarkScale, + ) + case .stamp: + ( + CGPoint(x: size.width * 0.5, y: size.height * 0.5), + CGSize( + width: size.width * style.stampExtent.width, + height: size.height * style.stampExtent.height, + ), + style.stampScale, + ) + } + } +} + +#if DEBUG + #Preview { + RegionOutlineArtworkPreview() + .padding() + } + + private struct RegionOutlineArtworkPreview: View { + @State private var outlines: [RegionOutline] = [] + + var body: some View { + if let style = WhereStylesheet.default.card.regular.regionShape { + RegionOutlineArtwork( + outlines: outlines, + tint: .orange, + style: style, + placement: .watermark, + ) + .frame(width: 320, height: 180) + .background(.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 28)) + .task { + outlines = await RegionGeometryCatalog.outlines(for: .california) + } + } + } + } +#endif diff --git a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift index a829f71a..3f824bfa 100644 --- a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit import SwiftUI import WhereCore @@ -42,6 +43,10 @@ struct RegionSummaryCard: View { /// other caller leaves it `nil` and gets the resolved look. var styleOverride: RegionStyle? + /// Loaded once per regular card and shared by both the large security + /// watermark and the smaller entry-stamp seal. + @State private var regionOutlines: [RegionOutline] = [] + @Environment(\.stylesheet) private var stylesheet @Environment(\.regionStyles) private var regionStyles @@ -97,6 +102,8 @@ struct RegionSummaryCard: View { tint: style.tint, size: card.entryStampSize, showsArcText: card.showsArcText, + regionOutlines: regionOutlines, + regionShape: card.regionShape, ) } @@ -149,12 +156,21 @@ struct RegionSummaryCard: View { ) } - Image(systemName: style.symbolName) - .font(.system(size: card.watermarkFontSize)) - .foregroundStyle(style.tint.opacity(stylesheet.card.watermarkOpacity)) - .rotationEffect(.degrees(-14)) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) - .offset(x: card.watermarkOffset.width, y: card.watermarkOffset.height) + if let regionShape = card.regionShape, !regionOutlines.isEmpty { + RegionOutlineArtwork( + outlines: regionOutlines, + tint: style.tint, + style: regionShape, + placement: .watermark, + ) + } else { + Image(systemName: style.symbolName) + .font(.system(size: card.watermarkFontSize)) + .foregroundStyle(style.tint.opacity(stylesheet.card.watermarkOpacity)) + .rotationEffect(.degrees(-14)) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) + .offset(x: card.watermarkOffset.width, y: card.watermarkOffset.height) + } } .clipShape(cardShape) .allowsHitTesting(false) @@ -197,6 +213,12 @@ struct RegionSummaryCard: View { .allowsHitTesting(false) } + private func loadRegionOutlines() async { + regionOutlines = [] + guard card.regionShape != nil else { return } + regionOutlines = await RegionGeometryCatalog.outlines(for: regionDays.region) + } + var body: some View { VStack(alignment: .leading, spacing: card.contentSpacing) { HStack(alignment: .top, spacing: stylesheet.spacing.large) { @@ -296,6 +318,7 @@ struct RegionSummaryCard: View { days: regionDays.days, ), ) + .task(id: regionDays.region, loadRegionOutlines) } } @@ -310,6 +333,8 @@ private struct EntryStamp: View { let tint: Color let size: CGFloat var showsArcText = true + let regionOutlines: [RegionOutline] + let regionShape: WhereStylesheet.CardStyle.RegionShape? var body: some View { ZStack { @@ -326,8 +351,18 @@ private struct EntryStamp: View { .padding(size * 0.13) VStack(spacing: size * 0.02) { - Image(systemName: symbolName) - .font(.system(size: size * 0.26)) + if let regionShape, !regionOutlines.isEmpty { + RegionOutlineArtwork( + outlines: regionOutlines, + tint: tint, + style: regionShape, + placement: .stamp, + ) + .frame(width: size * 0.42, height: size * 0.28) + } else { + Image(systemName: symbolName) + .font(.system(size: size * 0.26)) + } Text(verbatim: String(year)) .font(.system(size: size * 0.15, weight: .bold, design: .serif)) .monospacedDigit() diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index a1c239c9..87fbe28e 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -256,7 +256,10 @@ extension WhereStylesheet { var watermarkFontSize: CGFloat /// Offset of that watermark toward the bottom-trailing corner. var watermarkOffset: CGSize - /// Holographic sheen strength plus the deterministic pose used until a + /// RegionKit silhouette artwork for the regular card. `nil` keeps the + /// compact card on its simpler SF Symbol watermark and stamp glyph. + var regionShape: RegionShape? + /// Light-sheen strength plus the deterministic pose used until a /// live motion sample arrives (and whenever motion must stay static). var sheen: Sheen /// Line width of the heavy outer frame stroke. @@ -279,6 +282,21 @@ extension WhereStylesheet { var secondaryRingSpacing: CGFloat } + /// Relative projection geometry and ink strength for the repeated + /// region silhouette: one large security watermark and one stamp seal. + struct RegionShape: Equatable { + var watermarkCenter: CGPoint + var watermarkExtent: CGSize + var watermarkScale: CGFloat + var watermarkFillOpacity: Double + var watermarkStrokeOpacity: Double + var watermarkStrokeWidth: CGFloat + var stampExtent: CGSize + var stampScale: CGFloat + var stampFillOpacity: Double + var stampStrokeWidth: CGFloat + } + struct Sheen: Equatable { var intensity: Double /// Strength of only the white glint while the pose is @@ -418,6 +436,18 @@ extension WhereStylesheet { dayUnitFont: .title3.weight(.medium), watermarkFontSize: 150, watermarkOffset: CGSize(width: 20, height: 12), + regionShape: CardStyle.RegionShape( + watermarkCenter: CGPoint(x: 0.7, y: 0.57), + watermarkExtent: CGSize(width: 0.72, height: 0.78), + watermarkScale: 0.88, + watermarkFillOpacity: 0.13, + watermarkStrokeOpacity: 0.28, + watermarkStrokeWidth: 1.5, + stampExtent: CGSize(width: 0.78, height: 0.78), + stampScale: 0.88, + stampFillOpacity: 0.78, + stampStrokeWidth: 1, + ), sheen: CardStyle.Sheen( intensity: 1, staticGlintIntensity: 0.25, @@ -450,6 +480,7 @@ extension WhereStylesheet { dayUnitFont: .subheadline.weight(.medium), watermarkFontSize: 96, watermarkOffset: CGSize(width: 12, height: 10), + regionShape: nil, sheen: CardStyle.Sheen( intensity: 0.5, staticGlintIntensity: 0.5, diff --git a/Where/WhereUI/Tests/RegionOutlineArtworkTests.swift b/Where/WhereUI/Tests/RegionOutlineArtworkTests.swift new file mode 100644 index 00000000..3f6b77bc --- /dev/null +++ b/Where/WhereUI/Tests/RegionOutlineArtworkTests.swift @@ -0,0 +1,29 @@ +import RegionKit +import SwiftUI +import TestHostSupport +import Testing +@testable import WhereUI + +@MainActor +struct RegionOutlineArtworkTests { + /// Exercise the Canvas with RegionKit's real multipart geometry so changes + /// to either side of the projection boundary cannot leave cards blank or + /// trap on a representative state outline. + @Test func hostsRealRegionGeometry() async throws { + let outlines = await RegionGeometryCatalog.outlines(for: .california) + let style = try #require(WhereStylesheet.default.card.regular.regionShape) + let artwork = RegionOutlineArtwork( + outlines: outlines, + tint: .orange, + style: style, + placement: .watermark, + ) + + #expect(!outlines.isEmpty) + try show(UIHostingController(rootView: artwork)) { hosted in + hosted.view.frame = CGRect(x: 0, y: 0, width: 320, height: 180) + hosted.view.layoutIfNeeded() + #expect(hosted.view != nil) + } + } +} diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index 403f1593..21991452 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -37,6 +37,18 @@ struct WhereStylesheetTests { #expect(card.regionNameTracking == -0.5) #expect(card.watermarkFontSize == 150) #expect(card.watermarkOffset == CGSize(width: 20, height: 12)) + #expect(card.regionShape == .init( + watermarkCenter: CGPoint(x: 0.7, y: 0.57), + watermarkExtent: CGSize(width: 0.72, height: 0.78), + watermarkScale: 0.88, + watermarkFillOpacity: 0.13, + watermarkStrokeOpacity: 0.28, + watermarkStrokeWidth: 1.5, + stampExtent: CGSize(width: 0.78, height: 0.78), + stampScale: 0.88, + stampFillOpacity: 0.78, + stampStrokeWidth: 1, + )) #expect(card.sheen == .init( intensity: 1, staticGlintIntensity: 0.25, @@ -66,6 +78,7 @@ struct WhereStylesheetTests { #expect(card.regionNameTracking == 0) #expect(card.watermarkFontSize == 96) #expect(card.watermarkOffset == CGSize(width: 12, height: 10)) + #expect(card.regionShape == nil) #expect(card.sheen == .init( intensity: 0.5, staticGlintIntensity: 0.5, From 3d51fc5520799bbe95f315a3a6f1394c4674f5e1 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sat, 1 Aug 2026 22:40:18 -0700 Subject: [PATCH 07/28] Let Liquid Glass own card borders --- Where/WhereUI/README.md | 4 +- .../elsewhere.Loaded_iPad.png | 4 +- .../elsewhere.Loaded_iPad_accessibility.png | 4 +- .../elsewhere.Loaded_iPad_ax5.png | 4 +- .../elsewhere.Loaded_iPad_contrast.png | 4 +- .../elsewhere.Loaded_iPad_dark.png | 4 +- .../elsewhere.Loaded_iPhone.png | 4 +- .../elsewhere.Loaded_iPhone_accessibility.png | 4 +- .../elsewhere.Loaded_iPhone_ax5.png | 4 +- .../elsewhere.Loaded_iPhone_contrast.png | 4 +- .../elsewhere.Loaded_iPhone_dark.png | 4 +- .../locations.Loaded_iPad.png | 4 +- .../locations.Loaded_iPad_accessibility.png | 4 +- .../locations.Loaded_iPad_ax5.png | 4 +- .../locations.Loaded_iPad_contrast.png | 4 +- .../locations.Loaded_iPad_dark.png | 4 +- .../locations.Loaded_iPhone.png | 4 +- .../locations.Loaded_iPhone_accessibility.png | 4 +- .../locations.Loaded_iPhone_ax5.png | 4 +- .../locations.Loaded_iPhone_contrast.png | 4 +- .../locations.Loaded_iPhone_dark.png | 4 +- .../locations.MissingDays_iPhone.png | 4 +- .../locations.MissingDays_iPhone_dark.png | 4 +- .../regionCustomize.Editor_iPhone.png | 4 +- .../regionCustomize.Editor_iPhone_dark.png | 4 +- .../regionCustomize.NeutralEditor_iPhone.png | 4 +- ...ionCustomize.NeutralEditor_iPhone_dark.png | 4 +- .../Sources/Primary/RegionSummaryCard.swift | 37 ----------------- .../Sources/Shared/WhereStylesheet.swift | 41 ------------------- .../WhereUI/Tests/WhereStylesheetTests.swift | 17 -------- 30 files changed, 55 insertions(+), 148 deletions(-) diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index ae3fb144..29f69b43 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -216,7 +216,9 @@ option lists the picker shows. Regular `RegionSummaryCard`s also request their cached `RegionOutline`s from RegionKit and reuse the silhouette as both a large security-print watermark and the seal inside the circular entry stamp. Compact cards retain the simpler -symbol treatment. +symbol treatment. The card adds no ornamental edge strokes; its containing +Liquid Glass surface owns the subtle border so direct and production rendering +do not diverge. ## Previews diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad.png index dc0a8c92..751d208d 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e95808ce70dd66ebd8f0681fed4113b49046fff425de8656cc1c3e23050c5c78 -size 2876928 +oid sha256:a9425abe595a95b64bfde339afd75371859b5dc985d3f7ffa305f11dcd3b20dc +size 2869589 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad_accessibility.png index 50e52a76..108e7cd9 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ab3c75c49f7198548f12a3a9e9b0055ab4d10aaaf26a6899b774c984a1452fc9 -size 1922649 +oid sha256:9521ebf31475a873a801cc07542ab2c4bdf12f54c8aef51a48b69ee36f4d7d52 +size 1927015 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad_ax5.png index 08349d3b..10e85efe 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:41ac8f8c569c3842552dceadc363cf93af21ed81eb14344c85f1d69019676269 -size 3863671 +oid sha256:1767d29b9a3f5d24a453cd1d7f195818bf086db641f54d8d9e6469456490021c +size 3871857 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad_contrast.png index dfb2451d..f0f6a8bc 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b38a4801bbdb953a3950defb4b06d067ba185675a44057696e10df14dfb75f9f -size 2825637 +oid sha256:dc0bd7851f11eb4599df585cc8f5848ac50b254e92ff3a7a3adecc3b21d6e2f2 +size 2782978 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad_dark.png index 56d71ff7..e720b9d5 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:823c6173b33c9c53085e8a40b32d7fbd09102f26ed74df4037bb88729f47a488 -size 3383861 +oid sha256:687e745bbcc89a0744eb37a05abe0109ef196dbf502c11e05f233a47f4e9e29e +size 3236957 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone.png index d9a0cbd2..1ad4cbb3 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:26e47e9abaeef8b15b716b16dccb8d07e775d531aed2982953b5a23bfacf5f3d -size 1746173 +oid sha256:7f3588faad791466a36a2417c69b2664d9d0857b133c800af7d8a8d37edd8c79 +size 1659419 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone_accessibility.png index 3e9f9330..e3a7e679 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:528dd689172e205ac8acd561d4fcba38ba1a767b17a10068b1108c245ade157e -size 1124110 +oid sha256:75c18a1913cfb2f97eaf68771b4d62873b8ea80490c1b25b3e60ee952f813f19 +size 1127470 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone_ax5.png index 71fb4d32..4e9d97ac 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cc5f14097876a74aa656d71f581c6ccdb60950abda8ac27e5aa8abd21958d2d1 -size 1804768 +oid sha256:e79375f740175e735a7eb9f7ef8cb0b9377be80d8f8cfbf2e51813bd5e0091a5 +size 1693025 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone_contrast.png index e06e33cc..44d4ef44 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:975eb55606e55bdec76bb8160cf6781c89f900d717285abb061f11e314052c05 -size 1641558 +oid sha256:505b9c3b517d3cd4bd6762185ea09da9a1d982c57b0316e462d1fabbab7fb314 +size 1584353 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone_dark.png index d68d234e..59d9bed0 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7ad057408f2f3c0f438ea1fe8c862beb4159c141444f962ba6aba624ed11bca8 -size 2022712 +oid sha256:0b60cb261949c3fa9af6dd0564def9e92641f2e4baf555704085deb8ac18b3aa +size 1863310 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png index 7962fce3..1145868a 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cca6ca98c010680bc6d82d051265bc5e5a28381e94a637bf0d236792c679e9ed -size 3119860 +oid sha256:76ec2cc037b259082363d24bc7a8f77c38cf96c6dedd490127b2af0af793edec +size 3146283 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png index 678b7814..cc5a9217 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0548233d268cb670692a48cf088e1914a4dffca6c7d5c9856390a55490474a95 -size 2180703 +oid sha256:40068a63b10dbad003280a1509fbf99e2f099c1110c18e696c235da2c5e18de5 +size 2175153 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png index 2420a132..e98292eb 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d12277655d17c8aa405508e6adaed3c47acfaac92b17fb49d5224b7e9246ca12 -size 3452808 +oid sha256:6bc9358528e0df897007d732c8bdebcb118bf23365951d95b68f259318cc1467 +size 3486849 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png index d74c1a91..cea5b404 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:52633d05794aff7df1773b2e8cf084c48f5b783abf439ca1549ba467010f854b -size 3092863 +oid sha256:ba42a0190eb1a02499cd3471e97c5b116177d6f2aece688390f82301823d5724 +size 3100921 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png index 6dd97d3a..cca36621 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f3942be76204fabf37253cdca4e84c75366196799bb78a339ef9d3ac7513722c -size 3407469 +oid sha256:8da74b24108f713c0df92280ab9726d25c53e98aec281210c105319d00621ad4 +size 3403070 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png index 998967b3..64fbffbd 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d95f2dc3c4fe1d33ee2e359fc796aaecf3f44ed0cc0b0c0b8ef8dc4536b251f1 -size 1974535 +oid sha256:60784a6a134061a106d83354c8024ce1d9c2ab8cca4596aaf30724d2f399a0f8 +size 1994924 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png index e26fc39c..a1db147e 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:973de8d23fc800524a1f3d9fcb920f0e4c0fc670d1ab57efba0b6a17f06495f4 -size 1328226 +oid sha256:f9271db10ea31691cf045fa90e0f7962db7768c954d0d0c71ce40c36945aa95c +size 1334490 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png index f6eda84f..4b30b8f4 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c1b535a96dc2d8d9f9ac347fc0b07203d2b8a0c5eb3d3cec2202090d2d5d5d4d -size 2390456 +oid sha256:f5778fb8d759711818575096872d69f499a5d6f3b03990cc1a5429873e0013e9 +size 2415647 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png index 1483f4c6..c908550e 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:be8d71d61fc4cb564d78ef57c469a91941920647a6253651995a8187c0fc5e4f -size 1906143 +oid sha256:71bf9a86b5e0019ac04bfeeb225370ebb0f7424514da33c9a55c5675734f4bcf +size 1916425 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png index 74a86722..feaf75c0 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:483751b41266d48e05db5883588d8e1ae10bd6c8df6ea30d233de7f466594cb9 -size 2050351 +oid sha256:fa01999ae19c03112128333e9024df79edf63d5f011f022daab97e6f30ab32a2 +size 2076014 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png index 1fc8a9ee..43a23638 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:50107dea964c80d17287bafcee62faa43cefc93542718cf457de2ebe7fe7c6f6 -size 1113063 +oid sha256:19b15851393926582288bb08f26871aba454db40fa8b21dce18e37a1762226cb +size 1125430 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png index d84d3190..4fa85ca0 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:49f2a69aacdbe8dc18fa84e39ed72ff6698081727d3f60bfb27144ddcaaace28 -size 1114588 +oid sha256:697cde3c8fb3d2ce776fa4dd4aab1a32d2f6114878b247fd4eb71eadd999c959 +size 1123052 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png index c36ac528..028655cd 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a93fe569f81dbd60b8b740ddd784e35c37051b939813ad2033e58c14f390b13d -size 1347273 +oid sha256:93cee184db84b7f13f62b30f7ef0aaa26f48f518ecd56463d452f6d5757418ef +size 1341948 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png index 9803514d..6e8fb55a 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2124bc4cf08defb2d84926b1d61648d7c779d4c1947bbe8217ec1f486ec0caf2 -size 1422893 +oid sha256:8ea0a87ca2687399468c23bcacf571aa3be162e802aacff632c6625f3684458a +size 1424416 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone.png index d25e8e08..d3038a8e 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:61ecf148e38501308af1464564461b483ea1bec3f54de38d44580d80170f50b3 -size 1287059 +oid sha256:c2a2fad9e8f92e7511c1d6f9a9da38ade5b199f67abe7bc4d9d8d1aadb35e7ad +size 1284106 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png index 04b0e619..2ac7aeb2 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ea49dd2faf53d4b335e1b8155d56253ae63857e0f5eac09901b438014134b95b -size 1221232 +oid sha256:b8e4d35224b9d3d94fefd8f7a2536398e4d5ad443c34e5834e5b4d31160d15b4 +size 1233457 diff --git a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift index 3f824bfa..babface2 100644 --- a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift @@ -177,42 +177,6 @@ struct RegionSummaryCard: View { .accessibilityHidden(true) } - /// A layered, official-looking frame: a heavy solid outer line, a thin solid - /// line, a ring of perforation dots (Primary cards only), and a dashed inner - /// line — like the engraved, perforated edge of a passport page. - private var stampFrame: some View { - let frame = stylesheet.card.frame - return ZStack { - cardShape - .strokeBorder( - style.tint.opacity(frame.outerOpacity), - lineWidth: card.frameOuterLineWidth, - ) - cardShape - .inset(by: stylesheet.spacing.small) - .strokeBorder(style.tint.opacity(frame.thinOpacity), lineWidth: frame.thinWidth) - if card.showsPerforationRing { - cardShape - .inset(by: stylesheet.spacing.large) - .strokeBorder( - style.tint.opacity(frame.perforationOpacity), - style: StrokeStyle( - lineWidth: frame.perforationWidth, - lineCap: .round, - dash: frame.perforationDash, - ), - ) - } - cardShape - .inset(by: card.innerFrameInset) - .strokeBorder( - style.tint.opacity(frame.innerOpacity), - style: StrokeStyle(lineWidth: frame.innerWidth, dash: frame.innerDash), - ) - } - .allowsHitTesting(false) - } - private func loadRegionOutlines() async { regionOutlines = [] guard card.regionShape != nil else { return } @@ -296,7 +260,6 @@ struct RegionSummaryCard: View { intensity: card.sheen.intensity, staticGlintIntensity: card.sheen.staticGlintIntensity, ) - .overlay { stampFrame } .clipShape(cardShape) // Make the whole card a single hit target — without this only the // opaque sub-views (text, stamp, bar) take taps, leaving dead gaps diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index 87fbe28e..0f38e686 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -262,12 +262,6 @@ extension WhereStylesheet { /// Light-sheen strength plus the deterministic pose used until a /// live motion sample arrives (and whenever motion must stay static). var sheen: Sheen - /// Line width of the heavy outer frame stroke. - var frameOuterLineWidth: CGFloat - /// Whether the dashed perforation ring is drawn (Primary cards only). - var showsPerforationRing: Bool - /// Inset of the innermost dashed frame line. - var innerFrameInset: CGFloat var rosette: Rosette /// The tight rim glow; its `radius` drops to 0 under Reduce Transparency. var glow: Shadow @@ -331,30 +325,12 @@ extension WhereStylesheet { var glassTintOpacity: Double /// Opacity of the region-name header. var nameOpacity: Double - /// The layered stamp frame's strokes (shared across both variants). - var frame: Frame /// Fill opacities of the two security-print rosettes. var rosetteFill: RosetteFill /// How the day count changes while the card is on screen; resolves to /// ``DayCountStyle/reducedMotion`` under Reduce Motion. var dayCount: DayCountStyle - /// The passport-style frame drawn over the card: a heavy outer line, a - /// thin line, an optional perforation ring (see - /// ``CardStyle/showsPerforationRing``), and a dashed inner line. Each - /// opacity applies over the region tint. - struct Frame: Equatable { - var outerOpacity: Double - var thinOpacity: Double - var thinWidth: CGFloat - var perforationOpacity: Double - var perforationWidth: CGFloat - var perforationDash: [CGFloat] - var innerOpacity: Double - var innerWidth: CGFloat - var innerDash: [CGFloat] - } - /// Fill opacity of the bold and faint security-print rosettes. struct RosetteFill: Equatable { var primary: Double @@ -455,9 +431,6 @@ extension WhereStylesheet { // instead of washing out the card's central content. staticPose: .init(roll: 0, pitch: -1), ), - frameOuterLineWidth: 3.5, - showsPerforationRing: true, - innerFrameInset: 16, rosette: CardStyle.Rosette( wobble: 3, lineWidth: 2, @@ -487,9 +460,6 @@ extension WhereStylesheet { // Preserve the compact card's existing neutral treatment. staticPose: .init(roll: 0, pitch: 0), ), - frameOuterLineWidth: 2.5, - showsPerforationRing: false, - innerFrameInset: 12, rosette: CardStyle.Rosette( wobble: 2, lineWidth: 2, @@ -502,17 +472,6 @@ extension WhereStylesheet { watermarkOpacity: 0.08, glassTintOpacity: 0.18, nameOpacity: 0.8, - frame: Frame( - outerOpacity: 0.6, - thinOpacity: 0.35, - thinWidth: 1, - perforationOpacity: 0.45, - perforationWidth: 2.5, - perforationDash: [0.01, 6], - innerOpacity: 0.4, - innerWidth: 1, - innerDash: [5, 4], - ), rosetteFill: RosetteFill(primary: 0.12, secondary: 0.08), dayCount: .standard, ) diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index 21991452..6f0d5ac7 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -54,9 +54,6 @@ struct WhereStylesheetTests { staticGlintIntensity: 0.25, staticPose: .init(roll: 0, pitch: -1), )) - #expect(card.frameOuterLineWidth == 3.5) - #expect(card.showsPerforationRing) - #expect(card.innerFrameInset == 16) #expect(card.rosette == .init( wobble: 3, lineWidth: 2, @@ -84,9 +81,6 @@ struct WhereStylesheetTests { staticGlintIntensity: 0.5, staticPose: .init(roll: 0, pitch: 0), )) - #expect(card.frameOuterLineWidth == 2.5) - #expect(!card.showsPerforationRing) - #expect(card.innerFrameInset == 12) #expect(card.rosette == .init( wobble: 2, lineWidth: 2, @@ -108,17 +102,6 @@ struct WhereStylesheetTests { #expect(card.glassTintOpacity == 0.18) #expect(card.nameOpacity == 0.8) #expect(card.rosetteFill == .init(primary: 0.12, secondary: 0.08)) - #expect(card.frame == .init( - outerOpacity: 0.6, - thinOpacity: 0.35, - thinWidth: 1, - perforationOpacity: 0.45, - perforationWidth: 2.5, - perforationDash: [0.01, 6], - innerOpacity: 0.4, - innerWidth: 1, - innerDash: [5, 4], - )) #expect(card.dayCount == .standard) #expect(card.dayCount.animation == .easeOut(duration: 0.3)) } From f0107e68d7ca248171e78a9cd0c13847f54765d4 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sat, 1 Aug 2026 23:18:18 -0700 Subject: [PATCH 08/28] Cache simplified region artwork paths Keep RegionKit's cached outlines at source fidelity and expose a stateless, antimeridian-aware simplifier. Cache render-ready full, medium, and small SwiftUI paths at the Where UI root, use medium/small paths on cards, and isolate live tilt observation to the sheen overlay. --- Where/RegionKit/AGENTS.md | 10 +- Where/RegionKit/README.md | 3 +- .../Sources/RegionGeometryCatalog.swift | 12 +- .../Sources/RegionGeometrySimplifier.swift | 202 ++++++++++++++++++ .../Tests/RegionGeometrySimplifierTests.swift | 62 ++++++ Where/WhereUI/AGENTS.md | 5 + Where/WhereUI/README.md | 15 +- .../Primary/RegionOutlineArtwork.swift | 101 ++++----- .../Primary/RegionOutlinePathCache.swift | 128 +++++++++++ .../Sources/Primary/RegionSummaryCard.swift | 64 +++--- Where/WhereUI/Sources/Shared/TiltSheen.swift | 34 ++- .../Sources/Shared/WhereStylesheet.swift | 18 +- .../Tests/RegionOutlineArtworkTests.swift | 6 +- .../Tests/RegionOutlinePathCacheTests.swift | 34 +++ .../Tests/RegionSummaryCardTests.swift | 4 +- 15 files changed, 563 insertions(+), 135 deletions(-) create mode 100644 Where/RegionKit/Sources/RegionGeometrySimplifier.swift create mode 100644 Where/RegionKit/Tests/RegionGeometrySimplifierTests.swift create mode 100644 Where/WhereUI/Sources/Primary/RegionOutlinePathCache.swift create mode 100644 Where/WhereUI/Tests/RegionOutlinePathCacheTests.swift diff --git a/Where/RegionKit/AGENTS.md b/Where/RegionKit/AGENTS.md index 9824334d..7ffeb0a5 100644 --- a/Where/RegionKit/AGENTS.md +++ b/Where/RegionKit/AGENTS.md @@ -37,10 +37,12 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature the passed regions' `regions/.geojson` files, so the app parses only the tracked set, while `RegionGeometryCatalog.outlines(for: Region)` caches only the drawable region requested by UI artwork — never load the whole US for one - card. `.all` loads the whole catalog (dev viewer/tests); `.shared` the default - four. It's UI-free: `BoundingBox` / `LongitudeSpan` expose the min/max math, - but drawing and MapKit conversion live in the UI layer. `RegionAttributing` - lets `WhereCore` supply a live, swappable attributor. + card. `RegionGeometrySimplifier` vends stateless, projection-aware geometry + reduction; rendering fidelity and render-artifact caches belong to consumers. + `.all` loads the whole catalog (dev viewer/tests); `.shared` the default four. + It's UI-free: `BoundingBox` / `LongitudeSpan` expose the min/max math, but + drawing and MapKit conversion live in the UI layer. `RegionAttributing` lets + `WhereCore` supply a live, swappable attributor. - **Bundled geometry is credited in code, not only in prose.** `RegionDataSource` states each boundary set's origin, license, and fidelity, and derives its coverage from the catalog — the US sources by the `us-` id prefix the generator diff --git a/Where/RegionKit/README.md b/Where/RegionKit/README.md index da7820ba..8f4ad453 100644 --- a/Where/RegionKit/README.md +++ b/Where/RegionKit/README.md @@ -35,7 +35,8 @@ into it for lookup. RegionKit depends only on - **`RegionGeometryCatalog`** — read-only drawable `RegionOutline`s: a cached, per-region path for UI artwork, plus the developer region-map viewer's `.attribution` view of a given attributor and `.source` view of the whole - catalog. + catalog. `RegionGeometrySimplifier` can derive reduced geometry at a + consumer-chosen normalized tolerance without imposing UI sizes on RegionKit. - **`RegionDataSource`** — where the bundled geometry came from: the boundary set's name, its links, its `License`, its `Fidelity` (`.authoritative` vs the `.approximate` hand-drawn outlines), and the regions it covers. diff --git a/Where/RegionKit/Sources/RegionGeometryCatalog.swift b/Where/RegionKit/Sources/RegionGeometryCatalog.swift index 9d4be0c5..55d34561 100644 --- a/Where/RegionKit/Sources/RegionGeometryCatalog.swift +++ b/Where/RegionKit/Sources/RegionGeometryCatalog.swift @@ -104,14 +104,10 @@ public enum RegionGeometryCatalog { /// - `.source` decodes every available region from the catalog and ignores /// `attributor`. /// - /// The file read + JSON decode runs **off the main thread**: - /// `RegionGeometryCatalog` is a plain (non-`@MainActor`) type and - /// this method is `nonisolated`, so `await`-ing it from a - /// `@MainActor` view hops to the cooperative pool (and, for - /// `.source`, the cache actor) to decode, then returns the - /// `Sendable` result back to the main actor. Throws - /// `RegionGeometryError` / a `DecodingError` rather than absorbing a - /// missing or malformed bundle into an empty list. + /// `.source` decoding runs on its cache actor. `.attribution` maps the + /// caller-provided attributor on the caller's actor because it is already + /// resolved in memory. Throws `RegionGeometryError` / a `DecodingError` + /// rather than absorbing a missing or malformed bundle into an empty list. public static func outlines( for kind: RegionGeometryKind, attributor: RegionAttributor, diff --git a/Where/RegionKit/Sources/RegionGeometrySimplifier.swift b/Where/RegionKit/Sources/RegionGeometrySimplifier.swift new file mode 100644 index 00000000..6dd0fe9b --- /dev/null +++ b/Where/RegionKit/Sources/RegionGeometrySimplifier.swift @@ -0,0 +1,202 @@ +import Foundation + +/// Simplifies drawable region geometry with an antimeridian-aware planar +/// projection while preserving every polygon and its stable identity. +public enum RegionGeometrySimplifier { + /// Returns outlines simplified with Ramer-Douglas-Peucker. + /// + /// `tolerance` is expressed as a fraction of the complete region's longest + /// projected dimension. For example, `1 / 600` discards deviations smaller + /// than roughly half a point when rendered 300 points wide. A non-positive + /// tolerance returns `outlines` unchanged. Cancellation is checked while + /// processing detailed boundaries and is surfaced to the caller. + public static func simplify( + _ outlines: [RegionOutline], + tolerance: Double, + ) throws -> [RegionOutline] { + guard tolerance > 0 else { return outlines } + guard + let box = BoundingBox.enclosing(outlines), + let longitudeSpan = LongitudeSpan.enclosing( + outlines.lazy.flatMap { outline in + outline.coordinates.lazy.map(\.longitude) + }, + ) + else { return [] } + + let projection = Projection(box: box, longitudeSpan: longitudeSpan) + return try outlines.map { outline in + try Task.checkCancellation() + return try RegionOutline( + id: outline.id, + title: outline.title, + region: outline.region, + coordinates: simplifyRing( + outline.coordinates, + tolerance: tolerance, + projection: projection, + ), + ) + } + } + + /// Simplifies a closed polygon by splitting it into two open arcs and + /// applying Ramer-Douglas-Peucker to each. This avoids the coincident + /// first/last endpoint problem of treating a ring as one open line. + private static func simplifyRing( + _ coordinates: [Coordinate], + tolerance: Double, + projection: Projection, + ) throws -> [Coordinate] { + let ring = normalizedRing(coordinates) + guard ring.count > 3 else { return ring } + + let points = ring.map(projection.point(for:)) + let splitIndex = farthestPointIndex(from: points[0], in: points) + guard splitIndex > 0, splitIndex < ring.count else { return ring } + + let firstArc = Array(0 ... splitIndex) + let secondArc = Array(splitIndex ..< ring.count) + [0] + let firstKept = try simplifyOpenLine(firstArc, points: points, tolerance: tolerance) + let secondKept = try simplifyOpenLine(secondArc, points: points, tolerance: tolerance) + let kept = firstKept + secondKept.dropFirst().dropLast() + + // Each authored polygon remains a drawable polygon at every tolerance. + // Extremely tiny islands may simplify to a line; retain three ordered + // source vertices for those rather than dropping the island entirely. + guard kept.count >= 3 else { + return [ring[0], ring[ring.count / 3], ring[(ring.count * 2) / 3]] + } + return kept.map { ring[$0] } + } + + /// Removes only redundant closure/consecutive vertices. The returned ring + /// relies on renderers closing the path, matching `RegionOutline`'s API. + private static func normalizedRing(_ coordinates: [Coordinate]) -> [Coordinate] { + var result: [Coordinate] = [] + result.reserveCapacity(coordinates.count) + for coordinate in coordinates where coordinate != result.last { + result.append(coordinate) + } + if result.count > 3, result.first == result.last { + result.removeLast() + } + return result + } + + private static func farthestPointIndex(from origin: Point, in points: [Point]) -> Int { + var farthestIndex = 0 + var farthestDistanceSquared = 0.0 + for index in points.indices.dropFirst() { + let distanceSquared = points[index].distanceSquared(to: origin) + if distanceSquared > farthestDistanceSquared { + farthestIndex = index + farthestDistanceSquared = distanceSquared + } + } + return farthestIndex + } + + /// Iterative Ramer-Douglas-Peucker over source indices. An explicit stack + /// avoids recursion depth growing with a particularly detailed boundary. + private static func simplifyOpenLine( + _ indices: [Int], + points: [Point], + tolerance: Double, + ) throws -> [Int] { + guard indices.count > 2 else { return indices } + let toleranceSquared = tolerance * tolerance + var keep = Array(repeating: false, count: indices.count) + keep[0] = true + keep[indices.count - 1] = true + var segments = [Segment(first: 0, last: indices.count - 1)] + var inspectedPointCount = 0 + + while let segment = segments.popLast() { + guard segment.last - segment.first > 1 else { continue } + let start = points[indices[segment.first]] + let end = points[indices[segment.last]] + var farthestOffset: Int? + var farthestDistanceSquared = toleranceSquared + + for offset in (segment.first + 1) ..< segment.last { + inspectedPointCount += 1 + if inspectedPointCount.isMultiple(of: 256) { + try Task.checkCancellation() + } + let distanceSquared = points[indices[offset]].distanceSquared( + toSegmentFrom: start, + to: end, + ) + if distanceSquared > farthestDistanceSquared { + farthestOffset = offset + farthestDistanceSquared = distanceSquared + } + } + + if let farthestOffset { + keep[farthestOffset] = true + segments.append(Segment(first: segment.first, last: farthestOffset)) + segments.append(Segment(first: farthestOffset, last: segment.last)) + } + } + + return indices.enumerated().compactMap { offset, index in + keep[offset] ? index : nil + } + } + + private struct Projection { + let centerLongitude: Double + let midLatitude: Double + let longitudeCorrection: Double + let normalizationScale: Double + + init(box: BoundingBox, longitudeSpan: LongitudeSpan) { + centerLongitude = longitudeSpan.center + midLatitude = (box.minLatitude + box.maxLatitude) / 2 + longitudeCorrection = max(cos(midLatitude * .pi / 180), 0.1) + let latitudeSpan = max(box.maxLatitude - box.minLatitude, 0.0001) + let projectedLongitudeSpan = max( + longitudeSpan.degrees * longitudeCorrection, + 0.0001, + ) + normalizationScale = max(latitudeSpan, projectedLongitudeSpan) + } + + func point(for coordinate: Coordinate) -> Point { + let longitudeDelta = (coordinate.longitude - centerLongitude + 540) + .truncatingRemainder(dividingBy: 360) - 180 + return Point( + x: longitudeDelta * longitudeCorrection / normalizationScale, + y: (coordinate.latitude - midLatitude) / normalizationScale, + ) + } + } + + private struct Point { + let x: Double + let y: Double + + func distanceSquared(to other: Point) -> Double { + let dx = x - other.x + let dy = y - other.y + return dx * dx + dy * dy + } + + func distanceSquared(toSegmentFrom start: Point, to end: Point) -> Double { + let dx = end.x - start.x + let dy = end.y - start.y + let lengthSquared = dx * dx + dy * dy + guard lengthSquared > 0 else { return distanceSquared(to: start) } + let t = max(0, min(1, ((x - start.x) * dx + (y - start.y) * dy) / lengthSquared)) + let projection = Point(x: start.x + t * dx, y: start.y + t * dy) + return distanceSquared(to: projection) + } + } + + private struct Segment { + let first: Int + let last: Int + } +} diff --git a/Where/RegionKit/Tests/RegionGeometrySimplifierTests.swift b/Where/RegionKit/Tests/RegionGeometrySimplifierTests.swift new file mode 100644 index 00000000..f2028fd6 --- /dev/null +++ b/Where/RegionKit/Tests/RegionGeometrySimplifierTests.swift @@ -0,0 +1,62 @@ +import Foundation +@testable import RegionKit +import Testing + +struct RegionGeometrySimplifierTests { + @Test func toleranceReducesDenseRingsWhilePreservingIdentity() throws { + let denseRing = (0 ..< 360).map { degrees in + let angle = Double(degrees) * .pi / 180 + return Coordinate(latitude: sin(angle), longitude: cos(angle)) + } + let outline = RegionOutline( + id: RegionOutline.ID(title: "Circle", index: 0), + title: "Circle", + region: .california, + coordinates: denseRing, + ) + + let medium = try #require(RegionGeometrySimplifier.simplify( + [outline], + tolerance: 1 / 600, + ).first) + let small = try #require(RegionGeometrySimplifier.simplify( + [outline], + tolerance: 1 / 60, + ).first) + + #expect(outline.coordinates.count > medium.coordinates.count) + #expect(medium.coordinates.count > small.coordinates.count) + #expect(small.coordinates.count >= 3) + #expect(outline.id == medium.id && medium.id == small.id) + #expect(outline.title == medium.title && medium.title == small.title) + #expect(outline.region == medium.region && medium.region == small.region) + #expect(Set(medium.coordinates).isSubset(of: Set(outline.coordinates))) + #expect(Set(small.coordinates).isSubset(of: Set(outline.coordinates))) + } + + @Test func simplificationPreservesEveryPolygon() throws { + let outlines = (0 ..< 3).map { index in + RegionOutline( + id: RegionOutline.ID(title: "Island", index: index), + title: "Island", + region: Region(rawValue: "us-AK"), + coordinates: [ + Coordinate(latitude: Double(index), longitude: 0), + Coordinate(latitude: Double(index) + 0.000_01, longitude: 0.000_01), + Coordinate(latitude: Double(index), longitude: 0.000_02), + Coordinate(latitude: Double(index), longitude: 0), + ], + ) + } + + let simplified = try RegionGeometrySimplifier.simplify(outlines, tolerance: 1 / 60) + + #expect(outlines.map(\.id) == simplified.map(\.id)) + #expect(simplified.allSatisfy { $0.coordinates.count >= 3 }) + } + + @Test func nonPositiveToleranceLeavesGeometryUnchanged() async throws { + let outlines = await RegionGeometryCatalog.outlines(for: .california) + #expect(try RegionGeometrySimplifier.simplify(outlines, tolerance: 0) == outlines) + } +} diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index 6fb1049a..402e8a1c 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -43,6 +43,11 @@ and testing conventions live in the feature [`Where/AGENTS.md`](../AGENTS.md) [double-link rule](../../AGENTS.md#never-double-link-a-product-whereui-already-carries)); that's why `whereBroadwayRoot()` lives here rather than being called as `broadwayRoot` at each site. +- Keep render-ready region geometry in the root-injected + `RegionOutlinePathCache`: RegionKit owns the cached source outlines and its + stateless simplifier, while WhereUI chooses full/medium/small tolerances and + caches the resulting SwiftUI `Path`s; never project or simplify a boundary in + a card's `body`. - Continuous/looping motion (repeat-forever pulses, `TimelineView(.animation)`, typewriter reveals) must consult the shared `@MotionIsStatic` helper ([`Sources/Shared/MotionIsStatic.swift`](Sources/Shared/MotionIsStatic.swift)) diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 29f69b43..3d05846e 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -213,12 +213,15 @@ default empty resolver yields the fallback looks region-map viewer. The catalog also owns the selectable color/emoji/symbol option lists the picker shows. -Regular `RegionSummaryCard`s also request their cached `RegionOutline`s from -RegionKit and reuse the silhouette as both a large security-print watermark and -the seal inside the circular entry stamp. Compact cards retain the simpler -symbol treatment. The card adds no ornamental edge strokes; its containing -Liquid Glass surface owns the subtle border so direct and production rendering -do not diverge. +Regular `RegionSummaryCard`s ask the root-owned `RegionOutlinePathCache` for a +medium SwiftUI path for the large security-print watermark and a small path for +the seal inside the circular entry stamp. The UI cache derives both from +RegionKit's one cached source outline using its stateless simplifier; compact +cards retain the simpler symbol treatment. Live tilt is observed only by the +sheen overlay, so its 60 Hz updates do not invalidate the card's text or Canvas +artwork. The card adds no ornamental edge strokes; its containing Liquid Glass +surface owns the subtle border so direct and production rendering do not +diverge. ## Previews diff --git a/Where/WhereUI/Sources/Primary/RegionOutlineArtwork.swift b/Where/WhereUI/Sources/Primary/RegionOutlineArtwork.swift index a9339238..892c8338 100644 --- a/Where/WhereUI/Sources/Primary/RegionOutlineArtwork.swift +++ b/Where/WhereUI/Sources/Primary/RegionOutlineArtwork.swift @@ -1,82 +1,54 @@ import RegionKit import SwiftUI -/// Projects RegionKit coordinates into the repeated silhouette artwork on a -/// regular region card, preserving geographic aspect and antimeridian-spanning -/// multipart regions. +/// Places a cached, pre-projected region path into the repeated silhouette +/// artwork on a regular card while preserving its geographic aspect. struct RegionOutlineArtwork: View { enum Placement { case watermark case stamp } - let outlines: [RegionOutline] + let path: Path let tint: Color let style: WhereStylesheet.CardStyle.RegionShape let placement: Placement var body: some View { Canvas { context, size in - guard - let box = BoundingBox.enclosing(outlines), - let longitudeSpan = LongitudeSpan.enclosing( - outlines.lazy.flatMap { outline in - outline.coordinates.lazy.map(\.longitude) - }, - ) - else { return } - - let latitudeSpan = max(box.maxLatitude - box.minLatitude, 0.0001) - let midLatitude = (box.minLatitude + box.maxLatitude) / 2 - let longitudeCorrection = max(cos(midLatitude * .pi / 180), 0.1) - let projectedLongitudeSpan = max( - longitudeSpan.degrees * longitudeCorrection, - 0.0001, - ) + let bounds = path.boundingRect + guard !path.isEmpty, bounds.width > 0, bounds.height > 0 else { return } let layout = layout(in: size) let scale = min( - layout.extent.width / projectedLongitudeSpan, - layout.extent.height / latitudeSpan, - ) - - func point(for coordinate: Coordinate) -> CGPoint { - let longitudeDelta = (coordinate.longitude - longitudeSpan.center + 540) - .truncatingRemainder(dividingBy: 360) - 180 - return CGPoint( - x: layout.center.x - + longitudeDelta * longitudeCorrection * scale * layout.scale, - y: layout.center.y - + (midLatitude - coordinate.latitude) * scale * layout.scale, - ) - } - - for outline in outlines { - guard let first = outline.coordinates.first else { continue } - var path = Path() - path.move(to: point(for: first)) - for coordinate in outline.coordinates.dropFirst() { - path.addLine(to: point(for: coordinate)) - } - path.closeSubpath() + layout.extent.width / bounds.width, + layout.extent.height / bounds.height, + ) * layout.scale + var projectedContext = context + projectedContext.translateBy(x: layout.center.x, y: layout.center.y) + projectedContext.scaleBy(x: scale, y: scale) + projectedContext.translateBy(x: -bounds.midX, y: -bounds.midY) - switch placement { - case .watermark: - context.fill( - path, - with: .color(tint.opacity(style.watermarkFillOpacity)), - ) - context.stroke( - path, - with: .color(tint.opacity(style.watermarkStrokeOpacity)), - lineWidth: style.watermarkStrokeWidth, - ) - case .stamp: - context.fill( - path, - with: .color(tint.opacity(style.stampFillOpacity)), - ) - context.stroke(path, with: .color(tint), lineWidth: style.stampStrokeWidth) - } + switch placement { + case .watermark: + projectedContext.fill( + path, + with: .color(tint.opacity(style.watermarkFillOpacity)), + ) + projectedContext.stroke( + path, + with: .color(tint.opacity(style.watermarkStrokeOpacity)), + lineWidth: style.watermarkStrokeWidth / scale, + ) + case .stamp: + projectedContext.fill( + path, + with: .color(tint.opacity(style.stampFillOpacity)), + ) + projectedContext.stroke( + path, + with: .color(tint), + lineWidth: style.stampStrokeWidth / scale, + ) } } .allowsHitTesting(false) @@ -117,12 +89,13 @@ struct RegionOutlineArtwork: View { } private struct RegionOutlineArtworkPreview: View { - @State private var outlines: [RegionOutline] = [] + @State private var path = Path() + private let cache = RegionOutlinePathCache() var body: some View { if let style = WhereStylesheet.default.card.regular.regionShape { RegionOutlineArtwork( - outlines: outlines, + path: path, tint: .orange, style: style, placement: .watermark, @@ -130,7 +103,7 @@ struct RegionOutlineArtwork: View { .frame(width: 320, height: 180) .background(.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 28)) .task { - outlines = await RegionGeometryCatalog.outlines(for: .california) + path = await cache.path(for: .california, resolution: .medium) } } } diff --git a/Where/WhereUI/Sources/Primary/RegionOutlinePathCache.swift b/Where/WhereUI/Sources/Primary/RegionOutlinePathCache.swift new file mode 100644 index 00000000..7cfce72e --- /dev/null +++ b/Where/WhereUI/Sources/Primary/RegionOutlinePathCache.swift @@ -0,0 +1,128 @@ +import Foundation +import RegionKit +import SwiftUI + +/// UI-owned cache of projected region paths at the three rendering fidelities +/// Where needs. RegionKit owns/caches source geometry and provides the stateless +/// simplifier; this actor owns display policy and SwiftUI render artifacts. +actor RegionOutlinePathCache { + enum Resolution: Hashable { + case full + case medium + case small + + /// Roughly half a point of permitted deviation at each target width. + var tolerance: Double? { + switch self { + case .full: nil + case .medium: 1 / 600 + case .small: 1 / 60 + } + } + } + + private struct Key: Hashable { + let region: Region + let resolution: Resolution + } + + private var paths: [Key: Path] = [:] + + func path(for region: Region, resolution: Resolution) async -> Path { + let key = Key(region: region, resolution: resolution) + if let cached = paths[key] { return cached } + + let fullOutlines = await RegionGeometryCatalog.outlines(for: region) + guard !Task.isCancelled else { return Path() } + // Another caller may have populated this key while the actor was + // suspended on RegionKit's cache. + if let cached = paths[key] { return cached } + + let outlines: [RegionOutline] + do { + if let tolerance = resolution.tolerance { + outlines = try RegionGeometrySimplifier.simplify( + fullOutlines, + tolerance: tolerance, + ) + } else { + outlines = fullOutlines + } + } catch is CancellationError { + return Path() + } catch { + assertionFailure("Unexpected region simplification failure: \(error)") + return Path() + } + + guard !Task.isCancelled else { return Path() } + let built = Self.makePath(from: outlines, framedBy: fullOutlines) + paths[key] = built + return built + } + + /// Projects every polygon into one reusable path. Two move-only elements + /// pin `boundingRect` to the full geometry at every resolution; they draw + /// nothing, but prevent simplification from subtly changing placement. + private static func makePath( + from outlines: [RegionOutline], + framedBy fullOutlines: [RegionOutline], + ) -> Path { + guard let projection = Projection(outlines: fullOutlines) else { return Path() } + + var path = Path() + path.move(to: projection.minimum) + path.move(to: projection.maximum) + for outline in outlines { + guard let first = outline.coordinates.first else { continue } + path.move(to: projection.point(for: first)) + for coordinate in outline.coordinates.dropFirst() { + path.addLine(to: projection.point(for: coordinate)) + } + path.closeSubpath() + } + return path + } + + private struct Projection { + let centerLongitude: Double + let midLatitude: Double + let longitudeCorrection: Double + let minimum: CGPoint + let maximum: CGPoint + + init?(outlines: [RegionOutline]) { + guard + let box = BoundingBox.enclosing(outlines), + let longitudeSpan = LongitudeSpan.enclosing( + outlines.lazy.flatMap { outline in + outline.coordinates.lazy.map(\.longitude) + }, + ) + else { return nil } + + centerLongitude = longitudeSpan.center + midLatitude = (box.minLatitude + box.maxLatitude) / 2 + longitudeCorrection = max(cos(midLatitude * .pi / 180), 0.1) + let halfWidth = longitudeSpan.degrees * longitudeCorrection / 2 + let halfHeight = (box.maxLatitude - box.minLatitude) / 2 + minimum = CGPoint(x: -halfWidth, y: -halfHeight) + maximum = CGPoint(x: halfWidth, y: halfHeight) + } + + func point(for coordinate: Coordinate) -> CGPoint { + let longitudeDelta = (coordinate.longitude - centerLongitude + 540) + .truncatingRemainder(dividingBy: 360) - 180 + return CGPoint( + x: longitudeDelta * longitudeCorrection, + y: midLatitude - coordinate.latitude, + ) + } + } +} + +extension EnvironmentValues { + /// The scene/root-owned render cache. Optional only so a component rendered + /// without `whereBroadwayRoot()` can fall back to its symbol treatment. + @Entry var regionOutlinePathCache: RegionOutlinePathCache? +} diff --git a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift index babface2..f3e17967 100644 --- a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift @@ -43,12 +43,13 @@ struct RegionSummaryCard: View { /// other caller leaves it `nil` and gets the resolved look. var styleOverride: RegionStyle? - /// Loaded once per regular card and shared by both the large security - /// watermark and the smaller entry-stamp seal. - @State private var regionOutlines: [RegionOutline] = [] + /// Loaded once per regular card from the root-owned UI path cache. The large + /// watermark uses medium fidelity; the much smaller stamp uses small. + @State private var regionPaths: RegionArtworkPaths? @Environment(\.stylesheet) private var stylesheet @Environment(\.regionStyles) private var regionStyles + @Environment(\.regionOutlinePathCache) private var regionOutlinePathCache /// The resolved spec for this card's variant, read once so the rest of the /// view is a straight-line render with no `compact` branching. @@ -79,18 +80,6 @@ struct RegionSummaryCard: View { stylesheet.card.dayCount } - /// Use live gravity only after Core Motion has delivered a real sample. The - /// pre-sample/unavailable state and callers without a provider render the - /// variant's deterministic pose instead of treating zero as live tilt. - private var sheenPose: WhereStylesheet.CardStyle.Sheen.Pose { - guard let tilt, tilt.hasLiveSample else { return card.sheen.staticPose } - return .init(roll: tilt.roll, pitch: tilt.pitch) - } - - private var isSheenStatic: Bool { - tilt?.hasLiveSample != true - } - /// A circular rubber-stamp "entry" impression: the region glyph and year /// ringed by the region name, tilted as if pressed onto the page. The arc /// lettering is dropped on the small compact cards where it can't be read. @@ -102,7 +91,7 @@ struct RegionSummaryCard: View { tint: style.tint, size: card.entryStampSize, showsArcText: card.showsArcText, - regionOutlines: regionOutlines, + regionPath: regionPaths?.stamp ?? Path(), regionShape: card.regionShape, ) } @@ -156,9 +145,13 @@ struct RegionSummaryCard: View { ) } - if let regionShape = card.regionShape, !regionOutlines.isEmpty { + if + let regionShape = card.regionShape, + let regionPath = regionPaths?.watermark, + !regionPath.isEmpty + { RegionOutlineArtwork( - outlines: regionOutlines, + path: regionPath, tint: style.tint, style: regionShape, placement: .watermark, @@ -178,9 +171,20 @@ struct RegionSummaryCard: View { } private func loadRegionOutlines() async { - regionOutlines = [] - guard card.regionShape != nil else { return } - regionOutlines = await RegionGeometryCatalog.outlines(for: regionDays.region) + regionPaths = nil + guard card.regionShape != nil, let regionOutlinePathCache else { return } + async let watermark = regionOutlinePathCache.path( + for: regionDays.region, + resolution: .medium, + ) + async let stamp = regionOutlinePathCache.path( + for: regionDays.region, + resolution: .small, + ) + let (watermarkPath, stampPath) = await (watermark, stamp) + let loaded = RegionArtworkPaths(watermark: watermarkPath, stamp: stampPath) + guard !Task.isCancelled else { return } + regionPaths = loaded } var body: some View { @@ -251,11 +255,9 @@ struct RegionSummaryCard: View { in: cardShape, ) .tiltSheen( - roll: sheenPose.roll, - pitch: sheenPose.pitch, + tilt: tilt, staticRoll: card.sheen.staticPose.roll, staticPitch: card.sheen.staticPose.pitch, - isStatic: isSheenStatic, in: cardShape, intensity: card.sheen.intensity, staticGlintIntensity: card.sheen.staticGlintIntensity, @@ -296,7 +298,7 @@ private struct EntryStamp: View { let tint: Color let size: CGFloat var showsArcText = true - let regionOutlines: [RegionOutline] + let regionPath: Path let regionShape: WhereStylesheet.CardStyle.RegionShape? var body: some View { @@ -314,9 +316,9 @@ private struct EntryStamp: View { .padding(size * 0.13) VStack(spacing: size * 0.02) { - if let regionShape, !regionOutlines.isEmpty { + if let regionShape, !regionPath.isEmpty { RegionOutlineArtwork( - outlines: regionOutlines, + path: regionPath, tint: tint, style: regionShape, placement: .stamp, @@ -347,6 +349,12 @@ private struct EntryStamp: View { } } +/// The two cached render artifacts a regular card consumes together. +private struct RegionArtworkPaths { + let watermark: Path + let stamp: Path +} + /// Lays out `text` along the upper arc of a circle of the given `radius`, /// centered at twelve o'clock — the curved lettering of a rubber stamp. The /// angular span grows with the character count (capped) so short and long @@ -393,10 +401,12 @@ private struct ArcText: View { ) } .padding() + .whereBroadwayRoot() } #Preview("Changing count") { ChangingCountPreview() + .whereBroadwayRoot() } /// Stands in for the count changing under the user, which is otherwise only diff --git a/Where/WhereUI/Sources/Shared/TiltSheen.swift b/Where/WhereUI/Sources/Shared/TiltSheen.swift index 02f0cd94..8fdace9b 100644 --- a/Where/WhereUI/Sources/Shared/TiltSheen.swift +++ b/Where/WhereUI/Sources/Shared/TiltSheen.swift @@ -5,15 +5,15 @@ import SwiftUI /// coated card. Soft-light compositing preserves the card's underlying region /// hue, then the result is clipped to the card's shape and made non-interactive. /// -/// Driven by normalized `roll`/`pitch` (see `TiltProvider`). A caller also -/// supplies the deterministic pose used when motion must stay static, so Reduce -/// Motion and snapshot capture never depend on a live sensor reading. +/// This modifier is deliberately the observation boundary for `TiltProvider`: +/// only the lightweight overlay invalidates at the sensor's 60 Hz cadence, not +/// the card and its Canvas artwork beneath it. A caller also supplies the +/// deterministic pose used when motion must stay static, so Reduce Motion and +/// snapshot capture never depend on a live sensor reading. struct TiltSheen: ViewModifier { - var roll: Double - var pitch: Double + var tilt: TiltProvider? var staticRoll: Double var staticPitch: Double - var isStatic: Bool var shape: ClipShape /// Grayscale-wash and live-glint strength, `0...1`. var intensity: Double = 1 @@ -34,15 +34,15 @@ struct TiltSheen: ViewModifier { /// Tilt actually used to place the highlight. Static-motion contexts use /// the caller's deterministic pose so the sheen stays put. private var activeRoll: Double { - usesStaticPose ? staticRoll.clamped : roll.clamped + usesStaticPose ? staticRoll.clamped : (tilt?.roll ?? staticRoll).clamped } private var activePitch: Double { - usesStaticPose ? staticPitch.clamped : pitch.clamped + usesStaticPose ? staticPitch.clamped : (tilt?.pitch ?? staticPitch).clamped } private var usesStaticPose: Bool { - isStatic || motionIsStatic + motionIsStatic || tilt?.hasLiveSample != true } private var glintIntensity: Double { @@ -92,23 +92,21 @@ struct TiltSheen: ViewModifier { extension View { /// Overlay a tilt-reactive grayscale sheen clipped to `shape`. Pass the same - /// shape used for the card's `glassEffect` so the sheen lines up. + /// shape used for the card's `glassEffect` so the sheen lines up. The + /// provider is observed inside the modifier to keep its frequent updates + /// from invalidating the view that owns the card. func tiltSheen( - roll: Double, - pitch: Double, + tilt: TiltProvider?, staticRoll: Double, staticPitch: Double, - isStatic: Bool, in shape: some Shape, intensity: Double = 1, staticGlintIntensity: Double = 1, ) -> some View { modifier(TiltSheen( - roll: roll, - pitch: pitch, + tilt: tilt, staticRoll: staticRoll, staticPitch: staticPitch, - isStatic: isStatic, shape: shape, intensity: intensity, staticGlintIntensity: staticGlintIntensity, @@ -130,11 +128,9 @@ extension Double { .fill(.indigo.gradient) .frame(width: 320, height: 180) .tiltSheen( - roll: 0.4, - pitch: -0.2, + tilt: nil, staticRoll: 0.4, staticPitch: -0.2, - isStatic: false, in: RoundedRectangle(cornerRadius: 28, style: .continuous), staticGlintIntensity: 1, ) diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index 0f38e686..15def333 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -1102,11 +1102,27 @@ extension View { /// passes `WhereSession`'s live resolver, the widget process one built from /// its `WidgetSnapshot`, and intents one from their services; the default /// empty resolver yields fallback looks (previews, the region-map viewer). + /// The root also owns and injects the region-outline `Path` cache so cards + /// share render artifacts without a process-global UI singleton. public func whereBroadwayRoot( regionStyles: RegionStyleResolver = .default, ) -> some View { - broadwayRoot(themes: WhereThemes.current) + modifier(WhereBroadwayRootModifier(regionStyles: regionStyles)) + } +} + +/// Owns UI render resources once per Where root and injects them alongside the +/// Broadway/design context. Keeping the path cache here shares it across cards +/// without introducing a process-global UI singleton. +private struct WhereBroadwayRootModifier: ViewModifier { + let regionStyles: RegionStyleResolver + @State private var regionOutlinePathCache = RegionOutlinePathCache() + + func body(content: Content) -> some View { + content + .broadwayRoot(themes: WhereThemes.current) .environment(\.regionStyles, regionStyles) + .environment(\.regionOutlinePathCache, regionOutlinePathCache) } } diff --git a/Where/WhereUI/Tests/RegionOutlineArtworkTests.swift b/Where/WhereUI/Tests/RegionOutlineArtworkTests.swift index 3f6b77bc..d6942180 100644 --- a/Where/WhereUI/Tests/RegionOutlineArtworkTests.swift +++ b/Where/WhereUI/Tests/RegionOutlineArtworkTests.swift @@ -10,16 +10,16 @@ struct RegionOutlineArtworkTests { /// to either side of the projection boundary cannot leave cards blank or /// trap on a representative state outline. @Test func hostsRealRegionGeometry() async throws { - let outlines = await RegionGeometryCatalog.outlines(for: .california) + let path = await RegionOutlinePathCache().path(for: .california, resolution: .medium) let style = try #require(WhereStylesheet.default.card.regular.regionShape) let artwork = RegionOutlineArtwork( - outlines: outlines, + path: path, tint: .orange, style: style, placement: .watermark, ) - #expect(!outlines.isEmpty) + #expect(!path.isEmpty) try show(UIHostingController(rootView: artwork)) { hosted in hosted.view.frame = CGRect(x: 0, y: 0, width: 320, height: 180) hosted.view.layoutIfNeeded() diff --git a/Where/WhereUI/Tests/RegionOutlinePathCacheTests.swift b/Where/WhereUI/Tests/RegionOutlinePathCacheTests.swift new file mode 100644 index 00000000..3eb8b90e --- /dev/null +++ b/Where/WhereUI/Tests/RegionOutlinePathCacheTests.swift @@ -0,0 +1,34 @@ +import RegionKit +import SwiftUI +import Testing +@testable import WhereUI + +struct RegionOutlinePathCacheTests { + @Test func cachesProgressivelySimplifiedPathsWithStableFraming() async throws { + let alaska = try #require(Region(rawValue: "us-AK")) + let cache = RegionOutlinePathCache() + let full = await cache.path(for: alaska, resolution: .full) + let medium = await cache.path(for: alaska, resolution: .medium) + let small = await cache.path(for: alaska, resolution: .small) + let repeatedSmall = await cache.path(for: alaska, resolution: .small) + + #expect(!full.isEmpty) + #expect(elementCount(full) > elementCount(medium)) + #expect(elementCount(medium) > elementCount(small)) + #expect(full.boundingRect == medium.boundingRect) + #expect(medium.boundingRect == small.boundingRect) + #expect(repeatedSmall == small) + } + + @Test func otherRegionHasNoPath() async { + let cache = RegionOutlinePathCache() + let path = await cache.path(for: .other, resolution: .full) + #expect(path.isEmpty) + } + + private func elementCount(_ path: Path) -> Int { + var count = 0 + path.forEach { _ in count += 1 } + return count + } +} diff --git a/Where/WhereUI/Tests/RegionSummaryCardTests.swift b/Where/WhereUI/Tests/RegionSummaryCardTests.swift index e7e3e4cf..766c55c2 100644 --- a/Where/WhereUI/Tests/RegionSummaryCardTests.swift +++ b/Where/WhereUI/Tests/RegionSummaryCardTests.swift @@ -14,8 +14,8 @@ struct RegionSummaryCardTests { RegionSummaryCard(regionDays: RegionDays(region: .california, days: days), year: 2026) } - try show(UIHostingController(rootView: card(days: 148))) { hosted in - hosted.rootView = card(days: 149) + try show(UIHostingController(rootView: card(days: 148).whereBroadwayRoot())) { hosted in + hosted.rootView = card(days: 149).whereBroadwayRoot() hosted.view.layoutIfNeeded() #expect(hosted.view != nil) } From 65c99935c84b2bd2b5422d8453bc0cc6ff6acd00 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sat, 1 Aug 2026 23:42:42 -0700 Subject: [PATCH 09/28] Repeat region silhouettes as card microprint Reuse the cached small region path to fill a tangent-aligned inset perimeter on regular cards. Keep the treatment stylesheet-driven and refresh Locations and editor snapshots. --- Where/WhereUI/AGENTS.md | 5 +- Where/WhereUI/README.md | 15 +- .../locations.Loaded_iPad.png | 4 +- .../locations.Loaded_iPad_ax5.png | 4 +- .../locations.Loaded_iPad_contrast.png | 4 +- .../locations.Loaded_iPad_dark.png | 4 +- .../locations.Loaded_iPhone.png | 4 +- .../locations.Loaded_iPhone_ax5.png | 4 +- .../locations.Loaded_iPhone_contrast.png | 4 +- .../locations.Loaded_iPhone_dark.png | 4 +- .../locations.MissingDays_iPhone.png | 4 +- .../locations.MissingDays_iPhone_dark.png | 4 +- .../regionCustomize.Editor_iPhone.png | 4 +- .../regionCustomize.Editor_iPhone_dark.png | 4 +- .../regionCustomize.NeutralEditor_iPhone.png | 4 +- ...ionCustomize.NeutralEditor_iPhone_dark.png | 4 +- .../Primary/RegionOutlineSecurityBorder.swift | 179 ++++++++++++++++++ .../Sources/Primary/RegionSummaryCard.swift | 17 +- .../Sources/Shared/WhereStylesheet.swift | 18 +- .../RegionOutlineSecurityBorderTests.swift | 54 ++++++ .../WhereUI/Tests/WhereStylesheetTests.swift | 6 + 21 files changed, 310 insertions(+), 40 deletions(-) create mode 100644 Where/WhereUI/Sources/Primary/RegionOutlineSecurityBorder.swift create mode 100644 Where/WhereUI/Tests/RegionOutlineSecurityBorderTests.swift diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index 402e8a1c..a35a4faa 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -46,8 +46,9 @@ and testing conventions live in the feature [`Where/AGENTS.md`](../AGENTS.md) - Keep render-ready region geometry in the root-injected `RegionOutlinePathCache`: RegionKit owns the cached source outlines and its stateless simplifier, while WhereUI chooses full/medium/small tolerances and - caches the resulting SwiftUI `Path`s; never project or simplify a boundary in - a card's `body`. + caches the resulting SwiftUI `Path`s; reuse the small path for the stamp and + microprint border, and never project or simplify a boundary in a card's + `body`. - Continuous/looping motion (repeat-forever pulses, `TimelineView(.animation)`, typewriter reveals) must consult the shared `@MotionIsStatic` helper ([`Sources/Shared/MotionIsStatic.swift`](Sources/Shared/MotionIsStatic.swift)) diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 3d05846e..b2fe6af7 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -215,13 +215,14 @@ option lists the picker shows. Regular `RegionSummaryCard`s ask the root-owned `RegionOutlinePathCache` for a medium SwiftUI path for the large security-print watermark and a small path for -the seal inside the circular entry stamp. The UI cache derives both from -RegionKit's one cached source outline using its stateless simplifier; compact -cards retain the simpler symbol treatment. Live tilt is observed only by the -sheen overlay, so its 60 Hz updates do not invalidate the card's text or Canvas -artwork. The card adds no ornamental edge strokes; its containing Liquid Glass -surface owns the subtle border so direct and production rendering do not -diverge. +the seal inside the circular entry stamp. That small path is also repeated as a +tangent-aligned microprint border around the card's inner perimeter. The UI +cache derives both resolutions from RegionKit's one cached source outline using +its stateless simplifier; compact cards retain the simpler symbol treatment. +Live tilt is observed only by the sheen overlay, so its 60 Hz updates do not +invalidate the card's text or Canvas artwork. The card adds no standalone edge +stroke; its containing Liquid Glass surface owns the subtle outer border so +direct and production rendering do not diverge. ## Previews diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png index 1145868a..c9966c99 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:76ec2cc037b259082363d24bc7a8f77c38cf96c6dedd490127b2af0af793edec -size 3146283 +oid sha256:bdbadf26d5e973ab71d1ae6305fdcde62334bb4fee5ea29f9a9ecb517ddfd0f4 +size 3211680 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png index e98292eb..4841a118 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6bc9358528e0df897007d732c8bdebcb118bf23365951d95b68f259318cc1467 -size 3486849 +oid sha256:ce1a750bb6c41c58f5a0c5b793adc0b5b9e641525519c0895bed9bffb5f3a3fa +size 3555186 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png index cea5b404..9f05b9b1 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ba42a0190eb1a02499cd3471e97c5b116177d6f2aece688390f82301823d5724 -size 3100921 +oid sha256:e3a2562545f5701588f07e6968c6fa5828076bb02c2c43bf3ef0b12e164aeab6 +size 3171099 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png index cca36621..7c4145e0 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8da74b24108f713c0df92280ab9726d25c53e98aec281210c105319d00621ad4 -size 3403070 +oid sha256:6e0a380c1b2c08e8e3bd760db5c74a78fb31e729985ec8452f570de9cae41acb +size 3463876 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png index 64fbffbd..abefcfb5 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:60784a6a134061a106d83354c8024ce1d9c2ab8cca4596aaf30724d2f399a0f8 -size 1994924 +oid sha256:aab3280ac931c7c66d85dd5543187168080f85916dea5af6b96d42bcb94c295b +size 2028018 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png index 4b30b8f4..1eae908b 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f5778fb8d759711818575096872d69f499a5d6f3b03990cc1a5429873e0013e9 -size 2415647 +oid sha256:708fc2961fc254214c1b6ace7137f2222cedb709d25932bcc52f57ad5b7e9248 +size 2445348 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png index c908550e..680af17f 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:71bf9a86b5e0019ac04bfeeb225370ebb0f7424514da33c9a55c5675734f4bcf -size 1916425 +oid sha256:8e861d87e800518852c940e8ef5960b7de79352529140601bf610977e8f867f3 +size 1957643 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png index feaf75c0..88ebf12e 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fa01999ae19c03112128333e9024df79edf63d5f011f022daab97e6f30ab32a2 -size 2076014 +oid sha256:ca7afde9cd3338fed9210f739d126882ba0d5eac1848c366e391199ca01521b4 +size 2105819 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png index 43a23638..080d3f80 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:19b15851393926582288bb08f26871aba454db40fa8b21dce18e37a1762226cb -size 1125430 +oid sha256:f49cc900cdf17332e7539ef65437b3d44e32de96b3c5d96cf280c6d11ccead01 +size 1145058 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png index 4fa85ca0..493b31e4 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:697cde3c8fb3d2ce776fa4dd4aab1a32d2f6114878b247fd4eb71eadd999c959 -size 1123052 +oid sha256:ac396593760f1557386cc172cadf5ad28dde0989fff69af8270d2b309c0da408 +size 1138463 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png index 028655cd..765520cf 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:93cee184db84b7f13f62b30f7ef0aaa26f48f518ecd56463d452f6d5757418ef -size 1341948 +oid sha256:ce7e3555f26312c8d3c53c7f8c2c37d6909a555e5fe557f0d027bf9e359b5363 +size 1361664 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png index 6e8fb55a..0e267a2d 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8ea0a87ca2687399468c23bcacf571aa3be162e802aacff632c6625f3684458a -size 1424416 +oid sha256:9afd108596dce06c5c8d083e69f35620e69c8742682ffc3a95ba09ae515497e5 +size 1442041 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone.png index d3038a8e..0abac9a9 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c2a2fad9e8f92e7511c1d6f9a9da38ade5b199f67abe7bc4d9d8d1aadb35e7ad -size 1284106 +oid sha256:aff9cfad57c8c81bedc077c1a977a549dc9bf8e68c88dfae7e40212714593e3e +size 1298584 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png index 2ac7aeb2..77b4b5ea 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b8e4d35224b9d3d94fefd8f7a2536398e4d5ad443c34e5834e5b4d31160d15b4 -size 1233457 +oid sha256:4d71e88f59129c1e5d0cb6d449519a44a75f985afeec1f648a8f00aeb53747ea +size 1243019 diff --git a/Where/WhereUI/Sources/Primary/RegionOutlineSecurityBorder.swift b/Where/WhereUI/Sources/Primary/RegionOutlineSecurityBorder.swift new file mode 100644 index 00000000..d9ffa293 --- /dev/null +++ b/Where/WhereUI/Sources/Primary/RegionOutlineSecurityBorder.swift @@ -0,0 +1,179 @@ +import SwiftUI + +/// Repeats one cached small region silhouette around an inset rounded perimeter, +/// like the microprinted security border on a passport page. +struct RegionOutlineSecurityBorder: View { + let path: Path + let tint: Color + let cornerRadius: CGFloat + let style: WhereStylesheet.CardStyle.RegionShape.SecurityBorder + + var body: some View { + Canvas { context, size in + let bounds = path.boundingRect + guard !path.isEmpty, bounds.width > 0, bounds.height > 0 else { return } + let scale = min( + style.glyphSize / bounds.width, + style.glyphSize / bounds.height, + ) + + for placement in Self.placements( + in: size, + cornerRadius: cornerRadius, + inset: style.inset, + spacing: style.spacing, + ) { + var stamp = context + stamp.translateBy(x: placement.center.x, y: placement.center.y) + stamp.rotate(by: .radians(placement.rotation)) + stamp.scaleBy(x: scale, y: scale) + stamp.translateBy(x: -bounds.midX, y: -bounds.midY) + stamp.fill(path, with: .color(tint.opacity(style.opacity))) + } + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } + + /// Evenly spaced tangent-aligned placements around a clockwise rounded + /// perimeter, starting on its top edge. Kept deterministic for snapshots. + static func placements( + in size: CGSize, + cornerRadius: CGFloat, + inset: CGFloat, + spacing: CGFloat, + ) -> [Placement] { + let rect = CGRect(origin: .zero, size: size).insetBy(dx: inset, dy: inset) + guard rect.width > 0, rect.height > 0, spacing > 0 else { return [] } + + let radius = min( + max(0, cornerRadius - inset), + min(rect.width, rect.height) / 2, + ) + let horizontalLength = max(0, rect.width - radius * 2) + let verticalLength = max(0, rect.height - radius * 2) + let cornerLength = .pi * radius / 2 + let perimeter = horizontalLength * 2 + verticalLength * 2 + cornerLength * 4 + guard perimeter > 0 else { return [] } + + let count = max(4, Int(perimeter / spacing)) + var placements: [Placement] = [] + placements.reserveCapacity(count) + for index in 0 ..< count { + let distance = perimeter * CGFloat(index) / CGFloat(count) + placements.append(placement( + at: distance, + in: rect, + radius: radius, + horizontalLength: horizontalLength, + verticalLength: verticalLength, + cornerLength: cornerLength, + )) + } + return placements + } + + private static func placement( + at distance: CGFloat, + in rect: CGRect, + radius: CGFloat, + horizontalLength: CGFloat, + verticalLength: CGFloat, + cornerLength: CGFloat, + ) -> Placement { + var remaining = distance + + if remaining < horizontalLength { + return Placement( + center: CGPoint(x: rect.minX + radius + remaining, y: rect.minY), + rotation: 0, + ) + } + remaining -= horizontalLength + + if remaining < cornerLength, radius > 0 { + return cornerPlacement( + center: CGPoint(x: rect.maxX - radius, y: rect.minY + radius), + radius: radius, + startAngle: -.pi / 2, + distance: remaining, + ) + } + remaining -= cornerLength + + if remaining < verticalLength { + return Placement( + center: CGPoint(x: rect.maxX, y: rect.minY + radius + remaining), + rotation: .pi / 2, + ) + } + remaining -= verticalLength + + if remaining < cornerLength, radius > 0 { + return cornerPlacement( + center: CGPoint(x: rect.maxX - radius, y: rect.maxY - radius), + radius: radius, + startAngle: 0, + distance: remaining, + ) + } + remaining -= cornerLength + + if remaining < horizontalLength { + return Placement( + center: CGPoint(x: rect.maxX - radius - remaining, y: rect.maxY), + rotation: .pi, + ) + } + remaining -= horizontalLength + + if remaining < cornerLength, radius > 0 { + return cornerPlacement( + center: CGPoint(x: rect.minX + radius, y: rect.maxY - radius), + radius: radius, + startAngle: .pi / 2, + distance: remaining, + ) + } + remaining -= cornerLength + + if remaining < verticalLength { + return Placement( + center: CGPoint(x: rect.minX, y: rect.maxY - radius - remaining), + rotation: -.pi / 2, + ) + } + remaining -= verticalLength + + return cornerPlacement( + center: CGPoint(x: rect.minX + radius, y: rect.minY + radius), + radius: radius, + startAngle: .pi, + distance: remaining, + ) + } + + private static func cornerPlacement( + center: CGPoint, + radius: CGFloat, + startAngle: CGFloat, + distance: CGFloat, + ) -> Placement { + guard radius > 0 else { + return Placement(center: center, rotation: startAngle + .pi / 2) + } + let angle = startAngle + distance / radius + return Placement( + center: CGPoint( + x: center.x + cos(angle) * radius, + y: center.y + sin(angle) * radius, + ), + rotation: angle + .pi / 2, + ) + } + + struct Placement: Equatable { + let center: CGPoint + let rotation: Double + } +} diff --git a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift index f3e17967..bc21bf0e 100644 --- a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift @@ -98,8 +98,8 @@ struct RegionSummaryCard: View { /// A faint, region-tinted "security print" behind the content: a guilloché /// rosette of subtly wobbling concentric rings plus an oversized region - /// glyph watermarked into the corner, the way a passport page is printed - /// beneath its stamps. + /// glyph and microprinted silhouette border, the way a passport page is + /// printed beneath its stamps. private var stampPaper: some View { // Read the main-actor `style.tint` and the value-type `rosette` spec once // here so the nonisolated `Canvas` renderer closure captures `Sendable` @@ -145,6 +145,19 @@ struct RegionSummaryCard: View { ) } + if + let regionShape = card.regionShape, + let regionPath = regionPaths?.stamp, + !regionPath.isEmpty + { + RegionOutlineSecurityBorder( + path: regionPath, + tint: style.tint, + cornerRadius: card.cornerRadius, + style: regionShape.securityBorder, + ) + } + if let regionShape = card.regionShape, let regionPath = regionPaths?.watermark, diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index 15def333..56cd378c 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -277,7 +277,8 @@ extension WhereStylesheet { } /// Relative projection geometry and ink strength for the repeated - /// region silhouette: one large security watermark and one stamp seal. + /// region silhouette: one large security watermark, one stamp seal, and + /// a microprinted inset border. struct RegionShape: Equatable { var watermarkCenter: CGPoint var watermarkExtent: CGSize @@ -289,6 +290,15 @@ extension WhereStylesheet { var stampScale: CGFloat var stampFillOpacity: Double var stampStrokeWidth: CGFloat + var securityBorder: SecurityBorder + + /// The inset ring of tiny tangent-aligned region silhouettes. + struct SecurityBorder: Equatable { + var inset: CGFloat + var glyphSize: CGFloat + var spacing: CGFloat + var opacity: Double + } } struct Sheen: Equatable { @@ -423,6 +433,12 @@ extension WhereStylesheet { stampScale: 0.88, stampFillOpacity: 0.78, stampStrokeWidth: 1, + securityBorder: .init( + inset: 9, + glyphSize: 8, + spacing: 11, + opacity: 0.22, + ), ), sheen: CardStyle.Sheen( intensity: 1, diff --git a/Where/WhereUI/Tests/RegionOutlineSecurityBorderTests.swift b/Where/WhereUI/Tests/RegionOutlineSecurityBorderTests.swift new file mode 100644 index 00000000..22da6b2f --- /dev/null +++ b/Where/WhereUI/Tests/RegionOutlineSecurityBorderTests.swift @@ -0,0 +1,54 @@ +import CoreGraphics +import Testing +@testable import WhereUI + +struct RegionOutlineSecurityBorderTests { + @Test func placementsFollowTheInsetRoundedPerimeter() throws { + let placements = RegionOutlineSecurityBorder.placements( + in: CGSize(width: 320, height: 180), + cornerRadius: 28, + inset: 9, + spacing: 11, + ) + + #expect(placements.count > 80) + let first = try #require(placements.first) + #expect(first.center == CGPoint(x: 28, y: 9)) + #expect(first.rotation == 0) + #expect(placements.allSatisfy { placement in + placement.center.x >= 9 && placement.center.x <= 311 + && placement.center.y >= 9 && placement.center.y <= 171 + }) + } + + @Test func invalidGeometryHasNoPlacements() { + #expect(RegionOutlineSecurityBorder.placements( + in: .zero, + cornerRadius: 28, + inset: 9, + spacing: 13, + ).isEmpty) + #expect(RegionOutlineSecurityBorder.placements( + in: CGSize(width: 320, height: 180), + cornerRadius: 28, + inset: 9, + spacing: 0, + ).isEmpty) + } + + @Test func squareCornersHaveFinitePlacements() { + let placements = RegionOutlineSecurityBorder.placements( + in: CGSize(width: 320, height: 180), + cornerRadius: 0, + inset: 9, + spacing: 11, + ) + + #expect(!placements.isEmpty) + #expect(placements.allSatisfy { placement in + placement.center.x.isFinite + && placement.center.y.isFinite + && placement.rotation.isFinite + }) + } +} diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index 6f0d5ac7..ffaea84c 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -48,6 +48,12 @@ struct WhereStylesheetTests { stampScale: 0.88, stampFillOpacity: 0.78, stampStrokeWidth: 1, + securityBorder: .init( + inset: 9, + glyphSize: 8, + spacing: 11, + opacity: 0.22, + ), )) #expect(card.sheen == .init( intensity: 1, From 6648e8d7561c87b6c29347447f9d7ff4b297751b Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 2 Aug 2026 00:06:00 -0700 Subject: [PATCH 10/28] Preserve thin geography in small region paths Tighten the UI-owned small-resolution tolerance so Long Island and similar narrow features retain their silhouette at stamp size. Pin New York's retained path detail and refresh the affected card snapshots. --- .../locations.Loaded_iPad.png | 4 ++-- .../locations.Loaded_iPad_ax5.png | 4 ++-- .../locations.Loaded_iPad_contrast.png | 4 ++-- .../locations.Loaded_iPad_dark.png | 4 ++-- .../locations.Loaded_iPhone.png | 4 ++-- .../locations.Loaded_iPhone_ax5.png | 4 ++-- .../locations.Loaded_iPhone_contrast.png | 4 ++-- .../locations.Loaded_iPhone_dark.png | 4 ++-- .../locations.MissingDays_iPhone.png | 4 ++-- .../locations.MissingDays_iPhone_dark.png | 4 ++-- .../regionCustomize.Editor_iPhone.png | 4 ++-- .../regionCustomize.Editor_iPhone_dark.png | 4 ++-- .../regionCustomize.NeutralEditor_iPhone.png | 4 ++-- .../regionCustomize.NeutralEditor_iPhone_dark.png | 4 ++-- .../Sources/Primary/RegionOutlinePathCache.swift | 6 ++++-- Where/WhereUI/Tests/RegionOutlinePathCacheTests.swift | 10 ++++++++++ 16 files changed, 42 insertions(+), 30 deletions(-) diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png index c9966c99..31e6f977 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bdbadf26d5e973ab71d1ae6305fdcde62334bb4fee5ea29f9a9ecb517ddfd0f4 -size 3211680 +oid sha256:70e22b51eb4be5af3bc5f86a7503f625766345310af5b11b8814316d19cf4b88 +size 3212934 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png index 4841a118..4ae924ed 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ce1a750bb6c41c58f5a0c5b793adc0b5b9e641525519c0895bed9bffb5f3a3fa -size 3555186 +oid sha256:67fae72e7b0daa623f38b2640db656f89ebb159eaf1bb87aad5755df618c09c3 +size 3556485 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png index 9f05b9b1..610102f8 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e3a2562545f5701588f07e6968c6fa5828076bb02c2c43bf3ef0b12e164aeab6 -size 3171099 +oid sha256:20f68b2931a36d091f8ca2fec63c178499af84ba581530b9dfec716833df7e8d +size 3172874 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png index 7c4145e0..edb63483 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6e0a380c1b2c08e8e3bd760db5c74a78fb31e729985ec8452f570de9cae41acb -size 3463876 +oid sha256:6e5b4fb464df87e6d6056efc367b6117fe9fa8e336eac0168ef2f143cb09e9ae +size 3465729 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png index abefcfb5..573f56ef 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:aab3280ac931c7c66d85dd5543187168080f85916dea5af6b96d42bcb94c295b -size 2028018 +oid sha256:a73d039046e4758b7fd7f21f6711d37ef0de9a66f2e10e9d6a8b842c803369d3 +size 2028239 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png index 1eae908b..749c5dd5 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:708fc2961fc254214c1b6ace7137f2222cedb709d25932bcc52f57ad5b7e9248 -size 2445348 +oid sha256:9582486de83b624ad1de77c22f3c6eb9cf9c2d3da096ed12cf35cc240c64aacb +size 2446053 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png index 680af17f..1c718aaa 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8e861d87e800518852c940e8ef5960b7de79352529140601bf610977e8f867f3 -size 1957643 +oid sha256:262cd693b6852e8361d8c770d1d4354c1303816f4717350fbfb0c95ddf1964bd +size 1957906 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png index 88ebf12e..8d45b63e 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ca7afde9cd3338fed9210f739d126882ba0d5eac1848c366e391199ca01521b4 -size 2105819 +oid sha256:b0335b47ac37570a7a097b50d5d4ba9fa9cef42be94b26d85ee95813d18d1e44 +size 2106828 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png index 080d3f80..18c263e2 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f49cc900cdf17332e7539ef65437b3d44e32de96b3c5d96cf280c6d11ccead01 -size 1145058 +oid sha256:84ecf0e8ac6bae99abf341d0482df377590bbac94f86c0e74389972020f5f064 +size 1145517 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png index 493b31e4..04133dac 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ac396593760f1557386cc172cadf5ad28dde0989fff69af8270d2b309c0da408 -size 1138463 +oid sha256:0a0c535d59d18ec3ed9bfe07baa49aa04f0f00bd9bcbaf10996b81d2e6e52877 +size 1138619 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png index 765520cf..e50c881e 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ce7e3555f26312c8d3c53c7f8c2c37d6909a555e5fe557f0d027bf9e359b5363 -size 1361664 +oid sha256:e33be811e9d7a7b231e59b553ba07f570ed2bea4724d09731d504298ddfac978 +size 1361733 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png index 0e267a2d..4f64e1df 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9afd108596dce06c5c8d083e69f35620e69c8742682ffc3a95ba09ae515497e5 -size 1442041 +oid sha256:3882afe51c6392a3b91e2ebf5f541be9e5888a11e474370e983fb2a511430c21 +size 1442575 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone.png index 0abac9a9..2fea5bcc 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:aff9cfad57c8c81bedc077c1a977a549dc9bf8e68c88dfae7e40212714593e3e -size 1298584 +oid sha256:079f4ce3c454ffd8e5a15fd57aba7ff10804119434789dcea3f8568f20ae2519 +size 1298690 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png index 77b4b5ea..16d67ba8 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4d71e88f59129c1e5d0cb6d449519a44a75f985afeec1f648a8f00aeb53747ea -size 1243019 +oid sha256:243549ad1255365f0777291abb8be0220db000a90256102b53d9507c0f8b25de +size 1242955 diff --git a/Where/WhereUI/Sources/Primary/RegionOutlinePathCache.swift b/Where/WhereUI/Sources/Primary/RegionOutlinePathCache.swift index 7cfce72e..70dcdc39 100644 --- a/Where/WhereUI/Sources/Primary/RegionOutlinePathCache.swift +++ b/Where/WhereUI/Sources/Primary/RegionOutlinePathCache.swift @@ -11,12 +11,14 @@ actor RegionOutlinePathCache { case medium case small - /// Roughly half a point of permitted deviation at each target width. + /// Maximum normalized deviation chosen for each target size. The small + /// path stays below one display pixel so thin geography such as Long + /// Island does not collapse into a coarse wedge inside the stamp. var tolerance: Double? { switch self { case .full: nil case .medium: 1 / 600 - case .small: 1 / 60 + case .small: 1 / 240 } } } diff --git a/Where/WhereUI/Tests/RegionOutlinePathCacheTests.swift b/Where/WhereUI/Tests/RegionOutlinePathCacheTests.swift index 3eb8b90e..a903c3a6 100644 --- a/Where/WhereUI/Tests/RegionOutlinePathCacheTests.swift +++ b/Where/WhereUI/Tests/RegionOutlinePathCacheTests.swift @@ -26,6 +26,16 @@ struct RegionOutlinePathCacheTests { #expect(path.isEmpty) } + @Test func smallResolutionRetainsThinNewYorkGeography() async { + let cache = RegionOutlinePathCache() + let small = await cache.path(for: .newYork, resolution: .small) + + #expect( + elementCount(small) >= 110, + "The stamp path should retain Long Island instead of reducing it to a coarse wedge.", + ) + } + private func elementCount(_ path: Path) -> Int { var count = 0 path.forEach { _ in count += 1 } From bde1b9cd881e5a06f495f54594a5cb5fe6f1ecbe Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 2 Aug 2026 00:25:30 -0700 Subject: [PATCH 11/28] Use micro paths for card security borders Add a fourth UI-owned path fidelity for the repeated eight-point microprint while preserving the detailed small path for entry stamps. Pin the fidelity ordering and New York complexity budget, and refresh the intended card snapshots. --- Where/WhereUI/AGENTS.md | 8 ++++---- Where/WhereUI/README.md | 9 +++++---- .../locations.Loaded_iPad.png | 4 ++-- .../locations.Loaded_iPad_ax5.png | 4 ++-- .../locations.Loaded_iPad_contrast.png | 4 ++-- .../locations.Loaded_iPad_dark.png | 4 ++-- .../locations.Loaded_iPhone.png | 4 ++-- .../locations.Loaded_iPhone_ax5.png | 4 ++-- .../locations.Loaded_iPhone_contrast.png | 4 ++-- .../locations.Loaded_iPhone_dark.png | 4 ++-- .../locations.MissingDays_iPhone.png | 4 ++-- .../locations.MissingDays_iPhone_dark.png | 4 ++-- .../regionCustomize.Editor_iPhone.png | 4 ++-- .../regionCustomize.Editor_iPhone_dark.png | 4 ++-- .../regionCustomize.NeutralEditor_iPhone.png | 4 ++-- ...ionCustomize.NeutralEditor_iPhone_dark.png | 4 ++-- .../Primary/RegionOutlinePathCache.swift | 9 ++++++--- .../Primary/RegionOutlineSecurityBorder.swift | 4 ++-- .../Sources/Primary/RegionSummaryCard.swift | 20 ++++++++++++++----- .../Tests/RegionOutlinePathCacheTests.swift | 18 +++++++++++++++-- 20 files changed, 76 insertions(+), 48 deletions(-) diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index a35a4faa..726be90d 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -45,10 +45,10 @@ and testing conventions live in the feature [`Where/AGENTS.md`](../AGENTS.md) `broadwayRoot` at each site. - Keep render-ready region geometry in the root-injected `RegionOutlinePathCache`: RegionKit owns the cached source outlines and its - stateless simplifier, while WhereUI chooses full/medium/small tolerances and - caches the resulting SwiftUI `Path`s; reuse the small path for the stamp and - microprint border, and never project or simplify a boundary in a card's - `body`. + stateless simplifier, while WhereUI chooses full/medium/small/micro + tolerances and caches the resulting SwiftUI `Path`s; use the small path for + the stamp and the micro path for the repeated border, and never project or + simplify a boundary in a card's `body`. - Continuous/looping motion (repeat-forever pulses, `TimelineView(.animation)`, typewriter reveals) must consult the shared `@MotionIsStatic` helper ([`Sources/Shared/MotionIsStatic.swift`](Sources/Shared/MotionIsStatic.swift)) diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index b2fe6af7..01f96097 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -215,10 +215,11 @@ option lists the picker shows. Regular `RegionSummaryCard`s ask the root-owned `RegionOutlinePathCache` for a medium SwiftUI path for the large security-print watermark and a small path for -the seal inside the circular entry stamp. That small path is also repeated as a -tangent-aligned microprint border around the card's inner perimeter. The UI -cache derives both resolutions from RegionKit's one cached source outline using -its stateless simplifier; compact cards retain the simpler symbol treatment. +the seal inside the circular entry stamp. A separate micro path is repeated as +a tangent-aligned microprint border around the card's inner perimeter. The UI +cache derives all four resolutions from RegionKit's one cached source outline +using its stateless simplifier; compact cards retain the simpler symbol +treatment. Live tilt is observed only by the sheen overlay, so its 60 Hz updates do not invalidate the card's text or Canvas artwork. The card adds no standalone edge stroke; its containing Liquid Glass surface owns the subtle outer border so diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png index 31e6f977..62eada6c 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:70e22b51eb4be5af3bc5f86a7503f625766345310af5b11b8814316d19cf4b88 -size 3212934 +oid sha256:45c46f05f2082c86b0e1be17efa65e281ac98142aa0120882dfc772270ecb86f +size 3211520 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png index 4ae924ed..baabd1f5 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:67fae72e7b0daa623f38b2640db656f89ebb159eaf1bb87aad5755df618c09c3 -size 3556485 +oid sha256:7ac7ce889a3d0eff9ef852ec30109f90e4babce8474abc3f65a0176973916b49 +size 3555192 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png index 610102f8..06f4058f 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:20f68b2931a36d091f8ca2fec63c178499af84ba581530b9dfec716833df7e8d -size 3172874 +oid sha256:13c9c996d46c07496a707346dbddd7925298c12ea7f61aceaf93c4acb8b3d335 +size 3171096 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png index edb63483..2482676d 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6e5b4fb464df87e6d6056efc367b6117fe9fa8e336eac0168ef2f143cb09e9ae -size 3465729 +oid sha256:d5fbc0cfcf143994be7c73326f8f6aa5c26f5e393c6918e729430d20e356f4b9 +size 3464622 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png index 573f56ef..dd41278e 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a73d039046e4758b7fd7f21f6711d37ef0de9a66f2e10e9d6a8b842c803369d3 -size 2028239 +oid sha256:147b4e364278dc06c323022606f9689073afaf3f3370667fb79df1b7493c294b +size 2027719 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png index 749c5dd5..3d259b1a 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9582486de83b624ad1de77c22f3c6eb9cf9c2d3da096ed12cf35cc240c64aacb -size 2446053 +oid sha256:74bd7760359f94a2cbc6a792af0ca6b1799341b5fb8741271a783ed99959d3fb +size 2445464 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png index 1c718aaa..a5fd8008 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:262cd693b6852e8361d8c770d1d4354c1303816f4717350fbfb0c95ddf1964bd -size 1957906 +oid sha256:800369616203afbbb37442df8a73c5b2cd7fd49046e9d9e3a1d13e5760bc1b30 +size 1957863 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png index 8d45b63e..9bf186b4 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b0335b47ac37570a7a097b50d5d4ba9fa9cef42be94b26d85ee95813d18d1e44 -size 2106828 +oid sha256:4214cae930724dcd4420ad9db6edd743237a6a1a5f7279f1bd350f2955372b99 +size 2106579 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png index 18c263e2..20d0914f 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:84ecf0e8ac6bae99abf341d0482df377590bbac94f86c0e74389972020f5f064 -size 1145517 +oid sha256:43b4e16ea87579afdbb358b298506246dc07ea5089bf66a4d1198c49461ba575 +size 1144742 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png index 04133dac..728711e0 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0a0c535d59d18ec3ed9bfe07baa49aa04f0f00bd9bcbaf10996b81d2e6e52877 -size 1138619 +oid sha256:16fee7ae00a764b23564c44dc3a96d86a93db7833b6706e24f8004a537042120 +size 1138353 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png index e50c881e..32c50b4a 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e33be811e9d7a7b231e59b553ba07f570ed2bea4724d09731d504298ddfac978 -size 1361733 +oid sha256:5206553807f3237a0223d732a3e168299ab42825240533cd9d50a8c5e8dc6784 +size 1361669 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png index 4f64e1df..81cfd6b2 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3882afe51c6392a3b91e2ebf5f541be9e5888a11e474370e983fb2a511430c21 -size 1442575 +oid sha256:d8971c20015b09832ba878d14db049ffdea172354eb6dedcdc7ca1a0d7c47387 +size 1442210 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone.png index 2fea5bcc..5c87d657 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:079f4ce3c454ffd8e5a15fd57aba7ff10804119434789dcea3f8568f20ae2519 -size 1298690 +oid sha256:b85fcfea01f9b0da25979c6b5f89aad263bcd08ad49d399de11b49f1f6e943a9 +size 1298286 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png index 16d67ba8..8591e61d 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:243549ad1255365f0777291abb8be0220db000a90256102b53d9507c0f8b25de -size 1242955 +oid sha256:aac2bd3b0c1ebf022b608d5f875fb7d02e1a715590e92289c4a8a8ef91a7bd82 +size 1243402 diff --git a/Where/WhereUI/Sources/Primary/RegionOutlinePathCache.swift b/Where/WhereUI/Sources/Primary/RegionOutlinePathCache.swift index 70dcdc39..7b3e8433 100644 --- a/Where/WhereUI/Sources/Primary/RegionOutlinePathCache.swift +++ b/Where/WhereUI/Sources/Primary/RegionOutlinePathCache.swift @@ -2,7 +2,7 @@ import Foundation import RegionKit import SwiftUI -/// UI-owned cache of projected region paths at the three rendering fidelities +/// UI-owned cache of projected region paths at the four rendering fidelities /// Where needs. RegionKit owns/caches source geometry and provides the stateless /// simplifier; this actor owns display policy and SwiftUI render artifacts. actor RegionOutlinePathCache { @@ -10,15 +10,18 @@ actor RegionOutlinePathCache { case full case medium case small + case micro /// Maximum normalized deviation chosen for each target size. The small - /// path stays below one display pixel so thin geography such as Long - /// Island does not collapse into a coarse wedge inside the stamp. + /// path preserves thin geography such as Long Island inside the stamp; + /// the coarser micro path remains subpixel at the repeated border's + /// eight-point glyph size. var tolerance: Double? { switch self { case .full: nil case .medium: 1 / 600 case .small: 1 / 240 + case .micro: 1 / 60 } } } diff --git a/Where/WhereUI/Sources/Primary/RegionOutlineSecurityBorder.swift b/Where/WhereUI/Sources/Primary/RegionOutlineSecurityBorder.swift index d9ffa293..80a76bc9 100644 --- a/Where/WhereUI/Sources/Primary/RegionOutlineSecurityBorder.swift +++ b/Where/WhereUI/Sources/Primary/RegionOutlineSecurityBorder.swift @@ -1,7 +1,7 @@ import SwiftUI -/// Repeats one cached small region silhouette around an inset rounded perimeter, -/// like the microprinted security border on a passport page. +/// Repeats one cached micro-fidelity region silhouette around an inset rounded +/// perimeter, like the microprinted security border on a passport page. struct RegionOutlineSecurityBorder: View { let path: Path let tint: Color diff --git a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift index bc21bf0e..65166d38 100644 --- a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift @@ -44,7 +44,8 @@ struct RegionSummaryCard: View { var styleOverride: RegionStyle? /// Loaded once per regular card from the root-owned UI path cache. The large - /// watermark uses medium fidelity; the much smaller stamp uses small. + /// watermark uses medium fidelity, the stamp uses small, and the repeated + /// border uses micro. @State private var regionPaths: RegionArtworkPaths? @Environment(\.stylesheet) private var stylesheet @@ -147,7 +148,7 @@ struct RegionSummaryCard: View { if let regionShape = card.regionShape, - let regionPath = regionPaths?.stamp, + let regionPath = regionPaths?.microprint, !regionPath.isEmpty { RegionOutlineSecurityBorder( @@ -194,8 +195,16 @@ struct RegionSummaryCard: View { for: regionDays.region, resolution: .small, ) - let (watermarkPath, stampPath) = await (watermark, stamp) - let loaded = RegionArtworkPaths(watermark: watermarkPath, stamp: stampPath) + async let microprint = regionOutlinePathCache.path( + for: regionDays.region, + resolution: .micro, + ) + let (watermarkPath, stampPath, microprintPath) = await (watermark, stamp, microprint) + let loaded = RegionArtworkPaths( + watermark: watermarkPath, + stamp: stampPath, + microprint: microprintPath, + ) guard !Task.isCancelled else { return } regionPaths = loaded } @@ -362,10 +371,11 @@ private struct EntryStamp: View { } } -/// The two cached render artifacts a regular card consumes together. +/// The three cached render artifacts a regular card consumes together. private struct RegionArtworkPaths { let watermark: Path let stamp: Path + let microprint: Path } /// Lays out `text` along the upper arc of a circle of the given `radius`, diff --git a/Where/WhereUI/Tests/RegionOutlinePathCacheTests.swift b/Where/WhereUI/Tests/RegionOutlinePathCacheTests.swift index a903c3a6..97954a0f 100644 --- a/Where/WhereUI/Tests/RegionOutlinePathCacheTests.swift +++ b/Where/WhereUI/Tests/RegionOutlinePathCacheTests.swift @@ -10,14 +10,17 @@ struct RegionOutlinePathCacheTests { let full = await cache.path(for: alaska, resolution: .full) let medium = await cache.path(for: alaska, resolution: .medium) let small = await cache.path(for: alaska, resolution: .small) - let repeatedSmall = await cache.path(for: alaska, resolution: .small) + let micro = await cache.path(for: alaska, resolution: .micro) + let repeatedMicro = await cache.path(for: alaska, resolution: .micro) #expect(!full.isEmpty) #expect(elementCount(full) > elementCount(medium)) #expect(elementCount(medium) > elementCount(small)) + #expect(elementCount(small) > elementCount(micro)) #expect(full.boundingRect == medium.boundingRect) #expect(medium.boundingRect == small.boundingRect) - #expect(repeatedSmall == small) + #expect(small.boundingRect == micro.boundingRect) + #expect(repeatedMicro == micro) } @Test func otherRegionHasNoPath() async { @@ -36,6 +39,17 @@ struct RegionOutlinePathCacheTests { ) } + @Test func microResolutionBoundsRepeatedBorderDetail() async { + let cache = RegionOutlinePathCache() + let micro = await cache.path(for: .newYork, resolution: .micro) + + #expect(!micro.isEmpty) + #expect( + elementCount(micro) <= 60, + "The repeated eight-point border path should stay within its rendering budget.", + ) + } + private func elementCount(_ path: Path) -> Int { var count = 0 path.forEach { _ in count += 1 } From a319b0a27da9f968cc4203b6e070aaa084a6e898 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 2 Aug 2026 00:38:12 -0700 Subject: [PATCH 12/28] Remove stroke from geographic entry stamps Render the small region seal as a fill only so acute coastline geometry cannot produce visible stroke spikes. Remove the unused stylesheet token and refresh the targeted card references. --- .../LocationsViewSnapshotTests/locations.Loaded_iPad.png | 4 ++-- .../LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png | 4 ++-- .../locations.Loaded_iPad_contrast.png | 4 ++-- .../locations.Loaded_iPad_dark.png | 4 ++-- .../LocationsViewSnapshotTests/locations.Loaded_iPhone.png | 4 ++-- .../locations.Loaded_iPhone_ax5.png | 4 ++-- .../locations.Loaded_iPhone_contrast.png | 4 ++-- .../locations.Loaded_iPhone_dark.png | 4 ++-- .../locations.MissingDays_iPhone.png | 4 ++-- .../locations.MissingDays_iPhone_dark.png | 4 ++-- .../regionCustomize.Editor_iPhone.png | 4 ++-- .../regionCustomize.Editor_iPhone_dark.png | 4 ++-- .../regionCustomize.NeutralEditor_iPhone.png | 4 ++-- .../regionCustomize.NeutralEditor_iPhone_dark.png | 4 ++-- Where/WhereUI/Sources/Primary/RegionOutlineArtwork.swift | 5 ----- Where/WhereUI/Sources/Shared/WhereStylesheet.swift | 2 -- Where/WhereUI/Tests/WhereStylesheetTests.swift | 1 - 17 files changed, 28 insertions(+), 36 deletions(-) diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png index 62eada6c..73a147a2 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:45c46f05f2082c86b0e1be17efa65e281ac98142aa0120882dfc772270ecb86f -size 3211520 +oid sha256:2292ccd94d76fc0ba6040e919443d8edb013ed8d61b5bfad30520e237d1a485e +size 3210057 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png index baabd1f5..430cca12 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7ac7ce889a3d0eff9ef852ec30109f90e4babce8474abc3f65a0176973916b49 -size 3555192 +oid sha256:77e328bc5419bf935548962603f2ecd36baba14b384fab156d3873c6831b941e +size 3553727 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png index 06f4058f..6c9d5807 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:13c9c996d46c07496a707346dbddd7925298c12ea7f61aceaf93c4acb8b3d335 -size 3171096 +oid sha256:02b0ff3edc0cbafa6987e94cbcf255c5e6c846705630cafa22fcaae97a3e8009 +size 3168902 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png index 2482676d..1f68e47c 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d5fbc0cfcf143994be7c73326f8f6aa5c26f5e393c6918e729430d20e356f4b9 -size 3464622 +oid sha256:fd9e1df58f4439f00130c25d5c28f485d240b8f83bb816a3b6e72e15db9ff323 +size 3463947 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png index dd41278e..4aee5d5e 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:147b4e364278dc06c323022606f9689073afaf3f3370667fb79df1b7493c294b -size 2027719 +oid sha256:53a01e2fef1cf6b71dae752ab05959008662f13f9f14c3455012b3a851917e99 +size 2026647 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png index 3d259b1a..38b40622 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:74bd7760359f94a2cbc6a792af0ca6b1799341b5fb8741271a783ed99959d3fb -size 2445464 +oid sha256:390bd009dc69021cc097b1ba01ca3fa19e0cfd73f08d8c9cf28fa70f6a6474e4 +size 2444543 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png index a5fd8008..d105e5f6 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:800369616203afbbb37442df8a73c5b2cd7fd49046e9d9e3a1d13e5760bc1b30 -size 1957863 +oid sha256:606483a1a916a4faa097b81edf8a82d1bcae4e3ec45e47f24f2c0d192d09544f +size 1956072 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png index 9bf186b4..4cb23aba 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4214cae930724dcd4420ad9db6edd743237a6a1a5f7279f1bd350f2955372b99 -size 2106579 +oid sha256:acea3ad42a09799c722c4cb4c9c8b00a52447ffaea5b8cfb9b93cc48504a0de2 +size 2105328 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png index 20d0914f..d0d5d4d2 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:43b4e16ea87579afdbb358b298506246dc07ea5089bf66a4d1198c49461ba575 -size 1144742 +oid sha256:6117e769d7d62e2f6749355aa7cb560c801728247229e28c8fe9a7a9fc1fb49e +size 1144279 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png index 728711e0..3ce4d375 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:16fee7ae00a764b23564c44dc3a96d86a93db7833b6706e24f8004a537042120 -size 1138353 +oid sha256:54aaf8d2335c668c073fc7d9a50faedeb9264f8e36d7cb84958702197a5ef9b8 +size 1138023 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png index 32c50b4a..5b71484c 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5206553807f3237a0223d732a3e168299ab42825240533cd9d50a8c5e8dc6784 -size 1361669 +oid sha256:659f23c39e8f26216ca44b02a00c8491a4aefcbb14e863d51b120889b6bd3f31 +size 1361166 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png index 81cfd6b2..075bbc22 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d8971c20015b09832ba878d14db049ffdea172354eb6dedcdc7ca1a0d7c47387 -size 1442210 +oid sha256:930253998ccaeca9d73d0ebd5f4aaddb7271d18b11cd14c53bb0f8691b8d2fea +size 1441537 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone.png index 5c87d657..64c29052 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b85fcfea01f9b0da25979c6b5f89aad263bcd08ad49d399de11b49f1f6e943a9 -size 1298286 +oid sha256:ac800d7ee5e230ea69b0828d2d882c0e1e959ace77bf9a1d54bd5cf711f6720c +size 1297918 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png index 8591e61d..c7c80f4e 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:aac2bd3b0c1ebf022b608d5f875fb7d02e1a715590e92289c4a8a8ef91a7bd82 -size 1243402 +oid sha256:5ae928829be955a55276924f9944c50a03703caa25b14357b5ce473da26c1e01 +size 1242704 diff --git a/Where/WhereUI/Sources/Primary/RegionOutlineArtwork.swift b/Where/WhereUI/Sources/Primary/RegionOutlineArtwork.swift index 892c8338..e06f5c68 100644 --- a/Where/WhereUI/Sources/Primary/RegionOutlineArtwork.swift +++ b/Where/WhereUI/Sources/Primary/RegionOutlineArtwork.swift @@ -44,11 +44,6 @@ struct RegionOutlineArtwork: View { path, with: .color(tint.opacity(style.stampFillOpacity)), ) - projectedContext.stroke( - path, - with: .color(tint), - lineWidth: style.stampStrokeWidth / scale, - ) } } .allowsHitTesting(false) diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index 56cd378c..90d46ca8 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -289,7 +289,6 @@ extension WhereStylesheet { var stampExtent: CGSize var stampScale: CGFloat var stampFillOpacity: Double - var stampStrokeWidth: CGFloat var securityBorder: SecurityBorder /// The inset ring of tiny tangent-aligned region silhouettes. @@ -432,7 +431,6 @@ extension WhereStylesheet { stampExtent: CGSize(width: 0.78, height: 0.78), stampScale: 0.88, stampFillOpacity: 0.78, - stampStrokeWidth: 1, securityBorder: .init( inset: 9, glyphSize: 8, diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index ffaea84c..6a8a839d 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -47,7 +47,6 @@ struct WhereStylesheetTests { stampExtent: CGSize(width: 0.78, height: 0.78), stampScale: 0.88, stampFillOpacity: 0.78, - stampStrokeWidth: 1, securityBorder: .init( inset: 9, glyphSize: 8, From 92d7210edd0a26705ea0f4dc1e279bc9968c3353 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 2 Aug 2026 00:53:04 -0700 Subject: [PATCH 13/28] Lighten card security print in dark mode Resolve a Screen blend from the Broadway color-mode trait and apply it to the shared stamp and security-paper layers. Preserve normal light-mode compositing, pin the trait token, and refresh only the reviewed dark references. --- Where/WhereUI/README.md | 4 +++- .../elsewhere.Loaded_iPad_dark.png | 4 ++-- .../elsewhere.Loaded_iPhone_dark.png | 4 ++-- .../locations.Loaded_iPad_dark.png | 4 ++-- .../locations.Loaded_iPhone_dark.png | 4 ++-- .../locations.MissingDays_iPhone_dark.png | 4 ++-- .../regionCustomize.Editor_iPhone_dark.png | 4 ++-- .../regionCustomize.NeutralEditor_iPhone_dark.png | 4 ++-- Where/WhereUI/Sources/Primary/RegionSummaryCard.swift | 2 ++ Where/WhereUI/Sources/Shared/WhereStylesheet.swift | 10 ++++++++++ Where/WhereUI/Tests/WhereStylesheetTests.swift | 9 +++++++++ 11 files changed, 38 insertions(+), 15 deletions(-) diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 01f96097..ba2be795 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -219,7 +219,9 @@ the seal inside the circular entry stamp. A separate micro path is repeated as a tangent-aligned microprint border around the card's inner perimeter. The UI cache derives all four resolutions from RegionKit's one cached source outline using its stateless simplifier; compact cards retain the simpler symbol -treatment. +treatment. Security-print layers use normal compositing in light mode and +Screen in dark mode, so the same tinted details darken pale glass but lighten +dark glass. Live tilt is observed only by the sheen overlay, so its 60 Hz updates do not invalidate the card's text or Canvas artwork. The card adds no standalone edge stroke; its containing Liquid Glass surface owns the subtle outer border so diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad_dark.png index e720b9d5..9b98eaa5 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:687e745bbcc89a0744eb37a05abe0109ef196dbf502c11e05f233a47f4e9e29e -size 3236957 +oid sha256:c7de27a6017f2d7f5e6dc1d601343670dadf3e47c9c5602f42d81a4710932429 +size 3184405 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone_dark.png index 59d9bed0..af99bab0 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0b60cb261949c3fa9af6dd0564def9e92641f2e4baf555704085deb8ac18b3aa -size 1863310 +oid sha256:2da6d99facab00d37f456a43b23d3e964ed37ab3668a292957713f092b15833a +size 1840138 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png index 1f68e47c..cc4d8e11 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fd9e1df58f4439f00130c25d5c28f485d240b8f83bb816a3b6e72e15db9ff323 -size 3463947 +oid sha256:47f720ac3ae01a8947e1090852bf2d2c4fbf0d19f9ecbf18a2112624a7b62d47 +size 3545203 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png index 4cb23aba..de142707 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:acea3ad42a09799c722c4cb4c9c8b00a52447ffaea5b8cfb9b93cc48504a0de2 -size 2105328 +oid sha256:d4cf6cd31cd513f50754ead10aaccfd689a967949a047f6c1fdad330e98db24d +size 2170768 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png index 3ce4d375..b6e1b600 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:54aaf8d2335c668c073fc7d9a50faedeb9264f8e36d7cb84958702197a5ef9b8 -size 1138023 +oid sha256:1a29daf41586c050491a6e419ba03dfcc98239dd97ef467b778e336ce5153eeb +size 1170887 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png index 075bbc22..0dd249fe 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:930253998ccaeca9d73d0ebd5f4aaddb7271d18b11cd14c53bb0f8691b8d2fea -size 1441537 +oid sha256:4c4f895ed2eaa6bb9402f65caa62221297b97ec3f9da17013dbdfeedf600e3a0 +size 1464322 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png index c7c80f4e..96cef637 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5ae928829be955a55276924f9944c50a03703caa25b14357b5ce473da26c1e01 -size 1242704 +oid sha256:c8785ccc8a2f14a3025fad0789cf9d7714a04d1f6c2ac1e351ccc0a224db34b7 +size 1307354 diff --git a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift index 65166d38..a72d16ba 100644 --- a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift @@ -95,6 +95,7 @@ struct RegionSummaryCard: View { regionPath: regionPaths?.stamp ?? Path(), regionShape: card.regionShape, ) + .blendMode(stylesheet.card.securityPrintBlendMode) } /// A faint, region-tinted "security print" behind the content: a guilloché @@ -179,6 +180,7 @@ struct RegionSummaryCard: View { .offset(x: card.watermarkOffset.width, y: card.watermarkOffset.height) } } + .blendMode(stylesheet.card.securityPrintBlendMode) .clipShape(cardShape) .allowsHitTesting(false) .accessibilityHidden(true) diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index 90d46ca8..ff857940 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -55,6 +55,12 @@ struct WhereStylesheet: BStylesheet { card.dayCount = .reducedMotion developerOverlay.menu.motion = .reduced } + + // Security print behaves like ink on pale cards and reflected ink on + // dark ones: dark-mode artwork can only lighten the glass beneath it. + if traits.mode == .dark { + card.securityPrintBlendMode = .screen + } } /// The fixed token set: the fallback used off the `View` tree (layout @@ -336,6 +342,9 @@ extension WhereStylesheet { var nameOpacity: Double /// Fill opacities of the two security-print rosettes. var rosetteFill: RosetteFill + /// How decorative card ink combines with its Liquid Glass surface. + /// Dark mode uses Screen so every detail lifts the darker card beneath. + var securityPrintBlendMode: BlendMode /// How the day count changes while the card is on screen; resolves to /// ``DayCountStyle/reducedMotion`` under Reduce Motion. var dayCount: DayCountStyle @@ -487,6 +496,7 @@ extension WhereStylesheet { glassTintOpacity: 0.18, nameOpacity: 0.8, rosetteFill: RosetteFill(primary: 0.12, secondary: 0.08), + securityPrintBlendMode: .normal, dayCount: .standard, ) } diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index 6a8a839d..b00c1e50 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -107,6 +107,7 @@ struct WhereStylesheetTests { #expect(card.glassTintOpacity == 0.18) #expect(card.nameOpacity == 0.8) #expect(card.rosetteFill == .init(primary: 0.12, secondary: 0.08)) + #expect(card.securityPrintBlendMode == .normal) #expect(card.dayCount == .standard) #expect(card.dayCount.animation == .easeOut(duration: 0.3)) } @@ -384,6 +385,14 @@ struct WhereStylesheetTests { #expect(resolved.developerOverlay.menu.motion == .reduced) #expect(resolved.developerOverlay.menu.motion.usesSpatialMotion == false) } + + @MainActor + @Test func lightensCardSecurityPrintInDarkMode() throws { + var context = BContext(traits: .system) + context.traitOverrides.mode = .dark + let resolved = try context.stylesheets.get(WhereStylesheet.self) + #expect(resolved.card.securityPrintBlendMode == .screen) + } } /// Covers the WhereUI glue: `EnvironmentValues.stylesheet` resolves a From 18468470b2e802bca86bf0a0ebf6678ebbbb3818 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 2 Aug 2026 01:11:13 -0700 Subject: [PATCH 14/28] Use the full palette for fallback region colors Derive fallback appearances from the same complete palette exposed by the selector now that no compatibility pool is needed. Refresh the region-map references for the resulting pre-release color assignments. --- .../regionMap.Default_iPhone.png | 4 ++-- .../regionMap.Default_iPhone_dark.png | 4 ++-- .../Model/RegionAppearanceCatalog.swift | 21 ++----------------- .../Tests/RegionAppearanceCatalogTests.swift | 7 +++---- 4 files changed, 9 insertions(+), 27 deletions(-) diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionMapViewSnapshotTests/regionMap.Default_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionMapViewSnapshotTests/regionMap.Default_iPhone.png index c89689e8..2e332fc9 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionMapViewSnapshotTests/regionMap.Default_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionMapViewSnapshotTests/regionMap.Default_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:534b5b6c492494c6927e3f385e3b9196a424c4c894841e3b298f48603b297bca -size 148499 +oid sha256:28c3b096e376b63f90f203a26add70a0ce2b55f5f15dde402b3ede43b84f3c6a +size 148402 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionMapViewSnapshotTests/regionMap.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionMapViewSnapshotTests/regionMap.Default_iPhone_dark.png index d97a882f..aff6a65d 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionMapViewSnapshotTests/regionMap.Default_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionMapViewSnapshotTests/regionMap.Default_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2aac7496109e686a8b788b67f7836f221ee8d094d7e0a24ee55483873202e40f -size 150154 +oid sha256:141b345b6a5eabc3311bdb12634be502954dbdf1c5eaa06193453f084487877e +size 150146 diff --git a/Where/WhereUI/Sources/Model/RegionAppearanceCatalog.swift b/Where/WhereUI/Sources/Model/RegionAppearanceCatalog.swift index afb529e4..b3434eca 100644 --- a/Where/WhereUI/Sources/Model/RegionAppearanceCatalog.swift +++ b/Where/WhereUI/Sources/Model/RegionAppearanceCatalog.swift @@ -122,27 +122,10 @@ public enum RegionAppearanceCatalog { RegionAppearance(color: .teal, emoji: "🧭", symbolName: "location.magnifyingglass"), ] - /// Keep id-derived defaults stable as the user-selectable palette grows. - /// These are the eleven colors used by the original hash mapping. - private static let defaultColors: [RegionColorToken] = [ - .orange, - .indigo, - .red, - .blue, - .teal, - .green, - .mint, - .cyan, - .purple, - .pink, - .brown, - ] - - /// A stable accent token derived from the region id, matching the order of - /// the original palette so adding picker choices doesn't recolor a region. + /// An accent token derived from the region id across every selectable color. private static func defaultColor(for id: String) -> RegionColorToken { let sum = id.unicodeScalars.reduce(0) { $0 &+ Int($1.value) } - return defaultColors[sum % defaultColors.count] + return colors[sum % colors.count] } } diff --git a/Where/WhereUI/Tests/RegionAppearanceCatalogTests.swift b/Where/WhereUI/Tests/RegionAppearanceCatalogTests.swift index d8066f1b..3224fd7d 100644 --- a/Where/WhereUI/Tests/RegionAppearanceCatalogTests.swift +++ b/Where/WhereUI/Tests/RegionAppearanceCatalogTests.swift @@ -3,16 +3,15 @@ import Testing @testable import WhereCore @testable import WhereUI -/// The picker exposes every stored color while id-derived fallback appearances -/// remain stable as new choices are added. +/// The picker and id-derived fallback appearances share one complete palette. struct RegionAppearanceCatalogTests { @Test func selectorIncludesEveryColorToken() { #expect(RegionAppearanceCatalog.colors == RegionColorToken.allCases) } - @Test func expandedSelectorDoesNotRecolorExistingDefaults() throws { + @Test func idDerivedDefaultsUseExpandedColors() throws { let texas = try #require(Region(rawValue: "us-TX")) - #expect(RegionAppearanceCatalog.defaultAppearance(for: texas).color == .pink) + #expect(RegionAppearanceCatalog.defaultAppearance(for: texas).color == .charcoal) } @Test func lightSwatchesUseDarkSelectionGlyphs() { From 4517f1ee95ae2d7dca8bea05d0dcd6eeed417d36 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 2 Aug 2026 01:14:23 -0700 Subject: [PATCH 15/28] Drive region artwork from render styles Group watermark and stamp tokens into complete artwork specs owned by the card stylesheet. Keep the Canvas renderer placement-agnostic by passing the selected projection and ink treatment directly. --- .../Primary/RegionOutlineArtwork.swift | 79 +++++-------------- .../Sources/Primary/RegionSummaryCard.swift | 6 +- .../Sources/Shared/WhereStylesheet.swift | 53 ++++++++----- .../Tests/RegionOutlineArtworkTests.swift | 3 +- .../WhereUI/Tests/WhereStylesheetTests.swift | 23 +++--- 5 files changed, 70 insertions(+), 94 deletions(-) diff --git a/Where/WhereUI/Sources/Primary/RegionOutlineArtwork.swift b/Where/WhereUI/Sources/Primary/RegionOutlineArtwork.swift index e06f5c68..f95bf146 100644 --- a/Where/WhereUI/Sources/Primary/RegionOutlineArtwork.swift +++ b/Where/WhereUI/Sources/Primary/RegionOutlineArtwork.swift @@ -1,80 +1,44 @@ import RegionKit import SwiftUI -/// Places a cached, pre-projected region path into the repeated silhouette -/// artwork on a regular card while preserving its geographic aspect. +/// Draws a cached, pre-projected region path with stylesheet-owned projection +/// geometry and ink treatment while preserving its geographic aspect. struct RegionOutlineArtwork: View { - enum Placement { - case watermark - case stamp - } - let path: Path let tint: Color - let style: WhereStylesheet.CardStyle.RegionShape - let placement: Placement + let style: WhereStylesheet.CardStyle.RegionShape.Artwork var body: some View { Canvas { context, size in let bounds = path.boundingRect guard !path.isEmpty, bounds.width > 0, bounds.height > 0 else { return } - let layout = layout(in: size) let scale = min( - layout.extent.width / bounds.width, - layout.extent.height / bounds.height, - ) * layout.scale + size.width * style.extent.width / bounds.width, + size.height * style.extent.height / bounds.height, + ) * style.scale var projectedContext = context - projectedContext.translateBy(x: layout.center.x, y: layout.center.y) + projectedContext.translateBy( + x: size.width * style.center.x, + y: size.height * style.center.y, + ) projectedContext.scaleBy(x: scale, y: scale) projectedContext.translateBy(x: -bounds.midX, y: -bounds.midY) - switch placement { - case .watermark: - projectedContext.fill( - path, - with: .color(tint.opacity(style.watermarkFillOpacity)), - ) - projectedContext.stroke( - path, - with: .color(tint.opacity(style.watermarkStrokeOpacity)), - lineWidth: style.watermarkStrokeWidth / scale, - ) - case .stamp: - projectedContext.fill( - path, - with: .color(tint.opacity(style.stampFillOpacity)), - ) + projectedContext.fill( + path, + with: .color(tint.opacity(style.fillOpacity)), + ) + if let stroke = style.stroke { + projectedContext.stroke( + path, + with: .color(tint.opacity(stroke.opacity)), + lineWidth: stroke.width / scale, + ) } } .allowsHitTesting(false) .accessibilityHidden(true) } - - private func layout(in size: CGSize) -> (center: CGPoint, extent: CGSize, scale: CGFloat) { - switch placement { - case .watermark: - ( - CGPoint( - x: size.width * style.watermarkCenter.x, - y: size.height * style.watermarkCenter.y, - ), - CGSize( - width: size.width * style.watermarkExtent.width, - height: size.height * style.watermarkExtent.height, - ), - style.watermarkScale, - ) - case .stamp: - ( - CGPoint(x: size.width * 0.5, y: size.height * 0.5), - CGSize( - width: size.width * style.stampExtent.width, - height: size.height * style.stampExtent.height, - ), - style.stampScale, - ) - } - } } #if DEBUG @@ -92,8 +56,7 @@ struct RegionOutlineArtwork: View { RegionOutlineArtwork( path: path, tint: .orange, - style: style, - placement: .watermark, + style: style.watermark, ) .frame(width: 320, height: 180) .background(.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 28)) diff --git a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift index a72d16ba..0cb6d641 100644 --- a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift @@ -168,8 +168,7 @@ struct RegionSummaryCard: View { RegionOutlineArtwork( path: regionPath, tint: style.tint, - style: regionShape, - placement: .watermark, + style: regionShape.watermark, ) } else { Image(systemName: style.symbolName) @@ -344,8 +343,7 @@ private struct EntryStamp: View { RegionOutlineArtwork( path: regionPath, tint: tint, - style: regionShape, - placement: .stamp, + style: regionShape.stamp, ) .frame(width: size * 0.42, height: size * 0.28) } else { diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index ff857940..463313ed 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -282,21 +282,27 @@ extension WhereStylesheet { var secondaryRingSpacing: CGFloat } - /// Relative projection geometry and ink strength for the repeated - /// region silhouette: one large security watermark, one stamp seal, and - /// a microprinted inset border. + /// Styles for the repeated region silhouette: one large security + /// watermark, one stamp seal, and a microprinted inset border. struct RegionShape: Equatable { - var watermarkCenter: CGPoint - var watermarkExtent: CGSize - var watermarkScale: CGFloat - var watermarkFillOpacity: Double - var watermarkStrokeOpacity: Double - var watermarkStrokeWidth: CGFloat - var stampExtent: CGSize - var stampScale: CGFloat - var stampFillOpacity: Double + var watermark: Artwork + var stamp: Artwork var securityBorder: SecurityBorder + /// Projection geometry and ink treatment for one silhouette. + struct Artwork: Equatable { + var center: CGPoint + var extent: CGSize + var scale: CGFloat + var fillOpacity: Double + var stroke: Stroke? + + struct Stroke: Equatable { + var opacity: Double + var width: CGFloat + } + } + /// The inset ring of tiny tangent-aligned region silhouettes. struct SecurityBorder: Equatable { var inset: CGFloat @@ -431,15 +437,20 @@ extension WhereStylesheet { watermarkFontSize: 150, watermarkOffset: CGSize(width: 20, height: 12), regionShape: CardStyle.RegionShape( - watermarkCenter: CGPoint(x: 0.7, y: 0.57), - watermarkExtent: CGSize(width: 0.72, height: 0.78), - watermarkScale: 0.88, - watermarkFillOpacity: 0.13, - watermarkStrokeOpacity: 0.28, - watermarkStrokeWidth: 1.5, - stampExtent: CGSize(width: 0.78, height: 0.78), - stampScale: 0.88, - stampFillOpacity: 0.78, + watermark: .init( + center: CGPoint(x: 0.7, y: 0.57), + extent: CGSize(width: 0.72, height: 0.78), + scale: 0.88, + fillOpacity: 0.13, + stroke: .init(opacity: 0.28, width: 1.5), + ), + stamp: .init( + center: CGPoint(x: 0.5, y: 0.5), + extent: CGSize(width: 0.78, height: 0.78), + scale: 0.88, + fillOpacity: 0.78, + stroke: nil, + ), securityBorder: .init( inset: 9, glyphSize: 8, diff --git a/Where/WhereUI/Tests/RegionOutlineArtworkTests.swift b/Where/WhereUI/Tests/RegionOutlineArtworkTests.swift index d6942180..4970c13a 100644 --- a/Where/WhereUI/Tests/RegionOutlineArtworkTests.swift +++ b/Where/WhereUI/Tests/RegionOutlineArtworkTests.swift @@ -15,8 +15,7 @@ struct RegionOutlineArtworkTests { let artwork = RegionOutlineArtwork( path: path, tint: .orange, - style: style, - placement: .watermark, + style: style.watermark, ) #expect(!path.isEmpty) diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index b00c1e50..5f26d36f 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -38,15 +38,20 @@ struct WhereStylesheetTests { #expect(card.watermarkFontSize == 150) #expect(card.watermarkOffset == CGSize(width: 20, height: 12)) #expect(card.regionShape == .init( - watermarkCenter: CGPoint(x: 0.7, y: 0.57), - watermarkExtent: CGSize(width: 0.72, height: 0.78), - watermarkScale: 0.88, - watermarkFillOpacity: 0.13, - watermarkStrokeOpacity: 0.28, - watermarkStrokeWidth: 1.5, - stampExtent: CGSize(width: 0.78, height: 0.78), - stampScale: 0.88, - stampFillOpacity: 0.78, + watermark: .init( + center: CGPoint(x: 0.7, y: 0.57), + extent: CGSize(width: 0.72, height: 0.78), + scale: 0.88, + fillOpacity: 0.13, + stroke: .init(opacity: 0.28, width: 1.5), + ), + stamp: .init( + center: CGPoint(x: 0.5, y: 0.5), + extent: CGSize(width: 0.78, height: 0.78), + scale: 0.88, + fillOpacity: 0.78, + stroke: nil, + ), securityBorder: .init( inset: 9, glyphSize: 8, From 32c85f338ad3252ba2fbaa9a23cab1a719780474 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 2 Aug 2026 01:17:47 -0700 Subject: [PATCH 16/28] Move entry stamp drawing into card styles Collect the stamp rings, content geometry, typography, arc layout, opacity, and rotation under one card-style token. Keep the rendering view declarative and preserve the existing regular and compact snapshots. --- .../Sources/Primary/RegionSummaryCard.swift | 67 +++++++------ .../Sources/Shared/WhereStylesheet.swift | 94 +++++++++++++++++-- .../WhereUI/Tests/WhereStylesheetTests.swift | 37 +++++++- 3 files changed, 157 insertions(+), 41 deletions(-) diff --git a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift index 0cb6d641..9078ff02 100644 --- a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift @@ -90,8 +90,7 @@ struct RegionSummaryCard: View { year: year, symbolName: style.symbolName, tint: style.tint, - size: card.entryStampSize, - showsArcText: card.showsArcText, + style: card.entryStamp, regionPath: regionPaths?.stamp ?? Path(), regionShape: card.regionShape, ) @@ -312,61 +311,70 @@ struct RegionSummaryCard: View { /// A circular rubber-stamp impression — double ring, centered region glyph and /// year, with the region name curved along the top arc — tilted as if an -/// official pressed it onto a passport page. Sizes scale off `size` so it reads -/// the same on the big Primary cards and the compact Elsewhere ones. +/// official pressed it onto a passport page. The card stylesheet owns its +/// complete drawing treatment for both regular and compact cards. private struct EntryStamp: View { let title: String let year: Int let symbolName: String let tint: Color - let size: CGFloat - var showsArcText = true + let style: WhereStylesheet.CardStyle.EntryStamp let regionPath: Path let regionShape: WhereStylesheet.CardStyle.RegionShape? var body: some View { + let size = style.size ZStack { Circle() - .strokeBorder(tint.opacity(0.7), lineWidth: size * 0.035) + .strokeBorder( + tint.opacity(style.outerRing.opacity), + lineWidth: size * style.outerRing.lineWidthFraction, + ) Circle() .strokeBorder( - tint.opacity(0.45), + tint.opacity(style.innerRing.opacity), style: StrokeStyle( - lineWidth: size * 0.012, - dash: [size * 0.05, size * 0.035], + lineWidth: size * style.innerRing.lineWidthFraction, + dash: [ + size * style.innerRing.dash.lengthFraction, + size * style.innerRing.dash.spacingFraction, + ], ), ) - .padding(size * 0.13) + .padding(size * style.innerRing.insetFraction) - VStack(spacing: size * 0.02) { + VStack(spacing: size * style.content.spacingFraction) { if let regionShape, !regionPath.isEmpty { RegionOutlineArtwork( path: regionPath, tint: tint, style: regionShape.stamp, ) - .frame(width: size * 0.42, height: size * 0.28) + .frame( + width: size * style.content.artworkExtent.width, + height: size * style.content.artworkExtent.height, + ) } else { Image(systemName: symbolName) - .font(.system(size: size * 0.26)) + .font(style.content.symbolFont.font(for: size)) } Text(verbatim: String(year)) - .font(.system(size: size * 0.15, weight: .bold, design: .serif)) + .font(style.content.yearFont.font(for: size)) .monospacedDigit() } - .foregroundStyle(tint.opacity(0.85)) + .foregroundStyle(tint.opacity(style.content.opacity)) - if showsArcText { + if let arc = style.arc { ArcText( text: title, - radius: size * 0.37, - font: .system(size: size * 0.1, weight: .semibold, design: .serif), - color: tint.opacity(0.7), + size: size, + tint: tint, + style: arc, ) } } .frame(width: size, height: size) - .rotationEffect(.degrees(-8)) + .rotationEffect(.degrees(style.rotationDegrees)) .accessibilityHidden(true) } } @@ -384,19 +392,22 @@ private struct RegionArtworkPaths { /// region names both stay legible. private struct ArcText: View { let text: String - let radius: CGFloat - let font: Font - let color: Color + let size: CGFloat + let tint: Color + let style: WhereStylesheet.CardStyle.EntryStamp.Arc var body: some View { let characters = Array(text) - let sweep = min(250, Double(characters.count) * 17) + let sweep = min( + style.maximumSweepDegrees, + Double(characters.count) * style.sweepDegreesPerCharacter, + ) ZStack { ForEach(Array(characters.enumerated()), id: \.offset) { index, character in Text(String(character)) - .font(font) - .foregroundStyle(color) - .offset(y: -radius) + .font(style.font.font(for: size)) + .foregroundStyle(tint.opacity(style.opacity)) + .offset(y: -size * style.radiusFraction) .rotationEffect(angle(at: index, count: characters.count, sweep: sweep)) } } diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index 463313ed..63735247 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -249,11 +249,7 @@ extension WhereStylesheet { /// Spacing between the card's stacked rows (header, hero number, bar). var contentSpacing: CGFloat var progressBarHeight: CGFloat - /// Diameter of the circular "entry stamp" impression. - var entryStampSize: CGFloat - /// Whether the entry stamp draws its curved region-name arc — dropped on - /// the small compact stamp where it can't be read. - var showsArcText: Bool + var entryStamp: EntryStamp var regionNameFont: Font var regionNameTracking: CGFloat var heroNumberFont: Font @@ -282,6 +278,88 @@ extension WhereStylesheet { var secondaryRingSpacing: CGFloat } + /// Geometry, ink strength, and typography for the circular passport + /// impression. The compact style omits `arc` where its text cannot read. + struct EntryStamp: Equatable { + var size: CGFloat + var outerRing: Ring + var innerRing: DashedRing + var content: Content + var arc: Arc? + var rotationDegrees: Double + + struct Ring: Equatable { + var opacity: Double + var lineWidthFraction: CGFloat + } + + struct DashedRing: Equatable { + var opacity: Double + var lineWidthFraction: CGFloat + var dash: Dash + var insetFraction: CGFloat + + struct Dash: Equatable { + var lengthFraction: CGFloat + var spacingFraction: CGFloat + } + } + + struct Content: Equatable { + var spacingFraction: CGFloat + var artworkExtent: CGSize + var symbolFont: Typography + var yearFont: Typography + var opacity: Double + } + + struct Arc: Equatable { + var radiusFraction: CGFloat + var font: Typography + var opacity: Double + var maximumSweepDegrees: Double + var sweepDegreesPerCharacter: Double + } + + struct Typography: Equatable { + var sizeFraction: CGFloat + var weight: Font.Weight + var design: Font.Design + + func font(for size: CGFloat) -> Font { + .system(size: size * sizeFraction, weight: weight, design: design) + } + } + + static func standard(size: CGFloat, showsArcText: Bool) -> EntryStamp { + EntryStamp( + size: size, + outerRing: .init(opacity: 0.7, lineWidthFraction: 0.035), + innerRing: .init( + opacity: 0.45, + lineWidthFraction: 0.012, + dash: .init(lengthFraction: 0.05, spacingFraction: 0.035), + insetFraction: 0.13, + ), + content: .init( + spacingFraction: 0.02, + artworkExtent: CGSize(width: 0.42, height: 0.28), + symbolFont: .init(sizeFraction: 0.26, weight: .regular, design: .default), + yearFont: .init(sizeFraction: 0.15, weight: .bold, design: .serif), + opacity: 0.85, + ), + arc: showsArcText ? .init( + radiusFraction: 0.37, + font: .init(sizeFraction: 0.1, weight: .semibold, design: .serif), + opacity: 0.7, + maximumSweepDegrees: 250, + sweepDegreesPerCharacter: 17, + ) : nil, + rotationDegrees: -8, + ) + } + } + /// Styles for the repeated region silhouette: one large security /// watermark, one stamp seal, and a microprinted inset border. struct RegionShape: Equatable { @@ -424,8 +502,7 @@ extension WhereStylesheet { padding: 22, contentSpacing: 16, progressBarHeight: 10, - entryStampSize: 88, - showsArcText: true, + entryStamp: .standard(size: 88, showsArcText: true), // Fixed point size (not a Dynamic Type text style) for precise // control against the entry stamp: the longest common headline // names ("California" / "New York") fit, and any over-long one @@ -479,8 +556,7 @@ extension WhereStylesheet { padding: 16, contentSpacing: 10, progressBarHeight: 6, - entryStampSize: 52, - showsArcText: false, + entryStamp: .standard(size: 52, showsArcText: false), regionNameFont: .system(.title3, design: .serif).weight(.semibold), regionNameTracking: 0, heroNumberFont: .system(.title, design: .rounded, weight: .bold), diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index 5f26d36f..2be49db1 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -31,8 +31,7 @@ struct WhereStylesheetTests { #expect(card.padding == 22) #expect(card.contentSpacing == 16) #expect(card.progressBarHeight == 10) - #expect(card.entryStampSize == 88) - #expect(card.showsArcText) + #expect(card.entryStamp == expectedEntryStamp(size: 88, showsArcText: true)) #expect(card.regionNameFont == .system(size: 38, weight: .semibold, design: .serif)) #expect(card.regionNameTracking == -0.5) #expect(card.watermarkFontSize == 150) @@ -80,8 +79,7 @@ struct WhereStylesheetTests { #expect(card.padding == 16) #expect(card.contentSpacing == 10) #expect(card.progressBarHeight == 6) - #expect(card.entryStampSize == 52) - #expect(!card.showsArcText) + #expect(card.entryStamp == expectedEntryStamp(size: 52, showsArcText: false)) #expect(card.regionNameTracking == 0) #expect(card.watermarkFontSize == 96) #expect(card.watermarkOffset == CGSize(width: 12, height: 10)) @@ -106,6 +104,37 @@ struct WhereStylesheetTests { #expect(style.card[.compact] == style.card.compact) } + private func expectedEntryStamp( + size: CGFloat, + showsArcText: Bool, + ) -> WhereStylesheet.CardStyle.EntryStamp { + .init( + size: size, + outerRing: .init(opacity: 0.7, lineWidthFraction: 0.035), + innerRing: .init( + opacity: 0.45, + lineWidthFraction: 0.012, + dash: .init(lengthFraction: 0.05, spacingFraction: 0.035), + insetFraction: 0.13, + ), + content: .init( + spacingFraction: 0.02, + artworkExtent: CGSize(width: 0.42, height: 0.28), + symbolFont: .init(sizeFraction: 0.26, weight: .regular, design: .default), + yearFont: .init(sizeFraction: 0.15, weight: .bold, design: .serif), + opacity: 0.85, + ), + arc: showsArcText ? .init( + radiusFraction: 0.37, + font: .init(sizeFraction: 0.1, weight: .semibold, design: .serif), + opacity: 0.7, + maximumSweepDegrees: 250, + sweepDegreesPerCharacter: 17, + ) : nil, + rotationDegrees: -8, + ) + } + @Test func sharedCardStyle() { let card = style.card #expect(card.watermarkOpacity == 0.08) From 7454e5aa6bcf2aaa6d3f8dcc0931eca11410c0b3 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 2 Aug 2026 01:24:15 -0700 Subject: [PATCH 17/28] Add a microprint border preview Exercise the real New York micro-fidelity path in a colocated SwiftUI preview so the new security-border view satisfies preview coverage and remains easy to inspect. --- .../Primary/RegionOutlineSecurityBorder.swift | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/Where/WhereUI/Sources/Primary/RegionOutlineSecurityBorder.swift b/Where/WhereUI/Sources/Primary/RegionOutlineSecurityBorder.swift index 80a76bc9..20e9bb22 100644 --- a/Where/WhereUI/Sources/Primary/RegionOutlineSecurityBorder.swift +++ b/Where/WhereUI/Sources/Primary/RegionOutlineSecurityBorder.swift @@ -1,3 +1,4 @@ +import RegionKit import SwiftUI /// Repeats one cached micro-fidelity region silhouette around an inset rounded @@ -177,3 +178,35 @@ struct RegionOutlineSecurityBorder: View { let rotation: Double } } + +#if DEBUG + #Preview { + RegionOutlineSecurityBorderPreview() + .padding() + } + + private struct RegionOutlineSecurityBorderPreview: View { + @State private var path = Path() + private let cache = RegionOutlinePathCache() + + var body: some View { + let card = WhereStylesheet.default.card.regular + if let style = card.regionShape?.securityBorder { + RegionOutlineSecurityBorder( + path: path, + tint: .orange, + cornerRadius: card.cornerRadius, + style: style, + ) + .frame(width: 320, height: 180) + .background( + .orange.opacity(0.1), + in: RoundedRectangle(cornerRadius: card.cornerRadius), + ) + .task { + path = await cache.path(for: .newYork, resolution: .micro) + } + } + } + } +#endif From 5c0f6df692c6244b7ef789e3d4b668beb0fb93ba Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 2 Aug 2026 06:45:03 -0700 Subject: [PATCH 18/28] Refresh the developer overlay region map snapshot Update the selected-tool reference for the approved full-palette fallback colors already recorded in the standalone Region Map snapshots. --- .../developerOverlay.SelectedTool_iPhone.png | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DeveloperOverlaySnapshotTests/developerOverlay.SelectedTool_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DeveloperOverlaySnapshotTests/developerOverlay.SelectedTool_iPhone.png index 820fced3..aed908ef 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DeveloperOverlaySnapshotTests/developerOverlay.SelectedTool_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DeveloperOverlaySnapshotTests/developerOverlay.SelectedTool_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f8433804164e51ae0151d4915fa4e23c196212d2e35c4ee6b232903c9f878da6 -size 223338 +oid sha256:ec4f7d30f12969ca0f685d918fb7564dcbdfe48ac7e5eb769dcbf8db15ff5cf1 +size 223206 From 8615d70702d997e7f997895d416718bdde2c68c6 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 2 Aug 2026 07:16:28 -0700 Subject: [PATCH 19/28] Keep dark card security print legible on touch --- .../elsewhere.Loaded_iPad_dark.png | 4 +-- .../elsewhere.Loaded_iPhone_dark.png | 4 +-- .../locations.Loaded_iPad_dark.png | 4 +-- .../locations.Loaded_iPhone_dark.png | 4 +-- .../locations.MissingDays_iPhone_dark.png | 4 +-- .../regionCustomize.Editor_iPhone_dark.png | 4 +-- ...ionCustomize.NeutralEditor_iPhone_dark.png | 4 +-- .../Sources/Primary/RegionSummaryCard.swift | 18 +++++++---- .../Sources/Shared/WhereStylesheet.swift | 32 +++++++++++++++---- .../WhereUI/Tests/WhereStylesheetTests.swift | 12 +++++-- 10 files changed, 59 insertions(+), 31 deletions(-) diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad_dark.png index 9b98eaa5..31d6d5cc 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c7de27a6017f2d7f5e6dc1d601343670dadf3e47c9c5602f42d81a4710932429 -size 3184405 +oid sha256:94a7410165dc607c637f1d300a0c862eb6c6fb9cb3b115df48991f399cfbd057 +size 3348158 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone_dark.png index af99bab0..65b5f293 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2da6d99facab00d37f456a43b23d3e964ed37ab3668a292957713f092b15833a -size 1840138 +oid sha256:7ed0a491448b4aa160d28e466115d93400b67fd777f5653f61d58ae0a0032df7 +size 1913949 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png index cc4d8e11..f3902e16 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:47f720ac3ae01a8947e1090852bf2d2c4fbf0d19f9ecbf18a2112624a7b62d47 -size 3545203 +oid sha256:4e8b0e55eb888acb2f30c49a2d927d4c276a8c340ce9e4e826a13f1e8766ba9a +size 3615125 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png index de142707..8c35b90a 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d4cf6cd31cd513f50754ead10aaccfd689a967949a047f6c1fdad330e98db24d -size 2170768 +oid sha256:acee6f6000c3bc05347bb85af5b8d9f5ac5c68459edf59428622b6e6949b3fc1 +size 2197708 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png index b6e1b600..ab522318 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1a29daf41586c050491a6e419ba03dfcc98239dd97ef467b778e336ce5153eeb -size 1170887 +oid sha256:fa131305dad438f903ae6e7a64e8698fed851ee84bc849f12b394566988c9a18 +size 1187308 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png index 0dd249fe..b025a825 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4c4f895ed2eaa6bb9402f65caa62221297b97ec3f9da17013dbdfeedf600e3a0 -size 1464322 +oid sha256:feadfd6d97d4ebc5b0e1b4844a010acfb1ef0bf5e053602f8b8e04a1e9c10031 +size 1467559 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png index 96cef637..6bd2b721 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c8785ccc8a2f14a3025fad0789cf9d7714a04d1f6c2ac1e351ccc0a224db34b7 -size 1307354 +oid sha256:43017cf5c7b2656f77679d045859c0c06c27faa004be88819bb869de1ab46fd9 +size 1312907 diff --git a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift index 9078ff02..92134778 100644 --- a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift @@ -62,6 +62,12 @@ struct RegionSummaryCard: View { styleOverride ?? regionStyles.style(for: regionDays.region) } + /// Region ink on light cards; a pale derivative on dark cards that remains + /// distinct while interactive Liquid Glass illuminates nearby surfaces. + private var securityPrintTint: Color { + stylesheet.card.securityPrint.tint(style.tint) + } + private var cardShape: RoundedRectangle { RoundedRectangle(cornerRadius: card.cornerRadius, style: .continuous) } @@ -89,12 +95,11 @@ struct RegionSummaryCard: View { title: regionDays.region.localizedName.uppercased(), year: year, symbolName: style.symbolName, - tint: style.tint, + tint: securityPrintTint, style: card.entryStamp, regionPath: regionPaths?.stamp ?? Path(), regionShape: card.regionShape, ) - .blendMode(stylesheet.card.securityPrintBlendMode) } /// A faint, region-tinted "security print" behind the content: a guilloché @@ -105,7 +110,7 @@ struct RegionSummaryCard: View { // Read the main-actor `style.tint` and the value-type `rosette` spec once // here so the nonisolated `Canvas` renderer closure captures `Sendable` // values rather than reaching back into main-actor state. - let tint = style.tint + let tint = securityPrintTint let rosette = card.rosette let rosetteFill = stylesheet.card.rosetteFill return ZStack { @@ -153,7 +158,7 @@ struct RegionSummaryCard: View { { RegionOutlineSecurityBorder( path: regionPath, - tint: style.tint, + tint: tint, cornerRadius: card.cornerRadius, style: regionShape.securityBorder, ) @@ -166,19 +171,18 @@ struct RegionSummaryCard: View { { RegionOutlineArtwork( path: regionPath, - tint: style.tint, + tint: tint, style: regionShape.watermark, ) } else { Image(systemName: style.symbolName) .font(.system(size: card.watermarkFontSize)) - .foregroundStyle(style.tint.opacity(stylesheet.card.watermarkOpacity)) + .foregroundStyle(tint.opacity(stylesheet.card.watermarkOpacity)) .rotationEffect(.degrees(-14)) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) .offset(x: card.watermarkOffset.width, y: card.watermarkOffset.height) } } - .blendMode(stylesheet.card.securityPrintBlendMode) .clipShape(cardShape) .allowsHitTesting(false) .accessibilityHidden(true) diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index 63735247..5ae6eec1 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -56,10 +56,10 @@ struct WhereStylesheet: BStylesheet { developerOverlay.menu.motion = .reduced } - // Security print behaves like ink on pale cards and reflected ink on - // dark ones: dark-mode artwork can only lighten the glass beneath it. + // Pale ink lifts the security print off dark glass without a Screen + // blend amplifying Liquid Glass's interactive tint illumination. if traits.mode == .dark { - card.securityPrintBlendMode = .screen + card.securityPrint = .dark } } @@ -426,9 +426,8 @@ extension WhereStylesheet { var nameOpacity: Double /// Fill opacities of the two security-print rosettes. var rosetteFill: RosetteFill - /// How decorative card ink combines with its Liquid Glass surface. - /// Dark mode uses Screen so every detail lifts the darker card beneath. - var securityPrintBlendMode: BlendMode + /// How the region tint is prepared for decorative security printing. + var securityPrint: SecurityPrint /// How the day count changes while the card is on screen; resolves to /// ``DayCountStyle/reducedMotion`` under Reduce Motion. var dayCount: DayCountStyle @@ -439,6 +438,25 @@ extension WhereStylesheet { var secondary: Double } + /// Keeps security artwork region-tinted on pale glass and mixes it + /// toward white on dark glass so normal compositing remains legible + /// while the system energizes the glass on touch. + struct SecurityPrint: Equatable { + var whiteMix: Double + + func tint(_ regionTint: Color) -> Color { + guard whiteMix > 0 else { return regionTint } + return regionTint.mix( + with: .white, + by: whiteMix, + in: .perceptual, + ) + } + + static let standard = SecurityPrint(whiteMix: 0) + static let dark = SecurityPrint(whiteMix: 0.65) + } + /// How a card's day count changes when it updates with the card on screen /// — a passive sample lands, a manual day commits, a remote import /// arrives — and the number showing goes stale. @@ -583,7 +601,7 @@ extension WhereStylesheet { glassTintOpacity: 0.18, nameOpacity: 0.8, rosetteFill: RosetteFill(primary: 0.12, secondary: 0.08), - securityPrintBlendMode: .normal, + securityPrint: .standard, dayCount: .standard, ) } diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index 2be49db1..311544ab 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -141,7 +141,8 @@ struct WhereStylesheetTests { #expect(card.glassTintOpacity == 0.18) #expect(card.nameOpacity == 0.8) #expect(card.rosetteFill == .init(primary: 0.12, secondary: 0.08)) - #expect(card.securityPrintBlendMode == .normal) + #expect(card.securityPrint == .standard) + #expect(card.securityPrint.tint(.red) == .red) #expect(card.dayCount == .standard) #expect(card.dayCount.animation == .easeOut(duration: 0.3)) } @@ -421,11 +422,16 @@ struct WhereStylesheetTests { } @MainActor - @Test func lightensCardSecurityPrintInDarkMode() throws { + @Test func palesCardSecurityPrintInDarkMode() throws { var context = BContext(traits: .system) context.traitOverrides.mode = .dark let resolved = try context.stylesheets.get(WhereStylesheet.self) - #expect(resolved.card.securityPrintBlendMode == .screen) + #expect(resolved.card.securityPrint == .dark) + #expect(resolved.card.securityPrint.tint(.red) == Color.red.mix( + with: .white, + by: 0.65, + in: .perceptual, + )) } } From 0fd11ecd89a083d404594998cabf587f4f93247f Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 2 Aug 2026 08:07:08 -0700 Subject: [PATCH 20/28] Blend dark card security print by luminosity --- .../elsewhere.Loaded_iPad_dark.png | 4 ++-- .../elsewhere.Loaded_iPhone_dark.png | 4 ++-- .../locations.Loaded_iPad_dark.png | 4 ++-- .../locations.Loaded_iPhone_dark.png | 4 ++-- .../locations.MissingDays_iPhone_dark.png | 4 ++-- .../regionCustomize.Editor_iPhone_dark.png | 4 ++-- ...egionCustomize.NeutralEditor_iPhone_dark.png | 4 ++-- .../Sources/Primary/RegionSummaryCard.swift | 1 + .../Sources/Shared/WhereStylesheet.swift | 17 +++++++++++++---- Where/WhereUI/Tests/WhereStylesheetTests.swift | 2 ++ 10 files changed, 30 insertions(+), 18 deletions(-) diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad_dark.png index 31d6d5cc..38ac3141 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:94a7410165dc607c637f1d300a0c862eb6c6fb9cb3b115df48991f399cfbd057 -size 3348158 +oid sha256:45d18ce2f6a55c6a73ebe9ceb2f05e81d0d6c55084675575559ffe2c02841160 +size 3342904 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone_dark.png index 65b5f293..4e52a87c 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ElsewhereViewSnapshotTests/elsewhere.Loaded_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7ed0a491448b4aa160d28e466115d93400b67fd777f5653f61d58ae0a0032df7 -size 1913949 +oid sha256:01b8fe3be00337d5848fcd10b01b4a38a5ccb6e5c2effd6c80d7a346fc27f495 +size 1916913 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png index f3902e16..aa6056b7 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4e8b0e55eb888acb2f30c49a2d927d4c276a8c340ce9e4e826a13f1e8766ba9a -size 3615125 +oid sha256:b8190f1ff9eb89896aa9b6acddfc2dab0c54f23507d66da4c22fd6d810e8274d +size 3596757 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png index 8c35b90a..a2232cef 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:acee6f6000c3bc05347bb85af5b8d9f5ac5c68459edf59428622b6e6949b3fc1 -size 2197708 +oid sha256:40dac293f9ccc1326134c67f70cbb62930f3f0122cb527a69350fb6ad89278dc +size 2195306 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png index ab522318..27bd87f6 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fa131305dad438f903ae6e7a64e8698fed851ee84bc849f12b394566988c9a18 -size 1187308 +oid sha256:5a6d62f89bdb84ed5e0ca73639973c6d743e0637d10fddb1e4b408ccb6a107f1 +size 1187301 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png index b025a825..8a649afe 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.Editor_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:feadfd6d97d4ebc5b0e1b4844a010acfb1ef0bf5e053602f8b8e04a1e9c10031 -size 1467559 +oid sha256:175cf0db7bc895a873567e1bd6b8bbd93ea5b3c90d370cc8d6d8812cb7f08be1 +size 1470624 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png index 6bd2b721..ea280350 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/RegionCustomizeViewSnapshotTests/regionCustomize.NeutralEditor_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:43017cf5c7b2656f77679d045859c0c06c27faa004be88819bb869de1ab46fd9 -size 1312907 +oid sha256:4f15ada38d06d6d13da90f17d09d318aeace0b78100a9d17275141240912736c +size 1326736 diff --git a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift index 92134778..d6df34a8 100644 --- a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift @@ -183,6 +183,7 @@ struct RegionSummaryCard: View { .offset(x: card.watermarkOffset.width, y: card.watermarkOffset.height) } } + .blendMode(stylesheet.card.securityPrint.backgroundBlendMode) .clipShape(cardShape) .allowsHitTesting(false) .accessibilityHidden(true) diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index 5ae6eec1..1876df28 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -56,8 +56,8 @@ struct WhereStylesheet: BStylesheet { developerOverlay.menu.motion = .reduced } - // Pale ink lifts the security print off dark glass without a Screen - // blend amplifying Liquid Glass's interactive tint illumination. + // Pale, luminosity-only ink lifts the background security print off + // dark glass without changing its hue or saturation on touch. if traits.mode == .dark { card.securityPrint = .dark } @@ -443,6 +443,9 @@ extension WhereStylesheet { /// while the system energizes the glass on touch. struct SecurityPrint: Equatable { var whiteMix: Double + /// Applies to the rosettes, watermark, and microprint only; the + /// prominent entry stamp always uses normal compositing. + var backgroundBlendMode: BlendMode func tint(_ regionTint: Color) -> Color { guard whiteMix > 0 else { return regionTint } @@ -453,8 +456,14 @@ extension WhereStylesheet { ) } - static let standard = SecurityPrint(whiteMix: 0) - static let dark = SecurityPrint(whiteMix: 0.65) + static let standard = SecurityPrint( + whiteMix: 0, + backgroundBlendMode: .normal, + ) + static let dark = SecurityPrint( + whiteMix: 0.65, + backgroundBlendMode: .luminosity, + ) } /// How a card's day count changes when it updates with the card on screen diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index 311544ab..32409040 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -142,6 +142,7 @@ struct WhereStylesheetTests { #expect(card.nameOpacity == 0.8) #expect(card.rosetteFill == .init(primary: 0.12, secondary: 0.08)) #expect(card.securityPrint == .standard) + #expect(card.securityPrint.backgroundBlendMode == .normal) #expect(card.securityPrint.tint(.red) == .red) #expect(card.dayCount == .standard) #expect(card.dayCount.animation == .easeOut(duration: 0.3)) @@ -427,6 +428,7 @@ struct WhereStylesheetTests { context.traitOverrides.mode = .dark let resolved = try context.stylesheets.get(WhereStylesheet.self) #expect(resolved.card.securityPrint == .dark) + #expect(resolved.card.securityPrint.backgroundBlendMode == .luminosity) #expect(resolved.card.securityPrint.tint(.red) == Color.red.mix( with: .white, by: 0.65, From 860b406812cb153f248ca6b9d27f87f6f0f6fae7 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 2 Aug 2026 08:16:23 -0700 Subject: [PATCH 21/28] Add card designer configuration groundwork --- .../CardDesignerConfiguration.swift | 699 ++++++++++++++++++ .../CardDesignerEnvironment.swift | 12 + .../CardDesigner/CardDesignerExport.swift | 229 ++++++ .../CardDesigner/CardDesignerModel.swift | 149 ++++ .../Sources/Primary/RegionSummaryCard.swift | 38 +- Where/WhereUI/Sources/RootView.swift | 11 + .../Sources/Shared/WhereStylesheet.swift | 138 +++- .../CardDesignerConfigurationTests.swift | 53 ++ .../Tests/CardDesignerEnvironmentTests.swift | 14 + .../Tests/CardDesignerExportTests.swift | 31 + .../Tests/CardDesignerModelTests.swift | 65 ++ .../WhereUI/Tests/WhereStylesheetTests.swift | 36 +- 12 files changed, 1454 insertions(+), 21 deletions(-) create mode 100644 Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerConfiguration.swift create mode 100644 Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerEnvironment.swift create mode 100644 Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExport.swift create mode 100644 Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerModel.swift create mode 100644 Where/WhereUI/Tests/CardDesignerConfigurationTests.swift create mode 100644 Where/WhereUI/Tests/CardDesignerEnvironmentTests.swift create mode 100644 Where/WhereUI/Tests/CardDesignerExportTests.swift create mode 100644 Where/WhereUI/Tests/CardDesignerModelTests.swift diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerConfiguration.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerConfiguration.swift new file mode 100644 index 00000000..9df2f656 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerConfiguration.swift @@ -0,0 +1,699 @@ +#if DEBUG + import Foundation + import SwiftUI + + /// Versioned, Codable card appearance edited by the DEBUG card designer. + /// It contains presentation only: count animation and RegionKit cache policy + /// remain owned by the production stylesheet and path cache. + struct CardDesignerConfiguration: Codable, Equatable { + static let currentSchemaVersion = 1 + static let standard = CardDesignerConfiguration(styles: .standard) + + var schemaVersion = currentSchemaVersion + var regular: Card + var compact: Card + var shared: Shared + + enum Variant: String, CaseIterable, Codable { + case regular + case compact + + var style: WhereStylesheet.CardStyle.Variant { + switch self { + case .regular: .regular + case .compact: .compact + } + } + } + + subscript(_ variant: Variant) -> Card { + get { + switch variant { + case .regular: regular + case .compact: compact + } + } + set { + switch variant { + case .regular: regular = newValue + case .compact: compact = newValue + } + } + } + + init(styles: WhereStylesheet.CardStyles) { + regular = Card(styles.regular) + compact = Card(styles.compact) + shared = Shared(styles) + } + + func resolve( + over base: WhereStylesheet.CardStyles, + colorScheme: ColorScheme, + ) -> WhereStylesheet.CardStyles { + var resolved = base + resolved.regular = regular.style + resolved.compact = compact.style + resolved.watermarkOpacity = shared.watermarkOpacity + resolved.glassTintOpacity = shared.glassTintOpacity + resolved.nameOpacity = shared.nameOpacity + resolved.rosetteFill = .init( + primary: shared.primaryRosetteOpacity, + secondary: shared.secondaryRosetteOpacity, + ) + resolved.securityPrint = shared.securityPrint(for: colorScheme).style + return resolved + } + + struct Card: Codable, Equatable { + var cornerRadius: CGFloat + var padding: CGFloat + var contentSpacing: CGFloat + var progressBarHeight: CGFloat + var entryStamp: EntryStamp + var regionNameTypography: Typography + var regionNameTracking: CGFloat + var heroNumberTypography: Typography + var dayUnitTypography: Typography + var watermarkFontSize: CGFloat + var watermarkOffset: Offset + var regionShape: RegionShape? + var sheen: Sheen + var rosette: Rosette + var glow: Shadow + var lift: Shadow + + init(_ style: WhereStylesheet.CardStyle) { + cornerRadius = style.cornerRadius + padding = style.padding + contentSpacing = style.contentSpacing + progressBarHeight = style.progressBarHeight + entryStamp = EntryStamp(style.entryStamp) + regionNameTypography = Typography(style.regionNameTypography) + regionNameTracking = style.regionNameTracking + heroNumberTypography = Typography(style.heroNumberTypography) + dayUnitTypography = Typography(style.dayUnitTypography) + watermarkFontSize = style.watermarkFontSize + watermarkOffset = Offset(style.watermarkOffset) + regionShape = style.regionShape.map(RegionShape.init) + sheen = Sheen(style.sheen) + rosette = Rosette(style.rosette) + glow = Shadow(style.glow) + lift = Shadow(style.lift) + } + + var style: WhereStylesheet.CardStyle { + .init( + cornerRadius: cornerRadius, + padding: padding, + contentSpacing: contentSpacing, + progressBarHeight: progressBarHeight, + entryStamp: entryStamp.style, + regionNameTypography: regionNameTypography.style, + regionNameTracking: regionNameTracking, + heroNumberTypography: heroNumberTypography.style, + dayUnitTypography: dayUnitTypography.style, + watermarkFontSize: watermarkFontSize, + watermarkOffset: watermarkOffset.size, + regionShape: regionShape?.style, + sheen: sheen.style, + rosette: rosette.style, + glow: glow.style, + lift: lift.style, + ) + } + } + + struct Shared: Codable, Equatable { + var watermarkOpacity: Double + var glassTintOpacity: Double + var nameOpacity: Double + var primaryRosetteOpacity: Double + var secondaryRosetteOpacity: Double + var lightSecurityPrint: SecurityPrint + var darkSecurityPrint: SecurityPrint + + init(_ styles: WhereStylesheet.CardStyles) { + watermarkOpacity = styles.watermarkOpacity + glassTintOpacity = styles.glassTintOpacity + nameOpacity = styles.nameOpacity + primaryRosetteOpacity = styles.rosetteFill.primary + secondaryRosetteOpacity = styles.rosetteFill.secondary + lightSecurityPrint = SecurityPrint(.standard) + darkSecurityPrint = SecurityPrint(.dark) + } + + func securityPrint(for colorScheme: ColorScheme) -> SecurityPrint { + switch colorScheme { + case .light: lightSecurityPrint + case .dark: darkSecurityPrint + @unknown default: lightSecurityPrint + } + } + } + + struct Typography: Codable, Equatable { + var size: Size + var weight: FontWeight + var design: FontDesign + + init(_ typography: WhereStylesheet.CardStyle.Typography) { + switch typography.size { + case let .fixed(points): size = .fixed(points) + case let .semantic(textStyle): size = .semantic(TextStyle(textStyle)) + } + weight = FontWeight(typography.weight) + design = FontDesign(typography.design) + } + + var style: WhereStylesheet.CardStyle.Typography { + .init( + size: size.style, + weight: weight.style, + design: design.style, + ) + } + + enum Size: Codable, Equatable { + case fixed(CGFloat) + case semantic(TextStyle) + + var style: WhereStylesheet.CardStyle.Typography.Size { + switch self { + case let .fixed(points): .fixed(points) + case let .semantic(textStyle): .semantic(textStyle.style) + } + } + } + } + + enum TextStyle: String, CaseIterable, Codable { + case caption2 + case caption + case footnote + case subheadline + case callout + case body + case headline + case title3 + case title2 + case title + case largeTitle + + init(_ style: WhereStylesheet.CardStyle.Typography.TextStyle) { + self = TextStyle(rawValue: style.rawValue) ?? .body + } + + var style: WhereStylesheet.CardStyle.Typography.TextStyle { + .init(rawValue: rawValue) ?? .body + } + } + + enum FontWeight: String, CaseIterable, Codable { + case ultraLight + case thin + case light + case regular + case medium + case semibold + case bold + case heavy + case black + + init(_ weight: WhereStylesheet.CardStyle.Typography.Weight) { + self = FontWeight(rawValue: weight.rawValue) ?? .regular + } + + var style: WhereStylesheet.CardStyle.Typography.Weight { + .init(rawValue: rawValue) ?? .regular + } + + var fontWeight: Font.Weight { + style.fontWeight + } + } + + enum FontDesign: String, CaseIterable, Codable { + case `default` + case serif + case rounded + case monospaced + + init(_ design: WhereStylesheet.CardStyle.Typography.Design) { + self = FontDesign(rawValue: design.rawValue) ?? .default + } + + var style: WhereStylesheet.CardStyle.Typography.Design { + .init(rawValue: rawValue) ?? .default + } + + var fontDesign: Font.Design { + style.fontDesign + } + } + + struct EntryStamp: Codable, Equatable { + var size: CGFloat + var outerRing: Ring + var innerRing: DashedRing + var content: StampContent + var arc: Arc? + var rotationDegrees: Double + + init(_ style: WhereStylesheet.CardStyle.EntryStamp) { + size = style.size + outerRing = Ring(style.outerRing) + innerRing = DashedRing(style.innerRing) + content = StampContent(style.content) + arc = style.arc.map(Arc.init) + rotationDegrees = style.rotationDegrees + } + + var style: WhereStylesheet.CardStyle.EntryStamp { + .init( + size: size, + outerRing: outerRing.style, + innerRing: innerRing.style, + content: content.style, + arc: arc?.style, + rotationDegrees: rotationDegrees, + ) + } + } + + struct Ring: Codable, Equatable { + var opacity: Double + var lineWidthFraction: CGFloat + + init(_ style: WhereStylesheet.CardStyle.EntryStamp.Ring) { + opacity = style.opacity + lineWidthFraction = style.lineWidthFraction + } + + var style: WhereStylesheet.CardStyle.EntryStamp.Ring { + .init(opacity: opacity, lineWidthFraction: lineWidthFraction) + } + } + + struct DashedRing: Codable, Equatable { + var opacity: Double + var lineWidthFraction: CGFloat + var dashLengthFraction: CGFloat + var dashSpacingFraction: CGFloat + var insetFraction: CGFloat + + init(_ style: WhereStylesheet.CardStyle.EntryStamp.DashedRing) { + opacity = style.opacity + lineWidthFraction = style.lineWidthFraction + dashLengthFraction = style.dash.lengthFraction + dashSpacingFraction = style.dash.spacingFraction + insetFraction = style.insetFraction + } + + var style: WhereStylesheet.CardStyle.EntryStamp.DashedRing { + .init( + opacity: opacity, + lineWidthFraction: lineWidthFraction, + dash: .init( + lengthFraction: dashLengthFraction, + spacingFraction: dashSpacingFraction, + ), + insetFraction: insetFraction, + ) + } + } + + struct StampContent: Codable, Equatable { + var spacingFraction: CGFloat + var artworkExtent: Dimensions + var symbolFont: FractionalTypography + var yearFont: FractionalTypography + var opacity: Double + + init(_ style: WhereStylesheet.CardStyle.EntryStamp.Content) { + spacingFraction = style.spacingFraction + artworkExtent = Dimensions(style.artworkExtent) + symbolFont = FractionalTypography(style.symbolFont) + yearFont = FractionalTypography(style.yearFont) + opacity = style.opacity + } + + var style: WhereStylesheet.CardStyle.EntryStamp.Content { + .init( + spacingFraction: spacingFraction, + artworkExtent: artworkExtent.size, + symbolFont: symbolFont.style, + yearFont: yearFont.style, + opacity: opacity, + ) + } + } + + struct Arc: Codable, Equatable { + var radiusFraction: CGFloat + var font: FractionalTypography + var opacity: Double + var maximumSweepDegrees: Double + var sweepDegreesPerCharacter: Double + + init(_ style: WhereStylesheet.CardStyle.EntryStamp.Arc) { + radiusFraction = style.radiusFraction + font = FractionalTypography(style.font) + opacity = style.opacity + maximumSweepDegrees = style.maximumSweepDegrees + sweepDegreesPerCharacter = style.sweepDegreesPerCharacter + } + + var style: WhereStylesheet.CardStyle.EntryStamp.Arc { + .init( + radiusFraction: radiusFraction, + font: font.style, + opacity: opacity, + maximumSweepDegrees: maximumSweepDegrees, + sweepDegreesPerCharacter: sweepDegreesPerCharacter, + ) + } + } + + struct FractionalTypography: Codable, Equatable { + var sizeFraction: CGFloat + var weight: FontWeight + var design: FontDesign + + init(_ style: WhereStylesheet.CardStyle.EntryStamp.Typography) { + sizeFraction = style.sizeFraction + weight = FontWeight(style.weight) + design = FontDesign(style.design) + } + + var style: WhereStylesheet.CardStyle.EntryStamp.Typography { + .init( + sizeFraction: sizeFraction, + weight: weight.fontWeight, + design: design.fontDesign, + ) + } + } + + struct RegionShape: Codable, Equatable { + var watermark: Artwork + var stamp: Artwork + var securityBorder: SecurityBorder + + init(_ style: WhereStylesheet.CardStyle.RegionShape) { + watermark = Artwork(style.watermark) + stamp = Artwork(style.stamp) + securityBorder = SecurityBorder(style.securityBorder) + } + + var style: WhereStylesheet.CardStyle.RegionShape { + .init( + watermark: watermark.style, + stamp: stamp.style, + securityBorder: securityBorder.style, + ) + } + } + + struct Artwork: Codable, Equatable { + var center: Point + var extent: Dimensions + var scale: CGFloat + var fillOpacity: Double + var stroke: Stroke? + + init(_ style: WhereStylesheet.CardStyle.RegionShape.Artwork) { + center = Point(style.center) + extent = Dimensions(style.extent) + scale = style.scale + fillOpacity = style.fillOpacity + stroke = style.stroke.map(Stroke.init) + } + + var style: WhereStylesheet.CardStyle.RegionShape.Artwork { + .init( + center: center.point, + extent: extent.size, + scale: scale, + fillOpacity: fillOpacity, + stroke: stroke?.style, + ) + } + } + + struct Stroke: Codable, Equatable { + var opacity: Double + var width: CGFloat + + init(_ style: WhereStylesheet.CardStyle.RegionShape.Artwork.Stroke) { + opacity = style.opacity + width = style.width + } + + var style: WhereStylesheet.CardStyle.RegionShape.Artwork.Stroke { + .init(opacity: opacity, width: width) + } + } + + struct SecurityBorder: Codable, Equatable { + var inset: CGFloat + var glyphSize: CGFloat + var spacing: CGFloat + var opacity: Double + + init(_ style: WhereStylesheet.CardStyle.RegionShape.SecurityBorder) { + inset = style.inset + glyphSize = style.glyphSize + spacing = style.spacing + opacity = style.opacity + } + + var style: WhereStylesheet.CardStyle.RegionShape.SecurityBorder { + .init(inset: inset, glyphSize: glyphSize, spacing: spacing, opacity: opacity) + } + } + + struct Sheen: Codable, Equatable { + var intensity: Double + var staticGlintIntensity: Double + var staticRoll: Double + var staticPitch: Double + + init(_ style: WhereStylesheet.CardStyle.Sheen) { + intensity = style.intensity + staticGlintIntensity = style.staticGlintIntensity + staticRoll = style.staticPose.roll + staticPitch = style.staticPose.pitch + } + + var style: WhereStylesheet.CardStyle.Sheen { + .init( + intensity: intensity, + staticGlintIntensity: staticGlintIntensity, + staticPose: .init(roll: staticRoll, pitch: staticPitch), + ) + } + } + + struct Rosette: Codable, Equatable { + var wobble: CGFloat + var lineWidth: CGFloat + var primaryRingSpacing: CGFloat + var secondaryRingSpacing: CGFloat + + init(_ style: WhereStylesheet.CardStyle.Rosette) { + wobble = style.wobble + lineWidth = style.lineWidth + primaryRingSpacing = style.primaryRingSpacing + secondaryRingSpacing = style.secondaryRingSpacing + } + + var style: WhereStylesheet.CardStyle.Rosette { + .init( + wobble: wobble, + lineWidth: lineWidth, + primaryRingSpacing: primaryRingSpacing, + secondaryRingSpacing: secondaryRingSpacing, + ) + } + } + + struct Shadow: Codable, Equatable { + var opacity: Double + var radius: CGFloat + var offsetY: CGFloat + + init(_ style: WhereStylesheet.CardStyle.Shadow) { + opacity = style.opacity + radius = style.radius + offsetY = style.offsetY + } + + var style: WhereStylesheet.CardStyle.Shadow { + .init(opacity: opacity, radius: radius, offsetY: offsetY) + } + } + + struct SecurityPrint: Codable, Equatable { + var whiteMix: Double + var blendMode: CardDesignerBlendMode + + init(_ style: WhereStylesheet.CardStyles.SecurityPrint) { + whiteMix = style.whiteMix + blendMode = CardDesignerBlendMode(style.backgroundBlendMode) + } + + var style: WhereStylesheet.CardStyles.SecurityPrint { + .init(whiteMix: whiteMix, backgroundBlendMode: blendMode.style) + } + } + + struct Point: Codable, Equatable { + var x: CGFloat + var y: CGFloat + + init(_ point: CGPoint) { + x = point.x + y = point.y + } + + var point: CGPoint { + CGPoint(x: x, y: y) + } + } + + struct Dimensions: Codable, Equatable { + var width: CGFloat + var height: CGFloat + + init(_ size: CGSize) { + width = size.width + height = size.height + } + + var size: CGSize { + CGSize(width: width, height: height) + } + } + + struct Offset: Codable, Equatable { + var x: CGFloat + var y: CGFloat + + init(_ size: CGSize) { + x = size.width + y = size.height + } + + var size: CGSize { + CGSize(width: x, height: y) + } + } + } + + enum CardDesignerBlendMode: String, CaseIterable, Codable { + case normal + case multiply + case screen + case overlay + case darken + case lighten + case colorDodge + case colorBurn + case softLight + case hardLight + case difference + case exclusion + case hue + case saturation + case color + case luminosity + case sourceAtop + case destinationOver + case destinationOut + case plusDarker + case plusLighter + + init(_ mode: BlendMode) { + switch mode { + case .normal: self = .normal + case .multiply: self = .multiply + case .screen: self = .screen + case .overlay: self = .overlay + case .darken: self = .darken + case .lighten: self = .lighten + case .colorDodge: self = .colorDodge + case .colorBurn: self = .colorBurn + case .softLight: self = .softLight + case .hardLight: self = .hardLight + case .difference: self = .difference + case .exclusion: self = .exclusion + case .hue: self = .hue + case .saturation: self = .saturation + case .color: self = .color + case .luminosity: self = .luminosity + case .sourceAtop: self = .sourceAtop + case .destinationOver: self = .destinationOver + case .destinationOut: self = .destinationOut + case .plusDarker: self = .plusDarker + case .plusLighter: self = .plusLighter + @unknown default: self = .normal + } + } + + var style: BlendMode { + switch self { + case .normal: .normal + case .multiply: .multiply + case .screen: .screen + case .overlay: .overlay + case .darken: .darken + case .lighten: .lighten + case .colorDodge: .colorDodge + case .colorBurn: .colorBurn + case .softLight: .softLight + case .hardLight: .hardLight + case .difference: .difference + case .exclusion: .exclusion + case .hue: .hue + case .saturation: .saturation + case .color: .color + case .luminosity: .luminosity + case .sourceAtop: .sourceAtop + case .destinationOver: .destinationOver + case .destinationOut: .destinationOut + case .plusDarker: .plusDarker + case .plusLighter: .plusLighter + } + } + } + + extension CardDesignerConfiguration.FontWeight { + fileprivate init(_ weight: Font.Weight) { + switch weight { + case .ultraLight: self = .ultraLight + case .thin: self = .thin + case .light: self = .light + case .regular: self = .regular + case .medium: self = .medium + case .semibold: self = .semibold + case .bold: self = .bold + case .heavy: self = .heavy + case .black: self = .black + default: self = .regular + } + } + } + + extension CardDesignerConfiguration.FontDesign { + fileprivate init(_ design: Font.Design) { + switch design { + case .default: self = .default + case .serif: self = .serif + case .rounded: self = .rounded + case .monospaced: self = .monospaced + default: self = .default + } + } + } +#endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerEnvironment.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerEnvironment.swift new file mode 100644 index 00000000..2a12a188 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerEnvironment.swift @@ -0,0 +1,12 @@ +#if DEBUG + import SwiftUI + + extension EnvironmentValues { + /// Root-owned designer state used by the Settings studio. + @Entry var cardDesignerModel: CardDesignerModel? + + /// The session-only configuration applied to real cards outside the + /// studio. `nil` keeps the production stylesheet untouched. + @Entry var cardDesignerConfiguration: CardDesignerConfiguration? + } +#endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExport.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExport.swift new file mode 100644 index 00000000..53a09678 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExport.swift @@ -0,0 +1,229 @@ +#if DEBUG + import CoreTransferable + import Foundation + import UniformTypeIdentifiers + + struct CardDesignerJSONExport: Transferable { + let data: Data + + static var transferRepresentation: some TransferRepresentation { + DataRepresentation(exportedContentType: .json) { $0.data } + .suggestedFileName("Where Card Design.json") + } + } + + struct CardDesignerSwiftExport: Transferable { + let source: String + + static var transferRepresentation: some TransferRepresentation { + DataRepresentation(exportedContentType: .plainText) { + Data($0.source.utf8) + } + .suggestedFileName("Where Card Design.swift") + } + } + + enum CardDesignerSwiftExporter { + static func source(for configuration: CardDesignerConfiguration) -> String { + """ + // Generated by Where's DEBUG Card Designer Studio (schema \(configuration + .schemaVersion)). + // Paste these values into WhereStylesheet.CardStyles.standard. + + let regularCardStyle = \(card(configuration.regular)) + + let compactCardStyle = \(card(configuration.compact)) + + let lightCardStyles = \(cardStyles( + configuration, + securityPrint: configuration.shared.lightSecurityPrint, + )) + + let darkCardStyles = \(cardStyles( + configuration, + securityPrint: configuration.shared.darkSecurityPrint, + )) + """ + } + + private static func card(_ card: CardDesignerConfiguration.Card) -> String { + """ + WhereStylesheet.CardStyle( + cornerRadius: \(number(card.cornerRadius)), + padding: \(number(card.padding)), + contentSpacing: \(number(card.contentSpacing)), + progressBarHeight: \(number(card.progressBarHeight)), + entryStamp: \(entryStamp(card.entryStamp, indent: 1)), + regionNameTypography: \(typography(card.regionNameTypography)), + regionNameTracking: \(number(card.regionNameTracking)), + heroNumberTypography: \(typography(card.heroNumberTypography)), + dayUnitTypography: \(typography(card.dayUnitTypography)), + watermarkFontSize: \(number(card.watermarkFontSize)), + watermarkOffset: CGSize(width: \(number(card.watermarkOffset + .x)), height: \(number(card.watermarkOffset.y))), + regionShape: \(regionShape(card.regionShape, indent: 1)), + sheen: .init( + intensity: \(number(card.sheen.intensity)), + staticGlintIntensity: \(number(card.sheen.staticGlintIntensity)), + staticPose: .init(roll: \(number(card.sheen.staticRoll)), pitch: \(number(card + .sheen.staticPitch))), + ), + rosette: .init( + wobble: \(number(card.rosette.wobble)), + lineWidth: \(number(card.rosette.lineWidth)), + primaryRingSpacing: \(number(card.rosette.primaryRingSpacing)), + secondaryRingSpacing: \(number(card.rosette.secondaryRingSpacing)), + ), + glow: .init(opacity: \(number(card.glow.opacity)), radius: \(number(card.glow + .radius)), offsetY: \(number(card.glow.offsetY))), + lift: .init(opacity: \(number(card.lift.opacity)), radius: \(number(card.lift + .radius)), offsetY: \(number(card.lift.offsetY))), + ) + """ + } + + private static func cardStyles( + _ configuration: CardDesignerConfiguration, + securityPrint: CardDesignerConfiguration.SecurityPrint, + ) -> String { + let shared = configuration.shared + return """ + WhereStylesheet.CardStyles( + regular: regularCardStyle, + compact: compactCardStyle, + watermarkOpacity: \(number(shared.watermarkOpacity)), + glassTintOpacity: \(number(shared.glassTintOpacity)), + nameOpacity: \(number(shared.nameOpacity)), + rosetteFill: .init( + primary: \(number(shared.primaryRosetteOpacity)), + secondary: \(number(shared.secondaryRosetteOpacity)), + ), + securityPrint: .init( + whiteMix: \(number(securityPrint.whiteMix)), + backgroundBlendMode: .\(securityPrint.blendMode.rawValue), + ), + dayCount: .standard, + ) + """ + } + + private static func typography(_ typography: CardDesignerConfiguration + .Typography) -> String + { + let size = switch typography.size { + case let .fixed(points): ".fixed(\(number(points)))" + case let .semantic(style): ".semantic(.\(style.rawValue))" + } + return ".init(size: \(size), weight: .\(typography.weight.rawValue), design: .\(typography.design.rawValue))" + } + + private static func entryStamp( + _ stamp: CardDesignerConfiguration.EntryStamp, + indent: Int, + ) -> String { + let arc = stamp.arc.map { arc($0, indent: indent + 1) } ?? "nil" + return """ + .init( + \(spaces(indent + 1))size: \(number(stamp.size)), + \(spaces(indent + 1))outerRing: .init(opacity: \(number(stamp.outerRing + .opacity)), lineWidthFraction: \(number(stamp.outerRing.lineWidthFraction))), + \(spaces(indent + 1))innerRing: .init( + \(spaces(indent + 2))opacity: \(number(stamp.innerRing.opacity)), + \(spaces(indent + 2))lineWidthFraction: \(number(stamp.innerRing.lineWidthFraction)), + \(spaces(indent + 2))dash: .init(lengthFraction: \(number(stamp.innerRing + .dashLengthFraction)), spacingFraction: \(number(stamp.innerRing + .dashSpacingFraction))), + \(spaces(indent + 2))insetFraction: \(number(stamp.innerRing.insetFraction)), + \(spaces(indent + 1))), + \(spaces(indent + 1))content: .init( + \(spaces(indent + 2))spacingFraction: \(number(stamp.content.spacingFraction)), + \(spaces(indent + 2))artworkExtent: CGSize(width: \(number(stamp.content.artworkExtent + .width)), height: \(number(stamp.content.artworkExtent.height))), + \(spaces(indent + 2))symbolFont: \(fractionalTypography(stamp.content.symbolFont)), + \(spaces(indent + 2))yearFont: \(fractionalTypography(stamp.content.yearFont)), + \(spaces(indent + 2))opacity: \(number(stamp.content.opacity)), + \(spaces(indent + 1))), + \(spaces(indent + 1))arc: \(arc), + \(spaces(indent + 1))rotationDegrees: \(number(stamp.rotationDegrees)), + \(spaces(indent))) + """ + } + + private static func arc( + _ arc: CardDesignerConfiguration.Arc, + indent: Int, + ) -> String { + """ + .init( + \(spaces(indent + 1))radiusFraction: \(number(arc.radiusFraction)), + \(spaces(indent + 1))font: \(fractionalTypography(arc.font)), + \(spaces(indent + 1))opacity: \(number(arc.opacity)), + \(spaces(indent + 1))maximumSweepDegrees: \(number(arc.maximumSweepDegrees)), + \(spaces(indent + 1))sweepDegreesPerCharacter: \(number(arc.sweepDegreesPerCharacter)), + \(spaces(indent))) + """ + } + + private static func fractionalTypography( + _ typography: CardDesignerConfiguration.FractionalTypography, + ) -> String { + ".init(sizeFraction: \(number(typography.sizeFraction)), weight: .\(typography.weight.rawValue), design: .\(typography.design.rawValue))" + } + + private static func regionShape( + _ shape: CardDesignerConfiguration.RegionShape?, + indent: Int, + ) -> String { + guard let shape else { return "nil" } + return """ + .init( + \(spaces(indent + 1))watermark: \(artwork(shape.watermark, indent: indent + 1)), + \(spaces(indent + 1))stamp: \(artwork(shape.stamp, indent: indent + 1)), + \(spaces(indent + 1))securityBorder: .init( + \(spaces(indent + 2))inset: \(number(shape.securityBorder.inset)), + \(spaces(indent + 2))glyphSize: \(number(shape.securityBorder.glyphSize)), + \(spaces(indent + 2))spacing: \(number(shape.securityBorder.spacing)), + \(spaces(indent + 2))opacity: \(number(shape.securityBorder.opacity)), + \(spaces(indent + 1))), + \(spaces(indent))) + """ + } + + private static func artwork( + _ artwork: CardDesignerConfiguration.Artwork, + indent: Int, + ) -> String { + let stroke = artwork.stroke.map { + ".init(opacity: \(number($0.opacity)), width: \(number($0.width)))" + } ?? "nil" + return """ + .init( + \(spaces(indent + 1))center: CGPoint(x: \(number(artwork.center.x)), y: \(number(artwork + .center.y))), + \(spaces(indent + 1))extent: CGSize(width: \(number(artwork.extent + .width)), height: \(number(artwork.extent.height))), + \(spaces(indent + 1))scale: \(number(artwork.scale)), + \(spaces(indent + 1))fillOpacity: \(number(artwork.fillOpacity)), + \(spaces(indent + 1))stroke: \(stroke), + \(spaces(indent))) + """ + } + + private static func number(_ value: some BinaryFloatingPoint) -> String { + var formatted = String( + format: "%.4f", + locale: Locale(identifier: "en_US_POSIX"), + Double(value), + ) + while formatted.last == "0" { + formatted.removeLast() + } + if formatted.last == "." { formatted.removeLast() } + return formatted == "-0" ? "0" : formatted + } + + private static func spaces(_ level: Int) -> String { + String(repeating: " ", count: level) + } + } +#endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerModel.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerModel.swift new file mode 100644 index 00000000..4ee9af87 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerModel.swift @@ -0,0 +1,149 @@ +#if DEBUG + import Foundation + import Observation + + /// Root-owned state for the DEBUG card designer. Drafts persist across + /// launches, while the app-wide override deliberately starts disabled. + @MainActor @Observable + final class CardDesignerModel { + enum Section: CaseIterable { + case card + case typography + case entryStamp + case regionArtwork + case microprint + case rosettes + case sheen + case glassAndInk + case shadows + } + + var configuration: CardDesignerConfiguration { + didSet { + guard oldValue != configuration else { return } + persist() + } + } + + /// Session-only by design: a debug launch should never silently boot + /// with experimental cards active outside the studio. + var appliesToApp = false + var persistenceError: String? + + private let store: UserDefaults + private let key: String + + init(store: UserDefaults, key: String) { + self.store = store + self.key = key + do { + configuration = try Self.load(from: store, key: key) + } catch { + configuration = .standard + persistenceError = error.localizedDescription + } + } + + func resetAll() { + configuration = .standard + } + + func reset(_ variant: CardDesignerConfiguration.Variant) { + configuration[variant] = CardDesignerConfiguration.standard[variant] + } + + func resetShared() { + configuration.shared = CardDesignerConfiguration.standard.shared + } + + func reset(_ section: Section, variant: CardDesignerConfiguration.Variant) { + let standard = CardDesignerConfiguration.standard + var card = configuration[variant] + let standardCard = standard[variant] + switch section { + case .card: + card.cornerRadius = standardCard.cornerRadius + card.padding = standardCard.padding + card.contentSpacing = standardCard.contentSpacing + card.progressBarHeight = standardCard.progressBarHeight + card.watermarkFontSize = standardCard.watermarkFontSize + card.watermarkOffset = standardCard.watermarkOffset + case .typography: + card.regionNameTypography = standardCard.regionNameTypography + card.regionNameTracking = standardCard.regionNameTracking + card.heroNumberTypography = standardCard.heroNumberTypography + card.dayUnitTypography = standardCard.dayUnitTypography + case .entryStamp: + card.entryStamp = standardCard.entryStamp + case .regionArtwork: + card.regionShape = standardCard.regionShape + case .microprint: + guard let standardBorder = standardCard.regionShape?.securityBorder + else { return } + card.regionShape?.securityBorder = standardBorder + case .rosettes: + card.rosette = standardCard.rosette + configuration.shared.primaryRosetteOpacity = standard.shared + .primaryRosetteOpacity + configuration.shared.secondaryRosetteOpacity = standard.shared + .secondaryRosetteOpacity + case .sheen: + card.sheen = standardCard.sheen + case .glassAndInk: + resetShared() + return + case .shadows: + card.glow = standardCard.glow + card.lift = standardCard.lift + } + configuration[variant] = card + } + + func dismissPersistenceError() { + persistenceError = nil + } + + func jsonData() throws -> Data { + try Self.encoder.encode(configuration) + } + + private func persist() { + do { + try store.set(jsonData(), forKey: key) + persistenceError = nil + } catch { + persistenceError = error.localizedDescription + } + } + + private static func load( + from store: UserDefaults, + key: String, + ) throws -> CardDesignerConfiguration { + guard let data = store.data(forKey: key) else { return .standard } + let configuration = try JSONDecoder().decode(CardDesignerConfiguration.self, from: data) + guard configuration.schemaVersion == CardDesignerConfiguration.currentSchemaVersion + else { + throw CardDesignerPersistenceError.unsupportedSchema(configuration.schemaVersion) + } + return configuration + } + + private static var encoder: JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + return encoder + } + } + + private enum CardDesignerPersistenceError: LocalizedError { + case unsupportedSchema(Int) + + var errorDescription: String? { + switch self { + case let .unsupportedSchema(version): + "Card designer schema \(version) is not supported by this build." + } + } + } +#endif diff --git a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift index d6df34a8..32a90b60 100644 --- a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift @@ -51,11 +51,27 @@ struct RegionSummaryCard: View { @Environment(\.stylesheet) private var stylesheet @Environment(\.regionStyles) private var regionStyles @Environment(\.regionOutlinePathCache) private var regionOutlinePathCache + #if DEBUG + @Environment(\.colorScheme) private var colorScheme + @Environment(\.cardDesignerConfiguration) private var cardDesignerConfiguration + #endif + + private var cardStyles: WhereStylesheet.CardStyles { + #if DEBUG + if let cardDesignerConfiguration { + return cardDesignerConfiguration.resolve( + over: stylesheet.card, + colorScheme: colorScheme, + ) + } + #endif + return stylesheet.card + } /// The resolved spec for this card's variant, read once so the rest of the /// view is a straight-line render with no `compact` branching. private var card: WhereStylesheet.CardStyle { - stylesheet.card[variant] + cardStyles[variant] } private var style: RegionStyle { @@ -65,7 +81,7 @@ struct RegionSummaryCard: View { /// Region ink on light cards; a pale derivative on dark cards that remains /// distinct while interactive Liquid Glass illuminates nearby surfaces. private var securityPrintTint: Color { - stylesheet.card.securityPrint.tint(style.tint) + cardStyles.securityPrint.tint(style.tint) } private var cardShape: RoundedRectangle { @@ -84,7 +100,7 @@ struct RegionSummaryCard: View { /// How a count change plays out while the card is on screen. Reduce Motion is /// already resolved into it by the stylesheet. private var dayCount: WhereStylesheet.CardStyles.DayCountStyle { - stylesheet.card.dayCount + cardStyles.dayCount } /// A circular rubber-stamp "entry" impression: the region glyph and year @@ -112,7 +128,7 @@ struct RegionSummaryCard: View { // values rather than reaching back into main-actor state. let tint = securityPrintTint let rosette = card.rosette - let rosetteFill = stylesheet.card.rosetteFill + let rosetteFill = cardStyles.rosetteFill return ZStack { Canvas { context, size in func drawRosette(center: CGPoint, spacing: CGFloat, opacity: Double) { @@ -177,13 +193,13 @@ struct RegionSummaryCard: View { } else { Image(systemName: style.symbolName) .font(.system(size: card.watermarkFontSize)) - .foregroundStyle(tint.opacity(stylesheet.card.watermarkOpacity)) + .foregroundStyle(tint.opacity(cardStyles.watermarkOpacity)) .rotationEffect(.degrees(-14)) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) .offset(x: card.watermarkOffset.width, y: card.watermarkOffset.height) } } - .blendMode(stylesheet.card.securityPrint.backgroundBlendMode) + .blendMode(cardStyles.securityPrint.backgroundBlendMode) .clipShape(cardShape) .allowsHitTesting(false) .accessibilityHidden(true) @@ -219,13 +235,13 @@ struct RegionSummaryCard: View { HStack(alignment: .top, spacing: stylesheet.spacing.large) { VStack(alignment: .leading, spacing: stylesheet.spacing.xxSmall) { Text(regionDays.region.localizedName) - .font(card.regionNameFont) + .font(card.regionNameTypography.font) .tracking(card.regionNameTracking) .lineLimit(1) .allowsTightening(true) .minimumScaleFactor(0.7) .foregroundStyle(style.tint) - .opacity(stylesheet.card.nameOpacity) + .opacity(cardStyles.nameOpacity) if let caption { Text(caption) .font(.caption2.weight(.semibold)) @@ -248,11 +264,11 @@ struct RegionSummaryCard: View { HStack(alignment: .firstTextBaseline, spacing: stylesheet.spacing.small) { Text(regionDays.days, format: .number) - .font(card.heroNumberFont) + .font(card.heroNumberTypography.font) .contentTransition(dayCount.transition(days: regionDays.days)) .foregroundStyle(style.tint) Text(WhereFormat.dayUnit(regionDays.days)) - .font(card.dayUnitFont) + .font(card.dayUnitTypography.font) .foregroundStyle(.secondary) } @@ -277,7 +293,7 @@ struct RegionSummaryCard: View { .frame(maxWidth: .infinity, alignment: .leading) .background { stampPaper } .glassEffect( - .regular.tint(style.tint.opacity(stylesheet.card.glassTintOpacity)) + .regular.tint(style.tint.opacity(cardStyles.glassTintOpacity)) .interactive(interactive), in: cardShape, ) diff --git a/Where/WhereUI/Sources/RootView.swift b/Where/WhereUI/Sources/RootView.swift index 7a27f568..20677e3b 100644 --- a/Where/WhereUI/Sources/RootView.swift +++ b/Where/WhereUI/Sources/RootView.swift @@ -43,6 +43,12 @@ public struct RootView: View { /// the store is available. @State private var alerter: PeriscopeAlerter? @State private var toastCenter = DeveloperToastCenter() + /// One process-scoped designer draft. Its configuration persists, but + /// applying it to real cards remains session-only. + @State private var cardDesigner = CardDesignerModel( + store: .standard, + key: "where.debug.card-designer.configuration", + ) #endif private let launcher: LifecycleRunner #if DEBUG @@ -162,6 +168,11 @@ public struct RootView: View { .environment(model.session) #if DEBUG .environment(inspectorModeController) + .environment(\.cardDesignerModel, cardDesigner) + .environment( + \.cardDesignerConfiguration, + cardDesigner.appliesToApp ? cardDesigner.configuration : nil, + ) #endif // Which world the app is in, seeded once here so no view has to // ask the model. Reading the model's mode tracks its scope state, diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index 1876df28..8741a09f 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -250,10 +250,10 @@ extension WhereStylesheet { var contentSpacing: CGFloat var progressBarHeight: CGFloat var entryStamp: EntryStamp - var regionNameFont: Font + var regionNameTypography: Typography var regionNameTracking: CGFloat - var heroNumberFont: Font - var dayUnitFont: Font + var heroNumberTypography: Typography + var dayUnitTypography: Typography /// Point size of the oversized region glyph watermarked behind the card. var watermarkFontSize: CGFloat /// Offset of that watermark toward the bottom-trailing corner. @@ -278,6 +278,102 @@ extension WhereStylesheet { var secondaryRingSpacing: CGFloat } + /// A card text treatment kept as structured data so the DEBUG card + /// designer can round-trip it without trying to inspect an opaque + /// SwiftUI `Font` value. + struct Typography: Equatable { + var size: Size + var weight: Weight + var design: Design + + var font: Font { + switch size { + case let .fixed(points): + .system(size: points, weight: weight.fontWeight, design: design.fontDesign) + case let .semantic(textStyle): + .system(textStyle.fontTextStyle, design: design.fontDesign) + .weight(weight.fontWeight) + } + } + + enum Size: Equatable { + case fixed(CGFloat) + case semantic(TextStyle) + } + + enum TextStyle: String, CaseIterable, Codable { + case caption2 + case caption + case footnote + case subheadline + case callout + case body + case headline + case title3 + case title2 + case title + case largeTitle + + var fontTextStyle: Font.TextStyle { + switch self { + case .caption2: .caption2 + case .caption: .caption + case .footnote: .footnote + case .subheadline: .subheadline + case .callout: .callout + case .body: .body + case .headline: .headline + case .title3: .title3 + case .title2: .title2 + case .title: .title + case .largeTitle: .largeTitle + } + } + } + + enum Weight: String, CaseIterable, Codable { + case ultraLight + case thin + case light + case regular + case medium + case semibold + case bold + case heavy + case black + + var fontWeight: Font.Weight { + switch self { + case .ultraLight: .ultraLight + case .thin: .thin + case .light: .light + case .regular: .regular + case .medium: .medium + case .semibold: .semibold + case .bold: .bold + case .heavy: .heavy + case .black: .black + } + } + } + + enum Design: String, CaseIterable, Codable { + case `default` + case serif + case rounded + case monospaced + + var fontDesign: Font.Design { + switch self { + case .default: .default + case .serif: .serif + case .rounded: .rounded + case .monospaced: .monospaced + } + } + } + } + /// Geometry, ink strength, and typography for the circular passport /// impression. The compact style omits `arc` where its text cannot read. struct EntryStamp: Equatable { @@ -534,10 +630,22 @@ extension WhereStylesheet { // control against the entry stamp: the longest common headline // names ("California" / "New York") fit, and any over-long one // tightens then scales via `minimumScaleFactor`. - regionNameFont: .system(size: 38, weight: .semibold, design: .serif), + regionNameTypography: .init( + size: .fixed(38), + weight: .semibold, + design: .serif, + ), regionNameTracking: -0.5, - heroNumberFont: .system(size: 40, weight: .bold, design: .rounded), - dayUnitFont: .title3.weight(.medium), + heroNumberTypography: .init( + size: .fixed(40), + weight: .bold, + design: .rounded, + ), + dayUnitTypography: .init( + size: .semantic(.title3), + weight: .medium, + design: .default, + ), watermarkFontSize: 150, watermarkOffset: CGSize(width: 20, height: 12), regionShape: CardStyle.RegionShape( @@ -584,10 +692,22 @@ extension WhereStylesheet { contentSpacing: 10, progressBarHeight: 6, entryStamp: .standard(size: 52, showsArcText: false), - regionNameFont: .system(.title3, design: .serif).weight(.semibold), + regionNameTypography: .init( + size: .semantic(.title3), + weight: .semibold, + design: .serif, + ), regionNameTracking: 0, - heroNumberFont: .system(.title, design: .rounded, weight: .bold), - dayUnitFont: .subheadline.weight(.medium), + heroNumberTypography: .init( + size: .semantic(.title), + weight: .bold, + design: .rounded, + ), + dayUnitTypography: .init( + size: .semantic(.subheadline), + weight: .medium, + design: .default, + ), watermarkFontSize: 96, watermarkOffset: CGSize(width: 12, height: 10), regionShape: nil, diff --git a/Where/WhereUI/Tests/CardDesignerConfigurationTests.swift b/Where/WhereUI/Tests/CardDesignerConfigurationTests.swift new file mode 100644 index 00000000..7173ba82 --- /dev/null +++ b/Where/WhereUI/Tests/CardDesignerConfigurationTests.swift @@ -0,0 +1,53 @@ +#if DEBUG + import Foundation + import SwiftUI + import Testing + @testable import WhereUI + + struct CardDesignerConfigurationTests { + @Test func standardResolvesToTheProductionLightStyle() { + let resolved = CardDesignerConfiguration.standard.resolve( + over: .standard, + colorScheme: .light, + ) + #expect(resolved == .standard) + } + + @Test func standardResolvesToTheProductionDarkStyle() { + var expected = WhereStylesheet.CardStyles.standard + expected.securityPrint = .dark + let resolved = CardDesignerConfiguration.standard.resolve( + over: .standard, + colorScheme: .dark, + ) + #expect(resolved == expected) + } + + @Test func configurationRoundTripsThroughVersionedJSON() throws { + var configuration = CardDesignerConfiguration.standard + configuration.regular.cornerRadius = 33 + configuration.compact.regionNameTypography.size = .fixed(19) + configuration.shared.darkSecurityPrint.blendMode = .softLight + + let data = try JSONEncoder().encode(configuration) + let decoded = try JSONDecoder().decode(CardDesignerConfiguration.self, from: data) + + #expect(decoded == configuration) + #expect(decoded.schemaVersion == CardDesignerConfiguration.currentSchemaVersion) + } + + @Test(arguments: CardDesignerBlendMode.allCases) + func everyBlendModeRoundTrips(_ blendMode: CardDesignerBlendMode) { + #expect(CardDesignerBlendMode(blendMode.style) == blendMode) + } + + @Test func variantSubscriptReadsAndWritesIndependently() { + var configuration = CardDesignerConfiguration.standard + let compactRadius = configuration[.compact].cornerRadius + configuration[.regular].cornerRadius = 41 + + #expect(configuration[.regular].cornerRadius == 41) + #expect(configuration[.compact].cornerRadius == compactRadius) + } + } +#endif diff --git a/Where/WhereUI/Tests/CardDesignerEnvironmentTests.swift b/Where/WhereUI/Tests/CardDesignerEnvironmentTests.swift new file mode 100644 index 00000000..921540e8 --- /dev/null +++ b/Where/WhereUI/Tests/CardDesignerEnvironmentTests.swift @@ -0,0 +1,14 @@ +#if DEBUG + import SwiftUI + import Testing + @testable import WhereUI + + @MainActor + struct CardDesignerEnvironmentTests { + @Test func defaultsKeepTheDesignerAndOverrideAbsent() { + let environment = EnvironmentValues() + #expect(environment.cardDesignerModel == nil) + #expect(environment.cardDesignerConfiguration == nil) + } + } +#endif diff --git a/Where/WhereUI/Tests/CardDesignerExportTests.swift b/Where/WhereUI/Tests/CardDesignerExportTests.swift new file mode 100644 index 00000000..8ccebba4 --- /dev/null +++ b/Where/WhereUI/Tests/CardDesignerExportTests.swift @@ -0,0 +1,31 @@ +#if DEBUG + import Testing + @testable import WhereUI + + struct CardDesignerExportTests { + @Test func swiftExportIsDeterministicAndContainsBothAppearances() { + let configuration = CardDesignerConfiguration.standard + + let first = CardDesignerSwiftExporter.source(for: configuration) + let second = CardDesignerSwiftExporter.source(for: configuration) + + #expect(first == second) + #expect(first.contains("let regularCardStyle")) + #expect(first.contains("let compactCardStyle")) + #expect(first.contains("let lightCardStyles")) + #expect(first.contains("let darkCardStyles")) + #expect(first.contains("backgroundBlendMode: .luminosity")) + } + + @Test func swiftExportReflectsEditedValues() { + var configuration = CardDesignerConfiguration.standard + configuration.regular.cornerRadius = 31.25 + configuration.shared.darkSecurityPrint.whiteMix = 0.4 + + let source = CardDesignerSwiftExporter.source(for: configuration) + + #expect(source.contains("cornerRadius: 31.25")) + #expect(source.contains("whiteMix: 0.4")) + } + } +#endif diff --git a/Where/WhereUI/Tests/CardDesignerModelTests.swift b/Where/WhereUI/Tests/CardDesignerModelTests.swift new file mode 100644 index 00000000..fbfb9e2e --- /dev/null +++ b/Where/WhereUI/Tests/CardDesignerModelTests.swift @@ -0,0 +1,65 @@ +#if DEBUG + import Foundation + import Testing + @testable import WhereUI + + @MainActor + struct CardDesignerModelTests { + @Test func persistsTheDraftButNotTheAppWideOverride() throws { + let (store, suite) = try isolatedStore() + defer { store.removePersistentDomain(forName: suite) } + let key = "card-designer" + let model = CardDesignerModel(store: store, key: key) + model.configuration.regular.cornerRadius = 39 + model.appliesToApp = true + + let reloaded = CardDesignerModel(store: store, key: key) + + #expect(reloaded.configuration.regular.cornerRadius == 39) + #expect(reloaded.appliesToApp == false) + } + + @Test func corruptPersistenceSurfacesAnErrorAndUsesDefaults() throws { + let (store, suite) = try isolatedStore() + defer { store.removePersistentDomain(forName: suite) } + let key = "card-designer" + store.set(Data("not json".utf8), forKey: key) + + let model = CardDesignerModel(store: store, key: key) + + #expect(model.configuration == .standard) + #expect(model.persistenceError != nil) + } + + @Test func resetVariantLeavesTheOtherVariantUntouched() throws { + let (store, suite) = try isolatedStore() + defer { store.removePersistentDomain(forName: suite) } + let model = CardDesignerModel(store: store, key: "card-designer") + model.configuration.regular.cornerRadius = 42 + model.configuration.compact.cornerRadius = 31 + + model.reset(.regular) + + #expect(model.configuration.regular == CardDesignerConfiguration.standard.regular) + #expect(model.configuration.compact.cornerRadius == 31) + } + + @Test func resetAllRestoresEveryToken() throws { + let (store, suite) = try isolatedStore() + defer { store.removePersistentDomain(forName: suite) } + let model = CardDesignerModel(store: store, key: "card-designer") + model.configuration.shared.darkSecurityPrint.whiteMix = 0.2 + model.configuration.compact.sheen.intensity = 0.9 + + model.resetAll() + + #expect(model.configuration == .standard) + } + + private func isolatedStore() throws -> (store: UserDefaults, suite: String) { + let suite = "CardDesignerModelTests-\(UUID().uuidString)" + let store = try #require(UserDefaults(suiteName: suite)) + return (store, suite) + } + } +#endif diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index 32409040..3b5a2056 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -32,7 +32,26 @@ struct WhereStylesheetTests { #expect(card.contentSpacing == 16) #expect(card.progressBarHeight == 10) #expect(card.entryStamp == expectedEntryStamp(size: 88, showsArcText: true)) - #expect(card.regionNameFont == .system(size: 38, weight: .semibold, design: .serif)) + #expect(card.regionNameTypography == .init( + size: .fixed(38), + weight: .semibold, + design: .serif, + )) + #expect(card.regionNameTypography.font == .system( + size: 38, + weight: .semibold, + design: .serif, + )) + #expect(card.heroNumberTypography == .init( + size: .fixed(40), + weight: .bold, + design: .rounded, + )) + #expect(card.dayUnitTypography == .init( + size: .semantic(.title3), + weight: .medium, + design: .default, + )) #expect(card.regionNameTracking == -0.5) #expect(card.watermarkFontSize == 150) #expect(card.watermarkOffset == CGSize(width: 20, height: 12)) @@ -80,6 +99,21 @@ struct WhereStylesheetTests { #expect(card.contentSpacing == 10) #expect(card.progressBarHeight == 6) #expect(card.entryStamp == expectedEntryStamp(size: 52, showsArcText: false)) + #expect(card.regionNameTypography == .init( + size: .semantic(.title3), + weight: .semibold, + design: .serif, + )) + #expect(card.heroNumberTypography == .init( + size: .semantic(.title), + weight: .bold, + design: .rounded, + )) + #expect(card.dayUnitTypography == .init( + size: .semantic(.subheadline), + weight: .medium, + design: .default, + )) #expect(card.regionNameTracking == 0) #expect(card.watermarkFontSize == 96) #expect(card.watermarkOffset == CGSize(width: 12, height: 10)) From 66ca085447eb0072c3befbdf8b79539d5ab22596 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 2 Aug 2026 08:36:58 -0700 Subject: [PATCH 22/28] Add debug card designer studio --- Where/WhereUI/AGENTS.md | 5 + Where/WhereUI/README.md | 7 + .../CardDesignerStudioViewSnapshotTests.swift | 10 + .../cardDesignerStudio.Default_iPhone.png | 3 + ...cardDesignerStudio.Default_iPhone_dark.png | 3 + .../CardDesignerArtworkControls.swift | 22 ++ .../CardDesignerArtworkLayerControls.swift | 66 +++++ .../CardDesignerCGFloatControl.swift | 25 ++ .../CardDesignerConfiguration.swift | 100 +++++--- .../CardDesignerDoubleControl.swift | 25 ++ .../CardDesignerEntryStampControls.swift | 135 +++++++++++ .../CardDesigner/CardDesignerExport.swift | 29 ++- .../CardDesignerExportSection.swift | 36 +++ ...DesignerFractionalTypographyControls.swift | 31 +++ .../CardDesignerMicroprintControls.swift | 34 +++ .../CardDesigner/CardDesignerModel.swift | 20 +- .../CardDesigner/CardDesignerPreview.swift | 44 ++++ .../CardDesignerSectionHeader.swift | 18 ++ .../CardDesignerSecurityPrintControls.swift | 29 +++ .../CardDesignerSharedControls.swift | 53 ++++ .../CardDesigner/CardDesignerStrings.swift | 226 ++++++++++++++++++ .../CardDesigner/CardDesignerStudioView.swift | 186 ++++++++++++++ .../CardDesignerTypographyControls.swift | 47 ++++ .../CardDesignerVariantControls.swift | 226 ++++++++++++++++++ .../Flyover/WhereFlyoverCatalog.swift | 1 + .../Settings/AppearanceSettingsView.swift | 42 ++++ .../CardDesignerConfigurationTests.swift | 3 +- .../Tests/CardDesignerStudioViewTests.swift | 21 ++ 28 files changed, 1402 insertions(+), 45 deletions(-) create mode 100644 Where/WhereUI/SnapshotTests/CardDesignerStudioViewSnapshotTests.swift create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/CardDesignerStudioViewSnapshotTests/cardDesignerStudio.Default_iPhone.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/CardDesignerStudioViewSnapshotTests/cardDesignerStudio.Default_iPhone_dark.png create mode 100644 Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerArtworkControls.swift create mode 100644 Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerArtworkLayerControls.swift create mode 100644 Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerCGFloatControl.swift create mode 100644 Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerDoubleControl.swift create mode 100644 Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerEntryStampControls.swift create mode 100644 Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExportSection.swift create mode 100644 Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerFractionalTypographyControls.swift create mode 100644 Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerMicroprintControls.swift create mode 100644 Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerPreview.swift create mode 100644 Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerSectionHeader.swift create mode 100644 Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerSecurityPrintControls.swift create mode 100644 Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerSharedControls.swift create mode 100644 Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerStrings.swift create mode 100644 Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerStudioView.swift create mode 100644 Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerTypographyControls.swift create mode 100644 Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerVariantControls.swift create mode 100644 Where/WhereUI/Tests/CardDesignerStudioViewTests.swift diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index 726be90d..1dec361e 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -22,6 +22,8 @@ and testing conventions live in the feature [`Where/AGENTS.md`](../AGENTS.md) - Keep the DEBUG Logs destination visible for every `WhereModel.logStoreState`; opening, unavailable, and failed stores are diagnostics to render, not reasons to hide the tool. +- Keep the DEBUG card designer's draft in one root-owned `CardDesignerModel`; + persist the draft, but leave its app-wide override disabled at every launch. - Flyover infrastructure stays under `#if DEBUG` in [`Sources/Developer/Flyover`](Sources/Developer/Flyover), while each represented screen declares a DEBUG-only `WhereFlyoverProviding` extension @@ -103,6 +105,9 @@ rules: `.accentColor` stay inline. - `WhereThemes` is deliberately empty — the seam a future app-wide theme plugs into. +- The DEBUG card designer may override only presentation values already owned + by `CardStyles`; it must not add a second production styling system or alter + count animation and outline-cache behavior. ## Testing diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index ba2be795..c08610f6 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -227,6 +227,13 @@ invalidate the card's text or Canvas artwork. The card adds no standalone edge stroke; its containing Liquid Glass surface owns the subtle outer border so direct and production rendering do not diverge. +DEBUG builds include Card Designer Studio under Settings → Appearance. It +edits a versioned, persisted draft of the regular, compact, and shared card +presentation, previews both appearances with live tilt, and exports the full +result as JSON or a Swift stylesheet snippet. The draft affects the rest of the +app only while “Apply to App” is enabled; that switch intentionally resets on +every launch. + ## Previews Every previewable component ships a `#Preview` (wrapped in `#if DEBUG`) built diff --git a/Where/WhereUI/SnapshotTests/CardDesignerStudioViewSnapshotTests.swift b/Where/WhereUI/SnapshotTests/CardDesignerStudioViewSnapshotTests.swift new file mode 100644 index 00000000..23c21643 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/CardDesignerStudioViewSnapshotTests.swift @@ -0,0 +1,10 @@ +import SnapshotKitTesting +import Testing +@testable import WhereUI + +@MainActor +struct CardDesignerStudioViewSnapshotTests { + @Test func cardDesignerStudio() async { + await assertSnapshots(of: CardDesignerStudioView.self) + } +} diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CardDesignerStudioViewSnapshotTests/cardDesignerStudio.Default_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CardDesignerStudioViewSnapshotTests/cardDesignerStudio.Default_iPhone.png new file mode 100644 index 00000000..c3dd2437 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CardDesignerStudioViewSnapshotTests/cardDesignerStudio.Default_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8f4490ab20dee33fdc4310c5d8ec7a914354d8b572ac74f28f125a774424606b +size 971547 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CardDesignerStudioViewSnapshotTests/cardDesignerStudio.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CardDesignerStudioViewSnapshotTests/cardDesignerStudio.Default_iPhone_dark.png new file mode 100644 index 00000000..af3b3e01 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CardDesignerStudioViewSnapshotTests/cardDesignerStudio.Default_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f0a4edd3f4ce78f226711724ffc643f92a6a79aad7cd32f3a0f38045a9422b3 +size 1034978 diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerArtworkControls.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerArtworkControls.swift new file mode 100644 index 00000000..c40ef6aa --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerArtworkControls.swift @@ -0,0 +1,22 @@ +#if DEBUG + import SwiftUI + + struct CardDesignerArtworkControls: View { + @Binding var usesRegionShape: Bool + @Binding var regionShape: CardDesignerConfiguration.RegionShape + + var body: some View { + Toggle(String(localized: .cardDesignerUseRegionOutline), isOn: $usesRegionShape) + if usesRegionShape { + CardDesignerArtworkLayerControls( + title: .cardDesignerWatermark, + artwork: $regionShape.watermark, + ) + CardDesignerArtworkLayerControls( + title: .cardDesignerStampArtwork, + artwork: $regionShape.stamp, + ) + } + } + } +#endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerArtworkLayerControls.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerArtworkLayerControls.swift new file mode 100644 index 00000000..97835d8e --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerArtworkLayerControls.swift @@ -0,0 +1,66 @@ +#if DEBUG + import SwiftUI + + struct CardDesignerArtworkLayerControls: View { + let title: LocalizedStringResource + @Binding var artwork: CardDesignerConfiguration.Artwork + + var body: some View { + DisclosureGroup { + CardDesignerCGFloatControl( + title: .cardDesignerCenterX, + value: $artwork.center.x, + range: 0 ... 1, + step: 0.01, + ) + CardDesignerCGFloatControl( + title: .cardDesignerCenterY, + value: $artwork.center.y, + range: 0 ... 1, + step: 0.01, + ) + CardDesignerCGFloatControl( + title: .cardDesignerExtentWidth, + value: $artwork.extent.width, + range: 0.1 ... 1, + step: 0.01, + ) + CardDesignerCGFloatControl( + title: .cardDesignerExtentHeight, + value: $artwork.extent.height, + range: 0.1 ... 1, + step: 0.01, + ) + CardDesignerCGFloatControl( + title: .cardDesignerScale, + value: $artwork.scale, + range: 0.1 ... 2, + step: 0.01, + ) + CardDesignerDoubleControl( + title: .cardDesignerFillOpacity, + value: $artwork.fillOpacity, + range: 0 ... 1, + step: 0.01, + ) + Toggle(String(localized: .cardDesignerShowStroke), isOn: $artwork.showsStroke) + if artwork.showsStroke { + CardDesignerDoubleControl( + title: .cardDesignerStrokeOpacity, + value: $artwork.stroke.opacity, + range: 0 ... 1, + step: 0.01, + ) + CardDesignerCGFloatControl( + title: .cardDesignerStrokeWidth, + value: $artwork.stroke.width, + range: 0 ... 6, + step: 0.1, + ) + } + } label: { + Text(title) + } + } + } +#endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerCGFloatControl.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerCGFloatControl.swift new file mode 100644 index 00000000..1ed82669 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerCGFloatControl.swift @@ -0,0 +1,25 @@ +#if DEBUG + import SwiftUI + + struct CardDesignerCGFloatControl: View { + let title: LocalizedStringResource + @Binding var value: CGFloat + let range: ClosedRange + let step: CGFloat + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + LabeledContent { + Text(Double(value), format: .number.precision(.fractionLength(0 ... 4))) + .monospacedDigit() + } label: { + Text(title) + } + Slider(value: $value, in: range, step: step) + } + .onChange(of: value) { _, newValue in + value = min(range.upperBound, max(range.lowerBound, newValue)) + } + } + } +#endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerConfiguration.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerConfiguration.swift index 9df2f656..21177c9d 100644 --- a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerConfiguration.swift +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerConfiguration.swift @@ -42,8 +42,22 @@ } init(styles: WhereStylesheet.CardStyles) { - regular = Card(styles.regular) - compact = Card(styles.compact) + guard + let fallbackRegionShape = styles.regular.regionShape, + let fallbackArc = styles.regular.entryStamp.arc + else { + preconditionFailure("The regular card must provide designer fallback artwork.") + } + regular = Card( + styles.regular, + fallbackRegionShape: fallbackRegionShape, + fallbackArc: fallbackArc, + ) + compact = Card( + styles.compact, + fallbackRegionShape: fallbackRegionShape, + fallbackArc: fallbackArc, + ) shared = Shared(styles) } @@ -77,25 +91,31 @@ var dayUnitTypography: Typography var watermarkFontSize: CGFloat var watermarkOffset: Offset - var regionShape: RegionShape? + var usesRegionShape: Bool + var regionShape: RegionShape var sheen: Sheen var rosette: Rosette var glow: Shadow var lift: Shadow - init(_ style: WhereStylesheet.CardStyle) { + init( + _ style: WhereStylesheet.CardStyle, + fallbackRegionShape: WhereStylesheet.CardStyle.RegionShape, + fallbackArc: WhereStylesheet.CardStyle.EntryStamp.Arc, + ) { cornerRadius = style.cornerRadius padding = style.padding contentSpacing = style.contentSpacing progressBarHeight = style.progressBarHeight - entryStamp = EntryStamp(style.entryStamp) + entryStamp = EntryStamp(style.entryStamp, fallbackArc: fallbackArc) regionNameTypography = Typography(style.regionNameTypography) regionNameTracking = style.regionNameTracking heroNumberTypography = Typography(style.heroNumberTypography) dayUnitTypography = Typography(style.dayUnitTypography) watermarkFontSize = style.watermarkFontSize watermarkOffset = Offset(style.watermarkOffset) - regionShape = style.regionShape.map(RegionShape.init) + usesRegionShape = style.regionShape != nil + regionShape = RegionShape(style.regionShape ?? fallbackRegionShape) sheen = Sheen(style.sheen) rosette = Rosette(style.rosette) glow = Shadow(style.glow) @@ -115,7 +135,7 @@ dayUnitTypography: dayUnitTypography.style, watermarkFontSize: watermarkFontSize, watermarkOffset: watermarkOffset.size, - regionShape: regionShape?.style, + regionShape: usesRegionShape ? regionShape.style : nil, sheen: sheen.style, rosette: rosette.style, glow: glow.style, @@ -153,14 +173,22 @@ } struct Typography: Codable, Equatable { - var size: Size + var sizeMode: SizeMode + var fixedSize: CGFloat + var textStyle: TextStyle var weight: FontWeight var design: FontDesign init(_ typography: WhereStylesheet.CardStyle.Typography) { switch typography.size { - case let .fixed(points): size = .fixed(points) - case let .semantic(textStyle): size = .semantic(TextStyle(textStyle)) + case let .fixed(points): + sizeMode = .fixed + fixedSize = points + textStyle = .body + case let .semantic(textStyle): + sizeMode = .semantic + fixedSize = 17 + self.textStyle = TextStyle(textStyle) } weight = FontWeight(typography.weight) design = FontDesign(typography.design) @@ -168,25 +196,25 @@ var style: WhereStylesheet.CardStyle.Typography { .init( - size: size.style, + size: size, weight: weight.style, design: design.style, ) } - enum Size: Codable, Equatable { - case fixed(CGFloat) - case semantic(TextStyle) - - var style: WhereStylesheet.CardStyle.Typography.Size { - switch self { - case let .fixed(points): .fixed(points) - case let .semantic(textStyle): .semantic(textStyle.style) - } + private var size: WhereStylesheet.CardStyle.Typography.Size { + switch sizeMode { + case .fixed: .fixed(fixedSize) + case .semantic: .semantic(textStyle.style) } } } + enum SizeMode: String, CaseIterable, Codable { + case fixed + case semantic + } + enum TextStyle: String, CaseIterable, Codable { case caption2 case caption @@ -257,15 +285,20 @@ var outerRing: Ring var innerRing: DashedRing var content: StampContent - var arc: Arc? + var showsArc: Bool + var arc: Arc var rotationDegrees: Double - init(_ style: WhereStylesheet.CardStyle.EntryStamp) { + init( + _ style: WhereStylesheet.CardStyle.EntryStamp, + fallbackArc: WhereStylesheet.CardStyle.EntryStamp.Arc, + ) { size = style.size outerRing = Ring(style.outerRing) innerRing = DashedRing(style.innerRing) content = StampContent(style.content) - arc = style.arc.map(Arc.init) + showsArc = style.arc != nil + arc = Arc(style.arc ?? fallbackArc) rotationDegrees = style.rotationDegrees } @@ -275,7 +308,7 @@ outerRing: outerRing.style, innerRing: innerRing.style, content: content.style, - arc: arc?.style, + arc: showsArc ? arc.style : nil, rotationDegrees: rotationDegrees, ) } @@ -401,8 +434,10 @@ var securityBorder: SecurityBorder init(_ style: WhereStylesheet.CardStyle.RegionShape) { - watermark = Artwork(style.watermark) - stamp = Artwork(style.stamp) + let fallbackStroke = style.watermark.stroke + ?? .init(opacity: 0.25, width: 1) + watermark = Artwork(style.watermark, fallbackStroke: fallbackStroke) + stamp = Artwork(style.stamp, fallbackStroke: fallbackStroke) securityBorder = SecurityBorder(style.securityBorder) } @@ -420,14 +455,19 @@ var extent: Dimensions var scale: CGFloat var fillOpacity: Double - var stroke: Stroke? + var showsStroke: Bool + var stroke: Stroke - init(_ style: WhereStylesheet.CardStyle.RegionShape.Artwork) { + init( + _ style: WhereStylesheet.CardStyle.RegionShape.Artwork, + fallbackStroke: WhereStylesheet.CardStyle.RegionShape.Artwork.Stroke, + ) { center = Point(style.center) extent = Dimensions(style.extent) scale = style.scale fillOpacity = style.fillOpacity - stroke = style.stroke.map(Stroke.init) + showsStroke = style.stroke != nil + stroke = Stroke(style.stroke ?? fallbackStroke) } var style: WhereStylesheet.CardStyle.RegionShape.Artwork { @@ -436,7 +476,7 @@ extent: extent.size, scale: scale, fillOpacity: fillOpacity, - stroke: stroke?.style, + stroke: showsStroke ? stroke.style : nil, ) } } diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerDoubleControl.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerDoubleControl.swift new file mode 100644 index 00000000..81620bc9 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerDoubleControl.swift @@ -0,0 +1,25 @@ +#if DEBUG + import SwiftUI + + struct CardDesignerDoubleControl: View { + let title: LocalizedStringResource + @Binding var value: Double + let range: ClosedRange + let step: Double + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + LabeledContent { + Text(value, format: .number.precision(.fractionLength(0 ... 4))) + .monospacedDigit() + } label: { + Text(title) + } + Slider(value: $value, in: range, step: step) + } + .onChange(of: value) { _, newValue in + value = min(range.upperBound, max(range.lowerBound, newValue)) + } + } + } +#endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerEntryStampControls.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerEntryStampControls.swift new file mode 100644 index 00000000..2bc01410 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerEntryStampControls.swift @@ -0,0 +1,135 @@ +#if DEBUG + import SwiftUI + + struct CardDesignerEntryStampControls: View { + @Binding var stamp: CardDesignerConfiguration.EntryStamp + + var body: some View { + CardDesignerCGFloatControl( + title: .cardDesignerStampSize, + value: $stamp.size, + range: 32 ... 140, + step: 1, + ) + CardDesignerDoubleControl( + title: .cardDesignerRotation, + value: $stamp.rotationDegrees, + range: -30 ... 30, + step: 1, + ) + DisclosureGroup(String(localized: .cardDesignerOuterRing)) { + CardDesignerDoubleControl( + title: .cardDesignerOpacity, + value: $stamp.outerRing.opacity, + range: 0 ... 1, + step: 0.01, + ) + CardDesignerCGFloatControl( + title: .cardDesignerLineWidthFraction, + value: $stamp.outerRing.lineWidthFraction, + range: 0 ... 0.1, + step: 0.001, + ) + } + DisclosureGroup(String(localized: .cardDesignerInnerRing)) { + CardDesignerDoubleControl( + title: .cardDesignerOpacity, + value: $stamp.innerRing.opacity, + range: 0 ... 1, + step: 0.01, + ) + CardDesignerCGFloatControl( + title: .cardDesignerLineWidthFraction, + value: $stamp.innerRing.lineWidthFraction, + range: 0 ... 0.1, + step: 0.001, + ) + CardDesignerCGFloatControl( + title: .cardDesignerDashLength, + value: $stamp.innerRing.dashLengthFraction, + range: 0 ... 0.2, + step: 0.005, + ) + CardDesignerCGFloatControl( + title: .cardDesignerDashSpacing, + value: $stamp.innerRing.dashSpacingFraction, + range: 0 ... 0.2, + step: 0.005, + ) + CardDesignerCGFloatControl( + title: .cardDesignerInsetFraction, + value: $stamp.innerRing.insetFraction, + range: 0 ... 0.4, + step: 0.01, + ) + } + DisclosureGroup(String(localized: .cardDesignerStampContent)) { + CardDesignerCGFloatControl( + title: .cardDesignerSpacingFraction, + value: $stamp.content.spacingFraction, + range: 0 ... 0.2, + step: 0.005, + ) + CardDesignerCGFloatControl( + title: .cardDesignerArtworkWidth, + value: $stamp.content.artworkExtent.width, + range: 0.1 ... 1, + step: 0.01, + ) + CardDesignerCGFloatControl( + title: .cardDesignerArtworkHeight, + value: $stamp.content.artworkExtent.height, + range: 0.1 ... 1, + step: 0.01, + ) + CardDesignerDoubleControl( + title: .cardDesignerOpacity, + value: $stamp.content.opacity, + range: 0 ... 1, + step: 0.01, + ) + CardDesignerFractionalTypographyControls( + title: .cardDesignerSymbolTypography, + typography: $stamp.content.symbolFont, + ) + CardDesignerFractionalTypographyControls( + title: .cardDesignerYearTypography, + typography: $stamp.content.yearFont, + ) + } + Toggle(String(localized: .cardDesignerShowArc), isOn: $stamp.showsArc) + if stamp.showsArc { + DisclosureGroup(String(localized: .cardDesignerArc)) { + CardDesignerCGFloatControl( + title: .cardDesignerRadiusFraction, + value: $stamp.arc.radiusFraction, + range: 0.1 ... 0.6, + step: 0.01, + ) + CardDesignerDoubleControl( + title: .cardDesignerOpacity, + value: $stamp.arc.opacity, + range: 0 ... 1, + step: 0.01, + ) + CardDesignerDoubleControl( + title: .cardDesignerMaximumSweep, + value: $stamp.arc.maximumSweepDegrees, + range: 0 ... 360, + step: 1, + ) + CardDesignerDoubleControl( + title: .cardDesignerSweepPerCharacter, + value: $stamp.arc.sweepDegreesPerCharacter, + range: 1 ... 30, + step: 0.5, + ) + CardDesignerFractionalTypographyControls( + title: .cardDesignerArcTypography, + typography: $stamp.arc.font, + ) + } + } + } + } +#endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExport.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExport.swift index 53a09678..1a572588 100644 --- a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExport.swift +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExport.swift @@ -4,11 +4,15 @@ import UniformTypeIdentifiers struct CardDesignerJSONExport: Transferable { - let data: Data + let configuration: CardDesignerConfiguration static var transferRepresentation: some TransferRepresentation { - DataRepresentation(exportedContentType: .json) { $0.data } - .suggestedFileName("Where Card Design.json") + DataRepresentation(exportedContentType: .json) { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + return try encoder.encode($0.configuration) + } + .suggestedFileName("Where Card Design.json") } } @@ -61,7 +65,10 @@ watermarkFontSize: \(number(card.watermarkFontSize)), watermarkOffset: CGSize(width: \(number(card.watermarkOffset .x)), height: \(number(card.watermarkOffset.y))), - regionShape: \(regionShape(card.regionShape, indent: 1)), + regionShape: \(regionShape( + card.usesRegionShape ? card.regionShape : nil, + indent: 1, + )), sheen: .init( intensity: \(number(card.sheen.intensity)), staticGlintIntensity: \(number(card.sheen.staticGlintIntensity)), @@ -110,9 +117,9 @@ private static func typography(_ typography: CardDesignerConfiguration .Typography) -> String { - let size = switch typography.size { - case let .fixed(points): ".fixed(\(number(points)))" - case let .semantic(style): ".semantic(.\(style.rawValue))" + let size = switch typography.sizeMode { + case .fixed: ".fixed(\(number(typography.fixedSize)))" + case .semantic: ".semantic(.\(typography.textStyle.rawValue))" } return ".init(size: \(size), weight: .\(typography.weight.rawValue), design: .\(typography.design.rawValue))" } @@ -121,7 +128,7 @@ _ stamp: CardDesignerConfiguration.EntryStamp, indent: Int, ) -> String { - let arc = stamp.arc.map { arc($0, indent: indent + 1) } ?? "nil" + let arc = stamp.showsArc ? arc(stamp.arc, indent: indent + 1) : "nil" return """ .init( \(spaces(indent + 1))size: \(number(stamp.size)), @@ -193,9 +200,9 @@ _ artwork: CardDesignerConfiguration.Artwork, indent: Int, ) -> String { - let stroke = artwork.stroke.map { - ".init(opacity: \(number($0.opacity)), width: \(number($0.width)))" - } ?? "nil" + let stroke = artwork.showsStroke + ? ".init(opacity: \(number(artwork.stroke.opacity)), width: \(number(artwork.stroke.width)))" + : "nil" return """ .init( \(spaces(indent + 1))center: CGPoint(x: \(number(artwork.center.x)), y: \(number(artwork diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExportSection.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExportSection.swift new file mode 100644 index 00000000..9197b332 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExportSection.swift @@ -0,0 +1,36 @@ +#if DEBUG + import SwiftUI + + struct CardDesignerExportSection: View { + let configuration: CardDesignerConfiguration + + var body: some View { + Section { + ShareLink( + item: CardDesignerJSONExport(configuration: configuration), + preview: SharePreview(String(localized: .cardDesignerJSONExport)), + ) { + Label( + String(localized: .cardDesignerJSONExport), + systemImage: "doc.badge.gearshape", + ) + } + ShareLink( + item: CardDesignerSwiftExport( + source: CardDesignerSwiftExporter.source(for: configuration), + ), + preview: SharePreview(String(localized: .cardDesignerSwiftExport)), + ) { + Label( + String(localized: .cardDesignerSwiftExport), + systemImage: "swift", + ) + } + } header: { + Text(String(localized: .cardDesignerExport)) + } footer: { + Text(String(localized: .cardDesignerExportFooter)) + } + } + } +#endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerFractionalTypographyControls.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerFractionalTypographyControls.swift new file mode 100644 index 00000000..c05d2069 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerFractionalTypographyControls.swift @@ -0,0 +1,31 @@ +#if DEBUG + import SwiftUI + + struct CardDesignerFractionalTypographyControls: View { + let title: LocalizedStringResource + @Binding var typography: CardDesignerConfiguration.FractionalTypography + + var body: some View { + DisclosureGroup { + CardDesignerCGFloatControl( + title: .cardDesignerSizeFraction, + value: $typography.sizeFraction, + range: 0.02 ... 0.6, + step: 0.01, + ) + Picker(String(localized: .cardDesignerWeight), selection: $typography.weight) { + ForEach(CardDesignerConfiguration.FontWeight.allCases, id: \.self) { weight in + Text(weight.localizedName).tag(weight) + } + } + Picker(String(localized: .cardDesignerDesign), selection: $typography.design) { + ForEach(CardDesignerConfiguration.FontDesign.allCases, id: \.self) { design in + Text(design.localizedName).tag(design) + } + } + } label: { + Text(title) + } + } + } +#endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerMicroprintControls.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerMicroprintControls.swift new file mode 100644 index 00000000..436558eb --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerMicroprintControls.swift @@ -0,0 +1,34 @@ +#if DEBUG + import SwiftUI + + struct CardDesignerMicroprintControls: View { + @Binding var border: CardDesignerConfiguration.SecurityBorder + + var body: some View { + CardDesignerCGFloatControl( + title: .cardDesignerInset, + value: $border.inset, + range: 0 ... 30, + step: 0.5, + ) + CardDesignerCGFloatControl( + title: .cardDesignerGlyphSize, + value: $border.glyphSize, + range: 2 ... 24, + step: 0.5, + ) + CardDesignerCGFloatControl( + title: .cardDesignerSpacing, + value: $border.spacing, + range: 2 ... 32, + step: 0.5, + ) + CardDesignerDoubleControl( + title: .cardDesignerOpacity, + value: $border.opacity, + range: 0 ... 1, + step: 0.01, + ) + } + } +#endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerModel.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerModel.swift index 4ee9af87..ace221a0 100644 --- a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerModel.swift +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerModel.swift @@ -30,6 +30,14 @@ var appliesToApp = false var persistenceError: String? + var isShowingPersistenceError: Bool { + get { persistenceError != nil } + set { + guard newValue == false else { return } + persistenceError = nil + } + } + private let store: UserDefaults private let key: String @@ -44,6 +52,13 @@ } } + init(configuration: CardDesignerConfiguration) { + store = .standard + key = "where.debug.card-designer.preview" + self.configuration = configuration + appliesToApp = false + } + func resetAll() { configuration = .standard } @@ -76,11 +91,10 @@ case .entryStamp: card.entryStamp = standardCard.entryStamp case .regionArtwork: + card.usesRegionShape = standardCard.usesRegionShape card.regionShape = standardCard.regionShape case .microprint: - guard let standardBorder = standardCard.regionShape?.securityBorder - else { return } - card.regionShape?.securityBorder = standardBorder + card.regionShape.securityBorder = standardCard.regionShape.securityBorder case .rosettes: card.rosette = standardCard.rosette configuration.shared.primaryRosetteOpacity = standard.shared diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerPreview.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerPreview.swift new file mode 100644 index 00000000..c17cf79a --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerPreview.swift @@ -0,0 +1,44 @@ +#if DEBUG + import RegionKit + import SwiftUI + import WhereCore + + struct CardDesignerPreview: View { + let configuration: CardDesignerConfiguration + let variant: CardDesignerConfiguration.Variant + let colorScheme: ColorScheme + let region: Region + let color: RegionColorToken + let days: Int + let year: Int + let tilt: TiltProvider + + var body: some View { + GlassEffectContainer(spacing: 16) { + Button(action: {}) { + RegionSummaryCard( + regionDays: RegionDays(region: region, days: days), + caption: String(localized: .cardDesignerPreviewCaption), + variant: variant.style, + interactive: true, + year: year, + tilt: tilt, + styleOverride: previewStyle, + ) + } + .buttonStyle(.plain) + } + .environment(\.cardDesignerConfiguration, configuration) + .environment(\.colorScheme, colorScheme) + } + + private var previewStyle: RegionStyle { + let fallback = RegionAppearanceCatalog.defaultAppearance(for: region) + return RegionStyle( + symbolName: fallback.symbolName, + emoji: fallback.emoji, + tint: color.color, + ) + } + } +#endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerSectionHeader.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerSectionHeader.swift new file mode 100644 index 00000000..589785cd --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerSectionHeader.swift @@ -0,0 +1,18 @@ +#if DEBUG + import SwiftUI + + struct CardDesignerSectionHeader: View { + let title: LocalizedStringResource + let reset: () -> Void + + var body: some View { + HStack { + Text(title) + Spacer() + Button(String(localized: .cardDesignerReset), action: reset) + .textCase(nil) + .font(.caption) + } + } + } +#endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerSecurityPrintControls.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerSecurityPrintControls.swift new file mode 100644 index 00000000..81809523 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerSecurityPrintControls.swift @@ -0,0 +1,29 @@ +#if DEBUG + import SwiftUI + + struct CardDesignerSecurityPrintControls: View { + let title: LocalizedStringResource + @Binding var securityPrint: CardDesignerConfiguration.SecurityPrint + + var body: some View { + DisclosureGroup { + CardDesignerDoubleControl( + title: .cardDesignerWhiteMix, + value: $securityPrint.whiteMix, + range: 0 ... 1, + step: 0.01, + ) + Picker( + String(localized: .cardDesignerBlendMode), + selection: $securityPrint.blendMode, + ) { + ForEach(CardDesignerBlendMode.allCases, id: \.self) { blendMode in + Text(blendMode.localizedName).tag(blendMode) + } + } + } label: { + Text(title) + } + } + } +#endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerSharedControls.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerSharedControls.swift new file mode 100644 index 00000000..b5e52727 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerSharedControls.swift @@ -0,0 +1,53 @@ +#if DEBUG + import SwiftUI + + struct CardDesignerSharedControls: View { + @Binding var shared: CardDesignerConfiguration.Shared + let reset: () -> Void + + var body: some View { + Section { + CardDesignerDoubleControl( + title: .cardDesignerWatermarkOpacity, + value: $shared.watermarkOpacity, + range: 0 ... 1, + step: 0.01, + ) + CardDesignerDoubleControl( + title: .cardDesignerGlassTintOpacity, + value: $shared.glassTintOpacity, + range: 0 ... 1, + step: 0.01, + ) + CardDesignerDoubleControl( + title: .cardDesignerNameOpacity, + value: $shared.nameOpacity, + range: 0 ... 1, + step: 0.01, + ) + CardDesignerDoubleControl( + title: .cardDesignerPrimaryRosetteOpacity, + value: $shared.primaryRosetteOpacity, + range: 0 ... 1, + step: 0.01, + ) + CardDesignerDoubleControl( + title: .cardDesignerSecondaryRosetteOpacity, + value: $shared.secondaryRosetteOpacity, + range: 0 ... 1, + step: 0.01, + ) + CardDesignerSecurityPrintControls( + title: .cardDesignerLightInk, + securityPrint: $shared.lightSecurityPrint, + ) + CardDesignerSecurityPrintControls( + title: .cardDesignerDarkInk, + securityPrint: $shared.darkSecurityPrint, + ) + } header: { + CardDesignerSectionHeader(title: .cardDesignerGlassAndInk, reset: reset) + } + } + } +#endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerStrings.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerStrings.swift new file mode 100644 index 00000000..8b7a6a25 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerStrings.swift @@ -0,0 +1,226 @@ +#if DEBUG + import Foundation + + extension LocalizedStringResource { + static let cardDesignerAppearance = LocalizedStringResource("Appearance") + static let cardDesignerApplyFooter = + LocalizedStringResource( + "When enabled, changes appear on every location card until you turn this off or reset the studio.", + ) + static let cardDesignerApplyToApp = LocalizedStringResource("Apply to App") + static let cardDesignerArc = LocalizedStringResource("Curved Label") + static let cardDesignerArcTypography = LocalizedStringResource("Curved Label Type") + static let cardDesignerArtworkHeight = LocalizedStringResource("Artwork Height") + static let cardDesignerArtworkWidth = LocalizedStringResource("Artwork Width") + static let cardDesignerBlendMode = LocalizedStringResource("Blend Mode") + static let cardDesignerCard = LocalizedStringResource("Card Geometry") + static let cardDesignerCenterX = LocalizedStringResource("Horizontal Center") + static let cardDesignerCenterY = LocalizedStringResource("Vertical Center") + static let cardDesignerColor = LocalizedStringResource("Color") + static let cardDesignerContentSpacing = LocalizedStringResource("Content Spacing") + static let cardDesignerCornerRadius = LocalizedStringResource("Corner Radius") + static let cardDesignerDark = LocalizedStringResource("Dark") + static let cardDesignerDarkInk = LocalizedStringResource("Dark Appearance Ink") + static let cardDesignerDashLength = LocalizedStringResource("Dash Length") + static let cardDesignerDashSpacing = LocalizedStringResource("Dash Spacing") + static let cardDesignerDayUnit = LocalizedStringResource("Day Unit") + static let cardDesignerDesign = LocalizedStringResource("Design") + static let cardDesignerEntryStamp = LocalizedStringResource("Entry Stamp") + static let cardDesignerExport = LocalizedStringResource("Export") + static let cardDesignerExportFooter = + LocalizedStringResource( + "Share the complete configuration as JSON, or a Swift snippet ready to bring back into the stylesheet.", + ) + static let cardDesignerExtentHeight = LocalizedStringResource("Extent Height") + static let cardDesignerExtentWidth = LocalizedStringResource("Extent Width") + static let cardDesignerFallbackWatermarkSize = + LocalizedStringResource("Fallback Watermark Size") + static let cardDesignerFillOpacity = LocalizedStringResource("Fill Opacity") + static let cardDesignerGlassAndInk = LocalizedStringResource("Glass and Security Ink") + static let cardDesignerGlassTintOpacity = LocalizedStringResource("Glass Tint Opacity") + static let cardDesignerGlow = LocalizedStringResource("Glow") + static let cardDesignerGlyphSize = LocalizedStringResource("Glyph Size") + static let cardDesignerHeroNumber = LocalizedStringResource("Day Count") + static let cardDesignerInnerRing = LocalizedStringResource("Inner Ring") + static let cardDesignerInset = LocalizedStringResource("Inset") + static let cardDesignerInsetFraction = LocalizedStringResource("Inset Fraction") + static let cardDesignerIntensity = LocalizedStringResource("Intensity") + static let cardDesignerJSONExport = LocalizedStringResource("Card Design.json") + static let cardDesignerLift = LocalizedStringResource("Lift Shadow") + static let cardDesignerLight = LocalizedStringResource("Light") + static let cardDesignerLightInk = LocalizedStringResource("Light Appearance Ink") + static let cardDesignerLineWidth = LocalizedStringResource("Line Width") + static let cardDesignerLineWidthFraction = LocalizedStringResource("Line Width Fraction") + static let cardDesignerMaximumSweep = LocalizedStringResource("Maximum Sweep") + static let cardDesignerMicroprint = LocalizedStringResource("Microprint Border") + static let cardDesignerNameOpacity = LocalizedStringResource("Name Opacity") + static let cardDesignerOffsetY = LocalizedStringResource("Vertical Offset") + static let cardDesignerOpacity = LocalizedStringResource("Opacity") + static let cardDesignerOuterRing = LocalizedStringResource("Outer Ring") + static let cardDesignerPadding = LocalizedStringResource("Padding") + static let cardDesignerPersistenceErrorTitle = + LocalizedStringResource("Couldn't Save Card Design") + static let cardDesignerPointSize = LocalizedStringResource("Point Size") + static let cardDesignerPreview = LocalizedStringResource("Preview") + static let cardDesignerPreviewCaption = LocalizedStringResource("Your days in this region") + static let cardDesignerPrimaryRingSpacing = LocalizedStringResource("Primary Ring Spacing") + static let cardDesignerPrimaryRosetteOpacity = + LocalizedStringResource("Primary Rosette Opacity") + static let cardDesignerProgressHeight = LocalizedStringResource("Progress Height") + static let cardDesignerRadius = LocalizedStringResource("Radius") + static let cardDesignerRadiusFraction = LocalizedStringResource("Radius Fraction") + static let cardDesignerRegion = LocalizedStringResource("Region") + static let cardDesignerRegionArtwork = LocalizedStringResource("Region Artwork") + static let cardDesignerRegionName = LocalizedStringResource("Region Name") + static let cardDesignerReset = LocalizedStringResource("Reset") + static let cardDesignerResetAll = LocalizedStringResource("Reset Everything") + static let cardDesignerResetAllMessage = + LocalizedStringResource( + "This restores every regular, compact, and shared card setting to the app defaults.", + ) + static let cardDesignerResetAllTitle = LocalizedStringResource("Reset Card Designer?") + static let cardDesignerResetShared = LocalizedStringResource("Reset Shared Settings") + static let cardDesignerResetVariant = LocalizedStringResource("Reset This Variant") + static let cardDesignerRosettes = LocalizedStringResource("Rosettes") + static let cardDesignerRotation = LocalizedStringResource("Rotation") + static let cardDesignerScale = LocalizedStringResource("Scale") + static let cardDesignerSecondaryRingSpacing = + LocalizedStringResource("Secondary Ring Spacing") + static let cardDesignerSecondaryRosetteOpacity = + LocalizedStringResource("Secondary Rosette Opacity") + static let cardDesignerShadows = LocalizedStringResource("Shadows") + static let cardDesignerSheen = LocalizedStringResource("Motion Sheen") + static let cardDesignerShowArc = LocalizedStringResource("Show Curved Label") + static let cardDesignerShowStroke = LocalizedStringResource("Show Stroke") + static let cardDesignerSizeFraction = LocalizedStringResource("Size Fraction") + static let cardDesignerSizeMode = LocalizedStringResource("Size Mode") + static let cardDesignerSpacing = LocalizedStringResource("Spacing") + static let cardDesignerSpacingFraction = LocalizedStringResource("Spacing Fraction") + static let cardDesignerStampArtwork = LocalizedStringResource("Stamp Artwork") + static let cardDesignerStampContent = LocalizedStringResource("Stamp Content") + static let cardDesignerStampSize = LocalizedStringResource("Stamp Size") + static let cardDesignerStaticGlint = LocalizedStringResource("Fallback Glint") + static let cardDesignerStaticPitch = LocalizedStringResource("Fallback Pitch") + static let cardDesignerStaticRoll = LocalizedStringResource("Fallback Roll") + static let cardDesignerStrokeOpacity = LocalizedStringResource("Stroke Opacity") + static let cardDesignerStrokeWidth = LocalizedStringResource("Stroke Width") + static let cardDesignerSweepPerCharacter = LocalizedStringResource("Sweep Per Character") + static let cardDesignerSwiftExport = LocalizedStringResource("Card Design.swift") + static let cardDesignerSymbolTypography = LocalizedStringResource("Symbol Type") + static let cardDesignerTextStyle = LocalizedStringResource("Text Style") + static let cardDesignerTitle = LocalizedStringResource("Card Designer Studio") + static let cardDesignerTracking = LocalizedStringResource("Tracking") + static let cardDesignerTypography = LocalizedStringResource("Typography") + static let cardDesignerUseRegionOutline = LocalizedStringResource("Use Region Outline") + static let cardDesignerVariant = LocalizedStringResource("Variant") + static let cardDesignerWatermark = LocalizedStringResource("Watermark") + static let cardDesignerWatermarkOffsetX = + LocalizedStringResource("Watermark Horizontal Offset") + static let cardDesignerWatermarkOffsetY = + LocalizedStringResource("Watermark Vertical Offset") + static let cardDesignerWatermarkOpacity = LocalizedStringResource("Watermark Opacity") + static let cardDesignerWeight = LocalizedStringResource("Weight") + static let cardDesignerWhiteMix = LocalizedStringResource("White Mix") + static let cardDesignerWobble = LocalizedStringResource("Wobble") + static let cardDesignerYearTypography = LocalizedStringResource("Year Type") + + static func cardDesignerDays(_ value: Int) -> LocalizedStringResource { + LocalizedStringResource("Days: \(value)") + } + + static func cardDesignerYear(_ value: Int) -> LocalizedStringResource { + LocalizedStringResource("Year: \(value)") + } + } + + extension CardDesignerConfiguration.Variant { + var localizedName: String { + switch self { + case .regular: String(localized: "Regular") + case .compact: String(localized: "Compact") + } + } + } + + extension CardDesignerConfiguration.SizeMode { + var localizedName: String { + switch self { + case .fixed: String(localized: "Fixed") + case .semantic: String(localized: "Dynamic Type") + } + } + } + + extension CardDesignerConfiguration.TextStyle { + var localizedName: String { + switch self { + case .caption2: String(localized: "Caption 2") + case .caption: String(localized: "Caption") + case .footnote: String(localized: "Footnote") + case .subheadline: String(localized: "Subheadline") + case .callout: String(localized: "Callout") + case .body: String(localized: "Body") + case .headline: String(localized: "Headline") + case .title3: String(localized: "Title 3") + case .title2: String(localized: "Title 2") + case .title: String(localized: "Title") + case .largeTitle: String(localized: "Large Title") + } + } + } + + extension CardDesignerConfiguration.FontWeight { + var localizedName: String { + switch self { + case .ultraLight: String(localized: "Ultra Light") + case .thin: String(localized: "Thin") + case .light: String(localized: "Light") + case .regular: String(localized: "Regular") + case .medium: String(localized: "Medium") + case .semibold: String(localized: "Semibold") + case .bold: String(localized: "Bold") + case .heavy: String(localized: "Heavy") + case .black: String(localized: "Black") + } + } + } + + extension CardDesignerConfiguration.FontDesign { + var localizedName: String { + switch self { + case .default: String(localized: "Default") + case .serif: String(localized: "Serif") + case .rounded: String(localized: "Rounded") + case .monospaced: String(localized: "Monospaced") + } + } + } + + extension CardDesignerBlendMode { + var localizedName: String { + switch self { + case .normal: String(localized: "Normal") + case .multiply: String(localized: "Multiply") + case .screen: String(localized: "Screen") + case .overlay: String(localized: "Overlay") + case .darken: String(localized: "Darken") + case .lighten: String(localized: "Lighten") + case .colorDodge: String(localized: "Color Dodge") + case .colorBurn: String(localized: "Color Burn") + case .softLight: String(localized: "Soft Light") + case .hardLight: String(localized: "Hard Light") + case .difference: String(localized: "Difference") + case .exclusion: String(localized: "Exclusion") + case .hue: String(localized: "Hue") + case .saturation: String(localized: "Saturation") + case .color: String(localized: "Color") + case .luminosity: String(localized: "Luminosity") + case .sourceAtop: String(localized: "Source Atop") + case .destinationOver: String(localized: "Destination Over") + case .destinationOut: String(localized: "Destination Out") + case .plusDarker: String(localized: "Plus Darker") + case .plusLighter: String(localized: "Plus Lighter") + } + } + } +#endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerStudioView.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerStudioView.swift new file mode 100644 index 00000000..58648898 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerStudioView.swift @@ -0,0 +1,186 @@ +#if DEBUG + import RegionKit + import SnapshotKit + import SwiftUI + import WhereCore + + struct CardDesignerStudioView: View { + let model: CardDesignerModel + + @State private var variant = CardDesignerConfiguration.Variant.regular + @State private var previewColorScheme = ColorScheme.light + @State private var previewRegion = Region.newYork + @State private var previewColor = RegionColorToken.indigo + @State private var previewDays = 128 + @State private var previewYear = 2026 + @State private var isConfirmingResetAll = false + @State private var tilt = TiltProvider() + + var body: some View { + @Bindable var model = model + VStack(spacing: 0) { + CardDesignerPreview( + configuration: model.configuration, + variant: variant, + colorScheme: previewColorScheme, + region: previewRegion, + color: previewColor, + days: previewDays, + year: previewYear, + tilt: tilt, + ) + .padding() + .background(.background) + + Divider() + + Form { + Section { + Picker(String(localized: .cardDesignerVariant), selection: $variant) { + ForEach( + CardDesignerConfiguration.Variant.allCases, + id: \.self, + ) { variant in + Text(variant.localizedName).tag(variant) + } + } + .pickerStyle(.segmented) + + Picker( + String(localized: .cardDesignerAppearance), + selection: $previewColorScheme, + ) { + Text(String(localized: .cardDesignerLight)).tag(ColorScheme.light) + Text(String(localized: .cardDesignerDark)).tag(ColorScheme.dark) + } + .pickerStyle(.segmented) + + Picker(String(localized: .cardDesignerRegion), selection: $previewRegion) { + ForEach(RegionCatalog.shared.all, id: \.self) { region in + Text(region.localizedName).tag(region) + } + } + + Picker(String(localized: .cardDesignerColor), selection: $previewColor) { + ForEach(RegionAppearanceCatalog.colors, id: \.self) { color in + Label { + Text(WhereFormat.regionColorAccessibility(color)) + } icon: { + Circle().fill(color.color) + } + .tag(color) + } + } + + Stepper( + String(localized: .cardDesignerDays(previewDays)), + value: $previewDays, + in: 0 ... 366, + ) + Stepper( + String(localized: .cardDesignerYear(previewYear)), + value: $previewYear, + in: 1900 ... 2200, + ) + Toggle( + String(localized: .cardDesignerApplyToApp), + isOn: $model.appliesToApp, + ) + } header: { + Text(String(localized: .cardDesignerPreview)) + } footer: { + Text(String(localized: .cardDesignerApplyFooter)) + } + + switch variant { + case .regular: + CardDesignerVariantControls( + card: $model.configuration.regular, + reset: { model.reset($0, variant: .regular) }, + ) + case .compact: + CardDesignerVariantControls( + card: $model.configuration.compact, + reset: { model.reset($0, variant: .compact) }, + ) + } + + CardDesignerSharedControls( + shared: $model.configuration.shared, + reset: model.resetShared, + ) + CardDesignerExportSection(configuration: model.configuration) + } + } + .navigationTitle(String(localized: .cardDesignerTitle)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu( + String(localized: .cardDesignerReset), + systemImage: "arrow.counterclockwise", + ) { + Button(String(localized: .cardDesignerResetVariant)) { + model.reset(variant) + } + Button( + String(localized: .cardDesignerResetShared), + action: model.resetShared, + ) + Button(String(localized: .cardDesignerResetAll), role: .destructive) { + isConfirmingResetAll = true + } + } + } + } + .confirmationDialog( + String(localized: .cardDesignerResetAllTitle), + isPresented: $isConfirmingResetAll, + titleVisibility: .visible, + ) { + Button(String(localized: .cardDesignerResetAll), role: .destructive) { + model.resetAll() + } + Button(String(localized: .commonCancel), role: .cancel) {} + } message: { + Text(String(localized: .cardDesignerResetAllMessage)) + } + .alert( + String(localized: .cardDesignerPersistenceErrorTitle), + isPresented: $model.isShowingPersistenceError, + presenting: model.persistenceError, + ) { _ in + Button(String(localized: .commonOk), role: .cancel) {} + } message: { message in + Text(message) + } + .onAppear { tilt.start() } + .onDisappear { tilt.stop() } + } + } + + extension CardDesignerStudioView: SnapshotProviding { + static var snapshots: [SnapshotCase] { + whereSnapshot(name: "Default", configurations: .phoneLightDark) { + NavigationStack { + CardDesignerStudioView( + model: CardDesignerModel(configuration: .standard), + ) + } + } + } + } + + #Preview { + CardDesignerStudioView.snapshotPreviews + } + + extension CardDesignerStudioView: WhereFlyoverProviding { + static let flyoverData = WhereFlyoverData.hosted( + CardDesignerStudioView.self, + title: "Card Designer Studio", + ) { _ in + CardDesignerStudioView(model: CardDesignerModel(configuration: .standard)) + } + } +#endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerTypographyControls.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerTypographyControls.swift new file mode 100644 index 00000000..4bd1d542 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerTypographyControls.swift @@ -0,0 +1,47 @@ +#if DEBUG + import SwiftUI + + struct CardDesignerTypographyControls: View { + let title: LocalizedStringResource + @Binding var typography: CardDesignerConfiguration.Typography + + var body: some View { + DisclosureGroup { + Picker(String(localized: .cardDesignerSizeMode), selection: $typography.sizeMode) { + ForEach(CardDesignerConfiguration.SizeMode.allCases, id: \.self) { mode in + Text(mode.localizedName).tag(mode) + } + } + if typography.sizeMode == .fixed { + CardDesignerCGFloatControl( + title: .cardDesignerPointSize, + value: $typography.fixedSize, + range: 8 ... 72, + step: 0.5, + ) + } else { + Picker( + String(localized: .cardDesignerTextStyle), + selection: $typography.textStyle, + ) { + ForEach(CardDesignerConfiguration.TextStyle.allCases, id: \.self) { style in + Text(style.localizedName).tag(style) + } + } + } + Picker(String(localized: .cardDesignerWeight), selection: $typography.weight) { + ForEach(CardDesignerConfiguration.FontWeight.allCases, id: \.self) { weight in + Text(weight.localizedName).tag(weight) + } + } + Picker(String(localized: .cardDesignerDesign), selection: $typography.design) { + ForEach(CardDesignerConfiguration.FontDesign.allCases, id: \.self) { design in + Text(design.localizedName).tag(design) + } + } + } label: { + Text(title) + } + } + } +#endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerVariantControls.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerVariantControls.swift new file mode 100644 index 00000000..d6e21910 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerVariantControls.swift @@ -0,0 +1,226 @@ +#if DEBUG + import SwiftUI + + struct CardDesignerVariantControls: View { + @Binding var card: CardDesignerConfiguration.Card + let reset: (CardDesignerModel.Section) -> Void + + var body: some View { + Group { + Section { + CardDesignerCGFloatControl( + title: .cardDesignerCornerRadius, + value: $card.cornerRadius, + range: 0 ... 60, + step: 1, + ) + CardDesignerCGFloatControl( + title: .cardDesignerPadding, + value: $card.padding, + range: 0 ... 40, + step: 1, + ) + CardDesignerCGFloatControl( + title: .cardDesignerContentSpacing, + value: $card.contentSpacing, + range: 0 ... 30, + step: 1, + ) + CardDesignerCGFloatControl( + title: .cardDesignerProgressHeight, + value: $card.progressBarHeight, + range: 1 ... 20, + step: 0.5, + ) + CardDesignerCGFloatControl( + title: .cardDesignerFallbackWatermarkSize, + value: $card.watermarkFontSize, + range: 40 ... 240, + step: 1, + ) + CardDesignerCGFloatControl( + title: .cardDesignerWatermarkOffsetX, + value: $card.watermarkOffset.x, + range: -80 ... 80, + step: 1, + ) + CardDesignerCGFloatControl( + title: .cardDesignerWatermarkOffsetY, + value: $card.watermarkOffset.y, + range: -80 ... 80, + step: 1, + ) + } header: { + CardDesignerSectionHeader(title: .cardDesignerCard, reset: { reset(.card) }) + } + + Section { + CardDesignerTypographyControls( + title: .cardDesignerRegionName, + typography: $card.regionNameTypography, + ) + CardDesignerCGFloatControl( + title: .cardDesignerTracking, + value: $card.regionNameTracking, + range: -5 ... 10, + step: 0.1, + ) + CardDesignerTypographyControls( + title: .cardDesignerHeroNumber, + typography: $card.heroNumberTypography, + ) + CardDesignerTypographyControls( + title: .cardDesignerDayUnit, + typography: $card.dayUnitTypography, + ) + } header: { + CardDesignerSectionHeader( + title: .cardDesignerTypography, + reset: { reset(.typography) }, + ) + } + + Section { + CardDesignerEntryStampControls(stamp: $card.entryStamp) + } header: { + CardDesignerSectionHeader( + title: .cardDesignerEntryStamp, + reset: { reset(.entryStamp) }, + ) + } + + Section { + CardDesignerArtworkControls( + usesRegionShape: $card.usesRegionShape, + regionShape: $card.regionShape, + ) + } header: { + CardDesignerSectionHeader( + title: .cardDesignerRegionArtwork, + reset: { reset(.regionArtwork) }, + ) + } + + if card.usesRegionShape { + Section { + CardDesignerMicroprintControls(border: $card.regionShape.securityBorder) + } header: { + CardDesignerSectionHeader( + title: .cardDesignerMicroprint, + reset: { reset(.microprint) }, + ) + } + } + + Section { + CardDesignerCGFloatControl( + title: .cardDesignerWobble, + value: $card.rosette.wobble, + range: 0 ... 12, + step: 0.5, + ) + CardDesignerCGFloatControl( + title: .cardDesignerLineWidth, + value: $card.rosette.lineWidth, + range: 0 ... 6, + step: 0.25, + ) + CardDesignerCGFloatControl( + title: .cardDesignerPrimaryRingSpacing, + value: $card.rosette.primaryRingSpacing, + range: 4 ... 40, + step: 0.5, + ) + CardDesignerCGFloatControl( + title: .cardDesignerSecondaryRingSpacing, + value: $card.rosette.secondaryRingSpacing, + range: 4 ... 40, + step: 0.5, + ) + } header: { + CardDesignerSectionHeader( + title: .cardDesignerRosettes, + reset: { reset(.rosettes) }, + ) + } + + Section { + CardDesignerDoubleControl( + title: .cardDesignerIntensity, + value: $card.sheen.intensity, + range: 0 ... 1, + step: 0.01, + ) + CardDesignerDoubleControl( + title: .cardDesignerStaticGlint, + value: $card.sheen.staticGlintIntensity, + range: 0 ... 1, + step: 0.01, + ) + CardDesignerDoubleControl( + title: .cardDesignerStaticRoll, + value: $card.sheen.staticRoll, + range: -1 ... 1, + step: 0.05, + ) + CardDesignerDoubleControl( + title: .cardDesignerStaticPitch, + value: $card.sheen.staticPitch, + range: -1 ... 1, + step: 0.05, + ) + } header: { + CardDesignerSectionHeader(title: .cardDesignerSheen, reset: { reset(.sheen) }) + } + + Section { + DisclosureGroup(String(localized: .cardDesignerGlow)) { + CardDesignerDoubleControl( + title: .cardDesignerOpacity, + value: $card.glow.opacity, + range: 0 ... 1, + step: 0.01, + ) + CardDesignerCGFloatControl( + title: .cardDesignerRadius, + value: $card.glow.radius, + range: 0 ... 80, + step: 1, + ) + CardDesignerCGFloatControl( + title: .cardDesignerOffsetY, + value: $card.glow.offsetY, + range: -20 ... 50, + step: 1, + ) + } + DisclosureGroup(String(localized: .cardDesignerLift)) { + CardDesignerDoubleControl( + title: .cardDesignerOpacity, + value: $card.lift.opacity, + range: 0 ... 1, + step: 0.01, + ) + CardDesignerCGFloatControl( + title: .cardDesignerRadius, + value: $card.lift.radius, + range: 0 ... 80, + step: 1, + ) + CardDesignerCGFloatControl( + title: .cardDesignerOffsetY, + value: $card.lift.offsetY, + range: -20 ... 50, + step: 1, + ) + } + } header: { + CardDesignerSectionHeader( + title: .cardDesignerShadows, + reset: { reset(.shadows) }, + ) + } + } + } + } +#endif diff --git a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift index f51ef345..5c3c1079 100644 --- a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift +++ b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift @@ -91,6 +91,7 @@ LocationSettingsView.flyoverData, AlertsSettingsView.flyoverData, AppearanceSettingsView.flyoverData, + CardDesignerStudioView.flyoverData, AppIconView.flyoverData, VisibleYearSettingsView.flyoverData, DataSettingsView.flyoverData, diff --git a/Where/WhereUI/Sources/Settings/AppearanceSettingsView.swift b/Where/WhereUI/Sources/Settings/AppearanceSettingsView.swift index 50fef1a3..d929368c 100644 --- a/Where/WhereUI/Sources/Settings/AppearanceSettingsView.swift +++ b/Where/WhereUI/Sources/Settings/AppearanceSettingsView.swift @@ -7,6 +7,9 @@ struct AppearanceSettingsView: View { var focus: SettingsFocus? @State private var showAppIcon = false + #if DEBUG + @Environment(\.cardDesignerModel) private var cardDesignerModel + #endif var body: some View { SettingsFocusScope(focus: focus) { @@ -24,6 +27,30 @@ struct AppearanceSettingsView: View { } footer: { Text(String(localized: .settingsAppIconFooter)) } + + #if DEBUG + if let cardDesignerModel { + Section { + NavigationLink { + CardDesignerStudioView(model: cardDesignerModel) + } label: { + Label( + String(localized: .cardDesignerTitle), + systemImage: "paintpalette", + ) + } + .settingsRow(Item.cardDesigner) + } header: { + Text(String(localized: "Developer")) + } footer: { + Text( + String( + localized: "Tune card presentation live and export the result for implementation.", + ), + ) + } + } + #endif } } .navigationTitle(String(localized: .settingsAppearanceGroup)) @@ -41,16 +68,30 @@ extension AppearanceSettingsView: SettingsSection { enum Item: SettingsItem { case appIcon + #if DEBUG + case cardDesigner + #endif var title: String { switch self { case .appIcon: String(localized: .settingsAppIconLink) + #if DEBUG + case .cardDesigner: String(localized: .cardDesignerTitle) + #endif } } var keywords: [String] { switch self { case .appIcon: splitKeywords(String(localized: .settingsKeywordsAppIcon)) + #if DEBUG + case .cardDesigner: + splitKeywords( + String( + localized: "card cards design appearance rosette sheen stamp microprint", + ), + ) + #endif } } } @@ -72,6 +113,7 @@ extension AppearanceSettingsView: SettingsSection { title: "Appearance Settings", routes: [ .modal(to: AppIconView.flyoverID), + .push(to: CardDesignerStudioView.flyoverID), ], ) { _ in AppearanceSettingsView() diff --git a/Where/WhereUI/Tests/CardDesignerConfigurationTests.swift b/Where/WhereUI/Tests/CardDesignerConfigurationTests.swift index 7173ba82..1993bf55 100644 --- a/Where/WhereUI/Tests/CardDesignerConfigurationTests.swift +++ b/Where/WhereUI/Tests/CardDesignerConfigurationTests.swift @@ -26,7 +26,8 @@ @Test func configurationRoundTripsThroughVersionedJSON() throws { var configuration = CardDesignerConfiguration.standard configuration.regular.cornerRadius = 33 - configuration.compact.regionNameTypography.size = .fixed(19) + configuration.compact.regionNameTypography.sizeMode = .fixed + configuration.compact.regionNameTypography.fixedSize = 19 configuration.shared.darkSecurityPrint.blendMode = .softLight let data = try JSONEncoder().encode(configuration) diff --git a/Where/WhereUI/Tests/CardDesignerStudioViewTests.swift b/Where/WhereUI/Tests/CardDesignerStudioViewTests.swift new file mode 100644 index 00000000..f0215ed3 --- /dev/null +++ b/Where/WhereUI/Tests/CardDesignerStudioViewTests.swift @@ -0,0 +1,21 @@ +#if DEBUG + import SwiftUI + import Testing + import UIKit + @testable import WhereUI + + @MainActor + struct CardDesignerStudioViewTests { + @Test func hostsWithStandardConfiguration() { + let model = CardDesignerModel(configuration: .standard) + let controller = UIHostingController( + rootView: NavigationStack { + CardDesignerStudioView(model: model) + }, + ) + + #expect(controller.view != nil) + #expect(model.configuration == .standard) + } + } +#endif From 6f29511abba3fa3ca5e69ec45571c96c80b1e4b3 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 2 Aug 2026 09:06:22 -0700 Subject: [PATCH 23/28] Match card designer page to preview appearance --- .../cardDesignerStudio.Default_iPhone_dark.png | 4 ++-- .../CardDesigner/CardDesignerStudioView.swift | 17 ++++++++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CardDesignerStudioViewSnapshotTests/cardDesignerStudio.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CardDesignerStudioViewSnapshotTests/cardDesignerStudio.Default_iPhone_dark.png index af3b3e01..b972b926 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CardDesignerStudioViewSnapshotTests/cardDesignerStudio.Default_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CardDesignerStudioViewSnapshotTests/cardDesignerStudio.Default_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2f0a4edd3f4ce78f226711724ffc643f92a6a79aad7cd32f3a0f38045a9422b3 -size 1034978 +oid sha256:4b6c6fd0398f1bab5c10a2eb69706d8e6ede3dbc25075c7b8eb896712eaadf8b +size 1124176 diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerStudioView.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerStudioView.swift index 58648898..b0ae8f57 100644 --- a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerStudioView.swift +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerStudioView.swift @@ -8,13 +8,14 @@ let model: CardDesignerModel @State private var variant = CardDesignerConfiguration.Variant.regular - @State private var previewColorScheme = ColorScheme.light + @State private var previewColorScheme: ColorScheme? @State private var previewRegion = Region.newYork @State private var previewColor = RegionColorToken.indigo @State private var previewDays = 128 @State private var previewYear = 2026 @State private var isConfirmingResetAll = false @State private var tilt = TiltProvider() + @Environment(\.colorScheme) private var systemColorScheme var body: some View { @Bindable var model = model @@ -22,7 +23,7 @@ CardDesignerPreview( configuration: model.configuration, variant: variant, - colorScheme: previewColorScheme, + colorScheme: previewColorScheme ?? systemColorScheme, region: previewRegion, color: previewColor, days: previewDays, @@ -50,8 +51,10 @@ String(localized: .cardDesignerAppearance), selection: $previewColorScheme, ) { - Text(String(localized: .cardDesignerLight)).tag(ColorScheme.light) - Text(String(localized: .cardDesignerDark)).tag(ColorScheme.dark) + Text(String(localized: .cardDesignerLight)) + .tag(ColorScheme.light as ColorScheme?) + Text(String(localized: .cardDesignerDark)) + .tag(ColorScheme.dark as ColorScheme?) } .pickerStyle(.segmented) @@ -154,7 +157,11 @@ } message: { message in Text(message) } - .onAppear { tilt.start() } + .preferredColorScheme(previewColorScheme) + .onAppear { + previewColorScheme = previewColorScheme ?? systemColorScheme + tilt.start() + } .onDisappear { tilt.stop() } } } From 7fda34125c218a225e67a64b7d747712ea5c487a Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 2 Aug 2026 09:30:11 -0700 Subject: [PATCH 24/28] Add copyable card designer diffs --- Where/WhereUI/README.md | 6 +- .../CardDesignerConfigurationDifference.swift | 107 ++++++++++++++++++ .../CardDesigner/CardDesignerExport.swift | 38 ++++++- .../CardDesignerExportSection.swift | 62 +++++++++- .../CardDesignerJSONExporter.swift | 38 +++++++ .../CardDesigner/CardDesignerStrings.swift | 9 +- ...DesignerConfigurationDifferenceTests.swift | 31 +++++ .../Tests/CardDesignerExportTests.swift | 29 +++++ .../Tests/CardDesignerJSONExporterTests.swift | 44 +++++++ 9 files changed, 353 insertions(+), 11 deletions(-) create mode 100644 Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerConfigurationDifference.swift create mode 100644 Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerJSONExporter.swift create mode 100644 Where/WhereUI/Tests/CardDesignerConfigurationDifferenceTests.swift create mode 100644 Where/WhereUI/Tests/CardDesignerJSONExporterTests.swift diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index c08610f6..d9ccdb36 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -230,9 +230,9 @@ direct and production rendering do not diverge. DEBUG builds include Card Designer Studio under Settings → Appearance. It edits a versioned, persisted draft of the regular, compact, and shared card presentation, previews both appearances with live tilt, and exports the full -result as JSON or a Swift stylesheet snippet. The draft affects the rest of the -app only while “Apply to App” is enabled; that switch intentionally resets on -every launch. +result—or only its changes from the app defaults—as shareable or clipboard JSON +and Swift. The draft affects the rest of the app only while “Apply to App” is +enabled; that switch intentionally resets on every launch. ## Previews diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerConfigurationDifference.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerConfigurationDifference.swift new file mode 100644 index 00000000..481db509 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerConfigurationDifference.swift @@ -0,0 +1,107 @@ +#if DEBUG + import CoreFoundation + import Foundation + + /// Computes a leaf-level difference between a designer configuration and + /// the app defaults for sparse JSON and paste-ready Swift exports. + enum CardDesignerConfigurationDifference { + static func jsonObject( + for configuration: CardDesignerConfiguration, + ) throws -> [String: Any] { + let current = try object(for: configuration) + let standard = try object(for: .standard) + var difference = difference(current, from: standard) as? [String: Any] ?? [:] + // Schema metadata describes the sparse payload rather than a style + // change, so retain it even though it matches the baseline. + difference["schemaVersion"] = configuration.schemaVersion + return difference + } + + static func swiftAssignments( + for configuration: CardDesignerConfiguration, + ) throws -> [String] { + let current = try object(for: configuration) + let standard = try object(for: .standard) + guard let difference = difference(current, from: standard) as? [String: Any] + else { return [] } + + var assignments: [String] = [] + appendAssignments( + from: difference, + path: "configuration", + to: &assignments, + ) + return assignments + } + + private static func object( + for configuration: CardDesignerConfiguration, + ) throws -> [String: Any] { + let data = try JSONEncoder().encode(configuration) + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + throw DifferenceError.invalidConfigurationObject + } + return object + } + + private static func difference(_ current: Any, from standard: Any?) -> Any? { + if + let current = current as? [String: Any], + let standard = standard as? [String: Any] + { + var result: [String: Any] = [:] + for key in current.keys.sorted() { + guard let currentValue = current[key] else { continue } + if let value = difference(currentValue, from: standard[key]) { + result[key] = value + } + } + return result.isEmpty ? nil : result + } + + guard let standard else { return current } + return valuesAreEqual(current, standard) ? nil : current + } + + private static func valuesAreEqual(_ lhs: Any, _ rhs: Any) -> Bool { + guard let lhs = lhs as? NSObject, let rhs = rhs as? NSObject else { return false } + return lhs.isEqual(rhs) + } + + private static func appendAssignments( + from difference: [String: Any], + path: String, + to assignments: inout [String], + ) { + for key in difference.keys.sorted() where key != "schemaVersion" { + let nextPath = "\(path).\(key)" + if let nested = difference[key] as? [String: Any] { + appendAssignments(from: nested, path: nextPath, to: &assignments) + } else if let value = difference[key] { + assignments.append("\(nextPath) = \(swiftLiteral(value))") + } + } + } + + private static func swiftLiteral(_ value: Any) -> String { + if let number = value as? NSNumber { + if CFGetTypeID(number) == CFBooleanGetTypeID() { + return number.boolValue ? "true" : "false" + } + return number.stringValue + } + if let rawValue = value as? String { + // Every string in CardDesignerConfiguration is a RawRepresentable + // enum case, so member syntax is both compact and compilable. + return ".\(rawValue)" + } + assertionFailure("Unsupported card designer export value: \(value)") + return "/* unsupported */" + } + + private enum DifferenceError: Error { + case invalidConfigurationObject + } + } +#endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExport.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExport.swift index 1a572588..56f89963 100644 --- a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExport.swift +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExport.swift @@ -5,12 +5,14 @@ struct CardDesignerJSONExport: Transferable { let configuration: CardDesignerConfiguration + let diffOnly: Bool static var transferRepresentation: some TransferRepresentation { DataRepresentation(exportedContentType: .json) { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] - return try encoder.encode($0.configuration) + try CardDesignerJSONExporter.data( + for: $0.configuration, + diffOnly: $0.diffOnly, + ) } .suggestedFileName("Where Card Design.json") } @@ -29,6 +31,36 @@ enum CardDesignerSwiftExporter { static func source(for configuration: CardDesignerConfiguration) -> String { + source(for: configuration, diffOnly: false) + } + + static func source( + for configuration: CardDesignerConfiguration, + diffOnly: Bool, + ) -> String { + guard diffOnly else { return fullSource(for: configuration) } + do { + let assignments = try CardDesignerConfigurationDifference.swiftAssignments( + for: configuration, + ) + let changes = assignments.isEmpty + ? "// No card appearance values differ from standard." + : assignments.joined(separator: "\n") + return """ + // Diff generated by Where's DEBUG Card Designer Studio (schema \(configuration + .schemaVersion)). + // Paste this snippet back into WhereUI to reproduce only the edited values. + + var configuration = CardDesignerConfiguration.standard + \(changes) + """ + } catch { + assertionFailure("Card designer Swift diff export failed: \(error)") + return fullSource(for: configuration) + } + } + + private static func fullSource(for configuration: CardDesignerConfiguration) -> String { """ // Generated by Where's DEBUG Card Designer Studio (schema \(configuration .schemaVersion)). diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExportSection.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExportSection.swift index 9197b332..7e369a55 100644 --- a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExportSection.swift +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExportSection.swift @@ -1,36 +1,90 @@ #if DEBUG import SwiftUI + import UIKit struct CardDesignerExportSection: View { let configuration: CardDesignerConfiguration + @State private var diffOnly = false + @State private var copiedFormat: CopiedFormat? var body: some View { Section { + Toggle(String(localized: .cardDesignerDiffOnly), isOn: $diffOnly) ShareLink( - item: CardDesignerJSONExport(configuration: configuration), + item: CardDesignerJSONExport( + configuration: configuration, + diffOnly: diffOnly, + ), preview: SharePreview(String(localized: .cardDesignerJSONExport)), ) { Label( - String(localized: .cardDesignerJSONExport), + String(localized: .cardDesignerShareJSON), systemImage: "doc.badge.gearshape", ) } + Button(action: copyJSON) { + Label( + String( + localized: copiedFormat == .json + ? .cardDesignerCopiedJSON + : .cardDesignerCopyJSON, + ), + systemImage: copiedFormat == .json ? "checkmark" : "document.on.document", + ) + } ShareLink( item: CardDesignerSwiftExport( - source: CardDesignerSwiftExporter.source(for: configuration), + source: CardDesignerSwiftExporter.source( + for: configuration, + diffOnly: diffOnly, + ), ), preview: SharePreview(String(localized: .cardDesignerSwiftExport)), ) { Label( - String(localized: .cardDesignerSwiftExport), + String(localized: .cardDesignerShareSwift), systemImage: "swift", ) } + Button(action: copySwift) { + Label( + String( + localized: copiedFormat == .swift + ? .cardDesignerCopiedSwift + : .cardDesignerCopySwift, + ), + systemImage: copiedFormat == .swift ? "checkmark" : "document.on.document", + ) + } } header: { Text(String(localized: .cardDesignerExport)) } footer: { Text(String(localized: .cardDesignerExportFooter)) } + .sensoryFeedback(.success, trigger: copiedFormat) + .onChange(of: diffOnly) { copiedFormat = nil } + .onChange(of: configuration) { copiedFormat = nil } + } + + private func copyJSON() { + UIPasteboard.general.string = CardDesignerJSONExporter.text( + for: configuration, + diffOnly: diffOnly, + ) + copiedFormat = .json + } + + private func copySwift() { + UIPasteboard.general.string = CardDesignerSwiftExporter.source( + for: configuration, + diffOnly: diffOnly, + ) + copiedFormat = .swift + } + + private enum CopiedFormat: Equatable { + case json + case swift } } #endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerJSONExporter.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerJSONExporter.swift new file mode 100644 index 00000000..8bc838db --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerJSONExporter.swift @@ -0,0 +1,38 @@ +#if DEBUG + import Foundation + + enum CardDesignerJSONExporter { + static func data( + for configuration: CardDesignerConfiguration, + diffOnly: Bool, + ) throws -> Data { + if diffOnly { + return try JSONSerialization.data( + withJSONObject: CardDesignerConfigurationDifference.jsonObject( + for: configuration, + ), + options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes], + ) + } + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + return try encoder.encode(configuration) + } + + static func text( + for configuration: CardDesignerConfiguration, + diffOnly: Bool, + ) -> String { + do { + return try String( + decoding: data(for: configuration, diffOnly: diffOnly), + as: UTF8.self, + ) + } catch { + assertionFailure("Card designer JSON export failed: \(error)") + return "{}" + } + } + } +#endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerStrings.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerStrings.swift index 8b7a6a25..8c0194d8 100644 --- a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerStrings.swift +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerStrings.swift @@ -18,6 +18,10 @@ static let cardDesignerCenterY = LocalizedStringResource("Vertical Center") static let cardDesignerColor = LocalizedStringResource("Color") static let cardDesignerContentSpacing = LocalizedStringResource("Content Spacing") + static let cardDesignerCopiedJSON = LocalizedStringResource("Copied JSON") + static let cardDesignerCopiedSwift = LocalizedStringResource("Copied Swift") + static let cardDesignerCopyJSON = LocalizedStringResource("Copy JSON") + static let cardDesignerCopySwift = LocalizedStringResource("Copy Swift") static let cardDesignerCornerRadius = LocalizedStringResource("Corner Radius") static let cardDesignerDark = LocalizedStringResource("Dark") static let cardDesignerDarkInk = LocalizedStringResource("Dark Appearance Ink") @@ -25,11 +29,12 @@ static let cardDesignerDashSpacing = LocalizedStringResource("Dash Spacing") static let cardDesignerDayUnit = LocalizedStringResource("Day Unit") static let cardDesignerDesign = LocalizedStringResource("Design") + static let cardDesignerDiffOnly = LocalizedStringResource("Diff Only") static let cardDesignerEntryStamp = LocalizedStringResource("Entry Stamp") static let cardDesignerExport = LocalizedStringResource("Export") static let cardDesignerExportFooter = LocalizedStringResource( - "Share the complete configuration as JSON, or a Swift snippet ready to bring back into the stylesheet.", + "Share or copy JSON and Swift output. Diff Only includes just values that differ from the app defaults.", ) static let cardDesignerExtentHeight = LocalizedStringResource("Extent Height") static let cardDesignerExtentWidth = LocalizedStringResource("Extent Width") @@ -88,6 +93,8 @@ LocalizedStringResource("Secondary Ring Spacing") static let cardDesignerSecondaryRosetteOpacity = LocalizedStringResource("Secondary Rosette Opacity") + static let cardDesignerShareJSON = LocalizedStringResource("Share JSON") + static let cardDesignerShareSwift = LocalizedStringResource("Share Swift") static let cardDesignerShadows = LocalizedStringResource("Shadows") static let cardDesignerSheen = LocalizedStringResource("Motion Sheen") static let cardDesignerShowArc = LocalizedStringResource("Show Curved Label") diff --git a/Where/WhereUI/Tests/CardDesignerConfigurationDifferenceTests.swift b/Where/WhereUI/Tests/CardDesignerConfigurationDifferenceTests.swift new file mode 100644 index 00000000..691df733 --- /dev/null +++ b/Where/WhereUI/Tests/CardDesignerConfigurationDifferenceTests.swift @@ -0,0 +1,31 @@ +#if DEBUG + import Testing + @testable import WhereUI + + struct CardDesignerConfigurationDifferenceTests { + @Test func swiftAssignmentsContainOnlyEditedLeafValues() throws { + var configuration = CardDesignerConfiguration.standard + configuration.regular.cornerRadius = 31.25 + configuration.shared.darkSecurityPrint.blendMode = .softLight + + let assignments = try CardDesignerConfigurationDifference.swiftAssignments( + for: configuration, + ) + + #expect( + assignments == [ + "configuration.regular.cornerRadius = 31.25", + "configuration.shared.darkSecurityPrint.blendMode = .softLight", + ], + ) + } + + @Test func standardConfigurationHasNoSwiftAssignments() throws { + let assignments = try CardDesignerConfigurationDifference.swiftAssignments( + for: .standard, + ) + + #expect(assignments.isEmpty) + } + } +#endif diff --git a/Where/WhereUI/Tests/CardDesignerExportTests.swift b/Where/WhereUI/Tests/CardDesignerExportTests.swift index 8ccebba4..e58ccc88 100644 --- a/Where/WhereUI/Tests/CardDesignerExportTests.swift +++ b/Where/WhereUI/Tests/CardDesignerExportTests.swift @@ -27,5 +27,34 @@ #expect(source.contains("cornerRadius: 31.25")) #expect(source.contains("whiteMix: 0.4")) } + + @Test func swiftDiffContainsOnlyEditedLeafValues() { + var configuration = CardDesignerConfiguration.standard + configuration.regular.cornerRadius = 31.25 + configuration.shared.darkSecurityPrint.blendMode = .softLight + + let source = CardDesignerSwiftExporter.source( + for: configuration, + diffOnly: true, + ) + + #expect(source.contains("var configuration = CardDesignerConfiguration.standard")) + #expect(source.contains("configuration.regular.cornerRadius = 31.25")) + #expect( + source.contains("configuration.shared.darkSecurityPrint.blendMode = .softLight"), + ) + #expect(source.contains("configuration.regular.padding") == false) + #expect(source.contains("configuration.compact") == false) + } + + @Test func unchangedSwiftDiffExplainsThatThereAreNoChanges() { + let source = CardDesignerSwiftExporter.source( + for: .standard, + diffOnly: true, + ) + + #expect(source.contains("No card appearance values differ from standard.")) + #expect(source.contains("configuration.regular") == false) + } } #endif diff --git a/Where/WhereUI/Tests/CardDesignerJSONExporterTests.swift b/Where/WhereUI/Tests/CardDesignerJSONExporterTests.swift new file mode 100644 index 00000000..55b69b7d --- /dev/null +++ b/Where/WhereUI/Tests/CardDesignerJSONExporterTests.swift @@ -0,0 +1,44 @@ +#if DEBUG + import Foundation + import Testing + @testable import WhereUI + + struct CardDesignerJSONExporterTests { + @Test func diffRetainsSchemaAndContainsOnlyEditedLeafValues() throws { + var configuration = CardDesignerConfiguration.standard + configuration.regular.cornerRadius = 31.25 + configuration.shared.darkSecurityPrint.whiteMix = 0.4 + + let data = try CardDesignerJSONExporter.data( + for: configuration, + diffOnly: true, + ) + let object = try #require( + JSONSerialization.jsonObject(with: data) as? [String: Any], + ) + let regular = try #require(object["regular"] as? [String: Any]) + let shared = try #require(object["shared"] as? [String: Any]) + let dark = try #require(shared["darkSecurityPrint"] as? [String: Any]) + + #expect(object["schemaVersion"] as? Int == 1) + #expect(regular["cornerRadius"] as? Double == 31.25) + #expect(regular["padding"] == nil) + #expect(object["compact"] == nil) + #expect(dark["whiteMix"] as? Double == 0.4) + #expect(dark["blendMode"] == nil) + } + + @Test func fullExportRoundTripsTheConfiguration() throws { + var configuration = CardDesignerConfiguration.standard + configuration.compact.padding = 19 + + let data = try CardDesignerJSONExporter.data( + for: configuration, + diffOnly: false, + ) + + #expect(try JSONDecoder() + .decode(CardDesignerConfiguration.self, from: data) == configuration) + } + } +#endif From 7afa099a479a75b77297501e810a7351ae254fbb Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 2 Aug 2026 10:11:52 -0700 Subject: [PATCH 25/28] Recover corrupt card designer drafts --- .../CardDesigner/CardDesignerModel.swift | 7 +++++++ Where/WhereUI/Tests/CardDesignerModelTests.swift | 15 +++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerModel.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerModel.swift index ace221a0..718b5d0d 100644 --- a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerModel.swift +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerModel.swift @@ -60,6 +60,13 @@ } func resetAll() { + guard configuration != .standard else { + // A failed load already falls back to `.standard`, so assigning + // it again would skip the `didSet` persistence path and leave + // the corrupt or unsupported draft in storage. + persist() + return + } configuration = .standard } diff --git a/Where/WhereUI/Tests/CardDesignerModelTests.swift b/Where/WhereUI/Tests/CardDesignerModelTests.swift index fbfb9e2e..03afcca6 100644 --- a/Where/WhereUI/Tests/CardDesignerModelTests.swift +++ b/Where/WhereUI/Tests/CardDesignerModelTests.swift @@ -31,6 +31,21 @@ #expect(model.persistenceError != nil) } + @Test func resetAllReplacesCorruptPersistenceWithDefaults() throws { + let (store, suite) = try isolatedStore() + defer { store.removePersistentDomain(forName: suite) } + let key = "card-designer" + store.set(Data("not json".utf8), forKey: key) + let model = CardDesignerModel(store: store, key: key) + + model.resetAll() + let reloaded = CardDesignerModel(store: store, key: key) + + #expect(model.persistenceError == nil) + #expect(reloaded.configuration == .standard) + #expect(reloaded.persistenceError == nil) + } + @Test func resetVariantLeavesTheOtherVariantUntouched() throws { let (store, suite) = try isolatedStore() defer { store.removePersistentDomain(forName: suite) } From 77adc02237ebc8a35e6342d790ff4543804e7fd2 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 2 Aug 2026 10:12:23 -0700 Subject: [PATCH 26/28] Reload card artwork when designer state changes --- .../Sources/Primary/RegionSummaryCard.swift | 18 +++++++++++++++- .../Tests/RegionSummaryCardTests.swift | 21 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift index 32a90b60..a4d9566e 100644 --- a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift @@ -97,6 +97,14 @@ struct RegionSummaryCard: View { card.progressBarHeight } + private var regionArtworkLoadID: RegionArtworkLoadID { + RegionArtworkLoadID( + region: regionDays.region, + variant: variant, + isEnabled: card.regionShape != nil, + ) + } + /// How a count change plays out while the card is on screen. Reduce Motion is /// already resolved into it by the stylesheet. private var dayCount: WhereStylesheet.CardStyles.DayCountStyle { @@ -326,10 +334,18 @@ struct RegionSummaryCard: View { days: regionDays.days, ), ) - .task(id: regionDays.region, loadRegionOutlines) + .task(id: regionArtworkLoadID, loadRegionOutlines) } } +/// Restarts cached artwork loading when a designer switches either card variant +/// or the outline layer without first changing the previewed region. +struct RegionArtworkLoadID: Equatable { + let region: Region + let variant: WhereStylesheet.CardStyle.Variant + let isEnabled: Bool +} + /// A circular rubber-stamp impression — double ring, centered region glyph and /// year, with the region name curved along the top arc — tilted as if an /// official pressed it onto a passport page. The card stylesheet owns its diff --git a/Where/WhereUI/Tests/RegionSummaryCardTests.swift b/Where/WhereUI/Tests/RegionSummaryCardTests.swift index 766c55c2..aa8539b3 100644 --- a/Where/WhereUI/Tests/RegionSummaryCardTests.swift +++ b/Where/WhereUI/Tests/RegionSummaryCardTests.swift @@ -6,6 +6,27 @@ import Testing @MainActor struct RegionSummaryCardTests { + @Test func artworkLoadIDChangesWithDesignerControls() { + let disabled = RegionArtworkLoadID( + region: .newYork, + variant: .regular, + isEnabled: false, + ) + let enabled = RegionArtworkLoadID( + region: .newYork, + variant: .regular, + isEnabled: true, + ) + let compact = RegionArtworkLoadID( + region: .newYork, + variant: .compact, + isEnabled: true, + ) + + #expect(disabled != enabled) + #expect(enabled != compact) + } + /// A region card's count can change with the card on screen, which runs an /// animated morph (`CardStyles.DayCountStyle`) rather than a cut — so /// re-render one with a new count and confirm the card survives the update. From 585ebaad86034f3d8882ba11db68f83ac34518b7 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 2 Aug 2026 10:12:56 -0700 Subject: [PATCH 27/28] Localize the card designer studio --- .../CardDesignerExportSection.swift | 2 +- .../CardDesigner/CardDesignerModel.swift | 2 +- .../CardDesigner/CardDesignerStrings.swift | 237 +-- .../Sources/Resources/Localizable.xcstrings | 1837 +++++++++++++++++ .../Settings/AppearanceSettingsView.swift | 14 +- 5 files changed, 1891 insertions(+), 201 deletions(-) diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExportSection.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExportSection.swift index 7e369a55..93aa76d6 100644 --- a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExportSection.swift +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExportSection.swift @@ -15,7 +15,7 @@ configuration: configuration, diffOnly: diffOnly, ), - preview: SharePreview(String(localized: .cardDesignerJSONExport)), + preview: SharePreview(String(localized: .cardDesignerJsonExport)), ) { Label( String(localized: .cardDesignerShareJSON), diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerModel.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerModel.swift index 718b5d0d..2aea2f10 100644 --- a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerModel.swift +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerModel.swift @@ -163,7 +163,7 @@ var errorDescription: String? { switch self { case let .unsupportedSchema(version): - "Card designer schema \(version) is not supported by this build." + String(localized: .cardDesignerPersistenceUnsupportedSchema(version)) } } } diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerStrings.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerStrings.swift index 8c0194d8..6a29b140 100644 --- a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerStrings.swift +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerStrings.swift @@ -1,150 +1,11 @@ #if DEBUG import Foundation - extension LocalizedStringResource { - static let cardDesignerAppearance = LocalizedStringResource("Appearance") - static let cardDesignerApplyFooter = - LocalizedStringResource( - "When enabled, changes appear on every location card until you turn this off or reset the studio.", - ) - static let cardDesignerApplyToApp = LocalizedStringResource("Apply to App") - static let cardDesignerArc = LocalizedStringResource("Curved Label") - static let cardDesignerArcTypography = LocalizedStringResource("Curved Label Type") - static let cardDesignerArtworkHeight = LocalizedStringResource("Artwork Height") - static let cardDesignerArtworkWidth = LocalizedStringResource("Artwork Width") - static let cardDesignerBlendMode = LocalizedStringResource("Blend Mode") - static let cardDesignerCard = LocalizedStringResource("Card Geometry") - static let cardDesignerCenterX = LocalizedStringResource("Horizontal Center") - static let cardDesignerCenterY = LocalizedStringResource("Vertical Center") - static let cardDesignerColor = LocalizedStringResource("Color") - static let cardDesignerContentSpacing = LocalizedStringResource("Content Spacing") - static let cardDesignerCopiedJSON = LocalizedStringResource("Copied JSON") - static let cardDesignerCopiedSwift = LocalizedStringResource("Copied Swift") - static let cardDesignerCopyJSON = LocalizedStringResource("Copy JSON") - static let cardDesignerCopySwift = LocalizedStringResource("Copy Swift") - static let cardDesignerCornerRadius = LocalizedStringResource("Corner Radius") - static let cardDesignerDark = LocalizedStringResource("Dark") - static let cardDesignerDarkInk = LocalizedStringResource("Dark Appearance Ink") - static let cardDesignerDashLength = LocalizedStringResource("Dash Length") - static let cardDesignerDashSpacing = LocalizedStringResource("Dash Spacing") - static let cardDesignerDayUnit = LocalizedStringResource("Day Unit") - static let cardDesignerDesign = LocalizedStringResource("Design") - static let cardDesignerDiffOnly = LocalizedStringResource("Diff Only") - static let cardDesignerEntryStamp = LocalizedStringResource("Entry Stamp") - static let cardDesignerExport = LocalizedStringResource("Export") - static let cardDesignerExportFooter = - LocalizedStringResource( - "Share or copy JSON and Swift output. Diff Only includes just values that differ from the app defaults.", - ) - static let cardDesignerExtentHeight = LocalizedStringResource("Extent Height") - static let cardDesignerExtentWidth = LocalizedStringResource("Extent Width") - static let cardDesignerFallbackWatermarkSize = - LocalizedStringResource("Fallback Watermark Size") - static let cardDesignerFillOpacity = LocalizedStringResource("Fill Opacity") - static let cardDesignerGlassAndInk = LocalizedStringResource("Glass and Security Ink") - static let cardDesignerGlassTintOpacity = LocalizedStringResource("Glass Tint Opacity") - static let cardDesignerGlow = LocalizedStringResource("Glow") - static let cardDesignerGlyphSize = LocalizedStringResource("Glyph Size") - static let cardDesignerHeroNumber = LocalizedStringResource("Day Count") - static let cardDesignerInnerRing = LocalizedStringResource("Inner Ring") - static let cardDesignerInset = LocalizedStringResource("Inset") - static let cardDesignerInsetFraction = LocalizedStringResource("Inset Fraction") - static let cardDesignerIntensity = LocalizedStringResource("Intensity") - static let cardDesignerJSONExport = LocalizedStringResource("Card Design.json") - static let cardDesignerLift = LocalizedStringResource("Lift Shadow") - static let cardDesignerLight = LocalizedStringResource("Light") - static let cardDesignerLightInk = LocalizedStringResource("Light Appearance Ink") - static let cardDesignerLineWidth = LocalizedStringResource("Line Width") - static let cardDesignerLineWidthFraction = LocalizedStringResource("Line Width Fraction") - static let cardDesignerMaximumSweep = LocalizedStringResource("Maximum Sweep") - static let cardDesignerMicroprint = LocalizedStringResource("Microprint Border") - static let cardDesignerNameOpacity = LocalizedStringResource("Name Opacity") - static let cardDesignerOffsetY = LocalizedStringResource("Vertical Offset") - static let cardDesignerOpacity = LocalizedStringResource("Opacity") - static let cardDesignerOuterRing = LocalizedStringResource("Outer Ring") - static let cardDesignerPadding = LocalizedStringResource("Padding") - static let cardDesignerPersistenceErrorTitle = - LocalizedStringResource("Couldn't Save Card Design") - static let cardDesignerPointSize = LocalizedStringResource("Point Size") - static let cardDesignerPreview = LocalizedStringResource("Preview") - static let cardDesignerPreviewCaption = LocalizedStringResource("Your days in this region") - static let cardDesignerPrimaryRingSpacing = LocalizedStringResource("Primary Ring Spacing") - static let cardDesignerPrimaryRosetteOpacity = - LocalizedStringResource("Primary Rosette Opacity") - static let cardDesignerProgressHeight = LocalizedStringResource("Progress Height") - static let cardDesignerRadius = LocalizedStringResource("Radius") - static let cardDesignerRadiusFraction = LocalizedStringResource("Radius Fraction") - static let cardDesignerRegion = LocalizedStringResource("Region") - static let cardDesignerRegionArtwork = LocalizedStringResource("Region Artwork") - static let cardDesignerRegionName = LocalizedStringResource("Region Name") - static let cardDesignerReset = LocalizedStringResource("Reset") - static let cardDesignerResetAll = LocalizedStringResource("Reset Everything") - static let cardDesignerResetAllMessage = - LocalizedStringResource( - "This restores every regular, compact, and shared card setting to the app defaults.", - ) - static let cardDesignerResetAllTitle = LocalizedStringResource("Reset Card Designer?") - static let cardDesignerResetShared = LocalizedStringResource("Reset Shared Settings") - static let cardDesignerResetVariant = LocalizedStringResource("Reset This Variant") - static let cardDesignerRosettes = LocalizedStringResource("Rosettes") - static let cardDesignerRotation = LocalizedStringResource("Rotation") - static let cardDesignerScale = LocalizedStringResource("Scale") - static let cardDesignerSecondaryRingSpacing = - LocalizedStringResource("Secondary Ring Spacing") - static let cardDesignerSecondaryRosetteOpacity = - LocalizedStringResource("Secondary Rosette Opacity") - static let cardDesignerShareJSON = LocalizedStringResource("Share JSON") - static let cardDesignerShareSwift = LocalizedStringResource("Share Swift") - static let cardDesignerShadows = LocalizedStringResource("Shadows") - static let cardDesignerSheen = LocalizedStringResource("Motion Sheen") - static let cardDesignerShowArc = LocalizedStringResource("Show Curved Label") - static let cardDesignerShowStroke = LocalizedStringResource("Show Stroke") - static let cardDesignerSizeFraction = LocalizedStringResource("Size Fraction") - static let cardDesignerSizeMode = LocalizedStringResource("Size Mode") - static let cardDesignerSpacing = LocalizedStringResource("Spacing") - static let cardDesignerSpacingFraction = LocalizedStringResource("Spacing Fraction") - static let cardDesignerStampArtwork = LocalizedStringResource("Stamp Artwork") - static let cardDesignerStampContent = LocalizedStringResource("Stamp Content") - static let cardDesignerStampSize = LocalizedStringResource("Stamp Size") - static let cardDesignerStaticGlint = LocalizedStringResource("Fallback Glint") - static let cardDesignerStaticPitch = LocalizedStringResource("Fallback Pitch") - static let cardDesignerStaticRoll = LocalizedStringResource("Fallback Roll") - static let cardDesignerStrokeOpacity = LocalizedStringResource("Stroke Opacity") - static let cardDesignerStrokeWidth = LocalizedStringResource("Stroke Width") - static let cardDesignerSweepPerCharacter = LocalizedStringResource("Sweep Per Character") - static let cardDesignerSwiftExport = LocalizedStringResource("Card Design.swift") - static let cardDesignerSymbolTypography = LocalizedStringResource("Symbol Type") - static let cardDesignerTextStyle = LocalizedStringResource("Text Style") - static let cardDesignerTitle = LocalizedStringResource("Card Designer Studio") - static let cardDesignerTracking = LocalizedStringResource("Tracking") - static let cardDesignerTypography = LocalizedStringResource("Typography") - static let cardDesignerUseRegionOutline = LocalizedStringResource("Use Region Outline") - static let cardDesignerVariant = LocalizedStringResource("Variant") - static let cardDesignerWatermark = LocalizedStringResource("Watermark") - static let cardDesignerWatermarkOffsetX = - LocalizedStringResource("Watermark Horizontal Offset") - static let cardDesignerWatermarkOffsetY = - LocalizedStringResource("Watermark Vertical Offset") - static let cardDesignerWatermarkOpacity = LocalizedStringResource("Watermark Opacity") - static let cardDesignerWeight = LocalizedStringResource("Weight") - static let cardDesignerWhiteMix = LocalizedStringResource("White Mix") - static let cardDesignerWobble = LocalizedStringResource("Wobble") - static let cardDesignerYearTypography = LocalizedStringResource("Year Type") - - static func cardDesignerDays(_ value: Int) -> LocalizedStringResource { - LocalizedStringResource("Days: \(value)") - } - - static func cardDesignerYear(_ value: Int) -> LocalizedStringResource { - LocalizedStringResource("Year: \(value)") - } - } - extension CardDesignerConfiguration.Variant { var localizedName: String { switch self { - case .regular: String(localized: "Regular") - case .compact: String(localized: "Compact") + case .regular: String(localized: .cardDesignerVariantRegular) + case .compact: String(localized: .cardDesignerVariantCompact) } } } @@ -152,8 +13,8 @@ extension CardDesignerConfiguration.SizeMode { var localizedName: String { switch self { - case .fixed: String(localized: "Fixed") - case .semantic: String(localized: "Dynamic Type") + case .fixed: String(localized: .cardDesignerSizeModeFixed) + case .semantic: String(localized: .cardDesignerSizeModeSemantic) } } } @@ -161,17 +22,17 @@ extension CardDesignerConfiguration.TextStyle { var localizedName: String { switch self { - case .caption2: String(localized: "Caption 2") - case .caption: String(localized: "Caption") - case .footnote: String(localized: "Footnote") - case .subheadline: String(localized: "Subheadline") - case .callout: String(localized: "Callout") - case .body: String(localized: "Body") - case .headline: String(localized: "Headline") - case .title3: String(localized: "Title 3") - case .title2: String(localized: "Title 2") - case .title: String(localized: "Title") - case .largeTitle: String(localized: "Large Title") + case .caption2: String(localized: .cardDesignerTextStyleCaption2) + case .caption: String(localized: .cardDesignerTextStyleCaption) + case .footnote: String(localized: .cardDesignerTextStyleFootnote) + case .subheadline: String(localized: .cardDesignerTextStyleSubheadline) + case .callout: String(localized: .cardDesignerTextStyleCallout) + case .body: String(localized: .cardDesignerTextStyleBody) + case .headline: String(localized: .cardDesignerTextStyleHeadline) + case .title3: String(localized: .cardDesignerTextStyleTitle3) + case .title2: String(localized: .cardDesignerTextStyleTitle2) + case .title: String(localized: .cardDesignerTextStyleTitle) + case .largeTitle: String(localized: .cardDesignerTextStyleLargeTitle) } } } @@ -179,15 +40,15 @@ extension CardDesignerConfiguration.FontWeight { var localizedName: String { switch self { - case .ultraLight: String(localized: "Ultra Light") - case .thin: String(localized: "Thin") - case .light: String(localized: "Light") - case .regular: String(localized: "Regular") - case .medium: String(localized: "Medium") - case .semibold: String(localized: "Semibold") - case .bold: String(localized: "Bold") - case .heavy: String(localized: "Heavy") - case .black: String(localized: "Black") + case .ultraLight: String(localized: .cardDesignerFontWeightUltraLight) + case .thin: String(localized: .cardDesignerFontWeightThin) + case .light: String(localized: .cardDesignerFontWeightLight) + case .regular: String(localized: .cardDesignerFontWeightRegular) + case .medium: String(localized: .cardDesignerFontWeightMedium) + case .semibold: String(localized: .cardDesignerFontWeightSemibold) + case .bold: String(localized: .cardDesignerFontWeightBold) + case .heavy: String(localized: .cardDesignerFontWeightHeavy) + case .black: String(localized: .cardDesignerFontWeightBlack) } } } @@ -195,10 +56,10 @@ extension CardDesignerConfiguration.FontDesign { var localizedName: String { switch self { - case .default: String(localized: "Default") - case .serif: String(localized: "Serif") - case .rounded: String(localized: "Rounded") - case .monospaced: String(localized: "Monospaced") + case .default: String(localized: .cardDesignerFontDesignDefault) + case .serif: String(localized: .cardDesignerFontDesignSerif) + case .rounded: String(localized: .cardDesignerFontDesignRounded) + case .monospaced: String(localized: .cardDesignerFontDesignMonospaced) } } } @@ -206,27 +67,27 @@ extension CardDesignerBlendMode { var localizedName: String { switch self { - case .normal: String(localized: "Normal") - case .multiply: String(localized: "Multiply") - case .screen: String(localized: "Screen") - case .overlay: String(localized: "Overlay") - case .darken: String(localized: "Darken") - case .lighten: String(localized: "Lighten") - case .colorDodge: String(localized: "Color Dodge") - case .colorBurn: String(localized: "Color Burn") - case .softLight: String(localized: "Soft Light") - case .hardLight: String(localized: "Hard Light") - case .difference: String(localized: "Difference") - case .exclusion: String(localized: "Exclusion") - case .hue: String(localized: "Hue") - case .saturation: String(localized: "Saturation") - case .color: String(localized: "Color") - case .luminosity: String(localized: "Luminosity") - case .sourceAtop: String(localized: "Source Atop") - case .destinationOver: String(localized: "Destination Over") - case .destinationOut: String(localized: "Destination Out") - case .plusDarker: String(localized: "Plus Darker") - case .plusLighter: String(localized: "Plus Lighter") + case .normal: String(localized: .cardDesignerBlendNormal) + case .multiply: String(localized: .cardDesignerBlendMultiply) + case .screen: String(localized: .cardDesignerBlendScreen) + case .overlay: String(localized: .cardDesignerBlendOverlay) + case .darken: String(localized: .cardDesignerBlendDarken) + case .lighten: String(localized: .cardDesignerBlendLighten) + case .colorDodge: String(localized: .cardDesignerBlendColorDodge) + case .colorBurn: String(localized: .cardDesignerBlendColorBurn) + case .softLight: String(localized: .cardDesignerBlendSoftLight) + case .hardLight: String(localized: .cardDesignerBlendHardLight) + case .difference: String(localized: .cardDesignerBlendDifference) + case .exclusion: String(localized: .cardDesignerBlendExclusion) + case .hue: String(localized: .cardDesignerBlendHue) + case .saturation: String(localized: .cardDesignerBlendSaturation) + case .color: String(localized: .cardDesignerBlendColor) + case .luminosity: String(localized: .cardDesignerBlendLuminosity) + case .sourceAtop: String(localized: .cardDesignerBlendSourceAtop) + case .destinationOver: String(localized: .cardDesignerBlendDestinationOver) + case .destinationOut: String(localized: .cardDesignerBlendDestinationOut) + case .plusDarker: String(localized: .cardDesignerBlendPlusDarker) + case .plusLighter: String(localized: .cardDesignerBlendPlusLighter) } } } diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 95c54b18..eaf07531 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -228,6 +228,1843 @@ } } }, + "cardDesigner.SwiftExport" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Card Design.swift" + } + } + } + }, + "cardDesigner.appearance" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appearance" + } + } + } + }, + "cardDesigner.applyFooter" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "When enabled, changes appear on every location card until you turn this off or reset the studio." + } + } + } + }, + "cardDesigner.applyToApp" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apply to App" + } + } + } + }, + "cardDesigner.arc" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Curved Label" + } + } + } + }, + "cardDesigner.arcTypography" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Curved Label Type" + } + } + } + }, + "cardDesigner.artworkHeight" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Artwork Height" + } + } + } + }, + "cardDesigner.artworkWidth" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Artwork Width" + } + } + } + }, + "cardDesigner.blend.color" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Color" + } + } + } + }, + "cardDesigner.blend.colorBurn" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Color Burn" + } + } + } + }, + "cardDesigner.blend.colorDodge" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Color Dodge" + } + } + } + }, + "cardDesigner.blend.darken" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Darken" + } + } + } + }, + "cardDesigner.blend.destinationOut" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Destination Out" + } + } + } + }, + "cardDesigner.blend.destinationOver" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Destination Over" + } + } + } + }, + "cardDesigner.blend.difference" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Difference" + } + } + } + }, + "cardDesigner.blend.exclusion" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exclusion" + } + } + } + }, + "cardDesigner.blend.hardLight" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hard Light" + } + } + } + }, + "cardDesigner.blend.hue" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hue" + } + } + } + }, + "cardDesigner.blend.lighten" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lighten" + } + } + } + }, + "cardDesigner.blend.luminosity" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Luminosity" + } + } + } + }, + "cardDesigner.blend.multiply" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Multiply" + } + } + } + }, + "cardDesigner.blend.normal" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Normal" + } + } + } + }, + "cardDesigner.blend.overlay" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overlay" + } + } + } + }, + "cardDesigner.blend.plusDarker" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plus Darker" + } + } + } + }, + "cardDesigner.blend.plusLighter" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plus Lighter" + } + } + } + }, + "cardDesigner.blend.saturation" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saturation" + } + } + } + }, + "cardDesigner.blend.screen" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Screen" + } + } + } + }, + "cardDesigner.blend.softLight" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Soft Light" + } + } + } + }, + "cardDesigner.blend.sourceAtop" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Source Atop" + } + } + } + }, + "cardDesigner.blendMode" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blend Mode" + } + } + } + }, + "cardDesigner.card" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Card Geometry" + } + } + } + }, + "cardDesigner.centerX" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Horizontal Center" + } + } + } + }, + "cardDesigner.centerY" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vertical Center" + } + } + } + }, + "cardDesigner.color" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Color" + } + } + } + }, + "cardDesigner.contentSpacing" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Content Spacing" + } + } + } + }, + "cardDesigner.copiedJSON" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copied JSON" + } + } + } + }, + "cardDesigner.copiedSwift" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copied Swift" + } + } + } + }, + "cardDesigner.copyJSON" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copy JSON" + } + } + } + }, + "cardDesigner.copySwift" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copy Swift" + } + } + } + }, + "cardDesigner.cornerRadius" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Corner Radius" + } + } + } + }, + "cardDesigner.dark" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dark" + } + } + } + }, + "cardDesigner.darkInk" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dark Appearance Ink" + } + } + } + }, + "cardDesigner.dashLength" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dash Length" + } + } + } + }, + "cardDesigner.dashSpacing" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dash Spacing" + } + } + } + }, + "cardDesigner.dayUnit" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Day Unit" + } + } + } + }, + "cardDesigner.days" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Days: %lld" + } + } + } + }, + "cardDesigner.design" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Design" + } + } + } + }, + "cardDesigner.diffOnly" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diff Only" + } + } + } + }, + "cardDesigner.entryStamp" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entry Stamp" + } + } + } + }, + "cardDesigner.export" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Export" + } + } + } + }, + "cardDesigner.exportFooter" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Share or copy JSON and Swift output. Diff Only includes just values that differ from the app defaults." + } + } + } + }, + "cardDesigner.extentHeight" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extent Height" + } + } + } + }, + "cardDesigner.extentWidth" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extent Width" + } + } + } + }, + "cardDesigner.fallbackWatermarkSize" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fallback Watermark Size" + } + } + } + }, + "cardDesigner.fillOpacity" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fill Opacity" + } + } + } + }, + "cardDesigner.fontDesign.default" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Default" + } + } + } + }, + "cardDesigner.fontDesign.monospaced" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Monospaced" + } + } + } + }, + "cardDesigner.fontDesign.rounded" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rounded" + } + } + } + }, + "cardDesigner.fontDesign.serif" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serif" + } + } + } + }, + "cardDesigner.fontWeight.black" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Black" + } + } + } + }, + "cardDesigner.fontWeight.bold" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bold" + } + } + } + }, + "cardDesigner.fontWeight.heavy" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Heavy" + } + } + } + }, + "cardDesigner.fontWeight.light" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Light" + } + } + } + }, + "cardDesigner.fontWeight.medium" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medium" + } + } + } + }, + "cardDesigner.fontWeight.regular" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Regular" + } + } + } + }, + "cardDesigner.fontWeight.semibold" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Semibold" + } + } + } + }, + "cardDesigner.fontWeight.thin" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thin" + } + } + } + }, + "cardDesigner.fontWeight.ultraLight" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ultra Light" + } + } + } + }, + "cardDesigner.glassAndInk" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glass and Security Ink" + } + } + } + }, + "cardDesigner.glassTintOpacity" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glass Tint Opacity" + } + } + } + }, + "cardDesigner.glow" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glow" + } + } + } + }, + "cardDesigner.glyphSize" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glyph Size" + } + } + } + }, + "cardDesigner.heroNumber" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Day Count" + } + } + } + }, + "cardDesigner.innerRing" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inner Ring" + } + } + } + }, + "cardDesigner.inset" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inset" + } + } + } + }, + "cardDesigner.insetFraction" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inset Fraction" + } + } + } + }, + "cardDesigner.intensity" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intensity" + } + } + } + }, + "cardDesigner.jsonExport" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Card Design.json" + } + } + } + }, + "cardDesigner.lift" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lift Shadow" + } + } + } + }, + "cardDesigner.light" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Light" + } + } + } + }, + "cardDesigner.lightInk" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Light Appearance Ink" + } + } + } + }, + "cardDesigner.lineWidth" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Line Width" + } + } + } + }, + "cardDesigner.lineWidthFraction" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Line Width Fraction" + } + } + } + }, + "cardDesigner.maximumSweep" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maximum Sweep" + } + } + } + }, + "cardDesigner.microprint" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Microprint Border" + } + } + } + }, + "cardDesigner.nameOpacity" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Name Opacity" + } + } + } + }, + "cardDesigner.offsetY" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vertical Offset" + } + } + } + }, + "cardDesigner.opacity" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opacity" + } + } + } + }, + "cardDesigner.outerRing" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Outer Ring" + } + } + } + }, + "cardDesigner.padding" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Padding" + } + } + } + }, + "cardDesigner.persistence.errorTitle" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couldn't Save Card Design" + } + } + } + }, + "cardDesigner.persistence.unsupportedSchema" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Card designer schema %lld is not supported by this build." + } + } + } + }, + "cardDesigner.pointSize" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Point Size" + } + } + } + }, + "cardDesigner.preview" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preview" + } + } + } + }, + "cardDesigner.previewCaption" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your days in this region" + } + } + } + }, + "cardDesigner.primaryRingSpacing" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Primary Ring Spacing" + } + } + } + }, + "cardDesigner.primaryRosetteOpacity" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Primary Rosette Opacity" + } + } + } + }, + "cardDesigner.progressHeight" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Progress Height" + } + } + } + }, + "cardDesigner.radius" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Radius" + } + } + } + }, + "cardDesigner.radiusFraction" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Radius Fraction" + } + } + } + }, + "cardDesigner.region" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Region" + } + } + } + }, + "cardDesigner.regionArtwork" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Region Artwork" + } + } + } + }, + "cardDesigner.regionName" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Region Name" + } + } + } + }, + "cardDesigner.reset" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reset" + } + } + } + }, + "cardDesigner.resetAll" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reset Everything" + } + } + } + }, + "cardDesigner.resetAllMessage" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This restores every regular, compact, and shared card setting to the app defaults." + } + } + } + }, + "cardDesigner.resetAllTitle" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reset Card Designer?" + } + } + } + }, + "cardDesigner.resetShared" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reset Shared Settings" + } + } + } + }, + "cardDesigner.resetVariant" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reset This Variant" + } + } + } + }, + "cardDesigner.rosettes" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rosettes" + } + } + } + }, + "cardDesigner.rotation" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rotation" + } + } + } + }, + "cardDesigner.scale" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scale" + } + } + } + }, + "cardDesigner.secondaryRingSpacing" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secondary Ring Spacing" + } + } + } + }, + "cardDesigner.secondaryRosetteOpacity" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secondary Rosette Opacity" + } + } + } + }, + "cardDesigner.settings.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tune card presentation live and export the result for implementation." + } + } + } + }, + "cardDesigner.settings.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Developer" + } + } + } + }, + "cardDesigner.settings.keywords" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "card cards design appearance rosette sheen stamp microprint" + } + } + } + }, + "cardDesigner.shadows" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shadows" + } + } + } + }, + "cardDesigner.shareJSON" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Share JSON" + } + } + } + }, + "cardDesigner.shareSwift" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Share Swift" + } + } + } + }, + "cardDesigner.sheen" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Motion Sheen" + } + } + } + }, + "cardDesigner.showArc" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show Curved Label" + } + } + } + }, + "cardDesigner.showStroke" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show Stroke" + } + } + } + }, + "cardDesigner.sizeFraction" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Size Fraction" + } + } + } + }, + "cardDesigner.sizeMode" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Size Mode" + } + } + } + }, + "cardDesigner.sizeMode.fixed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fixed" + } + } + } + }, + "cardDesigner.sizeMode.semantic" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dynamic Type" + } + } + } + }, + "cardDesigner.spacing" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spacing" + } + } + } + }, + "cardDesigner.spacingFraction" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spacing Fraction" + } + } + } + }, + "cardDesigner.stampArtwork" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stamp Artwork" + } + } + } + }, + "cardDesigner.stampContent" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stamp Content" + } + } + } + }, + "cardDesigner.stampSize" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stamp Size" + } + } + } + }, + "cardDesigner.staticGlint" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fallback Glint" + } + } + } + }, + "cardDesigner.staticPitch" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fallback Pitch" + } + } + } + }, + "cardDesigner.staticRoll" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fallback Roll" + } + } + } + }, + "cardDesigner.strokeOpacity" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stroke Opacity" + } + } + } + }, + "cardDesigner.strokeWidth" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stroke Width" + } + } + } + }, + "cardDesigner.sweepPerCharacter" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sweep Per Character" + } + } + } + }, + "cardDesigner.symbolTypography" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Symbol Type" + } + } + } + }, + "cardDesigner.textStyle" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Text Style" + } + } + } + }, + "cardDesigner.textStyle.body" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Body" + } + } + } + }, + "cardDesigner.textStyle.callout" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Callout" + } + } + } + }, + "cardDesigner.textStyle.caption" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Caption" + } + } + } + }, + "cardDesigner.textStyle.caption2" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Caption 2" + } + } + } + }, + "cardDesigner.textStyle.footnote" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Footnote" + } + } + } + }, + "cardDesigner.textStyle.headline" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Headline" + } + } + } + }, + "cardDesigner.textStyle.largeTitle" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Large Title" + } + } + } + }, + "cardDesigner.textStyle.subheadline" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Subheadline" + } + } + } + }, + "cardDesigner.textStyle.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Title" + } + } + } + }, + "cardDesigner.textStyle.title2" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Title 2" + } + } + } + }, + "cardDesigner.textStyle.title3" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Title 3" + } + } + } + }, + "cardDesigner.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Card Designer Studio" + } + } + } + }, + "cardDesigner.tracking" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tracking" + } + } + } + }, + "cardDesigner.typography" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Typography" + } + } + } + }, + "cardDesigner.useRegionOutline" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Use Region Outline" + } + } + } + }, + "cardDesigner.variant" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Variant" + } + } + } + }, + "cardDesigner.variant.compact" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compact" + } + } + } + }, + "cardDesigner.variant.regular" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Regular" + } + } + } + }, + "cardDesigner.watermark" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Watermark" + } + } + } + }, + "cardDesigner.watermarkOffsetX" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Watermark Horizontal Offset" + } + } + } + }, + "cardDesigner.watermarkOffsetY" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Watermark Vertical Offset" + } + } + } + }, + "cardDesigner.watermarkOpacity" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Watermark Opacity" + } + } + } + }, + "cardDesigner.weight" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weight" + } + } + } + }, + "cardDesigner.whiteMix" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "White Mix" + } + } + } + }, + "cardDesigner.wobble" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wobble" + } + } + } + }, + "cardDesigner.year" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Year: %lld" + } + } + } + }, + "cardDesigner.yearTypography" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Year Type" + } + } + } + }, "common.cancel" : { "comment" : "Cancel button text.", "extractionState" : "manual", diff --git a/Where/WhereUI/Sources/Settings/AppearanceSettingsView.swift b/Where/WhereUI/Sources/Settings/AppearanceSettingsView.swift index d929368c..c5740dcf 100644 --- a/Where/WhereUI/Sources/Settings/AppearanceSettingsView.swift +++ b/Where/WhereUI/Sources/Settings/AppearanceSettingsView.swift @@ -41,13 +41,9 @@ struct AppearanceSettingsView: View { } .settingsRow(Item.cardDesigner) } header: { - Text(String(localized: "Developer")) + Text(String(localized: .cardDesignerSettingsHeader)) } footer: { - Text( - String( - localized: "Tune card presentation live and export the result for implementation.", - ), - ) + Text(String(localized: .cardDesignerSettingsFooter)) } } #endif @@ -86,11 +82,7 @@ extension AppearanceSettingsView: SettingsSection { case .appIcon: splitKeywords(String(localized: .settingsKeywordsAppIcon)) #if DEBUG case .cardDesigner: - splitKeywords( - String( - localized: "card cards design appearance rosette sheen stamp microprint", - ), - ) + splitKeywords(String(localized: .cardDesignerSettingsKeywords)) #endif } } From 288843c99f41c8abb14f25e309f5872a32c1e03d Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 2 Aug 2026 10:32:35 -0700 Subject: [PATCH 28/28] Add previews for card designer controls --- .../CardDesignerArtworkControls.swift | 12 ++++++++++++ .../CardDesignerArtworkLayerControls.swift | 12 ++++++++++++ .../CardDesignerCGFloatControl.swift | 12 ++++++++++++ .../CardDesigner/CardDesignerDoubleControl.swift | 12 ++++++++++++ .../CardDesignerEntryStampControls.swift | 9 +++++++++ .../CardDesigner/CardDesignerExportSection.swift | 6 ++++++ ...ardDesignerFractionalTypographyControls.swift | 12 ++++++++++++ .../CardDesignerMicroprintControls.swift | 11 +++++++++++ .../CardDesigner/CardDesignerPreview.swift | 16 ++++++++++++++++ .../CardDesigner/CardDesignerSectionHeader.swift | 8 ++++++++ .../CardDesignerSecurityPrintControls.swift | 12 ++++++++++++ .../CardDesignerSharedControls.swift | 7 +++++++ .../CardDesignerTypographyControls.swift | 12 ++++++++++++ .../CardDesignerVariantControls.swift | 7 +++++++ 14 files changed, 148 insertions(+) diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerArtworkControls.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerArtworkControls.swift index c40ef6aa..d3cb5923 100644 --- a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerArtworkControls.swift +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerArtworkControls.swift @@ -19,4 +19,16 @@ } } } + + #Preview { + @Previewable @State var configuration = CardDesignerConfiguration.standard + Form { + Section { + CardDesignerArtworkControls( + usesRegionShape: $configuration.regular.usesRegionShape, + regionShape: $configuration.regular.regionShape, + ) + } + } + } #endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerArtworkLayerControls.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerArtworkLayerControls.swift index 97835d8e..c9705efb 100644 --- a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerArtworkLayerControls.swift +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerArtworkLayerControls.swift @@ -63,4 +63,16 @@ } } } + + #Preview { + @Previewable @State var configuration = CardDesignerConfiguration.standard + Form { + Section { + CardDesignerArtworkLayerControls( + title: .cardDesignerWatermark, + artwork: $configuration.regular.regionShape.watermark, + ) + } + } + } #endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerCGFloatControl.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerCGFloatControl.swift index 1ed82669..ab240702 100644 --- a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerCGFloatControl.swift +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerCGFloatControl.swift @@ -22,4 +22,16 @@ } } } + + #Preview { + @Previewable @State var value: CGFloat = 28 + Form { + CardDesignerCGFloatControl( + title: .cardDesignerCornerRadius, + value: $value, + range: 0 ... 60, + step: 1, + ) + } + } #endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerDoubleControl.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerDoubleControl.swift index 81620bc9..c986663c 100644 --- a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerDoubleControl.swift +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerDoubleControl.swift @@ -22,4 +22,16 @@ } } } + + #Preview { + @Previewable @State var value = 0.65 + Form { + CardDesignerDoubleControl( + title: .cardDesignerOpacity, + value: $value, + range: 0 ... 1, + step: 0.01, + ) + } + } #endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerEntryStampControls.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerEntryStampControls.swift index 2bc01410..8a039f8c 100644 --- a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerEntryStampControls.swift +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerEntryStampControls.swift @@ -132,4 +132,13 @@ } } } + + #Preview { + @Previewable @State var configuration = CardDesignerConfiguration.standard + Form { + Section { + CardDesignerEntryStampControls(stamp: $configuration.regular.entryStamp) + } + } + } #endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExportSection.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExportSection.swift index 93aa76d6..aabe624b 100644 --- a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExportSection.swift +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExportSection.swift @@ -87,4 +87,10 @@ case swift } } + + #Preview { + Form { + CardDesignerExportSection(configuration: .standard) + } + } #endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerFractionalTypographyControls.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerFractionalTypographyControls.swift index c05d2069..574eff25 100644 --- a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerFractionalTypographyControls.swift +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerFractionalTypographyControls.swift @@ -28,4 +28,16 @@ } } } + + #Preview { + @Previewable @State var configuration = CardDesignerConfiguration.standard + Form { + Section { + CardDesignerFractionalTypographyControls( + title: .cardDesignerSymbolTypography, + typography: $configuration.regular.entryStamp.content.symbolFont, + ) + } + } + } #endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerMicroprintControls.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerMicroprintControls.swift index 436558eb..1e893940 100644 --- a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerMicroprintControls.swift +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerMicroprintControls.swift @@ -31,4 +31,15 @@ ) } } + + #Preview { + @Previewable @State var configuration = CardDesignerConfiguration.standard + Form { + Section { + CardDesignerMicroprintControls( + border: $configuration.regular.regionShape.securityBorder, + ) + } + } + } #endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerPreview.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerPreview.swift index c17cf79a..89b2403d 100644 --- a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerPreview.swift +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerPreview.swift @@ -41,4 +41,20 @@ ) } } + + #Preview { + @Previewable @State var tilt = TiltProvider() + CardDesignerPreview( + configuration: .standard, + variant: .regular, + colorScheme: .light, + region: .newYork, + color: .indigo, + days: 128, + year: 2026, + tilt: tilt, + ) + .padding() + .whereBroadwayRoot() + } #endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerSectionHeader.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerSectionHeader.swift index 589785cd..de85569d 100644 --- a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerSectionHeader.swift +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerSectionHeader.swift @@ -15,4 +15,12 @@ } } } + + #Preview { + Form { + Section {} header: { + CardDesignerSectionHeader(title: .cardDesignerTypography, reset: {}) + } + } + } #endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerSecurityPrintControls.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerSecurityPrintControls.swift index 81809523..e1041eaa 100644 --- a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerSecurityPrintControls.swift +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerSecurityPrintControls.swift @@ -26,4 +26,16 @@ } } } + + #Preview { + @Previewable @State var configuration = CardDesignerConfiguration.standard + Form { + Section { + CardDesignerSecurityPrintControls( + title: .cardDesignerDarkInk, + securityPrint: $configuration.shared.darkSecurityPrint, + ) + } + } + } #endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerSharedControls.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerSharedControls.swift index b5e52727..1c0a0d77 100644 --- a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerSharedControls.swift +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerSharedControls.swift @@ -50,4 +50,11 @@ } } } + + #Preview { + @Previewable @State var configuration = CardDesignerConfiguration.standard + Form { + CardDesignerSharedControls(shared: $configuration.shared, reset: {}) + } + } #endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerTypographyControls.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerTypographyControls.swift index 4bd1d542..c4718069 100644 --- a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerTypographyControls.swift +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerTypographyControls.swift @@ -44,4 +44,16 @@ } } } + + #Preview { + @Previewable @State var configuration = CardDesignerConfiguration.standard + Form { + Section { + CardDesignerTypographyControls( + title: .cardDesignerRegionName, + typography: $configuration.regular.regionNameTypography, + ) + } + } + } #endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerVariantControls.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerVariantControls.swift index d6e21910..0a09f44e 100644 --- a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerVariantControls.swift +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerVariantControls.swift @@ -223,4 +223,11 @@ } } } + + #Preview { + @Previewable @State var configuration = CardDesignerConfiguration.standard + Form { + CardDesignerVariantControls(card: $configuration.regular, reset: { _ in }) + } + } #endif