diff --git a/Where/RegionKit/AGENTS.md b/Where/RegionKit/AGENTS.md index b3e629cb..7ffeb0a5 100644 --- a/Where/RegionKit/AGENTS.md +++ b/Where/RegionKit/AGENTS.md @@ -33,12 +33,16 @@ 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. `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 c1575ba5..8f4ad453 100644 --- a/Where/RegionKit/README.md +++ b/Where/RegionKit/README.md @@ -32,9 +32,11 @@ 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. `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/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..55d34561 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 @@ -79,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, @@ -147,6 +168,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 +203,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/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/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/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/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/AGENTS.md b/Where/WhereUI/AGENTS.md index 6fb1049a..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 @@ -43,6 +45,12 @@ 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/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)) @@ -97,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 4e0daddd..d9ccdb36 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -213,6 +213,27 @@ 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 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. 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. 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 +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—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 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/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__/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 564d16fe..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:a2c8d441e90743ac57d9055fa361aeba423fbb560efb0c5f215d0d4027af1493 -size 1016568 +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 e0e0df98..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:b3d06689d02ac0a9c511df63eafc8f8cd7efa72f5e412075a4a1559675b106cc -size 239269 +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 09bc46ed..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:30f8b24d088da2d8feb4fb29b3ef95ba5d901402aa157e8520b7ec10c93ac0f2 -size 228592 +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__/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..b972b926 --- /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:4b6c6fd0398f1bab5c10a2eb69706d8e6ede3dbc25075c7b8eb896712eaadf8b +size 1124176 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 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..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:823c6173b33c9c53085e8a40b32d7fbd09102f26ed74df4037bb88729f47a488 -size 3383861 +oid sha256:45d18ce2f6a55c6a73ebe9ceb2f05e81d0d6c55084675575559ffe2c02841160 +size 3342904 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..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:7ad057408f2f3c0f438ea1fe8c862beb4159c141444f962ba6aba624ed11bca8 -size 2022712 +oid sha256:01b8fe3be00337d5848fcd10b01b4a38a5ccb6e5c2effd6c80d7a346fc27f495 +size 1916913 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPad.png index a0e86786..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:bb9aa09ee6ddb5ec20530eeada7d9becd2d33f81b7974e099ffd252d013015c6 -size 3323906 +oid sha256:2292ccd94d76fc0ba6040e919443d8edb013ed8d61b5bfad30520e237d1a485e +size 3210057 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..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:7b8cc655632c7d8e1be8b3760d879a1fe29058fa6af87bbe5cad4f3e03b60d33 -size 2265842 +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 126cf0f9..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:84ba760ccfb6f6b240245751435300dd525c3a0ceb2ac62402044d0bf9c12578 -size 3688736 +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 54880710..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:c4eaa176126332ba54d9153197d47ebd03d59efc08d90ba4a76181db625ae0d1 -size 3269793 +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 70b72b20..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:f412f543531c991fd31abf5c989ededb5531d640d3deb3427da47405a9b82284 -size 3456597 +oid sha256:b8190f1ff9eb89896aa9b6acddfc2dab0c54f23507d66da4c22fd6d810e8274d +size 3596757 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Loaded_iPhone.png index 93e7070a..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:59089d04f7b17bfcf322948942f5806be1d0c4225b9af930ba93b8f795287a23 -size 2225238 +oid sha256:53a01e2fef1cf6b71dae752ab05959008662f13f9f14c3455012b3a851917e99 +size 2026647 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..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:056d75b639133f36cd3b2be3143b512d8db4f7deec9f9bf4eedd572bf1d2fac3 -size 1381994 +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 f1686840..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:e9fb579f1afdc26ae94f9c92b357f0820674d553ed79d341d71529eb1ac34af2 -size 2679259 +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 4027f331..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:c074226034ff84c3436e5f5225b0b15d789b8e23aab6fdb3e8be7028886ce106 -size 2117829 +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 cf60e9ae..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:639c76c2c812870c1314ad6a788a476a3c8db8f5eb3685b41e8dca83cf566f83 -size 2116707 +oid sha256:40dac293f9ccc1326134c67f70cbb62930f3f0122cb527a69350fb6ad89278dc +size 2195306 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.MissingDays_iPhone.png index fb9f5eed..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:c206921e577c60cc9c568e4531de40172c0f827efd7a247018d5c82fbf2b9e55 -size 1250815 +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 fa4b608e..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:58a9611378865f81cf2ca61e4c8d9516e7e7033fe028b66f4feb48f3567b6dfb -size 1170253 +oid sha256:5a6d62f89bdb84ed5e0ca73639973c6d743e0637d10fddb1e4b408ccb6a107f1 +size 1187301 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..5b71484c --- /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: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 new file mode 100644 index 00000000..8a649afe --- /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:175cf0db7bc895a873567e1bd6b8bbd93ea5b3c90d370cc8d6d8812cb7f08be1 +size 1470624 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..64c29052 --- /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: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 new file mode 100644 index 00000000..ea280350 --- /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:4f15ada38d06d6d13da90f17d09d318aeace0b78100a9d17275141240912736c +size 1326736 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/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/Developer/CardDesigner/CardDesignerArtworkControls.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerArtworkControls.swift new file mode 100644 index 00000000..d3cb5923 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerArtworkControls.swift @@ -0,0 +1,34 @@ +#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, + ) + } + } + } + + #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 new file mode 100644 index 00000000..c9705efb --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerArtworkLayerControls.swift @@ -0,0 +1,78 @@ +#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) + } + } + } + + #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 new file mode 100644 index 00000000..ab240702 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerCGFloatControl.swift @@ -0,0 +1,37 @@ +#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)) + } + } + } + + #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/CardDesignerConfiguration.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerConfiguration.swift new file mode 100644 index 00000000..21177c9d --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerConfiguration.swift @@ -0,0 +1,739 @@ +#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) { + 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) + } + + 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 usesRegionShape: Bool + var regionShape: RegionShape + var sheen: Sheen + var rosette: Rosette + var glow: Shadow + var lift: Shadow + + 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, fallbackArc: fallbackArc) + regionNameTypography = Typography(style.regionNameTypography) + regionNameTracking = style.regionNameTracking + heroNumberTypography = Typography(style.heroNumberTypography) + dayUnitTypography = Typography(style.dayUnitTypography) + watermarkFontSize = style.watermarkFontSize + watermarkOffset = Offset(style.watermarkOffset) + usesRegionShape = style.regionShape != nil + regionShape = RegionShape(style.regionShape ?? fallbackRegionShape) + 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: usesRegionShape ? regionShape.style : nil, + 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 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): + 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) + } + + var style: WhereStylesheet.CardStyle.Typography { + .init( + size: size, + weight: weight.style, + design: design.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 + 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 showsArc: Bool + var arc: Arc + var rotationDegrees: Double + + 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) + showsArc = style.arc != nil + arc = Arc(style.arc ?? fallbackArc) + rotationDegrees = style.rotationDegrees + } + + var style: WhereStylesheet.CardStyle.EntryStamp { + .init( + size: size, + outerRing: outerRing.style, + innerRing: innerRing.style, + content: content.style, + arc: showsArc ? arc.style : nil, + 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) { + 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) + } + + 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 showsStroke: Bool + var stroke: Stroke + + 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 + showsStroke = style.stroke != nil + stroke = Stroke(style.stroke ?? fallbackStroke) + } + + var style: WhereStylesheet.CardStyle.RegionShape.Artwork { + .init( + center: center.point, + extent: extent.size, + scale: scale, + fillOpacity: fillOpacity, + stroke: showsStroke ? stroke.style : nil, + ) + } + } + + 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/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/CardDesignerDoubleControl.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerDoubleControl.swift new file mode 100644 index 00000000..c986663c --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerDoubleControl.swift @@ -0,0 +1,37 @@ +#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)) + } + } + } + + #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 new file mode 100644 index 00000000..8a039f8c --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerEntryStampControls.swift @@ -0,0 +1,144 @@ +#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, + ) + } + } + } + } + + #Preview { + @Previewable @State var configuration = CardDesignerConfiguration.standard + Form { + Section { + CardDesignerEntryStampControls(stamp: $configuration.regular.entryStamp) + } + } + } +#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..56f89963 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExport.swift @@ -0,0 +1,268 @@ +#if DEBUG + import CoreTransferable + import Foundation + import UniformTypeIdentifiers + + struct CardDesignerJSONExport: Transferable { + let configuration: CardDesignerConfiguration + let diffOnly: Bool + + static var transferRepresentation: some TransferRepresentation { + DataRepresentation(exportedContentType: .json) { + try CardDesignerJSONExporter.data( + for: $0.configuration, + diffOnly: $0.diffOnly, + ) + } + .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 { + 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)). + // 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.usesRegionShape ? card.regionShape : nil, + 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.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))" + } + + private static func entryStamp( + _ stamp: CardDesignerConfiguration.EntryStamp, + indent: Int, + ) -> String { + let arc = stamp.showsArc ? arc(stamp.arc, 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.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 + .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/CardDesignerExportSection.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExportSection.swift new file mode 100644 index 00000000..aabe624b --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerExportSection.swift @@ -0,0 +1,96 @@ +#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, + diffOnly: diffOnly, + ), + preview: SharePreview(String(localized: .cardDesignerJsonExport)), + ) { + Label( + 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, + diffOnly: diffOnly, + ), + ), + preview: SharePreview(String(localized: .cardDesignerSwiftExport)), + ) { + Label( + 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 + } + } + + #Preview { + Form { + CardDesignerExportSection(configuration: .standard) + } + } +#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..574eff25 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerFractionalTypographyControls.swift @@ -0,0 +1,43 @@ +#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) + } + } + } + + #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/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/CardDesignerMicroprintControls.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerMicroprintControls.swift new file mode 100644 index 00000000..1e893940 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerMicroprintControls.swift @@ -0,0 +1,45 @@ +#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, + ) + } + } + + #Preview { + @Previewable @State var configuration = CardDesignerConfiguration.standard + Form { + Section { + CardDesignerMicroprintControls( + border: $configuration.regular.regionShape.securityBorder, + ) + } + } + } +#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..2aea2f10 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerModel.swift @@ -0,0 +1,170 @@ +#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? + + var isShowingPersistenceError: Bool { + get { persistenceError != nil } + set { + guard newValue == false else { return } + persistenceError = nil + } + } + + 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 + } + } + + init(configuration: CardDesignerConfiguration) { + store = .standard + key = "where.debug.card-designer.preview" + self.configuration = configuration + appliesToApp = false + } + + 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 + } + + 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.usesRegionShape = standardCard.usesRegionShape + card.regionShape = standardCard.regionShape + case .microprint: + card.regionShape.securityBorder = standardCard.regionShape.securityBorder + 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): + String(localized: .cardDesignerPersistenceUnsupportedSchema(version)) + } + } + } +#endif diff --git a/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerPreview.swift b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerPreview.swift new file mode 100644 index 00000000..89b2403d --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerPreview.swift @@ -0,0 +1,60 @@ +#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, + ) + } + } + + #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 new file mode 100644 index 00000000..de85569d --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerSectionHeader.swift @@ -0,0 +1,26 @@ +#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) + } + } + } + + #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 new file mode 100644 index 00000000..e1041eaa --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerSecurityPrintControls.swift @@ -0,0 +1,41 @@ +#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) + } + } + } + + #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 new file mode 100644 index 00000000..1c0a0d77 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerSharedControls.swift @@ -0,0 +1,60 @@ +#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) + } + } + } + + #Preview { + @Previewable @State var configuration = CardDesignerConfiguration.standard + Form { + CardDesignerSharedControls(shared: $configuration.shared, 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..6a29b140 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerStrings.swift @@ -0,0 +1,94 @@ +#if DEBUG + import Foundation + + extension CardDesignerConfiguration.Variant { + var localizedName: String { + switch self { + case .regular: String(localized: .cardDesignerVariantRegular) + case .compact: String(localized: .cardDesignerVariantCompact) + } + } + } + + extension CardDesignerConfiguration.SizeMode { + var localizedName: String { + switch self { + case .fixed: String(localized: .cardDesignerSizeModeFixed) + case .semantic: String(localized: .cardDesignerSizeModeSemantic) + } + } + } + + extension CardDesignerConfiguration.TextStyle { + var localizedName: String { + switch self { + 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) + } + } + } + + extension CardDesignerConfiguration.FontWeight { + var localizedName: String { + switch self { + 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) + } + } + } + + extension CardDesignerConfiguration.FontDesign { + var localizedName: String { + switch self { + case .default: String(localized: .cardDesignerFontDesignDefault) + case .serif: String(localized: .cardDesignerFontDesignSerif) + case .rounded: String(localized: .cardDesignerFontDesignRounded) + case .monospaced: String(localized: .cardDesignerFontDesignMonospaced) + } + } + } + + extension CardDesignerBlendMode { + var localizedName: String { + switch self { + 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) + } + } + } +#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..b0ae8f57 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerStudioView.swift @@ -0,0 +1,193 @@ +#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? + @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 + VStack(spacing: 0) { + CardDesignerPreview( + configuration: model.configuration, + variant: variant, + colorScheme: previewColorScheme ?? systemColorScheme, + 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 as ColorScheme?) + Text(String(localized: .cardDesignerDark)) + .tag(ColorScheme.dark as ColorScheme?) + } + .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) + } + .preferredColorScheme(previewColorScheme) + .onAppear { + previewColorScheme = previewColorScheme ?? systemColorScheme + 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..c4718069 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerTypographyControls.swift @@ -0,0 +1,59 @@ +#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) + } + } + } + + #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 new file mode 100644 index 00000000..0a09f44e --- /dev/null +++ b/Where/WhereUI/Sources/Developer/CardDesigner/CardDesignerVariantControls.swift @@ -0,0 +1,233 @@ +#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) }, + ) + } + } + } + } + + #Preview { + @Previewable @State var configuration = CardDesignerConfiguration.standard + Form { + CardDesignerVariantControls(card: $configuration.regular, reset: { _ in }) + } + } +#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/Model/RegionAppearanceCatalog.swift b/Where/WhereUI/Sources/Model/RegionAppearanceCatalog.swift index 57a94d29..b3434eca 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,8 +122,7 @@ public enum RegionAppearanceCatalog { RegionAppearance(color: .teal, emoji: "🧭", symbolName: "location.magnifyingglass"), ] - /// 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. + /// 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 colors[sum % colors.count] @@ -146,6 +145,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/Primary/CalendarContentView.swift b/Where/WhereUI/Sources/Primary/CalendarContentView.swift index 6547131a..2801e2f2 100644 --- a/Where/WhereUI/Sources/Primary/CalendarContentView.swift +++ b/Where/WhereUI/Sources/Primary/CalendarContentView.swift @@ -266,6 +266,18 @@ 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 + } + + /// 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))) @@ -301,15 +313,14 @@ 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 - RoundedRectangle(cornerRadius: calendar.month.cornerRadius) + // gets the accent card (bluer wash, text, and heavier border). + 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/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/RegionOutlineArtwork.swift b/Where/WhereUI/Sources/Primary/RegionOutlineArtwork.swift new file mode 100644 index 00000000..f95bf146 --- /dev/null +++ b/Where/WhereUI/Sources/Primary/RegionOutlineArtwork.swift @@ -0,0 +1,69 @@ +import RegionKit +import SwiftUI + +/// Draws a cached, pre-projected region path with stylesheet-owned projection +/// geometry and ink treatment while preserving its geographic aspect. +struct RegionOutlineArtwork: View { + let path: Path + let tint: Color + 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 scale = min( + size.width * style.extent.width / bounds.width, + size.height * style.extent.height / bounds.height, + ) * style.scale + var projectedContext = context + 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) + + 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) + } +} + +#if DEBUG + #Preview { + RegionOutlineArtworkPreview() + .padding() + } + + private struct RegionOutlineArtworkPreview: View { + @State private var path = Path() + private let cache = RegionOutlinePathCache() + + var body: some View { + if let style = WhereStylesheet.default.card.regular.regionShape { + RegionOutlineArtwork( + path: path, + tint: .orange, + style: style.watermark, + ) + .frame(width: 320, height: 180) + .background(.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 28)) + .task { + path = await cache.path(for: .california, resolution: .medium) + } + } + } + } +#endif diff --git a/Where/WhereUI/Sources/Primary/RegionOutlinePathCache.swift b/Where/WhereUI/Sources/Primary/RegionOutlinePathCache.swift new file mode 100644 index 00000000..7b3e8433 --- /dev/null +++ b/Where/WhereUI/Sources/Primary/RegionOutlinePathCache.swift @@ -0,0 +1,133 @@ +import Foundation +import RegionKit +import SwiftUI + +/// 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 { + enum Resolution: Hashable { + case full + case medium + case small + case micro + + /// Maximum normalized deviation chosen for each target size. The small + /// 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 + } + } + } + + 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/RegionOutlineSecurityBorder.swift b/Where/WhereUI/Sources/Primary/RegionOutlineSecurityBorder.swift new file mode 100644 index 00000000..20e9bb22 --- /dev/null +++ b/Where/WhereUI/Sources/Primary/RegionOutlineSecurityBorder.swift @@ -0,0 +1,212 @@ +import RegionKit +import SwiftUI + +/// 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 + 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 + } +} + +#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 diff --git a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift index 84e8ac9e..a4d9566e 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 @@ -32,9 +33,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 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 @@ -43,19 +43,47 @@ struct RegionSummaryCard: View { /// other caller leaves it `nil` and gets the resolved look. var styleOverride: RegionStyle? + /// Loaded once per regular card from the root-owned UI path cache. The large + /// watermark uses medium fidelity, the stamp uses small, and the repeated + /// border uses micro. + @State private var regionPaths: RegionArtworkPaths? + @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 { 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 { + cardStyles.securityPrint.tint(style.tint) + } + private var cardShape: RoundedRectangle { RoundedRectangle(cornerRadius: card.cornerRadius, style: .continuous) } @@ -69,10 +97,18 @@ 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 { - stylesheet.card.dayCount + cardStyles.dayCount } /// A circular rubber-stamp "entry" impression: the region glyph and year @@ -83,23 +119,24 @@ struct RegionSummaryCard: View { title: regionDays.region.localizedName.uppercased(), year: year, symbolName: style.symbolName, - tint: style.tint, - size: card.entryStampSize, - showsArcText: card.showsArcText, + tint: securityPrintTint, + style: card.entryStamp, + regionPath: regionPaths?.stamp ?? Path(), + regionShape: card.regionShape, ) } /// 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` // 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 + let rosetteFill = cardStyles.rosetteFill return ZStack { Canvas { context, size in func drawRosette(center: CGPoint, spacing: CGFloat, opacity: Double) { @@ -138,52 +175,67 @@ 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, + let regionPath = regionPaths?.microprint, + !regionPath.isEmpty + { + RegionOutlineSecurityBorder( + path: regionPath, + tint: tint, + cornerRadius: card.cornerRadius, + style: regionShape.securityBorder, + ) + } + + if + let regionShape = card.regionShape, + let regionPath = regionPaths?.watermark, + !regionPath.isEmpty + { + RegionOutlineArtwork( + path: regionPath, + tint: tint, + style: regionShape.watermark, + ) + } else { + Image(systemName: style.symbolName) + .font(.system(size: card.watermarkFontSize)) + .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(cardStyles.securityPrint.backgroundBlendMode) .clipShape(cardShape) .allowsHitTesting(false) .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 { + 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, + ) + 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 } var body: some View { @@ -191,13 +243,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)) @@ -220,11 +272,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) } @@ -249,18 +301,18 @@ 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, ) - .holographicSheen( - roll: tilt?.roll ?? 0, - pitch: tilt?.pitch ?? 0, + .tiltSheen( + tilt: tilt, + staticRoll: card.sheen.staticPose.roll, + staticPitch: card.sheen.staticPose.pitch, in: cardShape, - tint: .white, - intensity: card.holographicIntensity, + 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 @@ -282,78 +334,117 @@ struct RegionSummaryCard: View { days: regionDays.days, ), ) + .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. 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) - - VStack(spacing: size * 0.02) { - Image(systemName: symbolName) - .font(.system(size: size * 0.26)) + .padding(size * style.innerRing.insetFraction) + + VStack(spacing: size * style.content.spacingFraction) { + if let regionShape, !regionPath.isEmpty { + RegionOutlineArtwork( + path: regionPath, + tint: tint, + style: regionShape.stamp, + ) + .frame( + width: size * style.content.artworkExtent.width, + height: size * style.content.artworkExtent.height, + ) + } else { + Image(systemName: symbolName) + .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) } } +/// 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`, /// 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 /// 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)) } } @@ -381,10 +472,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/Regions/RegionCustomizeView.swift b/Where/WhereUI/Sources/Regions/RegionCustomizeView.swift index f2f75b47..14b125ce 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 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 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) } @@ -86,7 +96,7 @@ struct RegionAppearanceEditor: View { if isSelected { Image(systemName: "checkmark") .font(.caption.weight(.bold)) - .foregroundStyle(.white) + .foregroundStyle(token.selectionForeground) } } } @@ -254,11 +264,30 @@ 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()) + } + } + + whereSnapshot(name: "NeutralEditor", configurations: .phoneLightDark) { + NavigationStack { + RegionCustomizeView(model: neutralPreviewModel()) + } + } } - .whereBroadwayRoot() + + private static func neutralPreviewModel() -> PrimaryRegionSelectionModel { + let model = PreviewSupport.primaryRegionSelectionModel() + model.setColor(.slate, for: .california) + return model + } + } + + #Preview("Stepping") { + RegionCustomizeView.snapshotPreviews } #endif 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/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/Settings/AppearanceSettingsView.swift b/Where/WhereUI/Sources/Settings/AppearanceSettingsView.swift index 50fef1a3..c5740dcf 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,26 @@ 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: .cardDesignerSettingsHeader)) + } footer: { + Text(String(localized: .cardDesignerSettingsFooter)) + } + } + #endif } } .navigationTitle(String(localized: .settingsAppearanceGroup)) @@ -41,16 +64,26 @@ 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: .cardDesignerSettingsKeywords)) + #endif } } } @@ -72,6 +105,7 @@ extension AppearanceSettingsView: SettingsSection { title: "Appearance Settings", routes: [ .modal(to: AppIconView.flyoverID), + .push(to: CardDesignerStudioView.flyoverID), ], ) { _ in AppearanceSettingsView() diff --git a/Where/WhereUI/Sources/Shared/HolographicSheen.swift b/Where/WhereUI/Sources/Shared/HolographicSheen.swift deleted file mode 100644 index aa9071ec..00000000 --- a/Where/WhereUI/Sources/Shared/HolographicSheen.swift +++ /dev/null @@ -1,118 +0,0 @@ -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. -/// -/// 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. -struct HolographicSheen: ViewModifier { - var roll: Double - var pitch: Double - var shape: ClipShape - var tint: Color = .white - /// Overall strength of the effect, `0...1`. - var intensity: Double = 1 - - @Environment(\.accessibilityReduceMotion) private var reduceMotion - - func body(content: Content) -> some View { - content.overlay { - sheen - .clipShape(shape) - .allowsHitTesting(false) - .accessibilityHidden(true) - } - } - - /// Tilt actually used to place the highlight. Reduce Motion replaces the - /// live values with a fixed, gentle diagonal so the sheen stays put. - private var activeRoll: Double { - reduceMotion ? 0.18 : roll.clamped - } - - private var activePitch: Double { - reduceMotion ? -0.12 : pitch.clamped - } - - private var sheen: some View { - GeometryReader { proxy in - let diagonal = max(proxy.size.width, proxy.size.height) - let glint = UnitPoint( - x: 0.5 + activeRoll * 0.55, - y: 0.5 - activePitch * 0.55, - ) - - ZStack { - // Rainbow foil that slides laterally as the device rolls. - LinearGradient( - colors: Self.foilHues, - startPoint: UnitPoint(x: activeRoll * 0.3, y: 0), - endPoint: UnitPoint(x: 1 + activeRoll * 0.3, y: 1), - ) - .opacity(0.28 * intensity) - .blendMode(.plusLighter) - - // Specular glint that tracks the tilt like a moving light. - RadialGradient( - colors: [tint.opacity(0.85 * intensity), tint.opacity(0)], - center: glint, - startRadius: 0, - endRadius: diagonal * 0.75, - ) - .blendMode(.plusLighter) - } - } - } - - /// Translucent rainbow used for the foil wash. - private static var foilHues: [Color] { - [.pink, .purple, .blue, .cyan, .green, .yellow, .orange] - } -} - -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( - roll: Double, - pitch: Double, - in shape: some Shape, - tint: Color = .white, - intensity: Double = 1, - ) -> some View { - modifier(HolographicSheen( - roll: roll, - pitch: pitch, - shape: shape, - tint: tint, - intensity: intensity, - )) - } -} - -extension Double { - /// Clamped to `-1...1` so out-of-range gravity readings can't fling the - /// glint off the card. - fileprivate var clamped: Double { - min(1, max(-1, self)) - } -} - -#if DEBUG - #Preview { - RoundedRectangle(cornerRadius: 28, style: .continuous) - .fill(.indigo.gradient) - .frame(width: 320, height: 180) - .holographicSheen( - roll: 0.4, - pitch: -0.2, - in: RoundedRectangle(cornerRadius: 28, style: .continuous), - ) - .padding() - } -#endif diff --git a/Where/WhereUI/Sources/Shared/TiltProvider.swift b/Where/WhereUI/Sources/Shared/TiltProvider.swift index 49d04604..c7a9e68e 100644 --- a/Where/WhereUI/Sources/Shared/TiltProvider.swift +++ b/Where/WhereUI/Sources/Shared/TiltProvider.swift @@ -2,15 +2,16 @@ 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 /// `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/TiltSheen.swift b/Where/WhereUI/Sources/Shared/TiltSheen.swift new file mode 100644 index 00000000..8fdace9b --- /dev/null +++ b/Where/WhereUI/Sources/Shared/TiltSheen.swift @@ -0,0 +1,139 @@ +import SwiftUI + +/// 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. +/// +/// 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 tilt: TiltProvider? + var staticRoll: Double + var staticPitch: Double + var shape: ClipShape + /// Grayscale-wash and live-glint strength, `0...1`. + var intensity: Double = 1 + /// White-glint strength when `usesStaticPose`, `0...1`. + var staticGlintIntensity: Double = 1 + + @MotionIsStatic private var motionIsStatic + + func body(content: Content) -> some View { + content.overlay { + sheen + .clipShape(shape) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + } + + /// 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 : (tilt?.roll ?? staticRoll).clamped + } + + private var activePitch: Double { + usesStaticPose ? staticPitch.clamped : (tilt?.pitch ?? staticPitch).clamped + } + + private var usesStaticPose: Bool { + motionIsStatic || tilt?.hasLiveSample != true + } + + private var glintIntensity: Double { + usesStaticPose ? staticGlintIntensity : intensity + } + + private var sheen: some View { + GeometryReader { proxy in + let diagonal = max(proxy.size.width, proxy.size.height) + let glint = UnitPoint( + x: 0.5 + activeRoll * 0.55, + y: 0.5 - activePitch * 0.55, + ) + + ZStack { + // A color-neutral luminance wash that slides as the device + // rolls, preserving the region tint beneath it. + LinearGradient( + 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(.softLight) + + // Specular glint that tracks the tilt like a moving light. + RadialGradient( + colors: [ + Color.white.opacity(0.85 * glintIntensity), + Color.white.opacity(0), + ], + center: glint, + startRadius: 0, + endRadius: diagonal * 0.75, + ) + .blendMode(.softLight) + } + } + } + + /// 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 grayscale sheen clipped to `shape`. Pass the same + /// 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( + tilt: TiltProvider?, + staticRoll: Double, + staticPitch: Double, + in shape: some Shape, + intensity: Double = 1, + staticGlintIntensity: Double = 1, + ) -> some View { + modifier(TiltSheen( + tilt: tilt, + staticRoll: staticRoll, + staticPitch: staticPitch, + shape: shape, + intensity: intensity, + staticGlintIntensity: staticGlintIntensity, + )) + } +} + +extension Double { + /// Clamped to `-1...1` so out-of-range gravity readings can't fling the + /// glint off the card. + fileprivate var clamped: Double { + min(1, max(-1, self)) + } +} + +#if DEBUG + #Preview { + RoundedRectangle(cornerRadius: 28, style: .continuous) + .fill(.indigo.gradient) + .frame(width: 320, height: 180) + .tiltSheen( + tilt: nil, + staticRoll: 0.4, + staticPitch: -0.2, + in: RoundedRectangle(cornerRadius: 28, style: .continuous), + staticGlintIntensity: 1, + ) + .padding() + } +#endif diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index 7b5cf3bf..8741a09f 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 } + + // 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 + } } /// The fixed token set: the fallback used off the `View` tree (layout @@ -243,27 +249,21 @@ 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 regionNameFont: Font + var entryStamp: EntryStamp + 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. var watermarkOffset: CGSize - /// Holographic sheen strength (the Primary cards catch more light). - var holographicIntensity: Double - /// 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 + /// 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 var rosette: Rosette /// The tight rim glow; its `radius` drops to 0 under Reduce Transparency. var glow: Shadow @@ -278,6 +278,228 @@ 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 { + 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 { + 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 + var glyphSize: CGFloat + var spacing: CGFloat + var opacity: Double + } + } + + struct Sheen: Equatable { + var intensity: Double + /// 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 + + 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 { @@ -298,36 +520,48 @@ 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 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 - /// 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 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 + /// 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 } + return regionTint.mix( + with: .white, + by: whiteMix, + in: .perceptual, + ) + } + + 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 /// — a passive sample lands, a manual day commits, a remote import /// arrives — and the number showing goes stale. @@ -391,25 +625,61 @@ 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 // 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), - holographicIntensity: 1, - frameOuterLineWidth: 3.5, - showsPerforationRing: true, - innerFrameInset: 16, + regionShape: CardStyle.RegionShape( + 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, + spacing: 11, + opacity: 0.22, + ), + ), + 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), + ), rosette: CardStyle.Rosette( wobble: 3, - lineWidth: 3, + lineWidth: 2, primaryRingSpacing: 18, secondaryRingSpacing: 15, ), @@ -421,18 +691,32 @@ extension WhereStylesheet { padding: 16, contentSpacing: 10, progressBarHeight: 6, - entryStampSize: 52, - showsArcText: false, - regionNameFont: .system(.title3, design: .serif).weight(.semibold), + entryStamp: .standard(size: 52, showsArcText: false), + 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), - holographicIntensity: 0.5, - frameOuterLineWidth: 2.5, - showsPerforationRing: false, - innerFrameInset: 12, + regionShape: nil, + sheen: CardStyle.Sheen( + intensity: 0.5, + staticGlintIntensity: 0.5, + // Preserve the compact card's existing neutral treatment. + staticPose: .init(roll: 0, pitch: 0), + ), rosette: CardStyle.Rosette( wobble: 2, lineWidth: 2, @@ -445,18 +729,8 @@ 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), + securityPrint: .standard, dayCount: .standard, ) } @@ -525,6 +799,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 } } @@ -600,16 +878,22 @@ 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), 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, @@ -1076,11 +1360,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/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/CardDesignerConfigurationTests.swift b/Where/WhereUI/Tests/CardDesignerConfigurationTests.swift new file mode 100644 index 00000000..1993bf55 --- /dev/null +++ b/Where/WhereUI/Tests/CardDesignerConfigurationTests.swift @@ -0,0 +1,54 @@ +#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.sizeMode = .fixed + configuration.compact.regionNameTypography.fixedSize = 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..e58ccc88 --- /dev/null +++ b/Where/WhereUI/Tests/CardDesignerExportTests.swift @@ -0,0 +1,60 @@ +#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")) + } + + @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 diff --git a/Where/WhereUI/Tests/CardDesignerModelTests.swift b/Where/WhereUI/Tests/CardDesignerModelTests.swift new file mode 100644 index 00000000..03afcca6 --- /dev/null +++ b/Where/WhereUI/Tests/CardDesignerModelTests.swift @@ -0,0 +1,80 @@ +#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 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) } + 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/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 diff --git a/Where/WhereUI/Tests/RegionAppearanceCatalogTests.swift b/Where/WhereUI/Tests/RegionAppearanceCatalogTests.swift new file mode 100644 index 00000000..3224fd7d --- /dev/null +++ b/Where/WhereUI/Tests/RegionAppearanceCatalogTests.swift @@ -0,0 +1,23 @@ +import RegionKit +import Testing +@testable import WhereCore +@testable import WhereUI + +/// The picker and id-derived fallback appearances share one complete palette. +struct RegionAppearanceCatalogTests { + @Test func selectorIncludesEveryColorToken() { + #expect(RegionAppearanceCatalog.colors == RegionColorToken.allCases) + } + + @Test func idDerivedDefaultsUseExpandedColors() throws { + let texas = try #require(Region(rawValue: "us-TX")) + #expect(RegionAppearanceCatalog.defaultAppearance(for: texas).color == .charcoal) + } + + @Test func lightSwatchesUseDarkSelectionGlyphs() { + #expect(RegionColorToken.gold.selectionForeground == .black) + #expect(RegionColorToken.lime.selectionForeground == .black) + #expect(RegionColorToken.silver.selectionForeground == .black) + #expect(RegionColorToken.coral.selectionForeground == .white) + } +} diff --git a/Where/WhereUI/Tests/RegionOutlineArtworkTests.swift b/Where/WhereUI/Tests/RegionOutlineArtworkTests.swift new file mode 100644 index 00000000..4970c13a --- /dev/null +++ b/Where/WhereUI/Tests/RegionOutlineArtworkTests.swift @@ -0,0 +1,28 @@ +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 path = await RegionOutlinePathCache().path(for: .california, resolution: .medium) + let style = try #require(WhereStylesheet.default.card.regular.regionShape) + let artwork = RegionOutlineArtwork( + path: path, + tint: .orange, + style: style.watermark, + ) + + #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() + #expect(hosted.view != nil) + } + } +} diff --git a/Where/WhereUI/Tests/RegionOutlinePathCacheTests.swift b/Where/WhereUI/Tests/RegionOutlinePathCacheTests.swift new file mode 100644 index 00000000..97954a0f --- /dev/null +++ b/Where/WhereUI/Tests/RegionOutlinePathCacheTests.swift @@ -0,0 +1,58 @@ +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 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(small.boundingRect == micro.boundingRect) + #expect(repeatedMicro == micro) + } + + @Test func otherRegionHasNoPath() async { + let cache = RegionOutlinePathCache() + let path = await cache.path(for: .other, resolution: .full) + #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.", + ) + } + + @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 } + return count + } +} 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/RegionSummaryCardTests.swift b/Where/WhereUI/Tests/RegionSummaryCardTests.swift index e7e3e4cf..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. @@ -14,8 +35,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) } diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index 8545bb95..3b5a2056 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -31,19 +31,60 @@ struct WhereStylesheetTests { #expect(card.padding == 22) #expect(card.contentSpacing == 16) #expect(card.progressBarHeight == 10) - #expect(card.entryStampSize == 88) - #expect(card.showsArcText) - #expect(card.regionNameFont == .system(size: 38, weight: .semibold, design: .serif)) + #expect(card.entryStamp == expectedEntryStamp(size: 88, showsArcText: true)) + #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)) - #expect(card.holographicIntensity == 1) - #expect(card.frameOuterLineWidth == 3.5) - #expect(card.showsPerforationRing) - #expect(card.innerFrameInset == 16) + #expect(card.regionShape == .init( + 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, + spacing: 11, + opacity: 0.22, + ), + )) + #expect(card.sheen == .init( + intensity: 1, + staticGlintIntensity: 0.25, + staticPose: .init(roll: 0, pitch: -1), + )) #expect(card.rosette == .init( wobble: 3, - lineWidth: 3, + lineWidth: 2, primaryRingSpacing: 18, secondaryRingSpacing: 15, )) @@ -57,15 +98,31 @@ 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.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)) - #expect(card.holographicIntensity == 0.5) - #expect(card.frameOuterLineWidth == 2.5) - #expect(!card.showsPerforationRing) - #expect(card.innerFrameInset == 12) + #expect(card.regionShape == nil) + #expect(card.sheen == .init( + intensity: 0.5, + staticGlintIntensity: 0.5, + staticPose: .init(roll: 0, pitch: 0), + )) #expect(card.rosette == .init( wobble: 2, lineWidth: 2, @@ -81,23 +138,46 @@ 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) #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.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)) } @@ -148,13 +228,19 @@ 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) + #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) @@ -369,6 +455,20 @@ struct WhereStylesheetTests { #expect(resolved.developerOverlay.menu.motion == .reduced) #expect(resolved.developerOverlay.menu.motion.usesSpatialMotion == false) } + + @MainActor + @Test func palesCardSecurityPrintInDarkMode() throws { + var context = BContext(traits: .system) + 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, + in: .perceptual, + )) + } } /// Covers the WhereUI glue: `EnvironmentValues.stylesheet` resolves a