<feed xmlns='http://www.w3.org/2005/Atom'>
<title>domain-dig.git/DomainDig/DomainViewModel.swift, branch main</title>
<subtitle>local-first ios domain inspection toolkit. dns, tls, rdap, audit.
</subtitle>
<id>http://git.krz.sh/krz/domain-dig.git/atom?h=main</id>
<link rel='self' href='http://git.krz.sh/krz/domain-dig.git/atom?h=main'/>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/'/>
<updated>2026-07-25T05:11:47+00:00</updated>
<entry>
<title>refactor: extract history query surface into DomainViewModel+History (v5 step 4, 5/n)</title>
<updated>2026-07-25T05:11:47+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-25T05:10:46+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=321a10f85435fdcbfb54f109ee53df895a550a42'/>
<id>urn:sha1:321a10f85435fdcbfb54f109ee53df895a550a42</id>
<content type='text'>
Fifth slice of the DomainViewModel decomposition.

- Moves the history query/compare surface into DomainViewModel+History.swift:
  per-domain snapshot reads (historyEntries/historyEntry/previousHistoryEntry),
  timeline grouping (timelineEntries/timelineSections), the two-snapshot
  selection model (toggle/clear/selectedSnapshots), diff generation and
  navigation (generateDiff*/moveTo*DiffChange/currentDiffTargetSectionID), and
  the resolver-mismatch notes. Pure move.
- The inspection *pipeline* that produces history — performLookup,
  applySnapshot, saveHistoryEntry, persistHistory, snapshot-metadata bookkeeping
  — stays on the main type with the inspection code.

This slice needs zero visibility changes: every dependency (the history array,
diff state, latestSnapshot) was already internal. The main-file diff is pure
deletion.

DomainViewModel.swift: 4306 -&gt; 4171 lines (4864 at the start of step 4; -693
total). App builds clean; unit suite 58/58. No project.pbxproj change.
</content>
</entry>
<entry>
<title>refactor: extract workflow surface into DomainViewModel+Workflows (v5 step 4, 4/n)</title>
<updated>2026-07-25T05:07:52+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-25T05:05:53+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=9092ab60f82152cc4ae38677c3af4a76013f4e54'/>
<id>urn:sha1:9092ab60f82152cc4ae38677c3af4a76013f4e54</id>
<content type='text'>
Fourth slice of the DomainViewModel decomposition (off main; the audit/
monitoring/export stack has merged).

- Moves the workflow surface into DomainViewModel+Workflows.swift: workflow
  lookup (workflow(withID:)/workflowsContaining), the DomainWorkflow
  collaboration checks, the CRUD mutators (create/update/delete/add/remove/move),
  runWorkflow/rerunCurrentDomain, and refreshWorkflowList. Pure move. The
  TrackedDomain overloads of canEdit/canDelete/collaborationLabel stay on the
  main type (they're watchlist collaboration, not workflow).
- Promotes the shared helpers the moved methods reach to internal, all staying
  on the main type: persistWorkflows (also called by clearWorkflows),
  startBatchLookup (the batch primitive shared with manual/watchlist runs),
  normalizedDomain/normalizedDomains, loadWorkflows, and the activeWorkflowRunID/
  Name state. The extension carries its own fileprivate String.nilIfEmpty,
  matching the per-file pattern already used across the codebase.

DomainViewModel.swift: 4433 -&gt; 4306 lines (4864 at the start of step 4).
App builds clean; unit suite 58/58. No project.pbxproj change.
</content>
</entry>
<entry>
<title>refactor: extract export/portability into DomainViewModel+Export (v5 step 4, 3/n)</title>
<updated>2026-07-25T05:00:29+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-25T04:59:07+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=e5d6c6c30df78e1d9b07088c256421aa663cb173'/>
<id>urn:sha1:e5d6c6c30df78e1d9b07088c256421aa663cb173</id>
<content type='text'>
Third slice of the DomainViewModel decomposition, stacked on the monitoring
split.

- Moves the export/data-portability surface into DomainViewModel+Export.swift:
  the single-report/batch/tracked-domain/timeline/workflow exporters, the
  full-backup and portable-slice exporters, prepareDataImport/applyDataImport,
  and persistCurrentAppSettings. The export-only WorkflowExportPayload struct
  moves with them. Pure move, no logic changes.
- The four report-projection helpers the exporters call (currentBatchReports,
  reports(for:), timelineReports, workflowReports) are promoted private -&gt;
  internal and stay on the main type: they build DomainReports through the
  shared report layer (report(for:)/reportBuilder), so they belong with
  inspection, not export. Those four visibility drops are the only non-deletion
  edits to DomainViewModel.

DomainViewModel.swift: 4601 -&gt; 4434 lines. App builds clean; unit suite 58/58.
No project.pbxproj change (DomainDig/ is a synchronized group).
</content>
</entry>
<entry>
<title>refactor: extract monitoring surface into DomainViewModel+Monitoring (v5 step 4, 2/n)</title>
<updated>2026-07-25T05:00:29+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-25T04:52:39+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=7805888980f6d41bcc881c5d2e6001b2d8e69351'/>
<id>urn:sha1:7805888980f6d41bcc881c5d2e6001b2d8e69351</id>
<content type='text'>
Second slice of the DomainViewModel decomposition, stacked on the audit split.

- Moves the monitoring configuration surface — notification authorization, the
  settings mutators (enabled/scope/interval/adaptive/sensitivity/quiet-hours/
  alert filter/alerts/selection), toggleMonitoring, runMonitoringNow, the manual
  run, and the per-domain interval/status labels — plus the private
  intervalLabel helper into DomainViewModel+Monitoring.swift. Pure move.
- persistMonitoringSettings(...) and sanitizeMonitoringSelection() are promoted
  private -&gt; internal: the tracked-domain lifecycle (add/delete/clear) on the
  main type calls them too, so they stay put and are now reachable cross-file.
  The two visibility drops are the only non-deletion edits to DomainViewModel.

DomainViewModel.swift: 4761 -&gt; 4601 lines. App builds clean; unit suite 58/58.
No project.pbxproj change (DomainDig/ is a synchronized group).
</content>
</entry>
<entry>
<title>refactor: extract audit surface into DomainViewModel+Audit (v5 step 4, 1/n)</title>
<updated>2026-07-25T05:00:29+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-25T04:45:56+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=e597cdf5b7084634e300af39b622c8552c9cc5e8'/>
<id>urn:sha1:e597cdf5b7084634e300af39b622c8552c9cc5e8</id>
<content type='text'>
First slice of the v5.0.0 god-file decomposition. DomainViewModel.swift is a
single ~4.9k-line class body; this begins splitting it into focused
`DomainViewModel+&lt;Concern&gt;.swift` extensions, one cohesive concern at a time,
with each move behavior-preserving and verified by build + the test net.

- Moves the audit read/CRUD/export surface (audits/auditSession/auditTimeline,
  updateAuditStatus/Notes, toggleAuditChecklistItem, add/update/remove
  AuditFinding, exportAuditData) into DomainViewModel+Audit.swift as an
  extension. Pure move — no logic changes.
- `startAudit(for:)` intentionally stays on the main type: it drives a live
  inspection to seed the session, so it belongs with the inspection pipeline
  until that is extracted. `persistAuditSessions()` is promoted from private to
  internal so both files can call it (its only cross-file dependency).

No project.pbxproj change is needed — DomainDig/ is a file-system-synchronized
group, so the new file is picked up automatically.

DomainViewModel.swift: 4864 -&gt; 4761 lines. App builds clean; unit suite 58/58.
</content>
</entry>
<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>
</feed>
