From fe83ff502ffcaa59c7a22471c634d565972e7644 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Sun, 2 Aug 2026 15:29:31 -0500 Subject: convert readme to nfo; convert docs to txt; relicense to 0bsd --- Docs/ACCESSIBILITY.md | 313 ------------------------------------------------ Docs/ACCESSIBILITY.txt | 313 ++++++++++++++++++++++++++++++++++++++++++++++++ Docs/ARCHITECTURE.md | 102 ---------------- Docs/ARCHITECTURE.txt | 102 ++++++++++++++++ Docs/data-migration.md | 94 --------------- Docs/data-migration.txt | 94 +++++++++++++++ Docs/local-api.md | 113 ----------------- Docs/local-api.txt | 113 +++++++++++++++++ 8 files changed, 622 insertions(+), 622 deletions(-) delete mode 100644 Docs/ACCESSIBILITY.md create mode 100644 Docs/ACCESSIBILITY.txt delete mode 100644 Docs/ARCHITECTURE.md create mode 100644 Docs/ARCHITECTURE.txt delete mode 100644 Docs/data-migration.md create mode 100644 Docs/data-migration.txt delete mode 100644 Docs/local-api.md create mode 100644 Docs/local-api.txt (limited to 'Docs') diff --git a/Docs/ACCESSIBILITY.md b/Docs/ACCESSIBILITY.md deleted file mode 100644 index 6e82055..0000000 --- a/Docs/ACCESSIBILITY.md +++ /dev/null @@ -1,313 +0,0 @@ -# Accessibility Audit - -`DomainDigUITests` runs Apple's `performAccessibilityAudit()` across every -primary screen. The audit checks contrast, hit-region size, clipped text at -large Dynamic Type, element descriptions, trait correctness, and Dynamic Type -support — the same ground the accessibility pass tracked in -[issue #21](https://github.com/krazywarez/domain-dig/issues/21) covers. - -## The colour palette - -Semantic colours live in `Shared/Colors.xcassets`, which is inside the `Shared` -file-system-synchronized group and therefore reaches the app, the widget, and -the share extension automatically. `AccentColor` stays in -`DomainDig/Assets.xcassets` because it is the system-wide tint resolved via -`ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME`. - -Use the generated asset symbols — `Color(.statusCritical)`, `Color(.appSurface)` -— never a literal. `ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS` -is on, so these are compile-time checked; a typo will not build. - -Every value clears WCAG AA (4.5:1) as text on its page, on its card, **and on -its own 16% badge tint** — the way `AppStatusBadgeView` actually draws it. The -worst of those three is shown: - -| Role | Light | Dark | Worst light | Worst dark | -| --- | --- | --- | --- | --- | -| `StatusInfo` / `AccentColor` | `#0000FF` | `#4DA3FF` | 6.76 | 6.47 | -| `StatusPositive` | `#008035` | `#30D158` | 4.54 | 7.62 | -| `StatusWarning` | `#AD5100` | `#FF9F0A` | 4.59 | 7.76 | -| `StatusCritical` | `#CC0700` | `#FF6961` | 4.68 | 6.12 | -| `StatusNeutral` | `#5A5A5F` | `#A1A1A6` | 5.84 | 6.76 | - -Each status foreground has a matching `…Surface` colour for the fill behind it, -paired through `AppStatusTone`. - -### Contrast alone is not a palette - -The first version of this palette maximised contrast and produced mud. Requiring -every foreground to clear 4.5:1 against *its own 16% tint* — the harshest -surface it ever sits on — pushed each colour ~20% darker than the common case -needed. `#7A5600` is not amber, it is olive; `#146C2E` is not green so much as -bottle-dark. Contrast passed and the UI was still hard to read, because hue -identity is what tells "warning" from "critical" at a glance. - -Two fixes: - -1. **Decouple the fill from the foreground.** `AppStatusTone` carries a - `foreground` and a `surface` that are authored independently, so the - foreground no longer has to survive a wash of itself. Every status foreground - is now fully saturated (`S = 1.0`). -2. **Warning is orange, not yellow.** Yellow cannot stay yellow at a lightness - low enough to clear 4.5:1 on white — it *becomes* olive. That is - colorimetric, not a tuning problem. Orange holds its identity when darkened, - so warning is `#AD5100` in light and `#FF9F0A` in dark. - -When adding a colour, search for the most saturated value that passes, not the -darkest. The darkest is always easy and always wrong. -| `AppTextSecondary` | `#5A5A5F` | `#A1A1A6` | 6.15 | 7.50 | - -`AppTextSecondary` replaces `.secondary` for body text. iOS's own `secondaryLabel` -is only **3.29:1** on a light card — below AA — which never showed while the app -was locked to dark, where the same colour reads 6.32:1. Unlocking light mode -exposed it across 191 sites. - -High Contrast variants push further in the same direction. Surfaces -(`AppBackground`, `AppSurface`, `AppSurfaceElevated`, `AppSeparator`) carry no -meaning, so they get Any/Dark and, where useful, High Contrast — but no status -semantics. - -Why custom values instead of the system palette: **every** system colour fails -in light mode. Measured on white — systemYellow 1.51:1, systemOrange 2.20:1, -systemGreen 2.22:1, systemCyan 2.54:1, systemRed 3.55:1. All of them pass in -dark mode, which is why the dark-locked app looked fine and why unlocking light -mode is impossible without this work. - -### The accent has two roles, and they conflict - -An accent used as **text on a dark background** must be light. The same accent -used as a **fill behind a white label** must be dark. One value cannot do both: -`#4DA3FF` reads at 8.00:1 as text on black, but only 2.63:1 behind white text. - -So there are two colours: - -- `StatusInfo` / `AccentColor` — the accent as *foreground*: text, icons, - bordered-button labels, tab bar. -- `AccentFill` — the accent as a *filled background* behind a label, used by - `.borderedProminent`. Stays dark in both schemes so a white label clears AA - (8.59:1 light, 7.56:1 dark). - -`AppOnAccent` is the label colour for a solid accent fill and flips by scheme — -white on the light accent, black on the dark one. - -## Appearance - -`AppAppearance` (System / Light / Dark) is stored in `@AppStorage` and applied in -**exactly one place** — the `WindowGroup` in `DomainDigApp`. Keep it that way. The -app previously carried 16 separate `.preferredColorScheme(.dark)` calls scattered -through view bodies, which is how light mode became unreachable without anyone -noticing; re-applying per view is what let the lock spread. - -Users override it under Settings → Display. - -### Known light-mode findings - -| Finding | Cause | Action | -| --- | --- | --- | -| 2× `contrast failed` on Settings | The last rows of a section sit under the translucent tab bar, so the audit measures text against a blended background. Present in dark mode too, since phase 0. | None — standard iOS scroll-under behaviour | -| 3× `contrast nearly passed` on Settings | iOS-rendered `Section` headers (`TIER`, `PREFERENCES`, `SERVICES`) use the system's grey. | Not fixed. Overriding system header styling across every section to gain ~0.3:1 on decorative labels trades platform convention for very little | - -Dark mode reports 18 findings and light mode 21; the three extra are the section -headers above. Everything the app actually controls passes in both schemes. - -## Enforcement — the ratchet is engaged - -With phases 1–5 landed, `AccessibilityAuditHarness.enforcedAuditTypes` enforces -**`.textClipped`, `.dynamicType`, `.hitRegion`, `.elementDetection`, -`.sufficientElementDescription`, `.trait`** on the empty-state test suite. A -named finding in any of these fails CI — regressions in five phases of work are -now gated, not merely reported. - -Three deliberate carve-outs, each with its evidence: - -1. **`.contrast` stays report-only.** The two long-standing Settings findings - are rows scrolled under the translucent tab bar; their attribution flips - between a row name and nil run-to-run, so no suppression is narrow enough to - keep CI stable. The centralised palette in `Shared/Colors.xcassets` is the - actual guard against contrast regressions. -2. **The seeded tests run `reportOnly`.** Bisecting the row/badge accessibility - modifiers showed the audit degrades on `children: .ignore` content — the - *correct* VoiceOver treatment for dense rows — emitting unattributed - contrast/dynamicType failures on rows that measure 6–7:1 and render - correctly. Their burndown still prints; it just doesn't gate. -3. **Characterised noise is suppressed narrowly and always logged** with a - `[noise: reason]` marker — disabled controls (WCAG 1.4.3 exempt), "nearly - passed" near-misses, system field placeholders (clipped at any length — - proven by shortening them to no effect), and unattributed - clipped/dynamic-type artifacts. Nothing disappears silently; see - `noiseReason(for:)` for each rule's provenance. - -Enforcement is a committed constant rather than a CI setting, for two reasons. -Environment variables do not work: neither a plain `xcodebuild` env var nor a -`TEST_RUNNER_`-prefixed build setting reaches the UI test process, so the toggle -silently did nothing. And a committed value makes "when did clipping become -enforced?" answerable with `git blame` instead of CI tribal knowledge. - -## Why coverage is split between local and CI - -**Audit coverage is not nested across OS versions.** Each runtime reports -findings the others miss, in *both* directions. Measured on this project: - -| Screen | iOS 18.6 | iOS 27.0 | -| --- | --- | --- | -| Tracked Domains | 2 (text clipped) | **6** (+ contrast ×3, element detection) | -| Settings | 2 contrast | **`dynamicType`** finding 18.6 missed | -| Dashboard @ `AccessibilityXXXL` | **hit region** + 2 clipped | 1 clipped only | - -Neither runtime is a superset, so the oldest supported OS needs its own run. -This also rules out committing per-screen baseline counts as a regression guard: -no single number is correct on both. - -The catch is that **GitHub's `macos-26` image ships only iOS 26.x simulator -runtimes.** It cannot test the 17.6 floor at all. A two-job CI matrix was tried -and produced two near-identical 26.x runs at double the macOS minutes. - -So the work is split by what each side can uniquely do: - -| | Runtime | Uniquely provides | -| --- | --- | --- | -| **CI** (`.github/workflows/build.yml`) | newest available | A clean checkout of the merge result — catches a file that was never committed, which a local run cannot. Matters here because `DomainDig.xcodeproj` is hand-edited and uses file-system-synchronized groups, where a whole missing folder still builds locally. | -| **Local** (`Scripts/audit-a11y.sh`) | oldest supported + newest | Real floor coverage, on a machine that actually has an 18.x runtime installed. | - -Together they cover both ends; neither duplicates the other. - -## Running it - -```sh -./Scripts/audit-a11y.sh # floor + current -./Scripts/audit-a11y.sh floor # oldest supported only (~85s) -./Scripts/audit-a11y.sh current # newest installed only -``` - -The script reads the deployment target from the project rather than hard-coding -it, and selects the oldest installed runtime **at or above** it — a runtime -below the deployment target is useless, because the app cannot install there. -If the nearest installed runtime is a major version above the target, it says -so rather than implying floor coverage it does not have. - -### Pre-push hook - -```sh -git config core.hooksPath .githooks -``` - -Runs the floor audit before a push, and only when Swift, asset, or project files -changed. Bypass with `git push --no-verify`. - -Pre-push rather than pre-commit deliberately: the suite takes ~85s, and at -pre-commit that blocks every commit. A hook routinely bypassed with -`--no-verify` is worse than no hook, because it trains you to ignore it. - -## Layout gotchas found the hard way - -- **`Label` clips its own title.** Every empty-state heading reported as clipped - text. `.fixedSize` applied to the `Label` does not reach the `Text` inside it, - so the fix is to split it into an `HStack { Image; Text }` and put the modifier - on the `Text`. Changing the font design did **not** help — that hypothesis was - tested and discarded. -- **Splitting a `Label` exposes its icon to VoiceOver.** `Label` folds the image - into the title's accessibility element; an `HStack` does not, so the icon - starts announcing its raw SF Symbol name ("checklist.unchecked"). Decorative - icons split out of a `Label` need `.accessibilityHidden(true)`. -- **Placeholder text is always reported as clipped.** Search prompts and - `TextField` placeholders are flagged regardless of length — shortening - "Search portfolio" to "Search" changed nothing. Treat `textClipped` findings on - a `searchField` or `textField` element as noise rather than shortening useful - prompts to chase them. -- **`AppLayout.minimumTapTarget` is the floor for every control.** `@ScaledMetric` - scales *down* below the default text size as well as up, so a scaled dimension - needs `max(scaled, AppLayout.minimumTapTarget)` or it drops under 44pt for - users who prefer smaller text. - -## VoiceOver conventions - -- **Dense rows use combine-for-summary, custom-content-for-detail.** - `BatchResultRowView` and `WatchlistRowView` each hold 8–9 text elements. - Reading them inline makes a long sweep unnavigable, so each row is one element: - `.accessibilityElement(children: .ignore)` + domain label + status value, with - the rest on `.accessibilityCustomContent(...)`. `.high` importance is spoken - inline; everything else reaches the More Content rotor on a vertical swipe. - Rows with only 3–4 elements (the portfolio activity/attention/expiry rows) are - left to `NavigationLink`'s automatic combine — custom content is for the dense - case, per WWDC21-10121. -- **The custom-content chain must live in a `ViewModifier`.** Inlined onto a row - body, six `.accessibilityCustomContent` calls plus the visual layout blow the - Swift type-checker's budget ("unable to type-check in reasonable time"). - `BatchRowAccessibility` / `WatchlistRowAccessibility` exist for that reason. -- **Splitting a `Label` exposes its icon; combining a header swallows its - trailing controls.** Two opposite traps. A decorative icon pulled out of a - `Label` needs `.accessibilityHidden(true)`. A header built as a `Button` must - *not* get `.accessibilityElement(children: .combine)` if its label contains - other controls (`CollapsibleSectionView`'s `trailing()` holds Track/Pin) — - combine would merge them into the header and make them unreachable. -- **Label-in-name (WCAG 2.5.3).** Every `accessibilityLabel` added to a control - with visible text keeps that text, so Voice Control still works. Free-form - labels are used only where the control is genuinely icon-only. -- **Technical strings** get `speechStyle: .technical` on `InfoRowViewData`, which - applies `.speechAlwaysIncludesPunctuation()` and - `.accessibilityTextContentType(.sourceCode)`. Set today on DNS record values - and cipher suites; extend it wherever the view model emits a fingerprint, - serial, or record string. - -## Color independence, motion, transparency - -- **Status is never colour-only.** In-app badges already pair a symbol with the - colour. The widget status dot is now an SF Symbol - (`checkmark.circle.fill` / `exclamationmark.triangle.fill` / - `exclamationmark.octagon.fill`) — the same vocabulary as the badges, so a - status reads consistently across surfaces and survives greyscale. -- **`accessibilityDifferentiateWithoutColor`** adds redundant shape only when the - user asks for it, avoiding clutter otherwise: the Dashboard summary-card dot - becomes a per-filter symbol, the selected quick-filter chip gains a checkmark - and border (selection was fill-colour only), and `LabeledValueRow` prefixes a - warning/failure symbol. -- **`accessibilityReduceMotion`** guards all five animation sites via - `withAnimation(reduceMotion ? nil : …)` / `.animation(reduceMotion ? nil : …)`: - `AppCopyButton`'s check cross-fade, `CollapsibleSectionView`'s expand/collapse, - `TimelineDiffView`'s scroll, and `WatchlistView`'s list reorder. -- **`accessibilityReduceTransparency`** swaps the single `.thinMaterial` for an - opaque `AppSurfaceElevated` capsule. - -These cannot be verified by `simctl`, which toggles only Increase Contrast — the -other three settings live in the simulator's Settings app. They are correct by -construction and build-clean; their runtime behaviour is part of the Phase 6 -manual pass. `SweepActivityController` was dropped from the motion list: it is -pure ActivityKit lifecycle with no animation to guard. - -### What the automated audit cannot check - -`performAccessibilityAudit()` validates descriptions, traits, contrast, hit -regions, and clipping. It does **not** exercise VoiceOver speech, the More -Content rotor, custom-content ordering, or announcements. Those are verified by -construction and a manual VoiceOver pass (Phase 6), not by the suite. A green -audit is necessary, not sufficient, for the row and speech work. - -Additionally, the dense rows (`BatchResultRowView`, `WatchlistRowView`) and the -widget never render in the audit — the test simulator has no tracked domains or -batch results. Their treatment is unverified by the suite for the same reason the -Phase 3 `ViewThatFits` work was deferred: absence of findings is absence of data. - -## Notes - -- **Disabled controls are a false positive, and are suppressed.** WCAG 1.4.3 - exempts inactive components from contrast requirements, but the audit flags - them anyway — Inspect's Run button is disabled until a domain is typed, and - auditing the empty state reported a contrast failure that was never a real - defect. The harness now drops contrast findings whose element reports - `isEnabled == false`. Suppressing on the rule beats driving the UI to enable - the control: typing raises the keyboard, which then follows the audit onto - later screens and flags the system emoji picker's category buttons. -- **A dirty simulator inflates the burndown.** Keyboard state persists across - runs, so a simulator left with the emoji picker open reports ~9 phantom - hit-region findings per screen. If findings appear that name system UI - ("Flags category", "Frequently Used category"), erase the simulator - (`xcrun simctl erase `) and re-run before believing them. -- Audits retry up to three times. Slower machines can miss the audit's internal - deadline (`Audit failed to complete in time`, code `-56`), which is a tooling - timeout, not an app defect. A screen that still cannot be audited is reported - as an `XCTSkip`, never a pass — skips are visually distinct in CI, so an - unaudited screen stays visible instead of being silently counted as clean. -- The suite launches with `DOMAIN_DIG_FORCE_PRO_PLUS` so Pro-gated screens are - reachable. `PurchaseService` honours that argument in `DEBUG` builds only. -- Everything used is available at the iOS 17.6 deployment floor; - `performAccessibilityAudit` is `ios(17.0)`. diff --git a/Docs/ACCESSIBILITY.txt b/Docs/ACCESSIBILITY.txt new file mode 100644 index 0000000..6e82055 --- /dev/null +++ b/Docs/ACCESSIBILITY.txt @@ -0,0 +1,313 @@ +# Accessibility Audit + +`DomainDigUITests` runs Apple's `performAccessibilityAudit()` across every +primary screen. The audit checks contrast, hit-region size, clipped text at +large Dynamic Type, element descriptions, trait correctness, and Dynamic Type +support — the same ground the accessibility pass tracked in +[issue #21](https://github.com/krazywarez/domain-dig/issues/21) covers. + +## The colour palette + +Semantic colours live in `Shared/Colors.xcassets`, which is inside the `Shared` +file-system-synchronized group and therefore reaches the app, the widget, and +the share extension automatically. `AccentColor` stays in +`DomainDig/Assets.xcassets` because it is the system-wide tint resolved via +`ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME`. + +Use the generated asset symbols — `Color(.statusCritical)`, `Color(.appSurface)` +— never a literal. `ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS` +is on, so these are compile-time checked; a typo will not build. + +Every value clears WCAG AA (4.5:1) as text on its page, on its card, **and on +its own 16% badge tint** — the way `AppStatusBadgeView` actually draws it. The +worst of those three is shown: + +| Role | Light | Dark | Worst light | Worst dark | +| --- | --- | --- | --- | --- | +| `StatusInfo` / `AccentColor` | `#0000FF` | `#4DA3FF` | 6.76 | 6.47 | +| `StatusPositive` | `#008035` | `#30D158` | 4.54 | 7.62 | +| `StatusWarning` | `#AD5100` | `#FF9F0A` | 4.59 | 7.76 | +| `StatusCritical` | `#CC0700` | `#FF6961` | 4.68 | 6.12 | +| `StatusNeutral` | `#5A5A5F` | `#A1A1A6` | 5.84 | 6.76 | + +Each status foreground has a matching `…Surface` colour for the fill behind it, +paired through `AppStatusTone`. + +### Contrast alone is not a palette + +The first version of this palette maximised contrast and produced mud. Requiring +every foreground to clear 4.5:1 against *its own 16% tint* — the harshest +surface it ever sits on — pushed each colour ~20% darker than the common case +needed. `#7A5600` is not amber, it is olive; `#146C2E` is not green so much as +bottle-dark. Contrast passed and the UI was still hard to read, because hue +identity is what tells "warning" from "critical" at a glance. + +Two fixes: + +1. **Decouple the fill from the foreground.** `AppStatusTone` carries a + `foreground` and a `surface` that are authored independently, so the + foreground no longer has to survive a wash of itself. Every status foreground + is now fully saturated (`S = 1.0`). +2. **Warning is orange, not yellow.** Yellow cannot stay yellow at a lightness + low enough to clear 4.5:1 on white — it *becomes* olive. That is + colorimetric, not a tuning problem. Orange holds its identity when darkened, + so warning is `#AD5100` in light and `#FF9F0A` in dark. + +When adding a colour, search for the most saturated value that passes, not the +darkest. The darkest is always easy and always wrong. +| `AppTextSecondary` | `#5A5A5F` | `#A1A1A6` | 6.15 | 7.50 | + +`AppTextSecondary` replaces `.secondary` for body text. iOS's own `secondaryLabel` +is only **3.29:1** on a light card — below AA — which never showed while the app +was locked to dark, where the same colour reads 6.32:1. Unlocking light mode +exposed it across 191 sites. + +High Contrast variants push further in the same direction. Surfaces +(`AppBackground`, `AppSurface`, `AppSurfaceElevated`, `AppSeparator`) carry no +meaning, so they get Any/Dark and, where useful, High Contrast — but no status +semantics. + +Why custom values instead of the system palette: **every** system colour fails +in light mode. Measured on white — systemYellow 1.51:1, systemOrange 2.20:1, +systemGreen 2.22:1, systemCyan 2.54:1, systemRed 3.55:1. All of them pass in +dark mode, which is why the dark-locked app looked fine and why unlocking light +mode is impossible without this work. + +### The accent has two roles, and they conflict + +An accent used as **text on a dark background** must be light. The same accent +used as a **fill behind a white label** must be dark. One value cannot do both: +`#4DA3FF` reads at 8.00:1 as text on black, but only 2.63:1 behind white text. + +So there are two colours: + +- `StatusInfo` / `AccentColor` — the accent as *foreground*: text, icons, + bordered-button labels, tab bar. +- `AccentFill` — the accent as a *filled background* behind a label, used by + `.borderedProminent`. Stays dark in both schemes so a white label clears AA + (8.59:1 light, 7.56:1 dark). + +`AppOnAccent` is the label colour for a solid accent fill and flips by scheme — +white on the light accent, black on the dark one. + +## Appearance + +`AppAppearance` (System / Light / Dark) is stored in `@AppStorage` and applied in +**exactly one place** — the `WindowGroup` in `DomainDigApp`. Keep it that way. The +app previously carried 16 separate `.preferredColorScheme(.dark)` calls scattered +through view bodies, which is how light mode became unreachable without anyone +noticing; re-applying per view is what let the lock spread. + +Users override it under Settings → Display. + +### Known light-mode findings + +| Finding | Cause | Action | +| --- | --- | --- | +| 2× `contrast failed` on Settings | The last rows of a section sit under the translucent tab bar, so the audit measures text against a blended background. Present in dark mode too, since phase 0. | None — standard iOS scroll-under behaviour | +| 3× `contrast nearly passed` on Settings | iOS-rendered `Section` headers (`TIER`, `PREFERENCES`, `SERVICES`) use the system's grey. | Not fixed. Overriding system header styling across every section to gain ~0.3:1 on decorative labels trades platform convention for very little | + +Dark mode reports 18 findings and light mode 21; the three extra are the section +headers above. Everything the app actually controls passes in both schemes. + +## Enforcement — the ratchet is engaged + +With phases 1–5 landed, `AccessibilityAuditHarness.enforcedAuditTypes` enforces +**`.textClipped`, `.dynamicType`, `.hitRegion`, `.elementDetection`, +`.sufficientElementDescription`, `.trait`** on the empty-state test suite. A +named finding in any of these fails CI — regressions in five phases of work are +now gated, not merely reported. + +Three deliberate carve-outs, each with its evidence: + +1. **`.contrast` stays report-only.** The two long-standing Settings findings + are rows scrolled under the translucent tab bar; their attribution flips + between a row name and nil run-to-run, so no suppression is narrow enough to + keep CI stable. The centralised palette in `Shared/Colors.xcassets` is the + actual guard against contrast regressions. +2. **The seeded tests run `reportOnly`.** Bisecting the row/badge accessibility + modifiers showed the audit degrades on `children: .ignore` content — the + *correct* VoiceOver treatment for dense rows — emitting unattributed + contrast/dynamicType failures on rows that measure 6–7:1 and render + correctly. Their burndown still prints; it just doesn't gate. +3. **Characterised noise is suppressed narrowly and always logged** with a + `[noise: reason]` marker — disabled controls (WCAG 1.4.3 exempt), "nearly + passed" near-misses, system field placeholders (clipped at any length — + proven by shortening them to no effect), and unattributed + clipped/dynamic-type artifacts. Nothing disappears silently; see + `noiseReason(for:)` for each rule's provenance. + +Enforcement is a committed constant rather than a CI setting, for two reasons. +Environment variables do not work: neither a plain `xcodebuild` env var nor a +`TEST_RUNNER_`-prefixed build setting reaches the UI test process, so the toggle +silently did nothing. And a committed value makes "when did clipping become +enforced?" answerable with `git blame` instead of CI tribal knowledge. + +## Why coverage is split between local and CI + +**Audit coverage is not nested across OS versions.** Each runtime reports +findings the others miss, in *both* directions. Measured on this project: + +| Screen | iOS 18.6 | iOS 27.0 | +| --- | --- | --- | +| Tracked Domains | 2 (text clipped) | **6** (+ contrast ×3, element detection) | +| Settings | 2 contrast | **`dynamicType`** finding 18.6 missed | +| Dashboard @ `AccessibilityXXXL` | **hit region** + 2 clipped | 1 clipped only | + +Neither runtime is a superset, so the oldest supported OS needs its own run. +This also rules out committing per-screen baseline counts as a regression guard: +no single number is correct on both. + +The catch is that **GitHub's `macos-26` image ships only iOS 26.x simulator +runtimes.** It cannot test the 17.6 floor at all. A two-job CI matrix was tried +and produced two near-identical 26.x runs at double the macOS minutes. + +So the work is split by what each side can uniquely do: + +| | Runtime | Uniquely provides | +| --- | --- | --- | +| **CI** (`.github/workflows/build.yml`) | newest available | A clean checkout of the merge result — catches a file that was never committed, which a local run cannot. Matters here because `DomainDig.xcodeproj` is hand-edited and uses file-system-synchronized groups, where a whole missing folder still builds locally. | +| **Local** (`Scripts/audit-a11y.sh`) | oldest supported + newest | Real floor coverage, on a machine that actually has an 18.x runtime installed. | + +Together they cover both ends; neither duplicates the other. + +## Running it + +```sh +./Scripts/audit-a11y.sh # floor + current +./Scripts/audit-a11y.sh floor # oldest supported only (~85s) +./Scripts/audit-a11y.sh current # newest installed only +``` + +The script reads the deployment target from the project rather than hard-coding +it, and selects the oldest installed runtime **at or above** it — a runtime +below the deployment target is useless, because the app cannot install there. +If the nearest installed runtime is a major version above the target, it says +so rather than implying floor coverage it does not have. + +### Pre-push hook + +```sh +git config core.hooksPath .githooks +``` + +Runs the floor audit before a push, and only when Swift, asset, or project files +changed. Bypass with `git push --no-verify`. + +Pre-push rather than pre-commit deliberately: the suite takes ~85s, and at +pre-commit that blocks every commit. A hook routinely bypassed with +`--no-verify` is worse than no hook, because it trains you to ignore it. + +## Layout gotchas found the hard way + +- **`Label` clips its own title.** Every empty-state heading reported as clipped + text. `.fixedSize` applied to the `Label` does not reach the `Text` inside it, + so the fix is to split it into an `HStack { Image; Text }` and put the modifier + on the `Text`. Changing the font design did **not** help — that hypothesis was + tested and discarded. +- **Splitting a `Label` exposes its icon to VoiceOver.** `Label` folds the image + into the title's accessibility element; an `HStack` does not, so the icon + starts announcing its raw SF Symbol name ("checklist.unchecked"). Decorative + icons split out of a `Label` need `.accessibilityHidden(true)`. +- **Placeholder text is always reported as clipped.** Search prompts and + `TextField` placeholders are flagged regardless of length — shortening + "Search portfolio" to "Search" changed nothing. Treat `textClipped` findings on + a `searchField` or `textField` element as noise rather than shortening useful + prompts to chase them. +- **`AppLayout.minimumTapTarget` is the floor for every control.** `@ScaledMetric` + scales *down* below the default text size as well as up, so a scaled dimension + needs `max(scaled, AppLayout.minimumTapTarget)` or it drops under 44pt for + users who prefer smaller text. + +## VoiceOver conventions + +- **Dense rows use combine-for-summary, custom-content-for-detail.** + `BatchResultRowView` and `WatchlistRowView` each hold 8–9 text elements. + Reading them inline makes a long sweep unnavigable, so each row is one element: + `.accessibilityElement(children: .ignore)` + domain label + status value, with + the rest on `.accessibilityCustomContent(...)`. `.high` importance is spoken + inline; everything else reaches the More Content rotor on a vertical swipe. + Rows with only 3–4 elements (the portfolio activity/attention/expiry rows) are + left to `NavigationLink`'s automatic combine — custom content is for the dense + case, per WWDC21-10121. +- **The custom-content chain must live in a `ViewModifier`.** Inlined onto a row + body, six `.accessibilityCustomContent` calls plus the visual layout blow the + Swift type-checker's budget ("unable to type-check in reasonable time"). + `BatchRowAccessibility` / `WatchlistRowAccessibility` exist for that reason. +- **Splitting a `Label` exposes its icon; combining a header swallows its + trailing controls.** Two opposite traps. A decorative icon pulled out of a + `Label` needs `.accessibilityHidden(true)`. A header built as a `Button` must + *not* get `.accessibilityElement(children: .combine)` if its label contains + other controls (`CollapsibleSectionView`'s `trailing()` holds Track/Pin) — + combine would merge them into the header and make them unreachable. +- **Label-in-name (WCAG 2.5.3).** Every `accessibilityLabel` added to a control + with visible text keeps that text, so Voice Control still works. Free-form + labels are used only where the control is genuinely icon-only. +- **Technical strings** get `speechStyle: .technical` on `InfoRowViewData`, which + applies `.speechAlwaysIncludesPunctuation()` and + `.accessibilityTextContentType(.sourceCode)`. Set today on DNS record values + and cipher suites; extend it wherever the view model emits a fingerprint, + serial, or record string. + +## Color independence, motion, transparency + +- **Status is never colour-only.** In-app badges already pair a symbol with the + colour. The widget status dot is now an SF Symbol + (`checkmark.circle.fill` / `exclamationmark.triangle.fill` / + `exclamationmark.octagon.fill`) — the same vocabulary as the badges, so a + status reads consistently across surfaces and survives greyscale. +- **`accessibilityDifferentiateWithoutColor`** adds redundant shape only when the + user asks for it, avoiding clutter otherwise: the Dashboard summary-card dot + becomes a per-filter symbol, the selected quick-filter chip gains a checkmark + and border (selection was fill-colour only), and `LabeledValueRow` prefixes a + warning/failure symbol. +- **`accessibilityReduceMotion`** guards all five animation sites via + `withAnimation(reduceMotion ? nil : …)` / `.animation(reduceMotion ? nil : …)`: + `AppCopyButton`'s check cross-fade, `CollapsibleSectionView`'s expand/collapse, + `TimelineDiffView`'s scroll, and `WatchlistView`'s list reorder. +- **`accessibilityReduceTransparency`** swaps the single `.thinMaterial` for an + opaque `AppSurfaceElevated` capsule. + +These cannot be verified by `simctl`, which toggles only Increase Contrast — the +other three settings live in the simulator's Settings app. They are correct by +construction and build-clean; their runtime behaviour is part of the Phase 6 +manual pass. `SweepActivityController` was dropped from the motion list: it is +pure ActivityKit lifecycle with no animation to guard. + +### What the automated audit cannot check + +`performAccessibilityAudit()` validates descriptions, traits, contrast, hit +regions, and clipping. It does **not** exercise VoiceOver speech, the More +Content rotor, custom-content ordering, or announcements. Those are verified by +construction and a manual VoiceOver pass (Phase 6), not by the suite. A green +audit is necessary, not sufficient, for the row and speech work. + +Additionally, the dense rows (`BatchResultRowView`, `WatchlistRowView`) and the +widget never render in the audit — the test simulator has no tracked domains or +batch results. Their treatment is unverified by the suite for the same reason the +Phase 3 `ViewThatFits` work was deferred: absence of findings is absence of data. + +## Notes + +- **Disabled controls are a false positive, and are suppressed.** WCAG 1.4.3 + exempts inactive components from contrast requirements, but the audit flags + them anyway — Inspect's Run button is disabled until a domain is typed, and + auditing the empty state reported a contrast failure that was never a real + defect. The harness now drops contrast findings whose element reports + `isEnabled == false`. Suppressing on the rule beats driving the UI to enable + the control: typing raises the keyboard, which then follows the audit onto + later screens and flags the system emoji picker's category buttons. +- **A dirty simulator inflates the burndown.** Keyboard state persists across + runs, so a simulator left with the emoji picker open reports ~9 phantom + hit-region findings per screen. If findings appear that name system UI + ("Flags category", "Frequently Used category"), erase the simulator + (`xcrun simctl erase `) and re-run before believing them. +- Audits retry up to three times. Slower machines can miss the audit's internal + deadline (`Audit failed to complete in time`, code `-56`), which is a tooling + timeout, not an app defect. A screen that still cannot be audited is reported + as an `XCTSkip`, never a pass — skips are visually distinct in CI, so an + unaudited screen stays visible instead of being silently counted as clean. +- The suite launches with `DOMAIN_DIG_FORCE_PRO_PLUS` so Pro-gated screens are + reachable. `PurchaseService` honours that argument in `DEBUG` builds only. +- Everything used is available at the iOS 17.6 deployment floor; + `performAccessibilityAudit` is `ios(17.0)`. diff --git a/Docs/ARCHITECTURE.md b/Docs/ARCHITECTURE.md deleted file mode 100644 index d0d2108..0000000 --- a/Docs/ARCHITECTURE.md +++ /dev/null @@ -1,102 +0,0 @@ -# DomainDig Architecture - -## Overview - -DomainDig is a local-first inspection and audit app built around one canonical output model: `DomainReport`. - -Inspection flow: - -1. `LookupRuntime` coordinates the section services that gather DNS, web, TLS, ownership, reachability, redirect, email, port, and enrichment data. -2. `DomainInspectionService` normalizes live and cached results into `LookupSnapshot`. -3. `DomainReportBuilder` converts each snapshot into the canonical `DomainReport`. -4. SwiftUI screens, exports, and the local API render from `DomainReport` or data derived from it. - -`LookupSnapshot` remains the internal persistence shape for raw inspection state. `DomainReport` is the stable presentation/export contract. - -## App Layers - -- Section services: network collection and local normalization only. -- `LookupRuntime`: orchestrates section services for a single inspection. -- `DomainInspectionService`: builds inspection snapshots with provenance, cache state, and failure metadata. -- `DomainReportBuilder`: assembles summaries, insights, risk scoring, workflow context, and report metadata. -- `DomainReportExporter`: renders TXT, CSV, JSON, Markdown, and PDF output for app and local API use. -- `DomainViewModel`: coordinates SwiftUI state, persistence, audit sessions, monitoring, workflows, batch operations, imports, and exports. Its surface is split by concern across `DomainViewModel+Audit`, `+Monitoring`, `+Export`, `+Workflows`, `+History`, and `+Widget` extensions; the core type keeps the stored state and the inspection pipeline. -- SwiftUI views: render screens and invoke view-model actions. The largest view file was decomposed too — Settings screens live in `SettingsViews.swift` and the result detail sections in `ResultSectionViews.swift`. - -## Audit Mode - -The app has one active Audit Mode implementation: - -- Models live in `DomainDig/DomainDig/AuditModels.swift`. -- UI lives in `DomainDig/DomainDig/AuditViews.swift`. -- Export rendering lives in `DomainDig/DomainDig/AuditExporter.swift`. -- Persistence is owned by `DomainViewModel` through `DomainDataPortabilityService`. - -An audit session captures: - -- Domain and reviewer metadata -- Session status -- Point-in-time `HistoryEntry` and `DomainReport` -- Historical snapshot context -- Evidence asset references -- Checklist progress -- Findings with severity, status, evidence references, notes, and checklist areas -- Reviewer notes - -Audit sessions are stored under the same local portability service as the rest of app data and are included in full backup/restore flows. - -The older standalone prototype files, `DomainDig/AuditMode.swift` and `DomainDig/AuditModeView.swift`, are preserved in the repository for reference but excluded from synchronized target membership. They are not the release audit path. - -## Data Portability - -`DomainDataPortabilityService` owns backup, import, validation, lifecycle counts, and merge/replace behavior for: - -- Tracked domains -- History snapshots -- Audit sessions -- Workflows -- Monitoring settings and logs -- App settings -- Local feature metadata - -Backup imports support merge and replace modes. Merge mode deduplicates by stable IDs or normalized domain keys, keeps local data where appropriate, and merges audit-session reviewer notes when the same audit session appears in multiple backups. - -## Feature Tiers - -`FeatureAccessService`, `PremiumAccessService`, `PurchaseService`, and `UsageCreditService` provide the app's feature-gating surfaces. - -The app remains local-first. Purchase and entitlement code is local app infrastructure and does not introduce a hosted DomainDig backend. - -## Local API - -`LocalAPIService` is an automation surface over the same inspection/reporting pipeline: - -- `DomainInspectionService` -- `DomainReportBuilder` -- `DomainReportExporter` -- `LocalAPIModels` - -`v5.0.0` stabilized this contract: `LocalAPIContract` is the single source of truth for the `v1` wire version and JSON encoder, the response envelope and payloads are documented, and the shape is regression-locked by `LocalAPIContractTests`. See [local-api.md](local-api.md) for the endpoint and compatibility reference, and [data-migration.md](data-migration.md) for how the persisted store is versioned across app updates. - -## Testing - -Two test targets run from the `DomainDig` scheme's test action: - -- `DomainDigTests` — unit coverage of the deterministic core: `DomainReportBuilder`, `DomainReportExporter`, `DiffService`, `DomainDataPortabilityService` (merge/replace dedup), the store-migration runner, and the Local API contract. `SnapshotFixture` builds the deep `LookupSnapshot`/`DomainReport` models through the real builder so tests construct inputs without wiring every field. -- `DomainDigUITests` — Apple's `performAccessibilityAudit()` over every primary screen at default and largest Dynamic Type, plus metadata and screenshot assertions. See [ACCESSIBILITY.md](ACCESSIBILITY.md). - -A plain `xcodebuild test` (and CI) runs both. The unit net went in first in `v5.0.0` and is what made the god-file decomposition safe to attempt. - -## Xcode Project Structure - -`DomainDig.xcodeproj` uses filesystem-synchronized groups for the `DomainDig` folder. Target membership exclusions are therefore important release metadata. Files that should remain in the tree but not compile, such as retired prototypes, must be listed in the appropriate synchronized build file exception set. - -## Adding A New Data Source - -1. Add the raw collection call to `LookupRuntime` or an existing section service. -2. Integrate it in `DomainInspectionService` with provenance, cache source, and normalized failures. -3. Extend `LookupSnapshot` only if the raw result must persist. -4. Add summarized representation to `DomainReportBuilder`. -5. Expose it through `DomainReportExporter` or `LocalAPIModels` when it is part of the external contract. -6. Render it in SwiftUI from `DomainReport` fields or view-model state. -7. Update backup/restore only when the data is user-authored state or long-lived app state. diff --git a/Docs/ARCHITECTURE.txt b/Docs/ARCHITECTURE.txt new file mode 100644 index 0000000..dbf66d1 --- /dev/null +++ b/Docs/ARCHITECTURE.txt @@ -0,0 +1,102 @@ +# DomainDig Architecture + +## Overview + +DomainDig is a local-first inspection and audit app built around one canonical output model: `DomainReport`. + +Inspection flow: + +1. `LookupRuntime` coordinates the section services that gather DNS, web, TLS, ownership, reachability, redirect, email, port, and enrichment data. +2. `DomainInspectionService` normalizes live and cached results into `LookupSnapshot`. +3. `DomainReportBuilder` converts each snapshot into the canonical `DomainReport`. +4. SwiftUI screens, exports, and the local API render from `DomainReport` or data derived from it. + +`LookupSnapshot` remains the internal persistence shape for raw inspection state. `DomainReport` is the stable presentation/export contract. + +## App Layers + +- Section services: network collection and local normalization only. +- `LookupRuntime`: orchestrates section services for a single inspection. +- `DomainInspectionService`: builds inspection snapshots with provenance, cache state, and failure metadata. +- `DomainReportBuilder`: assembles summaries, insights, risk scoring, workflow context, and report metadata. +- `DomainReportExporter`: renders TXT, CSV, JSON, Markdown, and PDF output for app and local API use. +- `DomainViewModel`: coordinates SwiftUI state, persistence, audit sessions, monitoring, workflows, batch operations, imports, and exports. Its surface is split by concern across `DomainViewModel+Audit`, `+Monitoring`, `+Export`, `+Workflows`, `+History`, and `+Widget` extensions; the core type keeps the stored state and the inspection pipeline. +- SwiftUI views: render screens and invoke view-model actions. The largest view file was decomposed too — Settings screens live in `SettingsViews.swift` and the result detail sections in `ResultSectionViews.swift`. + +## Audit Mode + +The app has one active Audit Mode implementation: + +- Models live in `DomainDig/DomainDig/AuditModels.swift`. +- UI lives in `DomainDig/DomainDig/AuditViews.swift`. +- Export rendering lives in `DomainDig/DomainDig/AuditExporter.swift`. +- Persistence is owned by `DomainViewModel` through `DomainDataPortabilityService`. + +An audit session captures: + +- Domain and reviewer metadata +- Session status +- Point-in-time `HistoryEntry` and `DomainReport` +- Historical snapshot context +- Evidence asset references +- Checklist progress +- Findings with severity, status, evidence references, notes, and checklist areas +- Reviewer notes + +Audit sessions are stored under the same local portability service as the rest of app data and are included in full backup/restore flows. + +The older standalone prototype files, `DomainDig/AuditMode.swift` and `DomainDig/AuditModeView.swift`, are preserved in the repository for reference but excluded from synchronized target membership. They are not the release audit path. + +## Data Portability + +`DomainDataPortabilityService` owns backup, import, validation, lifecycle counts, and merge/replace behavior for: + +- Tracked domains +- History snapshots +- Audit sessions +- Workflows +- Monitoring settings and logs +- App settings +- Local feature metadata + +Backup imports support merge and replace modes. Merge mode deduplicates by stable IDs or normalized domain keys, keeps local data where appropriate, and merges audit-session reviewer notes when the same audit session appears in multiple backups. + +## Feature Tiers + +`FeatureAccessService`, `PremiumAccessService`, `PurchaseService`, and `UsageCreditService` provide the app's feature-gating surfaces. + +The app remains local-first. Purchase and entitlement code is local app infrastructure and does not introduce a hosted DomainDig backend. + +## Local API + +`LocalAPIService` is an automation surface over the same inspection/reporting pipeline: + +- `DomainInspectionService` +- `DomainReportBuilder` +- `DomainReportExporter` +- `LocalAPIModels` + +`v5.0.0` stabilized this contract: `LocalAPIContract` is the single source of truth for the `v1` wire version and JSON encoder, the response envelope and payloads are documented, and the shape is regression-locked by `LocalAPIContractTests`. See [local-api.txt](local-api.txt) for the endpoint and compatibility reference, and [data-migration.txt](data-migration.txt) for how the persisted store is versioned across app updates. + +## Testing + +Two test targets run from the `DomainDig` scheme's test action: + +- `DomainDigTests` — unit coverage of the deterministic core: `DomainReportBuilder`, `DomainReportExporter`, `DiffService`, `DomainDataPortabilityService` (merge/replace dedup), the store-migration runner, and the Local API contract. `SnapshotFixture` builds the deep `LookupSnapshot`/`DomainReport` models through the real builder so tests construct inputs without wiring every field. +- `DomainDigUITests` — Apple's `performAccessibilityAudit()` over every primary screen at default and largest Dynamic Type, plus metadata and screenshot assertions. See [ACCESSIBILITY.txt](ACCESSIBILITY.txt). + +A plain `xcodebuild test` (and CI) runs both. The unit net went in first in `v5.0.0` and is what made the god-file decomposition safe to attempt. + +## Xcode Project Structure + +`DomainDig.xcodeproj` uses filesystem-synchronized groups for the `DomainDig` folder. Target membership exclusions are therefore important release metadata. Files that should remain in the tree but not compile, such as retired prototypes, must be listed in the appropriate synchronized build file exception set. + +## Adding A New Data Source + +1. Add the raw collection call to `LookupRuntime` or an existing section service. +2. Integrate it in `DomainInspectionService` with provenance, cache source, and normalized failures. +3. Extend `LookupSnapshot` only if the raw result must persist. +4. Add summarized representation to `DomainReportBuilder`. +5. Expose it through `DomainReportExporter` or `LocalAPIModels` when it is part of the external contract. +6. Render it in SwiftUI from `DomainReport` fields or view-model state. +7. Update backup/restore only when the data is user-authored state or long-lived app state. diff --git a/Docs/data-migration.md b/Docs/data-migration.md deleted file mode 100644 index 1ae4030..0000000 --- a/Docs/data-migration.md +++ /dev/null @@ -1,94 +0,0 @@ -# DomainDig Data Migration Policy - -How DomainDig's persisted data evolves across app versions without losing or -corrupting a user's on-device store. - -## What is persisted - -The store is a set of independent JSON blobs in `UserDefaults`, each under a -stable key (see `DomainDataPortabilityService.StorageKey`): - -| Data | Key | -|------|-----| -| Tracked domains | `trackedDomains` (legacy: `watchedDomains`) | -| Lookup history (snapshots) | `lookupHistory` | -| Audit sessions | `domainAudits` | -| Workflows | `domainWorkflows` | -| Monitoring settings / logs | `monitoring.settings`, `monitoring.logs` | -| App settings | `recentSearches`, `savedDomains`, resolver URL, density | -| Feature metadata | `purchase.cachedEntitlement`, `usageCredits.ledger` | - -A **backup export** (`DomainDigBackup`) is a separate, self-describing file that -bundles all of the above with its own `schemaVersion`. - -## Two version lines - -- **Store schema version** — `DataMigrationService.currentStoreSchemaVersion`, - persisted under `data.storeSchemaVersion`. Describes the shape of the - *on-device* `UserDefaults` store. Advanced by the migration runner. -- **Backup schema version** — `DomainDigBackup.currentSchemaVersion`, written - into every exported file. Describes the shape of an *export*. Checked on import - by `DataValidationService`. - -They advance independently: a store migration that doesn't change the export -shape need not bump the backup version, and vice versa. - -## How models evolve - -Prefer **additive, lenient decoding** — it needs no migration: - -- New optional field → add it with `decodeIfPresent(...) ?? default` in the - model's `init(from:)`. Old data simply lacks the key and falls back. -- New value in a `String`-backed enum → decode unknown values to a safe default - rather than throwing. - -Reach for a **migration step** only when lenient decoding can't express the -change: - -- Renaming or removing a storage key (e.g. `watchedDomains` → `trackedDomains`). -- Re-normalizing existing rows (dedup, canonicalizing domain casing). -- Reshaping a blob in a way old readers would misread. - -## The migration runner - -`DataMigrationService.migrateIfNeeded(defaults:)` runs at launch (and before any -backup export/import). Its contract: - -1. **Forward-only.** It reads the stored version and runs each step with a target - greater than it, in ascending order, up to `currentStoreSchemaVersion`, - stamping the new version after each step. -2. **Never downgrades.** A store stamped at a version *higher* than this build - understands (a user who ran a newer build first) is left untouched — no - rewrite, no data loss. -3. **Idempotent & safe on any state.** Every step must be safe to run on an empty - store and to re-run, because a downgrade-then-upgrade or a partial run can - replay it. v1 (the `watchedDomains` drop + dedup normalization) satisfies this - by loading through the deduplicating loaders and writing back. -4. **Pre-versioning installs.** Before this framework, a boolean marker - (`data.migrations.v3_4_0`) recorded that the v1 normalization had run. A set - marker is read as "already at version 1," so v1 never re-runs for those users. - -## Adding a migration - -1. Add a `case N:` to `DataMigrationService.runMigration(to:defaults:)` and a - private helper that performs the change. -2. Bump `currentStoreSchemaVersion` to `N`. -3. Make the helper idempotent and safe on an empty/older store. -4. Add a `DataMigrationServiceTests` case that seeds a pre-`N` fixture, runs - `migrateIfNeeded`, and asserts the upgrade plus the version stamp. -5. If the change also alters the export shape, bump - `DomainDigBackup.currentSchemaVersion` and update `Docs/local-api.md` / - backup validation as needed. - -## Backup import compatibility - -On import, `DataValidationService.validate(backup:)` compares the file's -`schemaVersion` to the current one: - -- **Newer** than this build → surfaced as an error (the build can't safely read - it). -- **Older** → imported under the same lenient decoders and merge/dedup rules that - govern the live store; a note is surfaced, not an error. - -Imported data flows through `migrateIfNeeded` and the same `save*` deduplication -as everything else, so an old backup lands in the store already normalized. diff --git a/Docs/data-migration.txt b/Docs/data-migration.txt new file mode 100644 index 0000000..c97a1be --- /dev/null +++ b/Docs/data-migration.txt @@ -0,0 +1,94 @@ +# DomainDig Data Migration Policy + +How DomainDig's persisted data evolves across app versions without losing or +corrupting a user's on-device store. + +## What is persisted + +The store is a set of independent JSON blobs in `UserDefaults`, each under a +stable key (see `DomainDataPortabilityService.StorageKey`): + +| Data | Key | +|------|-----| +| Tracked domains | `trackedDomains` (legacy: `watchedDomains`) | +| Lookup history (snapshots) | `lookupHistory` | +| Audit sessions | `domainAudits` | +| Workflows | `domainWorkflows` | +| Monitoring settings / logs | `monitoring.settings`, `monitoring.logs` | +| App settings | `recentSearches`, `savedDomains`, resolver URL, density | +| Feature metadata | `purchase.cachedEntitlement`, `usageCredits.ledger` | + +A **backup export** (`DomainDigBackup`) is a separate, self-describing file that +bundles all of the above with its own `schemaVersion`. + +## Two version lines + +- **Store schema version** — `DataMigrationService.currentStoreSchemaVersion`, + persisted under `data.storeSchemaVersion`. Describes the shape of the + *on-device* `UserDefaults` store. Advanced by the migration runner. +- **Backup schema version** — `DomainDigBackup.currentSchemaVersion`, written + into every exported file. Describes the shape of an *export*. Checked on import + by `DataValidationService`. + +They advance independently: a store migration that doesn't change the export +shape need not bump the backup version, and vice versa. + +## How models evolve + +Prefer **additive, lenient decoding** — it needs no migration: + +- New optional field → add it with `decodeIfPresent(...) ?? default` in the + model's `init(from:)`. Old data simply lacks the key and falls back. +- New value in a `String`-backed enum → decode unknown values to a safe default + rather than throwing. + +Reach for a **migration step** only when lenient decoding can't express the +change: + +- Renaming or removing a storage key (e.g. `watchedDomains` → `trackedDomains`). +- Re-normalizing existing rows (dedup, canonicalizing domain casing). +- Reshaping a blob in a way old readers would misread. + +## The migration runner + +`DataMigrationService.migrateIfNeeded(defaults:)` runs at launch (and before any +backup export/import). Its contract: + +1. **Forward-only.** It reads the stored version and runs each step with a target + greater than it, in ascending order, up to `currentStoreSchemaVersion`, + stamping the new version after each step. +2. **Never downgrades.** A store stamped at a version *higher* than this build + understands (a user who ran a newer build first) is left untouched — no + rewrite, no data loss. +3. **Idempotent & safe on any state.** Every step must be safe to run on an empty + store and to re-run, because a downgrade-then-upgrade or a partial run can + replay it. v1 (the `watchedDomains` drop + dedup normalization) satisfies this + by loading through the deduplicating loaders and writing back. +4. **Pre-versioning installs.** Before this framework, a boolean marker + (`data.migrations.v3_4_0`) recorded that the v1 normalization had run. A set + marker is read as "already at version 1," so v1 never re-runs for those users. + +## Adding a migration + +1. Add a `case N:` to `DataMigrationService.runMigration(to:defaults:)` and a + private helper that performs the change. +2. Bump `currentStoreSchemaVersion` to `N`. +3. Make the helper idempotent and safe on an empty/older store. +4. Add a `DataMigrationServiceTests` case that seeds a pre-`N` fixture, runs + `migrateIfNeeded`, and asserts the upgrade plus the version stamp. +5. If the change also alters the export shape, bump + `DomainDigBackup.currentSchemaVersion` and update `Docs/local-api.txt` / + backup validation as needed. + +## Backup import compatibility + +On import, `DataValidationService.validate(backup:)` compares the file's +`schemaVersion` to the current one: + +- **Newer** than this build → surfaced as an error (the build can't safely read + it). +- **Older** → imported under the same lenient decoders and merge/dedup rules that + govern the live store; a note is surfaced, not an error. + +Imported data flows through `migrateIfNeeded` and the same `save*` deduplication +as everything else, so an old backup lands in the store already normalized. diff --git a/Docs/local-api.md b/Docs/local-api.md deleted file mode 100644 index a0cebdc..0000000 --- a/Docs/local-api.md +++ /dev/null @@ -1,113 +0,0 @@ -# DomainDig Local API — `v1` - -The Local API exposes DomainDig's canonical report data to on-device automation -(Shortcuts, scripts, integrations). It is **off by default** and, when enabled, -binds only to loopback. - -- **Base URL:** `http://127.0.0.1:` (default port `47821`, configurable in - Settings → Local API) -- **Binding:** loopback only (`acceptLocalOnly`); never reachable off-device -- **Content type:** every response is `application/json` -- **Version:** `v1` (reported in every response envelope) - -This document is the stable contract. The response shape is pinned by -`DomainDigTests/LocalAPIContractTests.swift`; `LocalAPIContract` (in -`LocalAPIContract.swift`) is the single source of truth for the version string -and the JSON encoder. - -## Authentication - -Every request requires the token shown in Settings → Local API, supplied either -way: - -``` -Authorization: Bearer -``` -``` -X-API-Token: -``` - -A missing or wrong token returns `401 unauthorized`. Settings → Local API has a -**Copy cURL Command** button that emits a ready-to-run authenticated request. - -## Response envelope - -Every response — success or error — is wrapped in the same envelope: - -```json -{ - "success": true, - "version": "v1", - "data": { "...": "payload, present on success" } -} -``` -```json -{ - "success": false, - "version": "v1", - "error": { "code": "not_found", "message": "The requested Local API route does not exist." } -} -``` - -- On success, `data` holds the endpoint payload and `error` is **omitted**. -- On failure, `error` holds a machine `code` plus a human `message`, and `data` - is **omitted**. - -### Encoding conventions - -- **Dates** are ISO-8601 UTC strings, e.g. `"2023-11-14T22:13:20Z"`. -- **Absent optional fields are omitted, not `null`.** Consumers must treat a - missing key as "not present." -- Object keys are emitted in sorted order (deterministic output; not - contractually meaningful — do not depend on key order). - -## Endpoints - -| Method | Path | Payload (`data`) fields | -|--------|------|-------------------------| -| GET | `/portfolio` | `summary` → `{ totalDomains, healthyCount, warningCount, criticalCount, changedLast24h, expiringSoonCount, unreachableCount }` | -| GET | `/domains` | `domains: [TrackedDomain]` | -| GET | `/domains/{domain}` | `domain`, `trackedDomain?` (`TrackedDomain`), `latestReport?` (`DomainReport`) | -| GET | `/domains/{domain}/history` | `domain`, `history: [HistoryEntry]` | -| GET | `/events` | `events: [{ timestamp, domain, summary, status, severity }]` | -| GET | `/monitoring` | `isEnabled`, `scope` (`"allTracked"` \| `"selectedOnly"`), `alertsEnabled`, `monitoredDomains: [{ domain, monitoringEnabled, lastMonitoredAt?, lastAlertAt?, certificateWarningLevel }]` | -| POST | `/inspect` | body `{ "domain": "example.com" }` → `report` (`DomainReport`) | -| POST | `/inspect/{domain}` | `report` (`DomainReport`) | -| POST | `/monitoring/{domain}/enable` | `domain`, `monitoringEnabled` | -| POST | `/monitoring/{domain}/disable` | `domain`, `monitoringEnabled` | - -`certificateWarningLevel` encodes as `"none"`, `"warning"`, or `"critical"`. - -`DomainReport` is the app's canonical report model (the same shape the JSON -export produces); see `DomainReportBuilder.swift` for its fields. It is a large -object and is treated as an additive contract: new fields may appear without a -version bump. - -## Error codes - -| HTTP | `code` | When | -|------|--------|------| -| 400 | `bad_request` | The HTTP request line/path could not be parsed | -| 400 | `invalid_body` | `POST /inspect` body was not `{ "domain": "…" }` | -| 400 | `invalid_domain` | A path/body domain was empty or invalid | -| 401 | `unauthorized` | Missing or incorrect token | -| 404 | `not_found` | No such route | -| 404 | `domain_not_found` | No local data / tracked domain for the given name | -| 500 | `encoding_failed` | The response could not be encoded | -| 500 | `internal_error` | The request handler failed unexpectedly | - -## Compatibility policy - -The `version` field follows a semantic-version-style promise: - -- **Backward-compatible changes keep `version` at `v1`.** Adding a new endpoint, - or adding a new field to an existing payload, is non-breaking. **Consumers - must ignore unknown fields.** -- **Breaking changes bump `version`.** Renaming or removing a field, changing a - field's type, or changing the meaning/units of an existing field requires a new - version, an update to this document, and an update to - `LocalAPIContractTests.swift`. - -There are currently no deprecated fields or endpoints. When a field is -deprecated, it will be listed here with the version in which it becomes eligible -for removal, and will remain present for at least one subsequent version. diff --git a/Docs/local-api.txt b/Docs/local-api.txt new file mode 100644 index 0000000..a0cebdc --- /dev/null +++ b/Docs/local-api.txt @@ -0,0 +1,113 @@ +# DomainDig Local API — `v1` + +The Local API exposes DomainDig's canonical report data to on-device automation +(Shortcuts, scripts, integrations). It is **off by default** and, when enabled, +binds only to loopback. + +- **Base URL:** `http://127.0.0.1:` (default port `47821`, configurable in + Settings → Local API) +- **Binding:** loopback only (`acceptLocalOnly`); never reachable off-device +- **Content type:** every response is `application/json` +- **Version:** `v1` (reported in every response envelope) + +This document is the stable contract. The response shape is pinned by +`DomainDigTests/LocalAPIContractTests.swift`; `LocalAPIContract` (in +`LocalAPIContract.swift`) is the single source of truth for the version string +and the JSON encoder. + +## Authentication + +Every request requires the token shown in Settings → Local API, supplied either +way: + +``` +Authorization: Bearer +``` +``` +X-API-Token: +``` + +A missing or wrong token returns `401 unauthorized`. Settings → Local API has a +**Copy cURL Command** button that emits a ready-to-run authenticated request. + +## Response envelope + +Every response — success or error — is wrapped in the same envelope: + +```json +{ + "success": true, + "version": "v1", + "data": { "...": "payload, present on success" } +} +``` +```json +{ + "success": false, + "version": "v1", + "error": { "code": "not_found", "message": "The requested Local API route does not exist." } +} +``` + +- On success, `data` holds the endpoint payload and `error` is **omitted**. +- On failure, `error` holds a machine `code` plus a human `message`, and `data` + is **omitted**. + +### Encoding conventions + +- **Dates** are ISO-8601 UTC strings, e.g. `"2023-11-14T22:13:20Z"`. +- **Absent optional fields are omitted, not `null`.** Consumers must treat a + missing key as "not present." +- Object keys are emitted in sorted order (deterministic output; not + contractually meaningful — do not depend on key order). + +## Endpoints + +| Method | Path | Payload (`data`) fields | +|--------|------|-------------------------| +| GET | `/portfolio` | `summary` → `{ totalDomains, healthyCount, warningCount, criticalCount, changedLast24h, expiringSoonCount, unreachableCount }` | +| GET | `/domains` | `domains: [TrackedDomain]` | +| GET | `/domains/{domain}` | `domain`, `trackedDomain?` (`TrackedDomain`), `latestReport?` (`DomainReport`) | +| GET | `/domains/{domain}/history` | `domain`, `history: [HistoryEntry]` | +| GET | `/events` | `events: [{ timestamp, domain, summary, status, severity }]` | +| GET | `/monitoring` | `isEnabled`, `scope` (`"allTracked"` \| `"selectedOnly"`), `alertsEnabled`, `monitoredDomains: [{ domain, monitoringEnabled, lastMonitoredAt?, lastAlertAt?, certificateWarningLevel }]` | +| POST | `/inspect` | body `{ "domain": "example.com" }` → `report` (`DomainReport`) | +| POST | `/inspect/{domain}` | `report` (`DomainReport`) | +| POST | `/monitoring/{domain}/enable` | `domain`, `monitoringEnabled` | +| POST | `/monitoring/{domain}/disable` | `domain`, `monitoringEnabled` | + +`certificateWarningLevel` encodes as `"none"`, `"warning"`, or `"critical"`. + +`DomainReport` is the app's canonical report model (the same shape the JSON +export produces); see `DomainReportBuilder.swift` for its fields. It is a large +object and is treated as an additive contract: new fields may appear without a +version bump. + +## Error codes + +| HTTP | `code` | When | +|------|--------|------| +| 400 | `bad_request` | The HTTP request line/path could not be parsed | +| 400 | `invalid_body` | `POST /inspect` body was not `{ "domain": "…" }` | +| 400 | `invalid_domain` | A path/body domain was empty or invalid | +| 401 | `unauthorized` | Missing or incorrect token | +| 404 | `not_found` | No such route | +| 404 | `domain_not_found` | No local data / tracked domain for the given name | +| 500 | `encoding_failed` | The response could not be encoded | +| 500 | `internal_error` | The request handler failed unexpectedly | + +## Compatibility policy + +The `version` field follows a semantic-version-style promise: + +- **Backward-compatible changes keep `version` at `v1`.** Adding a new endpoint, + or adding a new field to an existing payload, is non-breaking. **Consumers + must ignore unknown fields.** +- **Breaking changes bump `version`.** Renaming or removing a field, changing a + field's type, or changing the meaning/units of an existing field requires a new + version, an update to this document, and an update to + `LocalAPIContractTests.swift`. + +There are currently no deprecated fields or endpoints. When a field is +deprecated, it will be listed here with the version in which it becomes eligible +for removal, and will remain present for at least one subsequent version. -- cgit v1.2.3