The modal guards tag deletion, device revocation and webhook deletion, so it is
the wrong control to leave unlabelled and escapable.
Note the original report was half wrong: role="dialog" and aria-modal="true"
were already set on the outer element, and are in the deployed bundle. The
observation was most likely taken against the inner .modal-box. Neither was
added here.
What was actually missing: the h2 existed but nothing pointed at it, so a screen
reader announced a dialog with no name; and the only focus management was
focusing Cancel on open, so Tab walked straight out into the skip link and page
header behind the backdrop.
Adds aria-labelledby and aria-describedby with sequence-suffixed ids so two
modals cannot cross-reference. aria-describedby matters more than usual here:
none of the three call sites' messages contain ". ", so the title is always the
generic "Are you sure?" and the specific text is the body.
The trap re-queries on each keypress rather than caching at open, and handles
three cases: focus escaping to body gets pulled back, first plus Shift+Tab wraps
to last, last plus Tab wraps to first. Focus is restored to the opener on every
close path, guarded by document.contains, because confirming a tag deletion
fires an htmx swap that can remove the button that opened the modal. Restoration
happens before onConfirm so htmx sees a sane focus state.
Native <dialog> would give the trap and Escape for free, but it renders in the
top layer while this is styled entirely with daisyUI .modal classes that assume
a positioned div, so converting means CSS work plus the seamark theme fork.
Worth doing as its own change, not smuggled into this one.
Includes the bundle rebuild, since nothing in the dev loop keeps that artifact
current.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9
Artifacts filter had no empty state: filtering to zero matches left a blank
panel, indistinguishable from something being broken. It now uses the existing
state-empty partial, with no CTA since the filter input is right there. Rows
appended by Load More now also obey the active filter, which is a change to
existing behaviour but is required for the empty state to be truthful.
The tag icon beside the tag selector computed to 5.44px by 24px on every repo
page: the class was right, the flex parent was shrinking it. Adds shrink-0 to
that instance only. 31 other icons are direct flex children without shrink-0
and are left alone, since a site-wide sweep found only this one squeezed.
Digest-page scan tabs set role="tab" but never aria-selected, while the
repo-page tabs do. Both are now set server-side so first paint is correct, with
JS keeping them in sync afterwards, following switchRepoTab's pattern. The
diff-content tabs had the identical defect and are fixed too, since the handler
is generic over radio tabs and leaving them out would have meant attributes set
by JS but never by the server.
Includes the bundle rebuild for these and the two /auth/token-adjacent JS
changes, since nothing in the dev loop keeps that artifact current on its own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9
The client switcher built every command as "<client> pull <ref>". That is
valid for docker, podman, buildah and nerdctl, but crane requires a
destination:
$ crane pull seamark.cr/user/bench8x1:v2
Error: requires at least 2 arg(s), only received 1
So selecting crane handed the user a command that cannot run. pullPrefix is a
prefix-only helper, which is precisely why it could not express this; add a
matching pullPostfix that returns " <image>.tar" for crane and "" for
everything else, including "none" (image reference only), which must get
neither prefix nor postfix.
Both render paths change together, since fixing one leaves the bug visible in
the other: the Go template helper paints first, and updatePullCommand in
app.js re-renders when the dropdown changes.
Repository names may contain slashes, so only the last path segment is used —
otherwise the destination would name a subdirectory that does not exist. A
name ending in "/" yields no destination at all rather than a bare ".tar",
on the grounds that a visibly wrong-arity command beats silently writing a
hidden file. That input is not reachable through the real repo-name path.
The test asserts the whole command string rather than just the postfix, so it
covers the prefix/postfix interaction and the "none" case where both vanish.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9
The Diff menu listed every tag including the one being viewed, and clicking
that entry did nothing: diffToTag returns early on to === currentTag, with no
navigation, no toast, no feedback.
Fixed in JS rather than in the template, which is the part that is easy to
get wrong. #diff-dropdown sits outside #tag-content, and the tag selector
block is marked "stays in DOM, never swapped" — so a {{ range }} filter would
be correct on first paint and stale after the first htmx tag swap, omitting
the originally loaded tag and re-including the newly current one. Same dead
entry, harder to see.
syncDiffMenu() reads the live value from #tag-selector and is called from
initTabs(), which already runs on load and again from the htmx:afterSettle
handler for #tag-content, so it stays correct across swaps.
Hidden rather than disabled: a disabled row still takes space and still reads
as an item to a screen reader, and "diff against the tag you are already on"
is meaningless rather than temporarily unavailable. Uses style.display to
match filterTags() in the same file, since daisyUI's .menu li rules outrank
Tailwind's .hidden.
The template guards the dropdown with {{ if gt (len .AllTags) 1 }} and tag
names are unique per repo (tags PK is did+repository+tag), so exactly one
entry is ever hidden and the menu can never end up empty. The early return in
diffToTag stays as a backstop.
Pre-existing, not a deploy regression. The baseline missed it because the
test repo had one tag, so the check skipped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9
Three unrelated client-side defects found while baselining production.
The webhook Test button always reported success. renderAlert writes no
status code, so both outcomes are HTTP 200 and the result lives in the
markup, which partials/alert.html emits as "alert alert-error". testWebhook
looked for class="error", which that string does not contain, and resp.ok
is always true, so the failure branch was unreachable. A webhook pointed at
a dead URL was reported as delivered. Match alert-error instead.
Avatar upload rejections lost the server's reason. The htmx:responseError
handler maps status codes to fixed strings and had no 400 case, so
"File too large (max 3MB)" and "Invalid file type" both surfaced as
"Something went wrong". Surface the body when it is short plain text; a
rendered error page or a long trace is not toast material.
The repository page's Layers tab was never initialised. initLayersTables
runs from DOMContentLoaded, when the panel is still a spinner, and from
htmx:afterSettle, which htmx.process() does not emit. So empty-layer hiding
and no-history run collapsing never ran there, and the checkbox claimed
rows were hidden while all of them were on screen. Export it and call it
from the tab controller. It now also re-seeds checkboxes within the loaded
scope, which fixes the digest page contradicting itself: the stored
preference was honoured for the rows and ignored for the control.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UAqi2hS2dhZoatqcWoYZQk
daisyUI dims the gutter prefix with `opacity: .5`, which multiplies
against whatever opacity the line's own text color carries. On
bg-base-300 a plain `$` measured 3.20:1 in light and 4.19:1 in dark,
and the `#` on a line already dimmed to /70 compounded to 2.15:1. The
text needs 4.5:1, so this failed in dark mode too, just less visibly
than the light-mode report that surfaced it.
Give the prefix an absolute muted color instead of a multiplying
opacity. The override has to sit unlayered: daisyUI ships this selector
in `@layer daisyui`, declared after `@layer components`, so a rule in
components loses on layer order however specific it is. A first attempt
inside components left daisyUI's `opacity: .5` live on top of the new
70% color, which made the `$` worse (0.5 -> 0.35 effective) rather than
better.
The hero tagline drops its /70 and now reads at the same weight as the
docker commands above it. install.html's two comment lines were at /50,
which failed on the text itself (3.20:1 light), and move to /70 where
they still read as comments.
Measured in Chromium against the built stylesheet, compositing each
pseudo-element color over its real background on a canvas: every prefix
and comment is now 5.84:1 light / 6.71:1 dark, against 14.03:1 / 12.02:1
for the command text, so the gutter stays visibly de-emphasized.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LpqkSRqpcnAjsSZgFTUN5z
1. Removing distribution/distribution from the Hold Service (biggest change)
The hold service previously used distribution's StorageDriver interface for all blob operations. This replaces it with direct AWS SDK v2 calls through ATCR's own pkg/s3.S3Service:
- New S3Service methods: Stat(), PutBytes(), Move(), Delete(), WalkBlobs(), ListPrefix() added to pkg/s3/types.go
- Pull zone fix: Presigned URLs are now generated against the real S3 endpoint, then the host is swapped to the CDN URL post-signing (previously the CDN URL was set as the endpoint, which
broke SigV4 signatures)
- All hold subsystems migrated: GC, OCI uploads, XRPC handlers, profile uploads, scan broadcaster, manifest posts — all now use *s3.S3Service instead of storagedriver.StorageDriver
- Config simplified: Removed configuration.Storage type and buildStorageConfigFromFields(); replaced with a simple S3Params() method
- Mock expanded: MockS3Client gains an in-memory object store + 5 new methods, replacing duplicate mockStorageDriver implementations in tests (~160 lines deleted from each test file)
2. Vulnerability Scan UI in AppView (new feature)
Displays scan results from the hold's PDS on the repository page:
- New lexicon: io/atcr/hold/scan.json with vulnReportBlob field for storing full Grype reports
- Two new HTMX endpoints: /api/scan-result (badge) and /api/vuln-details (modal with CVE table)
- New templates: vuln-badge.html (severity count chips) and vuln-details.html (full CVE table with NVD/GHSA links)
- Repository page: Lazy-loads scan badges per manifest via HTMX
- Tests: ~590 lines of test coverage for both handlers
3. S3 Diagnostic Tool
New cmd/s3-test/main.go (418 lines) — tests S3 connectivity with both SDK v1 and v2, including presigned URL generation, pull zone host swapping, and verbose signing debug output.
4. Deployment Tooling
- New syncServiceUnit() for comparing/updating systemd units on servers
- Update command now syncs config keys (adds missing keys from template) and service units with daemon-reload
5. DB Migration
0011_fix_captain_successor_column.yaml — rebuilds hold_captain_records to add the successor column that was missed in a previous migration.
6. Documentation
- APPVIEW-UI-FUTURE.md rewritten as a status-tracked feature inventory
- DISTRIBUTION.md renamed to CREDENTIAL_HELPER.md
- New REMOVING_DISTRIBUTION.md — 480-line analysis of fully removing distribution from the appview side
7. go.mod
aws-sdk-go v1 moved from indirect to direct (needed by cmd/s3-test).