···83838484 // How often to sync with the remote libSQL server.
8585 LibsqlSyncInterval time.Duration `yaml:"libsql_sync_interval" comment:"How often to sync with remote libSQL server. Default: 60s."`
8686+8787+ // Source code URL displayed in the footer "Source" link.
8888+ SourceURL string `yaml:"source_url" comment:"Source code URL displayed in the footer \"Source\" link. Defaults to the upstream ATCR project."`
8689}
87908891// HealthConfig defines health check and cache settings
···162165 v.SetDefault("ui.libsql_sync_url", "")
163166 v.SetDefault("ui.libsql_auth_token", "")
164167 v.SetDefault("ui.libsql_sync_interval", "60s")
168168+ v.SetDefault("ui.source_url", "https://tangled.org/evan.jarrett.net/at-container-registry")
165169166170 // Health defaults
167171 v.SetDefault("health.cache_ttl", "15m")
···216220217221 // Populate example billing tiers so operators see the structure
218222 cfg.Billing.Currency = "usd"
219219- cfg.Billing.SuccessURL = "{base_url}/settings#billing"
220220- cfg.Billing.CancelURL = "{base_url}/settings#billing"
223223+ cfg.Billing.SuccessURL = "{base_url}/settings/billing"
224224+ cfg.Billing.CancelURL = "{base_url}/settings/billing"
221225 cfg.Billing.OwnerBadge = true
222226 cfg.Billing.Tiers = []billing.BillingTierConfig{
223227 {Name: "deckhand", Description: "Get started with basic storage", MaxWebhooks: 1},
+1
pkg/appview/handlers/base.go
···4646 ClientName string // Full name: "AT Container Registry"
4747 ClientShortName string // Short name: "ATCR"
4848 AIAdvisorEnabled bool // True when Claude API key is configured
4949+ SourceURL string // Source code URL for the footer "Source" link
4950}
+4
pkg/appview/handlers/common.go
···1818 ClientShortName string // Brand name for templates (e.g., "ATCR")
1919 OciClient string // Preferred OCI client for pull commands (e.g., "docker", "podman")
2020 AIAdvisorEnabled bool // True when AI Image Advisor is available
2121+ SourceURL string // Source code URL for the footer "Source" link
2222+ CurrentPath string // Request path (used for OAuth return_to)
2123}
22242325// NewPageData creates a PageData struct with common fields populated from the request
···3638 ClientShortName: h.ClientShortName,
3739 OciClient: ociClient,
3840 AIAdvisorEnabled: h.AIAdvisorEnabled,
4141+ SourceURL: h.SourceURL,
4242+ CurrentPath: r.URL.RequestURI(),
3943 }
4044}
4145
+1-1
pkg/appview/handlers/device.go
···527527 <h1>✓ Device Authorized!</h1>
528528 <p>Device <strong>{{.DeviceName}}</strong> has been successfully authorized.</p>
529529 <p>You can now close this window and return to your terminal.</p>
530530- <p><a href="/settings#devices">View your authorized devices</a></p>
530530+ <p><a href="/settings/devices">View your authorized devices</a></p>
531531 </div>
532532</body>
533533</html>
+52-10
pkg/appview/handlers/diff.go
···183183}
184184185185func addToSevCount(s *vulnSummary, severity string) {
186186- switch severity {
187187- case "Critical":
186186+ // Normalize to canonical casing so "CRITICAL", "critical", "Crit" all land
187187+ // in the same bucket. Unknown severities count toward the total but don't
188188+ // bump any bucket — the template renders them as "Unknown" via the
189189+ // severityLabel helper.
190190+ switch strings.ToLower(strings.TrimSpace(severity)) {
191191+ case "critical", "crit", "c":
188192 s.Critical++
189189- case "High":
193193+ case "high", "h":
190194 s.High++
191191- case "Medium":
195195+ case "medium", "med", "m":
192196 s.Medium++
193193- case "Low":
197197+ case "low", "l":
194198 s.Low++
195199 }
196200 s.Total++
···387391 }()
388392 wg.Wait()
389393390390- if fromData.err != nil || toData.err != nil {
391391- RenderNotFound(w, r, &h.BaseUIHandler)
392392- return
394394+ // Track per-side fetch failures so we render the page with an inline
395395+ // alert naming which tag failed, instead of a generic 404 that makes
396396+ // users guess whether they typoed a tag or hit a transient outage.
397397+ // fromData.manifest / toData.manifest is nil only when the re-fetch at
398398+ // the top of fetchManifest hit a DB error (the tag resolution earlier
399399+ // already ruled out typos).
400400+ fromFailed := fromData.err != nil || fromData.manifest == nil
401401+ toFailed := toData.err != nil || toData.manifest == nil
402402+403403+ // Fall back to the top-level manifest we already fetched so the page
404404+ // still has something to render for tag labels and metadata.
405405+ if fromFailed {
406406+ fromData.manifest = fromManifest
407407+ }
408408+ if toFailed {
409409+ toData.manifest = toManifest
393410 }
394411395412 // Compute diffs
396413 layerDiff := computeLayerDiff(fromData.layers, toData.layers)
397414415415+ // ScanStatus distinguishes why vuln data may be missing: "ok" when both
416416+ // sides returned clean scan results; "no-data" when a scan was never
417417+ // recorded; "hold-unreachable" when we couldn't reach the hold to ask.
418418+ // The template branches on these so users can tell "not scanned yet"
419419+ // from "hold offline" at a glance.
420420+ fromScanStatus := "ok"
421421+ toScanStatus := "ok"
422422+ if fromData.vulnData == nil {
423423+ fromScanStatus = "hold-unreachable"
424424+ } else if fromData.vulnData.Error != "" {
425425+ fromScanStatus = "no-data"
426426+ }
427427+ if toData.vulnData == nil {
428428+ toScanStatus = "hold-unreachable"
429429+ } else if toData.vulnData.Error != "" {
430430+ toScanStatus = "no-data"
431431+ }
432432+398433 var vulnDiff []VulnDiffEntry
399399- hasVulnData := fromData.vulnData != nil && toData.vulnData != nil &&
400400- fromData.vulnData.Error == "" && toData.vulnData.Error == ""
434434+ hasVulnData := fromScanStatus == "ok" && toScanStatus == "ok"
401435 if hasVulnData {
402436 vulnDiff = computeVulnDiff(fromData.vulnData.Matches, toData.vulnData.Matches)
403437 }
···448482 NewVulns []vulnMatch
449483 UnchangedVulns []vulnMatch
450484 HasVulnData bool
485485+ FromScanStatus string
486486+ ToScanStatus string
487487+ FromFailed bool
488488+ ToFailed bool
451489 IsMultiArch bool
452490 CommonPlatforms []db.PlatformInfo
453491 SelectedPlatform string
···468506 NewVulns: newVulns,
469507 UnchangedVulns: unchangedVulns,
470508 HasVulnData: hasVulnData,
509509+ FromScanStatus: fromScanStatus,
510510+ ToScanStatus: toScanStatus,
511511+ FromFailed: fromFailed,
512512+ ToFailed: toFailed,
471513 IsMultiArch: isMultiArch,
472514 CommonPlatforms: commonPlatforms,
473515 SelectedPlatform: selectedPlatform,
+82-38
pkg/appview/handlers/digest_content.go
···44 "log/slog"
55 "net/http"
66 "strings"
77+ "sync"
7889 "atcr.io/pkg/appview/db"
910 "atcr.io/pkg/appview/holdclient"
···2122 identifier := chi.URLParam(r, "handle")
2223 wildcard := strings.TrimPrefix(chi.URLParam(r, "*"), "/")
23242424- // The wildcard is the repository name
2525 repository := wildcard
2626-2727- // The platform digest comes from query param
2826 digest := r.URL.Query().Get("digest")
2927 if digest == "" || repository == "" {
3028 http.Error(w, "missing parameters", http.StatusBadRequest)
3129 return
3230 }
33313434- // Resolve identity
3532 did, _, _, err := atproto.ResolveIdentity(r.Context(), identifier)
3633 if err != nil {
3734 http.Error(w, "not found", http.StatusNotFound)
3835 return
3936 }
40374141- // Fetch manifest details for the platform digest
4238 manifest, err := db.GetManifestDetail(h.ReadOnlyDB, did, repository, digest)
4339 if err != nil {
4440 http.Error(w, "manifest not found", http.StatusNotFound)
4541 return
4642 }
47434848- // Fetch layers from DB
4949- var layers []LayerDetail
5050- var vulnData *vulnDetailsData
5151-5244 dbLayers, err := db.GetLayersForManifest(h.ReadOnlyDB, manifest.ID)
5345 if err != nil {
5446 slog.Warn("Failed to fetch layers", "error", err)
5547 }
56485757- // Resolve hold endpoint (follow successor if migrated)
5849 hold, holdErr := ResolveHold(r.Context(), h.ReadOnlyDB, manifest.HoldEndpoint)
5050+ holdReachable := holdErr == nil
59516060- // Fetch OCI image config from hold for layer history
6161- if holdErr == nil {
6262- config, err := holdclient.FetchImageConfig(r.Context(), hold.URL, digest)
6363- if err == nil {
6464- layers = buildLayerDetails(config.History, dbLayers)
6565- } else {
6666- slog.Warn("Failed to fetch image config", "error", err,
6767- "holdEndpoint", manifest.HoldEndpoint, "manifestDigest", digest)
6868- layers = buildLayerDetails(nil, dbLayers)
6969- }
5252+ // Parallelize the three hold fetches. They're independent and each
5353+ // takes a network round-trip; serial runs add up on slow links.
5454+ var (
5555+ layers []LayerDetail
5656+ vulnData *vulnDetailsData
5757+ sbomData *sbomDetailsData
5858+ configFetchError bool
5959+ )
6060+6161+ if holdReachable {
6262+ var wg sync.WaitGroup
6363+ wg.Add(3)
6464+6565+ go func() {
6666+ defer wg.Done()
6767+ config, err := holdclient.FetchImageConfig(r.Context(), hold.URL, digest)
6868+ if err == nil {
6969+ layers = buildLayerDetails(config.History, dbLayers)
7070+ } else {
7171+ slog.Warn("Failed to fetch image config", "error", err,
7272+ "holdEndpoint", manifest.HoldEndpoint, "manifestDigest", digest)
7373+ layers = buildLayerDetails(nil, dbLayers)
7474+ configFetchError = true
7575+ }
7676+ }()
7777+7878+ go func() {
7979+ defer wg.Done()
8080+ vd := FetchVulnDetails(r.Context(), hold.DID, digest)
8181+ vulnData = &vd
8282+ }()
8383+8484+ go func() {
8585+ defer wg.Done()
8686+ sd := FetchSbomDetails(r.Context(), hold.DID, digest)
8787+ sbomData = &sd
8888+ }()
8989+9090+ wg.Wait()
7091 } else {
7192 layers = buildLayerDetails(nil, dbLayers)
7293 }
73947474- // Fetch vulnerability and SBOM details
7575- var sbomData *sbomDetailsData
7676- if holdErr == nil {
7777- vd := FetchVulnDetails(r.Context(), hold.DID, digest)
7878- vulnData = &vd
7979- sd := FetchSbomDetails(r.Context(), hold.DID, digest)
8080- sbomData = &sd
9595+ // VulnReason / SbomReason let the template branch distinctly on why
9696+ // data is missing instead of collapsing three causes into a generic
9797+ // "not available" message.
9898+ // ok — data is present
9999+ // hold-unreachable — we couldn't reach the hold
100100+ // not-scanned — hold is up but no scan record exists
101101+ // fetch-failed — scan record fetch failed on the hold
102102+ vulnReason := "ok"
103103+ if !holdReachable {
104104+ vulnReason = "hold-unreachable"
105105+ } else if vulnData == nil || vulnData.Error == "never-scanned" {
106106+ vulnReason = "not-scanned"
107107+ } else if vulnData.Error != "" {
108108+ vulnReason = "fetch-failed"
109109+ }
110110+111111+ sbomReason := "ok"
112112+ if !holdReachable {
113113+ sbomReason = "hold-unreachable"
114114+ } else if sbomData == nil || sbomData.Error == "never-scanned" {
115115+ sbomReason = "not-scanned"
116116+ } else if sbomData.Error != "" {
117117+ sbomReason = "fetch-failed"
81118 }
8211983120 data := struct {
8484- Layers []LayerDetail
8585- VulnData *vulnDetailsData
8686- SbomData *sbomDetailsData
121121+ Layers []LayerDetail
122122+ VulnData *vulnDetailsData
123123+ SbomData *sbomDetailsData
124124+ HoldReachable bool
125125+ ConfigFetchError bool
126126+ VulnReason string
127127+ SbomReason string
87128 }{
8888- Layers: layers,
8989- VulnData: vulnData,
9090- SbomData: sbomData,
129129+ Layers: layers,
130130+ VulnData: vulnData,
131131+ SbomData: sbomData,
132132+ HoldReachable: holdReachable,
133133+ ConfigFetchError: configFetchError,
134134+ VulnReason: vulnReason,
135135+ SbomReason: sbomReason,
91136 }
9213793138 w.Header().Set("Content-Type", "text/html")
941399595- // Support rendering individual sections for repo page tabs
96140 section := r.URL.Query().Get("section")
97141 switch section {
98142 case "layers":
99143 if err := h.Templates.ExecuteTemplate(w, "layers-section", data); err != nil {
100144 slog.Warn("Failed to render layers section", "error", err)
101101- http.Error(w, err.Error(), http.StatusInternalServerError)
145145+ RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render layers", err)
102146 }
103147 case "vulns":
104148 if err := h.Templates.ExecuteTemplate(w, "vulns-section", data); err != nil {
105149 slog.Warn("Failed to render vulns section", "error", err)
106106- http.Error(w, err.Error(), http.StatusInternalServerError)
150150+ RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render vulnerabilities", err)
107151 }
108152 case "sbom":
109153 if err := h.Templates.ExecuteTemplate(w, "sbom-section", data); err != nil {
110154 slog.Warn("Failed to render sbom section", "error", err)
111111- http.Error(w, err.Error(), http.StatusInternalServerError)
155155+ RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render SBOM", err)
112156 }
113157 default:
114158 if err := h.Templates.ExecuteTemplate(w, "digest-content", data); err != nil {
115159 slog.Warn("Failed to render digest content", "error", err)
116116- http.Error(w, err.Error(), http.StatusInternalServerError)
160160+ RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render content", err)
117161 }
118162 }
119163}
+32
pkg/appview/handlers/errors.go
···11package handlers
2233import (
44+ "encoding/json"
55+ "log/slog"
46 "net/http"
57)
68···3638 http.Error(w, "Page not found", http.StatusNotFound)
3739 }
3840}
4141+4242+// RenderHTMXError sends an error response suitable for htmx. For htmx requests
4343+// it sets an HX-Trigger header so the client fires a toast event; the JS
4444+// fallback in app.js will show a generic toast even without the header.
4545+// For non-htmx requests it falls back to http.Error. serverErr is logged but
4646+// never exposed to the user — pass userMsg for anything screen-readable.
4747+func RenderHTMXError(w http.ResponseWriter, r *http.Request, status int, userMsg string, serverErr error) {
4848+ if serverErr != nil {
4949+ slog.Error("htmx handler error",
5050+ "path", r.URL.Path,
5151+ "status", status,
5252+ "err", serverErr,
5353+ )
5454+ }
5555+ if userMsg == "" {
5656+ userMsg = http.StatusText(status)
5757+ }
5858+ if r.Header.Get("HX-Request") == "true" {
5959+ trigger := map[string]map[string]string{
6060+ "toast": {"message": userMsg, "type": "error"},
6161+ }
6262+ if b, err := json.Marshal(trigger); err == nil {
6363+ w.Header().Set("HX-Trigger", string(b))
6464+ }
6565+ w.Header().Set("HX-Reswap", "none")
6666+ w.WriteHeader(status)
6767+ return
6868+ }
6969+ http.Error(w, userMsg, status)
7070+}
+12-6
pkg/appview/handlers/home.go
···44package handlers
5566import (
77- "log"
77+ "log/slog"
88 "net/http"
991010 "atcr.io/pkg/appview/db"
···1717}
18181919func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
2020- // Get current user DID (empty string if not logged in)
2120 var currentUserDID string
2221 if user := middleware.GetUser(r); user != nil {
2322 currentUserDID = user.DID
2423 }
25242626- // Fetch featured repositories (top 6 by score - carousel cycles through them)
2525+ // Track whether either card query failed so the page can surface a
2626+ // distinct error banner instead of the "no repos yet" empty state.
2727+ // Partial failures still render whatever did succeed.
2828+ var queryError bool
2929+2730 featuredCards, err := db.GetRepoCards(h.ReadOnlyDB, 6, currentUserDID, db.SortByScore)
2831 if err != nil {
2929- log.Printf("Error fetching featured repos: %v", err)
3232+ slog.Error("home: fetch featured repos", "err", err)
3033 featuredCards = []db.RepoCardData{}
3434+ queryError = true
3135 }
3236 db.SetRegistryURL(featuredCards, h.RegistryURL)
33373434- // Fetch recently updated repositories (top 18 by last push - 6 rows at 3-col lg)
3538 recentCards, err := db.GetRepoCards(h.ReadOnlyDB, 18, currentUserDID, db.SortByLastUpdate)
3639 if err != nil {
3737- log.Printf("Error fetching recent repos: %v", err)
4040+ slog.Error("home: fetch recent repos", "err", err)
3841 recentCards = []db.RepoCardData{}
4242+ queryError = true
3943 }
4044 db.SetRegistryURL(recentCards, h.RegistryURL)
4145···4852 Meta *PageMeta
4953 FeaturedRepos []db.RepoCardData
5054 RecentRepos []db.RepoCardData
5555+ HasError bool
5156 }{
5257 PageData: pageData,
5358 Meta: NewPageMeta(
···6368 ),
6469 FeaturedRepos: featuredCards,
6570 RecentRepos: recentCards,
7171+ HasError: queryError,
6672 }
67736874 if err := h.Templates.ExecuteTemplate(w, "home", data); err != nil {
+16-6
pkg/appview/handlers/image_advisor.go
···4040type imageAdvisorData struct {
4141 Suggestions []advisorSuggestion
4242 Error string
4343+ // Model is shown in the results footer so users can attribute the
4444+ // suggestions to a specific model without us hardcoding it in the template.
4545+ Model string
4346}
4747+4848+// advisorModel is the Claude model used for image suggestions. Kept in one
4949+// place so the API call and the template footer stay in sync.
5050+const advisorModel = "claude-haiku-4-5-20251001"
5151+const advisorModelDisplay = "Claude Haiku 4.5"
44524553// OCI config types for full image config parsing
4654type advisorOCIConfig struct {
···168176 suggestions, err := parseAdvisorResponse(cachedJSON)
169177 if err == nil {
170178 slog.Debug("Serving cached advisor suggestions", "digest", digest)
171171- h.renderResults(w, imageAdvisorData{Suggestions: suggestions})
179179+ h.renderResults(w, imageAdvisorData{Suggestions: suggestions, Model: advisorModelDisplay})
172180 return
173181 }
174182 slog.Debug("Cached advisor data unparseable, fetching fresh", "digest", digest)
···217225 var promptBuf strings.Builder
218226 generateAdvisorPrompt(&promptBuf, report)
219227220220- // Call Claude API
228228+ // Call Claude API. The raw error often contains upstream HTTP body text
229229+ // which we must not surface to the user (potential secrets/PII). Log the
230230+ // detail; show a stable, sanitized message.
221231 responseText, err := callClaudeAPI(ctx, h.ClaudeAPIKey, promptBuf.String())
222232 if err != nil {
223233 slog.Warn("Claude API call failed", "error", err)
224224- h.renderResults(w, imageAdvisorData{Error: "AI service request failed: " + err.Error()})
234234+ h.renderResults(w, imageAdvisorData{Error: "The AI service couldn't generate suggestions right now. Please try again in a minute."})
225235 return
226236 }
227237···229239 suggestions, err := parseAdvisorResponse(responseText)
230240 if err != nil {
231241 slog.Warn("Failed to parse advisor response", "error", err, "response", responseText)
232232- h.renderResults(w, imageAdvisorData{Error: "Failed to parse AI response"})
242242+ h.renderResults(w, imageAdvisorData{Error: "We got a response from the AI service but couldn't read it. Please try again."})
233243 return
234244 }
235245···238248 slog.Warn("Failed to cache advisor suggestions", "error", err)
239249 }
240250241241- h.renderResults(w, imageAdvisorData{Suggestions: suggestions})
251251+ h.renderResults(w, imageAdvisorData{Suggestions: suggestions, Model: advisorModelDisplay})
242252}
243253244254func (h *ImageAdvisorHandler) renderResults(w http.ResponseWriter, data imageAdvisorData) {
···583593// callClaudeAPI sends the prompt to Claude Haiku using tool use and returns the structured JSON.
584594func callClaudeAPI(ctx context.Context, apiKey, prompt string) (string, error) {
585595 reqBody := map[string]any{
586586- "model": "claude-haiku-4-5-20251001",
596596+ "model": advisorModel,
587597 "max_tokens": 2048,
588598 "system": "Analyze the container image data. Provide actionable suggestions sorted by impact (highest first).",
589599 "tools": []map[string]any{{
+44-5
pkg/appview/handlers/legal.go
···2233import (
44 "net/http"
55+ "time"
56)
6777-// LegalPageData contains data for legal pages (terms, privacy)
88+// LegalPageData contains data for legal pages (terms, privacy).
89type LegalPageData struct {
910 PageData
1011 Meta *PageMeta
1112 CompanyName string
1213 Jurisdiction string
1414+ LastUpdated string
1515+}
1616+1717+// legalDefaults applies sensible fallbacks for operators who haven't set
1818+// CompanyName/Jurisdiction in config.
1919+func legalDefaults(company, jurisdiction string) (string, string) {
2020+ if company == "" {
2121+ company = "the Service"
2222+ }
2323+ if jurisdiction == "" {
2424+ jurisdiction = "United States"
2525+ }
2626+ return company, jurisdiction
2727+}
2828+2929+// Stamped at build time from the git commit date of the corresponding page
3030+// template via -ldflags -X (see Makefile). Empty falls back to legalFallbackDate
3131+// for bare `go build` / builds without a .git directory.
3232+var (
3333+ privacyLastUpdated string
3434+ termsLastUpdated string
3535+)
3636+3737+const legalFallbackDate = "April 2026"
3838+3939+func formatLegalDate(raw string) string {
4040+ if raw == "" {
4141+ return legalFallbackDate
4242+ }
4343+ t, err := time.Parse("2006-01-02", raw)
4444+ if err != nil {
4545+ return raw
4646+ }
4747+ return t.Format("January 2, 2006")
1348}
14491550// PrivacyPolicyHandler handles the /privacy page
···2459 ).WithCanonical("https://" + h.SiteURL + "/privacy").
2560 WithSiteName(h.ClientShortName)
26616262+ company, jurisdiction := legalDefaults(h.CompanyName, h.Jurisdiction)
2763 data := LegalPageData{
2864 PageData: NewPageData(r, &h.BaseUIHandler),
2965 Meta: meta,
3030- CompanyName: h.CompanyName,
3131- Jurisdiction: h.Jurisdiction,
6666+ CompanyName: company,
6767+ Jurisdiction: jurisdiction,
6868+ LastUpdated: formatLegalDate(privacyLastUpdated),
3269 }
33703471 if err := h.Templates.ExecuteTemplate(w, "privacy", data); err != nil {
···4986 ).WithCanonical("https://" + h.SiteURL + "/terms").
5087 WithSiteName(h.ClientShortName)
51888989+ company, jurisdiction := legalDefaults(h.CompanyName, h.Jurisdiction)
5290 data := LegalPageData{
5391 PageData: NewPageData(r, &h.BaseUIHandler),
5492 Meta: meta,
5555- CompanyName: h.CompanyName,
5656- Jurisdiction: h.Jurisdiction,
9393+ CompanyName: company,
9494+ Jurisdiction: jurisdiction,
9595+ LastUpdated: formatLegalDate(termsLastUpdated),
5796 }
58975998 if err := h.Templates.ExecuteTemplate(w, "terms", data); err != nil {
+44-10
pkg/appview/handlers/manifest_health.go
···2233import (
44 "context"
55+ "errors"
56 "log/slog"
77+ "net"
68 "net/http"
79 "net/url"
1010+ "strings"
811 "time"
912)
10131414+// classifyHealthError maps a CheckHealth error into a short reason code that
1515+// the template turns into a distinct tooltip. Prevents the badge from
1616+// collapsing every failure mode into a generic "Offline".
1717+//
1818+// Returns one of: "dns", "tls", "refused", "timeout", "http", "unknown"
1919+// (empty string when err is nil).
2020+func classifyHealthError(err error) string {
2121+ if err == nil {
2222+ return ""
2323+ }
2424+ var dnsErr *net.DNSError
2525+ if errors.As(err, &dnsErr) {
2626+ return "dns"
2727+ }
2828+ msg := strings.ToLower(err.Error())
2929+ if strings.Contains(msg, "x509") || strings.Contains(msg, "tls:") || strings.Contains(msg, "certificate") {
3030+ return "tls"
3131+ }
3232+ if strings.Contains(msg, "connection refused") {
3333+ return "refused"
3434+ }
3535+ if strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline exceeded") {
3636+ return "timeout"
3737+ }
3838+ if strings.Contains(msg, "status") || strings.Contains(msg, "http") {
3939+ return "http"
4040+ }
4141+ return "unknown"
4242+}
4343+1144// ManifestHealthHandler handles HTMX polling for manifest health status
1245type ManifestHealthHandler struct {
1346 BaseUIHandler
···3265 cached := h.HealthChecker.GetCachedStatus(endpoint)
3366 if cached != nil {
3467 // Cache hit - return final status
3535- h.renderBadge(w, endpoint, cached.Reachable, false)
6868+ h.renderBadge(w, endpoint, cached.Reachable, false, "")
3669 return
3770 }
3871···4376 reachable, err := h.HealthChecker.CheckHealth(ctx, endpoint)
44774578 // Check for HTTP errors first (connection refused, network unreachable, etc.)
4646- // This ensures we catch real failures even when timing aligns with context timeout
7979+ // This ensures we catch real failures even when timing aligns with context timeout.
4780 if err != nil {
4848- // Error - mark as unreachable
4949- h.renderBadge(w, endpoint, false, false)
8181+ h.renderBadge(w, endpoint, false, false, classifyHealthError(err))
5082 } else if ctx.Err() == context.DeadlineExceeded {
5151- // Context timed out but no HTTP error yet - still pending
5252- h.renderBadge(w, endpoint, false, true)
8383+ h.renderBadge(w, endpoint, false, true, "")
5384 } else {
5454- // Success
5555- h.renderBadge(w, endpoint, reachable, false)
8585+ h.renderBadge(w, endpoint, reachable, false, "")
5686 }
5787}
58885959-// renderBadge renders the appropriate badge HTML snippet
6060-func (h *ManifestHealthHandler) renderBadge(w http.ResponseWriter, endpoint string, reachable, pending bool) {
8989+// renderBadge renders the appropriate badge HTML snippet. Reason is one of the
9090+// classifyHealthError codes ("dns", "tls", "refused", "timeout", "http",
9191+// "unknown") or empty for success / pending states.
9292+func (h *ManifestHealthHandler) renderBadge(w http.ResponseWriter, endpoint string, reachable, pending bool, reason string) {
6193 w.Header().Set("Content-Type", "text/html")
62946395 data := struct {
6496 Pending bool
6597 Reachable bool
9898+ Reason string
6699 RetryURL string
67100 }{
68101 Pending: pending,
69102 Reachable: reachable,
103103+ Reason: reason,
70104 RetryURL: url.QueryEscape(endpoint),
71105 }
72106
+24-4
pkg/appview/handlers/meta.go
···33// PageMeta holds all metadata for a page's <head> section.
44// Use the builder methods to construct it with a fluent API.
55type PageMeta struct {
66- Title string // Page title (required)
77- Description string // Meta description (required)
66+ Title string // Page title (required; empty falls back to SiteName in template)
77+ Description string // Meta description (required; empty omits the tag entirely)
88 Canonical string // Canonical URL (optional)
99 Robots string // Robots directive, e.g. "noindex" (optional, defaults to "index, follow")
1010 OGType string // OpenGraph type, defaults to "website"
1111 OGImage string // OpenGraph image URL (optional)
1212+ OGImageAlt string // OpenGraph image alt text — improves social-share a11y
1313+ OGLocale string // OpenGraph locale (e.g. "en_US"); blank falls back in template
1214 TwitterCard string // Twitter card type, defaults to "summary_large_image"
1313- SiteName string // Site name for og:site_name (optional, defaults to "ATCR")
1515+ SiteName string // Site name for og:site_name (falls back to "ATCR" in template)
1416 JSONLD []any // JSON-LD structured data objects (optional)
1517}
16181719// NewPageMeta creates a new PageMeta with required fields and sensible defaults.
2020+// Callers should not pass empty title/description — the template falls back to
2121+// the SiteName for missing title and omits missing description, but those are
2222+// last-resort defenses.
1823func NewPageMeta(title, description string) *PageMeta {
1924 return &PageMeta{
2025 Title: title,
···3641 return m
3742}
38434444+// WithOGImageAlt sets the alt text for the OpenGraph image. Strongly recommended
4545+// when OGImage is set — screen readers on social platforms read this out.
4646+func (m *PageMeta) WithOGImageAlt(alt string) *PageMeta {
4747+ m.OGImageAlt = alt
4848+ return m
4949+}
5050+5151+// WithOGLocale overrides the default "en_US" locale.
5252+func (m *PageMeta) WithOGLocale(locale string) *PageMeta {
5353+ m.OGLocale = locale
5454+ return m
5555+}
5656+3957// WithOGType sets the OpenGraph type (e.g., "website", "profile", "article").
4058func (m *PageMeta) WithOGType(ogType string) *PageMeta {
4159 m.OGType = ogType
···5472 return m
5573}
56745757-// WithSiteName sets the site name for og:site_name.
7575+// WithSiteName sets the site name for og:site_name. Pass the caller's
7676+// ClientShortName — forgetting this on a branded deployment (e.g. Seamark)
7777+// leaks "ATCR" into social previews.
5878func (m *PageMeta) WithSiteName(name string) *PageMeta {
5979 m.SiteName = name
6080 return m
+42-30
pkg/appview/handlers/repository.go
···182182 repo.Version = metadata["org.opencontainers.image.version"]
183183 }
184184185185- // Fetch stats
185185+ // Fetch stats. Track availability separately so the template can render
186186+ // "—" or hide the stats row instead of showing zeros that masquerade as
187187+ // real counts.
186188 stats, err := db.GetRepositoryStats(h.ReadOnlyDB, owner.DID, repository)
189189+ statsAvailable := err == nil
187190 if err != nil {
188191 slog.Warn("Failed to fetch repository stats", "error", err)
189192 stats = &db.RepositoryStats{StarCount: 0}
···210213 isOwner = (user.DID == owner.DID)
211214 }
212215213213- // Fetch README content from repo page record or annotations
216216+ // Fetch README content from repo page record or annotations.
217217+ // ReadmeFetchFailed distinguishes "owner never provided a README" (show
218218+ // CTA to add one) from "we tried to fetch the configured README and it
219219+ // failed" (show retry CTA instead).
214220 var readmeHTML template.HTML
215221 var rawDescription string
222222+ var readmeFetchFailed bool
216223217224 repoPage, err := db.GetRepoPage(h.ReadOnlyDB, owner.DID, repository)
218225 if err == nil && repoPage != nil {
···238245 }
239246 }
240247 if readmeURL != "" {
241241- // Fetch raw markdown for editor pre-fill, then render
242248 rawBytes, fetchErr := h.ReadmeFetcher.FetchRaw(r.Context(), readmeURL)
243249 if fetchErr != nil {
244250 slog.Debug("Failed to fetch README from URL", "url", readmeURL, "error", fetchErr)
251251+ readmeFetchFailed = true
245252 } else {
246253 rawDescription = string(rawBytes)
247254 html, renderErr := h.ReadmeFetcher.RenderMarkdown(rawBytes)
248255 if renderErr != nil {
249256 slog.Debug("Failed to render fetched README", "url", readmeURL, "error", renderErr)
257257+ readmeFetchFailed = true
250258 } else {
251259 readmeHTML = template.HTML(html)
252260 }
···299307300308 data := struct {
301309 PageData
302302- Meta *PageMeta
303303- Owner *db.User
304304- Repository *db.Repository
305305- AllTags []string
306306- SelectedTag *SelectedTagData
307307- Stats *db.RepositoryStats
308308- TagCount int
309309- IsStarred bool
310310- IsOwner bool
311311- ReadmeHTML template.HTML
312312- RawDescription string
313313- ArtifactType string
314314- NonDefaultHolds []string
310310+ Meta *PageMeta
311311+ Owner *db.User
312312+ Repository *db.Repository
313313+ AllTags []string
314314+ SelectedTag *SelectedTagData
315315+ Stats *db.RepositoryStats
316316+ StatsAvailable bool
317317+ TagCount int
318318+ IsStarred bool
319319+ IsOwner bool
320320+ ReadmeHTML template.HTML
321321+ ReadmeFetchFailed bool
322322+ RawDescription string
323323+ ArtifactType string
324324+ NonDefaultHolds []string
315325 }{
316316- PageData: NewPageData(r, &h.BaseUIHandler),
317317- Meta: meta,
318318- Owner: owner,
319319- Repository: repo,
320320- AllTags: allTags,
321321- SelectedTag: selectedTag,
322322- Stats: stats,
323323- TagCount: tagCount,
324324- IsStarred: isStarred,
325325- IsOwner: isOwner,
326326- ReadmeHTML: readmeHTML,
327327- RawDescription: rawDescription,
328328- ArtifactType: artifactType,
329329- NonDefaultHolds: nonDefaultHolds,
326326+ PageData: NewPageData(r, &h.BaseUIHandler),
327327+ Meta: meta,
328328+ Owner: owner,
329329+ Repository: repo,
330330+ AllTags: allTags,
331331+ SelectedTag: selectedTag,
332332+ Stats: stats,
333333+ StatsAvailable: statsAvailable,
334334+ TagCount: tagCount,
335335+ IsStarred: isStarred,
336336+ IsOwner: isOwner,
337337+ ReadmeHTML: readmeHTML,
338338+ ReadmeFetchFailed: readmeFetchFailed,
339339+ RawDescription: rawDescription,
340340+ ArtifactType: artifactType,
341341+ NonDefaultHolds: nonDefaultHolds,
330342 }
331343332344 // If the owner has disabled AI advisor in their profile, hide the button
+25-6
pkg/appview/handlers/scan_result.go
···2525}
26262727// vulnBadgeData is the template data for the vuln-badge partial.
2828+// The badge renders one of four states, in priority order:
2929+// 1. Error — we couldn't reach the hold at all (network/5xx)
3030+// 2. NotScanned — hold reachable, no scan record for this digest (404)
3131+// 3. ScanFailed — scan record exists but the scanner didn't produce an SBOM
3232+// 4. Found — scan succeeded; render tier counts (or "Clean" when zero)
3333+//
3434+// These states must stay distinct so users can tell "hold is down" from
3535+// "this hasn't been scanned yet" from "scanner errored on this image".
2836type vulnBadgeData struct {
2937 Critical int64
3038 High int64
···3240 Low int64
3341 Total int64
3442 ScannedAt string
3535- Found bool // true if scan record exists
3636- Error bool // true if hold unreachable or error
3737- ScanFailed bool // true if scan record exists but scan failed (no blobs)
4343+ Found bool // true if scan record exists and succeeded
4444+ Error bool // true if hold unreachable (network/5xx)
4545+ NotScanned bool // true if hold is up but no scan record (404)
4646+ ScanFailed bool // true if scan record exists but scan failed (no SBOM)
3847 Digest string // for the detail modal link
3948 HoldEndpoint string // for the detail modal link
4049}
···8796 defer resp.Body.Close()
88978998 if resp.StatusCode == http.StatusNotFound {
9090- // No scan record — scanning disabled or not yet scanned. Render nothing.
9191- h.renderBadge(w, vulnBadgeData{Error: true})
9999+ // Hold is reachable but has no scan record — not yet scanned, or
100100+ // the image was pushed before scanning was enabled.
101101+ h.renderBadge(w, vulnBadgeData{NotScanned: true})
92102 return
93103 }
94104···160170 }
161171 defer resp.Body.Close()
162172173173+ if resp.StatusCode == http.StatusNotFound {
174174+ return vulnBadgeData{NotScanned: true}
175175+ }
163176 if resp.StatusCode != http.StatusOK {
164177 return vulnBadgeData{Error: true}
165178 }
···214227 if err != nil {
215228 slog.Debug("Failed to resolve hold for batch scan", "holdEndpoint", holdEndpoint, "error", err)
216229 w.Header().Set("Content-Type", "text/html")
230230+ // Emit "not scanned" badge for every digest so the placeholder resolves visibly.
231231+ var buf bytes.Buffer
232232+ if err := h.Templates.ExecuteTemplate(&buf, "vuln-badge", vulnBadgeData{Error: true}); err != nil {
233233+ slog.Warn("Failed to render vuln-badge placeholder", "error", err)
234234+ }
217235 for _, d := range digests {
218218- fmt.Fprintf(w, `<span id="scan-badge-%s" hx-swap-oob="outerHTML"></span>`, template.HTMLEscapeString(d))
236236+ fmt.Fprintf(w, `<span id="scan-badge-%s" hx-swap-oob="outerHTML">%s</span>`,
237237+ template.HTMLEscapeString(d), buf.String())
219238 }
220239 return
221240 }
+10-7
pkg/appview/handlers/scan_result_test.go
···165165166166 body := strings.TrimSpace(rr.Body.String())
167167168168- // 404 = no scan record. Should render NOTHING — not "Scan pending".
169169- if body != "" {
170170- t.Errorf("Expected empty body for 404, got: %q", body)
168168+ // 404 = no scan record yet. Renders a visible "Not scanned" placeholder
169169+ // so the htmx target resolves instead of staying empty forever.
170170+ if !strings.Contains(body, "Not scanned") {
171171+ t.Errorf("Expected 'Not scanned' placeholder for 404, got: %q", body)
171172 }
172173}
173174···189190190191 body := strings.TrimSpace(rr.Body.String())
191192192192- if body != "" {
193193- t.Errorf("Expected empty body for hold error, got: %q", body)
193193+ // Hold reachable but returned 5xx — distinct from "not scanned".
194194+ if !strings.Contains(body, "Hold offline") {
195195+ t.Errorf("Expected 'Hold offline' badge for hold error, got: %q", body)
194196 }
195197}
196198···207209208210 body := strings.TrimSpace(rr.Body.String())
209211210210- if body != "" {
211211- t.Errorf("Expected empty body for unreachable hold, got: %q", body)
212212+ // Network-unreachable hold — also distinct from "not scanned".
213213+ if !strings.Contains(body, "Hold offline") {
214214+ t.Errorf("Expected 'Hold offline' badge for unreachable hold, got: %q", body)
212215 }
213216}
214217
+92-59
pkg/appview/handlers/search.go
···99 "atcr.io/pkg/appview/middleware"
1010)
11111212-// SearchHandler handles the search page
1212+// searchPageSize is the per-page result count for both initial render and
1313+// "Load More" pagination. Kept consistent so noscript and htmx paths agree.
1414+const searchPageSize = 50
1515+1616+// searchResults holds the data shared by the full-page and partial renders.
1717+// Pulled out so SearchHandler can server-render the first page inline and
1818+// SearchResultsHandler can emit just the partial for htmx Load More.
1919+type searchResults struct {
2020+ PageData
2121+ Repositories []db.RepoCardData
2222+ SearchQuery string
2323+ HasMore bool
2424+ NextOffset int
2525+ // HasError is true when the DB query failed. Template branches to the
2626+ // shared error state rather than the empty-results copy.
2727+ HasError bool
2828+}
2929+3030+func (h *BaseUIHandler) runSearch(r *http.Request, query string, offset int) (searchResults, error) {
3131+ pageData := NewPageData(r, h)
3232+3333+ var currentUserDID string
3434+ if user := middleware.GetUser(r); user != nil {
3535+ currentUserDID = user.DID
3636+ }
3737+3838+ repos, total, err := db.SearchRepositories(h.ReadOnlyDB, query, searchPageSize, offset, currentUserDID)
3939+ if err != nil {
4040+ return searchResults{
4141+ PageData: pageData,
4242+ SearchQuery: query,
4343+ HasError: true,
4444+ }, err
4545+ }
4646+4747+ db.SetRegistryURL(repos, h.RegistryURL)
4848+ db.SetOciClient(repos, pageData.OciClient)
4949+5050+ return searchResults{
5151+ PageData: pageData,
5252+ Repositories: repos,
5353+ SearchQuery: query,
5454+ HasMore: offset+searchPageSize < total,
5555+ NextOffset: offset + searchPageSize,
5656+ }, nil
5757+}
5858+5959+// SearchHandler handles the search page. When a query is provided, it runs
6060+// the search server-side so the page works without JavaScript; htmx only
6161+// takes over for the "Load More" pagination link.
1362type SearchHandler struct {
1463 BaseUIHandler
1564}
16651766func (h *SearchHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
1818- query := r.URL.Query().Get("q")
6767+ query := strings.TrimSpace(r.URL.Query().Get("q"))
6868+ if len(query) > 200 {
6969+ query = query[:200]
7070+ }
19712020- // Build page meta
2172 title := "Search - " + h.ClientShortName
2273 description := "Search for container images on " + h.ClientShortName + ", the decentralized container registry"
2374 canonical := "https://" + h.SiteURL + "/search"
···29803081 meta := NewPageMeta(title, description).WithCanonical(canonical).WithSiteName(h.ClientShortName)
31828383+ var results searchResults
8484+ if query != "" {
8585+ var err error
8686+ results, err = h.runSearch(r, query, 0)
8787+ if err != nil {
8888+ // Don't 500 the whole page — render it with the error-state
8989+ // partial so the search form stays usable.
9090+ results.HasError = true
9191+ }
9292+ } else {
9393+ results.PageData = NewPageData(r, &h.BaseUIHandler)
9494+ }
9595+3296 data := struct {
3397 PageData
3498 Meta *PageMeta
3599 SearchQuery string
100100+ Results searchResults
36101 }{
3737- PageData: NewPageData(r, &h.BaseUIHandler),
102102+ PageData: results.PageData,
38103 Meta: meta,
39104 SearchQuery: query,
105105+ Results: results,
40106 }
4110742108 if err := h.Templates.ExecuteTemplate(w, "search", data); err != nil {
···45111 }
46112}
471134848-// SearchResultsHandler handles the HTMX request for search results
114114+// SearchResultsHandler serves the search-results partial for htmx Load More
115115+// pagination. Returns just the grid fragment, not a full page.
49116type SearchResultsHandler struct {
50117 BaseUIHandler
51118}
5211953120func (h *SearchResultsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
5454- query := r.URL.Query().Get("q")
121121+ query := strings.TrimSpace(r.URL.Query().Get("q"))
122122+ if len(query) > 200 {
123123+ query = query[:200]
124124+ }
551255656- // Validate and sanitize input
5757- query = strings.TrimSpace(query)
58126 if query == "" {
5959- // Return empty results if no query
6060- data := struct {
6161- PageData
6262- Repositories []db.RepoCardData
6363- SearchQuery string
6464- HasMore bool
6565- NextOffset int
6666- }{
6767- PageData: NewPageData(r, &h.BaseUIHandler),
6868- Repositories: []db.RepoCardData{},
6969- SearchQuery: "",
7070- HasMore: false,
127127+ empty := searchResults{
128128+ PageData: NewPageData(r, &h.BaseUIHandler),
129129+ SearchQuery: "",
71130 }
7272-7373- if err := h.Templates.ExecuteTemplate(w, "search-results.html", data); err != nil {
7474- http.Error(w, err.Error(), http.StatusInternalServerError)
131131+ if err := h.Templates.ExecuteTemplate(w, "search-results", empty); err != nil {
132132+ RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render results", err)
75133 }
76134 return
77135 }
781367979- // Limit query length to prevent abuse
8080- if len(query) > 200 {
8181- query = query[:200]
8282- }
8383-8484- limit := 50
85137 offset := 0
8686-87138 if o := r.URL.Query().Get("offset"); o != "" {
88139 offset, _ = strconv.Atoi(o)
89140 }
901419191- // Get current user DID (empty string if not logged in)
9292- var currentUserDID string
9393- if user := middleware.GetUser(r); user != nil {
9494- currentUserDID = user.DID
9595- }
9696-9797- repos, total, err := db.SearchRepositories(h.ReadOnlyDB, query, limit, offset, currentUserDID)
142142+ results, err := h.runSearch(r, query, offset)
98143 if err != nil {
9999- http.Error(w, err.Error(), http.StatusInternalServerError)
144144+ RenderHTMXError(w, r, http.StatusInternalServerError, "Search is temporarily unavailable", err)
100145 return
101146 }
102147103103- // Set registry URL and OCI client on all cards
104104- db.SetRegistryURL(repos, h.RegistryURL)
105105- pageData := NewPageData(r, &h.BaseUIHandler)
106106- db.SetOciClient(repos, pageData.OciClient)
107107-108108- data := struct {
109109- PageData
110110- Repositories []db.RepoCardData
111111- SearchQuery string
112112- HasMore bool
113113- NextOffset int
114114- }{
115115- PageData: pageData,
116116- Repositories: repos,
117117- SearchQuery: query,
118118- HasMore: offset+limit < total,
119119- NextOffset: offset + limit,
148148+ // Load More requests (offset > 0) render just the new cards plus a
149149+ // replacement Load More button via card-grid-append. Cards are OOB-swapped
150150+ // into the existing grid so the old grid, cards, and scroll position stay
151151+ // put. The primary outerHTML swap replaces the old Load More wrapper.
152152+ template := "search-results"
153153+ if offset > 0 {
154154+ template = "card-grid-append-search"
120155 }
121121-122122- if err := h.Templates.ExecuteTemplate(w, "search-results.html", data); err != nil {
123123- http.Error(w, err.Error(), http.StatusInternalServerError)
124124- return
156156+ if err := h.Templates.ExecuteTemplate(w, template, results); err != nil {
157157+ RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render results", err)
125158 }
126159}
+194-131
pkg/appview/handlers/settings.go
···3131 IsActive bool `json:"isActive"`
3232}
33333434-// SettingsHandler handles the settings page
3434+// SettingsHandler handles the settings page — dispatches per-tab.
3535type SettingsHandler struct {
3636 BaseUIHandler
3737}
38383939-func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
4040- user := middleware.GetUser(r)
4141- if user == nil {
4242- http.Redirect(w, r, "/auth/oauth/login?return_to=/settings", http.StatusFound)
4343- return
3939+// settingsTab describes a tab entry rendered in the tablist.
4040+type settingsTab struct {
4141+ Slug string
4242+ Label string
4343+ Icon string
4444+}
4545+4646+func settingsTabs() []settingsTab {
4747+ return []settingsTab{
4848+ {Slug: "user", Label: "User", Icon: "user"},
4949+ {Slug: "billing", Label: "Billing", Icon: "credit-card"},
5050+ {Slug: "storage", Label: "Storage", Icon: "hard-drive"},
5151+ {Slug: "devices", Label: "Devices", Icon: "terminal"},
5252+ {Slug: "webhooks", Label: "Webhooks", Icon: "webhook"},
5353+ {Slug: "advanced", Label: "Advanced", Icon: "shield-check"},
4454 }
5555+}
45564646- // Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety)
4747- client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
5757+var validSettingsTabs = map[string]bool{
5858+ "user": true, "storage": true, "billing": true,
5959+ "devices": true, "webhooks": true, "advanced": true,
6060+}
48614949- // Fetch sailor profile
5050- profile, err := storage.GetProfile(r.Context(), client)
5151- if err != nil {
5252- // Error fetching profile - log out user
5353- slog.Warn("Failed to fetch profile, logging out", "component", "settings", "did", user.DID, "error", err)
5454- http.Redirect(w, r, "/auth/logout", http.StatusFound)
5555- return
5656- }
6262+// settingsProfile is the sidebar identity info shared across all tabs.
6363+type settingsProfile struct {
6464+ Handle string
6565+ DID string
6666+ PDSEndpoint string
6767+ DefaultHold string
6868+ AutoRemoveUntagged bool
6969+ OciClient string
7070+ AIAdvisorEnabled bool
7171+ HasAIAdvisorAccess bool
7272+}
57735858- if profile == nil {
5959- // Profile doesn't exist yet (404) - user needs to log out and back in to create it
6060- slog.Warn("Profile doesn't exist, logging out", "component", "settings", "did", user.DID)
6161- http.Redirect(w, r, "/auth/logout", http.StatusFound)
6262- return
6363- }
7474+// settingsPageData is the struct passed to the settings shell + panel templates.
7575+// MemberHolds are holds where the user is already owner/crew; EligibleHolds
7676+// are ones they can opt-in to join. Splitting them upstream keeps the
7777+// hold_selector template from doing filter-the-same-list-twice gymnastics.
7878+type settingsPageData struct {
7979+ PageData
8080+ Meta *PageMeta
8181+ ActiveTab string
8282+ Tabs []settingsTab
8383+ Profile settingsProfile
8484+ ActiveHold *HoldDisplay
8585+ OtherHolds []HoldDisplay
8686+ MemberHolds []HoldDisplay
8787+ EligibleHolds []HoldDisplay
8888+ WebhooksData webhooksTemplateData
8989+ Subscription SubscriptionDisplay
9090+}
9191+9292+// ServeHTTP redirects /settings to /settings/user.
9393+func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
9494+ http.Redirect(w, r, "/settings/user", http.StatusFound)
9595+}
9696+9797+// ServeTab returns an http.Handler for a specific settings tab.
9898+// If HX-Request is set, only the panel fragment is rendered.
9999+func (h *SettingsHandler) ServeTab(tab string) http.HandlerFunc {
100100+ return func(w http.ResponseWriter, r *http.Request) {
101101+ if !validSettingsTabs[tab] {
102102+ http.NotFound(w, r)
103103+ return
104104+ }
641056565- slog.Debug("Fetched profile", "component", "settings", "did", user.DID, "default_hold", profile.DefaultHold)
106106+ user := middleware.GetUser(r)
107107+ if user == nil {
108108+ http.Redirect(w, r, "/auth/oauth/login?return_to=/settings/"+tab, http.StatusFound)
109109+ return
110110+ }
661116767- // Get available holds
6868- var activeHold *HoldDisplay
6969- var otherHolds, allHolds []HoldDisplay
112112+ client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
701137171- if h.DB != nil {
7272- availableHolds, err := db.GetAvailableHolds(h.DB, user.DID)
114114+ profile, err := storage.GetProfile(r.Context(), client)
73115 if err != nil {
7474- slog.Warn("Failed to get available holds", "component", "settings", "did", user.DID, "error", err)
7575- } else {
7676- for _, hold := range availableHolds {
7777- display := HoldDisplay{
7878- DID: hold.HoldDID,
7979- DisplayName: resolveHoldDisplayName(r.Context(), &h.BaseUIHandler, hold.HoldDID),
8080- Region: hold.Region,
8181- Membership: hold.Membership,
8282- IsActive: hold.HoldDID == profile.DefaultHold,
8383- }
116116+ slog.Warn("Failed to fetch profile, logging out", "component", "settings", "did", user.DID, "error", err)
117117+ http.Redirect(w, r, "/auth/logout", http.StatusFound)
118118+ return
119119+ }
120120+ if profile == nil {
121121+ slog.Warn("Profile doesn't exist, logging out", "component", "settings", "did", user.DID)
122122+ http.Redirect(w, r, "/auth/logout", http.StatusFound)
123123+ return
124124+ }
841258585- // Parse permissions JSON if present
8686- if hold.Permissions != "" {
8787- if err := json.Unmarshal([]byte(hold.Permissions), &display.Permissions); err != nil {
8888- slog.Warn("Failed to parse permissions JSON", "component", "settings", "did", user.DID, "hold_did", hold.HoldDID, "error", err)
8989- }
9090- }
126126+ meta := NewPageMeta(
127127+ "Settings - "+h.ClientShortName,
128128+ "Manage your "+h.ClientShortName+" account settings, authorized devices, and storage preferences",
129129+ ).WithRobots("noindex").
130130+ WithSiteName(h.ClientShortName)
911319292- // Check health status (uses cache if available, otherwise pings on-demand)
9393- if h.HealthChecker != nil {
9494- if status := h.HealthChecker.GetStatus(r.Context(), hold.HoldDID); status != nil {
9595- if status.Reachable {
9696- display.Status = "online"
9797- } else {
9898- display.Status = "offline"
9999- }
100100- }
101101- }
132132+ data := settingsPageData{
133133+ PageData: NewPageData(r, &h.BaseUIHandler),
134134+ Meta: meta,
135135+ ActiveTab: tab,
136136+ Tabs: settingsTabs(),
137137+ Profile: settingsProfile{
138138+ Handle: user.Handle,
139139+ DID: user.DID,
140140+ PDSEndpoint: user.PDSEndpoint,
141141+ DefaultHold: profile.DefaultHold,
142142+ AutoRemoveUntagged: profile.AutoRemoveUntagged,
143143+ OciClient: profile.OciClient,
144144+ AIAdvisorEnabled: profile.AIAdvisorEnabled == nil || *profile.AIAdvisorEnabled,
145145+ },
146146+ }
147147+ if h.BillingManager != nil {
148148+ data.Profile.HasAIAdvisorAccess = h.BillingManager.HasAIAdvisor(user.DID)
149149+ }
102150103103- // All holds go in dropdown list
104104- allHolds = append(allHolds, display)
151151+ // Per-tab data fetch.
152152+ switch tab {
153153+ case "storage":
154154+ data.ActiveHold, data.OtherHolds, data.MemberHolds, data.EligibleHolds = h.buildHoldsData(r.Context(), user.DID, profile.DefaultHold)
155155+ case "billing":
156156+ data.Subscription = h.buildSubscriptionDisplay(user.DID)
157157+ case "webhooks":
158158+ data.WebhooksData = h.buildWebhooksData(user.DID)
159159+ }
105160106106- // Separate active from other member holds (skip eligible)
107107- if hold.Membership != "eligible" {
108108- if display.IsActive {
109109- holdCopy := display
110110- activeHold = &holdCopy
111111- } else {
112112- otherHolds = append(otherHolds, display)
113113- }
114114- }
115115- }
161161+ // htmx partial: render just the panel.
162162+ tmplName := "settings"
163163+ if r.Header.Get("HX-Request") == "true" {
164164+ tmplName = "settings-panel"
165165+ }
166166+ if err := h.Templates.ExecuteTemplate(w, tmplName, data); err != nil {
167167+ http.Error(w, err.Error(), http.StatusInternalServerError)
168168+ return
116169 }
117170 }
171171+}
118172119119- // Fetch webhooks (local DB read)
120120- webhooksData := h.buildWebhooksData(user.DID)
173173+// buildHoldsData resolves the current user's holds for the storage tab.
174174+// Returns: the currently-active hold (if any), non-active member holds, the
175175+// full member-hold list (including active, for selector rendering), and
176176+// eligible holds (the user could join but isn't yet a member of).
177177+func (h *SettingsHandler) buildHoldsData(ctx context.Context, userDID, defaultHold string) (*HoldDisplay, []HoldDisplay, []HoldDisplay, []HoldDisplay) {
178178+ if h.DB == nil {
179179+ return nil, nil, nil, nil
180180+ }
121181122122- // Fetch subscription info (Stripe with in-memory cache)
123123- subscriptionData := h.buildSubscriptionDisplay(user.DID)
182182+ availableHolds, err := db.GetAvailableHolds(h.DB, userDID)
183183+ if err != nil {
184184+ slog.Warn("Failed to get available holds", "component", "settings", "did", userDID, "error", err)
185185+ return nil, nil, nil, nil
186186+ }
124187125125- meta := NewPageMeta(
126126- "Settings - "+h.ClientShortName,
127127- "Manage your "+h.ClientShortName+" account settings, authorized devices, and storage preferences",
128128- ).WithRobots("noindex").
129129- WithSiteName(h.ClientShortName)
188188+ var activeHold *HoldDisplay
189189+ var otherHolds, memberHolds, eligibleHolds []HoldDisplay
130190131131- data := struct {
132132- PageData
133133- Meta *PageMeta
134134- Profile struct {
135135- Handle string
136136- DID string
137137- PDSEndpoint string
138138- DefaultHold string
139139- AutoRemoveUntagged bool
140140- OciClient string
141141- AIAdvisorEnabled bool
142142- HasAIAdvisorAccess bool // billing tier grants access
191191+ for _, hold := range availableHolds {
192192+ display := HoldDisplay{
193193+ DID: hold.HoldDID,
194194+ DisplayName: resolveHoldDisplayName(ctx, &h.BaseUIHandler, hold.HoldDID),
195195+ Region: hold.Region,
196196+ Membership: hold.Membership,
197197+ IsActive: hold.HoldDID == defaultHold,
143198 }
144144- ActiveHold *HoldDisplay
145145- OtherHolds []HoldDisplay
146146- AllHolds []HoldDisplay
147147- WebhooksData webhooksTemplateData
148148- Subscription SubscriptionDisplay
149149- }{
150150- PageData: NewPageData(r, &h.BaseUIHandler),
151151- Meta: meta,
152152- ActiveHold: activeHold,
153153- OtherHolds: otherHolds,
154154- AllHolds: allHolds,
155155- WebhooksData: webhooksData,
156156- Subscription: subscriptionData,
157157- }
158199159159- data.Profile.Handle = user.Handle
160160- data.Profile.DID = user.DID
161161- data.Profile.PDSEndpoint = user.PDSEndpoint
162162- data.Profile.DefaultHold = profile.DefaultHold
163163- data.Profile.AutoRemoveUntagged = profile.AutoRemoveUntagged
164164- data.Profile.OciClient = profile.OciClient
165165- data.Profile.AIAdvisorEnabled = profile.AIAdvisorEnabled == nil || *profile.AIAdvisorEnabled
166166- if h.BillingManager != nil {
167167- data.Profile.HasAIAdvisorAccess = h.BillingManager.HasAIAdvisor(user.DID)
200200+ if hold.Permissions != "" {
201201+ if err := json.Unmarshal([]byte(hold.Permissions), &display.Permissions); err != nil {
202202+ slog.Warn("Failed to parse permissions JSON", "component", "settings", "did", userDID, "hold_did", hold.HoldDID, "error", err)
203203+ }
204204+ }
205205+206206+ if h.HealthChecker != nil {
207207+ if status := h.HealthChecker.GetStatus(ctx, hold.HoldDID); status != nil {
208208+ if status.Reachable {
209209+ display.Status = "online"
210210+ } else {
211211+ display.Status = "offline"
212212+ }
213213+ }
214214+ }
215215+216216+ if hold.Membership == "eligible" {
217217+ eligibleHolds = append(eligibleHolds, display)
218218+ continue
219219+ }
220220+221221+ memberHolds = append(memberHolds, display)
222222+ if display.IsActive {
223223+ holdCopy := display
224224+ activeHold = &holdCopy
225225+ } else {
226226+ otherHolds = append(otherHolds, display)
227227+ }
168228 }
169229170170- if err := h.Templates.ExecuteTemplate(w, "settings", data); err != nil {
171171- http.Error(w, err.Error(), http.StatusInternalServerError)
172172- return
173173- }
230230+ return activeHold, otherHolds, memberHolds, eligibleHolds
174231}
175232176233// webhooksTemplateData is the data passed to the webhooks_list template.
···248305 IsCurrent: tier.IsCurrent,
249306 }
250307 if tier.PriceCentsMonthly > 0 {
251251- td.PriceMonthly = fmt.Sprintf("$%d/mo", tier.PriceCentsMonthly/100)
308308+ if tier.PriceCentsMonthly%100 == 0 {
309309+ td.PriceMonthly = fmt.Sprintf("$%d/mo", tier.PriceCentsMonthly/100)
310310+ } else {
311311+ td.PriceMonthly = fmt.Sprintf("$%.2f/mo", float64(tier.PriceCentsMonthly)/100.0)
312312+ }
252313 }
253314 if tier.PriceCentsYearly > 0 {
254254- td.PriceYearly = fmt.Sprintf("$%d/yr", tier.PriceCentsYearly/100)
315315+ if tier.PriceCentsYearly%100 == 0 {
316316+ td.PriceYearly = fmt.Sprintf("$%d/yr", tier.PriceCentsYearly/100)
317317+ } else {
318318+ td.PriceYearly = fmt.Sprintf("$%.2f/yr", float64(tier.PriceCentsYearly)/100.0)
319319+ }
255320 }
256321 display.Tiers = append(display.Tiers, td)
257322 }
···331396 }
332397333398 if !hasAccess {
334334- w.Header().Set("Content-Type", "text/html")
335335- if err := h.Templates.ExecuteTemplate(w, "alert", map[string]string{
336336- "Type": "error",
337337- "Message": "You don't have access to this hold",
338338- }); err != nil {
339339- http.Error(w, err.Error(), http.StatusInternalServerError)
340340- }
399399+ // hx-swap="none" on the selector form means an inline alert
400400+ // would be discarded — route through RenderHTMXError so
401401+ // the client-side toast handler fires instead.
402402+ RenderHTMXError(w, r, http.StatusForbidden, "You don't have access to this hold", nil)
341403 return
342404 }
343405 }
···359421360422 // Save profile
361423 if err := storage.UpdateProfile(r.Context(), client, profile); err != nil {
362362- http.Error(w, "Failed to update profile: "+err.Error(), http.StatusInternalServerError)
424424+ RenderHTMXError(w, r, http.StatusInternalServerError, "Couldn't update your default hold", err)
363425 return
364426 }
365427···381443 }
382444 }
383445446446+ // Fire a success toast via HX-Trigger in addition to the HX-Refresh — the
447447+ // page reloads so the user sees the new hold applied, and the toast
448448+ // confirms the action took effect.
449449+ trigger, _ := json.Marshal(map[string]map[string]string{
450450+ "toast": {"message": "Default hold updated", "type": "success"},
451451+ })
452452+ w.Header().Set("HX-Trigger", string(trigger))
384453 w.Header().Set("HX-Refresh", "true")
385385- w.Header().Set("Content-Type", "text/html")
386386- if err := h.Templates.ExecuteTemplate(w, "alert", map[string]string{
387387- "Type": "success",
388388- "Message": "Default hold updated successfully!",
389389- }); err != nil {
390390- slog.Warn("Failed to render alert", "error", err)
391391- }
454454+ w.WriteHeader(http.StatusNoContent)
392455}
393456394457// UpdateAutoRemoveUntaggedHandler handles toggling the auto-remove-untagged setting
+11-2
pkg/appview/handlers/storage.go
···153153154154func (h *StorageHandler) renderError(w http.ResponseWriter, message string) {
155155 w.Header().Set("Content-Type", "text/html")
156156- fmt.Fprintf(w, `<div class="storage-error"><i data-lucide="alert-circle"></i> %s</div>`, message)
156156+ // Route through the alert partial so the error matches the rest of the
157157+ // UI; previous hand-rolled markup referenced a non-existent
158158+ // `storage-error` class.
159159+ if err := h.Templates.ExecuteTemplate(w, "alert", map[string]string{
160160+ "Type": "error",
161161+ "Message": message,
162162+ }); err != nil {
163163+ slog.Error("Failed to render storage alert", "error", err)
164164+ fmt.Fprintf(w, `<p class="text-sm text-error">%s</p>`, message)
165165+ }
157166}
158167159168func (h *StorageHandler) renderNoHold(w http.ResponseWriter) {
160169 w.Header().Set("Content-Type", "text/html")
161161- fmt.Fprint(w, `<div class="storage-info"><i data-lucide="info"></i> No hold configured. Set a default hold above to see storage usage.</div>`)
170170+ fmt.Fprint(w, `<p class="flex items-center gap-2 text-sm text-base-content/70"><svg class="icon size-4 shrink-0" aria-hidden="true"><use href="/icons.svg#info"></use></svg> No hold configured. Set a default hold above to see storage usage.</p>`)
162171}
163172164173// humanizeBytes converts bytes to human-readable format
···175175 even if values currently coincide. Used by .text-star/.fill-star/etc. */
176176 --color-star: oklch(82% 0.189 84.429);
177177178178+ /* Helm brand color (official Helm blue #0F1689). Two variants so the
179179+ light-mode value stays legible on a near-white surface and the
180180+ dark-mode value stays legible on Deep Ocean. */
181181+ --color-helm-light: oklch(31% 0.181 267.5);
182182+ --color-helm-dark: oklch(64.6% 0.19 273.2);
183183+178184 /* Vulnerability severity scale. Held constant across themes on purpose:
179185 CVE severity is a product-semantic signal that needs to read the same
180186 way regardless of surface. Content-pair colors come from the same hue
···391397 TOUCH TARGET SIZING
392398 Small buttons and compact form controls meet the keyboard minimum on
393399 desktop but fall below the 44×44 recommended touch target on touch
394394- devices (WCAG 2.5.5). Grow them only on coarse-pointer devices so
395395- pointer-primary layouts stay dense.
400400+ devices (WCAG 2.5.5). Grow them on any device that can't reliably
401401+ produce hover — covers pure touch as well as hybrid touchscreen
402402+ laptops where `pointer: coarse` alone misses.
396403 ======================================== */
397397-@media (pointer: coarse) {
404404+@media (pointer: coarse), (hover: none) {
398405 /* Icon-only buttons grow both axes — daisyUI's circle/square variants
399406 are the marker for these. */
400407 :is(.btn-circle, .btn-square):is(.btn-xs, .btn-sm) {
···508515509516 /* `min-w-0` + `flex-1` let the code shrink below its intrinsic width so
510517 `truncate` can actually produce an ellipsis inside a flex container.
511511- Without them, long commands overflow silently. */
518518+ Without them, long commands overflow silently. `pr-10` reserves room
519519+ for the absolutely-positioned copy button so the ellipsis doesn't
520520+ sit under it. */
512521 .cmd code {
513513- @apply font-mono text-sm truncate min-w-0 flex-1;
522522+ @apply font-mono text-sm truncate min-w-0 flex-1 pr-10;
523523+ }
524524+525525+ /* Copy button visibility:
526526+ - Touch / coarse-pointer devices (tap can't produce :hover and
527527+ rarely produces :focus): always visible at sm+ widths so users
528528+ can find the control.
529529+ - Hover-capable devices (desktop): hidden until the .cmd group is
530530+ hovered or the button itself focused, keeping the command line
531531+ visually tidy while power users still get the affordance.
532532+ Mobile (<sm) always shows the button regardless. */
533533+ .cmd .cmd-copy {
534534+ @apply opacity-100;
535535+ }
536536+ @media (hover: hover) and (pointer: fine) {
537537+ .cmd .cmd-copy {
538538+ @apply sm:opacity-0 transition-opacity;
539539+ }
540540+ .cmd:hover .cmd-copy,
541541+ .cmd .cmd-copy:focus,
542542+ .cmd .cmd-copy:focus-visible {
543543+ @apply opacity-100;
544544+ }
514545 }
515546516547 /* ----------------------------------------
···536567537568 /* ----------------------------------------
538569 HELM BRAND COLOR (official Helm blue #0F1689)
570570+ Tokens live on :root (--color-helm-{light,dark}) so the value is
571571+ declared once and any future brand shift updates every consumer.
539572 ---------------------------------------- */
540573 .text-helm {
541541- @apply text-[oklch(31%_0.181_267.5)];
574574+ color: var(--color-helm-light);
542575 }
543576544577 [data-theme="dark"] .text-helm {
545545- @apply text-[oklch(64.6%_0.19_273.2)];
578578+ color: var(--color-helm-dark);
546579 }
547580548581 .badge-helm {
549549- --badge-color: oklch(31% 0.181 267.5);
582582+ --badge-color: var(--color-helm-light);
550583 }
551584552585 [data-theme="dark"] .badge-helm {
553553- --badge-color: oklch(64.6% 0.19 273.2);
586586+ --badge-color: var(--color-helm-dark);
554587 }
555588556589 /* ----------------------------------------
+182-30
pkg/appview/src/js/app.js
···11+// Safe localStorage wrappers. Safari private mode, disabled storage, and
22+// quota-exceeded all throw from getItem/setItem — absorb those failures so
33+// individual features degrade silently instead of crashing the page.
44+function lsGet(key) {
55+ try { return localStorage.getItem(key); } catch (_) { return null; }
66+}
77+function lsSet(key, value) {
88+ try { localStorage.setItem(key, value); } catch (_) { /* quota/disabled */ }
99+}
1010+111// Theme management (system / light / dark)
212function getThemePreference() {
33- return localStorage.getItem('theme') || 'system';
1313+ return lsGet('theme') || 'system';
414}
515616function getEffectiveTheme(pref) {
···2030}
21312232function setTheme(theme) {
2323- localStorage.setItem('theme', theme);
3333+ lsSet('theme', theme);
2434 applyTheme();
2535 closeThemeDropdown();
2636}
···5161 });
5262}
53636464+// Sync aria-expanded on theme-toggle summaries with the native <details>
6565+// open state. Without this, SR announcements lag behind actual disclosure.
6666+document.addEventListener('DOMContentLoaded', () => {
6767+ document.querySelectorAll('[data-theme-toggle]').forEach(btn => {
6868+ const details = btn.closest('details');
6969+ if (!details) return;
7070+ const sync = () => btn.setAttribute('aria-expanded', details.open ? 'true' : 'false');
7171+ sync();
7272+ details.addEventListener('toggle', sync);
7373+ });
7474+});
7575+5476// Listen for system theme changes
5577window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
5678 if (getThemePreference() === 'system') {
···88110}
8911190112function closeSearch() {
9191- setSearchExpanded(document.querySelector('.nav-search-wrapper'), false);
113113+ const wrapper = document.querySelector('.nav-search-wrapper');
114114+ setSearchExpanded(wrapper, false);
115115+ // Return focus to the toggle button so keyboard users don't get dropped
116116+ // back at the top of the page when the search form collapses.
117117+ if (wrapper) {
118118+ const toggle = wrapper.querySelector('[aria-controls="nav-search-form"]');
119119+ if (toggle) toggle.focus();
120120+ }
92121}
9312294123// Close search on Escape key and click outside
···127156// dispatcher or direct callers — no implicit global `event` fallback.
128157function copyToClipboard(text, btn) {
129158 const onSuccess = () => {
130130- if (!btn) return;
159159+ if (!btn || !document.contains(btn)) return;
131160 const originalHTML = btn.innerHTML;
132161 btn.innerHTML = '<svg class="icon size-4" aria-hidden="true"><use href="/icons.svg#check"></use></svg> Copied!';
133133- setTimeout(() => { btn.innerHTML = originalHTML; }, 2000);
162162+ setTimeout(() => {
163163+ if (document.contains(btn)) btn.innerHTML = originalHTML;
164164+ }, 2000);
134165 };
135166136167 if (navigator.clipboard && window.isSecureContext) {
···184215 return !!ok;
185216}
186217187187-// Serialize a <table> (thead + tbody) as CSV (RFC 4180 quoting)
218218+// Serialize a <table> (thead + tbody) as CSV (RFC 4180 quoting).
219219+// Preserves embedded newlines — RFC 4180 allows them inside quoted fields,
220220+// and Excel/Sheets decode them back into line breaks. Collapsing them to
221221+// spaces would silently lose structure in multi-line SBOM/vuln cells.
188222function tableToCSV(table) {
189223 const escape = (s) => {
190190- const v = (s == null ? '' : String(s)).replace(/\s+/g, ' ').trim();
224224+ const v = (s == null ? '' : String(s)).trim();
191225 return /[",\n\r]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v;
192226 };
193227 const rowToCsv = (cells) => Array.from(cells).map((c) => escape(c.textContent)).join(',');
···559593 if (isLoggedIn && window.htmx) {
560594 window.htmx.ajax('POST', '/api/profile/oci-client', { values: { oci_client: client }, swap: 'none' });
561595 } else if (!isLoggedIn) {
562562- localStorage.setItem('oci-client', client);
596596+ lsSet('oci-client', client);
563597 }
564598 }
565599566600 // Restore preference for anonymous users.
567601 if (!isLoggedIn) {
568568- const saved = localStorage.getItem('oci-client');
602602+ const saved = lsGet('oci-client');
569603 if (saved) {
570604 const sel = document.getElementById('oci-client-switcher');
571605 if (sel) {
···591625 const active = t === tab;
592626 t.classList.toggle('btn-primary', active);
593627 t.classList.toggle('btn-ghost', !active);
628628+ t.setAttribute('aria-selected', active ? 'true' : 'false');
629629+ t.setAttribute('tabindex', active ? '0' : '-1');
594630 });
595595- document.querySelectorAll('.platform-content').forEach(p => p.classList.add('hidden'));
631631+ document.querySelectorAll('.platform-content').forEach(p => {
632632+ p.classList.add('hidden');
633633+ p.setAttribute('hidden', '');
634634+ });
596635 const panel = document.getElementById(tab.dataset.platform + '-content');
597597- if (panel) panel.classList.remove('hidden');
636636+ if (panel) {
637637+ panel.classList.remove('hidden');
638638+ panel.removeAttribute('hidden');
639639+ tab.focus();
640640+ }
641641+ });
642642+ // Arrow-key navigation across tabs within the tablist.
643643+ tab.addEventListener('keydown', (e) => {
644644+ if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
645645+ e.preventDefault();
646646+ const tabArr = Array.from(tabs);
647647+ const i = tabArr.indexOf(tab);
648648+ const next = e.key === 'ArrowRight'
649649+ ? tabArr[(i + 1) % tabArr.length]
650650+ : tabArr[(i - 1 + tabArr.length) % tabArr.length];
651651+ next.click();
598652 });
599653 });
600654});
···619673 if (!cookie) return;
620674621675 const handle = decodeURIComponent(cookie.split('=')[1]);
622622- if (handle) {
676676+ if (handle && typeof handle === 'string' && handle.length > 0) {
623677 // Save to recent accounts
624678 try {
625679 const key = 'atcr_recent_handles';
626626- let recent = JSON.parse(localStorage.getItem(key) || '[]');
680680+ const raw = lsGet(key);
681681+ let recent = [];
682682+ try { recent = JSON.parse(raw || '[]'); } catch (_) { recent = []; }
683683+ if (!Array.isArray(recent)) recent = [];
627684 recent = recent.filter(h => h !== handle);
628685 recent.unshift(handle);
629686 recent = recent.slice(0, 5);
630630- localStorage.setItem(key, JSON.stringify(recent));
687687+ lsSet(key, JSON.stringify(recent));
631688 } catch (err) {
632689 console.error('Failed to save recent account:', err);
633690 }
···648705 if (!carousel) return;
649706650707 const items = carousel.querySelectorAll('.carousel-item');
651651- if (items.length === 0) return;
708708+ if (items.length === 0 || !items[0]) return;
652709653710 let intervalId = null;
654711 const intervalMs = 5000;
655712713713+ // Respect prefers-reduced-motion — users who opt out of animation
714714+ // shouldn't have a carousel auto-advancing every 5 seconds, and the
715715+ // smooth-scroll itself is distracting to them. Use instant scroll
716716+ // for manual nav and skip auto-advance entirely.
717717+ const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
718718+ const scrollBehavior = () => reduceMotion.matches ? 'auto' : 'smooth';
719719+656720 // Cache the per-step scroll distance; offsetWidth forces layout, so
657721 // measuring once per resize beats once per autoplay tick. rAF-coalesces
658722 // bursty resize events.
659723 let stepPx = 0;
660724 let resizeRaf = 0;
661725 function measureStep() {
726726+ if (!items[0]) return;
662727 const gap = parseFloat(getComputedStyle(carousel).gap) || 24;
663728 stepPx = items[0].offsetWidth + gap;
664729 }
···674739 function advance() {
675740 const max = carousel.scrollWidth - carousel.clientWidth;
676741 if (carousel.scrollLeft >= max - 10) {
677677- carousel.scrollTo({ left: 0, behavior: 'smooth' });
742742+ carousel.scrollTo({ left: 0, behavior: scrollBehavior() });
678743 } else {
679679- carousel.scrollBy({ left: stepPx, behavior: 'smooth' });
744744+ carousel.scrollBy({ left: stepPx, behavior: scrollBehavior() });
680745 }
681746 }
682747683748 function retreat() {
684749 if (carousel.scrollLeft <= 10) {
685685- carousel.scrollTo({ left: carousel.scrollWidth, behavior: 'smooth' });
750750+ carousel.scrollTo({ left: carousel.scrollWidth, behavior: scrollBehavior() });
686751 } else {
687687- carousel.scrollBy({ left: -stepPx, behavior: 'smooth' });
752752+ carousel.scrollBy({ left: -stepPx, behavior: scrollBehavior() });
688753 }
689754 }
690755···692757 if (intervalId) return;
693758 if (document.visibilityState === 'hidden') return;
694759 if (carousel.scrollWidth <= carousel.clientWidth + 10) return;
760760+ if (reduceMotion.matches) return;
695761 intervalId = setInterval(advance, intervalMs);
696762 }
697763···702768 if (prevBtn) prevBtn.addEventListener('click', () => { stopInterval(); retreat(); startInterval(); });
703769 if (nextBtn) nextBtn.addEventListener('click', () => { stopInterval(); advance(); startInterval(); });
704770771771+ // User-controlled pause button for WCAG 2.2.2 compliance — auto-advancing
772772+ // content must be pausable without relying on hover, which touch users
773773+ // can't produce.
774774+ const pauseBtn = document.getElementById('carousel-pause');
775775+ let userPaused = false;
776776+ if (pauseBtn) {
777777+ const pauseIcon = pauseBtn.querySelector('.carousel-pause-icon');
778778+ const playIcon = pauseBtn.querySelector('.carousel-play-icon');
779779+ // Seed aria-label/aria-pressed on load so SRs announce the correct
780780+ // state before any click; without this the button reads with no
781781+ // label until the user interacts.
782782+ pauseBtn.setAttribute('aria-pressed', 'false');
783783+ pauseBtn.setAttribute('aria-label', 'Pause carousel auto-advance');
784784+ pauseBtn.addEventListener('click', () => {
785785+ userPaused = !userPaused;
786786+ if (userPaused) {
787787+ stopInterval();
788788+ pauseBtn.setAttribute('aria-pressed', 'true');
789789+ pauseBtn.setAttribute('aria-label', 'Resume carousel auto-advance');
790790+ if (pauseIcon) pauseIcon.classList.add('hidden');
791791+ if (playIcon) playIcon.classList.remove('hidden');
792792+ } else {
793793+ pauseBtn.setAttribute('aria-pressed', 'false');
794794+ pauseBtn.setAttribute('aria-label', 'Pause carousel auto-advance');
795795+ if (pauseIcon) pauseIcon.classList.remove('hidden');
796796+ if (playIcon) playIcon.classList.add('hidden');
797797+ startInterval();
798798+ }
799799+ });
800800+ }
801801+802802+ // Gate mouse-based pause and visibility resume on the user's explicit
803803+ // pause state so hovering doesn't un-pause against their wish.
705804 carousel.addEventListener('mouseenter', stopInterval);
706706- carousel.addEventListener('mouseleave', startInterval);
805805+ carousel.addEventListener('mouseleave', () => { if (!userPaused) startInterval(); });
707806708807 // Pause autoplay while the tab is hidden — a scroll-snap animation on an
709808 // invisible carousel still eats compositor time on the other tab.
710809 document.addEventListener('visibilitychange', () => {
711810 if (document.visibilityState === 'hidden') stopInterval();
712712- else startInterval();
811811+ else if (!userPaused) startInterval();
713812 });
714813715814 startInterval();
···724823 }
725824});
726825826826+// htmx error handling — fires toast on failed requests across the app.
827827+// Servers can also emit HX-Trigger: {"toast":{"message":"...","type":"error"}}
828828+// which htmx turns into a 'toast' CustomEvent handled below — this listener
829829+// is the fallback for handlers that didn't set the header.
830830+// Opt-out: any ancestor with data-suppress-htmx-toast skips the toast (use
831831+// for components that render their own inline error state).
832832+document.body.addEventListener('htmx:responseError', (evt) => {
833833+ const elt = evt.detail && evt.detail.elt;
834834+ if (elt && elt.closest && elt.closest('[data-suppress-htmx-toast]')) return;
835835+ const xhr = evt.detail && evt.detail.xhr;
836836+ // If server already triggered a toast via HX-Trigger, don't double up.
837837+ const trigger = xhr && xhr.getResponseHeader && xhr.getResponseHeader('HX-Trigger');
838838+ if (trigger && trigger.indexOf('toast') !== -1) return;
839839+ const status = xhr ? xhr.status : 0;
840840+ const msg = status === 401 ? 'Session expired \u2014 please sign in again'
841841+ : status === 403 ? 'Not authorized'
842842+ : status === 404 ? 'Not found'
843843+ : status === 429 ? 'Too many requests \u2014 please slow down'
844844+ : status >= 500 ? 'Server error \u2014 please try again'
845845+ : 'Something went wrong';
846846+ showToast(msg, 'error');
847847+});
848848+849849+document.body.addEventListener('htmx:sendError', (evt) => {
850850+ const elt = evt.detail && evt.detail.elt;
851851+ if (elt && elt.closest && elt.closest('[data-suppress-htmx-toast]')) return;
852852+ showToast('Network error \u2014 check your connection', 'error');
853853+});
854854+855855+// Server-triggered toast via HX-Trigger JSON header.
856856+// Accepts both { "toast": { "message": "...", "type": "success" } } (a custom
857857+// 'toast' event named in the header) and CustomEvent fired through the same
858858+// body element. Success/error/info/warning types map to showToast's internal
859859+// types (info and warning fall through to success styling until showToast
860860+// gains more variants).
861861+document.body.addEventListener('toast', (evt) => {
862862+ const d = (evt && evt.detail) || {};
863863+ const message = d.message || d.msg || '';
864864+ if (!message) return;
865865+ const type = d.type || 'info';
866866+ showToast(message, type);
867867+});
868868+727869// Toast notifications (auto-dismiss after 3s).
728870// - Uses textContent, never innerHTML — error text sometimes relays server
729871// response bodies that could contain markup.
···734876const TOAST_MAX = 4;
735877const TOAST_DEDUPE_MS = 1500;
736878737737-function showToast(message, type) {
879879+// Pre-create the toast container so the aria-live region exists before the
880880+// first announcement. If the very first toast fires before DOMContentLoaded
881881+// (e.g. an htmx:responseError during initial boot), we still construct the
882882+// container lazily in showToast() — but under normal flow the pre-created
883883+// one is used.
884884+function ensureToastContainer() {
738885 let container = document.getElementById('toast-container');
739739- if (!container) {
740740- container = document.createElement('div');
741741- container.id = 'toast-container';
742742- container.className = 'toast toast-end toast-bottom z-50';
743743- container.setAttribute('aria-live', 'polite');
744744- container.setAttribute('aria-atomic', 'false');
745745- document.body.appendChild(container);
746746- }
886886+ if (container) return container;
887887+ container = document.createElement('div');
888888+ container.id = 'toast-container';
889889+ container.className = 'toast toast-end toast-bottom z-50';
890890+ container.setAttribute('aria-live', 'polite');
891891+ container.setAttribute('aria-atomic', 'false');
892892+ if (document.body) document.body.appendChild(container);
893893+ return container;
894894+}
895895+document.addEventListener('DOMContentLoaded', ensureToastContainer);
896896+897897+function showToast(message, type) {
898898+ const container = ensureToastContainer();
747899748900 // Dedupe: if an identical toast is already on screen and was added
749901 // within the dedupe window, reset its dismiss timer instead of adding
+29-9
pkg/appview/src/js/repository.js
···190190 });
191191};
192192193193+// Cancel any pending filter rAF before htmx swaps the tag list; a stale
194194+// frame would walk a detached DOM and dirty layout for nothing.
195195+document.body.addEventListener('htmx:beforeSwap', () => {
196196+ if (filterTagsHandle) { cancelAnimationFrame(filterTagsHandle); filterTagsHandle = 0; }
197197+});
198198+193199// ----------------------------------------
194200// Tag-scoped tab controller (reads config from #tag-content data attributes)
195201// ----------------------------------------
···197203 if (!document.getElementById('tag-content')) return;
198204199205 const validTabs = ['overview', 'layers', 'vulns', 'sbom', 'artifacts'];
206206+ // State per target id: 'loading' while a request is in-flight, 'loaded'
207207+ // on success. On error we clear the entry so the retry button can
208208+ // trigger a fresh fetch; without a separate 'loading' marker, a failing
209209+ // request would leave loaded[id]=true and block all retries.
200210 let loaded = {};
201211202212 function lazyLoad(id, url) {
203203- if (loaded[id]) return;
204204- loaded[id] = true;
213213+ if (loaded[id] === 'loading' || loaded[id] === 'loaded') return;
214214+ loaded[id] = 'loading';
205215 const target = document.getElementById(id);
206206- if (!target) return;
216216+ if (!target) { delete loaded[id]; return; }
207217208218 // Abort if the request hangs. SBOM/vuln endpoints can stall when a
209219 // hold is overloaded; without a timeout the spinner spins forever.
···216226 return r.text();
217227 })
218228 .then(html => {
229229+ loaded[id] = 'loaded';
230230+ if (!document.contains(target)) return; // swapped out while fetching
219231 target.innerHTML = html;
220232 // innerHTML doesn't execute <script> tags — re-create them
221233 target.querySelectorAll('script').forEach(old => {
···226238 if (typeof window.htmx !== 'undefined') window.htmx.process(target);
227239 })
228240 .catch(err => {
229229- loaded[id] = false;
241241+ // Clear state immediately so the retry button (or another
242242+ // tab switch) can fire a fresh request.
243243+ delete loaded[id];
244244+ if (!document.contains(target)) return;
230245 const timedOut = err && err.name === 'AbortError';
231246 const msg = timedOut
232247 ? 'This section took too long to load.'
···254269255270 function contentUrl(section) {
256271 const el = document.getElementById('tag-content');
257257- if (!el) return null;
272272+ if (!el || !el.dataset) return null;
258273 const digest = el.dataset.digest;
259259- if (!digest) return null;
260260- return '/api/digest-content/' + el.dataset.owner + '/' + el.dataset.repo +
274274+ const owner = el.dataset.owner;
275275+ const repo = el.dataset.repo;
276276+ if (!digest || !owner || !repo) return null;
277277+ return '/api/digest-content/' + owner + '/' + repo +
261278 '?digest=' + encodeURIComponent(digest) + '§ion=' + section;
262279 }
263280264281 function tagsUrl() {
265282 const el = document.getElementById('tag-content');
266266- if (!el) return null;
267267- return '/api/repo-tags/' + el.dataset.owner + '/' + el.dataset.repo;
283283+ if (!el || !el.dataset) return null;
284284+ const owner = el.dataset.owner;
285285+ const repo = el.dataset.repo;
286286+ if (!owner || !repo) return null;
287287+ return '/api/repo-tags/' + owner + '/' + repo;
268288 }
269289270290 window.diffToTag = function(e, link) {
+30-6
pkg/appview/src/js/sailor-typeahead.js
···183183 const row = document.createElement('div');
184184 row.className = 'sailor-typeahead-item';
185185 row.setAttribute('role', 'option');
186186+ row.setAttribute('aria-selected', 'false');
186187 row.dataset.index = String(index);
187188 row.dataset.handle = actor.handle;
188189···344345 const clearBtn = document.createElement('button');
345346 clearBtn.type = 'button';
346347 clearBtn.className = 'sailor-typeahead-clear';
347347- clearBtn.tabIndex = -1; // keep out of tab order; Navigate button should come next
348348 clearBtn.setAttribute('aria-label', 'Change account');
349349 clearBtn.innerHTML = '×';
350350 clearBtn.addEventListener('click', () => this.clearSelection());
···401401402402 updateFocus(items) {
403403 items.forEach((item, i) => {
404404- item.classList.toggle('focused', i === this.focusIndex);
405405- if (i === this.focusIndex) {
404404+ const focused = i === this.focusIndex;
405405+ item.classList.toggle('focused', focused);
406406+ item.setAttribute('aria-selected', focused ? 'true' : 'false');
407407+ if (focused) {
406408 item.scrollIntoView({ block: 'nearest' });
407409 }
408410 });
411411+ }
412412+413413+ // Clear any pending debounce when the typeahead is torn down (htmx swap
414414+ // that removes the input from the DOM). Without this the timer callback
415415+ // would still fire and keep the class instance alive on a detached node.
416416+ destroy() {
417417+ if (this.debounceTimer) {
418418+ clearTimeout(this.debounceTimer);
419419+ this.debounceTimer = null;
420420+ }
409421 }
410422}
411423···480492 }
481493}
482494483483-document.addEventListener('DOMContentLoaded', () => {
495495+let currentTypeahead = null;
496496+function attachTypeahead() {
484497 const input = document.getElementById('handle');
485485- if (input) {
486486- new SailorTypeahead(input);
498498+ if (!input) return;
499499+ if (currentTypeahead && currentTypeahead.input === input) return; // already attached to this node
500500+ if (currentTypeahead) currentTypeahead.destroy();
501501+ currentTypeahead = new SailorTypeahead(input);
502502+}
503503+document.addEventListener('DOMContentLoaded', attachTypeahead);
504504+// Re-attach after htmx swaps — if #handle was inside a swapped region, the
505505+// old instance's debounce timer still references the detached node.
506506+document.body.addEventListener('htmx:afterSettle', attachTypeahead);
507507+document.body.addEventListener('htmx:beforeSwap', () => {
508508+ if (currentTypeahead && !document.contains(currentTypeahead.input)) {
509509+ currentTypeahead.destroy();
510510+ currentTypeahead = null;
487511 }
488512});
+62-91
pkg/appview/src/js/settings.js
···11-// Settings page: tab controller + account deletion modal.
22-// Both initializers are no-ops when their targets aren't on the page.
11+// Settings page: tablist arrow-key nav + mobile active-tab scroll + account deletion modal.
22+// Tab switching itself is handled server-side via per-tab URLs; htmx swaps the
33+// panel without a full page reload. JS here only adds keyboard ergonomics and
44+// ensures the active tab is visible in the mobile scroll strip on load.
3544-// ----------------------------------------
55-// Tab controller
66-// Mobile horizontal tablist (.settings-tab-mobile) and desktop vertical
77-// sidebar menu (.menu li[data-tab]) stay in sync via a single switch fn.
88-// Uses roving tabindex + arrow-key nav per WAI-ARIA tabs pattern.
99-// ----------------------------------------
106function initSettingsTabs() {
1111- const validTabs = ['user', 'billing', 'storage', 'devices', 'webhooks', 'advanced'];
1212- if (!document.querySelector('.settings-tab-mobile, .menu li[data-tab]')) return;
77+ const sidebarTabs = Array.from(document.querySelectorAll('.menu li[data-tab] a[role="tab"]'));
88+ const mobileTabs = Array.from(document.querySelectorAll('.settings-tab-mobile'));
99+ if (!sidebarTabs.length && !mobileTabs.length) return;
13101414- function switchSettingsTab(tabId) {
1515- document.querySelectorAll('.settings-panel').forEach(p => p.classList.add('hidden'));
1616- const panel = document.getElementById('tab-' + tabId);
1717- if (panel) panel.classList.remove('hidden');
1818-1919- document.querySelectorAll('.menu li[data-tab]').forEach(li => {
2020- const active = li.dataset.tab === tabId;
2121- li.classList.toggle('menu-active', active);
2222- const a = li.querySelector('a[role="tab"]');
2323- if (a) {
2424- a.setAttribute('aria-selected', active ? 'true' : 'false');
2525- a.setAttribute('tabindex', active ? '0' : '-1');
2626- }
1111+ function bindArrowNav(tabs, orientation) {
1212+ const prevKey = orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';
1313+ const nextKey = orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';
1414+ tabs.forEach(tab => {
1515+ tab.addEventListener('keydown', e => {
1616+ const idx = tabs.indexOf(e.currentTarget);
1717+ if (idx === -1) return;
1818+ let target = null;
1919+ if (e.key === prevKey) target = tabs[(idx - 1 + tabs.length) % tabs.length];
2020+ else if (e.key === nextKey) target = tabs[(idx + 1) % tabs.length];
2121+ else if (e.key === 'Home') target = tabs[0];
2222+ else if (e.key === 'End') target = tabs[tabs.length - 1];
2323+ if (!target) return;
2424+ e.preventDefault();
2525+ target.focus();
2626+ target.click();
2727+ });
2728 });
2828-2929- document.querySelectorAll('.settings-tab-mobile').forEach(btn => {
3030- const active = btn.dataset.tab === tabId;
3131- btn.classList.toggle('btn-ghost', !active);
3232- btn.classList.toggle('btn-secondary', active);
3333- btn.setAttribute('aria-selected', active ? 'true' : 'false');
3434- btn.setAttribute('tabindex', active ? '0' : '-1');
3535- });
3636-3737- history.replaceState(null, '', '#' + tabId);
3838- document.body.dispatchEvent(new CustomEvent('tab:' + tabId));
3929 }
3030+ bindArrowNav(sidebarTabs, 'vertical');
3131+ bindArrowNav(mobileTabs, 'horizontal');
40324141- // Exposed so HTMX hx-trigger="every 30s[isTabActive('devices')]" can poll.
4242- window.isTabActive = function(tabId) {
4343- const panel = document.getElementById('tab-' + tabId);
4444- return panel && !panel.classList.contains('hidden');
4545- };
4646-4747- // Exposed so inline <a href="#billing"> onclick can still hop tabs.
4848- window.switchSettingsTab = switchSettingsTab;
4949-5050- function handleTabKeydown(tabs, orientation) {
5151- const prevKey = orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';
5252- const nextKey = orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';
5353- return function(e) {
5454- const idx = tabs.indexOf(e.currentTarget);
5555- if (idx === -1) return;
5656- let target = null;
5757- if (e.key === prevKey) target = tabs[(idx - 1 + tabs.length) % tabs.length];
5858- else if (e.key === nextKey) target = tabs[(idx + 1) % tabs.length];
5959- else if (e.key === 'Home') target = tabs[0];
6060- else if (e.key === 'End') target = tabs[tabs.length - 1];
6161- if (!target) return;
6262- e.preventDefault();
6363- switchSettingsTab(target.dataset.tab || target.parentElement.dataset.tab);
6464- target.focus();
6565- };
3333+ function scrollActiveMobileIntoView() {
3434+ const activeMobile = mobileTabs.find(t => t.getAttribute('aria-selected') === 'true');
3535+ if (activeMobile) activeMobile.scrollIntoView({ inline: 'center', block: 'nearest' });
6636 }
3737+ scrollActiveMobileIntoView();
67386868- const mobileTabs = Array.from(document.querySelectorAll('.settings-tab-mobile'));
6969- const mobileKeydown = handleTabKeydown(mobileTabs, 'horizontal');
7070- mobileTabs.forEach(btn => {
7171- btn.addEventListener('click', e => {
7272- e.preventDefault();
7373- switchSettingsTab(btn.dataset.tab);
3939+ // Sidebar + mobile tablist live outside #tab-content, so htmx swaps don't
4040+ // touch their aria-selected / menu-active state. Sync on click.
4141+ function setActiveTab(slug) {
4242+ sidebarTabs.forEach(a => {
4343+ const active = a.parentElement.dataset.tab === slug;
4444+ a.setAttribute('aria-selected', active ? 'true' : 'false');
4545+ a.setAttribute('tabindex', active ? '0' : '-1');
4646+ a.parentElement.classList.toggle('menu-active', active);
7447 });
7575- btn.addEventListener('keydown', mobileKeydown);
7676- });
7777-7878- const sidebarTabs = Array.from(document.querySelectorAll('.menu li[data-tab] a[role="tab"]'));
7979- const sidebarKeydown = handleTabKeydown(sidebarTabs, 'vertical');
8080- sidebarTabs.forEach(link => {
8181- link.addEventListener('click', e => {
8282- e.preventDefault();
8383- switchSettingsTab(link.parentElement.dataset.tab);
4848+ mobileTabs.forEach(btn => {
4949+ const active = btn.dataset.tab === slug;
5050+ btn.setAttribute('aria-selected', active ? 'true' : 'false');
5151+ btn.setAttribute('tabindex', active ? '0' : '-1');
5252+ btn.classList.toggle('btn-secondary', active);
5353+ btn.classList.toggle('btn-ghost', !active);
8454 });
8585- link.addEventListener('keydown', sidebarKeydown);
5555+ scrollActiveMobileIntoView();
5656+ }
5757+ [...sidebarTabs, ...mobileTabs].forEach(link => {
5858+ link.addEventListener('click', () => setActiveTab(link.dataset.tab || link.parentElement.dataset.tab));
8659 });
87608888- let hash = window.location.hash.replace('#', '') || 'user';
8989- if (validTabs.indexOf(hash) === -1) hash = 'user';
9090- switchSettingsTab(hash);
9191-9292- window.addEventListener('hashchange', () => {
9393- let h = window.location.hash.replace('#', '') || 'user';
9494- if (validTabs.indexOf(h) !== -1) switchSettingsTab(h);
6161+ // Back/forward: htmx's history restore swaps #tab-content; sync tablist from URL.
6262+ document.body.addEventListener('htmx:historyRestore', () => {
6363+ const m = location.pathname.match(/^\/settings\/(user|storage|billing|devices|webhooks|advanced)/);
6464+ if (m) setActiveTab(m[1]);
9565 });
9666}
9767···10171// module stays pure JS with no server-side string interpolation.
10272// ----------------------------------------
10373function initAccountDeletion() {
104104- const deleteBtn = document.getElementById('delete-account-btn');
105105- if (!deleteBtn) return;
106106-107107- const clientShortName = deleteBtn.dataset.clientShortName || 'this account';
108108- const profileHandle = deleteBtn.dataset.profileHandle || '';
109109- const expectedConfirmation = 'DELETE ' + profileHandle;
7474+ // Delegated: #delete-account-btn may be swapped in via htmx (advanced tab).
7575+ document.addEventListener('click', function(e) {
7676+ const deleteBtn = e.target.closest('#delete-account-btn');
7777+ if (!deleteBtn) return;
7878+ showDeleteConfirmationModal(deleteBtn);
7979+ });
1108011181 function escapeHtml(text) {
11282 const div = document.createElement('div');
···11484 return div.innerHTML;
11585 }
11686117117- deleteBtn.addEventListener('click', showDeleteConfirmationModal);
118118-119119- function showDeleteConfirmationModal() {
8787+ function showDeleteConfirmationModal(deleteBtn) {
8888+ const clientShortName = deleteBtn.dataset.clientShortName || 'this account';
8989+ const profileHandle = deleteBtn.dataset.profileHandle || '';
9090+ const expectedConfirmation = 'DELETE ' + profileHandle;
12091 const deletePDSNow = document.getElementById('delete-pds-records').checked;
1219212293 const modal = document.createElement('div');
+43-9
pkg/appview/templates/components/card-grid.html
···1515 - .HasMore: bool - whether to show Load More button
1616*/}}
1717{{ if .Repositories }}
1818-<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3{{ if eq .Columns 4 }} xl:grid-cols-4{{ end }} gap-6">
1818+<div{{ if .TargetID }} id="{{ .TargetID }}"{{ end }} aria-live="polite" aria-busy="false" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3{{ if eq .Columns 4 }} xl:grid-cols-4{{ end }} gap-6">
1919 {{ range .Repositories }}
2020 {{ template "repo-card" . }}
2121 {{ end }}
2222</div>
2323-{{ if and .HasMore .LoadMoreURL }}
2424-<div class="mt-6 text-center">
2323+{{ if and .HasMore .LoadMoreURL .TargetID }}
2424+<div id="{{ .TargetID }}-lm" class="mt-6 text-center">
2525 <button
2626 class="btn btn-outline"
2727 hx-get="{{ .LoadMoreURL }}"
2828 hx-trigger="click"
2929- hx-target="#{{ .TargetID }}"
3030- hx-swap="beforeend"
2929+ hx-target="#{{ .TargetID }}-lm"
3030+ hx-swap="outerHTML"
3131+ hx-indicator="#{{ .TargetID }}-lm-spinner"
3132 >
3333+ <span id="{{ .TargetID }}-lm-spinner" class="htmx-indicator loading loading-spinner loading-sm"></span>
3234 Load More
3335 </button>
3436</div>
···3638{{ else }}
3739<div class="py-12 text-center">
3840 {{ if .EmptyIcon }}
3939- <div class="text-base-content/60 mb-4">
4141+ <div class="text-base-content/60">
4042 {{ icon .EmptyIcon "size-12 mx-auto mb-4" }}
4143 <p class="text-lg">{{ or .EmptyMessage "No repositories found." }}</p>
4244 </div>
4343- {{ if .EmptySubtext }}
4444- <p class="text-base-content/70 text-sm">{{ .EmptySubtext }}</p>
4545- {{ end }}
4645 {{ else }}
4746 <p class="text-base-content/60">{{ or .EmptyMessage "No repositories found." }}</p>
4747+ {{ end }}
4848+ {{ if .EmptySubtext }}
4949+ <p class="text-base-content/70 text-sm mt-2">{{ .EmptySubtext }}</p>
4850 {{ end }}
4951</div>
5052{{ end }}
5153{{ end }}
5454+5555+{{/*
5656+ card-grid-append — response fragment for Load More pagination.
5757+ Emits the new page's cards OOB-swapped into #{{ .TargetID }} and
5858+ replaces the existing #{{ .TargetID }}-lm button wrapper via the
5959+ primary outerHTML swap. When .HasMore is false, the button wrapper
6060+ is replaced with an empty div, removing the Load More control.
6161+*/}}
6262+{{ define "card-grid-append" }}
6363+<div hx-swap-oob="beforeend:#{{ .TargetID }}">
6464+ {{ range .Repositories }}
6565+ {{ template "repo-card" . }}
6666+ {{ end }}
6767+</div>
6868+{{ if and .HasMore .LoadMoreURL }}
6969+<div id="{{ .TargetID }}-lm" class="mt-6 text-center">
7070+ <button
7171+ class="btn btn-outline"
7272+ hx-get="{{ .LoadMoreURL }}"
7373+ hx-trigger="click"
7474+ hx-target="#{{ .TargetID }}-lm"
7575+ hx-swap="outerHTML"
7676+ hx-indicator="#{{ .TargetID }}-lm-spinner"
7777+ >
7878+ <span id="{{ .TargetID }}-lm-spinner" class="htmx-indicator loading loading-spinner loading-sm"></span>
7979+ Load More
8080+ </button>
8181+</div>
8282+{{ else }}
8383+<div id="{{ .TargetID }}-lm"></div>
8484+{{ end }}
8585+{{ end }}
···99 {{ template "nav" . }}
10101111 <main id="main-content" class="container mx-auto px-4 py-8 max-w-4xl">
1212- <h1 class="text-3xl font-display font-bold tracking-tight mb-2">Terms of Service - {{ .CompanyName }} ({{ .SiteURL }})</h1>
1313- <p class="text-base-content/60 mb-8"><em>Last updated: January 2025</em></p>
1212+ <h1 class="text-3xl font-display font-bold tracking-tight mb-2 wrap-break-word">Terms of Service — {{ .CompanyName }}{{ with .SiteURL }} ({{ . }}){{ end }}</h1>
1313+ <p class="text-base-content/60 mb-8"><em>Last updated: {{ .LastUpdated }}</em></p>
14141515 <p class="mb-8">These Terms of Service ("Terms") govern your use of {{ .CompanyName }} ("{{ .SiteURL }}", "the Service", "we", "us", "our"). By using the Service, you agree to these Terms. If you do not agree, do not use the Service.</p>
1616
···11{{ define "vulns-section" }}
22-<div class="space-y-4 min-w-0 pt-6">
33- {{ if .VulnData }}
22+<div class="space-y-4 min-w-0 pt-6" role="region" aria-live="polite">
33+ {{ if eq .VulnReason "ok" }}
44 {{ template "vuln-details" .VulnData }}
55+ {{ else if eq .VulnReason "hold-unreachable" }}
66+ <div class="alert alert-warning" role="status">
77+ {{ icon "wifi-off" "size-4 shrink-0" }}
88+ <div>
99+ <p class="font-medium">We couldn't reach the hold</p>
1010+ <p class="text-sm">Scan data is stored on the hold. It may be offline or unreachable right now.</p>
1111+ </div>
1212+ </div>
1313+ {{ else if eq .VulnReason "fetch-failed" }}
1414+ <div class="py-8 text-sm text-base-content/70 max-w-prose">
1515+ <p class="font-medium text-base-content">Scan data couldn't be loaded</p>
1616+ <p class="mt-1">The hold is reachable but didn't return scan results for this manifest. Try refreshing the page in a minute.</p>
1717+ </div>
518 {{ else }}
619 <div class="py-8 text-sm text-base-content/70 max-w-prose">
720 <p class="font-medium text-base-content">No vulnerability scan available yet</p>
+2-1
pkg/appview/templates/partials/webhooks_list.html
···44 <form hx-post="/api/webhooks"
55 hx-target="#{{ .ContainerID }}"
66 hx-swap="innerHTML"
77+ hx-disabled-elt="find button[type='submit']"
78 class="space-y-4 bg-base-200 rounded-lg p-4">
89 <h3 class="font-semibold">Add Webhook</h3>
910···3031 <div class="space-y-2 mt-1">
3132 {{ range .TriggerInfo }}
3233 <label class="flex items-start gap-3{{ if and (not .AlwaysAvailable) (not $.Limits.AllTriggers) }} opacity-50 cursor-not-allowed{{ else }} cursor-pointer{{ end }}">
3333- <input type="checkbox" name="trigger_{{ if eq .Name "push" }}push{{ else if eq .Name "scan:first" }}first{{ else if eq .Name "scan:all" }}all{{ else }}changed{{ end }}"
3434+ <input type="checkbox" name="{{ .FormName }}"
3435 class="checkbox checkbox-sm mt-0.5"
3536 {{ if .DefaultChecked }}checked{{ end }}
3637 {{ if and (not .AlwaysAvailable) (not $.Limits.AllTriggers) }}disabled{{ end }}>
+172
pkg/appview/ui.go
···77 "fmt"
88 "html/template"
99 "io/fs"
1010+ "math/rand/v2"
1011 "net/http"
1112 "net/url"
1213 "strings"
···8283 computeAssetHashesFromFS(fsys)
8384}
84858686+// humanizeCount renders any integer kind with a compact suffix (1.2K, 3.4M,
8787+// 5.6B). Accepts any so templates can pass int, int64, etc. without an
8888+// explicit conversion. Non-integer values render as "0".
8989+func humanizeCount(v any) string {
9090+ var n int64
9191+ switch x := v.(type) {
9292+ case int:
9393+ n = int64(x)
9494+ case int8:
9595+ n = int64(x)
9696+ case int16:
9797+ n = int64(x)
9898+ case int32:
9999+ n = int64(x)
100100+ case int64:
101101+ n = x
102102+ case uint:
103103+ n = int64(x)
104104+ case uint8:
105105+ n = int64(x)
106106+ case uint16:
107107+ n = int64(x)
108108+ case uint32:
109109+ n = int64(x)
110110+ case uint64:
111111+ n = int64(x)
112112+ default:
113113+ return "0"
114114+ }
115115+ neg := n < 0
116116+ if neg {
117117+ n = -n
118118+ }
119119+ var s string
120120+ switch {
121121+ case n < 1000:
122122+ s = fmt.Sprintf("%d", n)
123123+ case n < 1_000_000:
124124+ s = fmt.Sprintf("%.1fK", float64(n)/1000)
125125+ case n < 1_000_000_000:
126126+ s = fmt.Sprintf("%.1fM", float64(n)/1_000_000)
127127+ default:
128128+ s = fmt.Sprintf("%.1fB", float64(n)/1_000_000_000)
129129+ }
130130+ s = strings.TrimSuffix(s, ".0K")
131131+ s = strings.TrimSuffix(s, ".0M")
132132+ s = strings.TrimSuffix(s, ".0B")
133133+ if neg {
134134+ return "-" + s
135135+ }
136136+ return s
137137+}
138138+85139// AssetHash returns the cache-busting hash for an asset path
86140func AssetHash(path string) string {
87141 if hash, ok := assetHashes[path]; ok {
88142 return hash
89143 }
90144 return ""
145145+}
146146+147147+// fontPreloadPaths returns the list of /fonts/*.woff2 files that exist in
148148+// the resolved public FS. Skips *-ext (extended glyph) subsets to avoid
149149+// preloading weights the primary latin face already covers.
150150+func fontPreloadPaths(overrides *BrandingOverrides) []string {
151151+ fsys := resolvePublicFS(overrides)
152152+ entries, err := fs.ReadDir(fsys, "public/fonts")
153153+ if err != nil {
154154+ return nil
155155+ }
156156+ var paths []string
157157+ for _, e := range entries {
158158+ name := e.Name()
159159+ if e.IsDir() {
160160+ continue
161161+ }
162162+ if !strings.HasSuffix(name, ".woff2") {
163163+ continue
164164+ }
165165+ if strings.Contains(name, "-ext") {
166166+ continue
167167+ }
168168+ if strings.Contains(name, "italic") {
169169+ continue
170170+ }
171171+ paths = append(paths, "/fonts/"+name)
172172+ }
173173+ return paths
91174}
9217593176// CacheMiddleware adds Cache-Control headers to static file responses
···301384302385 "assetHash": AssetHash,
303386387387+ // seamarkHeroTagline picks a random Seamark hero tagline on the
388388+ // server so the Seamark hero renders with its final copy on first
389389+ // paint (crawlers get a real tagline; users see no JS flash). The
390390+ // prior approach rewrote innerHTML ~50ms after load, which caused
391391+ // a visible flash and always served "your beacon at sea." to bots.
392392+ "seamarkHeroTagline": func() []string {
393393+ options := [][]string{
394394+ {"guiding you ", "at", " sea."},
395395+ {"your beacon ", "at", " sea."},
396396+ {"never lost ", "at", " sea."},
397397+ {"find your way ", "at", " sea."},
398398+ {"charting courses ", "at", " sea."},
399399+ }
400400+ return options[rand.IntN(len(options))]
401401+ },
402402+403403+ // fontPreloads returns the list of woff2 fonts present in /fonts/
404404+ // so head.html can emit <link rel="preload"> tags without hardcoding
405405+ // filenames (which 404 silently when renamed). The *-ext subset
406406+ // variants are skipped — we only preload the primary-latin subsets
407407+ // that are used above the fold.
408408+ "fontPreloads": func() []string {
409409+ return fontPreloadPaths(overrides)
410410+ },
411411+304412 "formatDate": func(t time.Time) string {
305413 return t.Format("Jan 2, 2006")
306414 },
···309417 return t.IsZero() || t.Year() < 2000
310418 },
311419420420+ // pluralize picks singular/plural based on count. Use whenever a
421421+ // template would otherwise write "{{ if gt .N 1 }}s{{ end }}", which
422422+ // breaks for 0, negatives, and anything non-English.
423423+ // Usage: {{ pluralize .Count "package" "packages" }}
424424+ "pluralize": func(n int, singular, plural string) string {
425425+ if n == 1 || n == -1 {
426426+ return singular
427427+ }
428428+ return plural
429429+ },
430430+431431+ // humanizeTime renders an absolute, human-readable timestamp.
432432+ // Distinct from timeAgo (relative) — use for tooltips and places
433433+ // where a stable formatted date is preferable to "3 days ago".
434434+ // Zero times render as empty to avoid "Jan 1, 0001" leakage.
435435+ "humanizeTime": func(t time.Time) string {
436436+ if t.IsZero() || t.Year() < 2000 {
437437+ return ""
438438+ }
439439+ return t.Format("Jan 2, 2006 at 3:04 PM MST")
440440+ },
441441+442442+ // humanizeCount renders integers with compact suffix (1.2K, 3.4M, 5.6B).
443443+ // Numbers below 1000 render as-is. Negative values are prefixed with
444444+ // a minus sign (rare for counts but correctness beats surprise).
445445+ // Accepts any integer kind via reflect so templates can pass `int`,
446446+ // `int64`, etc. without an explicit conversion helper.
447447+ "humanizeCount": humanizeCount,
448448+449449+ // severityLabel maps a severity code (C/H/M/L/N/U or a full name) to
450450+ // its canonical full-word label. Use alongside the color class so
451451+ // screen readers announce the word even when sighted users see only
452452+ // the initial: <span class="sr-only">{{ severityLabel "C" }}</span>C
453453+ "severityLabel": func(code string) string {
454454+ switch strings.ToUpper(strings.TrimSpace(code)) {
455455+ case "C", "CRIT", "CRITICAL":
456456+ return "Critical"
457457+ case "H", "HIGH":
458458+ return "High"
459459+ case "M", "MED", "MEDIUM":
460460+ return "Medium"
461461+ case "L", "LOW":
462462+ return "Low"
463463+ case "N", "NEG", "NEGLIGIBLE":
464464+ return "Negligible"
465465+ case "U", "UNK", "UNKNOWN":
466466+ return "Unknown"
467467+ default:
468468+ return code
469469+ }
470470+ },
471471+312472 // icon renders an SVG icon from the sprite sheet
313473 // Usage: {{ icon "star" "size-4 text-amber-400" }}
314474 // The name is the icon ID in icons.svg, classes are applied to the SVG element
···359519 return "docker pull "
360520 }
361521 return client + " pull "
522522+ },
523523+524524+ // toJSON marshals any value to a JSON string safe for use in HTML attributes.
525525+ // json.Marshal escapes <, >, & and properly escapes " inside strings,
526526+ // so the result can be used as template.HTML without further escaping.
527527+ // Usage: hx-vals='{{ dict "repo" .Repo "tag" .Tag | toJSON }}'
528528+ "toJSON": func(v any) template.HTML {
529529+ b, err := json.Marshal(v)
530530+ if err != nil {
531531+ return template.HTML("{}")
532532+ }
533533+ return template.HTML(b)
362534 },
363535364536 // extraCSS returns a <style> block with consumer CSS overrides, or empty string.