<feed xmlns='http://www.w3.org/2005/Atom'>
<title>domain-dig.git/DomainDig/DomainViewModel.swift, branch v4.9.0</title>
<subtitle>local-first ios domain inspection toolkit. dns, tls, rdap, audit.
</subtitle>
<id>http://git.krz.sh/krz/domain-dig.git/atom?h=v4.9.0</id>
<link rel='self' href='http://git.krz.sh/krz/domain-dig.git/atom?h=v4.9.0'/>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/'/>
<updated>2026-07-22T20:04:32+00:00</updated>
<entry>
<title>feat(a11y): seeded audit fixtures; fix dense-row reflow they exposed (#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-22T05:47:12+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=7f917e98b929dcf0f7901d0bc4eb05e04db3aa0c'/>
<id>urn:sha1:7f917e98b929dcf0f7901d0bc4eb05e04db3aa0c</id>
<content type='text'>
The dense rows and portfolio sections never rendered in the audit — the
test simulator has no tracked domains or batch results — so five phases
of row treatment shipped unmeasured. Driving the add-domain UI was tried
earlier and rejected (keyboard contamination, persistent state), so this
adds DOMAIN_DIG_SEED_FIXTURES: DEBUG-only launch argument, same pattern
as DOMAIN_DIG_FORCE_PRO_PLUS, seeding four tracked domains and four
batch results chosen to exercise every badge path, including a failed
lookup and a stress-length domain name.

Fixtures are strictly in-memory. persistTrackedDomains, refreshWidgetData
(App Group file), refreshPersistedData, and refreshMonitoringState are
all guarded while fixtures are active — the last one mattered: it runs
right after seeding in the app task and was reloading the empty disk
over the fixtures, which initially made the seeded watchlist audit pass
by silently auditing the empty state.

Four new audit tests cover the seeded Dashboard, Tracked Domains, and
batch results at default and AccessibilityXXXL.

What they found was real. At XXXL the watchlist row rendered the domain
as "hea lt…" while the Registered badge wrapped one character per line
into a screen-height capsule. Fixes, verified by before/after
screenshots and the XXXL audits dropping to 7-8 findings per screen:

- AppStatusBadgeView gets .fixedSize() — a capsule badge must never
  letter-wrap; taking natural width instead forces the row layout to its
  stacked alternative.
- WatchlistRowView, BatchResultRowView, and PortfolioExpiryRow headers
  use ViewThatFits: domain-beside-badge while it genuinely fits, badge
  below the domain at accessibility sizes. Domain titles get
  fixedSize(horizontal: false, vertical: true) so they wrap rather than
  report a single-line ideal width to ViewThatFits and truncate.
- The watchlist monitoring metadata strip (three texts abreast) stacks
  vertically when it no longer fits instead of wrapping mid-word.

Known and deliberate: the seeded default-size audits still report a
contrast/dynamicType wave attributed to "unknown element". Bisecting the
row and badge accessibility modifiers showed most of it is an audit
artifact on children-ignored content (the same rows measure 6-7:1 and
render correctly); the artifact classes get characterised suppressions
when enforcement lands, not blanket ones.
</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>fix: extract nested ternary in batch quickStatus</title>
<updated>2026-07-20T21:33:58+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-20T21:32:41+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=23ef8ef9e632b205675b0fda8200635201d2a889'/>
<id>urn:sha1:23ef8ef9e632b205675b0fda8200635201d2a889</id>
<content type='text'>
Closes the last swift:S3358 in the new-code period. The hasChanges
branch nested a severity check inside the impactClassification ternary;
split into if/else. No behavior change.
</content>
</entry>
<entry>
<title>v4.8.3: Clear SonarCloud new-code issues</title>
<updated>2026-07-20T21:27:59+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-20T21:22:28+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=bb18197860ffdb491f010648ec903003b6e1bb65'/>
<id>urn:sha1:bb18197860ffdb491f010648ec903003b6e1bb65</id>
<content type='text'>
Fixes the 4 reported bugs and ~97 code smells flagged in the new-code
period. No behavior changes.

Bugs (swift:S3923) — DomainInspectionService's confidenceFor* helpers each
returned `error == nil ? .low : .low`, an inert conditional. Simplified to
`return .low` and dropped the now-unused `error` parameter.

Smells:
- Merged 14 identical `.empty`/`.error` switch branches in DomainViewModel
- Consolidated duplicate implementations (clearPresentedResults/reset,
  String.nonEmpty/nilIfEmpty, ExportFormat.id/fileExtension)
- Extracted nested ternaries into TLSGrade.tone, EmailSecurityGrade.tone,
  and ChangeImpactClassification.color; removed ContentView.impactColor
  and the duplicate mapping in BatchResultsView
- Documented empty closures and singleton inits
- Marked unused protocol-conformance parameters `_`
- Renamed CloudSyncTrigger.`import` to `imported` (raw value preserved)
  and SSLSessionDelegate's _serverTrust/_tlsMetadata
- Merged nested ifs in the DER parser; flattened closure nesting in
  PortScanService and IntegrationService
- Replaced two-case switches with if/else

Left open: S107 (init parameter counts), S115 (constants mirroring DoH and
ipapi JSON keys), S1075 (false positives on https:// literals), and two
S117 hits on SwiftUI $binding shorthand. These want a Won't Fix resolution
in SonarCloud, not a code change.
</content>
</entry>
<entry>
<title>v4.8.0: Add markdown and PDF export formats</title>
<updated>2026-07-20T18:56:41+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-17T16:09:02+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=01c3d342f6980a69feb8df27a3454fd721d6298e'/>
<id>urn:sha1:01c3d342f6980a69feb8df27a3454fd721d6298e</id>
<content type='text'>
- DomainExportFormat gains .markdown and .pdf (CaseIterable, Identifiable,
  titled), alongside the existing text/csv/json.
- Markdown reuses the existing text-export content verbatim via a line-based
  transform (section "Title\n----" underlines become "## Title", the leading
  title becomes an H1, other lines become bullets), so the two formats can
  never drift apart.
- PDF renders that Markdown as a simple monospaced multi-page document via
  UIGraphicsPDFRenderer (mirrors AuditExporter's existing PDF approach;
  degrades to raw Markdown bytes on non-UIKit platforms).
- Replaced the single/batch/tracked-domains/workflow share call sites' hand-
  written per-format switches with format-agnostic functions
  (exportSingleReportData, exportBatchReportData, exportTrackedDomainsData,
  exportWorkflowData) that delegate straight to DomainReportExporter, removing
  the now-orphaned per-format helper functions those switches used to call.
</content>
</entry>
<entry>
<title>v4.7.0: Add watchlist tags and saved filter views</title>
<updated>2026-07-17T15:57:30+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-17T15:45:42+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=fbf2d9bf2cf50eb681afe18deac06a96c04004e6'/>
<id>urn:sha1:fbf2d9bf2cf50eb681afe18deac06a96c04004e6</id>
<content type='text'>
- TrackedDomain.tags: [String] (backward-compatible custom decode), with
  updateTags(_:for:) and a normalized/deduplicated write path.
- Tag editing in TrackedDomainDetailView (comma-separated field, chip display).
- Tag filter chips in WatchlistView (TagFilterChipRowView), integrated into
  filteredTrackedDomains alongside the existing filter/search.
- Saved views: name + snapshot the current tag/filter/sort as a
  WatchlistSavedView preset (UserDefaults-backed, not part of backup/restore),
  with a management sheet to apply or delete presets.
</content>
</entry>
<entry>
<title>v4.7.0: Add domain reputation/blocklist data source</title>
<updated>2026-07-17T15:57:30+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-17T15:39:17+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=6e273c2676ce29cef057d117e4427e031886e743'/>
<id>urn:sha1:6e273c2676ce29cef057d117e4427e031886e743</id>
<content type='text'>
- New DomainReputationResult model (status: clean/listed/unknown, listed
  sources, checked-at) and a `reputation(domain:)` method on ExternalDataService,
  mirroring the existing pluggable-URL enrichment pattern (ownership history,
  DNS history, extended subdomains, pricing). With no endpoint configured
  (the default; DomainDig ships no bundled third-party reputation dependency)
  it resolves to unavailable rather than "clean".
- New .reputation FeatureCapability/DataCapability, gated Pro+ like domainPricing.
- Threaded reputation/reputationError through LookupSnapshot and HistoryEntry
  (backward-compatible decode) so results persist with history entries.
- Auto-fetched in performLookup alongside pricing; surfaced as a "Reputation"
  info row, folded into DomainInsightEngine's risk score/factors and top-level
  insights (a listed domain raises risk score and adds a factor/insight), and
  exported in text, CSV, and JSON report output.
- Reputation-driven risk changes ride the existing change-severity pipeline, so
  a listed status flip is visible to monitoring the same way any other risk
  delta is, without bespoke monitoring wiring.
</content>
</entry>
<entry>
<title>Implement v4.6.0: sweep Live Activity, share extension, iPad split view, actionable notifications</title>
<updated>2026-07-17T15:12:36+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-17T13:36:05+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=b4b94d54cbed9334a28cdcd70d3a8a0fd06e04ef'/>
<id>urn:sha1:b4b94d54cbed9334a28cdcd70d3a8a0fd06e04ef</id>
<content type='text'>
- Sweep Live Activity: SweepActivityAttributes (Shared/), lock-screen + Dynamic
  Island UI in the widget extension, driven by SweepActivityController wired into
  the batch pipeline (begin/update/end); NSSupportsLiveActivities in Info.plist.
- Share extension (DomainDigShareExtension): accepts a web URL from the share
  sheet, extracts the host, and hands it to the app via the App Group inbox
  (DomainDigShareInbox); the app consumes it on activation and inspects it.
- iPad layout: RootTabView uses NavigationSplitView in the regular size class
  and the tab bar in compact.
- Actionable notifications: per-domain threadIdentifier grouping, a Re-inspect
  action, and tap routing into the domain detail via the intent router.
- Bump version to 4.6.0 (build 38) across app, widget, and share targets.
</content>
</entry>
<entry>
<title>Complete v4.5.0: Run Sweep intent, detail deep link, and portfolio widget</title>
<updated>2026-07-17T05:31:57+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-17T05:27:45+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=d3a27d06809e6744c092f9bed4bf0537843d0210'/>
<id>urn:sha1:d3a27d06809e6744c092f9bed4bf0537843d0210</id>
<content type='text'>
Finishes the v4.5.0 "Home Screen &amp; Shortcuts reach" scope that the tag shipped
partially:

- Add RunSweepIntent (opens the app and runs refreshAllTrackedDomains via the
  in-process router) and expose it in DomainDigShortcuts.
- Extend the domaindig:// scheme with `sweep` and `domain` (detail) actions;
  route .detail to present TrackedDomainDetailView and .sweep to refresh the
  watchlist. Move DomainDigDeepLink into Shared/ so the widget can build links.
- Add a WidgetKit extension (DomainDigWidgetExtension) with small/medium/large
  Portfolio widgets showing health counts, per-domain status, and certificate
  countdowns; tapping a domain deep-links into its detail.
- Share portfolio state via an App Group (group.net.cleberg.DomainDig): the app
  writes a DomainDigWidgetData snapshot on launch/foreground and on watchlist
  changes and reloads timelines; the widget reads the same store.
</content>
</entry>
<entry>
<title>DomainDig v4.4.1: Consolidate Audit Mode and remove the CLI target</title>
<updated>2026-07-17T04:48:10+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-17T04:48:10+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=d8195992e519343d6d8ff7fb17aa98358800d913'/>
<id>urn:sha1:d8195992e519343d6d8ff7fb17aa98358800d913</id>
<content type='text'>
- Make DomainDig/DomainDig/Audit* the single active Audit Mode implementation
  (models, views, exporter) with an Audit tab and session/export UI
- Include audit sessions in backup/restore lifecycle counts, summaries, and
  merge behavior via DomainDataPortabilityService
- Remove the DomainDigCLI target, source file, scheme, and all project
  references; keep the shared inspection/report pipeline for the app
- Align AppVersion.current to 4.4.1 and refresh README/architecture docs
- Add RELEASE_ROADMAP.md
</content>
</entry>
</feed>
