<feed xmlns='http://www.w3.org/2005/Atom'>
<title>domain-dig.git/DomainDig/ContentView.swift, 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-25T05:23:45+00:00</updated>
<entry>
<title>refactor: extract result section views into ResultSectionViews (v5 step 4, 7/n)</title>
<updated>2026-07-25T05:23:45+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-25T05:22:34+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=0571e751f8d6f2975b7ea9fdbd30f5b77c05e836'/>
<id>urn:sha1:0571e751f8d6f2975b7ea9fdbd30f5b77c05e836</id>
<content type='text'>
Seventh slice of the god-file decomposition; second ContentView.swift slice.

- Moves the nine result detail section views into ResultSectionViews.swift:
  DomainSectionView, OwnershipSectionView, IntelligenceSectionView,
  SubdomainsSectionView, DNSSectionView, WebSectionView, EmailSectionView,
  NetworkSectionView, and PortRowsView. Pure move.
- Promotes the one-line appLoadingStyle() View helper private -&gt; internal: the
  moved section views and the staying LoadingCardView both use it. That is the
  only non-deletion edit to ContentView.swift.

The shared primitives (CardView, SectionTitleView, LabeledValueRow,
ResultColors) stay in ContentView.swift and are reached cross-file.

ContentView.swift: 2738 -&gt; 1626 lines (3881 at the start of the ContentView
pivot; -2255 total). App builds clean; unit suite 58/58. No project.pbxproj
change.
</content>
</entry>
<entry>
<title>refactor: extract Settings screens into SettingsViews.swift (v5 step 4, 6/n)</title>
<updated>2026-07-25T05:16:00+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-25T05:15:15+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=48999737a37ef16cad0d84e71fa1b229bb04903f'/>
<id>urn:sha1:48999737a37ef16cad0d84e71fa1b229bb04903f</id>
<content type='text'>
Pivots the god-file decomposition to ContentView.swift (the other ~3.9k-line
file). Unlike DomainViewModel, this is a set of independent SwiftUI View structs,
so it splits with no shared-state entanglement.

- Moves the Settings surface into SettingsViews.swift: SettingsView (the tab
  root, presented from RootTabView) plus its nine file-private section screens
  (Display, History &amp; Network, iCloud Sync, Local API, Monitoring, Import &amp;
  Export, Data Management, About) and the DataImportPreviewSheet. Pure move.
- The private section screens stay file-private together in the new file, reached
  only through SettingsView's navigation links. Zero visibility changes were
  needed: the block references nothing file-private to ContentView.swift, so the
  ContentView diff is pure deletion.

ContentView.swift: 3881 -&gt; 2739 lines. App builds clean; unit suite 58/58.
No project.pbxproj change (DomainDig/ is a synchronized group).
</content>
</entry>
<entry>
<title>feat: add Copy cURL Command button to the Local API page (#30)</title>
<updated>2026-07-25T00:09:01+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-24T23:46:47+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=22ad96331a4456662f740c48882cab8aadd6d0b4'/>
<id>urn:sha1:22ad96331a4456662f740c48882cab8aadd6d0b4</id>
<content type='text'>
Adds a one-tap way to get a working, authenticated request from the Local
API settings page. copyCurlCommand() copies a curl invocation using the
current bound address and token via the Authorization header:

  curl -H "Authorization: Bearer &lt;token&gt;" http://127.0.0.1:&lt;port&gt;/portfolio

Keeps the token in a header rather than a query string, so it stays out of
URLs, browser history, and the request-log view (which masks the token).
Guards on an empty token, matching copyToken().
</content>
</entry>
<entry>
<title>fix(a11y): stop section-header trailing controls letter-wrapping</title>
<updated>2026-07-22T20:04:44+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-22T20:02:57+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=d3af0e7d51c2bac5de801bfbbed96a105c442e64'/>
<id>urn:sha1:d3af0e7d51c2bac5de801bfbbed96a105c442e64</id>
<content type='text'>
Reported on device: the Domain section header's Note button rendered
vertically — "N o t e", one character per line in a screen-tall capsule —
at a larger (not even accessibility-tier) text size. Same pathology the
row badges had: text inside a squeezed HStack compresses to a
one-character column instead of the layout adapting.

Three-part fix, mirroring the proven row treatment:

- CollapsibleSectionView's header is now a ViewThatFits: title, trailing
  controls, and chevron on one line while they genuinely fit; otherwise
  the trailing controls drop below the title row. Applies to every
  section header, not just Domain.
- The Note and Track bordered buttons get .fixedSize() so their text can
  never letter-wrap — their natural width is what pushes the header onto
  its stacked layout.
- The "Tracked" pill becomes an icon-only indicator (eye in a tinted
  circle) — with Pin and Note beside it the full pill was the first thing
  to compress, and the word survives for VoiceOver via its label.

Enforced audit suite stays green (7 tests, 0 failures, floor runtime).
The post-lookup header state itself is not reachable by the harness — it
requires a live lookup — so on-device confirmation closes this out.
</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>
<entry>
<title>fix(a11y): replace translucent accent washes with authored surfaces</title>
<updated>2026-07-21T02:03:10+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-21T02:02:28+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=1c2830fbfc8cccfc0c585ec2356467bb95bc1642'/>
<id>urn:sha1:1c2830fbfc8cccfc0c585ec2356467bb95bc1642</id>
<content type='text'>
The selected Dashboard summary card rendered lavender, not blue. Its
background was a gradient of statusInfo at 28% and 12% opacity — a
dark-mode trick where a translucent accent over near-black reads as a
dim version of itself. Over a light background the same wash desaturates
toward violet.

Swapped for the authored StatusInfoSurface, which is a real colour with
a real contrast measurement rather than an emergent one. Same treatment
for the selected tag-filter chip (statusInfo at 30%) and an
intelligence-section badge (statusWarning at 14%).

The remaining opacity use, a 0.55 stroke in ContentView, is a border
rather than a text background and is left alone.

Verified across appearance and contrast settings on iOS 18.6: light 21
findings, light + Increase Contrast 18, dark 18, dark + Increase Contrast
18. Increase Contrast lowering the light count is the High Contrast
colorset variants working as intended on the system section headers.
</content>
</entry>
<entry>
<title>fix(a11y): rebalance the light palette so hues survive</title>
<updated>2026-07-21T02:03:10+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-21T01:25:01+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=9e58a36915bb1fe8cf88423c9519c0c12dbbf2e2'/>
<id>urn:sha1:9e58a36915bb1fe8cf88423c9519c0c12dbbf2e2</id>
<content type='text'>
Reported as "colors seem muted and hard to see on light mode", and
correct. The light palette optimised contrast and produced mud: #7A5600
reads olive rather than amber, #146C2E bottle-dark rather than green.
Contrast passed while the UI got harder to read, because hue identity is
what distinguishes warning from critical at a glance.

Two causes, both fixed.

Every foreground was required to clear 4.5:1 against its own 16% badge
tint — the harshest surface it ever sits on — which pushed each colour
about 20% darker than the common case needed. Most of what is actually on
screen is plain text on a card, with far more headroom. The fill is now
decoupled from the foreground: AppStatusTone carries a foreground and a
surface authored independently, with matching …Surface colorsets, so a
foreground no longer has to survive a wash of itself. Every status
foreground is now fully saturated.

And warning was yellow. Yellow cannot stay yellow at a lightness low
enough to pass 4.5:1 on white — it becomes olive. That is colorimetric,
not a tuning problem. Warning is now orange: #AD5100 light, #FF9F0A dark.

New light values: positive #008035, warning #AD5100, critical #CC0700.
Worst-case ratios 4.54–6.76 across page, card, and surface in both
schemes. Audit findings are unchanged — light 21, dark 18 — so the
vividness costs nothing.

Also picks up a literal .blue missed in phase 1: DomainDiffItem's
low-severity change colour, which the phase 1 sweep did not cover because
its pattern listed only cyan/yellow/green/red/orange/pink.
</content>
</entry>
<entry>
<title>feat(a11y): unlock light mode and add appearance preference (#21 phase 2)</title>
<updated>2026-07-21T02:03:10+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-21T00:14:04+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/domain-dig.git/commit/?id=2c608ed9cb11f3e835f577994b5627b9b7910f4f'/>
<id>urn:sha1:2c608ed9cb11f3e835f577994b5627b9b7910f4f</id>
<content type='text'>
Removes the 16 scattered .preferredColorScheme(.dark) calls and the one
.toolbarColorScheme, and applies appearance in exactly one place — the
WindowGroup in DomainDigApp. Re-applying per view is what let the lock
spread across eight files unnoticed until light mode was unreachable.

Adds AppAppearance (System / Light / Dark) in @AppStorage, exposed under
Settings &gt; Display next to Density. Honouring the system setting and
offering an override is one key, and it keeps the deliberate dark
aesthetic reachable for anyone who wants it.

Also replaces .secondary with AppTextSecondary across 191 sites. iOS's
own secondaryLabel is 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 is precisely what exposed it, so it belongs
here rather than in a later phase: without it, light mode would ship with
body text under 4.5:1 app-wide.

Dark mode reports 18 findings, unchanged from phase 1 — no regression
from unlocking. Light mode reports 21. The three extra are iOS-rendered
Section headers (TIER, PREFERENCES, SERVICES) using the system's grey;
overriding system header styling across every section to gain ~0.3:1 on
decorative labels is a poor trade and is left alone.

Two long-standing Settings contrast findings are now explained. They are
the last rows of a section sitting under the translucent tab bar, so the
audit measures text against a blended background — confirmed by
screenshot, present in dark mode since phase 0, and standard iOS
scroll-under behaviour rather than a defect.
</content>
</entry>
</feed>
