<feed xmlns='http://www.w3.org/2005/Atom'>
<title>domain-dig.git/Docs, branch v5.0.1</title>
<subtitle>local-first ios domain inspection toolkit. dns, tls, rdap, audit.
</subtitle>
<id>http://git.krz.sh/krz/domain-dig.git/atom?h=v5.0.1</id>
<link rel='self' href='http://git.krz.sh/krz/domain-dig.git/atom?h=v5.0.1'/>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/'/>
<updated>2026-07-25T16:40:57+00:00</updated>
<entry>
<title>docs: polish repo documentation after the v5.0.0 work</title>
<updated>2026-07-25T16:40:57+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-25T16:39:55+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=b824dae8ea16d20d75136687a7c59bc7b01afe27'/>
<id>urn:sha1:b824dae8ea16d20d75136687a7c59bc7b01afe27</id>
<content type='text'>
Brings the docs in line with the shipped v5.0.0 state and fixes staleness that
accumulated across the release.

- README: add a Tests section documenting the DomainDigTests unit net and that
  the scheme's test action runs both it and the accessibility audit; reword the
  roadmap pointer (v5.0.0 shipped, no longer "planned"); align the contact
  address to hello@zerolabs.sh (was cleberg.net, inconsistent with SECURITY.md).
- ARCHITECTURE: drop the stale "v4.4.1" from the title; note DomainReportExporter
  also renders Markdown and PDF; describe the DomainViewModel concern extensions
  and the ContentView split (SettingsViews / ResultSectionViews); record that
  v5.0.0 stabilized the Local API contract with links to local-api.md and
  data-migration.md; add a Testing section covering both test targets.
- SECURITY: bump the supported-versions table from 4.x to 5.x.

ACCESSIBILITY.md, local-api.md, and data-migration.md were already current and
are unchanged. Docs-only; no code changes.
</content>
</entry>
<entry>
<title>feat: versioned store-migration policy for persisted data (v5 step 2)</title>
<updated>2026-07-25T04:37:00+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-25T04:35:18+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=99e4623af1b08f36120a01b67cbe60df99668651'/>
<id>urn:sha1:99e4623af1b08f36120a01b67cbe60df99668651</id>
<content type='text'>
Third v5.0.0 roadmap item: define and implement a migration policy for the
on-device persisted store (tracked domains, history/snapshots, audits,
workflows, monitoring, settings), so data upgrades cleanly across app versions
instead of relying on a one-shot marker.

- DataMigrationService is reworked from a single boolean marker
  (`data.migrations.v3_4_0`) into a versioned runner keyed by an integer store
  schema version (`data.storeSchemaVersion`). It runs each step once in
  ascending order up to `currentStoreSchemaVersion`, stamping the version as it
  goes. Adding a future migration is now a `case N:` plus a version bump.

  Policy guarantees, all covered by tests:
  - Forward-only and idempotent; every step must be safe on an empty/older store.
  - Never downgrades: a store written by a newer build (higher version) is left
    byte-for-byte untouched.
  - Pre-versioning installs are handled: a set legacy boolean marker reads as
    "already at v1", so the v1 normalization never re-runs for them.

  v1 is the existing normalization pass (dedup + drop the legacy `watchedDomains`
  key + sanitize monitoring settings), now expressed as migration step 1.

- Docs/data-migration.md documents the persisted surface, the two independent
  version lines (store vs. backup export), when to use lenient decoding vs. a
  migration step, the runner contract, an "adding a migration" checklist, and
  backup-import compatibility. Linked from the README.

- DataMigrationServiceTests: 6 tests over legacy fixtures — fresh-store stamping,
  legacy `watchedDomains` migration + key drop, in-place dedup of the stored
  blob, idempotence, legacy-marker-as-v1, and the no-downgrade guard. Full unit
  suite: 58 passing.
</content>
</entry>
<entry>
<title>feat: stabilize and document the Local API v1 response contract (v5 step 3)</title>
<updated>2026-07-25T04:29:01+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-25T04:14:23+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=a52dee116d4066d1b59bd90b4ebc4def4e1597d6'/>
<id>urn:sha1:a52dee116d4066d1b59bd90b4ebc4def4e1597d6</id>
<content type='text'>
Second v5.0.0 roadmap item: make the Local API's public JSON contract explicit,
documented, and regression-locked, so external consumers (Shortcuts, scripts,
integrations) have a stable surface with a defined compatibility promise.

- LocalAPIContract: new single source of truth for the wire-format version
  ("v1") and the canonical JSON encoder (ISO-8601 dates, sorted keys). Both the
  success and error paths in LocalAPIService now route through it, so the format
  can't drift between them, and the ad-hoc per-call-site encoders are gone.

- The response envelope and every payload struct are promoted from `private` to
  internal so the contract is a first-class, testable part of the module. The
  transport/handler internals (request parser, HTTP response, secret store)
  stay private.

- Docs/local-api.md documents the base URL/auth, the envelope, the encoding
  conventions (notably: absent optionals are omitted, not null), every endpoint
  and its payload fields, the error codes, and the semantic-version-style
  compatibility policy (additive changes keep v1; renames/removals/type changes
  bump the version). Linked from the README.

- LocalAPIContractTests: 16 structure/"golden" tests pinning the envelope shape,
  each payload's field names, the enum encodings, and the ISO-8601 date format.
  They assert structure, not values, so ordinary behavior changes don't churn
  them but a renamed or dropped field fails CI. Full unit suite: 52 passing.
</content>
</entry>
<entry>
<title>docs cleanup</title>
<updated>2026-07-24T00:39:46+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-24T00:38:59+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=2ca5ddf53e72379169ab3eb26468a3baa76b0b38'/>
<id>urn:sha1:2ca5ddf53e72379169ab3eb26468a3baa76b0b38</id>
<content type='text'>
</content>
</entry>
<entry>
<title>test(a11y): Phase 6 verification — metadata assertions, middle-band sweep, 27.0 fix</title>
<updated>2026-07-24T00:34:57+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-24T00:20:52+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=7315ae3da604debfacfed9715a04dc91139a31e0'/>
<id>urn:sha1:7315ae3da604debfacfed9715a04dc91139a31e0</id>
<content type='text'>
Executes the Phase-6 manual runbook against the simulator, converting the
mechanically-checkable parts into permanent coverage and reporting the rest
honestly by tier.

- Fix an enforced `.dynamicType` failure surfaced by `audit-a11y.sh current`
  on iOS 27.0: the Settings `Section("Services")` system header (app sets no
  font; 18.6 floor and 26.x CI are clean). Narrow, proven `noiseReason`
  carve-out scoped to dynamicType on the exact Settings header titles. Delta:
  current FAIL -&gt; SUCCEEDED, finding still prints as `[noise: …]`.
- AccessibilityMetadataTests: assert the icon-only control labels and the dense
  Watchlist/Batch row label+value contracts (green on 18.6 and 27.0). These
  were one-time manual VoiceOver checks; now they gate.
- Middle-band Dynamic Type sweep at AccessibilityL across the seeded screens.
  Found no band-exclusive third bug (recorded), retained as regression
  insurance for a band that historically shipped two.
- AccessibilityScreenshotTests: best-effort, non-gating capture utility used to
  produce the cross-runtime Light/Dark/AXXXL screenshots (simctl appearance
  does not propagate headlessly; driven through the in-app picker instead).
- Docs/ACCESSIBILITY_VERIFICATION_RESULTS.md: full pass/fail/not-executable
  matrix + 15 screenshots. Notable positive result: Differentiate Without Color
  IS verifiable via the global com.apple.Accessibility defaults domain.
</content>
</entry>
<entry>
<title>feat(a11y): engage the audit enforcement ratchet (#21)</title>
<updated>2026-07-22T20:04:32+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-22T06:01:05+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=dd41ed2c51beae07288fdfa724e410e0d321d278'/>
<id>urn:sha1:dd41ed2c51beae07288fdfa724e410e0d321d278</id>
<content type='text'>
The point of the Phase 0 harness finally lands: named findings in
textClipped, dynamicType, hitRegion, elementDetection,
sufficientElementDescription, and trait now FAIL the build on the
empty-state suite. Regressions in five phases of accessibility work are
gated, not narrated.

Three carve-outs, each earned by evidence rather than convenience:

- contrast stays report-only. The two long-standing Settings findings are
  rows scrolled under the translucent tab bar, and their attribution
  flips between a row name and nil run-to-run — no suppression is narrow
  enough to keep CI stable. The centralised palette is the real guard.
- The seeded dense-row tests run reportOnly. Bisection showed the audit
  degrades on children-ignored content — the correct VoiceOver treatment
  for dense rows — emitting unattributed contrast/dynamicType failures on
  rows that measure 6-7:1 and render correctly.
- Characterised noise is suppressed narrowly and always logged as
  [noise: reason]: disabled controls (WCAG 1.4.3 exempt), "nearly passed"
  near-misses, system field placeholders (flagged at any length — proven
  by shortening them to no effect), and unattributed clipped/dynamic-type
  artifacts. noiseReason(for:) records each rule's provenance inline.

Validated in both directions. Positive: the full 11-test suite passes
with enforcement live, dark and light, on an erased simulator. Negative:
re-injecting the Phase 3 icon-exposure regression produced two named
[FAIL] findings and a failed suite — on both screens sharing the
component — then went green again on revert.

Docs and the CI workflow comment updated to describe the engaged state.
</content>
</entry>
<entry>
<title>docs(a11y): add Phase 6 manual verification checklist (#21)</title>
<updated>2026-07-22T02:40:58+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-22T02:39:51+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=80cb5d8553d3afd097b10e83cf7af0bf1ba56523'/>
<id>urn:sha1:80cb5d8553d3afd097b10e83cf7af0bf1ba56523</id>
<content type='text'>
A device checklist for everything the automated audit cannot reach:
VoiceOver speech, the More Content rotor, custom-content ordering,
completion announcements, Voice Control label-in-name, the colour/motion/
transparency settings simctl cannot toggle, iPad keyboard focus order,
and both system design languages (classic vs Liquid Glass).

Grounded in the actual implementation rather than generic guidance: each
item quotes the real label/announcement/field it verifies (e.g. "Refresh
all tracked domains", "Lookup complete for &lt;domain&gt;", the More Content
field order) and carries a [Px] tag pointing at the phase that introduced
it. Organised by iOS setting so each is toggled once, with a seed-data
step up front because the dense rows and widget are otherwise untestable
— the same reason they are unverified by the audit today. Includes a
sign-off matrix, a defect-log template, and the known-deferred items so a
ViewThatFits overflow is filed correctly rather than mistaken for a
regression.
</content>
</entry>
<entry>
<title>feat(a11y): color independence, reduce motion, reduce transparency (#21 phase 5)</title>
<updated>2026-07-22T02:40:44+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-22T01:47:34+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=c220e26bb496e455a5bdb6a8dc22abf09ecfed5f'/>
<id>urn:sha1:c220e26bb496e455a5bdb6a8dc22abf09ecfed5f</id>
<content type='text'>
Audit unchanged at 11 dark / 14 light — expected, as none of these
settings are exercised by performAccessibilityAudit, and simctl can
toggle only Increase Contrast, not Differentiate Without Color, Reduce
Motion, or Reduce Transparency. Correct by construction and build-clean;
runtime behaviour is verified in the Phase 6 manual pass.

Color independence:

- Widget status is now an SF Symbol (checkmark.circle.fill /
  exclamationmark.triangle.fill / exclamationmark.octagon.fill), the same
  vocabulary as the in-app badges, replacing a silent colour-only dot on
  both the domain rows and the small-view count pills. Status now survives
  greyscale and reads consistently across surfaces.
- Under accessibilityDifferentiateWithoutColor: the Dashboard
  summary-card dot becomes a per-filter symbol, the selected quick-filter
  chip gains a checkmark and a border (selection was fill-colour only, and
  also gains the .isSelected trait), and LabeledValueRow prefixes a
  warning/failure symbol. All gated on the setting so the default UI stays
  uncluttered.

Reduce motion: all five withAnimation/.animation sites now pass nil under
accessibilityReduceMotion — AppCopyButton's check cross-fade,
CollapsibleSectionView's expand/collapse, TimelineDiffView's scroll, and
WatchlistView's list reorder.

Reduce transparency: the single .thinMaterial capsule falls back to an
opaque AppSurfaceElevated fill under accessibilityReduceTransparency.

The SweepActivityController item from the plan is dropped: it is pure
ActivityKit lifecycle with no animation, confirmed back in the issue
triage.
</content>
</entry>
<entry>
<title>feat(a11y): VoiceOver labels, dense-row rotor content, announcements (#21 phase 4)</title>
<updated>2026-07-22T01:38:45+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-22T01:00:46+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=dc479a3ba0d27fe86509d0eb2d27b01132c2b37f'/>
<id>urn:sha1:dc479a3ba0d27fe86509d0eb2d27b01132c2b37f</id>
<content type='text'>
The audit count is unchanged at 11 dark, and that is the expected
result: performAccessibilityAudit validates descriptions, traits,
contrast, hit regions, and clipping, but exercises none of VoiceOver's
speech, the More Content rotor, custom-content ordering, or
announcements — which is the entire substance of this phase. It is
verified by construction and stays green with no regressions; the
manual VoiceOver pass is Phase 6.

Icon-only controls (~14) get accessibilityLabel, obeying label-in-name:
where a control has visible text the label keeps it, so Voice Control
still works. The pin and bookmark toggles gain accessibilityValue and
.isSelected; the audit and workflow checkboxes gain .isSelected and a
hint. Decorative icons split out of Labels are hidden.

AppStatusBadgeView now reads as one word ("Critical"), not "icon,
Critical", via children: .ignore + label. SectionTitleView and
CollapsibleSectionView headers get the .isHeader trait for rotor
navigation; the collapsible header also exposes expanded/collapsed as a
value with a hint. The header deliberately does NOT use children:
.combine — its trailing() closure can hold Track/Pin controls, and
combining would swallow them.

Dense rows use combine-for-summary, custom-content-for-detail.
BatchResultRowView (8 elements) and WatchlistRowView (up to 9) become a
single element — domain as label, status as value — with risk, IP,
timestamp, source, certificate, and monitoring on the More Content
rotor, risk and certificate at .high importance. Reading all of it
inline would make a long sweep unnavigable. The custom-content chains
live in ViewModifiers because inlining six of them plus the layout broke
the type-checker. The shorter 3-4 element portfolio rows are left to
NavigationLink's automatic combine, per WWDC21-10121.

Technical strings get a speechStyle field on InfoRowViewData:
.technical applies speechAlwaysIncludesPunctuation and
accessibilityTextContentType(.sourceCode), set on DNS record values and
cipher suites so load-bearing punctuation is not swallowed.

Completion announcements: the sweep posts from the view model; the
single lookup posts from an onChange in the view, since resultsLoaded is
derived from many loading flags and has no single view-model moment.

Widget: each domain row was a silent 8pt status dot plus a bare "12d"
countdown. Rows now read as one phrase ("example.com, critical,
certificate expires in 12 days"); the count pills are labelled.

Not verifiable by the suite: the dense rows and the widget never render
in the audit (no tracked domains or batch results in the test
simulator), same limit as the deferred Phase 3 row reflow. Documented in
Docs/ACCESSIBILITY.md.
</content>
</entry>
<entry>
<title>feat(a11y): Dynamic Type reflow and tap targets (#21 phase 3)</title>
<updated>2026-07-21T02:57:22+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-21T02:49:57+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=e2da09fec3d3d52ac6108b9256b2419405f76dd3'/>
<id>urn:sha1:e2da09fec3d3d52ac6108b9256b2419405f76dd3</id>
<content type='text'>
Takes the audit from 18 findings to 11 in dark mode. Everything that
remains is system-rendered or placeholder noise, characterised below.

The largest win was not where the plan expected. Every empty-state
heading reported as clipped text, and the cause was `Label`: it
constrains its own title, and `.fixedSize` applied to the Label does not
reach the `Text` inside. Splitting into `HStack { Image; Text }` and
putting the modifier on the Text cleared all four empty states at both
default and accessibility sizes.

That fix then caused a regression the audit caught immediately. `Label`
folds its image into the title's accessibility element; an HStack does
not, so the icon began announcing its raw SF Symbol name
("checklist.unchecked") to VoiceOver. Decorative icons split out of a
Label now carry .accessibilityHidden(true).

Tap targets:

- AppCopyButton was a literal 30x30 on nearly every data row. Now
  @ScaledMetric from 44, floored at AppLayout.minimumTapTarget —
  @ScaledMetric scales down below the default text size as well as up,
  so the floor is load-bearing.
- controlMinHeight was 42 in compact density, putting every collapsible
  section header and both Run buttons under the minimum.

Reflow:

- CardView's allowsHorizontalScroll defaulted to true, so nine call sites
  hid content behind a horizontal gesture instead of wrapping — a WCAG
  1.4.10 failure and the mechanism behind clipped rows at large text
  sizes. The default is now false, and the remaining opt-in is suppressed
  at accessibility sizes.
- Fixed .system(size:) point sizes replaced with text styles in the app
  and the widget.
- The widget is clamped at accessibility1, the one place clamping is
  correct: a widget canvas is a fixed size and WidgetKit truncates
  overflow with no scroll affordance.

Two hypotheses were tested and discarded rather than left in. Monospaced
fonts looked like the clipping culprit — the app is 82% monospaced and
hyphenates mid-word at accessibility sizes — but switching the empty
state to proportional changed nothing, and prose typography is a design
decision rather than an accessibility fix. Shortening search prompts and
the domain placeholder also changed nothing: placeholder text is reported
clipped regardless of length, so "Search" is flagged exactly as "Search
portfolio" was.

Not done: ViewThatFits reflow for BatchResultRowView and
WatchlistRowView. Those rows never render in the audit because the test
simulator has no tracked domains or batch results, so any change there
would be unverifiable. Absence of findings is absence of data.
</content>
</entry>
</feed>
